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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ import {
str_starts_with,
str_equals,
} from './string-utils'
import { parse_dimension } from './parse-dimension'
import { dimension_number_end } from './parse-dimension'

// Type name lookup table - maps numeric type to CSSTree-compatible strings
export const TYPE_NAMES = {
Expand Down Expand Up @@ -401,7 +401,10 @@ export class CSSNode {
}

if (type === DIMENSION) {
return parse_dimension(text).value
// Slice only the numeric part — avoids parse_dimension's extra unit substring + object
let num_str = text.substring(0, dimension_number_end(text))
// oxlint-disable-next-line prefer-number-coercion
return num_str ? Number.parseFloat(num_str) : 0
}

if (type === NUMBER) {
Expand Down Expand Up @@ -523,7 +526,8 @@ export class CSSNode {
/** Get the unit for dimension nodes (e.g., "px" from "100px", "%" from "50%") */
get unit(): string | undefined {
if (this.type !== DIMENSION) return undefined
return parse_dimension(this.text).unit
let text = this.text
return text.substring(dimension_number_end(text))
}

/** Numerator for ratio values, e.g. the Number "16" in `aspect-ratio: 16/9` */
Expand Down
17 changes: 14 additions & 3 deletions src/parse-dimension.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { is_digit, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_PERIOD } from './string-utils'

/** Parse a dimension string into value and unit, e.g. "100px" → { value: 100, unit: "px" } or "2%" → { value: 2, unit: "%" } */
export function parse_dimension(text: string): { value: number; unit: string } {
// Find where the numeric part ends
/**
* Find where a dimension's numeric part ends, e.g. 3 for "100px" or 2 for "2%".
* No allocation — callers that only need `value` or only `unit` can slice just
* the part they want instead of going through `parse_dimension`'s object + two
* substrings for both.
* @internal
*/
export function dimension_number_end(text: string): number {
let num_end = 0
for (let i = 0; i < text.length; i++) {
let ch = text.charCodeAt(i)
Expand Down Expand Up @@ -39,6 +44,12 @@ export function parse_dimension(text: string): { value: number; unit: string } {
}
}

return num_end
}

/** Parse a dimension string into value and unit, e.g. "100px" → { value: 100, unit: "px" } or "2%" → { value: 2, unit: "%" } */
export function parse_dimension(text: string): { value: number; unit: string } {
let num_end = dimension_number_end(text)
let num_str = text.substring(0, num_end)
let unit = text.substring(num_end)
// oxlint-disable-next-line prefer-number-coercion
Expand Down
29 changes: 14 additions & 15 deletions src/parse-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
} from './parse-utils'
import {
is_whitespace,
str_equals,
str_equals_range,
CHAR_PLUS,
CHAR_TILDE,
CHAR_GREATER_THAN,
Expand Down Expand Up @@ -791,16 +791,15 @@ export class SelectorParser {

// Parse the content inside the parentheses
if (content_end > content_start) {
// Check if this is an nth-* pseudo-class
let func_name_substr = this.source.substring(func_name_start, func_name_end)

if (this.is_nth_pseudo(func_name_substr)) {
// Check if this is an nth-* pseudo-class (offset-based — avoids allocating a
// substring for every parenthesized pseudo-class, e.g. :not(), :is(), :nth-child())
if (this.is_nth_pseudo(func_name_start, func_name_end)) {
// Parse as An+B expression
let child = this.parse_nth_expression(content_start, content_end)
if (child !== null) {
this.arena.set_first_child(node, child)
}
} else if (str_equals('lang', func_name_substr)) {
} else if (str_equals_range(this.source, func_name_start, func_name_end, 'lang')) {
// Parse as :lang() - comma-separated language identifiers
this.parse_lang_identifiers(content_start, content_end, node)
} else {
Expand All @@ -811,7 +810,7 @@ export class SelectorParser {

// Recursively parse the content as a selector
// Only :has() accepts relative selectors (starting with combinator)
let allow_relative = str_equals('has', func_name_substr)
let allow_relative = str_equals_range(this.source, func_name_start, func_name_end, 'has')
let child_selector = this.parse_selector(
content_start,
content_end,
Expand All @@ -834,15 +833,15 @@ export class SelectorParser {
return node
}

// Check if pseudo-class name is an nth-* pseudo
private is_nth_pseudo(name: string): boolean {
// Check if pseudo-class name (source[start, end)) is an nth-* pseudo
private is_nth_pseudo(start: number, end: number): boolean {
return (
str_equals('nth-child', name) ||
str_equals('nth-last-child', name) ||
str_equals('nth-of-type', name) ||
str_equals('nth-last-of-type', name) ||
str_equals('nth-col', name) ||
str_equals('nth-last-col', name)
str_equals_range(this.source, start, end, 'nth-child') ||
str_equals_range(this.source, start, end, 'nth-last-child') ||
str_equals_range(this.source, start, end, 'nth-of-type') ||
str_equals_range(this.source, start, end, 'nth-last-of-type') ||
str_equals_range(this.source, start, end, 'nth-col') ||
str_equals_range(this.source, start, end, 'nth-last-col')
)
}

Expand Down
23 changes: 23 additions & 0 deletions src/parse-value.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1013,6 +1013,29 @@ describe('Value Node Types', () => {
expect((decl!.first_child! as Value).children[0].text).toBe('red')
expect(decl?.is_important).toBe(true)
})

test('should skip comments between value tokens', () => {
const root = parse('body { margin: 10px /* comment */ 20px; }')
const decl = root.first_child?.first_child?.next_sibling?.first_child

const children = (decl!.first_child! as Value).children
expect(children).toHaveLength(2)
expect(children[0].type).toBe(DIMENSION)
expect(children[0].text).toBe('10px')
expect(children[1].type).toBe(DIMENSION)
expect(children[1].text).toBe('20px')
})

test('should skip comments between function arguments', () => {
const root = parse('body { color: rgb(255 /* red */, 0, /* green */ 0); }')
const decl = root.first_child?.first_child?.next_sibling?.first_child
const func = (decl!.first_child! as Value).children[0] as Function

expect(func.type).toBe(FUNCTION)
const args = func.children
expect(args.map((n) => n.type)).toEqual([NUMBER, OPERATOR, NUMBER, OPERATOR, NUMBER])
expect(args.map((n) => n.text)).toEqual(['255', ',', '0', ',', '0'])
})
})
})

Expand Down
28 changes: 28 additions & 0 deletions src/string-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,34 @@ export function str_equals(a: string, b: string): boolean {
return true
}

/**
* Case-insensitive equality between `source[start, end)` and `literal`, without allocating
* a substring first. Same semantics as `str_equals(literal, source.substring(start, end))`.
* `literal` MUST be lowercase.
*/
export function str_equals_range(
source: string,
start: number,
end: number,
literal: string,
): boolean {
if (end - start !== literal.length) {
return false
}

for (let i = 0; i < literal.length; i++) {
let ch = source.charCodeAt(start + i)
// normalize ASCII uppercase A-Z → a-z
ch |= 32

if (ch !== literal.charCodeAt(i)) {
return false
}
}

return true
}

/** Case-insensitive ASCII prefix check without allocations. `prefix` MUST be lowercase. */
export function str_starts_with(str: string, prefix: string): boolean {
if (str.length < prefix.length) {
Expand Down
45 changes: 19 additions & 26 deletions src/value-node-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,16 @@ import {
TOKEN_LEFT_PAREN,
TOKEN_RIGHT_PAREN,
TOKEN_UNICODE_RANGE,
TOKEN_WHITESPACE,
type TokenType,
} from './token-types'
import {
is_whitespace,
CHAR_MINUS_HYPHEN,
CHAR_PLUS,
CHAR_ASTERISK,
CHAR_FORWARD_SLASH,
str_equals,
str_equals_range,
} from './string-utils'
import { ConditionParser } from './parse-condition'

Expand Down Expand Up @@ -103,7 +104,7 @@ export class ValueNodeParser {
if (token_type === TOKEN_EOF) break

// Skip whitespace tokens (they're separators, not value nodes)
if (this.is_whitespace_inline()) {
if (token_type === TOKEN_WHITESPACE) {
continue
}

Expand All @@ -123,17 +124,6 @@ export class ValueNodeParser {
return first_node
}

// Helper to check if token is all whitespace (inline for hot paths)
private is_whitespace_inline(): boolean {
if (this.lexer.token_start >= this.lexer.token_end) return false
for (let i = this.lexer.token_start; i < this.lexer.token_end; i++) {
if (!is_whitespace(this.source.charCodeAt(i))) {
return false
}
}
return true
}

private parse_value_node(): number | null {
let token_type = this.lexer.token_type
let start = this.lexer.token_start
Expand Down Expand Up @@ -248,17 +238,20 @@ export class ValueNodeParser {
// The lexer's TOKEN_FUNCTION includes the '(' at the end
let name_end = end - 1 // Exclude the '('

// Get function name to check for special handling
let func_name_substr = this.source.substring(start, name_end)
// Check function name for special handling without allocating a substring — most
// function calls (calc(), var(), rgb(), translate(), ...) aren't any of these, and
// this runs once per FUNCTION token, one of the most common tokens in a value.

// Dispatch to dedicated parser for if()
if (str_equals('if', func_name_substr)) {
if (str_equals_range(this.source, start, name_end, 'if')) {
return this.parse_if_function_node(start, end)
}

let is_url = str_equals_range(this.source, start, name_end, 'url')

// 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,
is_url ? URL : FUNCTION,
start,
0, // length unknown yet
this.lexer.token_line,
Expand All @@ -271,14 +264,14 @@ export class ValueNodeParser {
// Don't parse contents to preserve URLs with dots, base64, inline SVGs, etc.
// Users can extract the full URL from the function's text property
// Note: Quoted urls like url("...") or url('...') parse normally
if (str_equals('url', func_name_substr) || str_equals('src', func_name_substr)) {
if (is_url || str_equals_range(this.source, start, name_end, 'src')) {
// Peek at the next token to see if it's a string
// If it's a string, parse normally. Otherwise, skip parsing children.
let save_pos = this.lexer.save_position()
this.lexer.next_token_fast(false)

// Skip whitespace
while (this.is_whitespace_inline() && this.lexer.pos < this.end) {
while (this.lexer.token_type === TOKEN_WHITESPACE && this.lexer.pos < this.end) {
this.lexer.next_token_fast(false)
}

Expand Down Expand Up @@ -345,7 +338,7 @@ export class ValueNodeParser {
}

// Skip whitespace
if (this.is_whitespace_inline()) continue
if (token_type === TOKEN_WHITESPACE) continue

// Parse argument node
let arg_node = this.parse_value_node()
Expand Down Expand Up @@ -414,7 +407,7 @@ export class ValueNodeParser {
}

// Skip whitespace and any stray separators between branches
if (this.is_whitespace_inline() || tt === TOKEN_SEMICOLON || tt === TOKEN_COLON) continue
if (tt === TOKEN_WHITESPACE || tt === TOKEN_SEMICOLON || tt === TOKEN_COLON) continue

// ── Condition ──────────────────────────────────────────────────────
let branch_start = this.lexer.token_start
Expand All @@ -434,7 +427,7 @@ export class ValueNodeParser {
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_WHITESPACE) continue
if (t === TOKEN_COLON) {
colon_found = true
break
Expand Down Expand Up @@ -462,7 +455,7 @@ export class ValueNodeParser {
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_WHITESPACE) continue

if (t === TOKEN_SEMICOLON) break // end of this branch

Expand Down Expand Up @@ -543,7 +536,7 @@ export class ValueNodeParser {
while (this.lexer.pos < this.end) {
this.lexer.next_token_fast(false)
if (this.lexer.token_start >= this.end) return TOKEN_EOF
if (this.is_whitespace_inline()) continue
if (this.lexer.token_type === TOKEN_WHITESPACE) continue
return this.lexer.token_type
}
return TOKEN_EOF
Expand Down Expand Up @@ -763,7 +756,7 @@ export class ValueNodeParser {
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
if (token_type === TOKEN_WHITESPACE) continue
let node = this.parse_value_node()
if (node !== null) nodes.push(node)
}
Expand Down Expand Up @@ -810,7 +803,7 @@ export class ValueNodeParser {
}

// Skip whitespace
if (this.is_whitespace_inline()) continue
if (token_type === TOKEN_WHITESPACE) continue

// Parse child node
// Note: We don't track paren_depth for LEFT_PAREN or TOKEN_FUNCTION here
Expand Down
Loading