Skip to content

Commit 48edf1d

Browse files
committed
2 parents 1d5963c + a8dc366 commit 48edf1d

14 files changed

Lines changed: 1298 additions & 486 deletions

File tree

src/parse/index.ts

Lines changed: 242 additions & 254 deletions
Large diffs are not rendered by default.

src/parse/lexer.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
/**
2+
* A fast lexer (scanner) for CSS input.
3+
*
4+
* Inspired by the approach used in es-module-shims / es-module-lexer,
5+
* this lexer uses numeric character-code comparisons (`charCodeAt`)
6+
* instead of single-character string comparisons, and employs sticky
7+
* (`y`-flag) regular expressions matched directly against the full
8+
* input to avoid creating temporary substring slices.
9+
*
10+
* The Lexer keeps an index (`pos`) into the original input. Simple
11+
* token types (whitespace, braces, colons, semicolons, commas) are
12+
* scanned character-by-character via `charCodeAt`, avoiding regular
13+
* expressions for those cases. For complex patterns `matchRegex` uses
14+
* a sticky regex positioned at `pos` so no string copy is needed.
15+
*/
16+
17+
// ─── Character-code constants ────────────────────────────────────────────────
18+
// Using charCodeAt comparisons is measurably faster than creating
19+
// single-character strings and comparing with `===`.
20+
21+
const Ch_TAB = 9; // \t
22+
const Ch_LF = 10; // \n
23+
const Ch_FF = 12; // \f
24+
const Ch_CR = 13; // \r
25+
const Ch_SPACE = 32; // ' '
26+
const Ch_COMMA = 44; // ,
27+
const Ch_SLASH = 47; // /
28+
const Ch_COLON = 58; // :
29+
const Ch_SEMI = 59; // ;
30+
const Ch_AT = 64; // @
31+
const Ch_OPEN = 123; // {
32+
const Ch_CLOSE = 125; // }
33+
const Ch_STAR = 42; // *
34+
35+
export class Lexer {
36+
/** The complete CSS source string. */
37+
readonly input: string;
38+
39+
/** Current read position (index into `input`). */
40+
pos: number;
41+
42+
/** Current source line (1-based). */
43+
lineno: number;
44+
45+
/** Current source column (1-based). */
46+
column: number;
47+
48+
constructor(input: string) {
49+
this.input = input;
50+
this.pos = 0;
51+
this.lineno = 1;
52+
this.column = 1;
53+
}
54+
55+
// ─── Lookahead helpers ────────────────────────────────────────────────────
56+
57+
/** Returns `true` when there is still input to consume. */
58+
get hasMore(): boolean {
59+
return this.pos < this.input.length;
60+
}
61+
62+
/**
63+
* Returns the character code at `pos + offset` without advancing,
64+
* or `NaN` when past the end of input.
65+
*
66+
* Comparing numeric codes (`charCodeAt`) is faster than creating
67+
* single-character strings with bracket indexing.
68+
*/
69+
charCodeAt(offset = 0): number {
70+
return this.input.charCodeAt(this.pos + offset);
71+
}
72+
73+
/**
74+
* Returns the character at `pos + offset` without advancing, or an
75+
* empty string when past the end of input.
76+
*/
77+
charAt(offset = 0): string {
78+
return this.input[this.pos + offset] ?? '';
79+
}
80+
81+
/**
82+
* Returns the remaining input from the current position.
83+
*
84+
* This creates a new string (same cost as the old `css.slice(…)` approach)
85+
* and is provided for compatibility with the bracket/quote-aware search
86+
* utilities that accept a plain string.
87+
*/
88+
get remaining(): string {
89+
return this.input.slice(this.pos);
90+
}
91+
92+
// ─── Consumption helpers ──────────────────────────────────────────────────
93+
94+
/**
95+
* Advance `pos` by `n` characters, updating line/column tracking.
96+
* Returns the consumed slice.
97+
*/
98+
consume(n: number): string {
99+
const start = this.pos;
100+
const end = start + n;
101+
this._advanceRange(start, end);
102+
return this.input.slice(start, end);
103+
}
104+
105+
/**
106+
* Advance `pos` up to (but not including) `absolutePos`, updating
107+
* line/column tracking. Returns the consumed slice.
108+
*/
109+
consumeTo(absolutePos: number): string {
110+
return this.consume(absolutePos - this.pos);
111+
}
112+
113+
/**
114+
* Apply a sticky (`y`-flag) regex directly against the full input
115+
* at the current position. If the regex matches, the matched text
116+
* is consumed and the `RegExpExecArray` is returned; otherwise
117+
* `null` is returned and `pos` is not changed.
118+
*
119+
* Using the `y` flag with `lastIndex` avoids creating a temporary
120+
* substring slice (which the old `^`-anchor + `this.remaining`
121+
* approach required).
122+
*/
123+
matchRegex(re: RegExp): RegExpExecArray | null {
124+
re.lastIndex = this.pos;
125+
const m = re.exec(this.input);
126+
if (m) {
127+
this._advanceRange(this.pos, this.pos + m[0].length);
128+
}
129+
return m;
130+
}
131+
132+
// ─── Character-based token scanners ──────────────────────────────────────
133+
134+
/**
135+
* Consume zero or more whitespace characters (space, tab, CR, LF,
136+
* form-feed) using `charCodeAt` instead of string comparisons.
137+
*/
138+
skipWhitespace(): void {
139+
const src = this.input;
140+
const len = src.length;
141+
while (this.pos < len) {
142+
const ch = src.charCodeAt(this.pos);
143+
if (ch === Ch_LF) {
144+
this.lineno++;
145+
this.column = 1;
146+
this.pos++;
147+
} else if (
148+
ch === Ch_SPACE ||
149+
ch === Ch_TAB ||
150+
ch === Ch_CR ||
151+
ch === Ch_FF
152+
) {
153+
this.column++;
154+
this.pos++;
155+
} else {
156+
break;
157+
}
158+
}
159+
}
160+
161+
/**
162+
* If the current character is `{`, consume it and any following
163+
* whitespace, then return `true`. Otherwise return `false`.
164+
*/
165+
tryOpenBrace(): boolean {
166+
if (this.input.charCodeAt(this.pos) !== Ch_OPEN) {
167+
return false;
168+
}
169+
this.pos++;
170+
this.column++;
171+
this.skipWhitespace();
172+
return true;
173+
}
174+
175+
/**
176+
* If the current character is `}`, consume it and return `true`.
177+
* Otherwise return `false`.
178+
*/
179+
tryCloseBrace(): boolean {
180+
if (this.input.charCodeAt(this.pos) !== Ch_CLOSE) {
181+
return false;
182+
}
183+
this.pos++;
184+
this.column++;
185+
return true;
186+
}
187+
188+
/**
189+
* If the current character is `:`, consume it and any following
190+
* whitespace, then return `true`. Otherwise return `false`.
191+
*/
192+
tryColon(): boolean {
193+
if (this.input.charCodeAt(this.pos) !== Ch_COLON) {
194+
return false;
195+
}
196+
this.pos++;
197+
this.column++;
198+
this.skipWhitespace();
199+
return true;
200+
}
201+
202+
/**
203+
* Consume any leading semicolons and whitespace characters using
204+
* `charCodeAt` instead of string comparisons.
205+
*/
206+
skipSemicolonAndWhitespace(): void {
207+
const src = this.input;
208+
const len = src.length;
209+
while (this.pos < len) {
210+
const ch = src.charCodeAt(this.pos);
211+
if (ch === Ch_LF) {
212+
this.lineno++;
213+
this.column = 1;
214+
this.pos++;
215+
} else if (
216+
ch === Ch_SEMI ||
217+
ch === Ch_SPACE ||
218+
ch === Ch_TAB ||
219+
ch === Ch_CR ||
220+
ch === Ch_FF
221+
) {
222+
this.column++;
223+
this.pos++;
224+
} else {
225+
break;
226+
}
227+
}
228+
}
229+
230+
/**
231+
* If the current character is `,`, consume it and any following
232+
* whitespace, then return `true`. Otherwise return `false`.
233+
*/
234+
tryCommaAndWhitespace(): boolean {
235+
if (this.input.charCodeAt(this.pos) !== Ch_COMMA) {
236+
return false;
237+
}
238+
this.pos++;
239+
this.column++;
240+
this.skipWhitespace();
241+
return true;
242+
}
243+
244+
// ─── Position snapshot ────────────────────────────────────────────────────
245+
246+
/**
247+
* Returns a snapshot of the current source position as an object
248+
* suitable for use in `Position` nodes.
249+
*/
250+
getPosition(): { line: number; column: number } {
251+
return { line: this.lineno, column: this.column };
252+
}
253+
254+
// ─── Internal helpers ─────────────────────────────────────────────────────
255+
256+
/**
257+
* Update `lineno`, `column`, and `pos` for a range of characters in
258+
* the original input. Uses `charCodeAt` for the newline check.
259+
*/
260+
private _advanceRange(from: number, to: number): void {
261+
const src = this.input;
262+
for (let i = from; i < to; i++) {
263+
if (src.charCodeAt(i) === Ch_LF) {
264+
this.lineno++;
265+
this.column = 1;
266+
} else {
267+
this.column++;
268+
}
269+
}
270+
this.pos = to;
271+
}
272+
}
273+
274+
// Re-export character codes so the parser can use them for fast checks
275+
export { Ch_AT, Ch_CLOSE, Ch_SLASH, Ch_STAR };

0 commit comments

Comments
 (0)