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:

Resolving patch:
diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py
--- a/src/textual/css/tokenizer.py
+++ b/src/textual/css/tokenizer.py
@@ -34,9 +34,9 @@ def __init__(
         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 @@ def _get_snippet(self) -> Panel:
             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 @@ def get_token(self, expect: Expect) -> Token:
                     "",
                     self.read_from,
                     self.code,
-                    (line_no + 1, col_no + 1),
+                    (line_no, col_no),
                     None,
                 )
             else:
@@ -217,7 +222,7 @@ def get_token(self, expect: Expect) -> Token:
             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 @@ def get_token(self, expect: Expect) -> Token:
                 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
--- a/tests/css/test_parse.py
+++ b/tests/css/test_parse.py
@@ -1238,7 +1238,7 @@ def test_combined_type_starts_with_number(self):
                 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 @@ def test_parse_bad_psuedo_selector():
     """
 
     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)