385 lines
12 KiB
Python
385 lines
12 KiB
Python
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)
|