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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/css-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(')')
Expand Down
2 changes: 1 addition & 1 deletion src/parse-anplusb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 37 additions & 37 deletions src/string-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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)
})
})

Expand Down Expand Up @@ -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)
})
})
40 changes: 19 additions & 21 deletions src/string-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ||
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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
}
Expand All @@ -131,32 +129,32 @@ 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
}
}
return -1
}

// 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
Expand Down Expand Up @@ -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
Expand Down
Loading