Meta: Handle bracketed range notations in CSS value grammar

This commit is contained in:
Callum Law 2026-04-26 13:56:37 +12:00 committed by Sam Atkins
parent 521beb5a96
commit 0d75d66f2e
4 changed files with 230 additions and 3 deletions

View file

@ -1,11 +1,58 @@
from dataclasses import dataclass
from math import inf
from typing import Union
@dataclass(frozen=True)
class NumericTypeRangeRestriction:
minimum: float
maximum: float
def dump(self) -> str:
return f"[{bound_value_to_string(self.minimum)},{bound_value_to_string(self.maximum)}]"
def bound_value_to_string(value: float) -> str:
if value == -inf:
return "-∞"
if value == inf:
return ""
return str(value)
def is_dimension_type(type_name: str) -> bool:
# NB: Keep this up to date with the list of dimensions in Units.json
return type_name in (
"angle",
"decibel",
"flex",
"frequency",
"length",
"resolution",
"time",
)
def is_dimension_percentage_mix_type(type_name: str) -> bool:
# https://drafts.csswg.org/css-values-4/#mixed-percentages
return type_name in (
"angle-percentage",
"frequency-percentage",
"length-percentage",
"time-percentage",
)
def is_numeric_type(type_name: str) -> bool:
# https://drafts.csswg.org/css-values-4/#numeric-data-types
return type_name in ("integer", "number", "percentage") or is_dimension_type(type_name)
@dataclass(frozen=True)
class Type:
name: str
custom_ident_blacklist: list[str] | None
numeric_type_accepted_range: NumericTypeRangeRestriction | None
def dump(self, indent: int) -> str:
output = f"{'': >{indent}}Type: {self.name}"
@ -13,6 +60,9 @@ class Type:
if self.custom_ident_blacklist:
output += f" ![{', '.join(self.custom_ident_blacklist)}]"
if self.numeric_type_accepted_range:
output += f" {self.numeric_type_accepted_range.dump()}"
return output + "\n"

View file

@ -1,5 +1,11 @@
from math import inf
from Utils.CSSGrammar.Parser.component_values import Keyword
from Utils.CSSGrammar.Parser.component_values import NumericTypeRangeRestriction
from Utils.CSSGrammar.Parser.component_values import Type
from Utils.CSSGrammar.Parser.component_values import is_dimension_percentage_mix_type
from Utils.CSSGrammar.Parser.component_values import is_dimension_type
from Utils.CSSGrammar.Parser.component_values import is_numeric_type
from Utils.CSSGrammar.Parser.token import Token
from Utils.CSSGrammar.Parser.token import TokenType
from Utils.lexer import Lexer
@ -74,6 +80,67 @@ class Tokenizer:
if not self.lexer.consume_specific_char(","):
raise SyntaxError("Expected ',' in custom-ident blacklist")
# https://drafts.csswg.org/css-values-4/#css-bracketed-range-notation
def consume_bracketed_range_notation(self, type_name: str) -> NumericTypeRangeRestriction:
self.discard_whitespace()
# If no range is indicated, either by using the bracketed range notation or in the property description, then
# [-∞,∞] is assumed.
if not self.lexer.consume_specific_char("["):
return NumericTypeRangeRestriction(-inf, inf)
self.discard_whitespace()
minimum = self.consume_bracketed_range_bound(type_name)
self.discard_whitespace()
if not self.lexer.consume_specific_char(","):
raise SyntaxError("Expected ',' in bracketed range notation")
self.discard_whitespace()
maximum = self.consume_bracketed_range_bound(type_name)
self.discard_whitespace()
if not self.lexer.consume_specific_char("]"):
raise SyntaxError("Expected ']' to close bracketed range notation")
return NumericTypeRangeRestriction(minimum, maximum)
def consume_bracketed_range_bound(self, type_name: str) -> float:
# Values of -∞ or ∞ must be written without units, even if the value type uses units.
if self.lexer.consume_specific_string("-∞"):
return -inf
if self.lexer.consume_specific_string(""):
return inf
# FIXME: Do we need to allow non-integer values?
bound_value = self.consume_decimal_integer()
if bound_value != 0 and (is_dimension_percentage_mix_type(type_name) or type_name == "length"):
raise SyntaxError("Types with units not resolvable at parse time only support zero and infinite bounds")
# FIXME: Validate and store the unit, for now we drop it and assume it was the relevant canonical unit.
unit = self.lexer.consume_while(is_identifier_character)
if unit and not is_dimension_type(type_name) and not is_dimension_percentage_mix_type(type_name):
raise SyntaxError("Unexpected unit for unitless bound value")
if not unit and bound_value != 0 and is_dimension_type(type_name):
raise SyntaxError("Expected unit for non-zero, non-infinite bound value")
return float(bound_value)
def consume_decimal_integer(self) -> int:
sign = 1
if self.lexer.consume_specific_char("-"):
sign = -1
digits = self.lexer.consume_while(lambda ch: ch.isdigit())
if not digits:
raise SyntaxError("Expected decimal integer")
return sign * int(digits)
def consume_a_non_terminal_token(self) -> Token:
assert self.lexer.consume_specific_char("<")
@ -86,11 +153,14 @@ class Tokenizer:
if name == "custom-ident":
custom_ident_blacklist = self.consume_custom_ident_blacklist()
# FIXME: Support numeric data type bracketed range notations (i.e. <integer [0,10]>)
numeric_type_accepted_range = None
if is_numeric_type(name) or is_dimension_percentage_mix_type(name):
numeric_type_accepted_range = self.consume_bracketed_range_notation(name)
if not self.lexer.consume_specific_char(">"):
raise SyntaxError("CSSGrammar::Tokenizer: Expected '>'")
return Token.create_component_value(Type(name, custom_ident_blacklist))
return Token.create_component_value(Type(name, custom_ident_blacklist, numeric_type_accepted_range))
def consume_a_keyword_token(self) -> Token:
value = self.consume_an_identifier()

View file

@ -1,7 +1,9 @@
from math import inf
from typing import TextIO
from Utils.CSSGrammar.Parser.component_values import Keyword
from Utils.CSSGrammar.Parser.component_values import Type
from Utils.CSSGrammar.Parser.component_values import is_dimension_percentage_mix_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
@ -11,6 +13,14 @@ from Utils.utils import snake_casify
from Utils.utils import title_casify
def bound_value_to_code(value: float, type_name: str) -> str:
if value == -inf:
return "AK::NumericLimits<i32>::min()" if type_name == "integer" else "AK::NumericLimits<float>::lowest()"
if value == inf:
return "AK::NumericLimits<i32>::max()" if type_name == "integer" else "AK::NumericLimits<float>::max()"
return str(value)
def generate_css_parser_expression_for_type_component_value(out: TextIO, cpp_name: str, type: Type) -> None:
type_name = snake_casify(type.name)
@ -24,6 +34,17 @@ def generate_css_parser_expression_for_type_component_value(out: TextIO, cpp_nam
additional_arguments += "}"
if type.numeric_type_accepted_range is not None:
minimum = bound_value_to_code(type.numeric_type_accepted_range.minimum, type_name)
maximum = bound_value_to_code(type.numeric_type_accepted_range.maximum, type_name)
accepted_range = f", {{ {minimum}, {maximum} }}"
additional_arguments += accepted_range
# NB: Pass the accepted range twice for dimension-percentage mixes, once for the dimension and once for the percentage.
if is_dimension_percentage_mix_type(type.name):
additional_arguments += accepted_range
out.write(f"auto {cpp_name} = parse_{type_name}_value(tokens{additional_arguments});\n")

View file

@ -37,6 +37,39 @@ class TestCSSGrammarParser(unittest.TestCase):
""",
)
def test_parse_unitless_numeric_type_bracketed_range_notation(self) -> None:
for value, expected in (
("<number [1,∞]>", "number [1.0,∞]"),
("<integer [1,∞]>", "integer [1.0,∞]"),
("<percentage [1,100]>", "percentage [1.0,100.0]"),
):
with self.subTest(value=value):
syntax = parse_value_definition_grammar(value)
self.assertEqual(
syntax.dump(),
f"""ComponentValue
Type: {expected}
""",
)
def test_parse_type_percentage_bracketed_range_notation(self) -> None:
syntax = parse_value_definition_grammar("<length-percentage [0,∞]>")
self.assertEqual(
syntax.dump(),
"""ComponentValue
Type: length-percentage [0.0,]
""",
)
def test_parse_numeric_type_without_bracketed_range_notation_defaults_to_infinite_range(self) -> None:
syntax = parse_value_definition_grammar("<number>")
self.assertEqual(
syntax.dump(),
"""ComponentValue
Type: number [-,]
""",
)
def test_parse_keyword(self) -> None:
syntax = parse_value_definition_grammar("auto")
self.assertEqual(
@ -65,7 +98,7 @@ class TestCSSGrammarParser(unittest.TestCase):
ComponentValue
Keyword: none
ComponentValue
Type: length
Type: length [-,]
""",
)
@ -122,6 +155,59 @@ class TestCSSGrammarParser(unittest.TestCase):
with self.assertRaises(SyntaxError):
parse_value_definition_grammar(value)
def test_reject_invalid_bracketed_range_notation(self) -> None:
for value in (
"<number [0 ∞]>",
"<number [0,∞>",
"<number [,∞]>",
"<number [1,]>",
"<number [1]>",
):
with self.subTest(value=value):
with self.assertRaises(SyntaxError):
parse_value_definition_grammar(value)
def test_reject_bracketed_range_notation_on_non_numeric_data_types(self) -> None:
for value in (
"<color [0,∞]>",
"<custom-ident [0,∞]>",
):
with self.subTest(value=value):
with self.assertRaises(SyntaxError):
parse_value_definition_grammar(value)
def test_reject_units_on_unitless_bracketed_range_bounds(self) -> None:
for value in (
"<number [1px,∞]>",
"<integer [1px,∞]>",
"<percentage [1px,100px]>",
):
with self.subTest(value=value):
with self.assertRaises(SyntaxError):
parse_value_definition_grammar(value)
def test_reject_units_on_infinite_bracketed_range_bounds(self) -> None:
for value in (
"<length [0,∞px]>",
"<length [-∞px,0]>",
):
with self.subTest(value=value):
with self.assertRaises(SyntaxError):
parse_value_definition_grammar(value)
def test_reject_non_zero_non_infinite_bounds_on_types_with_units_not_resolvable_at_parse_time(self) -> None:
for value in (
"<length [1px,10px]>",
"<angle-percentage [1deg,360deg]>",
):
with self.subTest(value=value):
with self.assertRaises(SyntaxError):
parse_value_definition_grammar(value)
def test_unit_required_for_non_zero_non_infinite_bounds(self) -> None:
with self.assertRaises(SyntaxError):
parse_value_definition_grammar("<angle [-90,90]>")
if __name__ == "__main__":
unittest.main()