diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 5e331995d5..d79a12745c 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -44,7 +44,7 @@ def _add_specificity( specificity2: Specificity triple. Returns: - Combined specificity + Combined specificity. """ a1, b1, c1 = specificity1 a2, b2, c2 = specificity2 diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index 5e304bc086..587fa166c8 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -248,7 +248,7 @@ def _parse_rules( except TokenError: raise except Exception as error: - raise StylesheetError(f"failed to parse css; {error}") + raise StylesheetError(f"failed to parse css; {error}") from None self._parse_cache[cache_key] = rules return rules diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index 7fbeb9793e..7cf1556613 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -57,9 +57,22 @@ selector_start=IDENTIFIER, variable_name=rf"{VARIABLE_REF}:", declaration_set_end=r"\}", - nested=r"\&", ).expect_eof(True) +expect_root_nested = Expect( + "selector or end of file", + whitespace=r"\s+", + comment_start=COMMENT_START, + comment_line=COMMENT_LINE, + selector_start_id=r"\#" + IDENTIFIER, + selector_start_class=r"\." + IDENTIFIER, + selector_start_universal=r"\*", + selector_start=IDENTIFIER, + variable_name=rf"{VARIABLE_REF}:", + declaration_set_end=r"\}", + nested=r"\&", +) + # After a variable declaration e.g. "$warning-text: TOKENS;" # for tokenizing variable value ------^~~~~~~^ expect_variable_name_continue = Expect( @@ -173,7 +186,7 @@ class TokenizerState: "declaration_set_start": expect_declaration, "declaration_name": expect_declaration_content, "declaration_end": expect_declaration, - "declaration_set_end": expect_root_scope, + "declaration_set_end": expect_root_nested, "nested": expect_selector_continue, } @@ -182,6 +195,7 @@ def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]: expect = self.EXPECT get_token = tokenizer.get_token get_state = self.STATE_MAP.get + nest_level = 0 while True: token = get_token(expect) name = token.name @@ -192,6 +206,13 @@ def __call__(self, code: str, read_from: CSSLocation) -> Iterable[Token]: continue elif name == "eof": break + elif name == "declaration_set_start": + nest_level += 1 + elif name == "declaration_set_end": + nest_level -= 1 + expect = expect_root_nested if nest_level else expect_root_scope + yield token + continue expect = get_state(name, expect) yield token diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index aca94564c5..cc748aabe0 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -215,13 +215,12 @@ def get_token(self, expect: Expect) -> Token: self.read_from, self.code, (line_no + 1, col_no + 1), - "Unexpected end of file", + "Unexpected end of file; did you forget a '}' ?", ) line = self.lines[line_no] match = expect.match(line, col_no) if match is None: expected = friendly_list(" ".join(name.split("_")) for name in expect.names) - message = f"Expected one of {expected}.; Did you forget a semicolon at the end of a line?" raise TokenError( self.read_from, self.code, @@ -288,7 +287,7 @@ def skip_to(self, expect: Expect) -> Token: self.read_from, self.code, (line_no, col_no), - "Unexpected end of file", + "Unexpected end of file; did you forget a '}' ?", ) line = self.lines[line_no] match = expect.search(line, col_no) diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py index 663a710637..8342d00c89 100644 --- a/tests/css/test_nested_css.py +++ b/tests/css/test_nested_css.py @@ -1,13 +1,17 @@ +import pytest + from textual.app import App, ComposeResult from textual.color import Color from textual.containers import Vertical +from textual.css.parse import parse +from textual.css.tokenizer import EOFError, TokenError from textual.widgets import Label class NestedApp(App): CSS = """ Screen { - #foo { + & > #foo { background: red; #egg { background: green; @@ -36,3 +40,21 @@ async def test_nest_app(): assert app.query_one("#foo").styles.color == Color.parse("magenta") assert app.query_one("#egg").styles.background == Color.parse("green") assert app.query_one("#foo .paul").styles.background == Color.parse("blue") + + +@pytest.mark.parametrize( + ("css", "exception"), + [ + ("Selector {", EOFError), + ("Selector{ Foo {", EOFError), + ("Selector{ Foo {}", EOFError), + ("> {}", TokenError), + ("&", TokenError), + ("&.foo", TokenError), + ("{", TokenError), + ], +) +def test_parse_errors(css: str, exception: type[Exception]) -> None: + """Check some CSS which should fail.""" + with pytest.raises(exception): + list(parse("", css, ("foo", "")))