diff --git a/Meta/Generators/generate_libweb_css_value_types_parsing.py b/Meta/Generators/generate_libweb_css_value_types_parsing.py index 265def60b6..17d8502ec5 100644 --- a/Meta/Generators/generate_libweb_css_value_types_parsing.py +++ b/Meta/Generators/generate_libweb_css_value_types_parsing.py @@ -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 Parser::parse_{name_snake_case}_value(TokenStream& 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}; }} """) diff --git a/Meta/Utils/CSSGrammar/Parser/component_values.py b/Meta/Utils/CSSGrammar/Parser/component_values.py new file mode 100644 index 0000000000..45d8d87c77 --- /dev/null +++ b/Meta/Utils/CSSGrammar/Parser/component_values.py @@ -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] diff --git a/Meta/Utils/CSSGrammar/Parser/grammar_node.py b/Meta/Utils/CSSGrammar/Parser/grammar_node.py new file mode 100644 index 0000000000..b94798859e --- /dev/null +++ b/Meta/Utils/CSSGrammar/Parser/grammar_node.py @@ -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 diff --git a/Meta/Utils/CSSGrammar/Parser/parser.py b/Meta/Utils/CSSGrammar/Parser/parser.py new file mode 100644 index 0000000000..db14802dda --- /dev/null +++ b/Meta/Utils/CSSGrammar/Parser/parser.py @@ -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) diff --git a/Meta/Utils/CSSGrammar/Parser/token.py b/Meta/Utils/CSSGrammar/Parser/token.py new file mode 100644 index 0000000000..741943b8da --- /dev/null +++ b/Meta/Utils/CSSGrammar/Parser/token.py @@ -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 diff --git a/Meta/Utils/CSSGrammar/Parser/tokenizer.py b/Meta/Utils/CSSGrammar/Parser/tokenizer.py new file mode 100644 index 0000000000..9f2712e4ed --- /dev/null +++ b/Meta/Utils/CSSGrammar/Parser/tokenizer.py @@ -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. ) + # FIXME: Support numeric data type bracketed range notations (i.e. ) + if not self.lexer.consume_specific_char(">"): + raise SyntaxError("CSSGrammar::Tokenizer: Expected '>'") + + return Token.create_component_value(Type(name)) diff --git a/Meta/Utils/CSSGrammar/generator.py b/Meta/Utils/CSSGrammar/generator.py new file mode 100644 index 0000000000..42513b6615 --- /dev/null +++ b/Meta/Utils/CSSGrammar/generator.py @@ -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 {{\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)) diff --git a/Tests/CMakeLists.txt b/Tests/CMakeLists.txt index 078d031491..b02bec3341 100644 --- a/Tests/CMakeLists.txt +++ b/Tests/CMakeLists.txt @@ -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) diff --git a/Tests/Meta/CMakeLists.txt b/Tests/Meta/CMakeLists.txt new file mode 100644 index 0000000000..12a5de775e --- /dev/null +++ b/Tests/Meta/CMakeLists.txt @@ -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() diff --git a/Tests/Meta/test-css-grammar-parser.py b/Tests/Meta/test-css-grammar-parser.py new file mode 100644 index 0000000000..6d1c4df1c0 --- /dev/null +++ b/Tests/Meta/test-css-grammar-parser.py @@ -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("") + self.assertEqual( + syntax.dump(), + """ComponentValue + Type: foo +""", + ) + + def test_parse_hyphenated_type_reference(self) -> None: + syntax = parse_value_definition_grammar("") + self.assertEqual( + syntax.dump(), + """ComponentValue + Type: foo-bar +""", + ) + + def test_parse_alternatives(self) -> None: + syntax = parse_value_definition_grammar(" | | ") + 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(" \t|\n ") + 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(" |") + + def test_reject_invalid_type_reference(self) -> None: + for value in ("<>", ""): + with self.subTest(value=value): + with self.assertRaises(SyntaxError): + parse_value_definition_grammar(value) + + +if __name__ == "__main__": + unittest.main()