Repository: Textualize/textual

Issue:
[CSS] Descendant type selectors can't have a numeric in their name
Consider the following code:

```python
from textual.app        import App, ComposeResult
from textual.containers import Vertical
from textual.widgets    import Header, Footer, Label

class LabelH1( Label ):
    ...

class CSSOddnessApp( App[ None ] ):

    CSS = """
    Vertical LabelH1 {
        background: red;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Vertical(
            Label( "Label" ),
            LabelH1( "LabelH1" ),
        )
        yield Footer()

if __name__ == "__main__":
    CSSOddnessApp().run()
```

When run we get the following error:

```
 Error in stylesheet:
 /Users/davep/develop/python/textual-sandbox/css_oddness.py:CSSOddnessApp:1:19
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ❱ 1 │                                                                                                                                                      │
│   2 │   Vertical LabelH1 {                                                                                                                                 │
│   3 │   │   background: red;                                                                                                                               │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 • Expected one of 'combinator child', 'comment start', 'declaration set start', 'new selector', 'pseudo class', 'selector', 'selector class', 'selector id',
 'selector universal', or 'whitespace'.
 • Did you forget a semicolon at the end of a line?
```

The same thing happens with `Vertical LabelH1`. On the other hand, if I remove the number from the inherited label widget:

```python
from textual.app        import App, ComposeResult
from textual.containers import Vertical
from textual.widgets    import Header, Footer, Label

class LabelHOne( Label ):
    ...

class CSSOddnessApp( App[ None ] ):

    CSS = """
    Vertical LabelHOne {
        background: red;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Vertical(
            Label( "Label" ),
            LabelHOne( "LabelHOne" ),
        )
        yield Footer()

if __name__ == "__main__":
    CSSOddnessApp().run()
```

this works fine. Likewise, if I retain the name but *don't* use combination:

```python
from textual.app        import App, ComposeResult
from textual.containers import Vertical
from textual.widgets    import Header, Footer, Label

class LabelH1( Label ):
    ...

class CSSOddnessApp( App[ None ] ):

    CSS = """
    LabelH1 {
        background: red;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Vertical(
            Label( "Label" ),
            LabelH1( "LabelH1" ),
        )
        yield Footer()

if __name__ == "__main__":
    CSSOddnessApp().run()
```

that also works fine.

I would suspect a variation on #1253.
[CSS] Descendant type selectors can't have a numeric in their name
Consider the following code:

```python
from textual.app        import App, ComposeResult
from textual.containers import Vertical
from textual.widgets    import Header, Footer, Label

class LabelH1( Label ):
    ...

class CSSOddnessApp( App[ None ] ):

    CSS = """
    Vertical LabelH1 {
        background: red;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Vertical(
            Label( "Label" ),
            LabelH1( "LabelH1" ),
        )
        yield Footer()

if __name__ == "__main__":
    CSSOddnessApp().run()
```

When run we get the following error:

```
 Error in stylesheet:
 /Users/davep/develop/python/textual-sandbox/css_oddness.py:CSSOddnessApp:1:19
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│ ❱ 1 │                                                                                                                                                      │
│   2 │   Vertical LabelH1 {                                                                                                                                 │
│   3 │   │   background: red;                                                                                                                               │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 • Expected one of 'combinator child', 'comment start', 'declaration set start', 'new selector', 'pseudo class', 'selector', 'selector class', 'selector id',
 'selector universal', or 'whitespace'.
 • Did you forget a semicolon at the end of a line?
```

The same thing happens with `Vertical LabelH1`. On the other hand, if I remove the number from the inherited label widget:

```python
from textual.app        import App, ComposeResult
from textual.containers import Vertical
from textual.widgets    import Header, Footer, Label

class LabelHOne( Label ):
    ...

class CSSOddnessApp( App[ None ] ):

    CSS = """
    Vertical LabelHOne {
        background: red;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Vertical(
            Label( "Label" ),
            LabelHOne( "LabelHOne" ),
        )
        yield Footer()

if __name__ == "__main__":
    CSSOddnessApp().run()
```

this works fine. Likewise, if I retain the name but *don't* use combination:

```python
from textual.app        import App, ComposeResult
from textual.containers import Vertical
from textual.widgets    import Header, Footer, Label

class LabelH1( Label ):
    ...

class CSSOddnessApp( App[ None ] ):

    CSS = """
    LabelH1 {
        background: red;
    }
    """

    def compose( self ) -> ComposeResult:
        yield Header()
        yield Vertical(
            Label( "Label" ),
            LabelH1( "LabelH1" ),
        )
        yield Footer()

if __name__ == "__main__":
    CSSOddnessApp().run()
```

that also works fine.

I would suspect a variation on #1253.


Discussion:

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
@@ -75,7 +75,7 @@
     selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*",
     selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*",
     selector_universal=r"\*",
-    selector=r"[a-zA-Z_\-]+",
+    selector=IDENTIFIER,
     combinator_child=">",
     new_selector=r",",
     declaration_set_start=r"\{",


Test patch:
diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py
--- a/tests/css/test_parse.py
+++ b/tests/css/test_parse.py
@@ -8,7 +8,7 @@
 from textual.css.scalar import Scalar, Unit
 from textual.css.stylesheet import Stylesheet, StylesheetParseError
 from textual.css.tokenize import tokenize
-from textual.css.tokenizer import ReferencedBy, Token
+from textual.css.tokenizer import ReferencedBy, Token, TokenError
 from textual.css.transition import Transition
 from textual.geometry import Spacing
 from textual.layouts.vertical import VerticalLayout
@@ -1189,3 +1189,40 @@ def test_text_align_empty_uses_default(self):
         stylesheet = Stylesheet()
         stylesheet.add_source(css)
         assert stylesheet.rules[0].styles.text_align == "start"
+
+
+class TestTypeNames:
+    def test_type_no_number(self):
+        stylesheet = Stylesheet()
+        stylesheet.add_source("TestType {}")
+        assert len(stylesheet.rules) == 1
+
+    def test_type_with_number(self):
+        stylesheet = Stylesheet()
+        stylesheet.add_source("TestType1 {}")
+        assert len(stylesheet.rules) == 1
+
+    def test_type_starts_with_number(self):
+        stylesheet = Stylesheet()
+        stylesheet.add_source("1TestType {}")
+        with pytest.raises(TokenError):
+            stylesheet.parse()
+
+    def test_combined_type_no_number(self):
+        for separator in " >,":
+            stylesheet = Stylesheet()
+            stylesheet.add_source(f"StartType {separator} TestType {{}}")
+            assert len(stylesheet.rules) == 1
+
+    def test_combined_type_with_number(self):
+        for separator in " >,":
+            stylesheet = Stylesheet()
+            stylesheet.add_source(f"StartType {separator} TestType1 {{}}")
+            assert len(stylesheet.rules) == 1
+
+    def test_combined_type_starts_with_number(self):
+        for separator in " >,":
+            stylesheet = Stylesheet()
+            stylesheet.add_source(f"StartType {separator} 1TestType {{}}")
+            with pytest.raises(TokenError):
+                stylesheet.parse()