From 5a3854ce1fcda3eb32ce54148f5e5325b603f003 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 18:58:21 +0000 Subject: [PATCH 1/2] perf: shrink published package by ~3.5% (216.5 kB to 208.8 kB) - Turn JSDoc on internal CSSNode/Lexer/arena/parser members into line comments so they no longer ship in dist - Share scan_matching_paren between AtRulePreludeParser and ConditionParser - Condense if() node type docs - Condense README API section (10.8 kB to 6.5 kB) Co-Authored-By: Claude Sonnet 5.5 --- README.md | 131 +++++++----------------------------- src/arena.ts | 2 +- src/css-node.ts | 94 ++++++++++++-------------- src/node-types.ts | 29 ++------ src/parse-anplusb.ts | 12 ++-- src/parse-atrule-prelude.ts | 64 +++++------------- src/parse-condition.ts | 62 +++++------------ src/parse-utils.ts | 34 ++++++++++ src/tokenize.ts | 52 ++++++-------- 9 files changed, 167 insertions(+), 313 deletions(-) diff --git a/README.md b/README.md index 3afada5..01a3ead 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Heavily influenced by [CSSTree](https://github.com/csstree/csstree). Some parsin - **Full modern CSS** — CSS Nesting, `:is()`, `:where()`, `:has()`, `@layer`, `@container` - **Error-tolerant** — keeps parsing through malformed input; one bad rule won't kill the file - **Precise source locations** — line, column, offset, and length on every node -- **Tiny footprint** — tree-shakeable subparsers; as small as 278 bytes gzipped, < 16 kB kB worst case +- **Tiny footprint** — tree-shakeable subparsers; as small as 278 bytes gzipped, < 16 kB worst case ## Installation @@ -65,34 +65,30 @@ for (const rule of ast) { ## API -Everything is exported from the package root. Each part is also available from its own entry point, so you can import only what you need and skip evaluating the rest of the package. Node properties and return types ship as TypeScript types, so this section sticks to short examples. +Everything is exported from the package root. Each part is also available from its own entry point, so you can import only what you need. Node properties and return types ship as TypeScript types. ### Entry points -| Import | Exports | -| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | -| `@projectwallace/css-parser` | Everything below, except the `Lexer` and `Parser` classes | -| `@projectwallace/css-parser/parse` | `parse`, `Parser`, `ParserOptions` | -| `@projectwallace/css-parser/parse-selector` | `parse_selector`, `parse_selector_list` | -| `@projectwallace/css-parser/parse-atrule-prelude` | `parse_atrule_prelude` | -| `@projectwallace/css-parser/parse-declaration` | `parse_declaration` | -| `@projectwallace/css-parser/parse-value` | `parse_value` | -| `@projectwallace/css-parser/parse-anplusb` | `parse_anplusb` | -| `@projectwallace/css-parser/parse-dimension` | `parse_dimension` | -| `@projectwallace/css-parser/tokenizer` | `tokenize`, `Lexer` | -| `@projectwallace/css-parser/walker` | `walk`, `traverse`, `SKIP`, `BREAK` | -| `@projectwallace/css-parser/nodes` | Node type constants (`STYLE_RULE`, `DECLARATION`, …), `NODE_TYPES` | -| `@projectwallace/css-parser/node-types` | Type guards (`is_declaration`, `is_rule`, …) and node types (`CSSNode`, `Rule`, `Declaration`, …) | -| `@projectwallace/css-parser/string-utils` | `is_custom`, `is_vendor_prefixed`, `strip_vendor_prefix`, `str_equals`, `str_starts_with`, `str_index_of` | -| `@projectwallace/css-parser/token-types` | Token type constants (`TOKEN_*`) | +- `@projectwallace/css-parser` — everything below, except the `Lexer` and `Parser` classes +- `/parse` — `parse`, `Parser`, `ParserOptions` +- `/parse-selector` — `parse_selector`, `parse_selector_list` +- `/parse-atrule-prelude` — `parse_atrule_prelude` +- `/parse-declaration` — `parse_declaration` +- `/parse-value` — `parse_value` +- `/parse-anplusb` — `parse_anplusb` +- `/parse-dimension` — `parse_dimension` +- `/tokenizer` — `tokenize`, `Lexer` +- `/walker` — `walk`, `traverse`, `SKIP`, `BREAK` +- `/nodes` — node type constants (`STYLE_RULE`, `DECLARATION`, …), `NODE_TYPES` +- `/node-types` — type guards (`is_declaration`, `is_rule`, …) and node types (`CSSNode`, `Rule`, …) +- `/string-utils` — `is_custom`, `is_vendor_prefixed`, `strip_vendor_prefix`, `str_equals`, `str_starts_with`, `str_index_of` +- `/token-types` — token type constants (`TOKEN_*`) ### `parse(source, options?)` -Parses a full stylesheet and returns the root `StyleSheet` node. Nodes are iterable and expose `first_child`, `next_sibling`, `children`, `text`, `line`, `column`, `start`, `end` and `length`. +Returns the root `StyleSheet` node. Nodes are iterable and expose `first_child`, `next_sibling`, `children`, `text`, `line`, `column`, `start`, `end` and `length`. ```typescript -import { parse } from '@projectwallace/css-parser' - const ast = parse('body { color: red; }') const rule = ast.first_child rule.block.first_child.property // "color" @@ -104,72 +100,26 @@ Options: - `parse_values` (default `true`) — parse declaration values into nodes - `parse_selectors` (default `true`) — parse selectors into nodes - `parse_atrule_preludes` (default `true`) — parse at-rule preludes into nodes -- `on_comment` — called for every comment; comments are never part of the tree - -```typescript -const comments = [] -parse('/* hi */ a { color: red }', { - parse_values: false, - on_comment: (info) => comments.push(info), // { start, end, length, line, column } -}) -``` - -### `parse_selector(source)` and `parse_selector_list(source)` +- `on_comment` — called for every comment (`{ start, end, length, line, column }`); comments are never part of the tree -`parse_selector` returns the first selector, `parse_selector_list` the whole `SelectorList`. +### Sub-parsers ```typescript -import { parse_selector, parse_selector_list } from '@projectwallace/css-parser' - parse_selector('a.b > c').children.map((part) => part.text) // ["a", ".b", ">", "c"] parse_selector_list('h1, h2').children.map((s) => s.text) // ["h1", "h2"] -``` - -### `parse_declaration(source)` - -```typescript -import { parse_declaration } from '@projectwallace/css-parser' const decl = parse_declaration('color: red !important') decl.property // "color" decl.is_important // true -decl.value.text // "red" -``` - -### `parse_value(source)` - -Parses a declaration value into a `Value` node with one child per component. - -```typescript -import { parse_value } from '@projectwallace/css-parser' for (const node of parse_value('10px 20px')) { - console.log(node.type_name, node.value, node.unit) // "Dimension" 10 "px", then "Dimension" 20 "px" + node.type_name // "Dimension" } -``` - -### `parse_atrule_prelude(name, prelude)` - -Returns an array of nodes for the prelude of the at-rule called `name` (without the `@`). - -```typescript -import { parse_atrule_prelude } from '@projectwallace/css-parser' -const [query] = parse_atrule_prelude('media', '(min-width: 768px)') +const [query] = parse_atrule_prelude('media', '(min-width: 768px)') // name without the `@` query.type_name // "MediaQuery" -``` - -### `parse_dimension(text)` - -Splits a number from its unit. It works for anything shaped like a number followed by a unit: lengths (`100px`), percentages (`2%`), durations (`200ms`) and so on. It does not validate; text without a leading number gives `value: 0`. -```typescript -import { parse_dimension } from '@projectwallace/css-parser' - -parse_dimension('100px') // { value: 100, unit: "px" } -parse_dimension('2%') // { value: 2, unit: "%" } -parse_dimension('1.5e3s') // { value: 1500, unit: "s" } -parse_dimension('5') // { value: 5, unit: "" } +parse_dimension('1.5e3s') // { value: 1500, unit: "s" } (no validation; no leading number gives `value: 0`) ``` ### `walk(node, callback)` and `traverse(node, { enter, leave })` @@ -177,28 +127,13 @@ parse_dimension('5') // { value: 5, unit: "" } Both walk the tree depth-first. Return `SKIP` to skip the children of a node and `BREAK` to stop entirely. `walk` also passes a `depth` that counts rule and at-rule nesting. `traverse` calls `enter` before and `leave` after the children (`leave` still runs after `SKIP`, but not after `BREAK`). ```typescript -import { - parse, - walk, - traverse, - SKIP, - BREAK, - DECLARATION, - AT_RULE, -} from '@projectwallace/css-parser' - -const ast = parse('@media screen { a { color: red; margin: 0 } }') - walk(ast, (node) => { - if (node.type === DECLARATION) { - console.log(node.property) // "color" - return BREAK - } + if (node.type === DECLARATION) return BREAK }) traverse(ast, { enter(node) { - if (node.type === AT_RULE) return SKIP // leave() still runs for this node + if (node.type === AT_RULE) return SKIP }, leave(node) {}, }) @@ -209,8 +144,6 @@ traverse(ast, { A generator of tokens; comments are skipped and reported through `on_comment`. ```typescript -import { tokenize } from '@projectwallace/css-parser' - for (const token of tokenize('a { color: red }')) { console.log(token.type, token.start, token.end) } @@ -218,31 +151,19 @@ for (const token of tokenize('a { color: red }')) { ### Node types, type guards and `clone()` -Node type constants (`STYLE_RULE`, `DECLARATION`, `DIMENSION`, …) and `NODE_TYPES` are exported from the root and from `@projectwallace/css-parser/nodes`. Type guards such as `is_declaration()` and `is_dimension()` that narrow a node to its specific type are exported from the root and from `@projectwallace/css-parser/node-types`. `node.clone({ deep?, locations? })` returns a mutable plain-object copy of a node. +Type guards such as `is_declaration()` narrow a node to its specific type. `node.clone({ deep?, locations? })` returns a mutable plain-object copy of a node. ```typescript -import { parse, is_declaration } from '@projectwallace/css-parser' - const decl = parse('a { color: red }').first_child.block.first_child is_declaration(decl) // true decl.type_name // "Declaration" -decl.clone({ deep: false }).property // "color" ``` ### String helpers -Allocation-free helpers, handy when working with node names. Also available from `@projectwallace/css-parser/string-utils`; token type constants (`TOKEN_*`) are available from `@projectwallace/css-parser/token-types`. The lowercase argument is always the first one. +Allocation-free helpers, handy when working with node names. The lowercase argument is always the first one. ```typescript -import { - is_custom, - is_vendor_prefixed, - strip_vendor_prefix, - str_equals, - str_starts_with, - str_index_of, -} from '@projectwallace/css-parser' - is_custom('--primary') // true is_vendor_prefixed('-webkit-transform') // true strip_vendor_prefix('-webkit-transform') // "transform" diff --git a/src/arena.ts b/src/arena.ts index 3eff134..b2f016a 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -372,7 +372,7 @@ export class CSSDataArena { } } - /** Shrink the buffer to the live node count, releasing wasted capacity. Call once after parsing; no-op if already tight. */ + // Shrink the buffer to the live node count, releasing wasted capacity. Call once after parsing; no-op if already tight. trim(): void { if (this.count === this.capacity) return const byte_count = this.count * BYTES_PER_NODE diff --git a/src/css-node.ts b/src/css-node.ts index 6d2bcff..03ffa3c 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -317,12 +317,12 @@ export class CSSNode { return this.source.substring(start, start + length) } - /** Get node type as number (for performance) */ + // Get node type as number (for performance) get type(): CSSNodeType { return this.arena.get_type(this.index) as CSSNodeType } - /** Get node type as human-readable string */ + // Get node type as human-readable string get type_name(): TypeName { // A switch compiles to a jump table, which is faster than an object lookup switch (this.type) { @@ -423,14 +423,14 @@ export class CSSNode { } } - /** Get the full text of this node from source */ + // Get the full text of this node from source get text(): string { const start = this.arena.get_start_offset(this.index) const length = this.arena.get_length(this.index) return this.source.substring(start, start + length) } - /** Get the "content" text (at-rule name for at-rules, layer name for import layers) */ + // Get the "content" text (at-rule name for at-rules, layer name for import layers) get name(): string | null | undefined { if (!nodes_with_name.has(this.type)) return const { type } = this @@ -440,7 +440,7 @@ export class CSSNode { return content } - /** Namespace prefix for type/universal selectors: null (none), '' (`|div`), 'ns' (`ns|div`), or '*' (`*|div`). */ + // Namespace prefix for type/universal selectors: null (none), '' (`|div`), 'ns' (`ns|div`), or '*' (`*|div`). get namespace(): string | null | undefined { const { type } = this if (type !== TYPE_SELECTOR && type !== UNIVERSAL_SELECTOR) return undefined @@ -450,17 +450,15 @@ export class CSSNode { return this.source.substring(start, start + length) } - /** Alias for `name` on declarations ("color" in "color: blue"), more semantic than `name` there. */ + // Alias for `name` on declarations ("color" in "color: blue"), more semantic than `name` there. get property(): string | undefined { const { type } = this if (type !== DECLARATION && type !== MEDIA_FEATURE && type !== SUPPORTS_DECLARATION) return return this.get_content() } - /** - * Declarations: the value text ("1px solid blue"). Dimension/number: the numeric value. - * String: content without quotes. URL: quoted string keeps quotes, unquoted URL doesn't. - */ + // Declarations: the value text ("1px solid blue"). Dimension/number: the numeric value. + // String: content without quotes. URL: quoted string keeps quotes, unquoted URL doesn't. get value(): CSSNode | string | number | null | undefined { const { type, text, first_child } = this @@ -544,7 +542,7 @@ export class CSSNode { return this.source.substring(start, start + length) } - /** At-rules: AT_RULE_PRELUDE wrapper (media queries, layer names, …). Style rules: SELECTOR_LIST/SELECTOR. Null if none. */ + // At-rules: AT_RULE_PRELUDE wrapper (media queries, layer names, …). Style rules: SELECTOR_LIST/SELECTOR. Null if none. get prelude(): CSSNode | null | undefined { if (this.type === AT_RULE) { const first = this.first_child @@ -560,7 +558,7 @@ export class CSSNode { return undefined } - /** Attribute selector operator (=, ~=, |=, ^=, $=, *=), or null for the bare `[attr]` form. */ + // Attribute selector operator (=, ~=, |=, ^=, $=, *=), or null for the bare `[attr]` form. get attr_operator(): string | null | undefined { if (this.type !== ATTRIBUTE_SELECTOR) return undefined @@ -587,7 +585,7 @@ export class CSSNode { return null } - /** Attribute selector flag: "i" (case-insensitive), "s" (case-sensitive), or null if absent. */ + // Attribute selector flag: "i" (case-insensitive), "s" (case-sensitive), or null if absent. get attr_flags(): string | null | undefined { if (this.type !== ATTRIBUTE_SELECTOR) return undefined @@ -611,26 +609,26 @@ export class CSSNode { return null } - /** Get the unit for dimension nodes (e.g., "px" from "100px", "%" from "50%") */ + // Get the unit for dimension nodes (e.g., "px" from "100px", "%" from "50%") get unit(): string | undefined { if (this.type !== DIMENSION) return undefined const text = this.text return text.substring(dimension_number_end(text)) } - /** Numerator for ratio values, e.g. the Number "16" in `aspect-ratio: 16/9` */ + // Numerator for ratio values, e.g. the Number "16" in `aspect-ratio: 16/9` get left(): CSSNode | undefined { if (this.type !== RATIO) return undefined return this.first_child ?? undefined } - /** Denominator for ratio values, e.g. the Number "9" in `aspect-ratio: 16/9` */ + // Denominator for ratio values, e.g. the Number "9" in `aspect-ratio: 16/9` get right(): CSSNode | undefined { if (this.type !== RATIO) return undefined return this.first_child?.next_sibling ?? undefined } - /** Get the parsed condition node of an if() branch, e.g. the Function "style(--active: 1)" or the Identifier "else" */ + // 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 @@ -638,25 +636,25 @@ export class CSSNode { return this.first_child ?? undefined } - /** True when this is the else branch of an if() function */ + // 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 */ + // Check if this declaration has !important get is_important(): boolean | undefined { if (this.type !== DECLARATION) return undefined return this.arena.has_flag(this.index, FLAG_IMPORTANT) } - /** Check if this declaration has a browser hack prefix */ + // Check if this declaration has a browser hack prefix get is_browserhack(): boolean | undefined { if (this.type !== DECLARATION) return undefined return this.arena.has_flag(this.index, FLAG_BROWSERHACK) } - /** Check if this has a vendor prefix (computed on-demand) */ + // Check if this has a vendor prefix (computed on-demand) get is_vendor_prefixed(): boolean { switch (this.type) { case DECLARATION: @@ -683,12 +681,12 @@ export class CSSNode { } } - /** Check if this node has an error */ + // Check if this node has an error get has_error(): boolean { return this.arena.has_flag(this.index, FLAG_HAS_ERROR) } - /** Check if this node has a prelude (at-rules and style rules) */ + // Check if this node has a prelude (at-rules and style rules) get has_prelude(): boolean { const { type } = this if (type === AT_RULE) { @@ -700,19 +698,19 @@ export class CSSNode { return false } - /** Check if this rule has a block { } */ + // Check if this rule has a block { } get has_block(): boolean { return this.arena.has_flag(this.index, FLAG_HAS_BLOCK) } - /** Check if this style rule has declarations */ + // Check if this style rule has declarations get has_declarations(): boolean | undefined { const { type } = this if (type !== AT_RULE && type !== STYLE_RULE) return undefined return this.arena.has_flag(this.index, FLAG_HAS_DECLARATIONS) } - /** Get the block node (for style rules and at-rules with blocks) */ + // Get the block node (for style rules and at-rules with blocks) get block(): CSSNode | null { const { type } = this // For StyleRule: block is sibling after selector list @@ -743,7 +741,7 @@ export class CSSNode { return null } - /** Check if this block is empty (no declarations or rules, only comments allowed) */ + // Check if this block is empty (no declarations or rules, only comments allowed) get is_empty(): boolean | undefined { // Only valid on block nodes if (this.type !== BLOCK) return undefined @@ -759,57 +757,55 @@ export class CSSNode { return true } - /** Get start line number */ + // Get start line number get line(): number { return this.arena.get_start_line(this.index) } - /** Get start column number */ + // Get start column number get column(): number { return this.arena.get_start_column(this.index) } - /** Get start offset in source */ + // Get start offset in source get start(): number { return this.arena.get_start_offset(this.index) } - /** Get length in source */ + // Get length in source get length(): number { return this.arena.get_length(this.index) } - /** - * Get end offset in source - * End is not stored, must be calculated - */ + // Get end offset in source + // End is not stored, must be calculated get end(): number { return this.start + this.length } // --- Tree Traversal --- - /** Get first child node */ + // Get first child node get first_child(): CSSNode | null { const child_index = this.arena.get_first_child(this.index) if (child_index === 0) return null return new CSSNode(this.arena, this.source, child_index) } - /** Get next sibling node */ + // Get next sibling node get next_sibling(): CSSNode | null { const sibling_index = this.arena.get_next_sibling(this.index) if (sibling_index === 0) return null return new CSSNode(this.arena, this.source, sibling_index) } - /** Check if this node has a next sibling */ + // Check if this node has a next sibling get has_next(): boolean { const sibling_index = this.arena.get_next_sibling(this.index) return sibling_index !== 0 } - /** Whether this node has children. For pseudo-class/element functions, tracks FLAG_HAS_PARENS so formatters can tell `:lang()` from `:hover`. */ + // Whether this node has children. For pseudo-class/element functions, tracks FLAG_HAS_PARENS so formatters can tell `:lang()` from `:hover`. get has_children(): boolean { const { type } = this // For pseudo-class/pseudo-element nodes, check if they have function syntax @@ -823,7 +819,7 @@ export class CSSNode { return this.arena.has_children(this.index) } - /** Count children without allocating an intermediate array */ + // Count children without allocating an intermediate array get child_count(): number { let count = 0 let child_index = this.arena.get_first_child(this.index) @@ -834,7 +830,7 @@ export class CSSNode { return count } - /** Get all children as an array */ + // Get all children as an array get children(): CSSNode[] | undefined { const result: CSSNode[] = [] let child = this.first_child @@ -845,7 +841,7 @@ export class CSSNode { return result } - /** Make CSSNode iterable over its children */ + // Make CSSNode iterable over its children *[Symbol.iterator](): Iterator { let child = this.first_child while (child) { @@ -856,7 +852,7 @@ export class CSSNode { // --- An+B Expression Helpers (for NODE_SELECTOR_NTH) --- - /** Get the 'a' coefficient from An+B expression (e.g., "2n" from "2n+1", "odd" from "odd") */ + // Get the 'a' coefficient from An+B expression (e.g., "2n" from "2n+1", "odd" from "odd") get nth_a(): string | null | undefined { const { type, arena, index } = this if (type !== NTH_SELECTOR && type !== NTH_OF_SELECTOR) return undefined @@ -867,7 +863,7 @@ export class CSSNode { return this.source.substring(start, start + len) } - /** Get the 'b' coefficient from An+B expression (e.g., "+1" from "2n+1") */ + // Get the 'b' coefficient from An+B expression (e.g., "+1" from "2n+1") get nth_b(): string | null | undefined { const { type, arena, index, source } = this if (type !== NTH_SELECTOR && type !== NTH_OF_SELECTOR) return undefined @@ -900,13 +896,13 @@ export class CSSNode { // --- Pseudo-Class Nth-Of Helpers (for NODE_SELECTOR_NTH_OF) --- - /** Get the An+B formula node from :nth-child(2n+1 of .foo) */ + // Get the An+B formula node from :nth-child(2n+1 of .foo) get nth(): CSSNode | undefined { if (this.type !== NTH_OF_SELECTOR) return undefined return this.first_child ?? undefined // First child is always NODE_SELECTOR_NTH } - /** Get the selector list from :nth-child(2n+1 of .foo) */ + // Get the selector list from :nth-child(2n+1 of .foo) get selector(): CSSNode | undefined { if (this.type !== NTH_OF_SELECTOR) return undefined const first = this.first_child @@ -915,10 +911,8 @@ export class CSSNode { // --- Node Cloning --- - /** - * Clone this node as a mutable plain object with children as arrays. - * Use sparingly — can consume a lot of memory. - */ + // Clone this node as a mutable plain object with children as arrays. + // Use sparingly — can consume a lot of memory. clone(options: CloneOptions = {}): PlainCSSNode { const { deep = true, locations = false } = options const { type } = this diff --git a/src/node-types.ts b/src/node-types.ts index 40c7e4b..5c8aa5b 100644 --- a/src/node-types.ts +++ b/src/node-types.ts @@ -349,14 +349,7 @@ export type Value = WithClone< CSSNode & WithChildren & { readonly type: typeof VALUE; readonly type_name: 'Value' } > -/** - * A compound `if()`-branch condition combining multiple tests with `not`/`and`/`or`, - * e.g. `not style(--scheme: light)` or `style(--a: 1) and media(width > 600px)`. - * - * Children are the flat sequence of PreludeOperator ("not"/"and"/"or") and - * Function (`style()`/`media()`/`supports()`) nodes, in source order — the same - * shape `@supports`'s own compound condition uses. - */ +/** Compound `if()` condition: a flat sequence of `not`/`and`/`or` operators and `style()`/`media()`/`supports()` functions, e.g. `not style(--a: 1)`. */ export type IfCondition = WithClone< CSSNode & WithChildren & { @@ -365,29 +358,15 @@ export type IfCondition = WithClone< } > -/** - * One branch inside a CSS `if()` inline conditional function. - * - * Each branch corresponds to a `: ` pair in: - * `if( : ; … else: )` - * - * - `condition` — the parsed condition node: a Function (e.g. `style(--x: 1)`), an - * Identifier (`else`), or an IfCondition for compound `not`/`and`/`or` conditions - * (e.g. `not style(--x: 1)`) - * - `value` — the value text, e.g. `"green"`; `null` when omitted - * - `is_else` — `true` for the `else` branch - * - `first_child` — same node as `condition` - * - `children` — condition node followed by parsed value nodes - */ +/** A `: ` branch of an `if()` function. `children` is the condition followed by the parsed value nodes. */ export type IfBranch = CSSNode & WithChildren & { readonly type: typeof IF_BRANCH readonly type_name: 'IfBranch' - /** The parsed condition node, e.g. the Function "style(--active: 1)", the Identifier "else", or an IfCondition for compound conditions */ + /** Function (`style(--x: 1)`), Identifier (`else`) or IfCondition */ readonly condition: Function | Identifier | IfCondition - /** The parsed value as a VALUE node, or null when the branch value is empty */ + /** Branch value, or null when 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-anplusb.ts b/src/parse-anplusb.ts index 03fadca..e6b1679 100644 --- a/src/parse-anplusb.ts +++ b/src/parse-anplusb.ts @@ -31,10 +31,8 @@ export class ANplusBParser { this.expr_end = 0 } - /** - * Parse An+B expression - * Examples: odd, even, 3, n, -n, 2n, 2n+1, -3n-5 - */ + // Parse An+B expression + // Examples: odd, even, 3, n, -n, 2n, 2n+1, -3n-5 parse_anplusb(start: number, end: number, line: number = 1): number | null { this.expr_end = end this.lexer.seek(start, line) @@ -238,10 +236,8 @@ export class ANplusBParser { return null } - /** - * Parse the b part after 'n' - * Handles: +5, -3, whitespace variations - */ + // Parse the b part after 'n' + // Handles: +5, -3, whitespace variations private parse_b_part(): string | null { this.skip_whitespace() diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index aeaf824..44ffc57 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -33,7 +33,12 @@ import { strip_vendor_prefix, CHAR_PERIOD, } from './string-utils' -import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' +import { + trim_boundaries, + skip_whitespace_and_comments_forward, + scan_matching_paren, + paren_scan, +} from './parse-utils' import { CSSNode } from './css-node' import type { AnyNode } from './node-types' import { ValueNodeParser } from './value-node-parser' @@ -153,41 +158,6 @@ export class AtRulePreludeParser { ) } - // 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. Returns - // [content_end, close_end, matched]: content_end/close_end are the positions right - // before/after the closing ')'; matched is false if EOF was hit first, in which case - // content_end/close_end are left at the position scanning started from (mirroring the - // caller's own pre-loop defaults, so callers that don't check `matched` still get sane - // fallback spans). - private paren_content_end = 0 - private paren_close_end = 0 - - private scan_matching_paren(): boolean { - let depth = 1 - let content_end = this.lexer.pos - let close_end = this.lexer.token_end - - while (this.lexer.pos < this.prelude_end && depth > 0) { - const token_type = this.next_token() - 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 - } - } else if (token_type === TOKEN_EOF) { - break - } - } - - this.paren_content_end = content_end - this.paren_close_end = close_end - return depth === 0 - } - // Parse a single media query: screen and (min-width: 768px) private parse_single_media_query(): number | null { const query_start = this.lexer.pos @@ -543,8 +513,8 @@ export class AtRulePreludeParser { if (this.lexer.token_type === TOKEN_FUNCTION) { // It's url( ... we need to find the matching ) - this.scan_matching_paren() - const close_end = this.paren_close_end + scan_matching_paren(this.lexer, this.prelude_end) + const close_end = paren_scan.close_end url_end = close_end } @@ -582,9 +552,9 @@ export class AtRulePreludeParser { // layer(...): the Function owns the parentheses, the LayerName only the name const content_start = this.lexer.token_end // After the opening '(' - const matched = this.scan_matching_paren() - const content_end = this.paren_content_end - const layer_end = matched ? this.paren_close_end : this.lexer.pos + const matched = scan_matching_paren(this.lexer, this.prelude_end) + const content_end = paren_scan.content_end + const layer_end = matched ? paren_scan.close_end : this.lexer.pos const func_node = this.create_node(FUNCTION, layer_start, layer_end) this.arena.set_content_start_delta(func_node, 0) @@ -638,9 +608,9 @@ export class AtRulePreludeParser { const column = this.lexer.column // Find matching closing parenthesis - this.scan_matching_paren() - const content_end = this.paren_content_end - const supports_end = this.paren_close_end + scan_matching_paren(this.lexer, this.prelude_end) + const content_end = paren_scan.content_end + const supports_end = paren_scan.close_end const func_node = this.create_node(FUNCTION, supports_start, supports_end) this.arena.set_content_start_delta(func_node, 0) @@ -751,9 +721,9 @@ export class AtRulePreludeParser { const paren_start = this.lexer.token_start const content_start = this.lexer.pos - this.scan_matching_paren() - const content_end = this.paren_content_end - const paren_end = this.paren_close_end + scan_matching_paren(this.lexer, this.prelude_end) + const content_end = paren_scan.content_end + const paren_end = paren_scan.close_end const scope_node = this.create_node(PRELUDE_SELECTORLIST, paren_start, paren_end) const trimmed = trim_boundaries(this.source, content_start, content_end) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index f0cb1dd..745b2c7 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -45,6 +45,8 @@ import { trim_boundaries, skip_whitespace_and_comments_forward, find_colon_at_depth_zero, + scan_matching_paren, + paren_scan, } from './parse-utils' import { SelectorParser } from './parse-selector' import type { ValueNodeParser } from './value-node-parser' @@ -94,41 +96,6 @@ export class ConditionParser { return this.lexer.next_token(false) } - // 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. Returns - // [content_end, close_end, matched]: content_end/close_end are the positions right - // before/after the closing ')'; matched is false if EOF was hit first, in which case - // content_end/close_end are left at the position scanning started from (mirroring the - // caller's own pre-loop defaults, so callers that don't check `matched` still get sane - // fallback spans). - private paren_content_end = 0 - private paren_close_end = 0 - - private scan_matching_paren(): boolean { - let depth = 1 - let content_end = this.lexer.pos - let close_end = this.lexer.token_end - - while (this.lexer.pos < this.end && depth > 0) { - const token_type = this.next_token() - 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 - } - } else if (token_type === TOKEN_EOF) { - break - } - } - - this.paren_content_end = content_end - this.paren_close_end = close_end - return depth === 0 - } - // Parse feature value via the shared ValueNodeParser, so calc()/env()/var() get full children. // Own lexer instance, so it doesn't disturb this.lexer's position — no save/restore needed. private parse_feature_value(start: number, end: number): number { @@ -277,8 +244,11 @@ export class ConditionParser { // token only covers 'name(' — extend to its matching ')' so calc() etc. stay whole const value_start = this.lexer.token_start let value_end = this.lexer.token_end - if (this.lexer.token_type === TOKEN_FUNCTION && this.scan_matching_paren()) { - value_end = this.paren_close_end + if ( + this.lexer.token_type === TOKEN_FUNCTION && + scan_matching_paren(this.lexer, this.end) + ) { + value_end = paren_scan.close_end } const value_first = this.parse_feature_value(value_start, value_end) if (value_first !== 0) { @@ -409,9 +379,9 @@ export class ConditionParser { const feature_start = this.lexer.token_start const content_start = this.lexer.pos - const matched = this.scan_matching_paren() - const content_end = this.paren_content_end - const feature_end = this.paren_close_end + const matched = scan_matching_paren(this.lexer, this.end) + const content_end = paren_scan.content_end + const feature_end = paren_scan.close_end if (!matched) return null return this.parse_media_feature_content(feature_start, feature_end, content_start, content_end) @@ -432,9 +402,9 @@ export class ConditionParser { const content_column = this.lexer.column // Find matching closing paren - this.scan_matching_paren() - const content_end = this.paren_content_end - const func_end = this.paren_close_end + scan_matching_paren(this.lexer, this.end) + const content_end = paren_scan.content_end + const func_end = paren_scan.close_end // Create function node const func_node = this.create_node(FUNCTION, func_start, func_end) @@ -530,9 +500,9 @@ export class ConditionParser { const content_line = this.lexer.line const content_column = this.lexer.column - const matched = this.scan_matching_paren() - const content_end = this.paren_content_end - const feature_end = this.paren_close_end + const matched = scan_matching_paren(this.lexer, this.end) + const content_end = paren_scan.content_end + const feature_end = paren_scan.close_end if (matched) { const query = this.create_node(SUPPORTS_QUERY, feature_start, feature_end) diff --git a/src/parse-utils.ts b/src/parse-utils.ts index 5f048d3..6d8d406 100644 --- a/src/parse-utils.ts +++ b/src/parse-utils.ts @@ -6,6 +6,8 @@ import { CHAR_RIGHT_PAREN, is_whitespace, } from './string-utils' +import { TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, TOKEN_FUNCTION, TOKEN_EOF } from './token-types' +import type { Lexer } from './tokenize' export function skip_whitespace_forward(source: string, pos: number, end: number): number { while (pos < end && is_whitespace(source.charCodeAt(pos))) { @@ -114,3 +116,35 @@ export function find_colon_at_depth_zero(source: string, start: number, end: num } return -1 } + +// Results of the last scan_matching_paren() call: the positions right before/after the closing ')'. +export const paren_scan = { content_end: 0, close_end: 0 } + +// Scan tokens from just after an already-open '(' or function-call (depth 1) to its matching +// ')', never reading past `end`. Returns false if EOF was hit first, in which case +// paren_scan is left at the position scanning started from, so callers that don't check the +// result still get sane fallback spans. +export function scan_matching_paren(lexer: Lexer, end: number): boolean { + let depth = 1 + let content_end = lexer.pos + let close_end = lexer.token_end + + while (lexer.pos < end && depth > 0) { + const token_type = lexer.next_token(false) + if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { + depth++ + } else if (token_type === TOKEN_RIGHT_PAREN) { + depth-- + if (depth === 0) { + content_end = lexer.token_start + close_end = lexer.token_end + } + } else if (token_type === TOKEN_EOF) { + break + } + } + + paren_scan.content_end = content_end + paren_scan.close_end = close_end + return depth === 0 +} diff --git a/src/tokenize.ts b/src/tokenize.ts index 4185f4d..d7ad26d 100644 --- a/src/tokenize.ts +++ b/src/tokenize.ts @@ -418,18 +418,16 @@ export class Lexer { } } - /** - * Fast-forward to the next `target` char that isn't inside a string or comment, without - * fully tokenizing what's in between — used by callers that only need to know *where a - * construct ends* (e.g. a selector's `{`), since the sub-parser for that construct - * re-tokenizes the span properly afterward. - * - * Strings go through consume_string and comments through _skip_comment, so escapes and - * on_comment callbacks behave identically to real tokenization. Ordinary characters cost - * one SKIP_SCAN_INTERESTING lookup each, same as consume_ident_or_function's inner loop. - * - * Returns true with this.pos at target, or false with this.pos at source_length. - */ + // Fast-forward to the next `target` char that isn't inside a string or comment, without + // fully tokenizing what's in between — used by callers that only need to know *where a + // construct ends* (e.g. a selector's `{`), since the sub-parser for that construct + // re-tokenizes the span properly afterward. + // + // Strings go through consume_string and comments through _skip_comment, so escapes and + // on_comment callbacks behave identically to real tokenization. Ordinary characters cost + // one SKIP_SCAN_INTERESTING lookup each, same as consume_ident_or_function's inner loop. + // + // Returns true with this.pos at target, or false with this.pos at source_length. skip_to_unquoted(target: number): boolean { const source = this.source const source_length = source.length @@ -484,13 +482,11 @@ export class Lexer { return false } - /** - * Same approach as skip_to_unquoted, but stops at any of `; { } ( ) !` — the stop - * characters parse-declaration.ts's value scan needs (paren depth, statement end, - * !important). The caller decides what each one means; this only locates the next one. - * - * Returns the stop character's code (this.pos left at it), or 0 at `end` if none found. - */ + // Same approach as skip_to_unquoted, but stops at any of `; { } ( ) !` — the stop + // characters parse-declaration.ts's value scan needs (paren depth, statement end, + // !important). The caller decides what each one means; this only locates the next one. + // + // Returns the stop character's code (this.pos left at it), or 0 at `end` if none found. skip_to_declaration_stop(end: number): number { const source = this.source let pos = this.pos @@ -973,10 +969,8 @@ export class Lexer { return type } - /** - * Save complete lexer state for backtracking - * @returns Object containing all lexer state - */ + // Save complete lexer state for backtracking + // @returns Object containing all lexer state save_position(): LexerPosition { return { pos: this.pos, @@ -1005,10 +999,8 @@ export class Lexer { return target } - /** - * Restore lexer state from saved position - * @param saved The saved position to restore - */ + // Restore lexer state from saved position + // @param saved The saved position to restore restore_position(saved: LexerPosition): void { this.pos = saved.pos this._line = saved.line @@ -1020,10 +1012,8 @@ export class Lexer { this.token_column = saved.token_column } - /** - * Skip whitespace and comments within a range, maintaining line/column tracking - * @param end The end boundary (exclusive) - */ + // Skip whitespace and comments within a range, maintaining line/column tracking + // @param end The end boundary (exclusive) skip_whitespace_in_range(end: number): void { while (this.pos < end) { const ch = this.source.charCodeAt(this.pos) From f6b79aad69b49a734942cbd65d2d27a759076295 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 19:10:56 +0000 Subject: [PATCH 2/2] revert: drop shared scan_matching_paren from this change Keep the paren-matching dedupe out of the size-trimming work; it can land separately. Co-Authored-By: Claude Sonnet 5.5 --- src/parse-atrule-prelude.ts | 64 +++++++++++++++++++++++++++---------- src/parse-condition.ts | 62 +++++++++++++++++++++++++---------- src/parse-utils.ts | 34 -------------------- 3 files changed, 93 insertions(+), 67 deletions(-) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 44ffc57..aeaf824 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -33,12 +33,7 @@ import { strip_vendor_prefix, CHAR_PERIOD, } from './string-utils' -import { - trim_boundaries, - skip_whitespace_and_comments_forward, - scan_matching_paren, - paren_scan, -} from './parse-utils' +import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' import { CSSNode } from './css-node' import type { AnyNode } from './node-types' import { ValueNodeParser } from './value-node-parser' @@ -158,6 +153,41 @@ export class AtRulePreludeParser { ) } + // 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. Returns + // [content_end, close_end, matched]: content_end/close_end are the positions right + // before/after the closing ')'; matched is false if EOF was hit first, in which case + // content_end/close_end are left at the position scanning started from (mirroring the + // caller's own pre-loop defaults, so callers that don't check `matched` still get sane + // fallback spans). + private paren_content_end = 0 + private paren_close_end = 0 + + private scan_matching_paren(): boolean { + let depth = 1 + let content_end = this.lexer.pos + let close_end = this.lexer.token_end + + while (this.lexer.pos < this.prelude_end && depth > 0) { + const token_type = this.next_token() + 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 + } + } else if (token_type === TOKEN_EOF) { + break + } + } + + this.paren_content_end = content_end + this.paren_close_end = close_end + return depth === 0 + } + // Parse a single media query: screen and (min-width: 768px) private parse_single_media_query(): number | null { const query_start = this.lexer.pos @@ -513,8 +543,8 @@ export class AtRulePreludeParser { if (this.lexer.token_type === TOKEN_FUNCTION) { // It's url( ... we need to find the matching ) - scan_matching_paren(this.lexer, this.prelude_end) - const close_end = paren_scan.close_end + this.scan_matching_paren() + const close_end = this.paren_close_end url_end = close_end } @@ -552,9 +582,9 @@ export class AtRulePreludeParser { // layer(...): the Function owns the parentheses, the LayerName only the name const content_start = this.lexer.token_end // After the opening '(' - const matched = scan_matching_paren(this.lexer, this.prelude_end) - const content_end = paren_scan.content_end - const layer_end = matched ? paren_scan.close_end : this.lexer.pos + const matched = this.scan_matching_paren() + const content_end = this.paren_content_end + const layer_end = matched ? this.paren_close_end : this.lexer.pos const func_node = this.create_node(FUNCTION, layer_start, layer_end) this.arena.set_content_start_delta(func_node, 0) @@ -608,9 +638,9 @@ export class AtRulePreludeParser { const column = this.lexer.column // Find matching closing parenthesis - scan_matching_paren(this.lexer, this.prelude_end) - const content_end = paren_scan.content_end - const supports_end = paren_scan.close_end + this.scan_matching_paren() + const content_end = this.paren_content_end + const supports_end = this.paren_close_end const func_node = this.create_node(FUNCTION, supports_start, supports_end) this.arena.set_content_start_delta(func_node, 0) @@ -721,9 +751,9 @@ export class AtRulePreludeParser { const paren_start = this.lexer.token_start const content_start = this.lexer.pos - scan_matching_paren(this.lexer, this.prelude_end) - const content_end = paren_scan.content_end - const paren_end = paren_scan.close_end + this.scan_matching_paren() + const content_end = this.paren_content_end + const paren_end = this.paren_close_end const scope_node = this.create_node(PRELUDE_SELECTORLIST, paren_start, paren_end) const trimmed = trim_boundaries(this.source, content_start, content_end) diff --git a/src/parse-condition.ts b/src/parse-condition.ts index 745b2c7..f0cb1dd 100644 --- a/src/parse-condition.ts +++ b/src/parse-condition.ts @@ -45,8 +45,6 @@ import { trim_boundaries, skip_whitespace_and_comments_forward, find_colon_at_depth_zero, - scan_matching_paren, - paren_scan, } from './parse-utils' import { SelectorParser } from './parse-selector' import type { ValueNodeParser } from './value-node-parser' @@ -96,6 +94,41 @@ export class ConditionParser { return this.lexer.next_token(false) } + // 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. Returns + // [content_end, close_end, matched]: content_end/close_end are the positions right + // before/after the closing ')'; matched is false if EOF was hit first, in which case + // content_end/close_end are left at the position scanning started from (mirroring the + // caller's own pre-loop defaults, so callers that don't check `matched` still get sane + // fallback spans). + private paren_content_end = 0 + private paren_close_end = 0 + + private scan_matching_paren(): boolean { + let depth = 1 + let content_end = this.lexer.pos + let close_end = this.lexer.token_end + + while (this.lexer.pos < this.end && depth > 0) { + const token_type = this.next_token() + 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 + } + } else if (token_type === TOKEN_EOF) { + break + } + } + + this.paren_content_end = content_end + this.paren_close_end = close_end + return depth === 0 + } + // Parse feature value via the shared ValueNodeParser, so calc()/env()/var() get full children. // Own lexer instance, so it doesn't disturb this.lexer's position — no save/restore needed. private parse_feature_value(start: number, end: number): number { @@ -244,11 +277,8 @@ export class ConditionParser { // token only covers 'name(' — extend to its matching ')' so calc() etc. stay whole const value_start = this.lexer.token_start let value_end = this.lexer.token_end - if ( - this.lexer.token_type === TOKEN_FUNCTION && - scan_matching_paren(this.lexer, this.end) - ) { - value_end = paren_scan.close_end + if (this.lexer.token_type === TOKEN_FUNCTION && this.scan_matching_paren()) { + value_end = this.paren_close_end } const value_first = this.parse_feature_value(value_start, value_end) if (value_first !== 0) { @@ -379,9 +409,9 @@ export class ConditionParser { const feature_start = this.lexer.token_start const content_start = this.lexer.pos - const matched = scan_matching_paren(this.lexer, this.end) - const content_end = paren_scan.content_end - const feature_end = paren_scan.close_end + const matched = this.scan_matching_paren() + const content_end = this.paren_content_end + const feature_end = this.paren_close_end if (!matched) return null return this.parse_media_feature_content(feature_start, feature_end, content_start, content_end) @@ -402,9 +432,9 @@ export class ConditionParser { const content_column = this.lexer.column // Find matching closing paren - scan_matching_paren(this.lexer, this.end) - const content_end = paren_scan.content_end - const func_end = paren_scan.close_end + this.scan_matching_paren() + const content_end = this.paren_content_end + const func_end = this.paren_close_end // Create function node const func_node = this.create_node(FUNCTION, func_start, func_end) @@ -500,9 +530,9 @@ export class ConditionParser { const content_line = this.lexer.line const content_column = this.lexer.column - const matched = scan_matching_paren(this.lexer, this.end) - const content_end = paren_scan.content_end - const feature_end = paren_scan.close_end + const matched = this.scan_matching_paren() + const content_end = this.paren_content_end + const feature_end = this.paren_close_end if (matched) { const query = this.create_node(SUPPORTS_QUERY, feature_start, feature_end) diff --git a/src/parse-utils.ts b/src/parse-utils.ts index 6d8d406..5f048d3 100644 --- a/src/parse-utils.ts +++ b/src/parse-utils.ts @@ -6,8 +6,6 @@ import { CHAR_RIGHT_PAREN, is_whitespace, } from './string-utils' -import { TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, TOKEN_FUNCTION, TOKEN_EOF } from './token-types' -import type { Lexer } from './tokenize' export function skip_whitespace_forward(source: string, pos: number, end: number): number { while (pos < end && is_whitespace(source.charCodeAt(pos))) { @@ -116,35 +114,3 @@ export function find_colon_at_depth_zero(source: string, start: number, end: num } return -1 } - -// Results of the last scan_matching_paren() call: the positions right before/after the closing ')'. -export const paren_scan = { content_end: 0, close_end: 0 } - -// Scan tokens from just after an already-open '(' or function-call (depth 1) to its matching -// ')', never reading past `end`. Returns false if EOF was hit first, in which case -// paren_scan is left at the position scanning started from, so callers that don't check the -// result still get sane fallback spans. -export function scan_matching_paren(lexer: Lexer, end: number): boolean { - let depth = 1 - let content_end = lexer.pos - let close_end = lexer.token_end - - while (lexer.pos < end && depth > 0) { - const token_type = lexer.next_token(false) - if (token_type === TOKEN_LEFT_PAREN || token_type === TOKEN_FUNCTION) { - depth++ - } else if (token_type === TOKEN_RIGHT_PAREN) { - depth-- - if (depth === 0) { - content_end = lexer.token_start - close_end = lexer.token_end - } - } else if (token_type === TOKEN_EOF) { - break - } - } - - paren_scan.content_end = content_end - paren_scan.close_end = close_end - return depth === 0 -}