Skip to content

Commit d74398c

Browse files
bartvenemanclaude
andauthored
Add support for CSS if() inline conditional function parsing (#253)
## Summary This PR adds comprehensive support for parsing CSS `if()` inline conditional functions (CSS Values Level 5 spec). The parser now recognizes `if()` functions and creates a dedicated `IF_BRANCH` node type to represent each condition-value pair within the function. ## Key Changes - **New `IF_BRANCH` node type**: Introduced a new AST node type to represent individual branches within an `if()` function, with properties for: - `condition`: The condition text (e.g., `"style(--active: 1)"` or `"else"`) - `value`: The value text between the colon and semicolon (or `null` if empty) - `is_else`: Boolean flag indicating if this is the `else` branch - `children`: Parsed condition node followed by parsed value nodes - **Dedicated `if()` parser**: Implemented `parse_if_function_node()` in `ValueParser` that: - Recognizes `if()` functions and dispatches to specialized parsing logic - Parses multiple branches separated by semicolons - Handles condition nodes (functions like `style()`, `supports()`, `media()`, or `else` identifier) - Parses value nodes of various types (dimensions, colors, functions, etc.) - Supports nested `if()` functions recursively - Properly tracks location information (start, end, line, column) - **Token handling**: Extended operator parsing to include colons and semicolons as structural separators within `if()` branches - **Type definitions**: Added `IfBranch` type to the public API with proper TypeScript support and type guards - **Comprehensive test coverage**: Added 30+ test cases covering: - Basic structure and node properties - Various condition types (`style()`, `supports()`, `media()`, `else`) - Multiple branches and nested `if()` functions - Different value node types (dimensions, colors, functions) - Empty values and trailing semicolons - Location tracking accuracy - Real-world spec examples ## Implementation Details The parser treats `if()` as a special function that creates a `FUNCTION` node with `IF_BRANCH` children instead of generic value nodes. Each branch's condition and value are tracked separately through arena fields (`contentStartDelta`/`contentLength` for condition, `valueStartDelta`/`valueLength` for value), allowing efficient text extraction without reparsing. The implementation properly handles whitespace, malformed input, and maintains accurate source location information for all nodes. https://claude.ai/code/session_01UmLv7na8e3eUyPAZbjMf3U --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9f93942 commit d74398c

10 files changed

Lines changed: 851 additions & 36 deletions

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@
8181
"test-build": "pnpm run build && vitest run --config vitest.config.build.ts",
8282
"build": "tsdown",
8383
"benchmark": "pnpm run build && node --expose-gc benchmark/index.ts",
84-
"lint": "oxlint --config .oxlintrc.json; oxfmt --check",
84+
"lint": "oxlint --config .oxlintrc.json && oxfmt --check",
8585
"check": "tsc --noEmit",
8686
"knip": "knip",
8787
"precommit": "pnpm run test --run; pnpm run lint; pnpm run check"

src/arena.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export const OPERATOR = 16 // operator: +, -, *, /, comma
6161
export const PARENTHESIS = 17 // parenthesized expression: (100% - 50px)
6262
export const URL = 18 // URL: url("file.css"), url(image.png), used in values and @import
6363
export const UNICODE_RANGE = 19 // unicode range: u+0025-00ff, u+4??
64+
export const IF_BRANCH = 59 // Branch inside an if() function: <condition>: <value>
6465

6566
// Selector node type constants (for detailed selector parsing)
6667
export const SELECTOR_LIST = 20 // comma-separated selectors

src/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
PARENTHESIS,
2121
URL,
2222
UNICODE_RANGE,
23+
IF_BRANCH,
2324
VALUE,
2425
SELECTOR_LIST,
2526
TYPE_SELECTOR,
@@ -67,6 +68,7 @@ export {
6768
PARENTHESIS,
6869
URL,
6970
UNICODE_RANGE,
71+
IF_BRANCH,
7072
VALUE,
7173
SELECTOR_LIST,
7274
TYPE_SELECTOR,
@@ -117,6 +119,7 @@ export const NODE_TYPES = {
117119
PARENTHESIS,
118120
URL,
119121
UNICODE_RANGE,
122+
IF_BRANCH,
120123
VALUE,
121124
// Selector nodes
122125
SELECTOR_LIST,

src/css-node.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
PRELUDE_SELECTORLIST,
4646
SUPPORTS_DECLARATION,
4747
RATIO,
48+
IF_BRANCH,
4849
FLAG_IMPORTANT,
4950
FLAG_HAS_ERROR,
5051
FLAG_HAS_BLOCK,
@@ -66,6 +67,7 @@ import {
6667
is_whitespace,
6768
is_vendor_prefixed,
6869
str_starts_with,
70+
str_equals,
6971
} from './string-utils'
7072
import { parse_dimension } from './parse-dimension'
7173

@@ -115,6 +117,7 @@ export const TYPE_NAMES = {
115117
[AT_RULE_PRELUDE]: 'AtrulePrelude',
116118
[PRELUDE_SELECTORLIST]: 'PreludeSelectorList',
117119
[RATIO]: 'Ratio',
120+
[IF_BRANCH]: 'IfBranch',
118121
} as const
119122

120123
export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown'
@@ -165,6 +168,7 @@ export type CSSNodeType =
165168
| typeof PRELUDE_SELECTORLIST
166169
| typeof SUPPORTS_DECLARATION
167170
| typeof RATIO
171+
| typeof IF_BRANCH
168172

169173
// Options for cloning nodes
170174
export interface CloneOptions {
@@ -199,6 +203,10 @@ export type PlainCSSNode = {
199203
left?: PlainCSSNode
200204
right?: PlainCSSNode
201205

206+
// IfBranch-specific
207+
condition?: PlainCSSNode
208+
is_else?: boolean
209+
202210
// Flags (only when true)
203211
is_important?: boolean
204212
is_vendor_prefixed?: boolean
@@ -253,6 +261,7 @@ const nodes_with_children = new Set<number>([
253261
FEATURE_RANGE,
254262
SUPPORTS_QUERY,
255263
SUPPORTS_DECLARATION,
264+
IF_BRANCH,
256265
])
257266

258267
const enumerable_properties = [
@@ -266,6 +275,8 @@ const enumerable_properties = [
266275
'nth_a',
267276
'nth_b',
268277
'selector',
278+
'condition',
279+
'is_else',
269280
'is_browserhack',
270281
'is_vendor_prefixed',
271282
'has_error',
@@ -380,6 +391,11 @@ export class CSSNode {
380391
return first_child?.first_child ?? null
381392
}
382393

394+
if (type === IF_BRANCH) {
395+
// First child is the condition node; second child (if any) is the VALUE wrapper
396+
return first_child?.next_sibling ?? null
397+
}
398+
383399
if (type === DIMENSION) {
384400
return parse_dimension(text).value
385401
}
@@ -518,6 +534,20 @@ export class CSSNode {
518534
return this.first_child?.next_sibling ?? undefined
519535
}
520536

537+
/** Get the parsed condition node of an if() branch, e.g. the Function "style(--active: 1)" or the Identifier "else" */
538+
get condition(): CSSNode | undefined {
539+
if (this.type !== IF_BRANCH) {
540+
return undefined
541+
}
542+
return this.first_child ?? undefined
543+
}
544+
545+
/** True when this is the else branch of an if() function */
546+
get is_else(): boolean | undefined {
547+
if (this.type !== IF_BRANCH) return undefined
548+
return str_equals('else', this.get_content())
549+
}
550+
521551
/** Check if this declaration has !important */
522552
get is_important(): boolean | undefined {
523553
if (this.type !== DECLARATION) return undefined

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export {
5858
type Parenthesis,
5959
type Url,
6060
type UnicodeRange,
61+
type IfBranch,
6162
type Value,
6263
type SelectorNode,
6364
type TypeSelector,
@@ -103,6 +104,7 @@ export {
103104
is_parenthesis,
104105
is_url,
105106
is_unicode_range,
107+
is_if_branch,
106108
is_value,
107109
is_type_selector,
108110
is_class_selector,

src/node-types.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
PARENTHESIS,
3737
URL,
3838
UNICODE_RANGE,
39+
IF_BRANCH,
3940
VALUE,
4041
SELECTOR_LIST,
4142
TYPE_SELECTOR,
@@ -245,6 +246,7 @@ export type Raw = Leaf<typeof RAW, 'Raw'>
245246

246247
type ValueLike =
247248
| Function
249+
| IfBranch
248250
| Identifier
249251
| Operator
250252
| Parenthesis
@@ -290,7 +292,19 @@ export type Hash = Leaf<typeof HASH, 'Hash'>
290292

291293
export type Function = WithClone<
292294
CSSNode &
293-
WithChildren<ValueLike> & {
295+
// if()-conditions reuse the shared condition parser (parse-condition.ts), so `style()`/
296+
// `supports()` hold SupportsDeclaration/SupportsQuery/PreludeOperator children (matching
297+
// `@supports`'s own shape, including the full compound and/or/not grammar for
298+
// `supports()`) and `media()` holds a MediaFeature or FeatureRange child (matching
299+
// `@media`'s own shape, including range comparison syntax) — see parse_if_condition_function
300+
WithChildren<
301+
| ValueLike
302+
| MediaFeature
303+
| SupportsDeclaration
304+
| SupportsQuery
305+
| FeatureRange
306+
| PreludeOperator
307+
> & {
294308
readonly type: typeof FUNCTION
295309
readonly type_name: 'Function'
296310
/** Function name, e.g. "rgb", "calc" */
@@ -328,6 +342,31 @@ export type Value = WithClone<
328342
CSSNode & WithChildren<ValueLike> & { readonly type: typeof VALUE; readonly type_name: 'Value' }
329343
>
330344

345+
/**
346+
* One branch inside a CSS `if()` inline conditional function.
347+
*
348+
* Each branch corresponds to a `<condition>: <value>` pair in:
349+
* `if( <condition>: <value>; … else: <fallback> )`
350+
*
351+
* - `condition` — the parsed condition node (Function, e.g. `style(--x: 1)`, or Identifier `else`)
352+
* - `value` — the value text, e.g. `"green"`; `null` when omitted
353+
* - `is_else` — `true` for the `else` branch
354+
* - `first_child` — same node as `condition`
355+
* - `children` — condition node followed by parsed value nodes
356+
*/
357+
export type IfBranch = CSSNode &
358+
WithChildren<Function | Identifier | Value> & {
359+
readonly type: typeof IF_BRANCH
360+
readonly type_name: 'IfBranch'
361+
/** The parsed condition node, e.g. the Function "style(--active: 1)" or the Identifier "else" */
362+
readonly condition: Function | Identifier
363+
/** The parsed value as a VALUE node, or null when the branch value is empty */
364+
readonly value: Value | null
365+
/** True when this is the else branch */
366+
readonly is_else: boolean
367+
clone(options?: CloneOptions): ToPlain<IfBranch>
368+
}
369+
331370
// ---------------------------------------------------------------------------
332371
// Selector nodes
333372
// ---------------------------------------------------------------------------
@@ -598,6 +637,7 @@ export type AnyNode =
598637
| Parenthesis
599638
| Url
600639
| UnicodeRange
640+
| IfBranch
601641
| Value
602642
| TypeSelector
603643
| ClassSelector
@@ -688,6 +728,9 @@ export function is_url(node: CSSNode): node is Url {
688728
export function is_unicode_range(node: CSSNode): node is UnicodeRange {
689729
return node.type === UNICODE_RANGE
690730
}
731+
export function is_if_branch(node: CSSNode): node is IfBranch {
732+
return node.type === IF_BRANCH
733+
}
691734
export function is_value(node: CSSNode): node is Value {
692735
return node.type === VALUE
693736
}

src/parse-condition.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,11 @@ import {
3939
CHAR_EQUALS,
4040
CHAR_FORWARD_SLASH,
4141
} from './string-utils'
42-
import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils'
42+
import {
43+
trim_boundaries,
44+
skip_whitespace_and_comments_forward,
45+
find_colon_at_depth_zero,
46+
} from './parse-utils'
4347
import { SelectorParser } from './parse-selector'
4448
import type { ValueNodeParser } from './value-node-parser'
4549

@@ -308,6 +312,12 @@ export class ConditionParser {
308312
content_start: number,
309313
content_end: number,
310314
): number {
315+
// parse_feature_range() below tokenizes via this.next_token(), which is bounded by
316+
// this.end — set it here so a direct call (bypassing parse_media_feature(), which would
317+
// otherwise set it) doesn't leave it stale. Never widens: content_end is always within
318+
// whatever bound the caller already established.
319+
this.end = content_end
320+
311321
// Check for range syntax (has comparison operators)
312322
let has_comparison = false
313323
let i = content_start
@@ -448,22 +458,11 @@ export class ConditionParser {
448458
* `@import … supports(…)`. Returns null if no top-level ':' is found.
449459
*/
450460
parse_supports_declaration_content(content_start: number, content_end: number): number | null {
451-
let colon_pos = this.find_colon_at_depth_zero(content_start, content_end)
461+
let colon_pos = find_colon_at_depth_zero(this.source, content_start, content_end)
452462
if (colon_pos === -1) return null
453463
return this.create_supports_declaration(content_start, content_end, colon_pos)
454464
}
455465

456-
private find_colon_at_depth_zero(start: number, end: number): number {
457-
let depth = 0
458-
for (let i = start; i < end; i++) {
459-
let ch = this.source.charCodeAt(i)
460-
if (ch === 0x28 /* ( */) depth++
461-
else if (ch === 0x29 /* ) */) depth--
462-
else if (ch === CHAR_COLON && depth === 0) return i
463-
}
464-
return -1
465-
}
466-
467466
/**
468467
* Parse a `<supports-condition>` — the compound and/or/not grammar shared by `@supports`
469468
* preludes and if()'s `supports(...)` condition function: parenthesized `(property: value)`

src/parse-utils.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { CHAR_ASTERISK, CHAR_FORWARD_SLASH, is_whitespace } from './string-utils'
1+
import {
2+
CHAR_ASTERISK,
3+
CHAR_COLON,
4+
CHAR_FORWARD_SLASH,
5+
CHAR_LEFT_PAREN,
6+
CHAR_RIGHT_PAREN,
7+
is_whitespace,
8+
} from './string-utils'
29

310
/** @internal */
411
export function skip_whitespace_forward(source: string, pos: number, end: number): number {
@@ -101,3 +108,15 @@ export function trim_boundaries(
101108
if (start >= end) return null
102109
return [start, end]
103110
}
111+
112+
/** Find the position of the first ':' at parenthesis depth 0 in [start, end). Returns -1 if not found. @internal */
113+
export function find_colon_at_depth_zero(source: string, start: number, end: number): number {
114+
let depth = 0
115+
for (let i = start; i < end; i++) {
116+
let ch = source.charCodeAt(i)
117+
if (ch === CHAR_LEFT_PAREN) depth++
118+
else if (ch === CHAR_RIGHT_PAREN) depth--
119+
else if (ch === CHAR_COLON && depth === 0) return i
120+
}
121+
return -1
122+
}

0 commit comments

Comments
 (0)