From 6ee3d3d00a0ff8f76692b783d97a0adcfafd9645 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:51:53 +0000 Subject: [PATCH] fix: parse nested boolean groups in @supports and style() into child nodes A parenthesized group that is not a single `(property: value)` declaration, such as `((a: 1) and (b: 2))`, was a leaf SupportsQuery with no children. `style((--a: 1) and (--b: 2))` was a leaf Function. Both now hold the same flat condition nodes (SupportsQuery / Operator / Function) as an `@supports` prelude, matching value-position `supports()`/`style()`. Only children are added: text, value, start and end of the outer nodes are unchanged, and simple declarations keep their existing shape. `SupportsQuery` children are now typed `SupportsDeclaration | SupportsQuery | PreludeOperator | Function`. Co-Authored-By: Claude Sonnet 5.5 --- src/node-types.ts | 4 +- src/parse-atrule-prelude.test.ts | 147 +++++++++++++++++++++++++++++++ src/parse-condition.ts | 43 ++++++++- 3 files changed, 191 insertions(+), 3 deletions(-) diff --git a/src/node-types.ts b/src/node-types.ts index 419cf06..b3e8ada 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -576,7 +576,9 @@ export type ContainerQuery = WithClone< export type SupportsQuery = WithClone< CSSNode & - WithChildren & { + // A simple `(property: value)` holds a SupportsDeclaration; a nested boolean group such as + // `((a: 1) and (b: 2))` holds the same flat condition nodes as an `@supports` prelude + WithChildren & { readonly type: typeof SUPPORTS_QUERY readonly type_name: 'SupportsQuery' /** The supports condition text, e.g. "display: flex" from "supports(display: flex)" */ diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 6dafe93..813fc20 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -947,6 +947,41 @@ describe('At-Rule Prelude Nodes', () => { expect(fn.children[0].type).toBe(SUPPORTS_DECLARATION) }) + test('should parse a boolean group inside style() into child nodes', () => { + const css = '@container style((--a: 1) and (--b: 2)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const query = (atRule.prelude as AtrulePrelude).children[0] as ContainerQuery + const fn = query.children[0] as Function + + expect(fn.type).toBe(FUNCTION) + // Only children are added: text and value are unchanged + expect(fn.text).toBe('style((--a: 1) and (--b: 2))') + expect(fn.value).toBe('(--a: 1) and (--b: 2)') + expect(fn.children.map((c) => c.type)).toEqual([ + SUPPORTS_QUERY, + PRELUDE_OPERATOR, + SUPPORTS_QUERY, + ]) + expect(fn.children.map((c) => c.text)).toEqual(['(--a: 1)', 'and', '(--b: 2)']) + expect(fn.children[0].first_child?.type).toBe(SUPPORTS_DECLARATION) + expect(fn.children[2].first_child?.type).toBe(SUPPORTS_DECLARATION) + }) + + test('should parse a named container with style(not (...)) into child nodes', () => { + const css = '@container name style(not (--a: 1)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const query = (atRule.prelude as AtrulePrelude).children[0] as ContainerQuery + const fn = query.children[1] as Function + + expect(fn.type).toBe(FUNCTION) + expect(fn.text).toBe('style(not (--a: 1))') + expect(fn.children.map((c) => c.type)).toEqual([PRELUDE_OPERATOR, SUPPORTS_QUERY]) + expect(fn.children[1].text).toBe('(--a: 1)') + expect(fn.children[1].first_child?.type).toBe(SUPPORTS_DECLARATION) + }) + test('should parse container query with name only (no conditions)', () => { // Container conditions are optional when a container name is specified // See https://bugzilla.mozilla.org/show_bug.cgi?id=2016474 @@ -1111,6 +1146,118 @@ describe('At-Rule Prelude Nodes', () => { expect(children[1].text).toBe('selector([popover]:open)') }) + test('should parse a nested boolean group into child nodes', () => { + const css = '@supports ((display: grid) and (display: flex)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children + const outer = children[0] as SupportsQuery + + expect(children.length).toBe(1) + expect(outer.type).toBe(SUPPORTS_QUERY) + // Only children are added: text and value are unchanged + expect(outer.text).toBe('((display: grid) and (display: flex))') + expect(outer.value).toBe('(display: grid) and (display: flex)') + expect(outer.children.map((c) => c.type)).toEqual([ + SUPPORTS_QUERY, + PRELUDE_OPERATOR, + SUPPORTS_QUERY, + ]) + expect(outer.children.map((c) => c.text)).toEqual([ + '(display: grid)', + 'and', + '(display: flex)', + ]) + expect(outer.children[0].first_child?.type).toBe(SUPPORTS_DECLARATION) + expect(outer.children[2].first_child?.type).toBe(SUPPORTS_DECLARATION) + }) + + test('should parse "not" before a nested boolean group', () => { + const css = '@supports not ((a: 1) or (b: 2)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children + + expect(children.map((c) => c.type)).toEqual([PRELUDE_OPERATOR, SUPPORTS_QUERY]) + const group = children[1] as SupportsQuery + expect(group.text).toBe('((a: 1) or (b: 2))') + expect(group.children.map((c) => c.text)).toEqual(['(a: 1)', 'or', '(b: 2)']) + }) + + test('should parse three levels of nested boolean groups', () => { + const css = '@supports (((a: 1) and (b: 2)) or (c: 3)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const outer = (atRule.prelude as AtrulePrelude).children[0] as SupportsQuery + + expect(outer.children.map((c) => c.text)).toEqual(['((a: 1) and (b: 2))', 'or', '(c: 3)']) + const middle = outer.children[0] as SupportsQuery + expect(middle.type).toBe(SUPPORTS_QUERY) + expect(middle.children.map((c) => c.text)).toEqual(['(a: 1)', 'and', '(b: 2)']) + expect(middle.children[0].first_child?.type).toBe(SUPPORTS_DECLARATION) + expect(outer.children[2].first_child?.type).toBe(SUPPORTS_DECLARATION) + }) + + test('should parse a function condition inside a nested group', () => { + const css = '@supports ((selector(:hover)) and (display: grid)) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const outer = (atRule.prelude as AtrulePrelude).children[0] as SupportsQuery + + expect(outer.children.map((c) => c.type)).toEqual([ + SUPPORTS_QUERY, + PRELUDE_OPERATOR, + SUPPORTS_QUERY, + ]) + const fn = outer.children[0].first_child as Function + expect(fn.type).toBe(FUNCTION) + expect(fn.text).toBe('selector(:hover)') + expect(fn.first_child?.type).toBe(SELECTOR_LIST) + }) + + test('should keep a simple declaration group as SupportsQuery > SupportsDeclaration', () => { + for (const css of ['@supports (display: grid) { }', '@supports not (display: grid) { }']) { + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const children = (atRule.prelude as AtrulePrelude).children + const query = children.at(-1) as SupportsQuery + + expect(query.text).toBe('(display: grid)') + expect(query.child_count).toBe(1) + expect(query.first_child?.type).toBe(SUPPORTS_DECLARATION) + } + }) + + test('should keep a non-nested style() argument as a single SupportsDeclaration', () => { + const css = '@supports style(--custom: 1) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const fn = (atRule.prelude as AtrulePrelude).children[0] as Function + + expect(fn.child_count).toBe(1) + expect(fn.first_child?.type).toBe(SUPPORTS_DECLARATION) + }) + + test('should slice nested nodes back to the right source substrings', () => { + const css = '@supports (\n (a: 1) and /* c */\n ((b: 2) or (c: 3))\n) { }' + const ast = parse(css) + const atRule = ast.first_child! as Atrule + const outer = (atRule.prelude as AtrulePrelude).children[0] as SupportsQuery + + type Sliceable = { start: number; end: number; text: string; children?: Sliceable[] } + const check = (node: Sliceable) => { + expect(css.slice(node.start, node.end)).toBe(node.text) + for (const child of node.children ?? []) check(child) + } + check(outer) + expect(outer.children.map((c) => c.text)).toEqual(['(a: 1)', 'and', '((b: 2) or (c: 3))']) + expect((outer.children[2] as SupportsQuery).children.map((c) => c.text)).toEqual([ + '(b: 2)', + 'or', + '(c: 3)', + ]) + }) + test('should have a SupportsDeclaration as direct child of SupportsQuery', () => { const css = '@supports (display: flex) { }' const ast = parse(css) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 493321e..de57e34 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -428,6 +428,8 @@ export class ConditionParser { const func_name_start = this.lexer.token_start const func_name_end = this.lexer.token_end - 1 // -1 to exclude '(' const content_start = this.lexer.token_end // After '(' + const content_line = this.lexer.line + const content_column = this.lexer.column // Find matching closing paren this.scan_matching_paren() @@ -458,7 +460,16 @@ export class ConditionParser { // style()'s argument is a — parse it into the same tree as (property: value) else if (str_equals_range(this.source, func_name_start, func_name_end, 'style')) { const decl_child = this.parse_supports_declaration_content(content_start, content_end) - if (decl_child !== null) { + if (decl_child === null) { + // Not a plain declaration: a boolean condition like `(--a: 1) and (--b: 2)` + this.append_nested_condition( + func_node, + content_start, + content_end, + content_line, + content_column, + ) + } else { this.arena.set_first_child(func_node, decl_child) } } @@ -475,6 +486,23 @@ export class ConditionParser { return this.create_supports_declaration(content_start, content_end, colon_pos) } + // Parse the content of a `style()` or parenthesized group that is not a single declaration as + // a nested and attach the result as `parent`'s children. `line`/`column` + // locate `start`. This re-seeks the lexer, so the position is restored afterwards for callers + // that keep scanning from where they left off. + private append_nested_condition( + parent: number, + start: number, + end: number, + line: number, + column: number, + ): void { + const saved = this.lexer.save_position() + const children = this.parse_supports_condition(start, end, line, column) + this.lexer.restore_position(saved) + this.arena.append_children(parent, children) + } + // Parse a `` — the compound and/or/not grammar shared by `@supports` // preludes and if()'s `supports(...)` condition function: parenthesized `(property: value)` // declarations, `and`/`or`/`not` logical operators, and nested function conditions @@ -499,6 +527,8 @@ export class ConditionParser { if (token_type === TOKEN_LEFT_PAREN) { const feature_start = this.lexer.token_start const content_start = this.lexer.pos + const content_line = this.lexer.line + const content_column = this.lexer.column const matched = this.scan_matching_paren() const content_end = this.paren_content_end @@ -513,7 +543,16 @@ export class ConditionParser { this.arena.set_value_length(query, trimmed[1] - trimmed[0]) const decl_child = this.parse_supports_declaration_content(trimmed[0], trimmed[1]) - if (decl_child !== null) { + if (decl_child === null) { + // A nested boolean group like `((a: 1) and (b: 2))` + this.append_nested_condition( + query, + content_start, + content_end, + content_line, + content_column, + ) + } else { this.arena.set_first_child(query, decl_child) } }