From d7f0df3b9d741a257e7ad056c53b946ca3135692 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Thu, 18 Dec 2025 15:46:59 +0100 Subject: [PATCH] fix: make sure all string comparisons are case-insensitive --- src/css-node.ts | 4 +- src/parse-anplusb.ts | 8 +- src/parse-atrule-prelude.test.ts | 60 +++++++++++++++ src/parse-selector.ts | 21 +++--- src/parse-value.test.ts | 58 +++++++++++++++ src/parse-value.ts | 8 +- src/string-utils.test.ts | 124 +++++++++++++++++++++++++++++++ src/string-utils.ts | 77 +++++++++++++++++++ 8 files changed, 340 insertions(+), 20 deletions(-) diff --git a/src/css-node.ts b/src/css-node.ts index be8ab71..9142f18 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -45,7 +45,7 @@ import { FLAG_HAS_PARENS, } from './arena' -import { CHAR_MINUS_HYPHEN, CHAR_PLUS, is_whitespace } from './string-utils' +import { CHAR_MINUS_HYPHEN, CHAR_PLUS, is_whitespace, str_starts_with } from './string-utils' import { parse_dimension } from './parse-utils' // Type name lookup table - maps numeric type to CSSTree-compatible strings @@ -239,7 +239,7 @@ export class CSSNode { // For URL nodes without children (e.g., @import url(...)), extract from text // Handle both url("...") and url('...') and just "..." or '...' const text = this.text - if (text.startsWith('url(')) { + if (str_starts_with(text, 'url(')) { // url("...") or url('...') or url(...) - extract content between parens const openParen = text.indexOf('(') const closeParen = text.lastIndexOf(')') diff --git a/src/parse-anplusb.ts b/src/parse-anplusb.ts index 2879f30..0c3365e 100644 --- a/src/parse-anplusb.ts +++ b/src/parse-anplusb.ts @@ -7,7 +7,7 @@ import { Lexer } from './lexer' 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 } from './string-utils' +import { CHAR_MINUS_HYPHEN, CHAR_PLUS, str_equals, str_index_of } from './string-utils' import { skip_whitespace_forward } from './parse-utils' import { CSSNode } from './css-node' @@ -53,9 +53,9 @@ export class ANplusBParser { // Handle special keywords: odd, even if (this.lexer.token_type === TOKEN_IDENT) { - const text = this.source.substring(this.lexer.token_start, this.lexer.token_end).toLowerCase() + const text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - if (text === 'odd' || text === 'even') { + if (str_equals('odd', text) || str_equals('even', text)) { a_start = this.lexer.token_start a_end = this.lexer.token_end return this.create_anplusb_node(node_start, a_start, a_end, 0, 0) @@ -168,7 +168,7 @@ export class ANplusBParser { // Handle dimension tokens: 2n, 3n+1, -5n-2 if (this.lexer.token_type === TOKEN_DIMENSION) { const token_text = this.source.substring(this.lexer.token_start, this.lexer.token_end) - const n_index = token_text.toLowerCase().indexOf('n') + const n_index = str_index_of(token_text, 'n') if (n_index !== -1) { a_start = this.lexer.token_start diff --git a/src/parse-atrule-prelude.test.ts b/src/parse-atrule-prelude.test.ts index 793b3ed..825e989 100644 --- a/src/parse-atrule-prelude.test.ts +++ b/src/parse-atrule-prelude.test.ts @@ -1361,3 +1361,63 @@ describe('parse_atrule_prelude()', () => { }) }) }) + +describe('Case-insensitive at-rule keywords', () => { + it('should parse @MEDIA with uppercase', () => { + const root = parse('@MEDIA (min-width: 768px) { body { color: red; } }') + const atrule = root.first_child + expect(atrule?.name).toBe('MEDIA') + }) + + it('should parse @Media with mixed case', () => { + const root = parse('@Media (min-width: 768px) { body { color: red; } }') + const atrule = root.first_child + expect(atrule?.name).toBe('Media') + }) + + it('should parse @IMPORT with uppercase', () => { + const root = parse('@IMPORT url("style.css");') + const atrule = root.first_child + expect(atrule?.name).toBe('IMPORT') + }) + + it('should parse @SUPPORTS with uppercase', () => { + const root = parse('@SUPPORTS (display: grid) { body { display: grid; } }') + const atrule = root.first_child + expect(atrule?.name).toBe('SUPPORTS') + }) + + it('should parse @LAYER with uppercase', () => { + const root = parse('@LAYER base { }') + const atrule = root.first_child + expect(atrule?.name).toBe('LAYER') + }) + + it('should parse @CONTAINER with uppercase', () => { + const root = parse('@CONTAINER (min-width: 400px) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('CONTAINER') + }) + + it('should parse media query operators in uppercase', () => { + const root = parse('@media (min-width: 768px) AND (max-width: 1024px) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('media') + // Verify the prelude was parsed (operators are case-insensitive) + expect(atrule?.children.length).toBeGreaterThan(0) + }) + + it('should parse OR operator in uppercase', () => { + const root = parse('@supports (display: grid) OR (display: flex) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('supports') + expect(atrule?.children.length).toBeGreaterThan(0) + }) + + it('should parse NOT operator in uppercase', () => { + const root = parse('@supports NOT (display: grid) { }') + const atrule = root.first_child + expect(atrule?.name).toBe('supports') + expect(atrule?.children.length).toBeGreaterThan(0) + }) +}) diff --git a/src/parse-selector.ts b/src/parse-selector.ts index 557bc6d..bc1d5b3 100644 --- a/src/parse-selector.ts +++ b/src/parse-selector.ts @@ -47,6 +47,7 @@ import { skip_whitespace_forward, skip_whitespace_and_comments_forward, skip_whi import { is_whitespace, is_vendor_prefixed, + str_equals, CHAR_PLUS, CHAR_TILDE, CHAR_GREATER_THAN, @@ -751,16 +752,16 @@ 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 = this.source.substring(func_name_start, func_name_end).toLowerCase() + let func_name_substr = this.source.substring(func_name_start, func_name_end) - if (this.is_nth_pseudo(func_name)) { + if (this.is_nth_pseudo(func_name_substr)) { // 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) this.arena.set_last_child(node, child) } - } else if (func_name === 'lang') { + } else if (str_equals('lang', func_name_substr)) { // Parse as :lang() - comma-separated language identifiers this.parse_lang_identifiers(content_start, content_end, node) } else { @@ -771,7 +772,7 @@ export class SelectorParser { // Recursively parse the content as a selector // Only :has() accepts relative selectors (starting with combinator) - let allow_relative = func_name === 'has' + let allow_relative = str_equals('has', func_name_substr) let child_selector = this.parse_selector(content_start, content_end, this.lexer.line, this.lexer.column, allow_relative) // Restore lexer state and selector_end @@ -792,12 +793,12 @@ export class SelectorParser { // Check if pseudo-class name is an nth-* pseudo private is_nth_pseudo(name: string): boolean { return ( - name === 'nth-child' || - name === 'nth-last-child' || - name === 'nth-of-type' || - name === 'nth-last-of-type' || - name === 'nth-col' || - name === 'nth-last-col' + 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) ) } diff --git a/src/parse-value.test.ts b/src/parse-value.test.ts index 07125b5..72d4a94 100644 --- a/src/parse-value.test.ts +++ b/src/parse-value.test.ts @@ -659,4 +659,62 @@ describe('Value Node Types', () => { }) }) }) + + describe('Case-insensitive function names', () => { + const getValue = (css: string) => { + const root = parse(css) + const rule = root.first_child + const decl = rule?.first_child?.next_sibling?.first_child + return decl?.values[0] + } + + it('should parse URL() with uppercase', () => { + const value = getValue('div { background: URL("image.png"); }') + expect(value?.type).toBe(URL) + expect(value?.text).toBe('URL("image.png")') + }) + + it('should parse Url() with mixed case', () => { + const value = getValue('div { background: Url("image.png"); }') + expect(value?.type).toBe(URL) + expect(value?.text).toBe('Url("image.png")') + }) + + it('should parse CALC() with uppercase', () => { + const value = getValue('div { width: CALC(100% - 20px); }') + expect(value?.type).toBe(FUNCTION) + expect(value?.text).toBe('CALC(100% - 20px)') + }) + + it('should parse Calc() with mixed case', () => { + const value = getValue('div { width: Calc(100% - 20px); }') + expect(value?.type).toBe(FUNCTION) + expect(value?.text).toBe('Calc(100% - 20px)') + }) + + it('should parse RGB() with uppercase', () => { + const value = getValue('div { color: RGB(255, 0, 0); }') + expect(value?.type).toBe(FUNCTION) + expect(value?.text).toBe('RGB(255, 0, 0)') + }) + + it('should parse RGBA() with uppercase', () => { + const value = getValue('div { color: RGBA(255, 0, 0, 0.5); }') + expect(value?.type).toBe(FUNCTION) + expect(value?.text).toBe('RGBA(255, 0, 0, 0.5)') + }) + + it('should parse unquoted URL() with uppercase', () => { + const value = getValue('div { background: URL(image.png); }') + expect(value?.type).toBe(URL) + expect(value?.text).toBe('URL(image.png)') + expect(value?.value).toBe('image.png') + }) + + it('should handle nested functions with uppercase', () => { + const value = getValue('div { width: CALC(MAX(100%, 50px) - 20px); }') + expect(value?.type).toBe(FUNCTION) + expect(value?.children[0].type).toBe(FUNCTION) + }) + }) }) diff --git a/src/parse-value.ts b/src/parse-value.ts index 5b9ed40..2674b59 100644 --- a/src/parse-value.ts +++ b/src/parse-value.ts @@ -15,7 +15,7 @@ import { TOKEN_LEFT_PAREN, TOKEN_RIGHT_PAREN, } from './token-types' -import { is_whitespace, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_ASTERISK, CHAR_FORWARD_SLASH } from './string-utils' +import { is_whitespace, CHAR_MINUS_HYPHEN, CHAR_PLUS, CHAR_ASTERISK, CHAR_FORWARD_SLASH, str_equals } from './string-utils' import { CSSNode } from './css-node' /** @internal */ @@ -154,11 +154,11 @@ export class ValueParser { let name_end = end - 1 // Exclude the '(' // Get function name to check for special handling - let func_name = this.source.substring(start, name_end).toLowerCase() + let func_name_substr = this.source.substring(start, name_end) // Create URL or function node based on function name (length will be set later) let node = this.arena.create_node( - func_name === 'url' ? URL : FUNCTION, + str_equals('url', func_name_substr) ? URL : FUNCTION, start, 0, // length unknown yet this.lexer.line, @@ -171,7 +171,7 @@ export class ValueParser { // 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 (func_name === 'url' || func_name === 'src') { + if (str_equals('url', func_name_substr) || str_equals('src', func_name_substr)) { // 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() diff --git a/src/string-utils.test.ts b/src/string-utils.test.ts index 27286d4..b2382a4 100644 --- a/src/string-utils.test.ts +++ b/src/string-utils.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest' import { is_whitespace, str_equals, + str_starts_with, + str_index_of, is_vendor_prefixed, CHAR_SPACE, CHAR_TAB, @@ -203,6 +205,69 @@ describe('string-utils', () => { }) }) + describe('str_starts_with', () => { + it('should match identical prefix', () => { + expect(str_starts_with('url(', 'url(')).toBe(true) + }) + + it('should match longer string with lowercase prefix', () => { + expect(str_starts_with('url(image.png)', 'url(')).toBe(true) + }) + + it('should match uppercase string with lowercase prefix', () => { + expect(str_starts_with('URL(image.png)', 'url(')).toBe(true) + }) + + it('should match mixed case string with lowercase prefix', () => { + expect(str_starts_with('Url(image.png)', 'url(')).toBe(true) + expect(str_starts_with('uRL(image.png)', 'url(')).toBe(true) + }) + + it('should not match when string is shorter than prefix', () => { + expect(str_starts_with('url', 'url(')).toBe(false) + }) + + it('should not match different prefix', () => { + expect(str_starts_with('src(image.png)', 'url(')).toBe(false) + }) + + it('should not match when prefix does not start string', () => { + expect(str_starts_with('image url()', 'url(')).toBe(false) + }) + + it('should work with function names for CSS parsing', () => { + expect(str_starts_with('CALC(1px + 2px)', 'calc')).toBe(true) + expect(str_starts_with('RGB(255, 0, 0)', 'rgb')).toBe(true) + expect(str_starts_with('RGBA(255, 0, 0, 0.5)', 'rgba')).toBe(true) + }) + + it('should work with pseudo-class functions', () => { + expect(str_starts_with('NTH-CHILD(2n)', 'nth-child')).toBe(true) + expect(str_starts_with('LANG(en, fr)', 'lang')).toBe(true) + expect(str_starts_with('HAS(> article)', 'has')).toBe(true) + }) + + it('should handle empty prefix', () => { + expect(str_starts_with('anything', '')).toBe(true) + }) + + it('should handle empty string', () => { + expect(str_starts_with('', 'url(')).toBe(false) + }) + + it('should handle empty both', () => { + expect(str_starts_with('', '')).toBe(true) + }) + + it('should be case-insensitive only on string side', () => { + // Prefix MUST be lowercase + expect(str_starts_with('URL(', 'url(')).toBe(true) + expect(str_starts_with('url(', 'url(')).toBe(true) + // The function doesn't normalize the prefix, so uppercase prefix won't match + expect(str_starts_with('url(', 'URL(')).toBe(false) + }) + }) + describe('is_vendor_prefixed', () => { it('should detect -webkit- vendor prefix', () => { const source = '-webkit-transform' @@ -286,3 +351,62 @@ describe('string-utils', () => { }) }) }) + + describe('str_index_of', () => { + it('should find single character in string', () => { + expect(str_index_of('hello', 'e')).toBe(1) + }) + + it('should find character case-insensitively', () => { + expect(str_index_of('HELLO', 'e')).toBe(1) + expect(str_index_of('Hello', 'e')).toBe(1) + }) + + it('should return -1 for character not found', () => { + expect(str_index_of('hello', 'x')).toBe(-1) + }) + + it('should find first occurrence', () => { + expect(str_index_of('hello', 'l')).toBe(2) + }) + + it('should find multi-character substring', () => { + expect(str_index_of('2n+1', 'n')).toBe(1) + expect(str_index_of('2N+1', 'n')).toBe(1) + }) + + it('should find multi-character substring case-insensitively', () => { + expect(str_index_of('HELLO', 'lo')).toBe(3) + expect(str_index_of('Hello', 'lo')).toBe(3) + }) + + it('should return -1 for substring not found', () => { + expect(str_index_of('hello', 'xyz')).toBe(-1) + }) + + it('should work with An+B patterns', () => { + expect(str_index_of('2n', 'n')).toBe(1) + expect(str_index_of('2N', 'n')).toBe(1) + expect(str_index_of('-5n-2', 'n')).toBe(2) + expect(str_index_of('-5N-2', 'n')).toBe(2) + }) + + it('should handle empty search string', () => { + expect(str_index_of('hello', '')).toBe(-1) + }) + + it('should find at string start', () => { + expect(str_index_of('hello', 'h')).toBe(0) + expect(str_index_of('HELLO', 'h')).toBe(0) + }) + + it('should find at string end', () => { + expect(str_index_of('hello', 'o')).toBe(4) + expect(str_index_of('HELLO', 'o')).toBe(4) + }) + + it('should find exact match', () => { + expect(str_index_of('n', 'n')).toBe(0) + expect(str_index_of('N', 'n')).toBe(0) + }) + }) diff --git a/src/string-utils.ts b/src/string-utils.ts index 4d85ac1..9cdb383 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -64,6 +64,83 @@ export function str_equals(a: string, b: string): boolean { return true } +/** + * Case-insensitive ASCII prefix check without allocations + * Returns true if string `str` starts with prefix (case-insensitive) + * + * IMPORTANT: prefix MUST be lowercase for correct comparison + * + * @param str - The string to check + * @param prefix - The lowercase prefix to match against + */ +export function str_starts_with(str: string, prefix: string): boolean { + if (str.length < prefix.length) { + return false + } + + for (let i = 0; i < prefix.length; i++) { + let ca = str.charCodeAt(i) + let cb = prefix.charCodeAt(i) + + // normalize only the string char (prefix is already lowercase) + if (ca >= 65 && ca <= 90) ca |= 32 // A-Z → a-z + + if (ca !== cb) { + return false + } + } + + return true +} + +/** + * Case-insensitive character/substring search without allocations + * Returns the index of the first occurrence of searchChar (case-insensitive) + * + * IMPORTANT: searchChar MUST be lowercase for correct comparison + * + * @param str - The string to search in + * @param searchChar - The lowercase character/substring to find + * @returns The index of the first match, or -1 if not found + */ +export function str_index_of(str: string, searchChar: string): number { + if (searchChar.length === 0) { + return -1 + } + + // Optimize for single character search + if (searchChar.length === 1) { + const searchCode = searchChar.charCodeAt(0) + for (let i = 0; i < str.length; i++) { + let ca = str.charCodeAt(i) + // normalize only the string char (searchChar is already lowercase) + if (ca >= 65 && ca <= 90) ca |= 32 // A-Z → a-z + if (ca === searchCode) { + return i + } + } + return -1 + } + + // Multi-character search + for (let i = 0; i <= str.length - searchChar.length; i++) { + let match = true + for (let j = 0; j < searchChar.length; j++) { + let ca = str.charCodeAt(i + j) + let cb = searchChar.charCodeAt(j) + if (ca >= 65 && ca <= 90) ca |= 32 // A-Z → a-z + if (ca !== cb) { + match = false + break + } + } + if (match) { + return i + } + } + return -1 +} + /** * Check if a string range has a vendor prefix *