diff --git a/src/arena.ts b/src/arena.ts index 2fad41c..eda3b94 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -62,6 +62,7 @@ export const PARENTHESIS = 17 // parenthesized expression: (100% - 50px) export const URL = 18 // URL: url("file.css"), url(image.png), used in values and @import export const UNICODE_RANGE = 19 // unicode range: u+0025-00ff, u+4?? export const IF_BRANCH = 59 // Branch inside an if() function: : +export const IF_CONDITION = 60 // Compound if()-branch condition: not style(...), style(...) and media(...), etc. // Selector node type constants (for detailed selector parsing) export const SELECTOR_LIST = 20 // comma-separated selectors diff --git a/src/constants.ts b/src/constants.ts index d073d4a..7400b56 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -21,6 +21,7 @@ import { URL, UNICODE_RANGE, IF_BRANCH, + IF_CONDITION, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -69,6 +70,7 @@ export { URL, UNICODE_RANGE, IF_BRANCH, + IF_CONDITION, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -120,6 +122,7 @@ export const NODE_TYPES = { URL, UNICODE_RANGE, IF_BRANCH, + IF_CONDITION, VALUE, // Selector nodes SELECTOR_LIST, diff --git a/src/css-node.ts b/src/css-node.ts index 0c14215..e2dac57 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -46,6 +46,7 @@ import { SUPPORTS_DECLARATION, RATIO, IF_BRANCH, + IF_CONDITION, FLAG_IMPORTANT, FLAG_HAS_ERROR, FLAG_HAS_BLOCK, @@ -118,6 +119,7 @@ export const TYPE_NAMES = { [PRELUDE_SELECTORLIST]: 'PreludeSelectorList', [RATIO]: 'Ratio', [IF_BRANCH]: 'IfBranch', + [IF_CONDITION]: 'IfCondition', } as const export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown' @@ -169,6 +171,7 @@ export type CSSNodeType = | typeof SUPPORTS_DECLARATION | typeof RATIO | typeof IF_BRANCH + | typeof IF_CONDITION // Options for cloning nodes export interface CloneOptions { @@ -262,6 +265,7 @@ const nodes_with_children = new Set([ SUPPORTS_QUERY, SUPPORTS_DECLARATION, IF_BRANCH, + IF_CONDITION, ]) const enumerable_properties = [ diff --git a/src/index.ts b/src/index.ts index 6d4cf03..015420f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ export { type Url, type UnicodeRange, type IfBranch, + type IfCondition, type Value, type SelectorNode, type TypeSelector, @@ -105,6 +106,7 @@ export { is_url, is_unicode_range, is_if_branch, + is_if_condition, is_value, is_type_selector, is_class_selector, diff --git a/src/node-types.ts b/src/node-types.ts index 5a4adfc..132b9f0 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -37,6 +37,7 @@ import { URL, UNICODE_RANGE, IF_BRANCH, + IF_CONDITION, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -342,24 +343,42 @@ export type Value = WithClone< CSSNode & WithChildren & { readonly type: typeof VALUE; readonly type_name: 'Value' } > +/** + * A compound `if()`-branch condition combining multiple tests with `not`/`and`/`or`, + * e.g. `not style(--scheme: light)` or `style(--a: 1) and media(width > 600px)`. + * + * Children are the flat sequence of PreludeOperator ("not"/"and"/"or") and + * Function (`style()`/`media()`/`supports()`) nodes, in source order — the same + * shape `@supports`'s own compound condition uses. + */ +export type IfCondition = WithClone< + CSSNode & + WithChildren & { + readonly type: typeof IF_CONDITION + readonly type_name: 'IfCondition' + } +> + /** * One branch inside a CSS `if()` inline conditional function. * * Each branch corresponds to a `: ` pair in: * `if( : ; … else: )` * - * - `condition` — the parsed condition node (Function, e.g. `style(--x: 1)`, or Identifier `else`) + * - `condition` — the parsed condition node: a Function (e.g. `style(--x: 1)`), an + * Identifier (`else`), or an IfCondition for compound `not`/`and`/`or` conditions + * (e.g. `not style(--x: 1)`) * - `value` — the value text, e.g. `"green"`; `null` when omitted * - `is_else` — `true` for the `else` branch * - `first_child` — same node as `condition` * - `children` — condition node followed by parsed value nodes */ export type IfBranch = CSSNode & - WithChildren & { + WithChildren & { readonly type: typeof IF_BRANCH readonly type_name: 'IfBranch' - /** The parsed condition node, e.g. the Function "style(--active: 1)" or the Identifier "else" */ - readonly condition: Function | Identifier + /** The parsed condition node, e.g. the Function "style(--active: 1)", the Identifier "else", or an IfCondition for compound conditions */ + readonly condition: Function | Identifier | IfCondition /** The parsed value as a VALUE node, or null when the branch value is empty */ readonly value: Value | null /** True when this is the else branch */ @@ -638,6 +657,7 @@ export type AnyNode = | Url | UnicodeRange | IfBranch + | IfCondition | Value | TypeSelector | ClassSelector @@ -731,6 +751,9 @@ export function is_unicode_range(node: CSSNode): node is UnicodeRange { export function is_if_branch(node: CSSNode): node is IfBranch { return node.type === IF_BRANCH } +export function is_if_condition(node: CSSNode): node is IfCondition { + return node.type === IF_CONDITION +} export function is_value(node: CSSNode): node is Value { return node.type === VALUE } diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index de2ec84..1588c24 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -12,6 +12,7 @@ import { URL, UNICODE_RANGE, IF_BRANCH, + IF_CONDITION, VALUE, DECLARATION, MEDIA_FEATURE, @@ -28,6 +29,7 @@ import type { FeatureRange, Function, IfBranch, + IfCondition, MediaFeature, Number, Operator, @@ -1328,6 +1330,138 @@ describe('Value Node Types', () => { expect((secondDecl.value as Value).text).toBe('1rem') }) + test('style() condition supports the full compound and/or/not grammar', () => { + // MDN if() example: style((--scheme: dark) or (--scheme: very-dark)) + const func = getFunc( + 'div { background-color: if(style((--scheme: dark) or (--scheme: very-dark)): black;) }', + ) + const styleFunc = getBranch(func, 0)?.first_child as Function + expect(styleFunc.name).toBe('style') + // children: SupportsQuery, PreludeOperator("or"), SupportsQuery + expect(styleFunc.children).toHaveLength(3) + + const first = styleFunc.children[0] as SupportsQuery + expect(first.type).toBe(SUPPORTS_QUERY) + expect((first.first_child as SupportsDeclaration).property).toBe('--scheme') + expect(((first.first_child as SupportsDeclaration).value as Value).text).toBe('dark') + + expect(styleFunc.children[1].type).toBe(PRELUDE_OPERATOR) + expect(styleFunc.children[1].text).toBe('or') + + const second = styleFunc.children[2] as SupportsQuery + expect(second.type).toBe(SUPPORTS_QUERY) + expect((second.first_child as SupportsDeclaration).property).toBe('--scheme') + expect(((second.first_child as SupportsDeclaration).value as Value).text).toBe('very-dark') + }) + + test('style() condition supports "and" combining two parenthesized declarations', () => { + // MDN if() example: style((--scheme: dark) and (--contrast: hi)) + const func = getFunc( + 'div { background-color: if(style((--scheme: dark) and (--contrast: hi)): black;) }', + ) + const styleFunc = getBranch(func, 0)?.first_child as Function + expect(styleFunc.children).toHaveLength(3) + expect(styleFunc.children[1].type).toBe(PRELUDE_OPERATOR) + expect(styleFunc.children[1].text).toBe('and') + expect( + ((styleFunc.children[0] as SupportsQuery).first_child as SupportsDeclaration).property, + ).toBe('--scheme') + expect( + ((styleFunc.children[2] as SupportsQuery).first_child as SupportsDeclaration).property, + ).toBe('--contrast') + }) + + // ── Compound if-condition: not/and/or combining test functions ───────── + + describe('compound if-condition (not/and/or)', () => { + test('"not style(...)" produces an IfCondition wrapping Operator + Function', () => { + // The reported bug: parsing dropped style() entirely, keeping only "not". + const func = getFunc('div { background-color: if(not style(--scheme: light): black;) }') + expect(func?.children).toHaveLength(1) + + const b0 = getBranch(func, 0)! + expect(b0.condition.type).toBe(IF_CONDITION) + expect(b0.condition.type_name).toBe('IfCondition') + expect(b0.condition.text).toBe('not style(--scheme: light)') + expect((b0.value as Value).text).toBe('black') + + const condition = b0.condition as IfCondition + expect(condition.children).toHaveLength(2) + expect(condition.children[0].type).toBe(PRELUDE_OPERATOR) + expect(condition.children[0].text).toBe('not') + + const styleFunc = condition.children[1] as Function + expect(styleFunc.type).toBe(FUNCTION) + expect(styleFunc.name).toBe('style') + const decl = styleFunc.children[0] as SupportsDeclaration + expect(decl.property).toBe('--scheme') + expect((decl.value as Value).text).toBe('light') + }) + + test('MDN example: multi-line whitespace and trailing semicolon around "not style(...)"', () => { + const func = getFunc( + 'div { background-color: if(\n\t\t\t\tnot style(--scheme: light): black;\n\t\t\t); }', + ) + const b0 = getBranch(func, 0)! + expect(b0.condition.type_name).toBe('IfCondition') + expect(b0.condition.text).toBe('not style(--scheme: light)') + expect((b0.value as Value).text).toBe('black') + }) + + test('branch.first_child is the IfCondition wrapper for compound conditions', () => { + const func = getFunc('div { color: if(not style(--x: 1): red; else: blue) }') + const b0 = getBranch(func, 0)! + expect(b0.first_child?.type).toBe(IF_CONDITION) + // children: IfCondition, VALUE — same shape as the simple-condition case + expect(b0.children).toHaveLength(2) + expect(b0.children[0].type).toBe(IF_CONDITION) + expect(b0.children[1].type).toBe(VALUE) + }) + + test('"style(...) and media(...)" combines two test functions', () => { + const func = getFunc( + 'div { color: if(style(--dark: 1) and media(min-width: 600px): black; else: white) }', + ) + const b0 = getBranch(func, 0)! + const condition = b0.condition as IfCondition + expect(condition.type).toBe(IF_CONDITION) + expect(condition.children).toHaveLength(3) + + expect((condition.children[0] as Function).name).toBe('style') + expect(condition.children[1].type).toBe(PRELUDE_OPERATOR) + expect(condition.children[1].text).toBe('and') + expect((condition.children[2] as Function).name).toBe('media') + + expect((b0.value as Value).text).toBe('black') + }) + + test('"style(...) or supports(...)" combines two test functions', () => { + const func = getFunc( + 'div { color: if(style(--x: 1) or supports(display: grid): black; else: white) }', + ) + const condition = getBranch(func, 0)?.condition as IfCondition + expect(condition.children).toHaveLength(3) + expect(condition.children[1].text).toBe('or') + }) + + test('a simple single-function condition stays unwrapped (no IfCondition)', () => { + // Guards against regressing the common case while fixing the compound one + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const b0 = getBranch(func, 0)! + expect(b0.condition.type).toBe(FUNCTION) + expect(b0.condition.type_name).toBe('Function') + }) + + test('unterminated "not style(" does not throw or run past the value', () => { + const func = getFunc('div { color: if(not style(--x: 1 }') + expect(func?.name).toBe('if') + const b0 = getBranch(func, 0)! + // style( is unterminated, so the compound condition can't close either — + // falls back to whatever was parsed without throwing. + expect(b0.condition).toBeDefined() + }) + }) + // ── Multiple branches ───────────────────────────────────────────────── test('should parse if() with three branches', () => { diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 339d049..bf77f68 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -15,7 +15,9 @@ import { URL, UNICODE_RANGE, IF_BRANCH, + IF_CONDITION, VALUE, + PRELUDE_OPERATOR, } from './arena' import { TOKEN_IDENT, @@ -33,6 +35,7 @@ import { TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, TOKEN_UNICODE_RANGE, + type TokenType, } from './token-types' import { is_whitespace, @@ -418,16 +421,7 @@ export class ValueNodeParser { let branch_line = this.lexer.token_line let branch_col = this.lexer.token_column - // Condition functions get specialized parsing; identifiers ("else") use generic - let condition_node: number | null - if (tt === TOKEN_FUNCTION) { - condition_node = this.parse_if_condition_function( - this.lexer.token_start, - this.lexer.token_end, - ) - } else { - condition_node = this.parse_value_node() - } + let condition_node = this.parse_if_condition() if (condition_node === null) continue let condition_end_pos = @@ -537,6 +531,122 @@ export class ValueNodeParser { return node } + private is_and_or_not(str: string): boolean { + // All logical operators are 2-3 chars: "and" (3), "or" (2), "not" (3) + return str_equals('and', str) || str_equals('or', str) || str_equals('not', str) + } + + // Advance past whitespace to the next real token, returning its type (TOKEN_EOF once + // `this.end` is reached). Used to look ahead for a not/and/or continuation without + // committing to consuming it — callers restore to a saved position when it doesn't. + private next_significant_token(): TokenType { + while (this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + if (this.lexer.token_start >= this.end) return TOKEN_EOF + if (this.is_whitespace_inline()) continue + return this.lexer.token_type + } + return TOKEN_EOF + } + + /** + * Parse an if()-branch condition: + * = ]> | else + * = style(…) | media(…) | supports(…) + * i.e. a single test function, the bare "else" identifier, or those test functions + * combined with not/and/or (e.g. "not style(--x: 1)", "style(--a: 1) and media(width > 600px)"). + * Called with the lexer's current token already positioned at the condition's first token. + * A simple single-function/"else" condition returns that node directly, unwrapped (matching + * prior behavior); a compound not/and/or condition wraps the flat operator/function chain in + * an IF_CONDITION node so IfBranch.condition/.value (single first_child/next_sibling hops) + * still see exactly one node for "the condition". + */ + private parse_if_condition(): number | null { + // "else" never combines with not/and/or — always a bare identifier. + if (this.lexer.token_type === TOKEN_IDENT) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + if (str_equals('else', text)) { + return this.parse_value_node() + } + } + + let first = 0 + let last = 0 + let component_count = 0 + let compound = false + + for (;;) { + let tt = this.lexer.token_type + let component: number | null = null + let is_operator = false + + if (tt === TOKEN_FUNCTION) { + component = this.parse_if_condition_function(this.lexer.token_start, this.lexer.token_end) + } else if (tt === TOKEN_IDENT) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + if (this.is_and_or_not(text)) { + is_operator = true + component = this.arena.create_node( + PRELUDE_OPERATOR, + this.lexer.token_start, + this.lexer.token_end - this.lexer.token_start, + this.lexer.token_line, + this.lexer.token_column, + ) + } else { + component = this.parse_value_node() + } + } else { + component = this.parse_value_node() + } + + if (component !== null) { + component_count++ + if (first === 0) first = component + else this.arena.set_next_sibling(last, component) + last = component + } + + // Only a test function or a not/and/or operator can be followed by more of the + // condition; anything else ends the condition here. + if (tt !== TOKEN_FUNCTION && !is_operator) break + + let saved = this.lexer.save_position() + let next_tt = this.next_significant_token() + if (next_tt === TOKEN_FUNCTION) { + compound = true + continue + } + if (next_tt === TOKEN_IDENT) { + let text = this.source.substring(this.lexer.token_start, this.lexer.token_end) + if (this.is_and_or_not(text)) { + compound = true + continue + } + } + this.lexer.restore_position(saved) + break + } + + if (first === 0) return null + if (component_count === 1 && !compound) return first + + // Compound condition — wrap the flat not/and/or/function chain in an IF_CONDITION node + let wrapper_start = this.arena.get_start_offset(first) + let last_sibling = this.arena.get_last_sibling(first) + let wrapper_end = + this.arena.get_start_offset(last_sibling) + this.arena.get_length(last_sibling) + let wrapper = this.arena.create_node( + IF_CONDITION, + wrapper_start, + wrapper_end - wrapper_start, + this.arena.get_start_line(first), + this.arena.get_start_column(first), + ) + this.arena.set_first_child(wrapper, first) + return wrapper + } + /** * Parse a condition function inside if() — style(), supports(), or media(). Content parsing * is delegated to the shared ConditionParser (see parse-condition.ts), so these produce the @@ -584,11 +694,23 @@ export class ValueNodeParser { let child_nodes: number[] = [] if (str_equals('style', func_name)) { + // style(): a bare single declaration when the content has a top-level + // ':' (style(--x: 1)); otherwise the full compound and/or/not grammar over + // parenthesized declarations, same as `@supports` — style((--a: 1) or (--a: 2)). let decl = this.condition_parser.parse_supports_declaration_content( content_start, content_end, ) - if (decl !== null) child_nodes = [decl] + if (decl === null) { + child_nodes = this.condition_parser.parse_supports_condition( + content_start, + content_end, + func_line, + func_col, + ) + } else { + child_nodes = [decl] + } } else if (str_equals('supports', func_name)) { // supports(): a bare single declaration when the content has a // top-level ':' (matching style()'s shorthand — supports(display: grid), no extra