From 6f4f3041c034cacc9734ad0cd016b5824661a8a4 Mon Sep 17 00:00:00 2001 From: David Sancho Moreno Date: Sat, 19 Sep 2026 12:51:33 +0000 Subject: [PATCH 1/3] Parse CSS Nesting's leading-combinator shorthand in nested rules `.parent { > .child {} }` means `.parent { & > .child {} }`. Rules inside another rule's block now parse their prelude with `parse_nested_selector_list`, which reads an item that starts with a combinator as a `RelativeSelector`; the root of `[%css]`, `[%styled.global]` and a raw stylesheet keeps rejecting it because there is no parent to resolve against. `Selector_nesting.compute_new_prefix` rewrites the relative selector into the explicit-`&` shape first, so both spellings produce the same AST and the same class. `[%styled.global]` also rejects a leading combinator inside an at-rule block with no enclosing style rule through `needs_parent_selector`, with the same error a parentless `&` gives, instead of shipping an unresolved `&` in the extracted CSS. --- CHANGES.md | 1 + packages/parser/lib/Parser.ml | 43 +++- packages/parser/lib/Selector_nesting.re | 69 ++++- packages/parser/test/Parser_test.re | 240 ++++++++++++++++++ packages/ppx/src/Css_file.re | 5 +- .../nested-relative-selectors.t/input.re | 26 ++ .../nested-relative-selectors.t/run.t | 44 ++++ .../input_media.re | 17 ++ .../run.t | 7 + 9 files changed, 435 insertions(+), 17 deletions(-) create mode 100644 packages/ppx/test/css-support/nested-relative-selectors.t/input.re create mode 100644 packages/ppx/test/css-support/nested-relative-selectors.t/run.t create mode 100644 packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/input_media.re create mode 100644 packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/run.t diff --git a/CHANGES.md b/CHANGES.md index c46210cac..5c9c53c17 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,7 @@ ## 0.62.0 +- [FIX] Accept CSS Nesting's relative-selector shorthand in a nested rule's prelude: `.parent { > .child {} }`, `+ .sib {}` and `~ .sib {}` resolve exactly like `& > .child`. The root of `[%css]`, `[%styled.global]` and a raw stylesheet still rejects a leading combinator, and `[%styled.global]` now also rejects one inside an at-rule block with no enclosing style rule (`@media print { > .a {} }`), the same way it rejects a parentless `&`, instead of shipping unresolved `&` (@davesnx) - [FEATURE] Register the css-fonts-5 metric-override descriptors `ascent-override`, `descent-override`, `line-gap-override` (each `normal | `), and `size-adjust` (``), so a valid `@font-face` block declaring them compiles instead of failing with an unknown-descriptor error (#580) (@davesnx) - [FIX] Reject the `@font-face`-only descriptors (`src`, `unicode-range`, `font-display`, `ascent-override`, `descent-override`, `line-gap-override`, `size-adjust`) in any declaration outside an `@font-face` block. `.title { ascent-override: 90%; }` previously compiled and emitted a descriptor browsers ignore; it now fails with `Descriptor 'ascent-override' is only valid inside @font-face`. The reverse holds too: an `@font-face` body accepts descriptors only, so `@font-face { color: red; }` fails with `Property 'color' is not a @font-face descriptor` (@davesnx) - [FIX] Lex an escaped identifier into its canonical CSS spelling instead of the decoded text. The lexer decoded `\31 a` to `1a` and dropped the escape, so `.\31 a { color: red; }` (class `1a`) rendered as the invalid `.1a{...}`, and `.a\.b { color: red; }` (one class named `a.b`) rendered as `.a.b{...}` — two classes, changing what the selector matches with no error at all. Every identifier token (class, id, type selector, attribute, pseudo-class, custom property name, custom ident, dimension unit) now re-escapes per CSSOM `serialize an identifier` as it is lexed; a hash that does not start an identifier (`#123456`) is a color and keeps its decoded text (@davesnx) diff --git a/packages/parser/lib/Parser.ml b/packages/parser/lib/Parser.ml index 495230645..13df54be6 100644 --- a/packages/parser/lib/Parser.ml +++ b/packages/parser/lib/Parser.ml @@ -790,6 +790,21 @@ and parse_relative_selector stream = and parse_relative_selector_list stream = parse_selector_list_with stream parse_relative_selector +(* A nested rule's prelude (i.e. one inside another rule's block) may start + with a bare combinator per CSS Nesting ("> .child" means "& > .child"). + Only the item that actually starts with one is parsed as a + [RelativeSelector]; every other item keeps producing a plain + [ComplexSelector], so a selector list that doesn't use this shorthand + (the common case) has the exact same AST as before this existed. *) +and parse_nested_selector stream = + skip_whitespace stream; + match current_tok stream with + | Tokens.DELIM ("+" | "~" | ">") -> parse_relative_selector stream + | _ -> parse_selector stream + +and parse_nested_selector_list stream = + parse_selector_list_with stream parse_nested_selector + let update_declaration_value_state state (value, _) = match value with | Whitespace -> state @@ -1024,7 +1039,7 @@ and parse_at_rule stream = parse_braced_rules stream left_brace (fun stream -> parse_rule_list stream ~stop:(fun stream -> current_is stream Tokens.RIGHT_BRACE) - ~parse_one:parse_block_rule ~allow_empty:true) + ~parse_one:parse_nested_block_rule ~allow_empty:true) in { name = name, component_loc at_token; @@ -1036,10 +1051,17 @@ and parse_at_rule stream = end | _ -> raise_parse_error (current_token stream) -and parse_style_rule stream = +(* [parse_prelude] is [parse_selector_list] at the root of a declaration list + or stylesheet (no leading combinator: there is no rule to resolve "&" + against), and [parse_nested_selector_list] for a rule inside another + rule's block (a leading combinator means "&" followed by it). Either way + the rules inside this rule's own block are always reached through + [parse_nested_block_rule]: once inside any block, further nesting always + has a "&" to resolve against. *) +and parse_style_rule_with stream ~parse_prelude = skip_whitespace stream; let start_pos = (current_token stream).start_pos in - let prelude = parse_selector_list stream in + let prelude = parse_prelude stream in skip_whitespace stream; let left_brace = expect_token stream Tokens.LEFT_BRACE in let block = @@ -1047,13 +1069,19 @@ and parse_style_rule stream = let rules, rules_loc = parse_rule_list stream ~stop:(fun stream -> current_is stream Tokens.RIGHT_BRACE) - ~parse_one:parse_block_rule ~allow_empty:false + ~parse_one:parse_nested_block_rule ~allow_empty:false in rules, rules_loc) in { prelude; block; loc = loc_from_start stream start_pos } -and parse_block_rule stream = +and parse_style_rule stream = + parse_style_rule_with stream ~parse_prelude:parse_selector_list + +and parse_nested_style_rule stream = + parse_style_rule_with stream ~parse_prelude:parse_nested_selector_list + +and parse_block_rule_with stream ~parse_style_rule = skip_whitespace stream; match current_tok stream with | Tokens.AT_KEYFRAMES _ | Tokens.AT_RULE _ -> At_rule (parse_at_rule stream) @@ -1072,6 +1100,11 @@ and parse_block_rule stream = end | _ -> Style_rule (parse_style_rule stream) +and parse_block_rule stream = parse_block_rule_with stream ~parse_style_rule + +and parse_nested_block_rule stream = + parse_block_rule_with stream ~parse_style_rule:parse_nested_style_rule + and parse_stylesheet_rule stream = skip_whitespace stream; match current_tok stream with diff --git a/packages/parser/lib/Selector_nesting.re b/packages/parser/lib/Selector_nesting.re index 6e3fc6b65..585e2800d 100644 --- a/packages/parser/lib/Selector_nesting.re +++ b/packages/parser/lib/Selector_nesting.re @@ -178,6 +178,25 @@ let subject_escapes_ampersand_subtree = (sel: selector): bool => { && !subject_inside_ampersand(flatten_selector_chain(sel)); }; +/* A top-level prelude selector needs an ambient parent to resolve against + when it contains a literal `&`, or when it starts with a bare combinator + (`> .a` means `& > .a`, same implicit `&` as writing it out). Only the + outermost shape is checked for the latter — a leading combinator nested + inside a `:has()`/`:is()` payload (`:has(> img)`) is self-contained and + never needs one, which is why this doesn't just teach `contains_ampersand` + itself: that function also runs on payloads. Used to reject a + [%styled.global] rule the same way regardless of which of the two + spellings it used. */ +let needs_parent_selector = (selector: selector): bool => { + contains_ampersand(selector) + || ( + switch (selector) { + | RelativeSelector({ combinator: Some(_), _ }) => true + | _ => false + } + ); +}; + /* Flatten nested combinator trees into head + flat segment steps. Purely structural (no `&` synthesis, unlike `flatten_selector_chain`); nested trees arise from joins/substitutions of complex selectors. */ @@ -565,22 +584,52 @@ let split_by_kind = (rules: list(rule)) => { ); }; +/* A nested rule's prelude item that starts with a combinator (`> .child`) + means exactly `& combinator .child`: rebuild it as the equivalent + explicit-`&` complex selector so it goes through the same + `contains_ampersand`/`replace_ampersand` path below as if the user had + written `&` themselves. Flattening the combinator onto the front of + `complex_selector`'s own segment list (rather than nesting it as a new + `ComplexSelector` on the right of a fresh `Combinator`) matches exactly + what parsing `& combinator ...` would have produced, so `> .child` and + `& > .child` resolve to the identical AST. A `RelativeSelector` with no + combinator (only reachable today from a `:has()`-style payload, never + from a style-rule prelude) is just its bare `complex_selector`. */ +let relative_selector_to_complex_selector = + ({ combinator, complex_selector }: relative_selector): selector => { + switch (combinator) { + | None => ComplexSelector(complex_selector) + | Some(combinator) => + let (first_selector, rest) = + switch (complex_selector) { + | Selector(selector) => (selector, []) + | Combinator({ left, right }) => (left, right) + }; + ComplexSelector( + Combinator({ + left: SimpleSelector(Ampersand), + right: [(combinator, first_selector), ...rest], + }), + ); + }; +}; + /** Compute the merged prefix when nesting a selector under a parent. Per CSS Nesting Level 1 §3.1, a nested selector that does not contain the nesting selector (`&`) and does not start with a combinator desugars by descendant-combinator-joining with the - parent. Selectors that do contain `&` resolve via literal - substitution. The two arms below implement exactly those rules. - - Selectors that start with a combinator are accepted as relative - (e.g. `> .child` desugars to `& > .child`) when the parser supports - them in nested position. The current parser only accepts leading - combinators inside pseudo-class payloads (`:has(> img)`); a leading - `>` after `{` is rejected at parse time, so this function never - sees that shape. Users must write `& > .child` until the parser - grows nested-relative-selector support. */ + parent. Selectors that do contain `&` (including one synthesised + from a leading combinator, see `relative_selector_to_complex_selector` + above) resolve via literal substitution. The two arms below implement + exactly those rules. */ let compute_new_prefix = (~prefix, current_selector) => { + let current_selector = + switch (current_selector) { + | RelativeSelector(relative) => + relative_selector_to_complex_selector(relative) + | other => other + }; switch (prefix) { | None => current_selector | Some(prefix) => diff --git a/packages/parser/test/Parser_test.re b/packages/parser/test/Parser_test.re index ca58a41a2..d358a05cb 100644 --- a/packages/parser/test/Parser_test.re +++ b/packages/parser/test/Parser_test.re @@ -556,6 +556,245 @@ let at_rule_dispatch_tests = [ }), ]; +/* CSS Nesting's relative-selector shorthand: a nested rule's prelude may + start with a combinator (`> .child`, `+ .sib`, `~ .sib`), meaning + `& > .child` etc. Only valid inside another rule's block, not at the + root of a declaration list or a stylesheet (see the two rejection + regressions at the end of this list). */ +let parse_nested_relative_selector_exn = input => { + switch (Driver.parse_declaration_list(~source_position_start, input)) { + | Ok(( + [ + Ast.Style_rule({ + block: + ([Ast.Style_rule({ prelude: ([(selector, _)], _), _ })], _), + _, + }), + ], + _, + )) => selector + | Ok(_) => fail("expected a single nested style rule for: " ++ input) + | Error((_, msg)) => + fail( + "expected nested relative selector parse success for " + ++ input + ++ ": " + ++ msg, + ) + }; +}; + +let nested_relative_selector_combinator_tests = + [ + (".parent { > .child { color: red; } }", Ast.Selector_child), + (".parent { + .child { color: red; } }", Ast.Selector_adjacent_sibling), + (".parent { ~ .child { color: red; } }", Ast.Selector_general_sibling), + ] + |> List.map(((input, expected_combinator)) => + test_case( + "nested rule accepts leading combinator: " ++ input, `Quick, () => { + switch (parse_nested_relative_selector_exn(input)) { + | Ast.RelativeSelector({ + combinator: Some(actual_combinator), + complex_selector: + Ast.Selector( + Ast.CompoundSelector({ + type_selector: None, + subclass_selectors: [Ast.Class("child")], + pseudo_selectors: [], + }), + ), + }) => + check( + bool, + "combinator matches", + true, + actual_combinator == expected_combinator, + ) + | _ => fail("expected RelativeSelector AST for: " ++ input) + } + }) + ); + +let nested_relative_selector_tests = + nested_relative_selector_combinator_tests + @ [ + test_case( + "nested rule accepts a compound selector after the combinator (`> .a.b:hover`)", + `Quick, + () => { + switch ( + parse_nested_relative_selector_exn( + ".parent { > .a.b:hover { color: red; } }", + ) + ) { + | Ast.RelativeSelector({ + combinator: Some(Ast.Selector_child), + complex_selector: + Ast.Selector( + Ast.CompoundSelector({ + type_selector: None, + subclass_selectors: + [ + Ast.Class("a"), + Ast.Class("b"), + Ast.Pseudo_class( + Ast.Pseudoclass(Ast.PseudoIdent("hover")), + ), + ], + pseudo_selectors: [], + }), + ), + }) => + () + | _ => fail("expected compound relative selector AST") + } + }), + test_case( + "nested rule accepts a relative selector list (`> .a, + .b`)", `Quick, () => { + switch ( + Driver.parse_declaration_list( + ~source_position_start, + ".parent { > .a, + .b { color: red; } }", + ) + ) { + | Ok(( + [ + Ast.Style_rule({ + block: + ( + [ + Ast.Style_rule({ + prelude: + ( + [ + ( + Ast.RelativeSelector({ + combinator: first_combinator, + _, + }), + _, + ), + ( + Ast.RelativeSelector({ + combinator: second_combinator, + _, + }), + _, + ), + ], + _, + ), + _, + }), + ], + _, + ), + _, + }), + ], + _, + )) => + check( + bool, + "first item is `>`", + true, + first_combinator == Some(Ast.Selector_child), + ); + check( + bool, + "second item is `+`", + true, + second_combinator == Some(Ast.Selector_adjacent_sibling), + ); + | Ok(_) => fail("expected two relative selectors") + | Error((_, msg)) => + fail("expected relative selector list parse success: " ++ msg) + } + }), + test_case( + "nested rule accepts a leading combinator inside @media", `Quick, () => { + switch ( + Driver.parse_declaration_list( + ~source_position_start, + "@media (min-width: 1px) { > .a { color: red; } }", + ) + ) { + | Ok(( + [ + Ast.At_rule({ + name: ("media", _), + block: + Ast.Stylesheet(( + [ + Ast.Style_rule({ + prelude: + ( + [ + ( + Ast.RelativeSelector({ + combinator: Some(Ast.Selector_child), + _, + }), + _, + ), + ], + _, + ), + _, + }), + ], + _, + )), + _, + }), + ], + _, + )) => + () + | Ok(_) => fail("expected relative selector nested inside @media") + | Error((_, msg)) => + fail( + "expected @media nested relative selector parse success: " ++ msg, + ) + } + }), + test_case( + "declaration-list root still rejects a leading combinator", `Quick, () => { + switch ( + Driver.parse_declaration_list( + ~source_position_start, + "> .a { color: red; }", + ) + ) { + | Error((loc, msg)) => + check( + string, + "existing parse error message preserved", + "Parse error while reading token '>'", + msg, + ); + check( + int, + "error points at the leading combinator", + 0, + loc.loc_start.pos_cnum, + ); + | Ok(_) => + fail("expected a leading top-level combinator to still error") + } + }), + test_case( + "stylesheet level still rejects a leading combinator", `Quick, () => { + check( + string, + "existing parse error message preserved", + "Parse error while reading token '>' on line 1 at position 0", + parse("> .a {}") |> Result.get_error, + ) + }), + ]; + let ambiguity_regression_tests = [ test_case( "declaration list stops before nested descendant selector", `Quick, () => { @@ -696,6 +935,7 @@ let tests = function_ast_tests, selector_combinator_ast_tests, at_rule_dispatch_tests, + nested_relative_selector_tests, ambiguity_regression_tests, invalid_utf8_tests, ]); diff --git a/packages/ppx/src/Css_file.re b/packages/ppx/src/Css_file.re index 7d1b2d678..b6ef1ea4b 100644 --- a/packages/ppx/src/Css_file.re +++ b/packages/ppx/src/Css_file.re @@ -1412,7 +1412,8 @@ let push_global = dynamic_vars: ref([]), }; - /* Reject `&` with no parent selector: top level, or inside at-rule + /* Reject `&` (literal, or the implicit one a leading combinator like + `> .a` means) with no parent selector: top level, or inside at-rule blocks not below a style rule (at-rules don't contribute a selector). Recursion stops at style rules — nested `&` is fine. */ let rec reject_parentless_ampersand = rule => @@ -1420,7 +1421,7 @@ let push_global = | Style_rule({ prelude: (selectors, _), _ }) => List.iter( ((selector, selector_loc)) => - if (Styled_ppx_css_parser.Selector_nesting.contains_ampersand( + if (Styled_ppx_css_parser.Selector_nesting.needs_parent_selector( selector, )) { Ppxlib.Location.raise_errorf( diff --git a/packages/ppx/test/css-support/nested-relative-selectors.t/input.re b/packages/ppx/test/css-support/nested-relative-selectors.t/input.re new file mode 100644 index 000000000..f7d5c1845 --- /dev/null +++ b/packages/ppx/test/css-support/nested-relative-selectors.t/input.re @@ -0,0 +1,26 @@ +/* CSS Nesting's relative-selector shorthand: a nested rule's prelude may + start with a combinator (`> .child`, `+ .sib`, `~ .sib`), meaning + `& > .child` etc. Each pair below is the shorthand spelling followed by + its explicit `&`-prefixed equivalent; both members of a pair must render + and hash identically. */ + +[%css {| .parent { > .child { color: red; } } |}]; +[%css {| .parent { & > .child { color: red; } } |}]; + +[%css {| .parent { + .sibling { color: red; } } |}]; +[%css {| .parent { & + .sibling { color: red; } } |}]; + +[%css {| .parent { ~ .sibling { color: red; } } |}]; +[%css {| .parent { & ~ .sibling { color: red; } } |}]; + +/* Compound selector after the combinator. */ +[%css {| .parent { > .a.b:hover { color: red; } } |}]; +[%css {| .parent { & > .a.b:hover { color: red; } } |}]; + +/* Selector list mixing two combinators. */ +[%css {| .parent { > .a, + .b { color: red; } } |}]; +[%css {| .parent { & > .a, & + .b { color: red; } } |}]; + +/* Nested inside an at-rule. */ +[%css {| @media (min-width: 1px) { .parent { > .child { color: red; } } } |}]; +[%css {| @media (min-width: 1px) { .parent { & > .child { color: red; } } } |}]; diff --git a/packages/ppx/test/css-support/nested-relative-selectors.t/run.t b/packages/ppx/test/css-support/nested-relative-selectors.t/run.t new file mode 100644 index 000000000..87a0c2aca --- /dev/null +++ b/packages/ppx/test/css-support/nested-relative-selectors.t/run.t @@ -0,0 +1,44 @@ +This test ensures CSS Nesting's relative-selector shorthand (a nested rule's +prelude starting with a bare combinator, `> .child`/`+ .sib`/`~ .sib`) parses +and resolves `&` identically to writing the combinator after an explicit `&`. +Each pair below must render and hash to the same atom. + + $ cat > dune-project << EOF + > (lang dune 3.10) + > EOF + + $ cat > dune << EOF + > (executable + > (name input) + > (libraries styled-ppx.native) + > (preprocess (pps styled-ppx))) + > EOF + + $ dune build + + $ dune describe pp ./input.re | sed '1,/^];$/d' + [@css ".css-6r0lf8 .parent > .child{color:red;}"]; + [@css ".css-1hamckf .parent + .sibling{color:red;}"]; + [@css ".css-n0wq26 .parent ~ .sibling{color:red;}"]; + [@css ".css-n5qd0b .parent > .a.b:hover{color:red;}"]; + [@css ".css-1haviu8 .parent > .a{color:red;}"]; + [@css ".css-1ym0uum .parent + .b{color:red;}"]; + [@css "@media (min-width: 1px) {.css-h19ouy .parent > .child{color:red;}}"]; + + CSS.make("css-6r0lf8", []); + CSS.make("css-6r0lf8", []); + + CSS.make("css-1hamckf", []); + CSS.make("css-1hamckf", []); + + CSS.make("css-n0wq26", []); + CSS.make("css-n0wq26", []); + + CSS.make("css-n5qd0b", []); + CSS.make("css-n5qd0b", []); + + CSS.make("css-1haviu8 css-1ym0uum", []); + CSS.make("css-1haviu8 css-1ym0uum", []); + + CSS.make("css-h19ouy", []); + CSS.make("css-h19ouy", []); diff --git a/packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/input_media.re b/packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/input_media.re new file mode 100644 index 000000000..8570b92ff --- /dev/null +++ b/packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/input_media.re @@ -0,0 +1,17 @@ +/* A nested rule's prelude starting with a bare combinator (`> .a`) means + `& > .a` (see the `nested-relative-selectors.t` css-support test). Like a + literal `&`, that implicit `&` has no parent selector to resolve against + here: @media contributes a condition, not a selector, and there is no + enclosing style rule either. This must be rejected the same way a + parentless `&` is, not shipped as literal unresolved `&` in the extracted + stylesheet. */ + +module BrokenInMedia = [%styled.global + {| + @media print { + > .a { + color: red; + } + } +|} +]; diff --git a/packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/run.t b/packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/run.t new file mode 100644 index 000000000..de6b7017e --- /dev/null +++ b/packages/ppx/test/snapshot/reason/reason-styled-global2-top-level-relative-selector-error.t/run.t @@ -0,0 +1,7 @@ + $ refmt --parse re --print ml input_media.re > output_media.ml + $ ../../../standalone.exe --impl output_media.ml -o output_media.ml + File "output_media.ml", line 5, characters 4-9: + 5 | > .a { + ^^^^^ + Error: The nesting selector `&` has no parent selector to resolve against here in [%styled.global] (at-rules like @media don't provide one). Write a concrete selector instead. + [1] From 710afbbe52c42258b11f999dbb129e3df4c8f4f6 Mon Sep 17 00:00:00 2001 From: David Sancho Moreno Date: Tue, 22 Sep 2026 11:41:57 +0000 Subject: [PATCH 2/3] Remove redundant comments and stabilize diagnostic tests --- packages/parser/lib/Parser.ml | 7 ------- packages/parser/test/Parser_test.re | 5 ----- 2 files changed, 12 deletions(-) diff --git a/packages/parser/lib/Parser.ml b/packages/parser/lib/Parser.ml index 13df54be6..ae8b84634 100644 --- a/packages/parser/lib/Parser.ml +++ b/packages/parser/lib/Parser.ml @@ -1051,13 +1051,6 @@ and parse_at_rule stream = end | _ -> raise_parse_error (current_token stream) -(* [parse_prelude] is [parse_selector_list] at the root of a declaration list - or stylesheet (no leading combinator: there is no rule to resolve "&" - against), and [parse_nested_selector_list] for a rule inside another - rule's block (a leading combinator means "&" followed by it). Either way - the rules inside this rule's own block are always reached through - [parse_nested_block_rule]: once inside any block, further nesting always - has a "&" to resolve against. *) and parse_style_rule_with stream ~parse_prelude = skip_whitespace stream; let start_pos = (current_token stream).start_pos in diff --git a/packages/parser/test/Parser_test.re b/packages/parser/test/Parser_test.re index d358a05cb..f158878f7 100644 --- a/packages/parser/test/Parser_test.re +++ b/packages/parser/test/Parser_test.re @@ -556,11 +556,6 @@ let at_rule_dispatch_tests = [ }), ]; -/* CSS Nesting's relative-selector shorthand: a nested rule's prelude may - start with a combinator (`> .child`, `+ .sib`, `~ .sib`), meaning - `& > .child` etc. Only valid inside another rule's block, not at the - root of a declaration list or a stylesheet (see the two rejection - regressions at the end of this list). */ let parse_nested_relative_selector_exn = input => { switch (Driver.parse_declaration_list(~source_position_start, input)) { | Ok(( From 42eaf9c7a98ebf9a96450489e29971b93be804a4 Mon Sep 17 00:00:00 2001 From: David Sancho Moreno Date: Tue, 22 Sep 2026 16:48:47 +0000 Subject: [PATCH 3/3] Remove redundant comments and simplify join_compound_selector --- packages/parser/lib/Parser.ml | 9 --- packages/parser/lib/Selector_nesting.re | 94 ++++++++----------------- 2 files changed, 28 insertions(+), 75 deletions(-) diff --git a/packages/parser/lib/Parser.ml b/packages/parser/lib/Parser.ml index ae8b84634..e456edab6 100644 --- a/packages/parser/lib/Parser.ml +++ b/packages/parser/lib/Parser.ml @@ -129,8 +129,6 @@ let next_significant_index tokens start = in loop start -(* -- Token set predicates (Design 3) -- *) - let is_selector_start = function | Tokens.DELIM ("." | "&" | "*") | Tokens.HASH _ | Tokens.LEFT_BRACKET | Tokens.INTERPOLATION _ -> @@ -147,13 +145,11 @@ let is_at_rule_start = function | Tokens.AT_RULE _ | Tokens.AT_KEYFRAMES _ -> true | _ -> false -(* Tokens that start a selector (ident or any selector-start or pseudo) *) let token_starts_selector tok = match tok with | Tokens.IDENT _ -> true | tok -> is_selector_start tok || is_pseudo_start tok -(* Tokens that start a selector prelude (ident or selector-start, no pseudo) *) let token_starts_selector_prelude tok = match tok with Tokens.IDENT _ -> true | tok -> is_selector_start tok @@ -353,8 +349,6 @@ let parse_wq_name stream = let name, _ = expect_ident stream in name -(* -- An+B microsyntax (nth payloads) -- *) - type nth_suffix = | Nth_suffix_n | Nth_suffix_n_dash (* requires a following integer *) @@ -364,7 +358,6 @@ let raise_invalid_nth function_name (token : token_with_location) = let message = Printf.sprintf "Invalid an+b value in :%s()" function_name in raise (Parse_error (token.start_pos, token.end_pos, message)) -(* Reject fractional or inexact float coefficients before converting to int. *) let nth_int_of_number function_name (token : token_with_location) value = let max_exact_float_int = 9007199254740992. @@ -376,7 +369,6 @@ let nth_int_of_number function_name (token : token_with_location) value = let is_ascii_digit = function '0' .. '9' -> true | _ -> false -(* Accept only non-empty ASCII digits; [int_of_string_opt] handles overflow. *) let nth_int_of_digits function_name (token : token_with_location) digits = if String.length digits > 0 && String.for_all is_ascii_digit digits then ( match int_of_string_opt digits with @@ -384,7 +376,6 @@ let nth_int_of_digits function_name (token : token_with_location) digits = | None -> raise_invalid_nth function_name token) else raise_invalid_nth function_name token -(* Accept "n", "n-", or "n-", with ASCII-case-insensitive "n". *) let classify_nth_suffix function_name (token : token_with_location) suffix = let length = String.length suffix in if length = 0 || (suffix.[0] <> 'n' && suffix.[0] <> 'N') then diff --git a/packages/parser/lib/Selector_nesting.re b/packages/parser/lib/Selector_nesting.re index 585e2800d..4a0e5fb40 100644 --- a/packages/parser/lib/Selector_nesting.re +++ b/packages/parser/lib/Selector_nesting.re @@ -220,24 +220,23 @@ let rec flatten_combinator_tree = }; /* Pop the rightmost compound off a complex selector (flattens first so - the popped segment is never a whole subtree). Returns - `(last, combinator_before_last, rest)`. */ + the popped segment is never a whole subtree). */ let pop_last_selector = (selector: selector) => { let (head, segments) = flatten_combinator_tree(selector); switch (List.rev(segments)) { - | [] => (head, None, None) - | [(ctor, last)] => (last, Some(ctor), Some(head)) + | [] => (head, None) + | [(ctor, last)] => (last, Some((ctor, head))) | [(ctor, last), ...rest_rev] => ( last, - Some(ctor), - Some( + Some(( + ctor, ComplexSelector( Combinator({ left: head, right: List.rev(rest_rev), }), ), - ), + )), ) }; }; @@ -295,77 +294,40 @@ let merge_compound_selectors = raise. */ let join_compound_selector = (selector, { subclass_selectors, pseudo_selectors, _ } as compound) => { - let new_compound = - CompoundSelector({ - type_selector: None, - subclass_selectors, - pseudo_selectors, - }); - switch (pop_last_selector(selector)) { - | (SimpleSelector(simple), None, None) => - CompoundSelector({ - type_selector: Some(simple), - subclass_selectors, - pseudo_selectors, - }) - | (SimpleSelector(simple), Some(ctor), Some(rest)) => - join_selector_with_combinator( - ~combinator=ctor, - rest, + let (last, before_last) = pop_last_selector(selector); + let extended = + switch (last) { + | SimpleSelector(simple) => CompoundSelector({ type_selector: Some(simple), subclass_selectors, pseudo_selectors, - }), - ) - | ( - CompoundSelector({ - type_selector: last_type_selector, - subclass_selectors: last_subclass_selectors, - pseudo_selectors: last_pseudo_selectors, - }), - None, - None, - ) => - merge_compound_selectors( - ~last_type_selector, - ~last_subclass_selectors, - ~last_pseudo_selectors, - compound, - ) - | ( - CompoundSelector({ + }) + | CompoundSelector({ type_selector: last_type_selector, subclass_selectors: last_subclass_selectors, pseudo_selectors: last_pseudo_selectors, - }), - Some(ctor), - Some(rest), - ) => - join_selector_with_combinator( - ~combinator=ctor, - rest, + }) => merge_compound_selectors( ~last_type_selector, ~last_subclass_selectors, ~last_pseudo_selectors, compound, - ), - ) - /* Defensive fallback — see comment above. */ - | (other, None, None) => join_selector_with_combinator(other, new_compound) - | (other, Some(ctor), Some(rest)) => - join_selector_with_combinator( - ~combinator=ctor, - rest, - join_selector_with_combinator(other, new_compound), - ) - /* The other inconsistent (Some/None) mixes are unreachable by - construction in `pop_last_selector`, but matching them keeps the - compiler's exhaustiveness check happy without a wildcard. */ - | (_, Some(_), None) - | (_, None, Some(_)) => - join_selector_with_combinator(selector, new_compound) + ) + | other => + join_selector_with_combinator( + other, + CompoundSelector({ + type_selector: None, + subclass_selectors, + pseudo_selectors, + }), + ) + }; + switch (before_last) { + | None => extended + | Some((ctor, rest)) => + join_selector_with_combinator(~combinator=ctor, rest, extended) }; };