diff --git a/examples/basic.css b/examples/basic.css index efafe797e..43cf43743 100644 --- a/examples/basic.css +++ b/examples/basic.css @@ -1,8 +1,10 @@ /* CSS file for basic.py */ +$primary: #20639b; + App > View { docks: side=left/1; - text: on #20639b; + text: on $primary; } Widget:hover { @@ -34,7 +36,7 @@ Widget:hover { } #content { - text: white on #20639b; + text: white on $primary; border-bottom: hkey #0f2b41; } 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 05482f575..04b118875 100644 --- a/src/textual/css/parse.py +++ b/src/textual/css/parse.py @@ -1,17 +1,14 @@ from __future__ import annotations -import itertools from collections import defaultdict +from functools import lru_cache +from itertools import dropwhile +from typing import Iterator, Iterable from rich import print -from functools import lru_cache -from typing import Iterator, Iterable - -from .styles import Styles -from .tokenize import tokenize, tokenize_declarations, Token -from .tokenizer import EOFError - +from textual.css.errors import UnresolvedVariableError +from ._styles_builder import StylesBuilder, DeclarationError from .model import ( Declaration, RuleSet, @@ -20,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 SELECTOR_MAP: dict[str, tuple[SelectorType, tuple[int, int, int]]] = { "selector": (SelectorType.TYPE, (0, 0, 1)), @@ -37,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 @@ -84,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] = [] @@ -208,21 +204,84 @@ def parse_declarations(css: str, path: str) -> Styles: return styles_builder.styles -# def _with_resolved_variables(tokens: Iterable[Token]) -> Iterable[Token]: -# variables: dict[str, list[Token]] = defaultdict(list) -# for token in 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" -# # At this point, we need to tokenize the variable value, as when we pass -# # the Declarations to the style builder, types must be known (e.g. Scalar vs Duration) -# variables[variable_name] = +def _is_whitespace(token: Token) -> bool: + return token.name == "whitespace" + + +def _unresolved( + variable_name: str, location: tuple[int, int] +) -> UnresolvedVariableError: + return UnresolvedVariableError( + f"variable ${variable_name} is not defined. " + f"attempted reference at location {location!r}" + ) + + +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. + """ + 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 + + # Lookahead for the variable value tokens + while True: + token = next(tokens, None) + if not token: + break + if token.name != "variable_value_end": + # For variables referring to other variables + if token.name == "variable_ref": + ref_name = token.value[1:] + if ref_name in variables: + variables[variable_name].extend(variables[ref_name]) + variable_tokens = dropwhile( + _is_whitespace, + variables[ref_name], + ) + yield from variable_tokens + else: + raise _unresolved( + variable_name=ref_name, location=token.location + ) + else: + variables[variable_name].append(token) + yield token + else: + yield token + break + elif token.name == "variable_ref": + variable_name = token.value[1:] # Trim the $, so $x -> x + if variable_name in variables: + variable_tokens = dropwhile( + _is_whitespace, + variables[variable_name], + ) + yield from variable_tokens + else: + raise _unresolved(variable_name=variable_name, location=token.location) + else: + yield token def parse(css: str, path: str) -> Iterable[RuleSet]: - tokens = iter((tokenize(css, path))) + tokens = iter(substitute_references(tokenize(css, path))) while True: token = next(tokens, None) if token is None: diff --git a/src/textual/css/tokenizer.py b/src/textual/css/tokenizer.py index d4175f726..7746b9fd3 100644 --- a/src/textual/css/tokenizer.py +++ b/src/textual/css/tokenizer.py @@ -1,9 +1,8 @@ from __future__ import annotations -from typing import NamedTuple import re +from typing import NamedTuple -from rich import print import rich.repr diff --git a/tests/css/test_parse.py b/tests/css/test_parse.py index ff02d63d9..a08882f5f 100644 --- a/tests/css/test_parse.py +++ b/tests/css/test_parse.py @@ -1,19 +1,61 @@ import pytest from rich.color import Color, ColorType -# from textual.css.parse import _resolve_variables +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 from textual.css.transition import Transition from textual.layouts.dock import DockLayout -# class TestVariableResolution: -# def test_resolve_single_variable(self): -# css = "$x: 1;" -# variables = _resolve_variables(tokenize(css, "")) -# assert variables == {"x": } +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)), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3)), + Token(name='number', value='1', path='', code=css, location=(0, 4)), + Token(name='variable_value_end', value=';', path='', code=css, location=(0, 5)), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 6)), + Token(name='selector_start_id', value='#some-widget', path='', code=css, location=(0, 7)), + Token(name='declaration_set_start', value='{', path='', code=css, location=(0, 19)), + Token(name='declaration_name', value='border:', path='', code=css, location=(0, 20)), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 27)), + Token(name='number', value='1', path='', code=css, location=(0, 4)), + Token(name='declaration_end', value=';', path='', code=css, location=(0, 30)), + Token(name='declaration_set_end', value='}', path='', code=css, location=(0, 31)) + ] + + 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)), + Token(name='whitespace', value=' ', path='', code=css, location=(0, 3)), + Token(name='number', value='1', path='', code=css, location=(0, 4)), + Token(name='variable_value_end', value='\n', path='', code=css, location=(0, 5)), + Token(name='variable_name', value='$y:', path='', code=css, location=(1, 0)), + Token(name='whitespace', value=' ', path='', code=css, location=(1, 3)), + Token(name='number', value='1', path='', code=css, location=(0, 4)), + Token(name='variable_value_end', value='\n', path='', code=css, location=(1, 6)), + Token(name='selector_start_class', value='.thing', path='', code=css, location=(2, 0)), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 6)), + Token(name='declaration_set_start', value='{', path='', code=css, location=(2, 7)), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 8)), + Token(name='declaration_name', value='border:', path='', code=css, location=(2, 9)), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 16)), + Token(name='number', value='1', path='', code=css, location=(0, 4)), + Token(name='whitespace', value=' ', path='', code=css, location=(2, 19)), + Token(name='declaration_set_end', value='}', path='', code=css, location=(2, 20)) + ] class TestParseLayout: