diff --git a/examples/basic.css b/examples/basic.css index 7500a8f1b..ca9150bf0 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -1,9 +1,11 @@ /* CSS file for basic.py */ +$primary: #20639b; + App > View { layout: dock; docks: side=left/1; - text: on #20639b; + text: on $primary; } #sidebar { @@ -26,7 +28,7 @@ App > View { } #content { - text: white on #20639b; + text: white on $primary; border-bottom: hkey #0f2b41; } diff --git a/examples/dev_sandbox.css b/examples/dev_sandbox.css deleted file mode 100644 index efafe797e..000000000 --- a/examples/dev_sandbox.css +++ /dev/null @@ -1,44 +0,0 @@ -/* CSS file for basic.py */ - -App > View { - docks: side=left/1; - text: on #20639b; -} - -Widget:hover { - outline: heavy; - text: bold !important; -} - -#sidebar { - text: #09312e on #3caea3; - dock: side; - width: 30; - offset-x: -100%; - transition: offset 500ms in_out_cubic; - border-right: outer #09312e; -} - -#sidebar.-active { - offset-x: 0; -} - -#header { - text: white on #173f5f; - height: 3; - border: hkey; -} - -#header.-visible { - visibility: hidden; -} - -#content { - text: white on #20639b; - border-bottom: hkey #0f2b41; -} - -#footer { - text: #3a3009 on #f6d55c; - height: 3; -} diff --git a/examples/dev_sandbox.py b/examples/dev_sandbox.py index 6da7068cb..146a5c842 100644 --- a/examples/dev_sandbox.py +++ b/examples/dev_sandbox.py @@ -1,23 +1,32 @@ +from rich.console import RenderableType +from rich.panel import Panel + from textual.app import App from textual.widget import Widget +class PanelWidget(Widget): + def render(self) -> RenderableType: + return Panel("hello world!", title="Title") + + class BasicApp(App): - """A basic app demonstrating CSS""" + """Sandbox application used for testing/development by Textual developers""" def on_load(self): """Bind keys here.""" self.bind("tab", "toggle_class('#sidebar', '-active')") self.bind("a", "toggle_class('#header', '-visible')") + self.bind("c", "toggle_class('#content', '-content-visible')") def on_mount(self): """Build layout here.""" self.mount( header=Widget(), - content=Widget(), + content=PanelWidget(), footer=Widget(), sidebar=Widget(), ) -BasicApp.run(css_file="dev_sandbox.css", watch_css=True, log="textual.log") +BasicApp.run(css_file="dev_sandbox.scss", watch_css=True, log="textual.log") diff --git a/examples/dev_sandbox.scss b/examples/dev_sandbox.scss new file mode 100644 index 000000000..9040e492d --- /dev/null +++ b/examples/dev_sandbox.scss @@ -0,0 +1,58 @@ +/* CSS file for dev_sandbox.py */ + +$text: #f0f0f0; +$primary: #021720; +$secondary:#95d52a; +$background: #262626; + +$primary-style: $text on $background; +$animation-speed: 500ms; +$animation: offset $animation-speed in_out_cubic; + +App > View { + docks: side=left/1; + text: on $background; +} + +Widget:hover { + outline: heavy; + text: bold !important; +} + +#sidebar { + text: $primary-style; + dock: side; + width: 30; + offset-x: -100%; + transition: $animation; + border-right: outer $secondary; +} + +#sidebar.-active { + offset-x: 0; +} + +#header { + text: $text on $primary; + height: 3; + border-bottom: hkey $secondary; +} + +#header.-visible { + visibility: hidden; +} + +#content { + text: $text on $background; + offset-y: -3; +} + +#content.-content-visible { + visibility: hidden; +} + +#footer { + text: $text on $primary; + height: 3; + border-top: hkey $secondary; +} diff --git a/src/textual/css/_styles_builder.py b/src/textual/css/_styles_builder.py index 26f10d67c..29c8b41cb 100644 --- a/src/textual/css/_styles_builder.py +++ b/src/textual/css/_styles_builder.py @@ -17,6 +17,7 @@ from .transition import Transition from .types import Edge, Display, Visibility from .._duration import _duration_as_seconds from .._easing import EASING +from .._loop import loop_last from ..geometry import Spacing, SpacingDimensions @@ -66,7 +67,7 @@ class StylesBuilder: def process_display(self, name: str, tokens: list[Token], important: bool) -> None: for token in tokens: - name, value, _, _, location = token + name, value, _, _, location, _ = token if name == "token": value = value.lower() @@ -109,7 +110,7 @@ class StylesBuilder: self, name: str, tokens: list[Token], important: bool ) -> None: for token in tokens: - name, value, _, _, location = token + name, value, _, _, location, _ = token if name == "token": value = value.lower() if value in VALID_VISIBILITY: @@ -127,7 +128,7 @@ class StylesBuilder: space: list[int] = [] append = space.append for token in tokens: - (token_name, value, _, _, location) = token + token_name, value, _, _, location, _ = token if token_name in ("number", "scalar"): try: append(int(value)) @@ -153,7 +154,7 @@ class StylesBuilder: style_tokens: list[str] = [] append = style_tokens.append for token in tokens: - token_name, value, _, _, _ = token + token_name, value, _, _, _, _ = token if token_name == "token": if value in VALID_BORDER: border_type = value @@ -299,15 +300,26 @@ class StylesBuilder: def process_text(self, name: str, tokens: list[Token], important: bool) -> None: style_definition = " ".join(token.value for token in tokens) + + # If every token in the value is a referenced by the same variable, + # we can display the variable name before the style definition. + # TODO: Factor this out to apply it to other properties too. + unique_references = {t.referenced_by for t in tokens if t.referenced_by} + if tokens and tokens[0].referenced_by and len(unique_references) == 1: + variable_prefix = f"${tokens[0].referenced_by.name}=" + else: + variable_prefix = "" + try: style = Style.parse(style_definition) + self.styles.text = style except Exception as error: - self.error(name, tokens[0], f"failed to parse style; {error}") + message = f"property 'text' has invalid value {variable_prefix}{style_definition!r}; {error}" + self.error(name, tokens[0], message) if important: self.styles.important.update( {"text_style", "text_background", "text_color"} ) - self.styles.text = style def process_text_color( self, name: str, tokens: list[Token], important: bool diff --git a/src/textual/css/errors.py b/src/textual/css/errors.py index 4856b011b..d4196db4e 100644 --- a/src/textual/css/errors.py +++ b/src/textual/css/errors.py @@ -9,6 +9,10 @@ class DeclarationError(Exception): super().__init__(message) +class UnresolvedVariableError(NameError): + pass + + class StyleTypeError(TypeError): pass diff --git a/src/textual/css/parse.py b/src/textual/css/parse.py index 9062df769..eb0b10ae4 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -1,14 +1,14 @@ from __future__ import annotations -from rich import print - +from collections import defaultdict from functools import lru_cache -from typing import Iterator, Iterable +from typing import Iterator, Iterable, Optional -from .styles import Styles -from .tokenize import tokenize, tokenize_declarations, Token -from .tokenizer import EOFError +from rich import print +from rich.cells import cell_len +from textual.css.errors import UnresolvedVariableError +from ._styles_builder import StylesBuilder, DeclarationError from .model import ( Declaration, RuleSet, @@ -17,8 +17,9 @@ from .model import ( SelectorSet, SelectorType, ) -from ._styles_builder import StylesBuilder, DeclarationError - +from .styles import Styles +from .tokenize import tokenize, tokenize_declarations, Token +from .tokenizer import EOFError, ReferencedBy SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = { "selector": (SelectorType.TYPE, (0, 0, 1)), @@ -34,7 +35,6 @@ SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = { @lru_cache(maxsize=1024) def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: - tokens = iter(tokenize(css_selectors, "")) get_selector = SELECTOR_MAP.get @@ -81,7 +81,6 @@ def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]: def parse_rule_set(tokens: Iterator[Token], token: Token) -> Iterable[RuleSet]: - get_selector = SELECTOR_MAP.get combinator: CombinatorType | None = CombinatorType.DESCENDENT selectors: list[Selector] = [] @@ -205,9 +204,111 @@ def parse_declarations(css: str, path: str) -> Styles: return styles_builder.styles -def parse(css: str, path: str) -> Iterable[RuleSet]: +def _unresolved( + variable_name: str, location: tuple[int, int] +) -> UnresolvedVariableError: + line_idx, col_idx = location + return UnresolvedVariableError( + f"reference to undefined variable '${variable_name}' at line {line_idx + 1}, column {col_idx + 1}." + ) - tokens = iter(tokenize(css, path)) + +def substitute_references(tokens: Iterator[Token]) -> Iterable[Token]: + """Replace variable references with values by substituting variable reference + tokens with the tokens representing their values. + + Args: + tokens (Iterator[Token]): Iterator of Tokens which may contain tokens + with the name "variable_ref". + + Returns: + Iterable[Token]: Yields Tokens such that any variable references (tokens where + token.name == "variable_ref") have been replaced with the tokens representing + the value. In other words, an Iterable of Tokens similar to the original input, + but with variables resolved. Substituted tokens will have their referenced_by + attribute populated with information about where the tokens are being substituted to. + """ + variables: dict[str, list[Token]] = defaultdict(list) + while tokens: + token = next(tokens, None) + if token is None: + break + if token.name == "variable_name": + variable_name = token.value[1:-1] # Trim the $ and the :, i.e. "$x:" -> "x" + yield token + + while True: + token = next(tokens, None) + if token.name == "whitespace": + yield token + else: + break + + # Store the tokens for any variable definitions, and substitute + # any variable references we encounter with them. + while True: + if not token: + break + elif token.name == "whitespace": + variables[variable_name].append(token) + yield token + elif token.name == "variable_value_end": + yield token + break + # For variables referring to other variables + elif token.name == "variable_ref": + ref_name = token.value[1:] + if ref_name in variables: + variable_tokens = variables[variable_name] + reference_tokens = variables[ref_name] + variable_tokens.extend(reference_tokens) + ref_location = token.location + ref_length = len(token.value) + for _token in reference_tokens: + yield _token.with_reference( + ReferencedBy( + name=ref_name, + location=ref_location, + length=ref_length, + ) + ) + else: + raise _unresolved( + variable_name=ref_name, location=token.location + ) + else: + variables[variable_name].append(token) + yield token + token = next(tokens, None) + elif token.name == "variable_ref": + variable_name = token.value[1:] # Trim the $, so $x -> x + if variable_name in variables: + variable_tokens = variables[variable_name] + ref_location = token.location + ref_length = len(token.value) + for token in variable_tokens: + yield token.with_reference( + ReferencedBy( + name=variable_name, + location=ref_location, + length=ref_length, + ) + ) + else: + raise _unresolved(variable_name=variable_name, location=token.location) + else: + yield token + + +def parse(css: str, path: str) -> Iterable[RuleSet]: + """Parse CSS by tokenizing it, performing variable substitution, + and generating rule sets from it. + + Args: + css (str): The input CSS + path (str): Path to the CSS + """ + tokens = iter(substitute_references(tokenize(css, path))) while True: token = next(tokens, None) if token is None: @@ -216,42 +317,6 @@ def parse(css: str, path: str) -> Iterable[RuleSet]: yield from parse_rule_set(tokens, token) -# if __name__ == "__main__": -# test = """ - -# App View { -# text: red; -# } - -# .foo.bar baz:focus, #egg .foo.baz { -# /* ignore me, I'm a comment */ -# display: block; -# visibility: visible; -# border: solid green !important; -# outline: red; -# padding: 1 2; -# margin: 5; -# text: bold red on magenta -# text-color: green; -# text-background: white -# docks: foo bar bar -# dock-group: foo -# dock-edge: top -# offset-x: 4 -# offset-y: 5 -# }""" - -# from .stylesheet import Stylesheet - -# print(test) -# print() -# stylesheet = Stylesheet() -# stylesheet.parse(test) -# print(stylesheet) -# print() -# print(stylesheet.css) - - if __name__ == "__main__": print(parse_selectors("Foo > Bar.baz { foo: bar")) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 532af0516..a67d1462b 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -36,10 +36,7 @@ from .scalar import Scalar, ScalarOffset, Unit from .scalar_animation import ScalarAnimation from .transition import Transition from .types import Display, Edge, Visibility - - from .types import Specificity3, Specificity4 -from .. import log from .._animator import Animation, EasingFunction from ..geometry import Spacing, SpacingDimensions from .._box import BoxType @@ -50,7 +47,6 @@ if sys.version_info >= (3, 8): else: from typing_extensions import TypedDict - if TYPE_CHECKING: from ..layout import Layout from ..dom import DOMNode diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index f29a73b59..e1b682857 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -1,17 +1,21 @@ from __future__ import annotations +import os from collections import defaultdict from operator import itemgetter import os from typing import cast, Iterable + import rich.repr -from rich.highlighter import ReprHighlighter -from rich.panel import Panel -from rich.text import Text from rich.console import Group, RenderableType +from rich.highlighter import ReprHighlighter +from rich.padding import Padding +from rich.panel import Panel +from rich.syntax import Syntax +from rich.text import Text - +from textual._loop import loop_last from .errors import StylesheetError from .match import _check_selectors from .model import RuleSet @@ -19,7 +23,6 @@ from .parse import parse from .styles import RulesMap from .types import Specificity3, Specificity4 from ..dom import DOMNode -from .. import log class StylesheetParseError(Exception): @@ -35,11 +38,17 @@ class StylesheetErrors: self.stylesheet = stylesheet @classmethod - def _get_snippet(cls, code: str, line_no: int, col_no: int, length: int) -> Panel: - lines = Text(code, style="dim").split() - lines[line_no].stylize("bold not dim", col_no, col_no + length - 1) - text = Text("\n").join(lines[max(0, line_no - 1) : line_no + 2]) - return Panel(text, border_style="red") + def _get_snippet(cls, code: str, line_no: int) -> Panel: + syntax = Syntax( + code, + lexer="scss", + theme="ansi_light", + line_numbers=True, + indent_guides=True, + line_range=(max(0, line_no - 2), line_no + 1), + highlight_lines={line_no}, + ) + return Panel(syntax, border_style="red") def __rich__(self) -> RenderableType: highlighter = ReprHighlighter() @@ -47,13 +56,30 @@ class StylesheetErrors: append = errors.append for rule in self.stylesheet.rules: for token, message in rule.errors: - line_no, col_no = token.location + append("") + append(Text(" Error in stylesheet:", style="bold red")) - append(highlighter(f"{token.path or ''}:{line_no}")) - append( - self._get_snippet(token.code, line_no, col_no, len(token.value) + 1) - ) - append(highlighter(Text(message, "red"))) + if token.referenced_by: + line_idx, col_idx = token.referenced_by.location + line_no, col_no = line_idx + 1, col_idx + 1 + append( + highlighter(f" {token.path or ''}:{line_no}:{col_no}") + ) + append(self._get_snippet(token.code, line_no)) + else: + line_idx, col_idx = token.location + line_no, col_no = line_idx + 1, col_idx + 1 + append( + highlighter(f" {token.path or ''}:{line_no}:{col_no}") + ) + append(self._get_snippet(token.code, line_no)) + + final_message = "" + for is_last, message_part in loop_last(message.split(";")): + end = "" if is_last else "\n" + final_message += f"• {message_part.strip()};{end}" + + append(Padding(highlighter(Text(final_message, "red")), pad=(0, 1))) append("") return Group(*errors) @@ -167,7 +193,6 @@ class Stylesheet: if __name__ == "__main__": - from rich.traceback import install install(show_locals=True) diff --git a/src/textual/css/tokenize.py b/src/textual/css/tokenize.py index f131eecd8..170fb1a6a 100644 --- a/src/textual/css/tokenize.py +++ b/src/textual/css/tokenize.py @@ -14,9 +14,9 @@ COLOR = r"\#[0-9a-fA-F]{6}|color\([0-9]{1,3}\)|rgb\(\d{1,3}\,\s?\d{1,3}\,\s?\d{1 KEY_VALUE = r"[a-zA-Z_-][a-zA-Z0-9_-]*=[0-9a-zA-Z_\-\/]+" TOKEN = "[a-zA-Z_-]+" STRING = r"\".*?\"" -VARIABLE_REF = r"\$[a-zA-Z0-9_-]+" +VARIABLE_REF = r"\$[a-zA-Z0-9_\-]+" -# Values permitted in declarations. +# Values permitted in variable and rule declarations. DECLARATION_VALUES = { "scalar": SCALAR, "duration": DURATION, @@ -38,19 +38,16 @@ expect_root_scope = Expect( selector_start_class=r"\.[a-zA-Z_\-][a-zA-Z0-9_\-]*", selector_start_universal=r"\*", selector_start=r"[a-zA-Z_\-]+", - variable_name=f"{VARIABLE_REF}:", + variable_name=rf"{VARIABLE_REF}:", ).expect_eof(True) # After a variable declaration e.g. "$warning-text: TOKENS;" # for tokenizing variable value ------^~~~~~~^ -expect_variable_value = Expect( - comment_start=COMMENT_START, - whitespace=r"\s+", - variable_value=rf"[^;\n{COMMENT_START}]+", -) - -expect_variable_value_end = Expect( +expect_variable_name_continue = Expect( variable_value_end=r"\n|;", + whitespace=r"\s+", + comment_start=COMMENT_START, + **DECLARATION_VALUES, ).expect_eof(True) expect_comment_end = Expect( @@ -72,8 +69,8 @@ expect_selector_continue = Expect( declaration_set_start=r"\{", ) -# A declaration e.g. "text: red;" -# ^---^ +# A rule declaration e.g. "text: red;" +# ^---^ expect_declaration = Expect( whitespace=r"\s+", comment_start=COMMENT_START, @@ -88,8 +85,8 @@ expect_declaration_solo = Expect( declaration_set_end=r"\}", ).expect_eof(True) -# The value(s)/content from a declaration e.g. "text: red;" -# ^---^ +# The value(s)/content from a rule declaration e.g. "text: red;" +# ^---^ expect_declaration_content = Expect( declaration_end=r"\n|;", whitespace=r"\s+", @@ -115,8 +112,7 @@ class TokenizerState: EXPECT = expect_root_scope STATE_MAP = { - "variable_name": expect_variable_value, - "variable_value": expect_variable_value_end, + "variable_name": expect_variable_name_continue, "variable_value_end": expect_root_scope, "selector_start": expect_selector_continue, "selector_start_id": expect_selector_continue, diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index d4175f726..e0910f43b 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import NamedTuple import re +from typing import NamedTuple -from rich import print import rich.repr +from rich.cells import cell_len class EOFError(Exception): @@ -39,6 +39,12 @@ class Expect: yield from zip(self.names, self.regexes) +class ReferencedBy(NamedTuple): + name: str + location: tuple[int, int] + length: int + + @rich.repr.auto class Token(NamedTuple): name: str @@ -46,6 +52,23 @@ class Token(NamedTuple): path: str code: str location: tuple[int, int] + referenced_by: ReferencedBy | None + + 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, + path=self.path, + code=self.code, + location=self.location, + referenced_by=by, + ) def __str__(self) -> str: return self.value @@ -55,6 +78,7 @@ class Token(NamedTuple): yield "value", self.value yield "path", self.path yield "location", self.location + yield "referenced_by", self.referenced_by class Tokenizer: @@ -70,7 +94,7 @@ class Tokenizer: col_no = self.col_no if line_no >= len(self.lines): if expect._expect_eof: - return Token("eof", "", self.path, self.code, (line_no, col_no)) + return Token("eof", "", self.path, self.code, (line_no, col_no), None) else: raise EOFError() line = self.lines[line_no] @@ -88,7 +112,9 @@ class Tokenizer: if value is not None: break - token = Token(name, value, self.path, self.code, (line_no, col_no)) + token = Token( + name, value, self.path, self.code, (line_no, col_no), referenced_by=None + ) col_no += len(value) if col_no >= len(line): line_no += 1 diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index ad03fb34d..5142d5cc9 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1,12 +1,178 @@ import pytest from rich.color import Color, ColorType +from textual.css.errors import UnresolvedVariableError +from textual.css.parse import substitute_references from textual.css.scalar import Scalar, Unit from textual.css.stylesheet import Stylesheet, StylesheetParseError +from textual.css.tokenize import tokenize +from textual.css.tokenizer import Token, ReferencedBy from textual.css.transition import Transition from textual.layouts.dock import DockLayout +class TestVariableReferenceSubstitution: + def test_simple_reference(self): + css = "$x: 1; #some-widget{border: $x;}" + variables = substitute_references(tokenize(css, "")) + assert list(variables) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 6), referenced_by=None), + Token(name='selector_start_id', value='#some-widget', path='', code=css, location=(0, 7), + referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 19), referenced_by=None), + Token(name='declaration_name', value='border:', path='', code=css, location=(0, 20), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 27), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 4), + referenced_by=ReferencedBy(name='x', location=(0, 28), length=2)), + Token(name='declaration_end', value=';', path='', code=css, location=(0, 30), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 31), referenced_by=None) + ] + + def test_simple_reference_no_whitespace(self): + css = "$x:1; #some-widget{border: $x;}" + variables = substitute_references(tokenize(css, "")) + assert list(variables) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='selector_start_id', value='#some-widget', path='', code=css, location=(0, 6), + referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 18), referenced_by=None), + Token(name='declaration_name', value='border:', path='', code=css, location=(0, 19), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 26), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 3), + referenced_by=ReferencedBy(name='x', location=(0, 27), length=2)), + Token(name='declaration_end', value=';', path='', code=css, location=(0, 29), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 30), referenced_by=None) + ] + + def test_undefined_variable(self): + css = ".thing { border: $not-defined; }" + with pytest.raises(UnresolvedVariableError): + list(substitute_references(tokenize(css, ""))) + + def test_transitive_reference(self): + css = "$x: 1\n$y: $x\n.thing { border: $y }" + assert list(substitute_references(tokenize(css, ""))) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='variable_value_end', value='\n', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='variable_name', value='$y:', path='', code=css, location=(1, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 3), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 4), + referenced_by=ReferencedBy(name='x', location=(1, 4), length=2)), + Token(name='variable_value_end', value='\n', path='', code=css, location=(1, 6), referenced_by=None), + Token(name='selector_start_class', value='.thing', path='', code=css, location=(2, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 6), referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(2, 7), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 8), referenced_by=None), + Token(name='declaration_name', value='border:', path='', code=css, location=(2, 9), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 16), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 4), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 19), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(2, 20), referenced_by=None) + ] + + def test_multi_value_variable(self): + css = "$x: 2 4\n$y: 6 $x 2\n.thing { border: $y }" + assert list(substitute_references(tokenize(css, ""))) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='number', value='2', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='number', value='4', path='', code=css, location=(0, 6), referenced_by=None), + Token(name='variable_value_end', value='\n', path='', code=css, location=(0, 7), referenced_by=None), + Token(name='variable_name', value='$y:', path='', code=css, location=(1, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 3), referenced_by=None), + Token(name='number', value='6', path='', code=css, location=(1, 4), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 5), referenced_by=None), + Token(name='number', value='2', path='', code=css, location=(0, 4), + referenced_by=ReferencedBy(name='x', location=(1, 6), length=2)), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 5), + referenced_by=ReferencedBy(name='x', location=(1, 6), length=2)), + Token(name='number', value='4', path='', code=css, location=(0, 6), + referenced_by=ReferencedBy(name='x', location=(1, 6), length=2)), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 8), referenced_by=None), + Token(name='number', value='2', path='', code=css, location=(1, 9), referenced_by=None), + Token(name='variable_value_end', value='\n', path='', code=css, location=(1, 10), referenced_by=None), + Token(name='selector_start_class', value='.thing', path='', code=css, location=(2, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 6), referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(2, 7), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 8), referenced_by=None), + Token(name='declaration_name', value='border:', path='', code=css, location=(2, 9), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 16), referenced_by=None), + Token(name='number', value='6', path='', code=css, location=(1, 4), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 5), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='number', value='2', path='', code=css, location=(0, 4), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 5), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='number', value='4', path='', code=css, location=(0, 6), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 8), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='number', value='2', path='', code=css, location=(1, 9), + referenced_by=ReferencedBy(name='y', location=(2, 17), length=2)), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 19), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(2, 20), referenced_by=None) + ] + + def test_variable_used_inside_property_value(self): + css = "$x: red\n.thing { border: on $x; }" + assert list(substitute_references(tokenize(css, ""))) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='token', value='red', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='variable_value_end', value='\n', path='', code=css, location=(0, 7), referenced_by=None), + Token(name='selector_start_class', value='.thing', path='', code=css, location=(1, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 6), referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(1, 7), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 8), referenced_by=None), + Token(name='declaration_name', value='border:', path='', code=css, location=(1, 9), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 16), referenced_by=None), + Token(name='token', value='on', path='', code=css, location=(1, 17), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 19), referenced_by=None), + Token(name='token', value='red', path='', code=css, location=(0, 4), + referenced_by=ReferencedBy(name='x', location=(1, 20), length=2)), + Token(name='declaration_end', value=';', path='', code=css, location=(1, 22), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 23), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(1, 24), referenced_by=None) + ] + + def test_variable_definition_eof(self): + css = "$x: 1" + assert list(substitute_references(tokenize(css, ""))) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 4), referenced_by=None) + ] + + def test_variable_reference_whitespace_trimming(self): + css = "$x: 123;.thing{border: $x}" + assert list(substitute_references(tokenize(css, ""))) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='number', value='123', path='', code=css, location=(0, 7), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 10), referenced_by=None), + Token(name='selector_start_class', value='.thing', path='', code=css, location=(0, 11), referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 17), referenced_by=None), + Token(name='declaration_name', value='border:', path='', code=css, location=(0, 18), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 25), referenced_by=None), + Token(name='number', value='123', path='', code=css, location=(0, 7), + referenced_by=ReferencedBy(name='x', location=(0, 26), length=2)), + Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 28), referenced_by=None) + ] + + class TestParseLayout: def test_valid_layout_name(self): css = "#some-widget { layout: dock; }" diff --git a/tests/css/test_tokenize.py b/tests/css/test_tokenize.py index 7a8a73f8c..2d3155382 100644 --- a/tests/css/test_tokenize.py +++ b/tests/css/test_tokenize.py @@ -1,6 +1,5 @@ import pytest -import textual.css.tokenizer from textual.css.tokenize import tokenize from textual.css.tokenizer import Token, TokenizeError @@ -21,114 +20,140 @@ VALID_VARIABLE_NAMES = [ def test_variable_declaration_valid_names(name): css = f"${name}: black on red;" assert list(tokenize(css, "")) == [ - Token( - name="variable_name", value=f"${name}:", path="", code=css, location=(0, 0) - ), - Token(name="whitespace", value=" ", path="", code=css, location=(0, 14)), - Token(name="variable_value", value="black on red", path="", code=css, location=(0, 15)), - Token(name="variable_value_end", value=";", path="", code=css, location=(0, 27)), + Token(name='variable_name', value=f'${name}:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 14), referenced_by=None), + Token(name='token', value='black', path='', code=css, location=(0, 15), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 20), referenced_by=None), + Token(name='token', value='on', path='', code=css, location=(0, 21), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 23), referenced_by=None), + Token(name='token', value='red', path='', code=css, location=(0, 24), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 27), referenced_by=None), + ] + + +def test_variable_declaration_multiple_values(): + css = "$x: 2vw\t4% 6s red;" + assert list(tokenize(css, "")) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='scalar', value='2vw', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='whitespace', value='\t', path='', code=css, location=(0, 7), referenced_by=None), + Token(name='scalar', value='4%', path='', code=css, location=(0, 8), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 10), referenced_by=None), + Token(name='duration', value='6s', path='', code=css, location=(0, 11), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 13), referenced_by=None), + Token(name='token', value='red', path='', code=css, location=(0, 15), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 18), referenced_by=None), ] def test_variable_declaration_comment_ignored(): css = "$x: red; /* comment */" assert list(tokenize(css, "")) == [ - Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 3)), - Token(name='variable_value', value='red', path='', code=css, location=(0, 4)), - Token(name='variable_value_end', value=';', path='', code=css, location=(0, 7)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 8)), + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='token', value='red', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 7), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 8), referenced_by=None), ] -def test_variable_declaration_comment_interspersed_raises(): +def test_variable_declaration_comment_interspersed_ignored(): css = "$x: re/* comment */d;" - with pytest.raises(TokenizeError): - assert list(tokenize(css, "")) - - -def test_variable_declaration_invalid_value_eof(): - css = "$x:\n" - with pytest.raises(textual.css.tokenizer.EOFError): - list(tokenize(css, "")) + assert list(tokenize(css, "")) == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='token', value='re', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='token', value='d', path='', code=css, location=(0, 19), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 20), referenced_by=None), + ] def test_variable_declaration_no_semicolon(): css = "$x: 1\n$y: 2" assert list(tokenize(css, "")) == [ - Token(name="variable_name", value="$x:", code=css, path="", location=(0, 0)), - Token(name="whitespace", value=" ", code=css, path="", location=(0, 3)), - Token(name="variable_value", value="1", code=css, path="", location=(0, 4)), - Token(name="variable_value_end", value="\n", code=css, path="", location=(0, 5)), - Token(name="variable_name", value="$y:", code=css, path="", location=(1, 0)), - Token(name="whitespace", value=" ", code=css, path="", location=(1, 3)), - Token(name="variable_value", value="2", code=css, path="", location=(1, 4)), + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='variable_value_end', value='\n', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='variable_name', value='$y:', path='', code=css, location=(1, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 3), referenced_by=None), + Token(name='number', value='2', path='', code=css, location=(1, 4), referenced_by=None), ] +def test_variable_declaration_invalid_value(): + css = "$x:(@$12x)" + with pytest.raises(TokenizeError): + list(tokenize(css, "")) + + def test_variables_declarations_amongst_rulesets(): css = "$x:1; .thing{text:red;} $y:2;" - assert list(tokenize(css, "")) == [ - Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0)), - Token(name='variable_value', value='1', path='', code=css, location=(0, 3)), - Token(name='variable_value_end', value=';', path='', code=css, location=(0, 4)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 5)), - Token(name='selector_start_class', value='.thing', path='', code=css, location=(0, 6)), - Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 12)), - Token(name='declaration_name', value='text:', path='', code=css, location=(0, 13)), - Token(name='token', value='red', path='', code=css, location=(0, 18)), - Token(name='declaration_end', value=';', path='', code=css, location=(0, 21)), - Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 22)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 23)), - Token(name='variable_name', value='$y:', path='', code=css, location=(0, 24)), - Token(name='variable_value', value='2', path='', code=css, location=(0, 27)), - Token(name='variable_value_end', value=';', path='', code=css, location=(0, 28)), + tokens = list(tokenize(css, "")) + assert tokens == [ + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='number', value='1', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='selector_start_class', value='.thing', path='', code=css, location=(0, 6), referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 12), referenced_by=None), + Token(name='declaration_name', value='text:', path='', code=css, location=(0, 13), referenced_by=None), + Token(name='token', value='red', path='', code=css, location=(0, 18), referenced_by=None), + Token(name='declaration_end', value=';', path='', code=css, location=(0, 21), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 22), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 23), referenced_by=None), + Token(name='variable_name', value='$y:', path='', code=css, location=(0, 24), referenced_by=None), + Token(name='number', value='2', path='', code=css, location=(0, 27), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 28), referenced_by=None), ] def test_variables_reference_in_rule_declaration_value(): css = ".warn{text: $warning;}" assert list(tokenize(css, "")) == [ - Token(name='selector_start_class', value='.warn', path='', code=css, location=(0, 0)), - Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 5)), - Token(name='declaration_name', value='text:', path='', code=css, location=(0, 6)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 11)), - Token(name='variable_ref', value='$warning', path='', code=css, location=(0, 12)), - Token(name='declaration_end', value=';', path='', code=css, location=(0, 20)), - Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 21)), + Token(name='selector_start_class', value='.warn', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='declaration_name', value='text:', path='', code=css, location=(0, 6), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 11), referenced_by=None), + Token(name='variable_ref', value='$warning', path='', code=css, location=(0, 12), referenced_by=None), + Token(name='declaration_end', value=';', path='', code=css, location=(0, 20), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 21), referenced_by=None), ] def test_variables_reference_in_rule_declaration_value_multiple(): css = ".card{padding: $pad-y $pad-x;}" assert list(tokenize(css, "")) == [ - Token(name='selector_start_class', value='.card', path='', code=css, location=(0, 0)), - Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 5)), - Token(name='declaration_name', value='padding:', path='', code=css, location=(0, 6)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 14)), - Token(name='variable_ref', value='$pad-y', path='', code=css, location=(0, 15)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 21)), - Token(name='variable_ref', value='$pad-x', path='', code=css, location=(0, 22)), - Token(name='declaration_end', value=';', path='', code=css, location=(0, 28)), - Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 29)), + Token(name='selector_start_class', value='.card', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 5), referenced_by=None), + Token(name='declaration_name', value='padding:', path='', code=css, location=(0, 6), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 14), referenced_by=None), + Token(name='variable_ref', value='$pad-y', path='', code=css, location=(0, 15), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 21), referenced_by=None), + Token(name='variable_ref', value='$pad-x', path='', code=css, location=(0, 22), referenced_by=None), + Token(name='declaration_end', value=';', path='', code=css, location=(0, 28), referenced_by=None), + Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 29), referenced_by=None) ] def test_variables_reference_in_variable_declaration(): css = "$x: $y;" assert list(tokenize(css, "")) == [ - Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 3)), - Token(name='variable_value', value='$y', path='', code=css, location=(0, 4)), - Token(name='variable_value_end', value=';', path='', code=css, location=(0, 6)), + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='variable_ref', value='$y', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 6), referenced_by=None) ] def test_variable_references_in_variable_declaration_multiple(): css = "$x: $y $z\n" assert list(tokenize(css, "")) == [ - Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0)), - Token(name='whitespace', value=' ', path='', code=css, location=(0, 3)), - Token(name='variable_value', value='$y $z', path='', code=css, location=(0, 4)), - Token(name='variable_value_end', value='\n', path='', code=css, location=(0, 10)), + Token(name='variable_name', value='$x:', path='', code=css, location=(0, 0), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3), referenced_by=None), + Token(name='variable_ref', value='$y', path='', code=css, location=(0, 4), referenced_by=None), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 6), referenced_by=None), + Token(name='variable_ref', value='$z', path='', code=css, location=(0, 8), referenced_by=None), + Token(name='variable_value_end', value='\n', path='', code=css, location=(0, 10), referenced_by=None) ]