ai-station/.venv/lib/python3.12/site-packages/textual/css/tokenizer.py

385 lines
12 KiB
Python
Raw Permalink Normal View History

2025-12-25 14:54:33 +00:00
from __future__ import annotations
import re
from typing import TYPE_CHECKING, NamedTuple
import rich.repr
from rich.console import Group, RenderableType
from rich.highlighter import ReprHighlighter
from rich.padding import Padding
from rich.panel import Panel
from rich.text import Text
from textual.css._error_tools import friendly_list
from textual.css.constants import VALID_PSEUDO_CLASSES
from textual.suggestions import get_suggestion
if TYPE_CHECKING:
from textual.css.types import CSSLocation
class TokenError(Exception):
"""Error raised when the CSS cannot be tokenized (syntax error)."""
def __init__(
self,
read_from: CSSLocation,
code: str,
start: tuple[int, int],
message: str,
end: tuple[int, int] | None = None,
) -> None:
"""
Args:
read_from: The location where the CSS was read from.
code: The code being parsed.
start: Line and column number of the error (1-indexed).
message: A message associated with the error.
end: End location of token (1-indexed), or None if not known.
"""
self.read_from = read_from
self.code = code
self.start = start
self.end = end or start
super().__init__(message)
def _get_snippet(self) -> Panel:
"""Get a short snippet of code around a given line number.
Returns:
A renderable.
"""
from rich.syntax import Syntax
line_no = self.start[0]
# TODO: Highlight column number
syntax = Syntax(
self.code,
lexer="scss",
theme="ansi_light",
line_numbers=True,
indent_guides=True,
line_range=(max(0, line_no - 2), line_no + 2),
highlight_lines={line_no},
)
syntax.stylize_range(
"reverse bold",
(self.start[0], self.start[1] - 1),
(self.end[0], self.end[1] - 1),
)
return Panel(syntax, border_style="red")
def __rich__(self) -> RenderableType:
highlighter = ReprHighlighter()
errors: list[RenderableType] = []
message = str(self)
errors.append(Text(" Error in stylesheet:", style="bold red"))
line_no, col_no = self.start
path, widget_variable = self.read_from
if widget_variable:
css_location = f" {path}, {widget_variable}:{line_no}:{col_no}"
else:
css_location = f" {path}:{line_no}:{col_no}"
errors.append(highlighter(css_location))
errors.append(self._get_snippet())
final_message = "\n".join(
f"{message_part.strip()}" for message_part in message.split(";")
)
errors.append(
Padding(
highlighter(
Text(final_message, "red"),
),
pad=(0, 1),
)
)
return Group(*errors)
class UnexpectedEnd(TokenError):
"""Indicates that the text being tokenized ended prematurely."""
@rich.repr.auto
class Expect:
"""Object that describes the format of tokens."""
def __init__(self, description: str, **tokens: str) -> None:
"""Create Expect object.
Args:
description: Description of this class of tokens, used in errors.
"""
self.description = f"Expected {description}"
self.names = list(tokens.keys())
self.regexes = list(tokens.values())
self._regex = re.compile(
"("
+ "|".join(f"(?P<{name}>{regex})" for name, regex in tokens.items())
+ ")"
)
self.match = self._regex.match
self.search = self._regex.search
self._expect_eof = False
self._expect_semicolon = True
self._extract_text = False
def expect_eof(self, eof: bool = True) -> Expect:
"""Expect an end of file."""
self._expect_eof = eof
return self
def expect_semicolon(self, semicolon: bool = True) -> Expect:
"""Tokenizer expects text to be terminated with a semi-colon."""
self._expect_semicolon = semicolon
return self
def extract_text(self, extract: bool = True) -> Expect:
self._extract_text = extract
return self
def __rich_repr__(self) -> rich.repr.Result:
yield from zip(self.names, self.regexes)
class ReferencedBy(NamedTuple):
name: str
location: tuple[int, int]
length: int
code: str
@rich.repr.auto(angular=True)
class Token(NamedTuple):
name: str
value: str
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)."""
line, offset = self.location
return (line + 1, offset + 1)
@property
def end(self) -> tuple[int, int]:
"""End line and column (1-indexed)."""
line, offset = self.location
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.
This is used for variable substitution, where a variable reference
can refer to tokens which were defined elsewhere. With the additional
ReferencedBy data attached, we can track where the token we are referring
to is used.
"""
return Token(
name=self.name,
value=self.value,
read_from=self.read_from,
code=self.code,
location=self.location,
referenced_by=by,
)
def __str__(self) -> str:
return self.value
def __rich_repr__(self) -> rich.repr.Result:
yield "name", self.name
yield "value", self.value
yield (
"read_from",
self.read_from[0] if not self.read_from[1] else self.read_from,
)
yield "code", self.code if len(self.code) < 40 else self.code[:40] + "..."
yield "location", self.location
yield "referenced_by", self.referenced_by, None
class Tokenizer:
"""Tokenizes Textual CSS."""
def __init__(self, text: str, read_from: CSSLocation = ("", "")) -> None:
"""Initialize the tokenizer.
Args:
text: String containing CSS.
read_from: Information regarding where the CSS was read from.
"""
self.read_from = read_from
self.code = text
self.lines = text.splitlines(keepends=True)
self.line_no = 0
self.col_no = 0
def get_token(self, expect: Expect) -> Token:
"""Get the next token.
Args:
expect: Expect object which describes which tokens may be read.
Raises:
UnexpectedEnd: If there is an unexpected end of file.
TokenError: If there is an error with the token.
Returns:
A new Token.
"""
line_no = self.line_no
col_no = self.col_no
if line_no >= len(self.lines):
if expect._expect_eof:
return Token(
"eof",
"",
self.read_from,
self.code,
(line_no, col_no),
None,
)
else:
raise UnexpectedEnd(
self.read_from,
self.code,
(line_no + 1, col_no + 1),
(
"Unexpected end of file; did you forget a '}' ?"
if expect._expect_semicolon
else "Unexpected end of text"
),
)
line = self.lines[line_no]
preceding_text: str = ""
if expect._extract_text:
match = expect.search(line, col_no)
if match is None:
preceding_text = line[self.col_no :]
self.line_no += 1
self.col_no = 0
else:
col_no = match.start()
preceding_text = line[self.col_no : col_no]
self.col_no = col_no
if preceding_text:
token = Token(
"text",
preceding_text,
self.read_from,
self.code,
(line_no, col_no),
referenced_by=None,
)
return token
else:
match = expect.match(line, col_no)
if match is None:
error_line = line[col_no:]
error_message = (
f"{expect.description} (found {error_line.split(';')[0]!r})."
)
if expect._expect_semicolon and not error_line.endswith(";"):
error_message += "; Did you forget a semicolon at the end of a line?"
raise TokenError(
self.read_from, self.code, (line_no + 1, col_no + 1), error_message
)
for name, value in zip(expect.names, match.groups()[1:]):
if value is not None:
break
else:
# For MyPy's benefit
raise AssertionError("can't reach here")
token = Token(
name,
value,
self.read_from,
self.code,
(line_no, col_no),
referenced_by=None,
)
if (
token.name == "pseudo_class"
and token.value.strip(":") not in VALID_PSEUDO_CLASSES
):
pseudo_class = token.value.strip(":")
suggestion = get_suggestion(pseudo_class, list(VALID_PSEUDO_CLASSES))
all_valid = f"must be one of {friendly_list(VALID_PSEUDO_CLASSES)}"
if suggestion:
raise TokenError(
self.read_from,
self.code,
(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 + 1, col_no + 1),
f"unknown pseudo-class {pseudo_class!r}; {all_valid}",
)
col_no += len(value)
if col_no >= len(line):
line_no += 1
col_no = 0
self.line_no = line_no
self.col_no = col_no
return token
def skip_to(self, expect: Expect) -> Token:
"""Skip tokens.
Args:
expect: Expect object describing the expected token.
Raises:
UnexpectedEndOfText: If end of file is reached.
Returns:
A new token.
"""
line_no = self.line_no
col_no = self.col_no
while True:
if line_no >= len(self.lines):
raise UnexpectedEnd(
self.read_from,
self.code,
(line_no, col_no),
(
"Unexpected end of file; did you forget a '}' ?"
if expect._expect_semicolon
else "Unexpected end of markup"
),
)
line = self.lines[line_no]
match = expect.search(line, col_no)
if match is None:
line_no += 1
col_no = 0
else:
self.line_no = line_no
self.col_no = match.span(0)[0]
return self.get_token(expect)