Repository: Textualize/textual
Pull request: Allow lists of nested selectors and allow styles after nested CSS blocks
Fixes #3969 and fixes #3999.

Changes the tokenizer to accept lists of selectors in a nested context and also to accept styles after nested scopes.

While working on the above, we opened #4039 because the tokenizer isn't yet clever enough to distinguish a selector with a pseudo-class from a rule with a value in a nested context, if the selector + pseudo-class doesn't use the `&` operator. (See [my comment](https://github.com/Textualize/textual/issues/3999#issuecomment-1896164715) and the interactions under it.)

Problem statement:
Nesting selectors in a selector list causes a stylesheet error
If you have a stylesheet like this:

```python
from textual.app import App, ComposeResult
from textual.widgets import Label

class NestedCSSTokenErrorApp(App[None]):

    CSS = """
    Label {
        &.foo, &.bar {
            border: solid red;
        }
    }
    """

    def compose(self) -> ComposeResult:
        yield Label("This is class foo", classes="foo")
        yield Label("This is class bar", classes="bar")

if __name__ == "__main__":
    NestedCSSTokenErrorApp().run()
```

the result is:

```
Error in stylesheet:
 /Users/davep/develop/python/textual-sandbox/nested_css_token_error.py, NestedCSSTokenErrorApp.CSS:3:16
╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│   1 │                                                                                                                                               │
│   2 │   Label {                                                                                                                                     │
│ ❱ 3 │   │   &.foo, &.bar {                                                                                                                          │
│   4 │   │   │   border: solid red;                                                                                                                  │
│   5 │   │   }                                                                                                                                       │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 • Expected selector or { (found '&.bar {').                                                                                                           
 • Did you forget a semicolon at the end of a line? 
```

This feels like a bug given that it's a slightly different way of writing this:

```python
from textual.app import App, ComposeResult
from textual.widgets import Label

class NestedCSSTokenErrorApp(App[None]):

    CSS = """
    Label {
        &.foo {
            border: solid red;
        }
        &.bar {
            border: solid red;
        }
    }
    """

    def compose(self) -> ComposeResult:
        yield Label("This is class foo", classes="foo")
        yield Label("This is class bar", classes="bar")

if __name__ == "__main__":
    NestedCSSTokenErrorApp().run()
```

Tagging @willmcgugan for triage.

A style after a nested section in CSS causes a CSS parsing error
This code:

```python
from textual.app import App, ComposeResult
from textual.widgets import Label

class NestedParseErrorApp(App[None]):

    CSS = """
    Screen {
        Label {
            border: solid red;
        }
        border: solid green;
    }
    """

    def compose(self) -> ComposeResult:
        yield Label("First", id="first")
        yield Label("Second", id="second")

if __name__ == "__main__":
    NestedParseErrorApp().run()
```

results in this error:

```
Error in stylesheet:
 /Users/davep/develop/python/textual-sandbox/nested_error.py, NestedParseErrorApp.CSS:6:15
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│   4 │   │   │   border: solid red;                                                                                                                                                                                         │
│   5 │   │   }                                                                                                                                                                                                              │
│ ❱ 6 │   │   border: solid green;                                                                                                                                                                                           │
│   7 │   }                                                                                                                                                                                                                  │
│   8                                                                                                                                                                                                                        │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 • Expected selector or { (found ': solid green                                                                                                                                                                               
 • ').                                                                                                                                                                                                                        
 • Did you forget a semicolon at the end of a line?       
```

whereas this works fine:

```python
from textual.app import App, ComposeResult
from textual.widgets import Label

class NestedParseErrorApp(App[None]):

    CSS = """
    Screen {
        border: solid green;
        Label {
            border: solid red;
        }
    }
    """

    def compose(self) -> ComposeResult:
        yield Label("First", id="first")
        yield Label("Second", id="second")

if __name__ == "__main__":
    NestedParseErrorApp().run()
```

The difference being where the `border` for the `Screen` comes in relation to the nested `Label`.

Hints from issue discussion:
To whomever picks this up, talk to @willmcgugan if you need pointers on this.
@willmcgugan if you had to guess would you say this will involve modifying the tokenizer state machine?
Yes, I think so. If I were to guess, the comma token causes the tokenizer to switch to an `Expect` that doesn't include the nesting operator.
I'll take a stab at this. I wouldn't put it past me to take more than today to fix this if I happen to struggle with understanding how the tokenizer state machine works.
Dave, would you stop breaking nested CSS, please? :)
@willmcgugan you said you had a fix for this somewhere?
I'm struggling a bit with the fact that, in a nested context, a selector + a pseudo-class (e.g., `Button:focus`) looks like a declaration `Button:` as far as the regular expressions in the `Expect` instances are concerned.

In other words, I can't come up with the supposedly simple fix you told me you have.
First file ... https://github.com/Textualize/textual/pull/4012/files
Thanks @willmcgugan, it doesn't work :')

```py
from textual.app import App, ComposeResult
from textual.widgets import Button, Label


class MyApp(App[None]):
    CSS = """
    Screen {
        Label {
            border: solid red;
        }

        Button:hover {
            background: yellow;
        }

        border: solid green;
    }
    """

    def compose(self) -> ComposeResult:
        yield Label("This is class foo")
        yield Button()


if __name__ == "__main__":
    MyApp().run()
```
You have a selector with Button: To the parser this looks suspiciously like a rule, and therefore needs the & to make it work.

Ideally we could check for a valid rule in the parser, and not just an identifier plus colon.
I know why it is failing. It's what I wrote [in my comment](https://github.com/Textualize/textual/issues/3999#issuecomment-1896110384) asking for help; I thought you had this case covered as well.

I PR'd the partial fix and I've [opened another issue](https://github.com/textualize/textual/issues/4039) to keep track of the interactions with pseudo-classes.
My fix was for the original issue, this is something different.

The parser isn't quite clever enough to know the difference, but the nesting operator will make it work.

I wish we had a way of at least offering the user the suggestion how to fix it.

```python
from textual.app import App, ComposeResult
from textual.widgets import Button, Label


class MyApp(App[None]):
    CSS = """
    Screen {
        Label {
            border: solid red;
        }

        & Button:hover {
            background: yellow;
        }

        border: solid green;
    }
    """

    def compose(self) -> ComposeResult:
        yield Label("This is class foo")
        yield Button()


if __name__ == "__main__":
    MyApp().run()
```

Commit message: Merge branch 'main' into improve-nested-tcss

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2bb816e2ab..c975d2f84b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 
 - Renamed `TextArea.tab_behaviour` to `TextArea.tab_behavior` https://github.com/Textualize/textual/pull/4124
 
+### Fixed
+
+- Improve support for selector lists in nested TCSS https://github.com/Textualize/textual/issues/3969
+- Improve support for rule declarations after nested TCSS rule sets https://github.com/Textualize/textual/issues/3999
+
 ## [0.50.1] - 2024-02-09
 
 ### Fixed
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
index 0d0df40002..b029fad550 100644
--- a/src/textual/css/tokenize.py
+++ b/src/textual/css/tokenize.py
@@ -64,6 +64,7 @@
     whitespace=r"\s+",
     comment_start=COMMENT_START,
     comment_line=COMMENT_LINE,
+    declaration_name=r"[a-zA-Z_\-]+\:",
     selector_start_id=r"\#" + IDENTIFIER,
     selector_start_class=r"\." + IDENTIFIER,
     selector_start_universal=r"\*",
@@ -105,6 +106,7 @@
     new_selector=r",",
     declaration_set_start=r"\{",
     declaration_set_end=r"\}",
+    nested=r"\&",
 ).expect_eof(True)
 
 # A rule declaration e.g. "text: red;"
diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py
index 821c655ff3..20424ad297 100644
--- a/tests/css/test_nested_css.py
+++ b/tests/css/test_nested_css.py
@@ -44,6 +44,54 @@ async def test_nest_app():
         assert app.query_one("#foo .paul").styles.background == Color.parse("blue")
 
 
+class ListOfNestedSelectorsApp(App[None]):
+    CSS = """
+    Label {
+        &.foo, &.bar {
+            background: red;
+        }
+    }
+    """
+
+    def compose(self) -> ComposeResult:
+        yield Label("one", classes="foo")
+        yield Label("two", classes="bar")
+        yield Label("three", classes="heh")
+
+
+async def test_lists_of_selectors_in_nested_css() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/3969."""
+    app = ListOfNestedSelectorsApp()
+    red = Color.parse("red")
+    async with app.run_test():
+        assert app.query_one(".foo").styles.background == red
+        assert app.query_one(".bar").styles.background == red
+        assert app.query_one(".heh").styles.background != red
+
+
+class DeclarationAfterNestedApp(App[None]):
+    # css = "Screen{Label{background:red;}background:green;}"
+    CSS = """
+    Screen {
+        Label {
+            background: red;
+        }
+        background: green;
+    }
+    """
+
+    def compose(self) -> ComposeResult:
+        yield Label("one")
+
+
+async def test_rule_declaration_after_nested() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/3999."""
+    app = DeclarationAfterNestedApp()
+    async with app.run_test():
+        assert app.screen.styles.background == Color.parse("green")
+        assert app.query_one(Label).styles.background == Color.parse("red")
+
+
 @pytest.mark.parametrize(
     ("css", "exception"),
     [


Code review comments:
diff_hunk: @@ -44,6 +44,54 @@ async def test_nest_app():
         assert app.query_one("#foo .paul").styles.background == Color.parse("blue")
 
 
+class ListOfNestedSelectorsApp(App[None]):
+    CSS = """
+    Label {
+        &.foo, &.bar {
+            background: red;
+        }
+    }
+    """
+
+    def compose(self) -> ComposeResult:
+        yield Label("one", classes="foo")
+        yield Label("two", classes="bar")
+        yield Label("three", classes="heh")
+
+
+async def test_lists_of_selectors_in_nested_css() -> None:
+    """Regression test for https://github.com/Textualize/textual/issues/3969."""
+    app = ListOfNestedSelectorsApp()
+    red = Color.parse("red")
+    async with app.run_test():
+        assert app.query_one(".foo").styles.background == red
+        assert app.query_one(".bar").styles.background == red
+        assert app.query_one(".heh").styles.background != red
+
+
+class DeclarationAfterNestedApp(App[None]):
+    # css = "Screen{Label{background:red;}background:green;}"
path: tests/css/test_nested_css.py
text: @user1:
```suggestion
```