Repository: Textualize/textual

Issue:
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`.
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.


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.
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.

Resolving patch:
diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py
--- 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;"


Test patch:
diff --git a/tests/css/test_nested_css.py b/tests/css/test_nested_css.py
--- a/tests/css/test_nested_css.py
+++ b/tests/css/test_nested_css.py
@@ -44,6 +44,53 @@ 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;
+    }
+    """
+
+    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"),
     [