Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/node-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -576,7 +576,9 @@ export type ContainerQuery = WithClone<

export type SupportsQuery = WithClone<
CSSNode &
WithChildren<SupportsDeclaration> & {
// 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<SupportsDeclaration | SupportsQuery | PreludeOperator | Function> & {
readonly type: typeof SUPPORTS_QUERY
readonly type_name: 'SupportsQuery'
/** The supports condition text, e.g. "display: flex" from "supports(display: flex)" */
Expand Down
147 changes: 147 additions & 0 deletions src/parse-atrule-prelude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 41 additions & 2 deletions src/parse-condition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -458,7 +460,16 @@ export class ConditionParser {
// style()'s argument is a <declaration> — 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)
}
}
Expand All @@ -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 <supports-condition> 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 `<supports-condition>` — 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
Expand All @@ -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
Expand All @@ -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)
}
}
Expand Down
Loading