diff --git a/README.md b/README.md index 0f9c33a..3afada5 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,7 @@ 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 the first one for `str_equals` and the second one for `str_starts_with` and `str_index_of`. This inconsistency will be fixed in a future breaking change. +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. ```typescript import { @@ -247,8 +247,8 @@ is_custom('--primary') // true is_vendor_prefixed('-webkit-transform') // true strip_vendor_prefix('-webkit-transform') // "transform" str_equals('media', 'MEDIA') // true -str_starts_with('WEBKIT-transform', 'webkit') // true -str_index_of('background-COLOR', 'color') // 11 +str_starts_with('webkit', 'WEBKIT-transform') // true +str_index_of('color', 'background-COLOR') // 11 ``` ## Non-goals diff --git a/src/css-node.ts b/src/css-node.ts index 4f634c3..6d2bcff 100644 --- a/src/css-node.ts +++ b/src/css-node.ts @@ -507,7 +507,7 @@ export class CSSNode { } // For URL nodes without children (e.g., @import url(...)), extract from text // Handle both url("...") and url('...') and just "..." or '...' - if (str_starts_with(text, 'url(')) { + if (str_starts_with('url(', text)) { // url("...") or url('...') or url(...) - extract content between parens const open_paren = text.indexOf('(') const close_paren = text.lastIndexOf(')') diff --git a/src/parse-anplusb.ts b/src/parse-anplusb.ts index 9e6c55c..619262b 100644 --- a/src/parse-anplusb.ts +++ b/src/parse-anplusb.ts @@ -197,7 +197,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 = str_index_of(token_text, 'n') + const n_index = str_index_of('n', token_text) if (n_index !== -1) { a_start = this.lexer.token_start diff --git a/src/string-utils.test.ts b/src/string-utils.test.ts index 5552ba4..f51bcad 100644 --- a/src/string-utils.test.ts +++ b/src/string-utils.test.ts @@ -212,48 +212,48 @@ describe('string-utils', () => { }) test('should match longer string with lowercase prefix', () => { - expect(str_starts_with('url(image.png)', 'url(')).toBe(true) + expect(str_starts_with('url(', 'url(image.png)')).toBe(true) }) test('should match uppercase string with lowercase prefix', () => { - expect(str_starts_with('URL(image.png)', 'url(')).toBe(true) + expect(str_starts_with('url(', 'URL(image.png)')).toBe(true) }) test('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) + expect(str_starts_with('url(', 'Url(image.png)')).toBe(true) + expect(str_starts_with('url(', 'uRL(image.png)')).toBe(true) }) test('should not match when string is shorter than prefix', () => { - expect(str_starts_with('url', 'url(')).toBe(false) + expect(str_starts_with('url(', 'url')).toBe(false) }) test('should not match different prefix', () => { - expect(str_starts_with('src(image.png)', 'url(')).toBe(false) + expect(str_starts_with('url(', 'src(image.png)')).toBe(false) }) test('should not match when prefix does not start string', () => { - expect(str_starts_with('image url()', 'url(')).toBe(false) + expect(str_starts_with('url(', 'image url()')).toBe(false) }) test('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) + expect(str_starts_with('calc', 'CALC(1px + 2px)')).toBe(true) + expect(str_starts_with('rgb', 'RGB(255, 0, 0)')).toBe(true) + expect(str_starts_with('rgba', 'RGBA(255, 0, 0, 0.5)')).toBe(true) }) test('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) + expect(str_starts_with('nth-child', 'NTH-CHILD(2n)')).toBe(true) + expect(str_starts_with('lang', 'LANG(en, fr)')).toBe(true) + expect(str_starts_with('has', 'HAS(> article)')).toBe(true) }) test('should handle empty prefix', () => { - expect(str_starts_with('anything', '')).toBe(true) + expect(str_starts_with('', 'anything')).toBe(true) }) test('should handle empty string', () => { - expect(str_starts_with('', 'url(')).toBe(false) + expect(str_starts_with('url(', '')).toBe(false) }) test('should handle empty both', () => { @@ -262,10 +262,10 @@ describe('string-utils', () => { test('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) 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) + expect(str_starts_with('URL(', 'url(')).toBe(false) }) }) @@ -401,59 +401,59 @@ describe('string-utils', () => { describe('str_index_of', () => { test('should find single character in string', () => { - expect(str_index_of('hello', 'e')).toBe(1) + expect(str_index_of('e', 'hello')).toBe(1) }) test('should find character case-insensitively', () => { - expect(str_index_of('HELLO', 'e')).toBe(1) - expect(str_index_of('Hello', 'e')).toBe(1) + expect(str_index_of('e', 'HELLO')).toBe(1) + expect(str_index_of('e', 'Hello')).toBe(1) }) test('should return -1 for character not found', () => { - expect(str_index_of('hello', 'x')).toBe(-1) + expect(str_index_of('x', 'hello')).toBe(-1) }) test('should find first occurrence', () => { - expect(str_index_of('hello', 'l')).toBe(2) + expect(str_index_of('l', 'hello')).toBe(2) }) test('should find multi-character substring', () => { - expect(str_index_of('2n+1', 'n')).toBe(1) - expect(str_index_of('2N+1', 'n')).toBe(1) + expect(str_index_of('n', '2n+1')).toBe(1) + expect(str_index_of('n', '2N+1')).toBe(1) }) test('should find multi-character substring case-insensitively', () => { - expect(str_index_of('HELLO', 'lo')).toBe(3) - expect(str_index_of('Hello', 'lo')).toBe(3) + expect(str_index_of('lo', 'HELLO')).toBe(3) + expect(str_index_of('lo', 'Hello')).toBe(3) }) test('should return -1 for substring not found', () => { - expect(str_index_of('hello', 'xyz')).toBe(-1) + expect(str_index_of('xyz', 'hello')).toBe(-1) }) test('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) + expect(str_index_of('n', '2n')).toBe(1) + expect(str_index_of('n', '2N')).toBe(1) + expect(str_index_of('n', '-5n-2')).toBe(2) + expect(str_index_of('n', '-5N-2')).toBe(2) }) test('should handle empty search string', () => { - expect(str_index_of('hello', '')).toBe(-1) + expect(str_index_of('', 'hello')).toBe(-1) }) test('should find at string start', () => { - expect(str_index_of('hello', 'h')).toBe(0) - expect(str_index_of('HELLO', 'h')).toBe(0) + expect(str_index_of('h', 'hello')).toBe(0) + expect(str_index_of('h', 'HELLO')).toBe(0) }) test('should find at string end', () => { - expect(str_index_of('hello', 'o')).toBe(4) - expect(str_index_of('HELLO', 'o')).toBe(4) + expect(str_index_of('o', 'hello')).toBe(4) + expect(str_index_of('o', 'HELLO')).toBe(4) }) test('should find exact match', () => { expect(str_index_of('n', 'n')).toBe(0) - 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 a74aee9..25e1f41 100644 --- a/src/string-utils.ts +++ b/src/string-utils.ts @@ -29,7 +29,7 @@ export const CHAR_LEFT_PAREN = 0x28 // ( export const CHAR_RIGHT_PAREN = 0x29 // ) export const CHAR_EXCLAMATION = 0x21 // ! -// Check if a character code is whitespace (space, tab, newline, CR, or FF) +/** True if the character code is whitespace (space, tab, newline, CR, or FF). */ export function is_whitespace(ch: number): boolean { return ( ch === CHAR_SPACE || @@ -49,10 +49,7 @@ export function is_digit(ch: number): boolean { return ch >= 0x30 && ch <= 0x39 // 0-9 } -/** - * @param a Base string, MUST be lowercase! - * @param b Compare string - */ +/** Case-insensitive ASCII equality without allocations. `a` MUST be lowercase. */ export function str_equals(a: string, b: string): boolean { if (a.length !== b.length) { return false @@ -73,9 +70,10 @@ 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. +/** + * 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, @@ -99,7 +97,7 @@ export function str_equals_range( return true } -// True if `source[start, end)` is and/or/not (case-insensitive) +/** True if `source[start, end)` is and/or/not (case-insensitive). */ export function is_and_or_not_range(source: string, start: number, end: number): boolean { const length = end - start if (length === 3) { @@ -111,7 +109,7 @@ export function is_and_or_not_range(source: string, start: number, end: number): } /** Case-insensitive ASCII prefix check without allocations. `prefix` MUST be lowercase. */ -export function str_starts_with(str: string, prefix: string): boolean { +export function str_starts_with(prefix: string, str: string): boolean { if (str.length < prefix.length) { return false } @@ -131,20 +129,20 @@ export function str_starts_with(str: string, prefix: string): boolean { return true } -/** Case-insensitive substring search without allocations, or -1 if not found. `searchChar` MUST be lowercase. */ -export function str_index_of(str: string, searchChar: string): number { - if (searchChar.length === 0) { +/** Case-insensitive substring search without allocations, or -1 if not found. `search_char` MUST be lowercase. */ +export function str_index_of(search_char: string, str: string): number { + if (search_char.length === 0) { return -1 } // Optimize for single character search - if (searchChar.length === 1) { - const searchCode = searchChar.charCodeAt(0) + if (search_char.length === 1) { + const search_code = search_char.charCodeAt(0) for (let i = 0; i < str.length; i++) { let ca = str.charCodeAt(i) - // normalize only the string char (searchChar is already lowercase) + // normalize only the string char (search_char is already lowercase) if (ca >= 65 && ca <= 90) ca |= 32 // A-Z → a-z - if (ca === searchCode) { + if (ca === search_code) { return i } } @@ -152,11 +150,11 @@ export function str_index_of(str: string, searchChar: string): number { } // Multi-character search - for (let i = 0; i <= str.length - searchChar.length; i++) { + for (let i = 0; i <= str.length - search_char.length; i++) { let match = true - for (let j = 0; j < searchChar.length; j++) { + for (let j = 0; j < search_char.length; j++) { let ca = str.charCodeAt(i + j) - const cb = searchChar.charCodeAt(j) + const cb = search_char.charCodeAt(j) if (ca >= 65 && ca <= 90) ca |= 32 // A-Z → a-z if (ca !== cb) { match = false @@ -216,7 +214,7 @@ export function is_custom(str: string): boolean { return str.charCodeAt(0) === CHAR_MINUS_HYPHEN && str.charCodeAt(1) === CHAR_MINUS_HYPHEN } -// Strip a vendor prefix, e.g. `-webkit-keyframes` → `keyframes`. Returns the input unchanged if it has no prefix. +/** Strip a vendor prefix, e.g. `-webkit-keyframes` → `keyframes`. Returns the input unchanged if it has no prefix. */ export function strip_vendor_prefix(str: string): string { if (!is_vendor_prefixed(str)) { return str