Module textual.css.parse

Expand source code
from __future__ import annotations

from functools import lru_cache
from pathlib import PurePath
from typing import Iterator, Iterable, NoReturn

from rich import print

from .errors import UnresolvedVariableError
from .types import Specificity3
from ._styles_builder import StylesBuilder, DeclarationError
from .model import (
    Declaration,
    RuleSet,
    Selector,
    CombinatorType,
    SelectorSet,
    SelectorType,
)
from .styles import Styles
from ..suggestions import get_suggestion
from .tokenize import tokenize, tokenize_declarations, Token, tokenize_values
from .tokenizer import EOFError, ReferencedBy

SELECTOR_MAP: dict[str, tuple[SelectorType, Specificity3]] = {
    "selector": (SelectorType.TYPE, (0, 0, 1)),
    "selector_start": (SelectorType.TYPE, (0, 0, 1)),
    "selector_class": (SelectorType.CLASS, (0, 1, 0)),
    "selector_start_class": (SelectorType.CLASS, (0, 1, 0)),
    "selector_id": (SelectorType.ID, (1, 0, 0)),
    "selector_start_id": (SelectorType.ID, (1, 0, 0)),
    "selector_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
    "selector_start_universal": (SelectorType.UNIVERSAL, (0, 0, 0)),
}


@lru_cache(maxsize=1024)
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:

    if not css_selectors.strip():
        return ()

    tokens = iter(tokenize(css_selectors, ""))

    get_selector = SELECTOR_MAP.get
    combinator: CombinatorType | None = CombinatorType.DESCENDENT
    selectors: list[Selector] = []
    rule_selectors: list[list[Selector]] = []

    while True:
        try:
            token = next(tokens)
        except EOFError:
            break
        token_name = token.name
        if token_name == "pseudo_class":
            selectors[-1]._add_pseudo_class(token.value.lstrip(":"))
        elif token_name == "whitespace":
            if combinator is None or combinator == CombinatorType.SAME:
                combinator = CombinatorType.DESCENDENT
        elif token_name == "new_selector":
            rule_selectors.append(selectors[:])
            selectors.clear()
            combinator = None
        elif token_name == "declaration_set_start":
            break
        elif token_name == "combinator_child":
            combinator = CombinatorType.CHILD
        else:
            _selector, specificity = get_selector(
                token_name, (SelectorType.TYPE, (0, 0, 0))
            )
            selectors.append(
                Selector(
                    name=token.value.lstrip(".#"),
                    combinator=combinator or CombinatorType.DESCENDENT,
                    type=_selector,
                    specificity=specificity,
                )
            )
            combinator = CombinatorType.SAME
    if selectors:
        rule_selectors.append(selectors[:])

    selector_set = tuple(SelectorSet.from_selectors(rule_selectors))
    return selector_set


def parse_rule_set(
    tokens: Iterator[Token],
    token: Token,
    is_default_rules: bool = False,
    tie_breaker: int = 0,
) -> Iterable[RuleSet]:
    get_selector = SELECTOR_MAP.get
    combinator: CombinatorType | None = CombinatorType.DESCENDENT
    selectors: list[Selector] = []
    rule_selectors: list[list[Selector]] = []
    styles_builder = StylesBuilder()

    while True:
        if token.name == "pseudo_class":
            selectors[-1]._add_pseudo_class(token.value.lstrip(":"))
        elif token.name == "whitespace":
            if combinator is None or combinator == CombinatorType.SAME:
                combinator = CombinatorType.DESCENDENT
        elif token.name == "new_selector":
            rule_selectors.append(selectors[:])
            selectors.clear()
            combinator = None
        elif token.name == "declaration_set_start":
            break
        elif token.name == "combinator_child":
            combinator = CombinatorType.CHILD
        else:
            _selector, specificity = get_selector(
                token.name, (SelectorType.TYPE, (0, 0, 0))
            )
            selectors.append(
                Selector(
                    name=token.value.lstrip(".#"),
                    combinator=combinator or CombinatorType.DESCENDENT,
                    type=_selector,
                    specificity=specificity,
                )
            )
            combinator = CombinatorType.SAME

        token = next(tokens)

    if selectors:
        rule_selectors.append(selectors[:])

    declaration = Declaration(token, "")

    errors: list[tuple[Token, str]] = []

    while True:
        token = next(tokens)
        token_name = token.name
        if token_name in ("whitespace", "declaration_end"):
            continue
        if token_name == "declaration_name":
            if declaration.tokens:
                try:
                    styles_builder.add_declaration(declaration)
                except DeclarationError as error:
                    errors.append((error.token, error.message))
            declaration = Declaration(token, "")
            declaration.name = token.value.rstrip(":")
        elif token_name == "declaration_set_end":
            break
        else:
            declaration.tokens.append(token)

    if declaration.tokens:
        try:
            styles_builder.add_declaration(declaration)
        except DeclarationError as error:
            errors.append((error.token, error.message))

    rule_set = RuleSet(
        list(SelectorSet.from_selectors(rule_selectors)),
        styles_builder.styles,
        errors,
        is_default_rules=is_default_rules,
        tie_breaker=tie_breaker,
    )
    rule_set._post_parse()
    yield rule_set


def parse_declarations(css: str, path: str) -> Styles:
    """Parse declarations and return a Styles object.

    Args:
        css (str): String containing CSS.
        path (str): Path to the CSS, or something else to identify the location.

    Returns:
        Styles: A styles object.
    """

    tokens = iter(tokenize_declarations(css, path))
    styles_builder = StylesBuilder()

    declaration: Declaration | None = None
    errors: list[tuple[Token, str]] = []

    while True:
        token = next(tokens, None)
        if token is None:
            break
        token_name = token.name
        if token_name in ("whitespace", "declaration_end", "eof"):
            continue
        if token_name == "declaration_name":
            if declaration and declaration.tokens:
                try:
                    styles_builder.add_declaration(declaration)
                except DeclarationError as error:
                    errors.append((error.token, error.message))
                    raise
            declaration = Declaration(token, "")
            declaration.name = token.value.rstrip(":")
        elif token_name == "declaration_set_end":
            break
        else:
            if declaration:
                declaration.tokens.append(token)

    if declaration and declaration.tokens:
        try:
            styles_builder.add_declaration(declaration)
        except DeclarationError as error:
            errors.append((error.token, error.message))
            raise

    return styles_builder.styles


def _unresolved(variable_name: str, variables: Iterable[str], token: Token) -> NoReturn:
    """Raise a TokenError regarding an unresolved variable.

    Args:
        variable_name (str): A variable name.
        variables (Iterable[str]): Possible choices used to generate suggestion.
        token (Token): The Token.

    Raises:
        UnresolvedVariableError: Always raises a TokenError.

    """
    message = f"reference to undefined variable '${variable_name}'"
    suggested_variable = get_suggestion(variable_name, list(variables))
    if suggested_variable:
        message += f"; did you mean '${suggested_variable}'?"

    raise UnresolvedVariableError(
        token.path,
        token.code,
        token.start,
        message,
        end=token.end,
    )


def substitute_references(
    tokens: Iterable[Token], css_variables: dict[str, list[Token]] | None = None
) -> Iterable[Token]:
    """Replace variable references with values by substituting variable reference
    tokens with the tokens representing their values.

    Args:
        tokens (Iterable[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]] = css_variables.copy() if css_variables else {}

    iter_tokens = iter(tokens)

    while tokens:
        token = next(iter_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(iter_tokens, None)
                # TODO: Mypy error looks legit
                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.setdefault(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.setdefault(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(
                                    ref_name, ref_location, ref_length, token.code
                                )
                            )
                    else:
                        _unresolved(ref_name, variables.keys(), token)
                else:
                    variables.setdefault(variable_name, []).append(token)
                    yield token
                token = next(iter_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)
                ref_code = token.code
                for _token in variable_tokens:
                    yield _token.with_reference(
                        ReferencedBy(variable_name, ref_location, ref_length, ref_code)
                    )
            else:
                _unresolved(variable_name, variables.keys(), token)
        else:
            yield token


def parse(
    css: str,
    path: str | PurePath,
    variables: dict[str, str] | None = None,
    variable_tokens: dict[str, list[Token]] | None = None,
    is_default_rules: bool = False,
    tie_breaker: int = 0,
) -> 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
        variables (dict[str, str]): Substitution variables to substitute tokens for.
        is_default_rules (bool): True if the rules we're extracting are
            default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
    """

    reference_tokens = tokenize_values(variables) if variables is not None else {}
    if variable_tokens:
        reference_tokens.update(variable_tokens)

    tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
    while True:
        token = next(tokens, None)
        if token is None:
            break
        if token.name.startswith("selector_start"):
            yield from parse_rule_set(
                tokens,
                token,
                is_default_rules=is_default_rules,
                tie_breaker=tie_breaker,
            )


if __name__ == "__main__":
    print(parse_selectors("Foo > Bar.baz { foo: bar"))

    css = """#something {
    text: on red;
    transition: offset 5.51s in_out_cubic;
    offset-x: 100%;
}
"""

    from textual.css.stylesheet import Stylesheet, StylesheetParseError
    from rich.console import Console

    console = Console()
    stylesheet = Stylesheet()
    try:
        stylesheet.add_source(css)
    except StylesheetParseError as e:
        console.print(e.errors)
    print(stylesheet)
    print(stylesheet.css)

Functions

def parse(css: str, path: str | PurePath, variables: dict[str, str] | None = None, variable_tokens: dict[str, list[Token]] | None = None, is_default_rules: bool = False, tie_breaker: int = 0) ‑> 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
variables : dict[str, str]
Substitution variables to substitute tokens for.
is_default_rules : bool
True if the rules we're extracting are default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
Expand source code
def parse(
    css: str,
    path: str | PurePath,
    variables: dict[str, str] | None = None,
    variable_tokens: dict[str, list[Token]] | None = None,
    is_default_rules: bool = False,
    tie_breaker: int = 0,
) -> 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
        variables (dict[str, str]): Substitution variables to substitute tokens for.
        is_default_rules (bool): True if the rules we're extracting are
            default (i.e. in Widget.DEFAULT_CSS) rules. False if they're from user defined CSS.
    """

    reference_tokens = tokenize_values(variables) if variables is not None else {}
    if variable_tokens:
        reference_tokens.update(variable_tokens)

    tokens = iter(substitute_references(tokenize(css, path), variable_tokens))
    while True:
        token = next(tokens, None)
        if token is None:
            break
        if token.name.startswith("selector_start"):
            yield from parse_rule_set(
                tokens,
                token,
                is_default_rules=is_default_rules,
                tie_breaker=tie_breaker,
            )
def parse_declarations(css: str, path: str) ‑> Styles

Parse declarations and return a Styles object.

Args

css : str
String containing CSS.
path : str
Path to the CSS, or something else to identify the location.

Returns

Styles
A styles object.
Expand source code
def parse_declarations(css: str, path: str) -> Styles:
    """Parse declarations and return a Styles object.

    Args:
        css (str): String containing CSS.
        path (str): Path to the CSS, or something else to identify the location.

    Returns:
        Styles: A styles object.
    """

    tokens = iter(tokenize_declarations(css, path))
    styles_builder = StylesBuilder()

    declaration: Declaration | None = None
    errors: list[tuple[Token, str]] = []

    while True:
        token = next(tokens, None)
        if token is None:
            break
        token_name = token.name
        if token_name in ("whitespace", "declaration_end", "eof"):
            continue
        if token_name == "declaration_name":
            if declaration and declaration.tokens:
                try:
                    styles_builder.add_declaration(declaration)
                except DeclarationError as error:
                    errors.append((error.token, error.message))
                    raise
            declaration = Declaration(token, "")
            declaration.name = token.value.rstrip(":")
        elif token_name == "declaration_set_end":
            break
        else:
            if declaration:
                declaration.tokens.append(token)

    if declaration and declaration.tokens:
        try:
            styles_builder.add_declaration(declaration)
        except DeclarationError as error:
            errors.append((error.token, error.message))
            raise

    return styles_builder.styles
def parse_rule_set(tokens: Iterator[Token], token: Token, is_default_rules: bool = False, tie_breaker: int = 0) ‑> Iterable[RuleSet]
Expand source code
def parse_rule_set(
    tokens: Iterator[Token],
    token: Token,
    is_default_rules: bool = False,
    tie_breaker: int = 0,
) -> Iterable[RuleSet]:
    get_selector = SELECTOR_MAP.get
    combinator: CombinatorType | None = CombinatorType.DESCENDENT
    selectors: list[Selector] = []
    rule_selectors: list[list[Selector]] = []
    styles_builder = StylesBuilder()

    while True:
        if token.name == "pseudo_class":
            selectors[-1]._add_pseudo_class(token.value.lstrip(":"))
        elif token.name == "whitespace":
            if combinator is None or combinator == CombinatorType.SAME:
                combinator = CombinatorType.DESCENDENT
        elif token.name == "new_selector":
            rule_selectors.append(selectors[:])
            selectors.clear()
            combinator = None
        elif token.name == "declaration_set_start":
            break
        elif token.name == "combinator_child":
            combinator = CombinatorType.CHILD
        else:
            _selector, specificity = get_selector(
                token.name, (SelectorType.TYPE, (0, 0, 0))
            )
            selectors.append(
                Selector(
                    name=token.value.lstrip(".#"),
                    combinator=combinator or CombinatorType.DESCENDENT,
                    type=_selector,
                    specificity=specificity,
                )
            )
            combinator = CombinatorType.SAME

        token = next(tokens)

    if selectors:
        rule_selectors.append(selectors[:])

    declaration = Declaration(token, "")

    errors: list[tuple[Token, str]] = []

    while True:
        token = next(tokens)
        token_name = token.name
        if token_name in ("whitespace", "declaration_end"):
            continue
        if token_name == "declaration_name":
            if declaration.tokens:
                try:
                    styles_builder.add_declaration(declaration)
                except DeclarationError as error:
                    errors.append((error.token, error.message))
            declaration = Declaration(token, "")
            declaration.name = token.value.rstrip(":")
        elif token_name == "declaration_set_end":
            break
        else:
            declaration.tokens.append(token)

    if declaration.tokens:
        try:
            styles_builder.add_declaration(declaration)
        except DeclarationError as error:
            errors.append((error.token, error.message))

    rule_set = RuleSet(
        list(SelectorSet.from_selectors(rule_selectors)),
        styles_builder.styles,
        errors,
        is_default_rules=is_default_rules,
        tie_breaker=tie_breaker,
    )
    rule_set._post_parse()
    yield rule_set
def parse_selectors(css_selectors: str) ‑> tuple[SelectorSet, ...]
Expand source code
@lru_cache(maxsize=1024)
def parse_selectors(css_selectors: str) -> tuple[SelectorSet, ...]:

    if not css_selectors.strip():
        return ()

    tokens = iter(tokenize(css_selectors, ""))

    get_selector = SELECTOR_MAP.get
    combinator: CombinatorType | None = CombinatorType.DESCENDENT
    selectors: list[Selector] = []
    rule_selectors: list[list[Selector]] = []

    while True:
        try:
            token = next(tokens)
        except EOFError:
            break
        token_name = token.name
        if token_name == "pseudo_class":
            selectors[-1]._add_pseudo_class(token.value.lstrip(":"))
        elif token_name == "whitespace":
            if combinator is None or combinator == CombinatorType.SAME:
                combinator = CombinatorType.DESCENDENT
        elif token_name == "new_selector":
            rule_selectors.append(selectors[:])
            selectors.clear()
            combinator = None
        elif token_name == "declaration_set_start":
            break
        elif token_name == "combinator_child":
            combinator = CombinatorType.CHILD
        else:
            _selector, specificity = get_selector(
                token_name, (SelectorType.TYPE, (0, 0, 0))
            )
            selectors.append(
                Selector(
                    name=token.value.lstrip(".#"),
                    combinator=combinator or CombinatorType.DESCENDENT,
                    type=_selector,
                    specificity=specificity,
                )
            )
            combinator = CombinatorType.SAME
    if selectors:
        rule_selectors.append(selectors[:])

    selector_set = tuple(SelectorSet.from_selectors(rule_selectors))
    return selector_set
def substitute_references(tokens: Iterable[Token], css_variables: dict[str, list[Token]] | None = None) ‑> Iterable[Token]

Replace variable references with values by substituting variable reference tokens with the tokens representing their values.

Args

tokens : Iterable[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.
Expand source code
def substitute_references(
    tokens: Iterable[Token], css_variables: dict[str, list[Token]] | None = None
) -> Iterable[Token]:
    """Replace variable references with values by substituting variable reference
    tokens with the tokens representing their values.

    Args:
        tokens (Iterable[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]] = css_variables.copy() if css_variables else {}

    iter_tokens = iter(tokens)

    while tokens:
        token = next(iter_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(iter_tokens, None)
                # TODO: Mypy error looks legit
                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.setdefault(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.setdefault(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(
                                    ref_name, ref_location, ref_length, token.code
                                )
                            )
                    else:
                        _unresolved(ref_name, variables.keys(), token)
                else:
                    variables.setdefault(variable_name, []).append(token)
                    yield token
                token = next(iter_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)
                ref_code = token.code
                for _token in variable_tokens:
                    yield _token.with_reference(
                        ReferencedBy(variable_name, ref_location, ref_length, ref_code)
                    )
            else:
                _unresolved(variable_name, variables.keys(), token)
        else:
            yield token