diff --git a/src/arena.ts b/src/arena.ts index 3321037..7c4d37a 100644 --- a/src/arena.ts +++ b/src/arena.ts @@ -86,6 +86,7 @@ export const CONTAINER_QUERY = 35 // container query: sidebar (min-width: 400px) export const SUPPORTS_QUERY = 36 // supports query: (display: flex) export const LAYER_NAME = 37 // layer name: base, components export const PRELUDE_OPERATOR = 38 // logical operator: and, or, not +export const FEATURE_RANGE = 39 // Range syntax: (50px <= width <= 100px) // Flag constants (bit-packed in 1 byte) export const FLAG_IMPORTANT = 1 << 0 // Has !important diff --git a/src/constants.ts b/src/constants.ts index 8fbe61c..f4ee4df 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -38,6 +38,7 @@ import { SUPPORTS_QUERY, LAYER_NAME, PRELUDE_OPERATOR, + FEATURE_RANGE, FLAG_IMPORTANT, ATTR_OPERATOR_NONE, ATTR_OPERATOR_EQUAL, @@ -88,6 +89,7 @@ export { SUPPORTS_QUERY, LAYER_NAME, PRELUDE_OPERATOR, + FEATURE_RANGE, FLAG_IMPORTANT, ATTR_OPERATOR_NONE, ATTR_OPERATOR_EQUAL, @@ -143,4 +145,5 @@ export const NODE_TYPES = { SUPPORTS_QUERY, LAYER_NAME, PRELUDE_OPERATOR, + FEATURE_RANGE, } as const diff --git a/src/css-node.ts b/src/css-node.ts index 1b3e698..9c0d0a3 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -37,6 +37,7 @@ import { SUPPORTS_QUERY, LAYER_NAME, PRELUDE_OPERATOR, + FEATURE_RANGE, FLAG_IMPORTANT, FLAG_HAS_ERROR, FLAG_HAS_BLOCK, @@ -86,6 +87,7 @@ export const TYPE_NAMES = { [SUPPORTS_QUERY]: 'SupportsQuery', [LAYER_NAME]: 'Layer', [PRELUDE_OPERATOR]: 'Operator', + [FEATURE_RANGE]: 'MediaFeatureRange', } as const export type TypeName = (typeof TYPE_NAMES)[keyof typeof TYPE_NAMES] | 'unknown' @@ -128,6 +130,7 @@ export type CSSNodeType = | typeof SUPPORTS_QUERY | typeof LAYER_NAME | typeof PRELUDE_OPERATOR + | typeof FEATURE_RANGE // Options for cloning nodes export interface CloneOptions { diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index e401d9c..036ca20 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -14,6 +14,8 @@ import { PRELUDE_OPERATOR, URL, FUNCTION, + DIMENSION, + FEATURE_RANGE, } from './arena' describe('At-Rule Prelude Nodes', () => { @@ -425,7 +427,7 @@ describe('At-Rule Prelude Nodes', () => { // Feature should have content const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) - expect(feature?.value).toContain('min-width') + expect(feature?.name).toBe('min-width') }) it('should trim whitespace and comments from media features', () => { @@ -436,7 +438,7 @@ describe('At-Rule Prelude Nodes', () => { const queryChildren = children[0].children const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) - expect(feature?.value).toBe('min-width: 768px') + expect(feature?.name).toBe('min-width') }) it('should parse complex media query with and operator', () => { @@ -465,6 +467,128 @@ describe('At-Rule Prelude Nodes', () => { expect(features.length).toBe(2) }) + it('should extract feature name from standard feature', () => { + const css = '@media (orientation: portrait) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) + + expect(feature?.name).toBe('orientation') + expect(feature?.children.length).toBe(1) + expect(feature?.children[0].type).toBe(IDENTIFIER) + }) + + it('should extract feature name from boolean feature', () => { + const css = '@media (hover) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) + + expect(feature?.name).toBe('hover') + }) + + it('should parse feature values as typed children', () => { + const css = '@media (min-width: 768px) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) + + expect(feature?.name).toBe('min-width') + expect(feature?.children.length).toBe(1) + expect(feature?.children[0].type).toBe(DIMENSION) + }) + + it('should parse identifier value as child', () => { + const css = '@media (orientation: portrait) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) + + expect(feature?.children.length).toBe(1) + expect(feature?.children[0].type).toBe(IDENTIFIER) + expect(feature?.children[0].text).toBe('portrait') + }) + + it('should have no children for boolean features', () => { + const css = '@media (hover) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const feature = queryChildren.find((c) => c.type === MEDIA_FEATURE) + + expect(feature?.children.length).toBe(0) + }) + + it('should parse range syntax with single comparison', () => { + const css = '@media (width >= 400px) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const range = queryChildren.find((c) => c.type === FEATURE_RANGE) + + expect(range?.type).toBe(FEATURE_RANGE) + expect(range?.name).toBe('width') + expect(range?.children.length).toBe(2) // dimension + operator + + // Verify child types + expect(range?.children[0].type).toBe(PRELUDE_OPERATOR) // >= + expect(range?.children[1].type).toBe(DIMENSION) // 400px + }) + + it('should parse range syntax with double comparison', () => { + const css = '@media (50px <= width <= 100px) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const range = queryChildren.find((c) => c.type === FEATURE_RANGE) + + expect(range?.type).toBe(FEATURE_RANGE) + expect(range?.name).toBe('width') + expect(range?.children.length).toBe(4) // dim, op, op, dim + + // Verify child types + expect(range?.children[0].type).toBe(DIMENSION) // 50px + expect(range?.children[1].type).toBe(PRELUDE_OPERATOR) // <= + expect(range?.children[2].type).toBe(PRELUDE_OPERATOR) // <= + expect(range?.children[3].type).toBe(DIMENSION) // 100px + }) + + it('should parse range syntax with less-than', () => { + const css = '@media (400px < width) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const range = queryChildren.find((c) => c.type === FEATURE_RANGE) + + expect(range?.type).toBe(FEATURE_RANGE) + expect(range?.name).toBe('width') + expect(range?.children.length).toBe(2) + + // Verify child types + expect(range?.children[0].type).toBe(DIMENSION) // 400px + expect(range?.children[1].type).toBe(PRELUDE_OPERATOR) // < + }) + + it('should parse range syntax with equals', () => { + const css = '@media (width = 500px) { }' + const ast = parse(css) + const atRule = ast.first_child + const queryChildren = atRule?.children[0].children || [] + const range = queryChildren.find((c) => c.type === FEATURE_RANGE) + + expect(range?.type).toBe(FEATURE_RANGE) + expect(range?.name).toBe('width') + expect(range?.children.length).toBe(2) + + // Verify child types + expect(range?.children[0].type).toBe(PRELUDE_OPERATOR) // = + expect(range?.children[1].type).toBe(DIMENSION) // 500px + }) + it('should parse comma-separated media queries', () => { const css = '@media screen, print { }' const ast = parse(css) @@ -474,6 +598,11 @@ describe('At-Rule Prelude Nodes', () => { // Should have 2 media query nodes const queries = children.filter((c) => c.type === MEDIA_QUERY) expect(queries.length).toBe(2) + const [screen, print] = queries + expect(screen.type_name).toBe('MediaQuery') + expect(screen.text).toBe('screen') + expect(print.type_name).toBe('MediaQuery') + expect(print.text).toBe('print') }) }) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index 5bc943f..c1873a2 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -12,6 +12,10 @@ import { PRELUDE_OPERATOR, URL, FUNCTION, + NUMBER, + DIMENSION, + STRING, + FEATURE_RANGE, } from './arena' import { TOKEN_IDENT, @@ -23,9 +27,12 @@ import { TOKEN_STRING, TOKEN_URL, TOKEN_FUNCTION, + TOKEN_NUMBER, + TOKEN_PERCENTAGE, + TOKEN_DIMENSION, type TokenType, } from './token-types' -import { str_equals } from './string-utils' +import { str_equals, is_whitespace, CHAR_COLON, CHAR_LESS_THAN, CHAR_GREATER_THAN, CHAR_EQUALS } from './string-utils' import { trim_boundaries, skip_whitespace_forward } from './parse-utils' import { CSSNode } from './css-node' @@ -187,7 +194,7 @@ export class AtRulePreludeParser { return query_node } - // Parse media feature: (min-width: 768px) + // Parse media feature: (min-width: 768px) or range: (50px <= width <= 100px) private parse_media_feature(): number | null { let feature_start = this.lexer.token_start // '(' position @@ -210,14 +217,55 @@ export class AtRulePreludeParser { let content_end = this.lexer.token_start // Before ')' let feature_end = this.lexer.token_end // After ')' - // Create media feature node + // Check for range syntax (has comparison operators) + let has_comparison = false + for (let i = content_start; i < content_end; i++) { + let ch = this.source.charCodeAt(i) + if (ch === CHAR_LESS_THAN || ch === CHAR_GREATER_THAN || ch === CHAR_EQUALS) { + has_comparison = true + break + } + } + + if (has_comparison) { + return this.parse_feature_range(feature_start, feature_end, content_start, content_end) + } + + // Standard feature or boolean feature let feature = this.create_node(MEDIA_FEATURE, feature_start, feature_end) - // Store feature content (without parentheses) in value fields, trimmed - let trimmed = trim_boundaries(this.source, content_start, content_end) - if (trimmed) { - this.arena.set_value_start_delta(feature, trimmed[0] - feature_start) - this.arena.set_value_length(feature, trimmed[1] - trimmed[0]) + // Find colon to separate name from value + let colon_pos = -1 + for (let i = content_start; i < content_end; i++) { + if (this.source.charCodeAt(i) === CHAR_COLON) { + colon_pos = i + break + } + } + + if (colon_pos !== -1) { + // Standard feature: (name: value) + let name_trimmed = trim_boundaries(this.source, content_start, colon_pos) + if (name_trimmed) { + this.arena.set_content_start_delta(feature, name_trimmed[0] - feature_start) + this.arena.set_content_length(feature, name_trimmed[1] - name_trimmed[0]) + } + + // Parse value portion + let value_trimmed = trim_boundaries(this.source, colon_pos + 1, content_end) + if (value_trimmed) { + let value_nodes = this.parse_feature_value(value_trimmed[0], value_trimmed[1]) + if (value_nodes.length > 0) { + this.arena.append_children(feature, value_nodes) + } + } + } else { + // Boolean feature: (hover), (color) + let trimmed = trim_boundaries(this.source, content_start, content_end) + if (trimmed) { + this.arena.set_content_start_delta(feature, trimmed[0] - feature_start) + this.arena.set_content_length(feature, trimmed[1] - trimmed[0]) + } } return feature @@ -643,6 +691,111 @@ export class AtRulePreludeParser { } return this.lexer.next_token_fast(false) } + + // Helper: Parse a single value token into a node + private parse_value_token(): number | null { + switch (this.lexer.token_type) { + case TOKEN_IDENT: + return this.create_node(IDENTIFIER, this.lexer.token_start, this.lexer.token_end) + case TOKEN_NUMBER: + return this.create_node(NUMBER, this.lexer.token_start, this.lexer.token_end) + case TOKEN_PERCENTAGE: + case TOKEN_DIMENSION: + return this.create_node(DIMENSION, this.lexer.token_start, this.lexer.token_end) + case TOKEN_STRING: + return this.create_node(STRING, this.lexer.token_start, this.lexer.token_end) + default: + return null + } + } + + // Helper: Parse feature value portion into typed nodes + private parse_feature_value(start: number, end: number): number[] { + let saved_pos = this.lexer.save_position() + this.lexer.pos = start + + let nodes: number[] = [] + + while (this.lexer.pos < end) { + this.lexer.next_token_fast(false) + if (this.lexer.token_start >= end) break + + // Skip whitespace tokens + let all_whitespace = true + for (let i = this.lexer.token_start; i < this.lexer.token_end && i < end; i++) { + if (!is_whitespace(this.source.charCodeAt(i))) { + all_whitespace = false + break + } + } + if (all_whitespace) continue + + // Create node based on token type + let node = this.parse_value_token() + if (node !== null) nodes.push(node) + } + + this.lexer.restore_position(saved_pos) + return nodes + } + + // Parse media feature range syntax: (50px <= width <= 100px) + private parse_feature_range( + feature_start: number, + feature_end: number, + content_start: number, + content_end: number + ): number { + let range_node = this.create_node(FEATURE_RANGE, feature_start, feature_end) + let children: number[] = [] + let feature_name_start = -1 + let feature_name_end = -1 + + let pos = content_start + + while (pos < content_end) { + pos = skip_whitespace_forward(this.source, pos, content_end) + if (pos >= content_end) break + + let ch = this.source.charCodeAt(pos) + + // Comparison operator + if (ch === CHAR_LESS_THAN || ch === CHAR_GREATER_THAN || ch === CHAR_EQUALS) { + let op_start = pos++ + if (pos < content_end && this.source.charCodeAt(pos) === CHAR_EQUALS) pos++ + + let op = this.create_node(PRELUDE_OPERATOR, op_start, pos) + children.push(op) + } else { + // Value or feature name + let saved = this.lexer.save_position() + this.lexer.pos = pos + this.next_token() + + if (this.lexer.token_type === TOKEN_IDENT) { + // Feature name + feature_name_start = this.lexer.token_start + feature_name_end = this.lexer.token_end + } else { + // Value + let value_nodes = this.parse_feature_value(this.lexer.token_start, this.lexer.token_end) + children.push(...value_nodes) + } + + pos = this.lexer.pos + this.lexer.restore_position(saved) + } + } + + // Store feature name in content fields + if (feature_name_start !== -1) { + this.arena.set_content_start_delta(range_node, feature_name_start - feature_start) + this.arena.set_content_length(range_node, feature_name_end - feature_name_start) + } + + this.arena.append_children(range_node, children) + return range_node + } } /** diff --git a/src/string-utils.ts b/src/string-utils.ts index 95e9dfa..453eef9 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -21,6 +21,7 @@ export const CHAR_PIPE = 0x7c // | export const CHAR_DOLLAR = 0x24 // $ export const CHAR_CARET = 0x5e // ^ export const CHAR_COLON = 0x3a // : +export const CHAR_LESS_THAN = 0x3c // < /** * Check if a character code is whitespace (space, tab, newline, CR, or FF)