diff --git a/src/parse-anplusb.ts b/src/parse-anplusb.ts index 42df302..b026e37 100644 --- a/src/parse-anplusb.ts +++ b/src/parse-anplusb.ts @@ -8,7 +8,7 @@ import { Lexer } from './tokenize' import { NTH_SELECTOR, CSSDataArena } from './arena' import { TOKEN_IDENT, TOKEN_NUMBER, TOKEN_DIMENSION, TOKEN_DELIM, type TokenType } from './token-types' import { CHAR_MINUS_HYPHEN, CHAR_PLUS, str_equals, str_index_of } from './string-utils' -import { skip_whitespace_forward } from './parse-utils' +import { skip_whitespace_and_comments_forward } from './parse-utils' import { CSSNode } from './css-node' /** @internal */ @@ -262,7 +262,7 @@ export class ANplusBParser { } private skip_whitespace(): void { - this.lexer.pos = skip_whitespace_forward(this.source, this.lexer.pos, this.expr_end) + this.lexer.pos = skip_whitespace_and_comments_forward(this.source, this.lexer.pos, this.expr_end) } private create_anplusb_node(start: number, a_start: number, a_end: number, b_start: number, b_end: number): number { diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index bc68f72..c6fb2d7 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -1718,3 +1718,147 @@ describe('Case-insensitive at-rule keywords', () => { expect(atrule?.children.length).toBeGreaterThan(0) }) }) + +describe('Comment Handling in At-Rule Preludes', () => { + describe('@media queries with comments', () => { + it('should parse media query with comment before screen', () => { + const root = parse('@media /* comment */ screen { }') + const atrule = root.first_child + expect(atrule?.name).toBe('media') + const mediaQuery = atrule?.prelude?.first_child + expect(mediaQuery?.type).toBe(MEDIA_QUERY) + // Should find the media type + const mediaType = mediaQuery?.first_child + expect(mediaType?.type).toBe(MEDIA_TYPE) + expect(mediaType?.text).toBe('screen') + }) + + it('should parse media query with comment in media feature', () => { + const root = parse('@media (/* comment */ min-width: 768px) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('media') + const mediaQuery = atrule?.prelude?.first_child + expect(mediaQuery?.type).toBe(MEDIA_QUERY) + const mediaFeature = mediaQuery?.first_child + expect(mediaFeature?.type).toBe(MEDIA_FEATURE) + expect(mediaFeature?.property).toBe('min-width') + }) + + it('should parse media feature with comment around colon', () => { + const root = parse('@media (min-width /* comment */ : /* comment */ 768px) { }') + const atrule = root.first_child + const mediaFeature = atrule?.prelude?.first_child?.first_child + expect(mediaFeature?.type).toBe(MEDIA_FEATURE) + expect(mediaFeature?.property).toBe('min-width') + }) + + it('should parse media query list with comments between queries', () => { + const root = parse('@media screen /* comment */ , /* comment */ print { }') + const atrule = root.first_child + expect(atrule?.name).toBe('media') + const prelude = atrule?.prelude + expect(prelude?.children.length).toBe(2) + }) + + it('should parse media feature range with comments around operators', () => { + const root = parse('@media (/* comment */ 400px /* comment */ <= /* comment */ width) { }') + const atrule = root.first_child + const mediaQuery = atrule?.prelude?.first_child + const featureRange = mediaQuery?.first_child + expect(featureRange?.type).toBe(FEATURE_RANGE) + }) + + it('should not match operators inside comments in media features', () => { + const root = parse('@media (/* < */ width: 400px) { }') + const atrule = root.first_child + const mediaFeature = atrule?.prelude?.first_child?.first_child + expect(mediaFeature?.type).toBe(MEDIA_FEATURE) // Should be MEDIA_FEATURE, not FEATURE_RANGE + expect(mediaFeature?.property).toBe('width') + }) + }) + + describe('@container queries with comments', () => { + it('should parse container query with comment before feature', () => { + const root = parse('@container /* comment */ (min-width: 400px) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('container') + const containerQuery = atrule?.prelude?.first_child + expect(containerQuery?.type).toBe(CONTAINER_QUERY) + }) + }) + + describe('@supports queries with comments', () => { + it('should parse supports query with comment in feature', () => { + const root = parse('@supports (/* comment */ display: grid) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('supports') + const supportsQuery = atrule?.prelude?.first_child + expect(supportsQuery?.type).toBe(SUPPORTS_QUERY) + }) + + it('should parse supports with comments between queries', () => { + const root = parse('@supports (display: grid) /* comment */ or /* comment */ (display: flex) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('supports') + expect(atrule?.prelude?.children.length).toBeGreaterThan(0) + }) + }) + + describe('@layer with comments', () => { + it('should parse layer names with comments between them', () => { + const root = parse('@layer foo /* comment */ , /* comment */ bar { }') + const atrule = root.first_child + expect(atrule?.name).toBe('layer') + const prelude = atrule?.prelude + expect(prelude?.children.length).toBe(2) + const [layer1, layer2] = prelude?.children || [] + expect(layer1?.type).toBe(LAYER_NAME) + expect(layer1?.value).toBe('foo') + expect(layer2?.type).toBe(LAYER_NAME) + expect(layer2?.value).toBe('bar') + }) + }) + + describe('@import with comments', () => { + it('should parse import with comment before URL', () => { + const root = parse('@import /* comment */ "styles.css";') + const atrule = root.first_child + expect(atrule?.name).toBe('import') + expect(atrule?.prelude?.children.length).toBeGreaterThan(0) + }) + + it('should parse import with comment before layer', () => { + const root = parse('@import "styles.css" /* comment */ layer(base);') + const atrule = root.first_child + expect(atrule?.name).toBe('import') + expect(atrule?.prelude?.children.length).toBeGreaterThan(0) + }) + }) + + describe('@keyframes with comments', () => { + it('should parse keyframes name with comment before it', () => { + const root = parse('@keyframes /* comment */ slidein { }') + const atrule = root.first_child + expect(atrule?.name).toBe('keyframes') + const identifier = atrule?.prelude?.first_child + expect(identifier?.type).toBe(IDENTIFIER) + expect(identifier?.text).toBe('slidein') + }) + }) + + describe('Multiline comments', () => { + it('should handle multiline comments in @media queries', () => { + const root = parse(`@media screen +/* comment +with +newlines */ +and (min-width: 768px) { }`) + const atrule = root.first_child + expect(atrule?.name).toBe('media') + const mediaQuery = atrule?.prelude?.first_child + expect(mediaQuery?.type).toBe(MEDIA_QUERY) + // Should still parse the media feature after the multiline comment + expect(mediaQuery?.children.length).toBeGreaterThan(0) + }) + }) +}) diff --git a/src/parse-atrule-prelude.ts b/src/parse-atrule-prelude.ts index f348620..271b0fc 100644 --- a/src/parse-atrule-prelude.ts +++ b/src/parse-atrule-prelude.ts @@ -33,7 +33,7 @@ import { type TokenType, } from './token-types' import { str_equals, is_whitespace, strip_vendor_prefix, CHAR_COLON, CHAR_LESS_THAN, CHAR_GREATER_THAN, CHAR_EQUALS } from './string-utils' -import { trim_boundaries, skip_whitespace_forward } from './parse-utils' +import { trim_boundaries, skip_whitespace_and_comments_forward } from './parse-utils' import { CSSNode } from './css-node' /** @internal */ @@ -224,12 +224,18 @@ export class AtRulePreludeParser { // Check for range syntax (has comparison operators) let has_comparison = false - for (let i = content_start; i < content_end; i++) { + let i = content_start + while (i < content_end) { + // Skip whitespace and comments + i = skip_whitespace_and_comments_forward(this.source, i, content_end) + if (i >= content_end) break + let ch = this.source.charCodeAt(i) if (ch === CHAR_LESS_THAN || ch === CHAR_GREATER_THAN || ch === CHAR_EQUALS) { has_comparison = true break } + i++ } if (has_comparison) { @@ -241,11 +247,17 @@ export class AtRulePreludeParser { // 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 + let j = content_start + while (j < content_end) { + // Skip whitespace and comments + j = skip_whitespace_and_comments_forward(this.source, j, content_end) + if (j >= content_end) break + + if (this.source.charCodeAt(j) === CHAR_COLON) { + colon_pos = j break } + j++ } if (colon_pos !== -1) { @@ -686,9 +698,9 @@ export class AtRulePreludeParser { return null } - // Helper: Skip whitespace + // Helper: Skip whitespace and comments private skip_whitespace(): void { - this.lexer.pos = skip_whitespace_forward(this.source, this.lexer.pos, this.prelude_end) + this.lexer.pos = skip_whitespace_and_comments_forward(this.source, this.lexer.pos, this.prelude_end) } // Helper: Peek at next token type without consuming @@ -774,7 +786,7 @@ export class AtRulePreludeParser { let pos = content_start while (pos < content_end) { - pos = skip_whitespace_forward(this.source, pos, content_end) + pos = skip_whitespace_and_comments_forward(this.source, pos, content_end) if (pos >= content_end) break let ch = this.source.charCodeAt(pos) diff --git a/src/parse-selector.test.ts b/src/parse-selector.test.ts index 1392a44..ff18ac4 100644 --- a/src/parse-selector.test.ts +++ b/src/parse-selector.test.ts @@ -20,7 +20,6 @@ import { ATTR_FLAG_CASE_INSENSITIVE, ATTR_FLAG_CASE_SENSITIVE, } from './arena' -import { NODE_TYPES } from './constants' // Helper for low-level testing function parseSelectorInternal(selector: string) { @@ -1928,9 +1927,35 @@ describe('Selector Nodes', () => { test(':nth-child(1 of li)', () => { const root = parse_selector('ul:has(:nth-child(1 of li))') - const has = root.first_child!.children[1] - expect(has.type).toBe(PSEUDO_CLASS_SELECTOR) - expect(has.text).toBe(':has(:nth-child(1 of li))') + const nth = root.first_child!.children[1] + expect(nth.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(nth.text).toBe(':has(:nth-child(1 of li))') + }) + + test(':nth-child(1 /* test */ of /* test */ li)', () => { + const input = ':nth-child(1 /* test */ of /* test */ li)' + const root = parse_selector(input) + const nth = root.first_child!.first_child + expect(nth?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(nth?.text).toBe(input) + expect(nth?.first_child?.type).toBe(NTH_OF_SELECTOR) + const nth_of = nth?.first_child + expect(nth_of?.text).toBe('1 /* test */ of /* test */ li') + expect(nth_of?.children).toHaveLength(2) + expect(nth_of?.children[0].type_name).toBe('Nth') + expect(nth_of?.children[1].type_name).toBe('SelectorList') + }) + + test(':nth-child(3n OF .test)', () => { + const input = ':nth-child(3n OF .test)' + const root = parse_selector(input) + const nth = root.first_child!.first_child + expect(nth?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(nth?.text).toBe(input) + expect(nth?.first_child?.type).toBe(NTH_OF_SELECTOR) + const nth_of = nth?.first_child + expect(nth_of?.text).toBe('3n OF .test') + expect(nth_of?.children).toHaveLength(2) }) }) }) @@ -2608,4 +2633,187 @@ describe('Selector Nodes', () => { }) }) }) + + describe('Comment Handling in Selectors', () => { + describe('Namespace selectors with comments', () => { + it('should parse namespace selector with comment before pipe', () => { + const root = parse_selector('ns /* comment */ |E') + const selector = root.first_child + const typeSelector = selector?.first_child + expect(typeSelector?.type).toBe(TYPE_SELECTOR) + expect(typeSelector?.text).toBe('ns /* comment */ |E') + }) + + it('should parse universal namespace selector with comment before pipe', () => { + const root = parse_selector('* /* comment */ |E') + const selector = root.first_child + const typeSelector = selector?.first_child + expect(typeSelector?.type).toBe(TYPE_SELECTOR) + expect(typeSelector?.text).toBe('* /* comment */ |E') + }) + + it('should handle comment after namespace prefix where no pipe exists', () => { + const root = parse_selector('div /* comment */ .class') + const selector = root.first_child + // Comment acts as whitespace, creating a descendant combinator + expect(selector?.children.length).toBe(3) + const [type, combinator, classSelector] = selector?.children || [] + expect(type?.type).toBe(TYPE_SELECTOR) + expect(combinator?.type).toBe(COMBINATOR) + expect(classSelector?.type).toBe(CLASS_SELECTOR) + }) + }) + + describe('Pseudo-element with comments', () => { + it('should parse pseudo-element with comment before second colon', () => { + const root = parse_selector('div: /* comment */ :before') + const selector = root.first_child + const pseudoElement = selector?.children[1] + expect(pseudoElement?.type).toBe(PSEUDO_ELEMENT_SELECTOR) + expect(pseudoElement?.name).toBe('before') + }) + + it('should parse pseudo-class when comment after first colon', () => { + const root = parse_selector('div:/* comment */hover') + const selector = root.first_child + expect(selector?.children.length).toBe(2) + const [type, pseudoClass] = selector?.children || [] + expect(type?.type).toBe(TYPE_SELECTOR) + expect(pseudoClass?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(pseudoClass?.name).toBe('hover') + }) + }) + + describe('nth-child with comments', () => { + it('should parse nth-child with comments in An+B expression', () => { + const root = parse_selector(':nth-child(2n /* comment */ + /* comment */ 1)') + const selector = root.first_child + const nthChild = selector?.first_child + expect(nthChild?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(nthChild?.name).toBe('nth-child') + }) + + it('should parse nth-child with comment before "of" keyword', () => { + const root = parse_selector(':nth-child(2n+1 /* comment */ of .class)') + const selector = root.first_child + const nthChild = selector?.first_child + expect(nthChild?.type).toBe(PSEUDO_CLASS_SELECTOR) + const nthOfSelector = nthChild?.first_child + expect(nthOfSelector?.type).toBe(NTH_OF_SELECTOR) + }) + + it('should parse nth-child with comment after "of" keyword', () => { + const root = parse_selector(':nth-child(2n+1 of /* comment */ .class)') + const selector = root.first_child + const nthChild = selector?.first_child + expect(nthChild?.type).toBe(PSEUDO_CLASS_SELECTOR) + const nthOfSelector = nthChild?.first_child + expect(nthOfSelector?.type).toBe(NTH_OF_SELECTOR) + }) + + it('should not match "of" inside comments', () => { + const root = parse_selector(':nth-child(2n /* of */ + 1)') + const selector = root.first_child + const nthChild = selector?.first_child + expect(nthChild?.type).toBe(PSEUDO_CLASS_SELECTOR) + const nthSelector = nthChild?.first_child + // Should be NTH_SELECTOR, not NTH_OF_SELECTOR + expect(nthSelector?.type).toBe(NTH_SELECTOR) + }) + + it('should parse nth-last-child with comments', () => { + const root = parse_selector(':nth-last-child( /* comment */ 2n /* comment */ )') + const selector = root.first_child + const nthChild = selector?.first_child + expect(nthChild?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(nthChild?.name).toBe('nth-last-child') + }) + + it('should parse nth-of-type with comments', () => { + const root = parse_selector(':nth-of-type(/* comment */odd/* comment */)') + const selector = root.first_child + const nth = selector?.first_child + expect(nth?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(nth?.name).toBe('nth-of-type') + }) + + it('should match "of" keyword case-insensitively - "Of"', () => { + const root = parse_selector(':nth-child(2n Of .class)') + const selector = root.first_child + const nthChild = selector?.first_child + const nthOfSelector = nthChild?.first_child + expect(nthOfSelector?.type).toBe(NTH_OF_SELECTOR) + }) + + it('should match "of" keyword case-insensitively - "OF"', () => { + const root = parse_selector(':nth-child(2n OF .class)') + const selector = root.first_child + const nthChild = selector?.first_child + const nthOfSelector = nthChild?.first_child + expect(nthOfSelector?.type).toBe(NTH_OF_SELECTOR) + }) + + it('should match "of" keyword case-insensitively - "oF"', () => { + const root = parse_selector(':nth-child(2n oF .class)') + const selector = root.first_child + const nthChild = selector?.first_child + const nthOfSelector = nthChild?.first_child + expect(nthOfSelector?.type).toBe(NTH_OF_SELECTOR) + }) + }) + + describe('Comments in compound selectors', () => { + it('should parse comments already tested in combinator tests', () => { + // These are already tested in the "should parse selector with comments around..." tests + const root = parse_selector('a /* comment */ > /* comment */ b') + expect(root.children.length).toBe(1) + }) + }) + + describe('Comments in attribute selectors', () => { + it('should parse comments already tested in attribute tests', () => { + // These are already tested in "should trim comments from attribute selectors" + const root = parse_selector('[/* comment */data-test="value"/* test */]') + const selector = root.first_child + const attr = selector?.first_child + expect(attr?.type).toBe(ATTRIBUTE_SELECTOR) + }) + }) + + describe('Comments in selector lists', () => { + it('should parse comments already tested in selector list tests', () => { + // These are already tested in "should parse selector list with comments..." + const root = parse_selector('a, /* comment */ b, c') + expect(root.children.length).toBe(3) + }) + }) + + describe('Multiline comments', () => { + it('should handle multiline comments in selectors', () => { + const root = parse_selector(`div +/* comment +with +newlines */ +> p`) + const selector = root.first_child + expect(selector?.children.length).toBe(3) + const [div, combinator, p] = selector?.children || [] + expect(div?.type).toBe(TYPE_SELECTOR) + expect(combinator?.type).toBe(COMBINATOR) + expect(p?.type).toBe(TYPE_SELECTOR) + }) + + it('should handle multiline comments in nth-child', () => { + const root = parse_selector(`:nth-child(2n +/* comment +with +newlines */ ++ 1)`) + const selector = root.first_child + const nthChild = selector?.first_child + expect(nthChild?.type).toBe(PSEUDO_CLASS_SELECTOR) + expect(nthChild?.name).toBe('nth-child') + }) + }) + }) }) diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 39f6050..8255791 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -384,6 +384,12 @@ export class SelectorParser { // Parse type selector or namespace selector (ns|E or ns|*) // Called when we've seen an IDENT token private parse_type_or_namespace_selector(start: number, end: number): number | null { + // Save position before skipping whitespace/comments + const saved = this.lexer.save_position() + + // Skip whitespace and comments before checking for | + this.skip_whitespace() + // Check if followed by | (namespace separator) if (this.lexer.pos < this.selector_end && this.source.charCodeAt(this.lexer.pos) === CHAR_PIPE) { this.lexer.pos++ // skip | @@ -391,6 +397,9 @@ export class SelectorParser { if (node !== null) return node // Invalid - restore and treat as regular type selector this.lexer.pos = end + } else { + // No pipe found, restore position + this.lexer.restore_position(saved) } // Regular type selector (no namespace) @@ -400,6 +409,12 @@ export class SelectorParser { // Parse universal selector or namespace selector (*|E or *|*) // Called when we've seen a * DELIM token private parse_universal_or_namespace_selector(start: number, end: number): number | null { + // Save position before skipping whitespace/comments + const saved = this.lexer.save_position() + + // Skip whitespace and comments before checking for | + this.skip_whitespace() + // Check if followed by | (any-namespace prefix) if (this.lexer.pos < this.selector_end && this.source.charCodeAt(this.lexer.pos) === CHAR_PIPE) { this.lexer.pos++ // skip | @@ -407,6 +422,9 @@ export class SelectorParser { if (node !== null) return node // Invalid - restore and treat as regular universal selector this.lexer.pos = end + } else { + // No pipe found, restore position + this.lexer.restore_position(saved) } // Regular universal selector (no namespace) @@ -429,7 +447,7 @@ export class SelectorParser { // Skip whitespace and comments this.skip_whitespace() - has_whitespace = has_whitespace && (this.lexer.pos > whitespace_start) + has_whitespace = has_whitespace && this.lexer.pos > whitespace_start if (this.lexer.pos >= this.selector_end) { this.lexer.pos = whitespace_start @@ -675,11 +693,20 @@ export class SelectorParser { // Save lexer state for potential restoration const saved = this.lexer.save_position() + // Save position before skipping whitespace/comments + const saved_ws = this.lexer.save_position() + + // Skip whitespace and comments before checking for second colon + this.skip_whitespace() + // Check for double colon (::) let is_pseudo_element = false if (this.lexer.pos < this.selector_end && this.source.charCodeAt(this.lexer.pos) === CHAR_COLON) { is_pseudo_element = true this.lexer.pos++ // skip second colon + } else { + // No second colon, restore position + this.lexer.restore_position(saved_ws) } // Next token should be identifier or function @@ -909,10 +936,17 @@ export class SelectorParser { } } - // Find the position of standalone "of" keyword + // Find the position of standalone "of" keyword (case-insensitive) private find_of_keyword(start: number, end: number): number { - for (let i = start; i < end - 1; i++) { - if (this.source.charCodeAt(i) === 0x6f /* o */ && this.source.charCodeAt(i + 1) === 0x66 /* f */) { + let i = start + while (i < end - 1) { + i = skip_whitespace_and_comments_forward(this.source, i, end) + if (i >= end - 1) break + + let ch1 = this.source.charCodeAt(i) + let ch2 = this.source.charCodeAt(i + 1) + // Check for 'o' or 'O' (0x6f or 0x4f) followed by 'f' or 'F' (0x66 or 0x46) + if ((ch1 === 0x6f || ch1 === 0x4f) && (ch2 === 0x66 || ch2 === 0x46)) { // Check it's a word boundary let before_ok = i === start || is_whitespace(this.source.charCodeAt(i - 1)) let after_ok = i + 2 >= end || is_whitespace(this.source.charCodeAt(i + 2)) @@ -921,6 +955,7 @@ export class SelectorParser { return i } } + i++ } return -1 }