Skip to content

Commit 87d0cd7

Browse files
authored
Merge pull request #5697 from Textualize/markup-harden
parser refactor
2 parents 62cd593 + 8c0d465 commit 87d0cd7

File tree

9 files changed

+85
-38
lines changed

9 files changed

+85
-38
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](http://keepachangelog.com/)
66
and this project adheres to [Semantic Versioning](http://semver.org/).
77

8+
## Unreleased
9+
10+
### Fixed
11+
12+
- Fixed markup escaping edge cases https://github.com/Textualize/textual/pull/5697
13+
14+
### Changed
15+
16+
- Collapsible title now accepts str, Text, or Content https://github.com/Textualize/textual/pull/5697
17+
818
## [3.0.1] - 2025-04-01
919

1020
### Fixed

src/textual/_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ def feed(self, data: str) -> Iterable[T]:
8383
if not data:
8484
self._eof = True
8585
try:
86-
self._gen.throw(EOFError())
86+
self._gen.throw(ParseEOF())
8787
except StopIteration:
8888
pass
8989
while tokens:

src/textual/_xterm_parser.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from textual import constants, events, messages
1010
from textual._ansi_sequences import ANSI_SEQUENCES_KEYS, IGNORE_SEQUENCE
1111
from textual._keyboard_protocol import FUNCTIONAL_KEYS
12-
from textual._parser import Parser, ParseTimeout, Peek1, Read1, TokenCallback
12+
from textual._parser import ParseEOF, Parser, ParseTimeout, Peek1, Read1, TokenCallback
1313
from textual.keys import KEY_NAME_REPLACEMENTS, Keys, _character_to_key
1414
from textual.message import Message
1515

@@ -187,7 +187,7 @@ def reissue_sequence_as_keys(reissue_sequence: str) -> None:
187187

188188
try:
189189
character = yield read1()
190-
except EOFError:
190+
except ParseEOF:
191191
return
192192

193193
if bracketed_paste:
@@ -216,7 +216,7 @@ def send_escape() -> None:
216216
except ParseTimeout:
217217
send_escape()
218218
break
219-
except EOFError:
219+
except ParseEOF:
220220
send_escape()
221221
return
222222

src/textual/css/parse.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
)
1818
from textual.css.styles import Styles
1919
from textual.css.tokenize import Token, tokenize, tokenize_declarations, tokenize_values
20-
from textual.css.tokenizer import EOFError, ReferencedBy
20+
from textual.css.tokenizer import ReferencedBy, UnexpectedEnd
2121
from textual.css.types import CSSLocation, Specificity3
2222
from textual.suggestions import get_suggestion
2323

@@ -66,7 +66,7 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:
6666
while True:
6767
try:
6868
token = next(tokens, None)
69-
except EOFError:
69+
except UnexpectedEnd:
7070
break
7171
if token is None:
7272
break

src/textual/css/tokenizer.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ def __rich__(self) -> RenderableType:
102102
return Group(*errors)
103103

104104

105-
class EOFError(TokenError):
106-
"""Indicates that the CSS ended prematurely."""
105+
class UnexpectedEnd(TokenError):
106+
"""Indicates that the text being tokenized ended prematurely."""
107107

108108

109109
@rich.repr.auto
@@ -231,7 +231,7 @@ def get_token(self, expect: Expect) -> Token:
231231
expect: Expect object which describes which tokens may be read.
232232
233233
Raises:
234-
EOFError: If there is an unexpected end of file.
234+
UnexpectedEnd: If there is an unexpected end of file.
235235
TokenError: If there is an error with the token.
236236
237237
Returns:
@@ -251,11 +251,15 @@ def get_token(self, expect: Expect) -> Token:
251251
None,
252252
)
253253
else:
254-
raise EOFError(
254+
raise UnexpectedEnd(
255255
self.read_from,
256256
self.code,
257257
(line_no + 1, col_no + 1),
258-
"Unexpected end of file; did you forget a '}' ?",
258+
(
259+
"Unexpected end of file; did you forget a '}' ?"
260+
if expect._expect_semicolon
261+
else "Unexpected end of text"
262+
),
259263
)
260264
line = self.lines[line_no]
261265
preceding_text: str = ""
@@ -348,7 +352,7 @@ def skip_to(self, expect: Expect) -> Token:
348352
expect: Expect object describing the expected token.
349353
350354
Raises:
351-
EOFError: If end of file is reached.
355+
UnexpectedEndOfText: If end of file is reached.
352356
353357
Returns:
354358
A new token.
@@ -358,11 +362,15 @@ def skip_to(self, expect: Expect) -> Token:
358362

359363
while True:
360364
if line_no >= len(self.lines):
361-
raise EOFError(
365+
raise UnexpectedEnd(
362366
self.read_from,
363367
self.code,
364368
(line_no, col_no),
365-
"Unexpected end of file; did you forget a '}' ?",
369+
(
370+
"Unexpected end of file; did you forget a '}' ?"
371+
if expect._expect_semicolon
372+
else "Unexpected end of markup"
373+
),
366374
)
367375
line = self.lines[line_no]
368376
match = expect.search(line, col_no)

src/textual/markup.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
from textual.css.parse import substitute_references
4+
from textual.css.tokenizer import UnexpectedEnd
45

56
__all__ = ["MarkupError", "escape", "to_content"]
67

@@ -40,7 +41,7 @@ class MarkupError(Exception):
4041
variable_ref=VARIABLE_REF,
4142
whitespace=r"\s+",
4243
)
43-
.expect_eof()
44+
.expect_eof(False)
4445
.expect_semicolon(False)
4546
)
4647

@@ -66,7 +67,7 @@ class MarkupError(Exception):
6667
double_string=r"\".*?\"",
6768
single_string=r"'.*?'",
6869
)
69-
.expect_eof()
70+
.expect_eof(True)
7071
.expect_semicolon(False)
7172
)
7273

@@ -302,6 +303,10 @@ def to_content(
302303
_rich_traceback_omit = True
303304
try:
304305
return _to_content(markup, style, template_variables)
306+
except UnexpectedEnd:
307+
raise MarkupError(
308+
"Unexpected end of markup; are you missing a closing square bracket?"
309+
) from None
305310
except Exception as error:
306311
# Ensure all errors are wrapped in a MarkupError
307312
raise MarkupError(str(error)) from None

src/textual/widgets/_collapsible.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from textual.app import ComposeResult
55
from textual.binding import Binding
66
from textual.containers import Container
7+
from textual.content import Content, ContentText
78
from textual.css.query import NoMatches
89
from textual.message import Message
910
from textual.reactive import reactive
@@ -47,23 +48,21 @@ class CollapsibleTitle(Static, can_focus=True):
4748
"""
4849

4950
collapsed = reactive(True)
50-
label = reactive("Toggle")
51+
label: reactive[ContentText] = reactive(Content("Toggle"))
5152

5253
def __init__(
5354
self,
5455
*,
55-
label: str,
56+
label: ContentText,
5657
collapsed_symbol: str,
5758
expanded_symbol: str,
5859
collapsed: bool,
5960
) -> None:
6061
super().__init__()
6162
self.collapsed_symbol = collapsed_symbol
6263
self.expanded_symbol = expanded_symbol
63-
self.label = label
64+
self.label = Content.from_text(label)
6465
self.collapsed = collapsed
65-
self._collapsed_label = f"{collapsed_symbol} {label}"
66-
self._expanded_label = f"{expanded_symbol} {label}"
6766

6867
class Toggle(Message):
6968
"""Request toggle."""
@@ -77,19 +76,21 @@ def action_toggle_collapsible(self) -> None:
7776
"""Toggle the state of the parent collapsible."""
7877
self.post_message(self.Toggle())
7978

80-
def _watch_label(self, label: str) -> None:
81-
self._collapsed_label = f"{self.collapsed_symbol} {label}"
82-
self._expanded_label = f"{self.expanded_symbol} {label}"
79+
def validate_label(self, label: ContentText) -> Content:
80+
return Content.from_text(label)
81+
82+
def _update_label(self) -> None:
83+
assert isinstance(self.label, Content)
8384
if self.collapsed:
84-
self.update(self._collapsed_label)
85+
self.update(Content.assemble(self.collapsed_symbol, " ", self.label))
8586
else:
86-
self.update(self._expanded_label)
87+
self.update(Content.assemble(self.expanded_symbol, " ", self.label))
88+
89+
def _watch_label(self) -> None:
90+
self._update_label()
8791

8892
def _watch_collapsed(self, collapsed: bool) -> None:
89-
if collapsed:
90-
self.update(self._collapsed_label)
91-
else:
92-
self.update(self._expanded_label)
93+
self._update_label()
9394

9495

9596
class Collapsible(Widget):

tests/css/test_nested_css.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from textual.color import Color
77
from textual.containers import Vertical
88
from textual.css.parse import parse
9-
from textual.css.tokenizer import EOFError, TokenError
9+
from textual.css.tokenizer import TokenError, UnexpectedEnd
1010
from textual.widgets import Button, Label
1111

1212

@@ -94,16 +94,16 @@ async def test_rule_declaration_after_nested() -> None:
9494
@pytest.mark.parametrize(
9595
("css", "exception"),
9696
[
97-
("Selector {", EOFError),
98-
("Selector{ Foo {", EOFError),
99-
("Selector{ Foo {}", EOFError),
97+
("Selector {", UnexpectedEnd),
98+
("Selector{ Foo {", UnexpectedEnd),
99+
("Selector{ Foo {}", UnexpectedEnd),
100100
("> {}", TokenError),
101101
("&", TokenError),
102102
("&&", TokenError),
103103
("&.foo", TokenError),
104104
("& .foo", TokenError),
105105
("{", TokenError),
106-
("*{", EOFError),
106+
("*{", UnexpectedEnd),
107107
],
108108
)
109109
def test_parse_errors(css: str, exception: type[Exception]) -> None:

tests/test_content.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import pytest
34
from rich.text import Text
45

56
from textual.content import Content, Span
@@ -219,10 +220,21 @@ def test_assemble():
219220
]
220221

221222

222-
def test_escape():
223+
@pytest.mark.parametrize(
224+
"markup,plain",
225+
[
226+
("\\[", "["),
227+
("\\[foo", "[foo"),
228+
("\\[foo]", "[foo]"),
229+
("\\[/foo", "[/foo"),
230+
("\\[/foo]", "[/foo]"),
231+
("\\[]", "[]"),
232+
],
233+
)
234+
def test_escape(markup: str, plain: str) -> None:
223235
"""Test that escaping the first square bracket."""
224-
content = Content.from_markup("\\[bold]Not really bold")
225-
assert content.plain == "[bold]Not really bold"
236+
content = Content.from_markup(markup)
237+
assert content.plain == plain
226238
assert content.spans == []
227239

228240

@@ -261,3 +273,14 @@ def test_first_line():
261273
first_line = content.first_line
262274
assert first_line.plain == "foo"
263275
assert first_line.spans == [Span(0, 3, "red")]
276+
277+
278+
def test_errors():
279+
with pytest.raises(Exception):
280+
Content.from_markup("[")
281+
282+
with pytest.raises(Exception):
283+
Content.from_markup("[:")
284+
285+
with pytest.raises(Exception):
286+
Content.from_markup("[foo")

0 commit comments

Comments
 (0)