Meta: Implement CSS value type parsing code generation

To do this we parse the grammar supplied in `ValueTypes.json` and
generate the appropriate parsing function in `CSS::Parser::Parser`

Only a small subset of the CSS grammar (i.e. types `<foo>` and
alternatives `<foo> | <bar>`) is implemented so far, it will be
expanded in later commits.
This commit is contained in:
Callum Law 2026-04-24 23:29:57 +12:00 committed by Sam Atkins
parent 8849435d6f
commit 09418e8c77
10 changed files with 370 additions and 3 deletions

View file

@ -14,6 +14,7 @@ from typing import TextIO
sys.path.append(str(Path(__file__).resolve().parent.parent))
from Utils.CSSGrammar.generator import generate_css_parser_expression_for_grammar
from Utils.utils import snake_casify
@ -106,9 +107,9 @@ namespace Web::CSS::Parser {
RefPtr<StyleValue const> Parser::parse_{name_snake_case}_value(TokenStream<ComponentValue>& tokens)
{{
// {value_type_name} = {grammar}
(void)tokens;
// FIXME: Generate parser code for this value type.
return nullptr;
""")
generate_css_parser_expression_for_grammar(out, name_snake_case, grammar)
out.write(f""" return {name_snake_case};
}}
""")

View file

@ -0,0 +1,14 @@
from dataclasses import dataclass
from typing import Union
@dataclass(frozen=True)
class Type:
name: str
def dump(self, indent: int) -> str:
return f"{'': >{indent}}Type: {self.name}\n"
# https://drafts.csswg.org/css-values-4/#component-types
ComponentValue = Union[Type]

View file

@ -0,0 +1,39 @@
from dataclasses import dataclass
from enum import Enum
from Utils.CSSGrammar.Parser.component_values import ComponentValue
class GrammarNode:
def dump(self, indent: int = 0) -> str:
raise NotImplementedError
# https://drafts.csswg.org/css-values-4/#component-types
@dataclass(frozen=True)
class ComponentValueGrammarNode(GrammarNode):
component_value: ComponentValue
def dump(self, indent: int = 0) -> str:
return f"{'': >{indent}}ComponentValue\n" + self.component_value.dump(indent + 2)
class CombinatorType(Enum):
# https://drafts.csswg.org/css-values-4/#comb-one
# A bar (|) separates two or more alternatives: exactly one of them must occur.
ALTERNATIVES = "Alternatives"
# https://drafts.csswg.org/css-values-4/#component-combinators
@dataclass(frozen=True)
class CombinatorGrammarNode(GrammarNode):
combinator_type: CombinatorType
children: list[GrammarNode]
def dump(self, indent: int = 0) -> str:
output = f"{'': >{indent}}Combinator({self.combinator_type.value}):\n"
for child in self.children:
output += child.dump(indent + 2)
return output

View file

@ -0,0 +1,61 @@
from Utils.CSSGrammar.Parser.grammar_node import CombinatorGrammarNode
from Utils.CSSGrammar.Parser.grammar_node import CombinatorType
from Utils.CSSGrammar.Parser.grammar_node import ComponentValueGrammarNode
from Utils.CSSGrammar.Parser.grammar_node import GrammarNode
from Utils.CSSGrammar.Parser.token import Token
from Utils.CSSGrammar.Parser.token import TokenType
from Utils.CSSGrammar.Parser.tokenizer import Tokenizer
class Parser:
def __init__(self, tokens: list[Token]) -> None:
self.tokens = tokens
self.index = 0
@classmethod
def parse_value_definition_grammar(cls, input: str) -> GrammarNode:
parser = cls(Tokenizer.tokenize(input))
value = parser.parse_alternatives()
if not parser.peek().is_token_type(TokenType.END_OF_FILE):
raise SyntaxError("CSSGrammar::Parser: Unexpected trailing input")
return value
def parse_alternatives(self) -> GrammarNode:
children = [self.parse_component_value()]
while self.peek().is_token_type(TokenType.SINGLE_BAR):
self.consume()
children.append(self.parse_component_value())
if len(children) == 1:
return children[0]
return CombinatorGrammarNode(CombinatorType.ALTERNATIVES, children)
def parse_component_value(self) -> GrammarNode:
# https://drafts.csswg.org/css-values-4/#component-multipliers
# FIXME: Support component multipliers
if not self.peek().is_token_type(TokenType.COMPONENT_VALUE):
raise SyntaxError("CSSGrammar::Parser: Expected a component value")
return ComponentValueGrammarNode(self.consume().component_value())
def peek(self, offset: int = 0) -> Token:
index = min(self.index + offset, len(self.tokens) - 1)
return self.tokens[index]
def consume(self) -> Token:
token = self.peek()
if not token.is_token_type(TokenType.END_OF_FILE):
self.index += 1
return token
def parse_value_definition_grammar(input: str) -> GrammarNode:
return Parser.parse_value_definition_grammar(input)

View file

@ -0,0 +1,33 @@
from dataclasses import dataclass
from enum import Enum
from typing import Self
from Utils.CSSGrammar.Parser.component_values import ComponentValue
class TokenType(Enum):
END_OF_FILE = "end-of-file"
SINGLE_BAR = "single-bar"
COMPONENT_VALUE = "component-value"
@dataclass(frozen=True)
class Token:
token_type: TokenType
value: ComponentValue | None
@classmethod
def create(cls, token_type: TokenType) -> Self:
return cls(token_type, None)
@classmethod
def create_component_value(cls, component_value: ComponentValue) -> Self:
return cls(TokenType.COMPONENT_VALUE, component_value)
def is_token_type(self, token_type: TokenType) -> bool:
return self.token_type == token_type
def component_value(self) -> ComponentValue:
assert self.token_type == TokenType.COMPONENT_VALUE and isinstance(self.value, ComponentValue)
return self.value

View file

@ -0,0 +1,59 @@
from Utils.CSSGrammar.Parser.component_values import Type
from Utils.CSSGrammar.Parser.token import Token
from Utils.CSSGrammar.Parser.token import TokenType
from Utils.lexer import Lexer
def is_identifier_character(ch: str) -> bool:
return ch.isascii() and (ch.isalnum() or ch == "-")
class Tokenizer:
def __init__(self, input: str) -> None:
self.lexer = Lexer(input)
@classmethod
def tokenize(cls, input: str) -> list[Token]:
return cls(input).tokenize_impl()
def tokenize_impl(self) -> list[Token]:
tokens = []
while True:
self.discard_whitespace()
if self.lexer.is_eof():
tokens.append(Token.create(TokenType.END_OF_FILE))
return tokens
tokens.append(self.consume_a_token())
def discard_whitespace(self) -> None:
self.lexer.ignore_while(lambda ch: ch.isspace() and ch.isascii())
def consume_an_identifier(self) -> str:
return self.lexer.consume_while(is_identifier_character)
def consume_a_token(self) -> Token:
match self.lexer.peek():
case "|":
self.lexer.consume()
return Token.create(TokenType.SINGLE_BAR)
case "<":
return self.consume_a_non_terminal_token()
raise SyntaxError("CSSGrammar::Tokenizer: Unexpected character")
def consume_a_non_terminal_token(self) -> Token:
assert self.lexer.consume_specific_char("<")
name = self.consume_an_identifier()
if not name:
raise SyntaxError("CSSGrammar::Tokenizer: Expected a type name")
# FIXME: Support custom-ident blacklist notation (i.e. <custom-ident ![foo, bar]>)
# FIXME: Support numeric data type bracketed range notations (i.e. <integer [0,10]>)
if not self.lexer.consume_specific_char(">"):
raise SyntaxError("CSSGrammar::Tokenizer: Expected '>'")
return Token.create_component_value(Type(name))

View file

@ -0,0 +1,72 @@
from typing import TextIO
from Utils.CSSGrammar.Parser.component_values import Type
from Utils.CSSGrammar.Parser.grammar_node import CombinatorGrammarNode
from Utils.CSSGrammar.Parser.grammar_node import CombinatorType
from Utils.CSSGrammar.Parser.grammar_node import ComponentValueGrammarNode
from Utils.CSSGrammar.Parser.grammar_node import GrammarNode
from Utils.CSSGrammar.Parser.parser import parse_value_definition_grammar
from Utils.utils import snake_casify
def generate_css_parser_expression_for_type_component_value(out: TextIO, cpp_name: str, type: Type) -> None:
type_name = snake_casify(type.name)
out.write(f"auto {cpp_name} = parse_{type_name}_value(tokens);\n")
def generate_css_parser_expression_for_component_value_grammar_node(
out: TextIO, cpp_name: str, grammar_node: ComponentValueGrammarNode
) -> None:
match grammar_node.component_value:
case Type() as type_component_value:
generate_css_parser_expression_for_type_component_value(out, cpp_name, type_component_value)
return
raise TypeError(f"Unhandled component value type: {type(grammar_node.component_value).__name__}")
def generate_css_parser_expression_for_alternatives(
out: TextIO, cpp_name: str, alternatives: list[GrammarNode]
) -> None:
out.write(f"auto const parse_{cpp_name}_alternatives = [&]() -> RefPtr<StyleValue const> {{\n")
for i, alternative in enumerate(alternatives):
alternative_name = f"{cpp_name}_alternative_{i}"
generate_css_parser_expression_for_grammar_node(out, alternative_name, alternative)
out.write(f"""if ({alternative_name})
return {alternative_name};
""")
out.write(f"""return nullptr;
}};
auto {cpp_name} = parse_{cpp_name}_alternatives();
""")
def generate_css_parser_expression_for_combinator_grammar_node(
out: TextIO, cpp_name: str, grammar_node: CombinatorGrammarNode
) -> None:
match grammar_node.combinator_type:
case CombinatorType.ALTERNATIVES:
generate_css_parser_expression_for_alternatives(out, cpp_name, grammar_node.children)
return
raise TypeError(f"Unhandled combinator type: {grammar_node.combinator_type}")
def generate_css_parser_expression_for_grammar_node(out: TextIO, cpp_name: str, grammar_node: GrammarNode) -> None:
match grammar_node:
case ComponentValueGrammarNode():
generate_css_parser_expression_for_component_value_grammar_node(out, cpp_name, grammar_node)
return
case CombinatorGrammarNode():
generate_css_parser_expression_for_combinator_grammar_node(out, cpp_name, grammar_node)
return
raise TypeError(f"Unhandled grammar node type: {type(grammar_node).__name__}")
def generate_css_parser_expression_for_grammar(out: TextIO, cpp_name: str, grammar: str) -> None:
generate_css_parser_expression_for_grammar_node(out, cpp_name, parse_value_definition_grammar(grammar))

View file

@ -17,6 +17,7 @@ add_subdirectory(LibUnicode)
add_subdirectory(LibURL)
add_subdirectory(LibWasm)
add_subdirectory(LibXML)
add_subdirectory(Meta)
if (ENABLE_GUI_TARGETS)
add_subdirectory(LibGfx)

10
Tests/Meta/CMakeLists.txt Normal file
View file

@ -0,0 +1,10 @@
if (NOT WIN32)
add_custom_target(test-css-grammar-parser ALL DEPENDS "${CMAKE_BINARY_DIR}/bin/test-css-grammar-parser.py")
add_custom_command(
OUTPUT "${CMAKE_BINARY_DIR}/bin/test-css-grammar-parser.py"
COMMAND "${CMAKE_COMMAND}" -E copy "${ladybird_SOURCE_DIR}/Tests/Meta/test-css-grammar-parser.py" "${CMAKE_BINARY_DIR}/bin/test-css-grammar-parser.py"
DEPENDS "${ladybird_SOURCE_DIR}/Tests/Meta/test-css-grammar-parser.py"
)
add_test(NAME test-css-grammar-parser COMMAND "${Python3_EXECUTABLE}" "${CMAKE_BINARY_DIR}/bin/test-css-grammar-parser.py")
set_tests_properties(test-css-grammar-parser PROPERTIES ENVIRONMENT LADYBIRD_SOURCE_DIR=${LADYBIRD_SOURCE_DIR})
endif()

View file

@ -0,0 +1,77 @@
import os
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(os.environ["LADYBIRD_SOURCE_DIR"]) / "Meta"))
from Utils.CSSGrammar.Parser.parser import parse_value_definition_grammar
class TestCSSGrammarParser(unittest.TestCase):
def test_parse_type_reference(self) -> None:
syntax = parse_value_definition_grammar("<foo>")
self.assertEqual(
syntax.dump(),
"""ComponentValue
Type: foo
""",
)
def test_parse_hyphenated_type_reference(self) -> None:
syntax = parse_value_definition_grammar("<foo-bar>")
self.assertEqual(
syntax.dump(),
"""ComponentValue
Type: foo-bar
""",
)
def test_parse_alternatives(self) -> None:
syntax = parse_value_definition_grammar("<foo> | <bar> | <baz>")
self.assertEqual(
syntax.dump(),
"""Combinator(Alternatives):
ComponentValue
Type: foo
ComponentValue
Type: bar
ComponentValue
Type: baz
""",
)
def test_parse_ignores_whitespace_around_tokens(self) -> None:
syntax = parse_value_definition_grammar(" <foo>\t|\n<bar> ")
self.assertEqual(
syntax.dump(),
"""Combinator(Alternatives):
ComponentValue
Type: foo
ComponentValue
Type: bar
""",
)
def test_reject_empty_input(self) -> None:
with self.assertRaises(SyntaxError):
parse_value_definition_grammar("")
def test_reject_standalone_bar(self) -> None:
with self.assertRaises(SyntaxError):
parse_value_definition_grammar("|")
def test_reject_trailing_bar(self) -> None:
with self.assertRaises(SyntaxError):
parse_value_definition_grammar("<foo> |")
def test_reject_invalid_type_reference(self) -> None:
for value in ("<>", "<foo", "<foo/auto>"):
with self.subTest(value=value):
with self.assertRaises(SyntaxError):
parse_value_definition_grammar(value)
if __name__ == "__main__":
unittest.main()