From 1e73ead5537ea7e0965c1f8b2d518d5d5ca62b44 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 18:43:46 +0000 Subject: [PATCH 01/11] Add support for parsing inline if() The CSS inline if() function uses colons and semicolons as structural delimiters (condition: value; condition: value; else: fallback). These were previously silently dropped inside function argument parsing. Now TOKEN_COLON and TOKEN_SEMICOLON produce OPERATOR nodes in value contexts, preserving the full structure of if(), style(), supports(), and media() condition functions in the AST. --- src/parse-value.test.ts | 171 +++++++++++++++++++++++++++++++++++++++ src/value-node-parser.ts | 4 + 2 files changed, 175 insertions(+) diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 8597b61..d6c603a 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -1117,4 +1117,175 @@ describe('Value Node Types', () => { expect((value as Function | undefined)?.children[0].type).toBe(FUNCTION) }) }) + + describe('FUNCTION if()', () => { + const getFunc = (css: string) => { + const root = parse(css) + const decl = root.first_child?.first_child?.next_sibling?.first_child + return (decl!.first_child! as Value).children[0] as Function | undefined + } + + test('should parse if() as a FUNCTION node', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('if') + }) + + test('should expose full text and inner value', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + expect(func?.text).toBe('if(style(--active: 1): green; else: red)') + expect(func?.value).toBe('style(--active: 1): green; else: red') + }) + + test('should preserve : and ; as OPERATOR children', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + // children: style(), :, green, ;, else, :, red + expect(func?.children).toHaveLength(7) + expect(func?.children[0].type).toBe(FUNCTION) + expect(func?.children[1].type).toBe(OPERATOR) + expect(func?.children[1].text).toBe(':') + expect(func?.children[2].type).toBe(IDENTIFIER) + expect(func?.children[2].text).toBe('green') + expect(func?.children[3].type).toBe(OPERATOR) + expect(func?.children[3].text).toBe(';') + expect(func?.children[4].type).toBe(IDENTIFIER) + expect(func?.children[4].text).toBe('else') + expect(func?.children[5].type).toBe(OPERATOR) + expect(func?.children[5].text).toBe(':') + expect(func?.children[6].type).toBe(IDENTIFIER) + expect(func?.children[6].text).toBe('red') + }) + + test('should parse style() condition with : operator preserved', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const styleFunc = func?.children[0] as Function | undefined + expect(styleFunc?.type).toBe(FUNCTION) + expect(styleFunc?.name).toBe('style') + // children: --active, :, 1 + expect(styleFunc?.children).toHaveLength(3) + expect(styleFunc?.children[0].type).toBe(IDENTIFIER) + expect(styleFunc?.children[0].text).toBe('--active') + expect(styleFunc?.children[1].type).toBe(OPERATOR) + expect(styleFunc?.children[1].text).toBe(':') + expect(styleFunc?.children[2].type).toBe(NUMBER) + expect(styleFunc?.children[2].text).toBe('1') + }) + + test('should parse if() with supports() condition', () => { + const func = getFunc('div { display: if(supports(display: grid): grid; else: block) }') + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(7) + + const supportsFunc = func?.children[0] as Function | undefined + expect(supportsFunc?.type).toBe(FUNCTION) + expect(supportsFunc?.name).toBe('supports') + expect(supportsFunc?.children).toHaveLength(3) + expect(supportsFunc?.children[0].text).toBe('display') + expect(supportsFunc?.children[1].text).toBe(':') + expect(supportsFunc?.children[2].text).toBe('grid') + + expect(func?.children[2].text).toBe('grid') + expect(func?.children[4].text).toBe('else') + expect(func?.children[6].text).toBe('block') + }) + + test('should parse if() with media() condition', () => { + const func = getFunc('div { color: if(media(min-width: 600px): blue; else: red) }') + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('if') + + const mediaFunc = func?.children[0] as Function | undefined + expect(mediaFunc?.type).toBe(FUNCTION) + expect(mediaFunc?.name).toBe('media') + + expect(func?.children[2].text).toBe('blue') + expect(func?.children[6].text).toBe('red') + }) + + test('should parse if() with multiple conditions', () => { + // if(style(--large: 1): 2rem; style(--medium: 1): 1.5rem; else: 1rem) + const func = getFunc( + 'div { font-size: if(style(--large: 1): 2rem; style(--medium: 1): 1.5rem; else: 1rem) }', + ) + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('if') + // children: style(), :, 2rem, ;, style(), :, 1.5rem, ;, else, :, 1rem + expect(func?.children).toHaveLength(11) + + expect(func?.children[0].type).toBe(FUNCTION) + expect((func?.children[0] as Function).name).toBe('style') + expect(func?.children[1].text).toBe(':') + expect(func?.children[2].text).toBe('2rem') + expect(func?.children[3].text).toBe(';') + expect(func?.children[4].type).toBe(FUNCTION) + expect((func?.children[4] as Function).name).toBe('style') + expect(func?.children[5].text).toBe(':') + expect(func?.children[6].text).toBe('1.5rem') + expect(func?.children[7].text).toBe(';') + expect(func?.children[8].text).toBe('else') + expect(func?.children[9].text).toBe(':') + expect(func?.children[10].text).toBe('1rem') + }) + + test('should parse nested if() functions', () => { + const func = getFunc( + 'div { color: if(style(--a: 1): if(style(--b: 1): blue; else: green); else: red) }', + ) + expect(func?.type).toBe(FUNCTION) + expect(func?.name).toBe('if') + + // children: style(--a: 1), :, if(...), ;, else, :, red + expect(func?.children).toHaveLength(7) + expect(func?.children[2].type).toBe(FUNCTION) + expect((func?.children[2] as Function).name).toBe('if') + }) + + test('should parse if() with dimension value', () => { + const func = getFunc('div { width: if(style(--wide: 1): 100%; else: 50%) }') + expect(func?.type).toBe(FUNCTION) + expect(func?.children[2].type).toBe(DIMENSION) + expect(func?.children[2].text).toBe('100%') + expect(func?.children[6].type).toBe(DIMENSION) + expect(func?.children[6].text).toBe('50%') + }) + + test('should parse if() with color value', () => { + const func = getFunc('div { color: if(style(--dark: 1): #000; else: #fff) }') + expect(func?.type).toBe(FUNCTION) + expect(func?.children[2].type).toBe(HASH) + expect(func?.children[2].text).toBe('#000') + expect(func?.children[6].type).toBe(HASH) + expect(func?.children[6].text).toBe('#fff') + }) + + test('should parse if() with function value', () => { + const func = getFunc( + 'div { color: if(supports(color: oklch(0 0 0)): oklch(0.5 0.2 240); else: blue) }', + ) + expect(func?.type).toBe(FUNCTION) + // value after colon is a function + expect(func?.children[2].type).toBe(FUNCTION) + expect((func?.children[2] as Function).name).toBe('oklch') + }) + + test('should correctly parse declaration following if()', () => { + const root = parse('div { color: if(style(--x: 1): red; else: blue); font-size: 1em }') + const block = root.first_child?.first_child?.next_sibling + const children = (block as import('./node-types').Block | null | undefined)?.children + expect(children).toHaveLength(2) + expect(children?.[0].type).toBe(DECLARATION) + expect(children?.[1].type).toBe(DECLARATION) + }) + + test('should have correct location for if() function', () => { + const func = getFunc('div { color: if(style(--x: 1): red; else: blue) }') + expect(func?.start).toBe(13) + expect(func?.text).toBe('if(style(--x: 1): red; else: blue)') + expect(func?.length).toBe(34) + expect(func?.end).toBe(47) + expect(func?.line).toBe(1) + expect(func?.column).toBe(14) + }) + }) }) diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 3465d60..f5a51c7 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -25,6 +25,8 @@ import { TOKEN_FUNCTION, TOKEN_DELIM, TOKEN_COMMA, + TOKEN_COLON, + TOKEN_SEMICOLON, TOKEN_EOF, TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, @@ -137,6 +139,8 @@ export class ValueNodeParser { return this.parse_operator_node(start, end) case TOKEN_COMMA: + case TOKEN_COLON: + case TOKEN_SEMICOLON: return this.create_node(OPERATOR, start, end) case TOKEN_LEFT_PAREN: From 7bae5322dcf9fa6cf30ecc5c9b64bd7ce38f6361 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 18:55:20 +0000 Subject: [PATCH 02/11] Add spec-based parsing of CSS inline if() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the CSS Values Level 5 grammar for inline if(): if( + ) = : ? ;? = style(…) | media(…) | supports(…) | else Key changes: - New IF_BRANCH (58) node type in the arena. Each condition/value pair inside if() becomes an IfBranch node — a first-class AST node rather than a flat list of tokens. - IfBranch exposes: .condition — the condition text ("style(--x: 1)", "else", …) .value — the value text ("green", "red", …), or null if absent .is_else — true on the else branch .first_child — parsed condition node (Function or Identifier) .children — condition node followed by parsed value nodes - FUNCTION("if") children are exclusively IfBranch nodes; colons and semicolons are structural separators and are not emitted as OPERATOR nodes at the if() level. - Condition functions (style(), supports(), media()) are generic FUNCTION nodes. Their `:` separators are preserved as OPERATOR children (the TOKEN_COLON → OPERATOR change from the previous commit), which is correct for all three condition types. - Nested if() functions are parsed recursively via the same dispatch in parse_function_node(). - New is_if_branch() type predicate and IfBranch TypeScript type exported from the public API. --- src/arena.ts | 1 + src/constants.ts | 3 + src/css-node.ts | 23 +++ src/index.ts | 2 + src/node-types.ts | 31 ++++ src/parse-value.test.ts | 302 +++++++++++++++++++++++++++------------ src/value-node-parser.ts | 149 +++++++++++++++++++ 7 files changed, 419 insertions(+), 92 deletions(-) diff --git a/src/arena.ts b/src/arena.ts index 23983df..565861e 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -61,6 +61,7 @@ export const OPERATOR = 16 // operator: +, -, *, /, comma 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 = 58 // Branch inside an if() function: : // 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 e0db88d..d073d4a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -20,6 +20,7 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -67,6 +68,7 @@ export { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -117,6 +119,7 @@ export const NODE_TYPES = { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, // Selector nodes SELECTOR_LIST, diff --git a/src/css-node.ts b/src/css-node.ts index bf50ca8..0f5fb39 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -45,6 +45,7 @@ import { PRELUDE_SELECTORLIST, SUPPORTS_DECLARATION, RATIO, + IF_BRANCH, FLAG_IMPORTANT, FLAG_HAS_ERROR, FLAG_HAS_BLOCK, @@ -66,6 +67,7 @@ import { is_whitespace, is_vendor_prefixed, str_starts_with, + str_equals, } from './string-utils' import { parse_dimension } from './parse-dimension' @@ -115,6 +117,7 @@ export const TYPE_NAMES = { [AT_RULE_PRELUDE]: 'AtrulePrelude', [PRELUDE_SELECTORLIST]: 'PreludeSelectorList', [RATIO]: 'Ratio', + [IF_BRANCH]: 'IfBranch', } as const export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown' @@ -165,6 +168,7 @@ export type CSSNodeType = | typeof PRELUDE_SELECTORLIST | typeof SUPPORTS_DECLARATION | typeof RATIO + | typeof IF_BRANCH // Options for cloning nodes export interface CloneOptions { @@ -199,6 +203,10 @@ export type PlainCSSNode = { left?: PlainCSSNode right?: PlainCSSNode + // IfBranch-specific + condition?: string + is_else?: boolean + // Flags (only when true) is_important?: boolean is_vendor_prefixed?: boolean @@ -253,6 +261,7 @@ const nodes_with_children = new Set([ FEATURE_RANGE, SUPPORTS_QUERY, SUPPORTS_DECLARATION, + IF_BRANCH, ]) const enumerable_properties = [ @@ -266,6 +275,8 @@ const enumerable_properties = [ 'nth_a', 'nth_b', 'selector', + 'condition', + 'is_else', 'is_browserhack', 'is_vendor_prefixed', 'has_error', @@ -518,6 +529,18 @@ export class CSSNode { return this.first_child?.next_sibling ?? undefined } + /** Get the condition text of an if() branch, e.g. "style(--active: 1)" or "else" */ + get condition(): string | undefined { + if (this.type !== IF_BRANCH) return undefined + return this.get_content() + } + + /** True when this is the else branch of an if() function */ + get is_else(): boolean | undefined { + if (this.type !== IF_BRANCH) return undefined + return str_equals('else', this.get_content()) + } + /** Check if this declaration has !important */ get is_important(): boolean | undefined { if (this.type !== DECLARATION) return undefined diff --git a/src/index.ts b/src/index.ts index 832278f..6d4cf03 100644 --- a/src/index.ts +++ b/src/index.ts @@ -58,6 +58,7 @@ export { type Parenthesis, type Url, type UnicodeRange, + type IfBranch, type Value, type SelectorNode, type TypeSelector, @@ -103,6 +104,7 @@ export { is_parenthesis, is_url, is_unicode_range, + is_if_branch, is_value, is_type_selector, is_class_selector, diff --git a/src/node-types.ts b/src/node-types.ts index e868d8c..daccab8 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -36,6 +36,7 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, SELECTOR_LIST, TYPE_SELECTOR, @@ -245,6 +246,7 @@ export type Raw = Leaf type ValueLike = | Function + | IfBranch | Identifier | Operator | Parenthesis @@ -328,6 +330,31 @@ export type Value = WithClone< CSSNode & WithChildren & { readonly type: typeof VALUE; readonly type_name: 'Value' } > +/** + * One branch inside a CSS `if()` inline conditional function. + * + * Each branch corresponds to a `: ` pair in: + * `if( : ; … else: )` + * + * - `condition` — the condition text, e.g. `"style(--x: 1)"` or `"else"` + * - `value` — the value text, e.g. `"green"`; `null` when omitted + * - `is_else` — `true` for the `else` branch + * - `first_child` — the parsed condition node (Function or Identifier) + * - `children` — condition node followed by parsed value nodes + */ +export type IfBranch = CSSNode & + WithChildren & { + readonly type: typeof IF_BRANCH + readonly type_name: 'IfBranch' + /** Condition text, e.g. "style(--active: 1)" or "else" */ + readonly condition: string + /** Value text between the colon and the next semicolon/close-paren, or null if empty */ + readonly value: string | null + /** True when this is the else branch */ + readonly is_else: boolean + clone(options?: CloneOptions): ToPlain + } + // --------------------------------------------------------------------------- // Selector nodes // --------------------------------------------------------------------------- @@ -598,6 +625,7 @@ export type AnyNode = | Parenthesis | Url | UnicodeRange + | IfBranch | Value | TypeSelector | ClassSelector @@ -688,6 +716,9 @@ export function is_url(node: CSSNode): node is Url { export function is_unicode_range(node: CSSNode): node is UnicodeRange { return node.type === UNICODE_RANGE } +export function is_if_branch(node: CSSNode): node is IfBranch { + return node.type === IF_BRANCH +} 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 d6c603a..0952257 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -11,6 +11,7 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, VALUE, DECLARATION, } from './arena' @@ -20,6 +21,7 @@ import type { Declaration, Dimension, Function, + IfBranch, Number, Operator, Parenthesis, @@ -1124,44 +1126,84 @@ describe('Value Node Types', () => { const decl = root.first_child?.first_child?.next_sibling?.first_child return (decl!.first_child! as Value).children[0] as Function | undefined } + const getBranch = (func: Function | undefined, idx: number) => + func?.children[idx] as IfBranch | undefined - test('should parse if() as a FUNCTION node', () => { + // ── Basic structure ────────────────────────────────────────────────── + + test('should parse if() as a FUNCTION node with IF_BRANCH children', () => { const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') expect(func?.type).toBe(FUNCTION) expect(func?.name).toBe('if') + // The two branches are the only direct children + expect(func?.children).toHaveLength(2) + expect(func?.children[0].type).toBe(IF_BRANCH) + expect(func?.children[1].type).toBe(IF_BRANCH) }) - test('should expose full text and inner value', () => { + test('should expose full function text and inner value', () => { const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') expect(func?.text).toBe('if(style(--active: 1): green; else: red)') expect(func?.value).toBe('style(--active: 1): green; else: red') }) - test('should preserve : and ; as OPERATOR children', () => { + // ── IF_BRANCH node properties ───────────────────────────────────────── + + test('branch has correct condition and value text', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const b0 = getBranch(func, 0) + const b1 = getBranch(func, 1) + + expect(b0?.condition).toBe('style(--active: 1)') + expect(b0?.value).toBe('green') + expect(b0?.is_else).toBe(false) + + expect(b1?.condition).toBe('else') + expect(b1?.value).toBe('red') + expect(b1?.is_else).toBe(true) + }) + + test('branch.text spans condition through value', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + expect(getBranch(func, 0)?.text).toBe('style(--active: 1): green') + expect(getBranch(func, 1)?.text).toBe('else: red') + }) + + test('branch type_name is IfBranch', () => { const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') - // children: style(), :, green, ;, else, :, red - expect(func?.children).toHaveLength(7) - expect(func?.children[0].type).toBe(FUNCTION) - expect(func?.children[1].type).toBe(OPERATOR) - expect(func?.children[1].text).toBe(':') - expect(func?.children[2].type).toBe(IDENTIFIER) - expect(func?.children[2].text).toBe('green') - expect(func?.children[3].type).toBe(OPERATOR) - expect(func?.children[3].text).toBe(';') - expect(func?.children[4].type).toBe(IDENTIFIER) - expect(func?.children[4].text).toBe('else') - expect(func?.children[5].type).toBe(OPERATOR) - expect(func?.children[5].text).toBe(':') - expect(func?.children[6].type).toBe(IDENTIFIER) - expect(func?.children[6].text).toBe('red') - }) - - test('should parse style() condition with : operator preserved', () => { + expect(getBranch(func, 0)?.type_name).toBe('IfBranch') + }) + + // ── Branch children: condition node + value nodes ───────────────────── + + test('branch.first_child is the condition node', () => { const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') - const styleFunc = func?.children[0] as Function | undefined - expect(styleFunc?.type).toBe(FUNCTION) + const b0 = getBranch(func, 0)! + expect(b0.first_child?.type).toBe(FUNCTION) + expect((b0.first_child as Function).name).toBe('style') + + const b1 = getBranch(func, 1)! + expect(b1.first_child?.type).toBe(IDENTIFIER) + expect(b1.first_child?.text).toBe('else') + }) + + test('branch children contain condition then value nodes', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const b0 = getBranch(func, 0)! + // children: FUNCTION("style"), IDENTIFIER("green") + expect(b0.children).toHaveLength(2) + expect(b0.children[0].type).toBe(FUNCTION) + expect(b0.children[1].type).toBe(IDENTIFIER) + expect(b0.children[1].text).toBe('green') + }) + + // ── style() condition ───────────────────────────────────────────────── + + test('style() condition children preserve : as OPERATOR', () => { + const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') + const styleFunc = getBranch(func, 0)?.first_child as Function | undefined expect(styleFunc?.name).toBe('style') - // children: --active, :, 1 + // children: IDENTIFIER("--active"), OPERATOR(":"), NUMBER("1") expect(styleFunc?.children).toHaveLength(3) expect(styleFunc?.children[0].type).toBe(IDENTIFIER) expect(styleFunc?.children[0].text).toBe('--active') @@ -1171,105 +1213,161 @@ describe('Value Node Types', () => { expect(styleFunc?.children[2].text).toBe('1') }) + // ── supports() condition ────────────────────────────────────────────── + test('should parse if() with supports() condition', () => { const func = getFunc('div { display: if(supports(display: grid): grid; else: block) }') - expect(func?.type).toBe(FUNCTION) expect(func?.name).toBe('if') - expect(func?.children).toHaveLength(7) + expect(func?.children).toHaveLength(2) + + const b0 = getBranch(func, 0)! + expect(b0.condition).toBe('supports(display: grid)') + expect(b0.value).toBe('grid') - const supportsFunc = func?.children[0] as Function | undefined - expect(supportsFunc?.type).toBe(FUNCTION) - expect(supportsFunc?.name).toBe('supports') - expect(supportsFunc?.children).toHaveLength(3) - expect(supportsFunc?.children[0].text).toBe('display') - expect(supportsFunc?.children[1].text).toBe(':') - expect(supportsFunc?.children[2].text).toBe('grid') + const supportsFunc = b0.first_child as Function + expect(supportsFunc.name).toBe('supports') + // children: IDENTIFIER("display"), OPERATOR(":"), IDENTIFIER("grid") + expect(supportsFunc.children).toHaveLength(3) + expect(supportsFunc.children[0].text).toBe('display') + expect(supportsFunc.children[1].text).toBe(':') + expect(supportsFunc.children[2].text).toBe('grid') - expect(func?.children[2].text).toBe('grid') - expect(func?.children[4].text).toBe('else') - expect(func?.children[6].text).toBe('block') + const b1 = getBranch(func, 1)! + expect(b1.is_else).toBe(true) + expect(b1.value).toBe('block') }) + // ── media() condition ──────────────────────────────────────────────── + test('should parse if() with media() condition', () => { const func = getFunc('div { color: if(media(min-width: 600px): blue; else: red) }') - expect(func?.type).toBe(FUNCTION) expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(2) - const mediaFunc = func?.children[0] as Function | undefined - expect(mediaFunc?.type).toBe(FUNCTION) - expect(mediaFunc?.name).toBe('media') + const b0 = getBranch(func, 0)! + expect(b0.condition).toBe('media(min-width: 600px)') + expect(b0.value).toBe('blue') + expect((b0.first_child as Function).name).toBe('media') - expect(func?.children[2].text).toBe('blue') - expect(func?.children[6].text).toBe('red') + const b1 = getBranch(func, 1)! + expect(b1.is_else).toBe(true) + expect(b1.value).toBe('red') }) - test('should parse if() with multiple conditions', () => { - // if(style(--large: 1): 2rem; style(--medium: 1): 1.5rem; else: 1rem) + // ── Multiple branches ───────────────────────────────────────────────── + + test('should parse if() with three branches', () => { const func = getFunc( 'div { font-size: if(style(--large: 1): 2rem; style(--medium: 1): 1.5rem; else: 1rem) }', ) - expect(func?.type).toBe(FUNCTION) - expect(func?.name).toBe('if') - // children: style(), :, 2rem, ;, style(), :, 1.5rem, ;, else, :, 1rem - expect(func?.children).toHaveLength(11) - - expect(func?.children[0].type).toBe(FUNCTION) - expect((func?.children[0] as Function).name).toBe('style') - expect(func?.children[1].text).toBe(':') - expect(func?.children[2].text).toBe('2rem') - expect(func?.children[3].text).toBe(';') - expect(func?.children[4].type).toBe(FUNCTION) - expect((func?.children[4] as Function).name).toBe('style') - expect(func?.children[5].text).toBe(':') - expect(func?.children[6].text).toBe('1.5rem') - expect(func?.children[7].text).toBe(';') - expect(func?.children[8].text).toBe('else') - expect(func?.children[9].text).toBe(':') - expect(func?.children[10].text).toBe('1rem') - }) - - test('should parse nested if() functions', () => { - const func = getFunc( - 'div { color: if(style(--a: 1): if(style(--b: 1): blue; else: green); else: red) }', - ) - expect(func?.type).toBe(FUNCTION) expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(3) + + const b0 = getBranch(func, 0)! + expect(b0.condition).toBe('style(--large: 1)') + expect(b0.value).toBe('2rem') + expect(b0.is_else).toBe(false) + + const b1 = getBranch(func, 1)! + expect(b1.condition).toBe('style(--medium: 1)') + expect(b1.value).toBe('1.5rem') + expect(b1.is_else).toBe(false) - // children: style(--a: 1), :, if(...), ;, else, :, red - expect(func?.children).toHaveLength(7) - expect(func?.children[2].type).toBe(FUNCTION) - expect((func?.children[2] as Function).name).toBe('if') + const b2 = getBranch(func, 2)! + expect(b2.condition).toBe('else') + expect(b2.value).toBe('1rem') + expect(b2.is_else).toBe(true) }) - test('should parse if() with dimension value', () => { + // ── Value node types ────────────────────────────────────────────────── + + test('value can be a DIMENSION', () => { const func = getFunc('div { width: if(style(--wide: 1): 100%; else: 50%) }') - expect(func?.type).toBe(FUNCTION) - expect(func?.children[2].type).toBe(DIMENSION) - expect(func?.children[2].text).toBe('100%') - expect(func?.children[6].type).toBe(DIMENSION) - expect(func?.children[6].text).toBe('50%') + expect(getBranch(func, 0)?.children[1].type).toBe(DIMENSION) + expect(getBranch(func, 0)?.children[1].text).toBe('100%') + expect(getBranch(func, 1)?.children[1].type).toBe(DIMENSION) + expect(getBranch(func, 1)?.children[1].text).toBe('50%') }) - test('should parse if() with color value', () => { + test('value can be a HASH color', () => { const func = getFunc('div { color: if(style(--dark: 1): #000; else: #fff) }') - expect(func?.type).toBe(FUNCTION) - expect(func?.children[2].type).toBe(HASH) - expect(func?.children[2].text).toBe('#000') - expect(func?.children[6].type).toBe(HASH) - expect(func?.children[6].text).toBe('#fff') + expect(getBranch(func, 0)?.children[1].type).toBe(HASH) + expect(getBranch(func, 0)?.children[1].text).toBe('#000') + expect(getBranch(func, 1)?.children[1].type).toBe(HASH) + expect(getBranch(func, 1)?.children[1].text).toBe('#fff') }) - test('should parse if() with function value', () => { + test('value can be a FUNCTION (e.g. oklch())', () => { const func = getFunc( 'div { color: if(supports(color: oklch(0 0 0)): oklch(0.5 0.2 240); else: blue) }', ) - expect(func?.type).toBe(FUNCTION) - // value after colon is a function - expect(func?.children[2].type).toBe(FUNCTION) - expect((func?.children[2] as Function).name).toBe('oklch') + expect(getBranch(func, 0)?.children[1].type).toBe(FUNCTION) + expect((getBranch(func, 0)?.children[1] as Function).name).toBe('oklch') }) - test('should correctly parse declaration following if()', () => { + test('value can be empty (condition-only branch)', () => { + // if(style(--x: 1):; else: red) — empty value between : and ; + const func = getFunc('div { color: if(style(--x: 1):; else: red) }') + expect(func?.children).toHaveLength(2) + const b0 = getBranch(func, 0)! + expect(b0.condition).toBe('style(--x: 1)') + expect(b0.value).toBeNull() + // Only the condition node as child, no value nodes + expect(b0.children).toHaveLength(1) + }) + + test('trailing semicolon before closing paren is accepted', () => { + const func = getFunc('div { color: if(style(--x: 1): red;) }') + expect(func?.children).toHaveLength(1) + const b0 = getBranch(func, 0)! + expect(b0.condition).toBe('style(--x: 1)') + expect(b0.value).toBe('red') + }) + + // ── Nested if() ─────────────────────────────────────────────────────── + + test('nested if() in value is parsed recursively', () => { + const func = getFunc( + 'div { color: if(style(--a: 1): if(style(--b: 1): blue; else: green); else: red) }', + ) + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(2) + + // Branch 0 value is a nested if() FUNCTION + const innerIf = getBranch(func, 0)?.children[1] as Function | undefined + expect(innerIf?.type).toBe(FUNCTION) + expect(innerIf?.name).toBe('if') + expect(innerIf?.children).toHaveLength(2) + expect(getBranch(innerIf, 0)?.condition).toBe('style(--b: 1)') + expect(getBranch(innerIf, 0)?.value).toBe('blue') + expect(getBranch(innerIf, 1)?.is_else).toBe(true) + expect(getBranch(innerIf, 1)?.value).toBe('green') + }) + + // ── Real-world example from the spec ────────────────────────────────── + + test('spec example: supports(color: lch(…))', () => { + const css = + 'h2 { color: if(supports(color: lch(29.57% 43.25 344.44)): lch(29.57% 43.25 344.44); else: #792359) }' + const func = getFunc(css) + expect(func?.name).toBe('if') + expect(func?.children).toHaveLength(2) + + const b0 = getBranch(func, 0)! + expect(b0.is_else).toBe(false) + expect((b0.first_child as Function).name).toBe('supports') + expect(b0.children[1].type).toBe(FUNCTION) + expect((b0.children[1] as Function).name).toBe('lch') + + const b1 = getBranch(func, 1)! + expect(b1.is_else).toBe(true) + expect(b1.children[1].type).toBe(HASH) + expect(b1.children[1].text).toBe('#792359') + }) + + // ── Declaration boundary ────────────────────────────────────────────── + + test('declaration following if() is parsed correctly', () => { const root = parse('div { color: if(style(--x: 1): red; else: blue); font-size: 1em }') const block = root.first_child?.first_child?.next_sibling const children = (block as import('./node-types').Block | null | undefined)?.children @@ -1278,8 +1376,11 @@ describe('Value Node Types', () => { expect(children?.[1].type).toBe(DECLARATION) }) - test('should have correct location for if() function', () => { + // ── Location tracking ───────────────────────────────────────────────── + + test('if() function has correct location', () => { const func = getFunc('div { color: if(style(--x: 1): red; else: blue) }') + // "div { color: " = 13 chars, so if() starts at offset 13 expect(func?.start).toBe(13) expect(func?.text).toBe('if(style(--x: 1): red; else: blue)') expect(func?.length).toBe(34) @@ -1287,5 +1388,22 @@ describe('Value Node Types', () => { expect(func?.line).toBe(1) expect(func?.column).toBe(14) }) + + test('IF_BRANCH has correct location', () => { + const func = getFunc('div { color: if(style(--x: 1): red; else: blue) }') + const b0 = getBranch(func, 0)! + // "div { color: if(" = 16 chars, so first branch starts at 16 + expect(b0.start).toBe(16) + expect(b0.text).toBe('style(--x: 1): red') + expect(b0.end).toBe(34) + expect(b0.line).toBe(1) + expect(b0.column).toBe(17) + + const b1 = getBranch(func, 1)! + // "; else: blue" — else starts at 36 + expect(b1.start).toBe(36) + expect(b1.text).toBe('else: blue') + expect(b1.end).toBe(46) + }) }) }) diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index f5a51c7..07c0c5a 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -14,6 +14,7 @@ import { PARENTHESIS, URL, UNICODE_RANGE, + IF_BRANCH, } from './arena' import { TOKEN_IDENT, @@ -188,6 +189,11 @@ export class ValueNodeParser { // Get function name to check for special handling let func_name_substr = this.source.substring(start, name_end) + // Dispatch to dedicated parser for if() + if (str_equals('if', func_name_substr)) { + return this.parse_if_function_node(start, end) + } + // Create URL or function node based on function name (length will be set later) let node = this.arena.create_node( str_equals('url', func_name_substr) ? URL : FUNCTION, @@ -322,6 +328,149 @@ export class ValueNodeParser { return node } + /** + * Parse an if() inline conditional function. + * + * Spec grammar (CSS Values Level 5): + * if( + ) + * = : ? ;? + * = style(…) | media(…) | supports(…) | else + * + * Each branch becomes an IF_BRANCH child of the FUNCTION("if") node. + * The colon and semicolons are structural separators and do not become + * OPERATOR nodes — structure is carried by the IF_BRANCH nodes. + * + * IF_BRANCH arena fields: + * content (contentStartDelta / contentLength) → condition text + * value (valueStartDelta / valueLength) → value text + * children → condition node (Function|Identifier) then value nodes + */ + private parse_if_function_node(start: number, end: number): number { + let name_end = end - 1 // exclude '(' + let save_line = this.lexer.token_line + let save_col = this.lexer.token_column + + let node = this.arena.create_node(FUNCTION, start, 0, save_line, save_col) + this.arena.set_content_start_delta(node, 0) + this.arena.set_content_length(node, name_end - start) // length of "if" + + let branches: number[] = [] + let func_end = end + let content_start = end // right after 'if(' + let content_end = end + let if_closed = false + + while (this.lexer.pos < this.end && !if_closed) { + this.lexer.next_token_fast(false) + let tt = this.lexer.token_type + + if (tt === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + + if (tt === TOKEN_RIGHT_PAREN) { + content_end = this.lexer.token_start + func_end = this.lexer.token_end + break + } + + // Skip whitespace and any stray separators between branches + if (this.is_whitespace_inline() || tt === TOKEN_SEMICOLON || tt === TOKEN_COLON) continue + + // ── Condition ────────────────────────────────────────────────────── + let branch_start = this.lexer.token_start + let branch_line = this.lexer.token_line + let branch_col = this.lexer.token_column + + // parse_value_node() handles TOKEN_FUNCTION (recursive) and TOKEN_IDENT + let condition_node = this.parse_value_node() + if (condition_node === null) continue + + let condition_end_pos = + this.arena.get_start_offset(condition_node) + this.arena.get_length(condition_node) + + // ── Find the ':' separator ───────────────────────────────────────── + let colon_found = false + while (this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + let t = this.lexer.token_type + if (t === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + if (this.is_whitespace_inline()) continue + if (t === TOKEN_COLON) { + colon_found = true + break + } + if (t === TOKEN_RIGHT_PAREN) { + // Condition with no colon — malformed; still record branch, close if() + content_end = this.lexer.token_start + func_end = this.lexer.token_end + if_closed = true + break + } + // Skip other unexpected tokens + } + + // ── Value tokens until ';' or end of if() ──────────────────────── + let value_tokens: number[] = [] + let value_start = -1 + let value_last_end = condition_end_pos + + if (colon_found && !if_closed) { + while (this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + let t = this.lexer.token_type + if (t === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + if (this.is_whitespace_inline()) continue + + if (t === TOKEN_SEMICOLON) break // end of this branch + + if (t === TOKEN_RIGHT_PAREN) { + content_end = this.lexer.token_start + func_end = this.lexer.token_end + if_closed = true + break + } + + let vnode = this.parse_value_node() + if (vnode !== null) { + let ns = this.arena.get_start_offset(vnode) + if (value_start === -1) value_start = ns + value_tokens.push(vnode) + value_last_end = ns + this.arena.get_length(vnode) + } + } + } + + // ── Create IF_BRANCH node ────────────────────────────────────────── + let branch_end = value_last_end + let branch_node = this.arena.create_node( + IF_BRANCH, + branch_start, + branch_end - branch_start, + branch_line, + branch_col, + ) + this.arena.set_content_start_delta(branch_node, 0) + this.arena.set_content_length(branch_node, condition_end_pos - branch_start) + + if (value_start !== -1) { + this.arena.set_value_start_delta(branch_node, value_start - branch_start) + this.arena.set_value_length(branch_node, value_last_end - value_start) + } + + this.arena.append_children(branch_node, [condition_node, ...value_tokens]) + branches.push(branch_node) + } + + this.arena.set_length(node, func_end - start) + this.arena.set_value_start_delta(node, content_start - start) + this.arena.set_value_length(node, content_end - content_start) + this.arena.append_children(node, branches) + + return node + } + private parse_parenthesis_node(start: number, end: number): number { // Create parenthesis node (length will be set later) let node = this.arena.create_node( From f506916f702fef38398c6124c514c51cdb45cbba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:27:05 +0000 Subject: [PATCH 03/11] Parse if() condition functions and branch values as structured nodes style() and supports() conditions now produce a DECLARATION child (property + VALUE), media() conditions produce a MEDIA_FEATURE child (property + value children), and each IF_BRANCH value is wrapped in a VALUE node so branch.value returns a Value node instead of a raw string. --- src/css-node.ts | 5 + src/node-types.ts | 6 +- src/parse-value.test.ts | 102 +++++++++------- src/value-node-parser.ts | 243 +++++++++++++++++++++++++++++++++++++-- 4 files changed, 303 insertions(+), 53 deletions(-) diff --git a/src/css-node.ts b/src/css-node.ts index 0f5fb39..858c3c1 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -391,6 +391,11 @@ export class CSSNode { return first_child?.first_child ?? null } + if (type === IF_BRANCH) { + // First child is the condition node; second child (if any) is the VALUE wrapper + return first_child?.next_sibling ?? null + } + if (type === DIMENSION) { return parse_dimension(text).value } diff --git a/src/node-types.ts b/src/node-types.ts index daccab8..7c52628 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -343,13 +343,13 @@ export type Value = WithClone< * - `children` — condition node followed by parsed value nodes */ export type IfBranch = CSSNode & - WithChildren & { + WithChildren & { readonly type: typeof IF_BRANCH readonly type_name: 'IfBranch' /** Condition text, e.g. "style(--active: 1)" or "else" */ readonly condition: string - /** Value text between the colon and the next semicolon/close-paren, or null if empty */ - readonly value: string | null + /** 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 */ readonly is_else: boolean clone(options?: CloneOptions): ToPlain diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 0952257..6aa4eb7 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -14,6 +14,7 @@ import { IF_BRANCH, VALUE, DECLARATION, + MEDIA_FEATURE, } from './arena' import type { Atrule, @@ -1155,11 +1156,11 @@ describe('Value Node Types', () => { const b1 = getBranch(func, 1) expect(b0?.condition).toBe('style(--active: 1)') - expect(b0?.value).toBe('green') + expect((b0?.value as Value).text).toBe('green') expect(b0?.is_else).toBe(false) expect(b1?.condition).toBe('else') - expect(b1?.value).toBe('red') + expect((b1?.value as Value).text).toBe('red') expect(b1?.is_else).toBe(true) }) @@ -1187,30 +1188,29 @@ describe('Value Node Types', () => { expect(b1.first_child?.text).toBe('else') }) - test('branch children contain condition then value nodes', () => { + test('branch children contain condition node then VALUE wrapper', () => { const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') const b0 = getBranch(func, 0)! - // children: FUNCTION("style"), IDENTIFIER("green") + // children: FUNCTION("style"), VALUE("green") expect(b0.children).toHaveLength(2) expect(b0.children[0].type).toBe(FUNCTION) - expect(b0.children[1].type).toBe(IDENTIFIER) + expect(b0.children[1].type).toBe(VALUE) expect(b0.children[1].text).toBe('green') }) // ── style() condition ───────────────────────────────────────────────── - test('style() condition children preserve : as OPERATOR', () => { + test('style() condition has a DECLARATION child', () => { const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') const styleFunc = getBranch(func, 0)?.first_child as Function | undefined expect(styleFunc?.name).toBe('style') - // children: IDENTIFIER("--active"), OPERATOR(":"), NUMBER("1") - expect(styleFunc?.children).toHaveLength(3) - expect(styleFunc?.children[0].type).toBe(IDENTIFIER) - expect(styleFunc?.children[0].text).toBe('--active') - expect(styleFunc?.children[1].type).toBe(OPERATOR) - expect(styleFunc?.children[1].text).toBe(':') - expect(styleFunc?.children[2].type).toBe(NUMBER) - expect(styleFunc?.children[2].text).toBe('1') + // 1 child: DECLARATION + expect(styleFunc?.children).toHaveLength(1) + const decl = styleFunc?.children[0] as Declaration + expect(decl.type).toBe(DECLARATION) + expect(decl.property).toBe('--active') + expect((decl.value as Value).children[0].type).toBe(NUMBER) + expect((decl.value as Value).children[0].text).toBe('1') }) // ── supports() condition ────────────────────────────────────────────── @@ -1222,19 +1222,20 @@ describe('Value Node Types', () => { const b0 = getBranch(func, 0)! expect(b0.condition).toBe('supports(display: grid)') - expect(b0.value).toBe('grid') + expect((b0.value as Value).text).toBe('grid') const supportsFunc = b0.first_child as Function expect(supportsFunc.name).toBe('supports') - // children: IDENTIFIER("display"), OPERATOR(":"), IDENTIFIER("grid") - expect(supportsFunc.children).toHaveLength(3) - expect(supportsFunc.children[0].text).toBe('display') - expect(supportsFunc.children[1].text).toBe(':') - expect(supportsFunc.children[2].text).toBe('grid') + // 1 child: DECLARATION + expect(supportsFunc.children).toHaveLength(1) + const decl = supportsFunc.children[0] as Declaration + expect(decl.type).toBe(DECLARATION) + expect(decl.property).toBe('display') + expect((decl.value as Value).children[0].text).toBe('grid') const b1 = getBranch(func, 1)! expect(b1.is_else).toBe(true) - expect(b1.value).toBe('block') + expect((b1.value as Value).text).toBe('block') }) // ── media() condition ──────────────────────────────────────────────── @@ -1246,12 +1247,20 @@ describe('Value Node Types', () => { const b0 = getBranch(func, 0)! expect(b0.condition).toBe('media(min-width: 600px)') - expect(b0.value).toBe('blue') - expect((b0.first_child as Function).name).toBe('media') + expect((b0.value as Value).text).toBe('blue') + + const mediaFunc = b0.first_child as Function + expect(mediaFunc.name).toBe('media') + // 1 child: MEDIA_FEATURE + expect(mediaFunc.children).toHaveLength(1) + const feature = mediaFunc.children[0] + expect(feature.type).toBe(MEDIA_FEATURE) + expect(feature.property).toBe('min-width') + expect(feature.value?.type).toBe(DIMENSION) const b1 = getBranch(func, 1)! expect(b1.is_else).toBe(true) - expect(b1.value).toBe('red') + expect((b1.value as Value).text).toBe('red') }) // ── Multiple branches ───────────────────────────────────────────────── @@ -1265,17 +1274,17 @@ describe('Value Node Types', () => { const b0 = getBranch(func, 0)! expect(b0.condition).toBe('style(--large: 1)') - expect(b0.value).toBe('2rem') + expect((b0.value as Value).text).toBe('2rem') expect(b0.is_else).toBe(false) const b1 = getBranch(func, 1)! expect(b1.condition).toBe('style(--medium: 1)') - expect(b1.value).toBe('1.5rem') + expect((b1.value as Value).text).toBe('1.5rem') expect(b1.is_else).toBe(false) const b2 = getBranch(func, 2)! expect(b2.condition).toBe('else') - expect(b2.value).toBe('1rem') + expect((b2.value as Value).text).toBe('1rem') expect(b2.is_else).toBe(true) }) @@ -1283,17 +1292,18 @@ describe('Value Node Types', () => { test('value can be a DIMENSION', () => { const func = getFunc('div { width: if(style(--wide: 1): 100%; else: 50%) }') - expect(getBranch(func, 0)?.children[1].type).toBe(DIMENSION) + // children[1] is the VALUE wrapper; VALUE.text spans the value text + expect(getBranch(func, 0)?.children[1].type).toBe(VALUE) expect(getBranch(func, 0)?.children[1].text).toBe('100%') - expect(getBranch(func, 1)?.children[1].type).toBe(DIMENSION) + expect(getBranch(func, 1)?.children[1].type).toBe(VALUE) expect(getBranch(func, 1)?.children[1].text).toBe('50%') }) test('value can be a HASH color', () => { const func = getFunc('div { color: if(style(--dark: 1): #000; else: #fff) }') - expect(getBranch(func, 0)?.children[1].type).toBe(HASH) + expect(getBranch(func, 0)?.children[1].type).toBe(VALUE) expect(getBranch(func, 0)?.children[1].text).toBe('#000') - expect(getBranch(func, 1)?.children[1].type).toBe(HASH) + expect(getBranch(func, 1)?.children[1].type).toBe(VALUE) expect(getBranch(func, 1)?.children[1].text).toBe('#fff') }) @@ -1301,8 +1311,11 @@ describe('Value Node Types', () => { const func = getFunc( 'div { color: if(supports(color: oklch(0 0 0)): oklch(0.5 0.2 240); else: blue) }', ) - expect(getBranch(func, 0)?.children[1].type).toBe(FUNCTION) - expect((getBranch(func, 0)?.children[1] as Function).name).toBe('oklch') + // children[1] is the VALUE wrapper; VALUE.children[0] is the FUNCTION + expect(getBranch(func, 0)?.children[1].type).toBe(VALUE) + expect(((getBranch(func, 0)?.children[1] as Value).children[0] as Function).name).toBe( + 'oklch', + ) }) test('value can be empty (condition-only branch)', () => { @@ -1321,7 +1334,7 @@ describe('Value Node Types', () => { expect(func?.children).toHaveLength(1) const b0 = getBranch(func, 0)! expect(b0.condition).toBe('style(--x: 1)') - expect(b0.value).toBe('red') + expect((b0.value as Value).text).toBe('red') }) // ── Nested if() ─────────────────────────────────────────────────────── @@ -1333,15 +1346,17 @@ describe('Value Node Types', () => { expect(func?.name).toBe('if') expect(func?.children).toHaveLength(2) - // Branch 0 value is a nested if() FUNCTION - const innerIf = getBranch(func, 0)?.children[1] as Function | undefined + // Branch 0 value is a VALUE wrapper containing a nested if() FUNCTION + const valueNode = getBranch(func, 0)?.children[1] as Value | undefined + expect(valueNode?.type).toBe(VALUE) + const innerIf = valueNode?.children[0] as Function | undefined expect(innerIf?.type).toBe(FUNCTION) expect(innerIf?.name).toBe('if') expect(innerIf?.children).toHaveLength(2) expect(getBranch(innerIf, 0)?.condition).toBe('style(--b: 1)') - expect(getBranch(innerIf, 0)?.value).toBe('blue') + expect((getBranch(innerIf, 0)?.value as Value).text).toBe('blue') expect(getBranch(innerIf, 1)?.is_else).toBe(true) - expect(getBranch(innerIf, 1)?.value).toBe('green') + expect((getBranch(innerIf, 1)?.value as Value).text).toBe('green') }) // ── Real-world example from the spec ────────────────────────────────── @@ -1356,13 +1371,16 @@ describe('Value Node Types', () => { const b0 = getBranch(func, 0)! expect(b0.is_else).toBe(false) expect((b0.first_child as Function).name).toBe('supports') - expect(b0.children[1].type).toBe(FUNCTION) - expect((b0.children[1] as Function).name).toBe('lch') + // children[1] is VALUE; VALUE.children[0] is the lch() FUNCTION + expect(b0.children[1].type).toBe(VALUE) + expect(((b0.children[1] as Value).children[0] as Function).name).toBe('lch') const b1 = getBranch(func, 1)! expect(b1.is_else).toBe(true) - expect(b1.children[1].type).toBe(HASH) - expect(b1.children[1].text).toBe('#792359') + // children[1] is VALUE; VALUE.children[0] is the HASH + expect(b1.children[1].type).toBe(VALUE) + expect((b1.children[1] as Value).children[0].type).toBe(HASH) + expect((b1.children[1] as Value).children[0].text).toBe('#792359') }) // ── Declaration boundary ────────────────────────────────────────────── diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 07c0c5a..a15755d 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -15,6 +15,9 @@ import { URL, UNICODE_RANGE, IF_BRANCH, + DECLARATION, + MEDIA_FEATURE, + VALUE, } from './arena' import { TOKEN_IDENT, @@ -341,9 +344,11 @@ export class ValueNodeParser { * OPERATOR nodes — structure is carried by the IF_BRANCH nodes. * * IF_BRANCH arena fields: - * content (contentStartDelta / contentLength) → condition text - * value (valueStartDelta / valueLength) → value text - * children → condition node (Function|Identifier) then value nodes + * content → condition text (e.g. "style(--x: 1)" or "else") + * value → value text (e.g. "green") + * children → [condition-node, VALUE-node?] + * condition-node: FUNCTION(style/supports/media) or IDENTIFIER(else) + * VALUE-node: wraps the parsed value tokens; absent when value is empty */ private parse_if_function_node(start: number, end: number): number { let name_end = end - 1 // exclude '(' @@ -381,8 +386,16 @@ export class ValueNodeParser { let branch_line = this.lexer.token_line let branch_col = this.lexer.token_column - // parse_value_node() handles TOKEN_FUNCTION (recursive) and TOKEN_IDENT - let condition_node = this.parse_value_node() + // 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() + } if (condition_node === null) continue let condition_end_pos = @@ -414,6 +427,8 @@ export class ValueNodeParser { let value_tokens: number[] = [] let value_start = -1 let value_last_end = condition_end_pos + let value_line = 0 + let value_col = 0 if (colon_found && !if_closed) { while (this.lexer.pos < this.end) { @@ -435,15 +450,32 @@ export class ValueNodeParser { let vnode = this.parse_value_node() if (vnode !== null) { let ns = this.arena.get_start_offset(vnode) - if (value_start === -1) value_start = ns + if (value_start === -1) { + value_start = ns + value_line = this.arena.get_start_line(vnode) + value_col = this.arena.get_start_column(vnode) + } value_tokens.push(vnode) value_last_end = ns + this.arena.get_length(vnode) } } } + // ── Wrap value tokens in a VALUE node ───────────────────────────── + let value_node: number | null = null + if (value_tokens.length > 0) { + value_node = this.arena.create_node( + VALUE, + value_start, + value_last_end - value_start, + value_line, + value_col, + ) + this.arena.append_children(value_node, value_tokens) + } + // ── Create IF_BRANCH node ────────────────────────────────────────── - let branch_end = value_last_end + let branch_end = value_node !== null ? value_last_end : condition_end_pos let branch_node = this.arena.create_node( IF_BRANCH, branch_start, @@ -459,7 +491,9 @@ export class ValueNodeParser { this.arena.set_value_length(branch_node, value_last_end - value_start) } - this.arena.append_children(branch_node, [condition_node, ...value_tokens]) + let branch_children: number[] = [condition_node] + if (value_node !== null) branch_children.push(value_node) + this.arena.append_children(branch_node, branch_children) branches.push(branch_node) } @@ -471,6 +505,199 @@ export class ValueNodeParser { return node } + /** + * Parse a condition function inside if() — style(), supports(), or media(). + * + * Creates a FUNCTION node whose children are: + * - style() / supports() → one DECLARATION child (property + VALUE) + * - media() → one MEDIA_FEATURE child (property + value children) + * - anything else → generic value nodes as children + * + * Called when the current lexer token is TOKEN_FUNCTION (the '(' is already consumed). + * @param func_start Source offset of the first char of the function name. + * @param token_end Source offset right after '(' (== lexer.token_end at call site). + */ + private parse_if_condition_function(func_start: number, token_end: number): number { + let func_name_end = token_end - 1 // before '(' + let func_name = this.source.substring(func_start, func_name_end) + let func_line = this.lexer.token_line + let func_col = this.lexer.token_column + + let content_start = token_end // right after '(' + let content_end = content_start + let func_end = content_start + + // Scan for matching ')' to find the full function extent + let depth = 1 + while (this.lexer.pos < this.end && depth > 0) { + this.lexer.next_token_fast(false) + let tt = this.lexer.token_type + if (tt === TOKEN_EOF) break + if (this.lexer.token_start >= this.end) break + + if (tt === TOKEN_LEFT_PAREN || tt === TOKEN_FUNCTION) { + depth++ + } else if (tt === TOKEN_RIGHT_PAREN) { + depth-- + if (depth === 0) { + content_end = this.lexer.token_start // before ')' + func_end = this.lexer.token_end // after ')' + } + } + } + + // Create FUNCTION node spanning the full function text + let func_node = this.arena.create_node( + FUNCTION, + func_start, + func_end - func_start, + func_line, + func_col, + ) + this.arena.set_content_start_delta(func_node, 0) + this.arena.set_content_length(func_node, func_name_end - func_start) + this.arena.set_value_start_delta(func_node, content_start - func_start) + this.arena.set_value_length(func_node, content_end - content_start) + + // Parse content based on function name + let child_nodes: number[] = [] + + if (str_equals('style', func_name) || str_equals('supports', func_name)) { + // Parse as DECLARATION (property: value) + let decl = this.parse_declaration_in_range(content_start, content_end) + if (decl !== null) child_nodes = [decl] + } else if (str_equals('media', func_name)) { + // Parse as MEDIA_FEATURE (property: value) + let feature = this.parse_media_feature_in_range(content_start, content_end) + if (feature !== null) child_nodes = [feature] + } else { + // Generic: parse content as value nodes + child_nodes = this.parse_value_nodes_in_range(content_start, content_end) + } + + this.arena.append_children(func_node, child_nodes) + return func_node + } + + /** + * Parse a "property: value" text range into a DECLARATION node. + * The DECLARATION has one child: a VALUE node containing the parsed value tokens. + */ + private parse_declaration_in_range(content_start: number, content_end: number): number | null { + let colon = this.find_colon_at_depth_zero(content_start, content_end) + let prop_end = colon === -1 ? content_end : colon + let prop_range = this.trim_range(content_start, prop_end) + if (!prop_range) return null + + let [prop_start, prop_end_trim] = prop_range + let decl_end = colon === -1 ? prop_end_trim : content_end + + let decl = this.arena.create_node( + DECLARATION, + prop_start, + decl_end - prop_start, + this.lexer.token_line, + this.lexer.token_column, + ) + this.arena.set_content_start_delta(decl, 0) + this.arena.set_content_length(decl, prop_end_trim - prop_start) + + if (colon !== -1) { + let val_range = this.trim_range(colon + 1, content_end) + if (val_range) { + let [val_start, val_end] = val_range + let val_nodes = this.parse_value_nodes_in_range(val_start, val_end) + let value_node = this.arena.create_node( + VALUE, + val_start, + val_end - val_start, + this.lexer.token_line, + this.lexer.token_column, + ) + this.arena.append_children(value_node, val_nodes) + this.arena.append_children(decl, [value_node]) + } + } + + return decl + } + + /** + * Parse a "property: value" text range into a MEDIA_FEATURE node. + * Value tokens become children of the MEDIA_FEATURE. + */ + private parse_media_feature_in_range(content_start: number, content_end: number): number | null { + let colon = this.find_colon_at_depth_zero(content_start, content_end) + let prop_end = colon === -1 ? content_end : colon + let prop_range = this.trim_range(content_start, prop_end) + if (!prop_range) return null + + let feature = this.arena.create_node( + MEDIA_FEATURE, + content_start, + content_end - content_start, + this.lexer.token_line, + this.lexer.token_column, + ) + this.arena.set_content_start_delta(feature, prop_range[0] - content_start) + this.arena.set_content_length(feature, prop_range[1] - prop_range[0]) + + if (colon !== -1) { + let val_range = this.trim_range(colon + 1, content_end) + if (val_range) { + let val_nodes = this.parse_value_nodes_in_range(val_range[0], val_range[1]) + this.arena.append_children(feature, val_nodes) + } + } + + return feature + } + + /** Parse value tokens in a source sub-range using save/restore to protect lexer state. */ + private parse_value_nodes_in_range(start: number, end: number): number[] { + let saved_end = this.end + let saved_pos = this.lexer.save_position() + + this.end = end + this.lexer.seek(start, this.lexer.line, this.lexer.column) + + let nodes: number[] = [] + while (this.lexer.pos < this.end) { + this.lexer.next_token_fast(false) + if (this.lexer.token_start >= this.end) break + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + if (this.is_whitespace_inline()) continue + let node = this.parse_value_node() + if (node !== null) nodes.push(node) + } + + this.lexer.restore_position(saved_pos) + this.end = saved_end + + return nodes + } + + /** Find the position of the first ':' at parenthesis depth 0. Returns -1 if not found. */ + private find_colon_at_depth_zero(start: number, end: number): number { + let depth = 0 + for (let i = start; i < end; i++) { + let ch = this.source.charCodeAt(i) + if (ch === 0x28 /* ( */) depth++ + else if (ch === 0x29 /* ) */) depth-- + else if (ch === 0x3a /* : */ && depth === 0) return i + } + return -1 + } + + /** Trim leading/trailing whitespace from [start, end). Returns null if the range is empty. */ + private trim_range(start: number, end: number): [number, number] | null { + while (start < end && is_whitespace(this.source.charCodeAt(start))) start++ + while (end > start && is_whitespace(this.source.charCodeAt(end - 1))) end-- + if (start >= end) return null + return [start, end] + } + private parse_parenthesis_node(start: number, end: number): number { // Create parenthesis node (length will be set later) let node = this.arena.create_node( From 99c5dc283adf9b50c88c4870b22785bbe56dfd12 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 12:23:48 +0000 Subject: [PATCH 04/11] Fix IF_BRANCH/RATIO node type collision from rebase onto main Rebasing onto main (which added RATIO = 58) collided with this branch's IF_BRANCH = 58, so IF_BRANCH moves to 59. Also widens Function's children type to include Declaration/MediaFeature, which if()'s style()/supports()/media() condition parsing produces but the type didn't account for. --- src/arena.ts | 2 +- src/node-types.ts | 4 +++- src/parse-value.test.ts | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/arena.ts b/src/arena.ts index 565861e..2fad41c 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -61,7 +61,7 @@ export const OPERATOR = 16 // operator: +, -, *, /, comma 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 = 58 // Branch inside an if() function: : +export const IF_BRANCH = 59 // Branch inside an if() function: : // Selector node type constants (for detailed selector parsing) export const SELECTOR_LIST = 20 // comma-separated selectors diff --git a/src/node-types.ts b/src/node-types.ts index 7c52628..54a4187 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -292,7 +292,9 @@ export type Hash = Leaf export type Function = WithClone< CSSNode & - WithChildren & { + // `style(...)`/`supports(...)` if()-conditions hold a Declaration child; `media(...)` + // if()-conditions hold a MediaFeature child (see parse_if_condition_function) + WithChildren & { readonly type: typeof FUNCTION readonly type_name: 'Function' /** Function name, e.g. "rgb", "calc" */ diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 6aa4eb7..b6e106d 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -23,6 +23,7 @@ import type { Dimension, Function, IfBranch, + MediaFeature, Number, Operator, Parenthesis, @@ -1253,7 +1254,7 @@ describe('Value Node Types', () => { expect(mediaFunc.name).toBe('media') // 1 child: MEDIA_FEATURE expect(mediaFunc.children).toHaveLength(1) - const feature = mediaFunc.children[0] + const feature = mediaFunc.children[0] as MediaFeature expect(feature.type).toBe(MEDIA_FEATURE) expect(feature.property).toBe('min-width') expect(feature.value?.type).toBe(DIMENSION) From 4af4bd278090283d4ff8f6f89b50b0821f51eee7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 12:44:41 +0000 Subject: [PATCH 05/11] Address review feedback on if() PR - css-node.ts: use braces/newline for guard-clause return in IfBranch.condition getter - IfBranch.condition now returns the parsed condition node (Function | Identifier) instead of its raw text, matching first_child; text is still available via condition.text - parse-value.test.ts: rename abbreviated `idx` param to `index`, update condition assertions to .condition.text --- src/css-node.ts | 12 +++++++----- src/node-types.ts | 8 ++++---- src/parse-value.test.ts | 24 ++++++++++++------------ 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/css-node.ts b/src/css-node.ts index 858c3c1..0c14215 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -204,7 +204,7 @@ export type PlainCSSNode = { right?: PlainCSSNode // IfBranch-specific - condition?: string + condition?: PlainCSSNode is_else?: boolean // Flags (only when true) @@ -534,10 +534,12 @@ export class CSSNode { return this.first_child?.next_sibling ?? undefined } - /** Get the condition text of an if() branch, e.g. "style(--active: 1)" or "else" */ - get condition(): string | undefined { - if (this.type !== IF_BRANCH) return undefined - return this.get_content() + /** Get the parsed condition node of an if() branch, e.g. the Function "style(--active: 1)" or the Identifier "else" */ + get condition(): CSSNode | undefined { + if (this.type !== IF_BRANCH) { + return undefined + } + return this.first_child ?? undefined } /** True when this is the else branch of an if() function */ diff --git a/src/node-types.ts b/src/node-types.ts index 54a4187..7bcc84e 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -338,18 +338,18 @@ export type Value = WithClone< * Each branch corresponds to a `: ` pair in: * `if( : ; … else: )` * - * - `condition` — the condition text, e.g. `"style(--x: 1)"` or `"else"` + * - `condition` — the parsed condition node (Function, e.g. `style(--x: 1)`, or Identifier `else`) * - `value` — the value text, e.g. `"green"`; `null` when omitted * - `is_else` — `true` for the `else` branch - * - `first_child` — the parsed condition node (Function or Identifier) + * - `first_child` — same node as `condition` * - `children` — condition node followed by parsed value nodes */ export type IfBranch = CSSNode & WithChildren & { readonly type: typeof IF_BRANCH readonly type_name: 'IfBranch' - /** Condition text, e.g. "style(--active: 1)" or "else" */ - readonly condition: string + /** The parsed condition node, e.g. the Function "style(--active: 1)" or the Identifier "else" */ + readonly condition: Function | Identifier /** 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 */ diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index b6e106d..18a7e06 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -1128,8 +1128,8 @@ describe('Value Node Types', () => { const decl = root.first_child?.first_child?.next_sibling?.first_child return (decl!.first_child! as Value).children[0] as Function | undefined } - const getBranch = (func: Function | undefined, idx: number) => - func?.children[idx] as IfBranch | undefined + const getBranch = (func: Function | undefined, index: number) => + func?.children[index] as IfBranch | undefined // ── Basic structure ────────────────────────────────────────────────── @@ -1156,11 +1156,11 @@ describe('Value Node Types', () => { const b0 = getBranch(func, 0) const b1 = getBranch(func, 1) - expect(b0?.condition).toBe('style(--active: 1)') + expect(b0?.condition?.text).toBe('style(--active: 1)') expect((b0?.value as Value).text).toBe('green') expect(b0?.is_else).toBe(false) - expect(b1?.condition).toBe('else') + expect(b1?.condition?.text).toBe('else') expect((b1?.value as Value).text).toBe('red') expect(b1?.is_else).toBe(true) }) @@ -1222,7 +1222,7 @@ describe('Value Node Types', () => { expect(func?.children).toHaveLength(2) const b0 = getBranch(func, 0)! - expect(b0.condition).toBe('supports(display: grid)') + expect(b0.condition.text).toBe('supports(display: grid)') expect((b0.value as Value).text).toBe('grid') const supportsFunc = b0.first_child as Function @@ -1247,7 +1247,7 @@ describe('Value Node Types', () => { expect(func?.children).toHaveLength(2) const b0 = getBranch(func, 0)! - expect(b0.condition).toBe('media(min-width: 600px)') + expect(b0.condition.text).toBe('media(min-width: 600px)') expect((b0.value as Value).text).toBe('blue') const mediaFunc = b0.first_child as Function @@ -1274,17 +1274,17 @@ describe('Value Node Types', () => { expect(func?.children).toHaveLength(3) const b0 = getBranch(func, 0)! - expect(b0.condition).toBe('style(--large: 1)') + expect(b0.condition.text).toBe('style(--large: 1)') expect((b0.value as Value).text).toBe('2rem') expect(b0.is_else).toBe(false) const b1 = getBranch(func, 1)! - expect(b1.condition).toBe('style(--medium: 1)') + expect(b1.condition.text).toBe('style(--medium: 1)') expect((b1.value as Value).text).toBe('1.5rem') expect(b1.is_else).toBe(false) const b2 = getBranch(func, 2)! - expect(b2.condition).toBe('else') + expect(b2.condition.text).toBe('else') expect((b2.value as Value).text).toBe('1rem') expect(b2.is_else).toBe(true) }) @@ -1324,7 +1324,7 @@ describe('Value Node Types', () => { const func = getFunc('div { color: if(style(--x: 1):; else: red) }') expect(func?.children).toHaveLength(2) const b0 = getBranch(func, 0)! - expect(b0.condition).toBe('style(--x: 1)') + expect(b0.condition.text).toBe('style(--x: 1)') expect(b0.value).toBeNull() // Only the condition node as child, no value nodes expect(b0.children).toHaveLength(1) @@ -1334,7 +1334,7 @@ describe('Value Node Types', () => { const func = getFunc('div { color: if(style(--x: 1): red;) }') expect(func?.children).toHaveLength(1) const b0 = getBranch(func, 0)! - expect(b0.condition).toBe('style(--x: 1)') + expect(b0.condition.text).toBe('style(--x: 1)') expect((b0.value as Value).text).toBe('red') }) @@ -1354,7 +1354,7 @@ describe('Value Node Types', () => { expect(innerIf?.type).toBe(FUNCTION) expect(innerIf?.name).toBe('if') expect(innerIf?.children).toHaveLength(2) - expect(getBranch(innerIf, 0)?.condition).toBe('style(--b: 1)') + expect(getBranch(innerIf, 0)?.condition?.text).toBe('style(--b: 1)') expect((getBranch(innerIf, 0)?.value as Value).text).toBe('blue') expect(getBranch(innerIf, 1)?.is_else).toBe(true) expect((getBranch(innerIf, 1)?.value as Value).text).toBe('green') From c5c702d78fe6c58539bae23923eb8acdfa3bc850 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:01:24 +0000 Subject: [PATCH 06/11] Trim if() parser's duplicate logic to reduce bundle size Dedupes three pieces of logic the if()-parsing code had reimplemented privately, bringing it in line with patterns already established elsewhere in this file and in parse-atrule-prelude.ts: - trim_range -> reuse parse-utils.ts's trim_boundaries (also fixes a minor gap: comments inside if() condition values now trim correctly) - find_colon_at_depth_zero -> extracted to parse-utils.ts, shared with parse-atrule-prelude.ts's identical private copy - the unquoted url()/src() scan and the if()-condition-function extent scan -> unified into one scan_matching_paren(bounded) helper, mirroring AtRulePreludeParser's existing scan_matching_paren Also condenses the two JSDoc blocks that (unlike .d.ts comments, which are stripped) ship verbatim in the compiled JS, to keep only the non-obvious spec-grammar reference. Net: ~740 lines from ~764 in value-node-parser.ts, plus removes a duplicate function from parse-atrule-prelude.ts; the packed npm tarball drops from ~43.8kB back to ~42.7kB. Added a regression test for the scan_matching_paren bounded path (unterminated nested condition function inside if()). --- src/parse-utils.ts | 21 +++++- src/parse-value.test.ts | 12 ++++ src/value-node-parser.ts | 138 ++++++++++++++++----------------------- 3 files changed, 89 insertions(+), 82 deletions(-) diff --git a/src/parse-utils.ts b/src/parse-utils.ts index 946ed7f..e175ba6 100644 --- a/src/parse-utils.ts +++ b/src/parse-utils.ts @@ -1,4 +1,11 @@ -import { CHAR_ASTERISK, CHAR_FORWARD_SLASH, is_whitespace } from './string-utils' +import { + CHAR_ASTERISK, + CHAR_COLON, + CHAR_FORWARD_SLASH, + CHAR_LEFT_PAREN, + CHAR_RIGHT_PAREN, + is_whitespace, +} from './string-utils' /** @internal */ export function skip_whitespace_forward(source: string, pos: number, end: number): number { @@ -101,3 +108,15 @@ export function trim_boundaries( if (start >= end) return null return [start, end] } + +/** Find the position of the first ':' at parenthesis depth 0 in [start, end). Returns -1 if not found. @internal */ +export function find_colon_at_depth_zero(source: string, start: number, end: number): number { + let depth = 0 + for (let i = start; i < end; i++) { + let ch = source.charCodeAt(i) + if (ch === CHAR_LEFT_PAREN) depth++ + else if (ch === CHAR_RIGHT_PAREN) depth-- + else if (ch === CHAR_COLON && depth === 0) return i + } + return -1 +} diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 18a7e06..4c444d0 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -1338,6 +1338,18 @@ describe('Value Node Types', () => { expect((b0.value as Value).text).toBe('red') }) + test('unterminated condition function inside if() does not throw or run past the value', () => { + // style( never closes: parsing should stop gracefully instead of consuming + // past the declaration value's end + const func = getFunc('div { color: if(style(--x: 1 }') + expect(func?.name).toBe('if') + const b0 = getBranch(func, 0)! + const condition = b0.condition as Function + expect(condition.name).toBe('style') + expect(condition.text).toBe('style(') + expect(condition.children).toHaveLength(0) + }) + // ── Nested if() ─────────────────────────────────────────────────────── test('nested if() in value is parsed recursively', () => { diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index a15755d..89ff1d9 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -44,6 +44,7 @@ import { CHAR_FORWARD_SLASH, str_equals, } from './string-utils' +import { trim_boundaries, find_colon_at_depth_zero } from './parse-utils' /** @internal */ export class ValueNodeParser { @@ -184,6 +185,40 @@ export class ValueNodeParser { return null } + // Scan tokens from just after an already-open '(' or function-call (depth 1) to its + // matching ')'. Must be called right after consuming the opening token. + // When `bounded` is true, scanning stops at `this.end` (the if()-condition-function + // case, which must not overrun its enclosing range). When `bounded` is false, `this.end` + // is ignored and scanning continues to a real ')' or EOF (the unquoted url()/src() case, + // whose content may contain characters like ';' that would otherwise truncate it early — + // see the caller for why). + // Returns [content_end, close_end, matched]; matched is false if EOF was hit first, in + // which case content_end/close_end are left at their initial (scan-start) values. + private scan_matching_paren(bounded: boolean): [content_end: number, close_end: number, matched: boolean] { + let depth = 1 + let content_end = this.lexer.pos + let close_end = this.lexer.token_end + + while ((!bounded || this.lexer.pos < this.end) && depth > 0) { + this.lexer.next_token_fast(false) + let token_type = this.lexer.token_type + if (token_type === TOKEN_EOF) break + if (bounded && this.lexer.token_start >= this.end) break + + if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { + depth++ + } else if (token_type === TOKEN_RIGHT_PAREN) { + depth-- + if (depth === 0) { + content_end = this.lexer.token_start + close_end = this.lexer.token_end + } + } + } + + return [content_end, close_end, depth === 0] + } + private parse_function_node(start: number, end: number): number { // Function name is everything before the '(' // The lexer's TOKEN_FUNCTION includes the '(' at the end @@ -236,30 +271,14 @@ export class ValueNodeParser { // Note: We can't rely on `end` because URLs may contain semicolons // that confuse the declaration parser (e.g., data:image/png;base64,...) // So we consume tokens until we find the matching ')' regardless of `end` - let paren_depth = 1 let func_end = end let content_start = end // Position after 'url(' let content_end = end - // Just consume tokens until we find the matching ')' - // Don't create child nodes - while (paren_depth > 0) { - this.lexer.next_token_fast(false) - - let token_type = this.lexer.token_type - if (token_type === TOKEN_EOF) break - - // Track parentheses depth - if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { - paren_depth++ - } else if (token_type === TOKEN_RIGHT_PAREN) { - paren_depth-- - if (paren_depth === 0) { - content_end = this.lexer.token_start // Position of ')' - func_end = this.lexer.token_end - break - } - } + let [scanned_content_end, scanned_func_end, matched] = this.scan_matching_paren(false) + if (matched) { + content_end = scanned_content_end + func_end = scanned_func_end } // Set function total length (includes opening and closing parens) @@ -336,19 +355,11 @@ export class ValueNodeParser { * * Spec grammar (CSS Values Level 5): * if( + ) - * = : ? ;? + * = : ? ;? * = style(…) | media(…) | supports(…) | else * - * Each branch becomes an IF_BRANCH child of the FUNCTION("if") node. - * The colon and semicolons are structural separators and do not become - * OPERATOR nodes — structure is carried by the IF_BRANCH nodes. - * - * IF_BRANCH arena fields: - * content → condition text (e.g. "style(--x: 1)" or "else") - * value → value text (e.g. "green") - * children → [condition-node, VALUE-node?] - * condition-node: FUNCTION(style/supports/media) or IDENTIFIER(else) - * VALUE-node: wraps the parsed value tokens; absent when value is empty + * Each branch becomes an IF_BRANCH child (see the `IfBranch` type in node-types.ts + * for its shape). Colons/semicolons here are structural separators, not OPERATOR nodes. */ private parse_if_function_node(start: number, end: number): number { let name_end = end - 1 // exclude '(' @@ -507,15 +518,12 @@ export class ValueNodeParser { /** * Parse a condition function inside if() — style(), supports(), or media(). + * Children: style()/supports() → one DECLARATION (property + VALUE); media() → one + * MEDIA_FEATURE; anything else → generic value nodes. * - * Creates a FUNCTION node whose children are: - * - style() / supports() → one DECLARATION child (property + VALUE) - * - media() → one MEDIA_FEATURE child (property + value children) - * - anything else → generic value nodes as children - * - * Called when the current lexer token is TOKEN_FUNCTION (the '(' is already consumed). - * @param func_start Source offset of the first char of the function name. - * @param token_end Source offset right after '(' (== lexer.token_end at call site). + * Called with the current token at TOKEN_FUNCTION (the '(' already consumed). + * @param func_start Offset of the function name's first char. + * @param token_end Offset right after '(' (== lexer.token_end here). */ private parse_if_condition_function(func_start: number, token_end: number): number { let func_name_end = token_end - 1 // before '(' @@ -528,22 +536,10 @@ export class ValueNodeParser { let func_end = content_start // Scan for matching ')' to find the full function extent - let depth = 1 - while (this.lexer.pos < this.end && depth > 0) { - this.lexer.next_token_fast(false) - let tt = this.lexer.token_type - if (tt === TOKEN_EOF) break - if (this.lexer.token_start >= this.end) break - - if (tt === TOKEN_LEFT_PAREN || tt === TOKEN_FUNCTION) { - depth++ - } else if (tt === TOKEN_RIGHT_PAREN) { - depth-- - if (depth === 0) { - content_end = this.lexer.token_start // before ')' - func_end = this.lexer.token_end // after ')' - } - } + let [scanned_content_end, scanned_func_end, matched] = this.scan_matching_paren(true) + if (matched) { + content_end = scanned_content_end + func_end = scanned_func_end } // Create FUNCTION node spanning the full function text @@ -584,9 +580,9 @@ export class ValueNodeParser { * The DECLARATION has one child: a VALUE node containing the parsed value tokens. */ private parse_declaration_in_range(content_start: number, content_end: number): number | null { - let colon = this.find_colon_at_depth_zero(content_start, content_end) + let colon = find_colon_at_depth_zero(this.source, content_start, content_end) let prop_end = colon === -1 ? content_end : colon - let prop_range = this.trim_range(content_start, prop_end) + let prop_range = trim_boundaries(this.source, content_start, prop_end) if (!prop_range) return null let [prop_start, prop_end_trim] = prop_range @@ -603,7 +599,7 @@ export class ValueNodeParser { this.arena.set_content_length(decl, prop_end_trim - prop_start) if (colon !== -1) { - let val_range = this.trim_range(colon + 1, content_end) + let val_range = trim_boundaries(this.source, colon + 1, content_end) if (val_range) { let [val_start, val_end] = val_range let val_nodes = this.parse_value_nodes_in_range(val_start, val_end) @@ -627,9 +623,9 @@ export class ValueNodeParser { * Value tokens become children of the MEDIA_FEATURE. */ private parse_media_feature_in_range(content_start: number, content_end: number): number | null { - let colon = this.find_colon_at_depth_zero(content_start, content_end) + let colon = find_colon_at_depth_zero(this.source, content_start, content_end) let prop_end = colon === -1 ? content_end : colon - let prop_range = this.trim_range(content_start, prop_end) + let prop_range = trim_boundaries(this.source, content_start, prop_end) if (!prop_range) return null let feature = this.arena.create_node( @@ -643,7 +639,7 @@ export class ValueNodeParser { this.arena.set_content_length(feature, prop_range[1] - prop_range[0]) if (colon !== -1) { - let val_range = this.trim_range(colon + 1, content_end) + let val_range = trim_boundaries(this.source, colon + 1, content_end) if (val_range) { let val_nodes = this.parse_value_nodes_in_range(val_range[0], val_range[1]) this.arena.append_children(feature, val_nodes) @@ -678,26 +674,6 @@ export class ValueNodeParser { return nodes } - /** Find the position of the first ':' at parenthesis depth 0. Returns -1 if not found. */ - private find_colon_at_depth_zero(start: number, end: number): number { - let depth = 0 - for (let i = start; i < end; i++) { - let ch = this.source.charCodeAt(i) - if (ch === 0x28 /* ( */) depth++ - else if (ch === 0x29 /* ) */) depth-- - else if (ch === 0x3a /* : */ && depth === 0) return i - } - return -1 - } - - /** Trim leading/trailing whitespace from [start, end). Returns null if the range is empty. */ - private trim_range(start: number, end: number): [number, number] | null { - while (start < end && is_whitespace(this.source.charCodeAt(start))) start++ - while (end > start && is_whitespace(this.source.charCodeAt(end - 1))) end-- - if (start >= end) return null - return [start, end] - } - private parse_parenthesis_node(start: number, end: number): number { // Create parenthesis node (length will be set later) let node = this.arena.create_node( From 6c0204380c32c1c3ef715b3ab5ab6a35ef4dab91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:02:37 +0000 Subject: [PATCH 07/11] fix: format scan_matching_paren signature per oxfmt --- src/value-node-parser.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 89ff1d9..4987364 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -194,7 +194,9 @@ export class ValueNodeParser { // see the caller for why). // Returns [content_end, close_end, matched]; matched is false if EOF was hit first, in // which case content_end/close_end are left at their initial (scan-start) values. - private scan_matching_paren(bounded: boolean): [content_end: number, close_end: number, matched: boolean] { + private scan_matching_paren( + bounded: boolean, + ): [content_end: number, close_end: number, matched: boolean] { let depth = 1 let content_end = this.lexer.pos let close_end = this.lexer.token_end From bb1dd4ae822f903afbdf361f96af3a1e5006303c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 07:11:03 +0000 Subject: [PATCH 08/11] test: rename abbreviated var in new if() test --- src/parse-value.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 4c444d0..a3b5217 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -1343,8 +1343,8 @@ describe('Value Node Types', () => { // past the declaration value's end const func = getFunc('div { color: if(style(--x: 1 }') expect(func?.name).toBe('if') - const b0 = getBranch(func, 0)! - const condition = b0.condition as Function + const branch = getBranch(func, 0)! + const condition = branch.condition as Function expect(condition.name).toBe('style') expect(condition.text).toBe('style(') expect(condition.children).toHaveLength(0) From a8d56224b2af9e9a18dab4c20588d5698283bc8c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:20:11 +0000 Subject: [PATCH 09/11] fix: parse if() condition functions into real MediaFeature/SupportsQuery nodes style()/supports()/media() inside if() now delegate to ConditionParser (introduced in the sub-parser extraction PR) instead of producing plain Function/Identifier children. This fixes range-syntax media features like media(400px <= width) hanging/mis-parsing, and adds support for the full compound and/or/not supports() grammar, matching @supports's own prelude shape. supports()/style() still accept the bare single-declaration shorthand as before. Function's children type is widened to include MediaFeature, SupportsDeclaration, SupportsQuery, FeatureRange and PreludeOperator to reflect these new shapes. --- src/node-types.ts | 16 ++++- src/parse-condition.ts | 6 ++ src/parse-value.test.ts | 78 +++++++++++++++++++-- src/value-node-parser.ts | 148 ++++++++++++++++----------------------- 4 files changed, 152 insertions(+), 96 deletions(-) diff --git a/src/node-types.ts b/src/node-types.ts index 7bcc84e..5a4adfc 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -292,9 +292,19 @@ export type Hash = Leaf export type Function = WithClone< CSSNode & - // `style(...)`/`supports(...)` if()-conditions hold a Declaration child; `media(...)` - // if()-conditions hold a MediaFeature child (see parse_if_condition_function) - WithChildren & { + // if()-conditions reuse the shared condition parser (parse-condition.ts), so `style()`/ + // `supports()` hold SupportsDeclaration/SupportsQuery/PreludeOperator children (matching + // `@supports`'s own shape, including the full compound and/or/not grammar for + // `supports()`) and `media()` holds a MediaFeature or FeatureRange child (matching + // `@media`'s own shape, including range comparison syntax) — see parse_if_condition_function + WithChildren< + | ValueLike + | MediaFeature + | SupportsDeclaration + | SupportsQuery + | FeatureRange + | PreludeOperator + > & { readonly type: typeof FUNCTION readonly type_name: 'Function' /** Function name, e.g. "rgb", "calc" */ diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 9215d29..3ecad60 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -308,6 +308,12 @@ export class ConditionParser { content_start: number, content_end: number, ): number { + // parse_feature_range() below tokenizes via this.next_token(), which is bounded by + // this.end — set it here so a direct call (bypassing parse_media_feature(), which would + // otherwise set it) doesn't leave it stale. Never widens: content_end is always within + // whatever bound the caller already established. + this.end = content_end + // Check for range syntax (has comparison operators) let has_comparison = false let i = content_start diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index a3b5217..edd673f 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -15,18 +15,25 @@ import { VALUE, DECLARATION, MEDIA_FEATURE, + SUPPORTS_DECLARATION, + FEATURE_RANGE, + SUPPORTS_QUERY, + PRELUDE_OPERATOR, } from './arena' import type { Atrule, Block, Declaration, Dimension, + FeatureRange, Function, IfBranch, MediaFeature, Number, Operator, Parenthesis, + SupportsDeclaration, + SupportsQuery, Url, Value, } from './node-types' @@ -1201,14 +1208,14 @@ describe('Value Node Types', () => { // ── style() condition ───────────────────────────────────────────────── - test('style() condition has a DECLARATION child', () => { + test('style() condition has a SUPPORTS_DECLARATION child', () => { const func = getFunc('div { color: if(style(--active: 1): green; else: red) }') const styleFunc = getBranch(func, 0)?.first_child as Function | undefined expect(styleFunc?.name).toBe('style') - // 1 child: DECLARATION + // 1 child: SUPPORTS_DECLARATION, matching @supports style()'s shape expect(styleFunc?.children).toHaveLength(1) - const decl = styleFunc?.children[0] as Declaration - expect(decl.type).toBe(DECLARATION) + const decl = styleFunc?.children[0] as SupportsDeclaration + expect(decl.type).toBe(SUPPORTS_DECLARATION) expect(decl.property).toBe('--active') expect((decl.value as Value).children[0].type).toBe(NUMBER) expect((decl.value as Value).children[0].text).toBe('1') @@ -1227,10 +1234,10 @@ describe('Value Node Types', () => { const supportsFunc = b0.first_child as Function expect(supportsFunc.name).toBe('supports') - // 1 child: DECLARATION + // 1 child: SUPPORTS_DECLARATION, matching @supports's own shape expect(supportsFunc.children).toHaveLength(1) - const decl = supportsFunc.children[0] as Declaration - expect(decl.type).toBe(DECLARATION) + const decl = supportsFunc.children[0] as SupportsDeclaration + expect(decl.type).toBe(SUPPORTS_DECLARATION) expect(decl.property).toBe('display') expect((decl.value as Value).children[0].text).toBe('grid') @@ -1264,6 +1271,63 @@ describe('Value Node Types', () => { expect((b1.value as Value).text).toBe('red') }) + test('media() condition can be a boolean feature (bare media type)', () => { + const func = getFunc('div { color: if(media(screen): blue; else: red) }') + const mediaFunc = getBranch(func, 0)?.first_child as Function + expect(mediaFunc.children).toHaveLength(1) + const feature = mediaFunc.children[0] as MediaFeature + expect(feature.type).toBe(MEDIA_FEATURE) + expect(feature.property).toBe('screen') + expect(feature.value).toBeNull() + }) + + test('media() condition supports range syntax', () => { + const func = getFunc('div { color: if(media(400px <= width): blue; else: red) }') + const mediaFunc = getBranch(func, 0)?.first_child as Function + expect(mediaFunc.children).toHaveLength(1) + const range = mediaFunc.children[0] as FeatureRange + expect(range.type).toBe(FEATURE_RANGE) + expect(range.name).toBe('width') + expect(range.children[0].type).toBe(DIMENSION) + expect(range.children[0].text).toBe('400px') + expect(range.children[1].type).toBe(PRELUDE_OPERATOR) + expect(range.children[1].text).toBe('<=') + }) + + test('media() condition supports double-sided range syntax', () => { + const func = getFunc('div { color: if(media(400px <= width <= 800px): blue; else: red) }') + const mediaFunc = getBranch(func, 0)?.first_child as Function + const range = mediaFunc.children[0] as FeatureRange + expect(range.type).toBe(FEATURE_RANGE) + expect(range.name).toBe('width') + expect(range.children.map((c) => c.text)).toEqual(['400px', '<=', '<=', '800px']) + }) + + test('supports() condition supports the full compound and/or/not grammar', () => { + const func = getFunc( + 'div { display: if(supports((display: grid) and (gap: 1rem)): grid; else: block) }', + ) + const supportsFunc = getBranch(func, 0)?.first_child as Function + expect(supportsFunc.name).toBe('supports') + // children: SupportsQuery, PreludeOperator("and"), SupportsQuery + expect(supportsFunc.children).toHaveLength(3) + + const first = supportsFunc.children[0] as SupportsQuery + expect(first.type).toBe(SUPPORTS_QUERY) + const firstDecl = first.first_child as SupportsDeclaration + expect(firstDecl.property).toBe('display') + expect((firstDecl.value as Value).text).toBe('grid') + + expect(supportsFunc.children[1].type).toBe(PRELUDE_OPERATOR) + expect(supportsFunc.children[1].text).toBe('and') + + const second = supportsFunc.children[2] as SupportsQuery + expect(second.type).toBe(SUPPORTS_QUERY) + const secondDecl = second.first_child as SupportsDeclaration + expect(secondDecl.property).toBe('gap') + expect((secondDecl.value as Value).text).toBe('1rem') + }) + // ── Multiple branches ───────────────────────────────────────────────── test('should parse if() with three branches', () => { diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index 4987364..ba9233d 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -15,8 +15,6 @@ import { URL, UNICODE_RANGE, IF_BRANCH, - DECLARATION, - MEDIA_FEATURE, VALUE, } from './arena' import { @@ -44,7 +42,7 @@ import { CHAR_FORWARD_SLASH, str_equals, } from './string-utils' -import { trim_boundaries, find_colon_at_depth_zero } from './parse-utils' +import { ConditionParser } from './parse-condition' /** @internal */ export class ValueNodeParser { @@ -54,6 +52,27 @@ export class ValueNodeParser { protected end: number = 0 // Last node from parse_chain(), for callers sizing a wrapper node. Avoids a tuple/array return. last_chain_node: number = 0 + // Shared media-feature/supports-condition/style() parsing for if()'s condition functions, so + // they produce the same MediaFeature/FeatureRange/SupportsQuery/SupportsDeclaration nodes real + // @media/@supports conditions do. Lazily built with a dedicated helper ValueNodeParser — NOT + // `this` — because ConditionParser's content methods call back into the injected parser's + // parse_chain(), which reseeks its own lexer/end; reentering `this` while it's still mid-parse + // (inside parse_if_function_node's own scan) would clobber that in-progress state. Lazy, since + // eagerly constructing the helper here would recurse: every ValueNodeParser's constructor would + // try to build another ValueNodeParser to inject, forever. The helper's own condition_parser is + // simply never accessed (it's only ever used via parse_chain), so the recursion stops there. + private _condition_parser: ConditionParser | null = null + + private get condition_parser(): ConditionParser { + if (this._condition_parser === null) { + this._condition_parser = new ConditionParser( + this.arena, + this.source, + new ValueNodeParser(this.arena, this.source), + ) + } + return this._condition_parser + } constructor(arena: CSSDataArena, source: string) { this.arena = arena @@ -519,9 +538,13 @@ export class ValueNodeParser { } /** - * Parse a condition function inside if() — style(), supports(), or media(). - * Children: style()/supports() → one DECLARATION (property + VALUE); media() → one - * MEDIA_FEATURE; anything else → generic value nodes. + * 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 + * same node shapes real `@supports`/`@media` conditions do: + * style()/supports() → SUPPORTS_DECLARATION(s) — supports() gets the full compound + * grammar (and/or/not, nested conditions), same as `@supports`. + * media() → a single MEDIA_FEATURE or FEATURE_RANGE (comparison syntax), same as `@media`. + * anything else → generic value nodes, as before. * * Called with the current token at TOKEN_FUNCTION (the '(' already consumed). * @param func_start Offset of the function name's first char. @@ -560,14 +583,41 @@ export class ValueNodeParser { // Parse content based on function name let child_nodes: number[] = [] - if (str_equals('style', func_name) || str_equals('supports', func_name)) { - // Parse as DECLARATION (property: value) - let decl = this.parse_declaration_in_range(content_start, content_end) + if (str_equals('style', func_name)) { + let decl = this.condition_parser.parse_supports_declaration_content( + content_start, + content_end, + ) if (decl !== null) 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 + // parens needed); otherwise the full compound grammar, same as `@supports`'s own + // prelude — supports((a) and (b)), supports(not (c)), nested style()/selector()/… + let decl = this.condition_parser.parse_supports_declaration_content( + content_start, + content_end, + ) + 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('media', func_name)) { - // Parse as MEDIA_FEATURE (property: value) - let feature = this.parse_media_feature_in_range(content_start, content_end) - if (feature !== null) child_nodes = [feature] + // media()'s own parens delimit the feature; there's no separate inner paren pair, + // so the feature span equals the content span (see parse_media_feature_content's docs) + let feature = this.condition_parser.parse_media_feature_content( + content_start, + content_end, + content_start, + content_end, + ) + child_nodes = [feature] } else { // Generic: parse content as value nodes child_nodes = this.parse_value_nodes_in_range(content_start, content_end) @@ -577,80 +627,6 @@ export class ValueNodeParser { return func_node } - /** - * Parse a "property: value" text range into a DECLARATION node. - * The DECLARATION has one child: a VALUE node containing the parsed value tokens. - */ - private parse_declaration_in_range(content_start: number, content_end: number): number | null { - let colon = find_colon_at_depth_zero(this.source, content_start, content_end) - let prop_end = colon === -1 ? content_end : colon - let prop_range = trim_boundaries(this.source, content_start, prop_end) - if (!prop_range) return null - - let [prop_start, prop_end_trim] = prop_range - let decl_end = colon === -1 ? prop_end_trim : content_end - - let decl = this.arena.create_node( - DECLARATION, - prop_start, - decl_end - prop_start, - this.lexer.token_line, - this.lexer.token_column, - ) - this.arena.set_content_start_delta(decl, 0) - this.arena.set_content_length(decl, prop_end_trim - prop_start) - - if (colon !== -1) { - let val_range = trim_boundaries(this.source, colon + 1, content_end) - if (val_range) { - let [val_start, val_end] = val_range - let val_nodes = this.parse_value_nodes_in_range(val_start, val_end) - let value_node = this.arena.create_node( - VALUE, - val_start, - val_end - val_start, - this.lexer.token_line, - this.lexer.token_column, - ) - this.arena.append_children(value_node, val_nodes) - this.arena.append_children(decl, [value_node]) - } - } - - return decl - } - - /** - * Parse a "property: value" text range into a MEDIA_FEATURE node. - * Value tokens become children of the MEDIA_FEATURE. - */ - private parse_media_feature_in_range(content_start: number, content_end: number): number | null { - let colon = find_colon_at_depth_zero(this.source, content_start, content_end) - let prop_end = colon === -1 ? content_end : colon - let prop_range = trim_boundaries(this.source, content_start, prop_end) - if (!prop_range) return null - - let feature = this.arena.create_node( - MEDIA_FEATURE, - content_start, - content_end - content_start, - this.lexer.token_line, - this.lexer.token_column, - ) - this.arena.set_content_start_delta(feature, prop_range[0] - content_start) - this.arena.set_content_length(feature, prop_range[1] - prop_range[0]) - - if (colon !== -1) { - let val_range = trim_boundaries(this.source, colon + 1, content_end) - if (val_range) { - let val_nodes = this.parse_value_nodes_in_range(val_range[0], val_range[1]) - this.arena.append_children(feature, val_nodes) - } - } - - return feature - } - /** Parse value tokens in a source sub-range using save/restore to protect lexer state. */ private parse_value_nodes_in_range(start: number, end: number): number[] { let saved_end = this.end From b7c3d5ca6dfd921a22eec1c22a881e3580114d0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 09:21:24 +0000 Subject: [PATCH 10/11] fix: remove duplicate find_colon_at_depth_zero in parse-condition.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the shared parse-utils.ts export instead of a private copy — the private one was flagged by knip as making the shared export unused. --- src/parse-condition.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 3ecad60..ed5796e 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -39,7 +39,11 @@ import { CHAR_EQUALS, CHAR_FORWARD_SLASH, } from './string-utils' -import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' +import { + trim_boundaries, + skip_whitespace_and_comments_forward, + find_colon_at_depth_zero, +} from './parse-utils' import { SelectorParser } from './parse-selector' import type { ValueNodeParser } from './value-node-parser' @@ -454,22 +458,11 @@ export class ConditionParser { * `@import … supports(…)`. Returns null if no top-level ':' is found. */ parse_supports_declaration_content(content_start: number, content_end: number): number | null { - let colon_pos = this.find_colon_at_depth_zero(content_start, content_end) + let colon_pos = find_colon_at_depth_zero(this.source, content_start, content_end) if (colon_pos === -1) return null return this.create_supports_declaration(content_start, content_end, colon_pos) } - private find_colon_at_depth_zero(start: number, end: number): number { - let depth = 0 - for (let i = start; i < end; i++) { - let ch = this.source.charCodeAt(i) - if (ch === 0x28 /* ( */) depth++ - else if (ch === 0x29 /* ) */) depth-- - else if (ch === CHAR_COLON && depth === 0) return i - } - return -1 - } - /** * Parse a `` — the compound and/or/not grammar shared by `@supports` * preludes and if()'s `supports(...)` condition function: parenthesized `(property: value)` From 2767d0c838fc31cfac8c7fb2d059efdf75e38fca Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 16 Aug 2026 14:14:25 +0200 Subject: [PATCH 11/11] fix linting --- package.json | 2 +- src/parse-value.test.ts | 18 +++++++++--------- src/value-node-parser.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 605b97c..9856eaf 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,7 @@ "test-build": "pnpm run build && vitest run --config vitest.config.build.ts", "build": "tsdown", "benchmark": "pnpm run build && node --expose-gc benchmark/index.ts", - "lint": "oxlint --config .oxlintrc.json; oxfmt --check", + "lint": "oxlint --config .oxlintrc.json && oxfmt --check", "check": "tsc --noEmit", "knip": "knip", "precommit": "pnpm run test --run; pnpm run lint; pnpm run check" diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index edd673f..de2ec84 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -1163,13 +1163,13 @@ describe('Value Node Types', () => { const b0 = getBranch(func, 0) const b1 = getBranch(func, 1) - expect(b0?.condition?.text).toBe('style(--active: 1)') - expect((b0?.value as Value).text).toBe('green') - expect(b0?.is_else).toBe(false) + expect(b0!.condition?.text).toBe('style(--active: 1)') + expect((b0!.value as Value).text).toBe('green') + expect(b0!.is_else).toBe(false) - expect(b1?.condition?.text).toBe('else') - expect((b1?.value as Value).text).toBe('red') - expect(b1?.is_else).toBe(true) + expect(b1!.condition?.text).toBe('else') + expect((b1!.value as Value).text).toBe('red') + expect(b1!.is_else).toBe(true) }) test('branch.text spans condition through value', () => { @@ -1378,7 +1378,7 @@ describe('Value Node Types', () => { ) // children[1] is the VALUE wrapper; VALUE.children[0] is the FUNCTION expect(getBranch(func, 0)?.children[1].type).toBe(VALUE) - expect(((getBranch(func, 0)?.children[1] as Value).children[0] as Function).name).toBe( + expect(((getBranch(func, 0)!.children[1] as Value).children[0] as Function).name).toBe( 'oklch', ) }) @@ -1431,9 +1431,9 @@ describe('Value Node Types', () => { expect(innerIf?.name).toBe('if') expect(innerIf?.children).toHaveLength(2) expect(getBranch(innerIf, 0)?.condition?.text).toBe('style(--b: 1)') - expect((getBranch(innerIf, 0)?.value as Value).text).toBe('blue') + expect((getBranch(innerIf, 0)!.value as Value).text).toBe('blue') expect(getBranch(innerIf, 1)?.is_else).toBe(true) - expect((getBranch(innerIf, 1)?.value as Value).text).toBe('green') + expect((getBranch(innerIf, 1)!.value as Value).text).toBe('green') }) // ── Real-world example from the spec ────────────────────────────────── diff --git a/src/value-node-parser.ts b/src/value-node-parser.ts index ba9233d..339d049 100644 --- a/src/value-node-parser.ts +++ b/src/value-node-parser.ts @@ -507,7 +507,7 @@ export class ValueNodeParser { } // ── Create IF_BRANCH node ────────────────────────────────────────── - let branch_end = value_node !== null ? value_last_end : condition_end_pos + let branch_end = value_node === null ? condition_end_pos : value_last_end let branch_node = this.arena.create_node( IF_BRANCH, branch_start,