Repository: Textualize/textual

Issue:
CSS error reporting sometimes off by one
See https://github.com/Textualize/textual/pull/3582#issuecomment-1787507687.

Running the app at the bottom produces the error below, where the error reporting is off by one.
See the line above the panel and the code lines in the code snippet printed.

```
 Error in stylesheet:
 /Users/davep/develop/python/textual-upstream/sandbox/foo.py, CSSErrorApp.CSS:1:4
╭────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│   1 │                                                                                                                                                                                                                      │
│ ❱ 2 │   :                                                                                                                                                                                                                  │
│   3                                                                                                                                                                                                                        │
╰────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
 • Expected one of 'comment line', 'comment start', 'selector start', 'selector start class', 'selector start id', 'selector start universal', 'variable name', or 'whitespace'.                                              
 • Did you forget a semicolon at the end of a line?      
```

```py
from textual.app import App, ComposeResult
from textual.widgets import Label

class CSSErrorApp(App[None]):

    CSS = """
    :
    """

    def compose(self) -> ComposeResult:
        yield Label()

if __name__ == "__main__":
    CSSErrorApp().run()
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2589f911..496739944 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
 - Fixed outline not rendering correctly in some scenarios (e.g. on Button widgets) https://github.com/Textualize/textual/issues/3628
 - Fixed live-reloading of screen CSS https://github.com/Textualize/textual/issues/3454
 - `Select.value` could be in an invalid state https://github.com/Textualize/textual/issues/3612
+- Off-by-one in CSS error reporting https://github.com/Textualize/textual/issues/3625
 
 ### Added
 
diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py
index d7cfc449d..875537eb7 100644
--- a/src/textual/css/tokenizer.py
+++ b/src/textual/css/tokenizer.py
@@ -34,9 +34,9 @@ class TokenError(Exception):
         Args:
             read_from: The location where the CSS was read from.
             code: The code being parsed.
-            start: Line number of the error.
+            start: Line and column number of the error (1-indexed).
             message: A message associated with the error.
-            end: End location of token, or None if not known.
+            end: End location of token (1-indexed), or None if not known.
         """
 
         self.read_from = read_from
@@ -60,9 +60,13 @@ class TokenError(Exception):
             line_numbers=True,
             indent_guides=True,
             line_range=(max(0, line_no - 2), line_no + 2),
-            highlight_lines={line_no + 1},
+            highlight_lines={line_no},
+        )
+        syntax.stylize_range(
+            "reverse bold",
+            (self.start[0], self.start[1] - 1),
+            (self.end[0], self.end[1] - 1),
         )
-        syntax.stylize_range("reverse bold", self.start, self.end)
         return Panel(syntax, border_style="red")
 
     def __rich__(self) -> RenderableType:
@@ -136,19 +140,20 @@ class Token(NamedTuple):
     read_from: CSSLocation
     code: str
     location: tuple[int, int]
+    """Token starting location, 0-indexed."""
     referenced_by: ReferencedBy | None = None
 
     @property
     def start(self) -> tuple[int, int]:
-        """Start line and column (1 indexed)."""
+        """Start line and column (1-indexed)."""
         line, offset = self.location
-        return (line + 1, offset)
+        return (line + 1, offset + 1)
 
     @property
     def end(self) -> tuple[int, int]:
-        """End line and column (1 indexed)."""
+        """End line and column (1-indexed)."""
         line, offset = self.location
-        return (line + 1, offset + len(self.value))
+        return (line + 1, offset + len(self.value) + 1)
 
     def with_reference(self, by: ReferencedBy | None) -> "Token":
         """Return a copy of the Token, with reference information attached.
@@ -199,7 +204,7 @@ class Tokenizer:
                     "",
                     self.read_from,
                     self.code,
-                    (line_no + 1, col_no + 1),
+                    (line_no, col_no),
                     None,
                 )
             else:
@@ -217,7 +222,7 @@ class Tokenizer:
             raise TokenError(
                 self.read_from,
                 self.code,
-                (line_no, col_no),
+                (line_no + 1, col_no + 1),
                 message,
             )
         iter_groups = iter(match.groups())
@@ -251,14 +256,14 @@ class Tokenizer:
                 raise TokenError(
                     self.read_from,
                     self.code,
-                    (line_no, col_no),
+                    (line_no + 1, col_no + 1),
                     f"unknown pseudo-class {pseudo_class!r}; did you mean {suggestion!r}?; {all_valid}",
                 )
             else:
                 raise TokenError(
                     self.read_from,
                     self.code,
-                    (line_no, col_no),
+                    (line_no + 1, col_no + 1),
                     f"unknown pseudo-class {pseudo_class!r}; {all_valid}",
                 )
 


Test patch:
diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py
index 124f820d5..febe4f06b 100644
--- a/tests/css/test_parse.py
+++ b/tests/css/test_parse.py
@@ -1238,7 +1238,7 @@ class TestTypeNames:
                 stylesheet.parse()
 
 
-def test_parse_bad_psuedo_selector():
+def test_parse_bad_pseudo_selector():
     """Check unknown selector raises a token error."""
 
     bad_selector = """\
@@ -1248,9 +1248,27 @@ Widget:foo{
     """
 
     stylesheet = Stylesheet()
-    stylesheet.add_source(bad_selector, "foo")
+    stylesheet.add_source(bad_selector, None)
 
     with pytest.raises(TokenError) as error:
         stylesheet.parse()
 
-    assert error.value.start == (0, 6)
+    assert error.value.start == (1, 7)
+
+
+def test_parse_bad_pseudo_selector_with_suggestion():
+    """Check unknown pseudo selector raises token error with correct position."""
+
+    bad_selector = """
+Widget:blu {
+    border: red;
+}
+"""
+
+    stylesheet = Stylesheet()
+    stylesheet.add_source(bad_selector, None)
+
+    with pytest.raises(TokenError) as error:
+        stylesheet.parse()
+
+    assert error.value.start == (2, 7)