Repository: Textualize/textual

Issue:
Nested TCSS interacts poorly with pseudo-classes in selectors.
Working on #3969 and #3999 we realised that pseudo-classes in nested selectors aren't correctly handled.

(In short, to the tokenizer, a selector + pseudo-class looks the same as a rule + value in a nested context.)

Examples of CSS that breaks the tokenizer:

```py
from textual.app import App, ComposeResult
from textual.widgets import Label


class NestedCSSTokenErrorApp(App[None]):
    CSS = """
    Screen {
        Label:hover {
                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()
```

```py
from textual.app import App, ComposeResult
from textual.widgets import Label


class NestedCSSTokenErrorApp(App[None]):
    CSS = """
    Screen {
        Label {
            &.foo, &.bar:hover {
                border: solid red;
            }
        }

        border: solid green;
    }
    """

    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()
```



Discussion:
See [Will's comment](https://github.com/Textualize/textual/issues/3999#issuecomment-1896259284) showing how the use of `&` “makes” some cases work.

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
@@ -30,6 +30,19 @@
 VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+"
 
 IDENTIFIER = r"[a-zA-Z_\-][a-zA-Z0-9_\-]*"
+SELECTOR_TYPE_NAME = r"[A-Z_][a-zA-Z0-9_]*"
+"""Selectors representing Widget type names should start with upper case or '_'.
+
+The fact that a selector starts with an upper case letter or '_' is relevant in the
+context of nested CSS to help determine whether xxx:yyy is a declaration + value or a
+selector + pseudo-class."""
+DECLARATION_NAME = r"[a-z][a-zA-Z0-9_\-]*"
+"""Declaration of TCSS rules start with lowercase.
+
+The fact that a declaration starts with a lower case letter is relevant in the context
+of nested CSS to help determine whether xxx:yyy is a declaration + value or a selector
++ pseudo-class.
+"""
 
 # Values permitted in variable and rule declarations.
 DECLARATION_VALUES = {
@@ -54,7 +67,7 @@
     selector_start_id=r"\#" + IDENTIFIER,
     selector_start_class=r"\." + IDENTIFIER,
     selector_start_universal=r"\*",
-    selector_start=IDENTIFIER,
+    selector_start=SELECTOR_TYPE_NAME,
     variable_name=rf"{VARIABLE_REF}:",
     declaration_set_end=r"\}",
 ).expect_eof(True)
@@ -64,11 +77,11 @@
     whitespace=r"\s+",
     comment_start=COMMENT_START,
     comment_line=COMMENT_LINE,
-    declaration_name=r"[a-zA-Z_\-]+\:",
+    declaration_name=DECLARATION_NAME + r"\:",
     selector_start_id=r"\#" + IDENTIFIER,
     selector_start_class=r"\." + IDENTIFIER,
     selector_start_universal=r"\*",
-    selector_start=IDENTIFIER,
+    selector_start=SELECTOR_TYPE_NAME,
     variable_name=rf"{VARIABLE_REF}:",
     declaration_set_end=r"\}",
     nested=r"\&",
@@ -98,10 +111,10 @@
     comment_start=COMMENT_START,
     comment_line=COMMENT_LINE,
     pseudo_class=r"\:[a-zA-Z_-]+",
-    selector_id=r"\#[a-zA-Z_\-][a-zA-Z0-9_\-]*",
-    selector_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*",
+    selector_id=r"\#" + IDENTIFIER,
+    selector_class=r"\." + IDENTIFIER,
     selector_universal=r"\*",
-    selector=IDENTIFIER,
+    selector=SELECTOR_TYPE_NAME,
     combinator_child=">",
     new_selector=r",",
     declaration_set_start=r"\{",
@@ -117,13 +130,13 @@
     whitespace=r"\s+",
     comment_start=COMMENT_START,
     comment_line=COMMENT_LINE,
-    declaration_name=r"[a-zA-Z_\-]+\:",
+    declaration_name=DECLARATION_NAME + r"\:",
     declaration_set_end=r"\}",
     #
     selector_start_id=r"\#" + IDENTIFIER,
     selector_start_class=r"\." + IDENTIFIER,
     selector_start_universal=r"\*",
-    selector_start=IDENTIFIER,
+    selector_start=SELECTOR_TYPE_NAME,
 )
 
 expect_declaration_solo = Expect(
@@ -131,7 +144,7 @@
     whitespace=r"\s+",
     comment_start=COMMENT_START,
     comment_line=COMMENT_LINE,
-    declaration_name=r"[a-zA-Z_\-]+\:",
+    declaration_name=DECLARATION_NAME + r"\:",
     declaration_set_end=r"\}",
 ).expect_eof(True)
 
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
--- a/src/textual/message_pump.py
+++ b/src/textual/message_pump.py
@@ -16,7 +16,17 @@
 from asyncio import CancelledError, Queue, QueueEmpty, Task, create_task
 from contextlib import contextmanager
 from functools import partial
-from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Iterable, cast
+from typing import (
+    TYPE_CHECKING,
+    Any,
+    Awaitable,
+    Callable,
+    Generator,
+    Iterable,
+    Type,
+    TypeVar,
+    cast,
+)
 from weakref import WeakSet
 
 from . import Logger, events, log, messages
@@ -52,18 +62,21 @@ class MessagePumpClosed(Exception):
     pass
 
 
+_MessagePumpMetaSub = TypeVar("_MessagePumpMetaSub", bound="_MessagePumpMeta")
+
+
 class _MessagePumpMeta(type):
     """Metaclass for message pump. This exists to populate a Message inner class of a Widget with the
     parent classes' name.
     """
 
     def __new__(
-        cls,
+        cls: Type[_MessagePumpMetaSub],
         name: str,
         bases: tuple[type, ...],
         class_dict: dict[str, Any],
-        **kwargs,
-    ):
+        **kwargs: Any,
+    ) -> _MessagePumpMetaSub:
         namespace = camel_to_snake(name)
         isclass = inspect.isclass
         handlers: dict[
diff --git a/src/textual/widget.py b/src/textual/widget.py
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -243,6 +243,10 @@ def __get__(self, obj: Widget, objtype: type[Widget] | None = None) -> str | Non
         return title.markup
 
 
+class BadWidgetName(Exception):
+    """Raised when widget class names do not satisfy the required restrictions."""
+
+
 @rich.repr.auto
 class Widget(DOMNode):
     """
@@ -2888,11 +2892,17 @@ def __init_subclass__(
         inherit_css: bool = True,
         inherit_bindings: bool = True,
     ) -> None:
-        base = cls.__mro__[0]
+        name = cls.__name__
+        if not name[0].isupper() and not name.startswith("_"):
+            raise BadWidgetName(
+                f"Widget subclass {name!r} should be capitalised or start with '_'."
+            )
+
         super().__init_subclass__(
             inherit_css=inherit_css,
             inherit_bindings=inherit_bindings,
         )
+        base = cls.__mro__[0]
         if issubclass(base, Widget):
             cls.can_focus = base.can_focus if can_focus is None else can_focus
             cls.can_focus_children = (


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
@@ -7,7 +7,7 @@
 from textual.containers import Vertical
 from textual.css.parse import parse
 from textual.css.tokenizer import EOFError, TokenError
-from textual.widgets import Label
+from textual.widgets import Button, Label
 
 
 class NestedApp(App):
@@ -110,3 +110,76 @@ def test_parse_errors(css: str, exception: type[Exception]) -> None:
     """Check some CSS which should fail."""
     with pytest.raises(exception):
         list(parse("", css, ("foo", "")))
+
+
+class PseudoClassesInNestedApp(App[None]):
+    CSS = """
+    Vertical {
+        Button:light, Button:dark {
+            background: red;
+        }
+
+        min-height: 3;  # inconsequential rule to add entropy.
+
+        #two, *:focus {
+            background: green !important;
+        }
+
+        height: auto;  # inconsequential rule to add entropy.
+
+        Label {
+            background: yellow;
+
+            &:light, &:dark {
+                background: red;
+            }
+
+            &:hover {
+                background: green !important;
+            }
+        }
+    }
+    """
+
+    AUTO_FOCUS = "Button"
+
+    def compose(self) -> ComposeResult:
+        with Vertical():
+            yield Button(id="one", classes="first_half")
+            yield Button(id="two", classes="first_half")
+        with Vertical():
+            yield Label("Hello, world!", id="three", classes="first_half")
+            yield Label("Hello, world!", id="four", classes="first_half")
+        with Vertical():
+            yield Button(id="five", classes="second_half")
+            yield Button(id="six", classes="second_half")
+            yield Label("Hello, world!", id="seven", classes="second_half")
+            yield Label("Hello, world!", id="eight", classes="second_half")
+
+
+async def test_pseudo_classes_work_in_nested_css() -> None:
+    """Makes sure pseudo-classes are correctly understood in nested TCSS.
+
+    Regression test for https://github.com/Textualize/textual/issues/4039.
+    """
+
+    app = PseudoClassesInNestedApp()
+    green = Color.parse("green")
+    red = Color.parse("red")
+    async with app.run_test() as pilot:
+        assert app.query_one("#one").styles.background == green
+        assert app.query_one("#two").styles.background == green
+        assert app.query_one("#five").styles.background == red
+        assert app.query_one("#six").styles.background == red
+
+        assert app.query_one("#three").styles.background == red
+        assert app.query_one("#four").styles.background == red
+        assert app.query_one("#seven").styles.background == red
+        assert app.query_one("#eight").styles.background == red
+
+        await pilot.hover("#eight")
+
+        assert app.query_one("#three").styles.background == red
+        assert app.query_one("#four").styles.background == red
+        assert app.query_one("#seven").styles.background == red
+        assert app.query_one("#eight").styles.background == green
diff --git a/tests/css/test_stylesheet.py b/tests/css/test_stylesheet.py
--- a/tests/css/test_stylesheet.py
+++ b/tests/css/test_stylesheet.py
@@ -207,6 +207,55 @@ def test_did_you_mean_for_css_property_names(
     assert help_text.summary == expected_summary
 
 
+@pytest.mark.parametrize(
+    "css_property_name,expected_property_name_suggestion",
+    [
+        ["backgroundu", "background"],
+        ["bckgroundu", "background"],
+        ["ofset-x", "offset-x"],
+        ["ofst_y", "offset-y"],
+        ["colr", "color"],
+        ["colour", "color"],
+        ["wdth", "width"],
+        ["wth", "width"],
+        ["wh", None],
+        ["xkcd", None],
+    ],
+)
+def test_did_you_mean_for_property_names_in_nested_css(
+    css_property_name: str, expected_property_name_suggestion: "str | None"
+) -> None:
+    """Test that we get nice errors with mistyped declaractions in nested CSS.
+
+    When implementing pseudo-class support in nested TCSS
+    (https://github.com/Textualize/textual/issues/4039), the first iterations didn't
+    preserve this so we add these tests to make sure we don't take this feature away
+    unintentionally.
+    """
+    stylesheet = Stylesheet()
+    css = """
+    Screen {
+        * {
+            border: blue;
+            ${PROPERTY}: red;
+        }
+    }
+    """.replace(
+        "${PROPERTY}", css_property_name
+    )
+
+    stylesheet.add_source(css)
+    with pytest.raises(StylesheetParseError) as err:
+        stylesheet.parse()
+
+    _, help_text = err.value.errors.rules[1].errors[0]
+    displayed_css_property_name = css_property_name.replace("_", "-")
+    expected_summary = f"Invalid CSS property {displayed_css_property_name!r}"
+    if expected_property_name_suggestion:
+        expected_summary += f". Did you mean '{expected_property_name_suggestion}'?"
+    assert help_text.summary == expected_summary
+
+
 @pytest.mark.parametrize(
     "css_property_name,css_property_value,expected_color_suggestion",
     [
diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py
--- a/tests/css/test_tokenize.py
+++ b/tests/css/test_tokenize.py
@@ -898,3 +898,83 @@ def test_allow_new_lines():
         ),
     ]
     assert list(tokenize(css, ("", ""))) == expected
+
+
+@pytest.mark.parametrize(
+    ["pseudo_class", "expected"],
+    [
+        ("blue", "blur"),
+        ("br", "blur"),
+        ("canfocus", "can-focus"),
+        ("can_focus", "can-focus"),
+        ("can-foc", "can-focus"),
+        ("drk", "dark"),
+        ("ark", "dark"),
+        ("disssabled", "disabled"),
+        ("enalbed", "enabled"),
+        ("focoswithin", "focus-within"),
+        ("focus_whitin", "focus-within"),
+        ("fcus", "focus"),
+        ("huver", "hover"),
+        ("LIght", "light"),
+    ],
+)
+def test_did_you_mean_pseudo_classes(pseudo_class: str, expected: str) -> None:
+    """Make sure we get the correct suggestion for pseudo-classes with typos."""
+
+    css = f"""
+    Button:{pseudo_class} {{
+        background: red;
+    }}
+    """
+
+    with pytest.raises(TokenError) as err:
+        list(tokenize(css, ("", "")))
+
+    assert f"unknown pseudo-class {pseudo_class!r}" in str(err.value)
+    assert f"did you mean {expected!r}" in str(err.value)
+
+
+@pytest.mark.parametrize(
+    ["pseudo_class", "expected"],
+    [
+        ("blue", "blur"),
+        ("br", "blur"),
+        ("canfocus", "can-focus"),
+        ("can_focus", "can-focus"),
+        ("can-foc", "can-focus"),
+        ("drk", "dark"),
+        ("ark", "dark"),
+        ("disssabled", "disabled"),
+        ("enalbed", "enabled"),
+        ("focoswithin", "focus-within"),
+        ("focus_whitin", "focus-within"),
+        ("fcus", "focus"),
+        ("huver", "hover"),
+        ("LIght", "light"),
+    ],
+)
+def test_did_you_mean_pseudo_classes_in_nested_css(
+    pseudo_class: str, expected: str
+) -> None:
+    """Test that we get nice errors for pseudo-classes with typos in nested TCSS.
+
+    When implementing pseudo-class support in nested TCSS
+    (https://github.com/Textualize/textual/issues/4039), the first iterations didn't
+    preserve this so we add these tests to make sure we don't take this feature away
+    unintentionally.
+    """
+
+    css = f"""
+    Screen {{
+        Button:{pseudo_class} {{
+            background: red;
+        }}
+    }}
+    """
+
+    with pytest.raises(TokenError) as err:
+        list(tokenize(css, ("", "")))
+
+    assert f"unknown pseudo-class {pseudo_class!r}" in str(err.value)
+    assert f"did you mean {expected!r}" in str(err.value)
diff --git a/tests/test_focus.py b/tests/test_focus.py
--- a/tests/test_focus.py
+++ b/tests/test_focus.py
@@ -149,12 +149,12 @@ def test_no_focus_empty_selector(screen: Screen):
 
     screen.set_focus(screen.query_one("#foo"))
     assert screen.focused is not None
-    assert screen.focus_next("bananas") is None
+    assert screen.focus_next("#bananas") is None
     assert screen.focused is None
 
     screen.set_focus(screen.query_one("#foo"))
     assert screen.focused is not None
-    assert screen.focus_previous("bananas") is None
+    assert screen.focus_previous("#bananas") is None
     assert screen.focused is None
 
 
diff --git a/tests/test_query.py b/tests/test_query.py
--- a/tests/test_query.py
+++ b/tests/test_query.py
@@ -244,7 +244,7 @@ async def test_query_set_styles_invalid_css_raises_error():
     app = App()
     async with app.run_test():
         with pytest.raises(DeclarationError):
-            app.query(Widget).set_styles(css="random_rule: 1fr;")
+            app.query(Widget).set_styles(css="random-rule: 1fr;")
 
 
 async def test_query_set_styles_kwds():
diff --git a/tests/test_widget.py b/tests/test_widget.py
--- a/tests/test_widget.py
+++ b/tests/test_widget.py
@@ -11,7 +11,7 @@
 from textual.css.query import NoMatches
 from textual.geometry import Offset, Size
 from textual.message import Message
-from textual.widget import MountError, PseudoClasses, Widget
+from textual.widget import BadWidgetName, MountError, PseudoClasses, Widget
 from textual.widgets import Label, LoadingIndicator
 
 
@@ -513,3 +513,12 @@ def compose(self) -> ComposeResult:
             "l1",
             "l3",
         ]
+
+
+def test_bad_widget_name_raised() -> None:
+    """Ensure error is raised when bad class names are used for widgets."""
+
+    with pytest.raises(BadWidgetName):
+
+        class lowercaseWidget(Widget):
+            pass