orinium_browser/engine/css/tokenizer.rs
1//! CSS Tokenizer
2//!
3//! This module implements a **CSS tokenizer**, responsible for converting
4//! a raw CSS source string into a flat stream of tokens.
5//!
6//! ## Responsibilities
7//!
8//! - Consume raw characters
9//! - Produce syntactic tokens defined by the CSS specification
10//! - Preserve the original structure of the input as much as possible
11//!
12//! ## Non-responsibilities
13//!
14//! - Parsing selectors or declarations
15//! - Interpreting values (lengths, colors, percentages, etc.)
16//! - Building trees or nested structures
17//!
18//! ## Design notes
19//!
20//! - Tokens are produced in a **linear stream**
21//! - Function tokens only represent the function name
22//! - Matching of parentheses and function arguments is handled by the parser
23//!
24//! ## Allocation strategy
25//!
26//! Cascade stylesheets are populated with many short identifiers, and the
27//! majority of them contain no escape sequences. Because the tokenizer
28//! borrows from the input string, any such lexeme is returned as a
29//! [`Cow::Borrowed`] slice of the input and only escape-containing (or
30//! otherwise transformed) lexemes are materialized onto the heap. Numbers are
31//! parsed directly from the input slice instead of being assembled in a
32//! scratch buffer.
33
34use std::borrow::Cow;
35
36/// CSS token produced by the tokenizer.
37///
38/// This represents *syntactic units* only.
39/// No semantic interpretation (length, color, etc.) is performed here.
40///
41/// String payloads are [`Cow`]s borrowing from the input where no escape
42/// sequence was encountered, so common identifiers, units and comments avoid
43/// heap allocation entirely.
44#[derive(Debug, Clone)]
45pub enum Token<'a> {
46 /// Identifier token (e.g. `div`, `color`, `--custom`)
47 Ident(Cow<'a, str>),
48
49 /// Function token (e.g. `calc`, `var`)
50 Function(Cow<'a, str>),
51
52 /// Unquoted `url(...)` token (e.g. `url(data:image/png;base64,...)`). The
53 /// raw inner content is captured as a single lexeme, including characters
54 /// that would otherwise be tokenized further (such as `:` or `%` in a
55 /// base64 `data:` URL).
56 Url(Cow<'a, str>),
57
58 /// Plain number without unit (e.g. `0`, `1.5`)
59 Number(f32),
60
61 /// Quoted string token (e.g. `"hello"`, `'world'`)
62 String(Cow<'a, str>),
63
64 /// Dimension token (e.g. `10px`, `50%`, `2em`)
65 ///
66 /// Percentages are also represented as a dimension
67 /// with `%` as the unit.
68 Dimension(f32, Cow<'a, str>),
69
70 /// Delimiter token (single-character symbols such as `:`, `;`, `>`, `+`)
71 Delim(char),
72
73 /// Hash with String (e.g. `#fff`)
74 Hash(Cow<'a, str>),
75
76 /// AtKeyword (e.g. `@media`)
77 AtKeyword(Cow<'a, str>),
78
79 /// One or more whitespace characters
80 Whitespace,
81
82 /// Comment
83 Comment(Cow<'a, str>),
84
85 /// End-of-input marker
86 EOF,
87}
88
89/// Equality across token lifetimes so tokens borrowing from a live input can
90/// be compared against `'static` literals (as in tests) or vice versa.
91impl<'a, 'b> PartialEq<Token<'b>> for Token<'a> {
92 fn eq(&self, other: &Token<'b>) -> bool {
93 match (self, other) {
94 (Token::Ident(a), Token::Ident(b)) => a == b,
95 (Token::Function(a), Token::Function(b)) => a == b,
96 (Token::Url(a), Token::Url(b)) => a == b,
97 (Token::Number(a), Token::Number(b)) => a == b,
98 (Token::String(a), Token::String(b)) => a == b,
99 (Token::Dimension(av, au), Token::Dimension(bv, bu)) => av == bv && au == bu,
100 (Token::Delim(a), Token::Delim(b)) => a == b,
101 (Token::Hash(a), Token::Hash(b)) => a == b,
102 (Token::AtKeyword(a), Token::AtKeyword(b)) => a == b,
103 (Token::Whitespace, Token::Whitespace) => true,
104 (Token::Comment(a), Token::Comment(b)) => a == b,
105 (Token::EOF, Token::EOF) => true,
106 _ => false,
107 }
108 }
109}
110
111/// CSS tokenizer.
112///
113/// This struct is responsible for converting a CSS source string
114/// into a stream of `Token`s.
115///
116/// Responsibilities:
117/// - Consume raw characters
118/// - Produce syntactic tokens
119///
120/// Non-responsibilities:
121/// - Parsing declarations or selectors
122/// - Interpreting values (length, color, etc.)
123/// - Building trees or higher-level structures
124#[derive(Clone)]
125pub struct Tokenizer<'a> {
126 /// Iterator over the input characters
127 chars: std::str::Chars<'a>,
128
129 /// Current character under examination
130 current: Option<char>,
131
132 /// The full input, used to slice borrowed token payloads
133 input: &'a str,
134}
135
136impl<'a> Tokenizer<'a> {
137 /// Create a new tokenizer from a CSS source string.
138 pub fn new(input: &'a str) -> Self {
139 let mut chars = input.chars();
140 let current = chars.next();
141
142 Self {
143 chars,
144 current,
145 input,
146 }
147 }
148
149 /// Advance to the next character.
150 ///
151 /// This method should update `self.current`.
152 fn bump(&mut self) {
153 self.current = self.chars.next();
154 }
155
156 /// Peek the current character without consuming it.
157 fn peek(&self) -> Option<char> {
158 self.current
159 }
160
161 /// Peek the next character from the current one without consuming it.
162 fn peek_next(&self) -> Option<char> {
163 self.chars.clone().next()
164 }
165
166 /// Byte offset of the current (unconsumed) character. This is also the
167 /// exclusive end of the last consumed lexeme, so slices of `self.input`
168 /// taken between a recorded start and this position are exact.
169 fn pos(&self) -> usize {
170 self.input.len() - self.chars.as_str().len()
171 }
172
173 /// Byte offset of the *first* byte of the current character (i.e. of the
174 /// character that `self.pos()` points just past). Used as the start of a
175 /// lexeme that begins at the current character.
176 fn cur_start(&self) -> usize {
177 self.pos() - self.current.map(char::len_utf8).unwrap_or(0)
178 }
179
180 /// Consume and return the next token from the input.
181 ///
182 /// This is the main entry point used by the parser.
183 pub fn next_token(&mut self) -> Token<'a> {
184 let token = match self.peek() {
185 Some(c) if c.is_whitespace() => self.consume_whitespace(),
186 Some(c) if is_number_start(c, self.peek_next()) => self.consume_number_like(),
187 Some(c) if is_ident_start(c) => self.consume_ident_like(),
188 Some(c) if is_string_delimiter(c) => self.consume_string_like(),
189 Some('/') => {
190 if self.peek_next() == Some('*') {
191 self.bump(); // consume '/'
192 self.bump(); // consume '*'
193 self.consume_comment()
194 } else {
195 self.bump();
196 Token::Delim('/')
197 }
198 }
199 Some('#') => {
200 self.bump(); // consume '#'
201 let start = self.cur_start();
202 while let Some(c) = self.peek() {
203 if is_ident_continue(c) {
204 self.bump();
205 } else {
206 break;
207 }
208 }
209 Token::Hash(Cow::Borrowed(&self.input[start..self.cur_start()]))
210 }
211 Some('@') => {
212 self.bump();
213 let start = self.cur_start();
214 while let Some(c) = self.peek() {
215 if is_ident_continue(c) {
216 self.bump();
217 } else {
218 break;
219 }
220 }
221 Token::AtKeyword(Cow::Borrowed(&self.input[start..self.cur_start()]))
222 }
223 Some(c) => {
224 self.bump();
225 Token::Delim(c)
226 }
227 None => Token::EOF,
228 };
229
230 log::debug!(target: "CssTokenizer", "Tokenized: {:?}", token);
231
232 token
233 }
234
235 /// Consume consecutive whitespace characters.
236 ///
237 /// Produces a single `Token::Whitespace`.
238 fn consume_whitespace(&mut self) -> Token<'a> {
239 while matches!(self.current, Some(c) if c.is_whitespace()) {
240 self.bump();
241 }
242 Token::Whitespace
243 }
244
245 /// Consume an identifier or function token.
246 ///
247 /// If an identifier is immediately followed by `(`,
248 /// this method should produce a `Token::Function`.
249 ///
250 /// Identifiers without escape sequences are returned as borrowed slices
251 /// of the input; escape resolution materializes a heap buffer lazily.
252 fn consume_ident_like(&mut self) -> Token<'a> {
253 let start = self.cur_start();
254 let mut owned: Option<String> = None;
255
256 while let Some(c) = self.peek() {
257 if c == '\\' {
258 let buf =
259 owned.get_or_insert_with(|| self.input[start..self.cur_start()].to_string());
260 if let Some(escaped) = self.consume_escape() {
261 buf.push(escaped);
262 }
263 } else if is_ident_continue(c) {
264 if let Some(buf) = owned.as_mut() {
265 buf.push(c);
266 }
267 self.bump();
268 } else {
269 break;
270 }
271 }
272
273 let ident: Cow<'a, str> = match owned {
274 Some(s) => Cow::Owned(s),
275 None => Cow::Borrowed(&self.input[start..self.cur_start()]),
276 };
277
278 if self.peek() == Some('(') {
279 if ident.eq_ignore_ascii_case("url") {
280 self.consume_url_after_name(ident)
281 } else {
282 Token::Function(ident)
283 }
284 } else {
285 Token::Ident(ident)
286 }
287 }
288
289 /// Consume the body of an unquoted `url(...)` token after the name has
290 /// already been read.
291 ///
292 /// Per the CSS Syntax spec, `url(` followed by a string delimiter is a
293 /// plain function token whose quoted string argument is handled by the
294 /// parser. Otherwise we consume the raw content up to the closing `)`,
295 /// resolving escapes, so that the URL is preserved byte-for-byte.
296 fn consume_url_after_name(&mut self, ident: Cow<'a, str>) -> Token<'a> {
297 self.bump(); // consume '('
298
299 if let Some(c) = self.peek()
300 && is_string_delimiter(c)
301 {
302 // Quoted URL (e.g. `url("foo.png")`) — behave as a function token
303 // so the parser handles the string argument.
304 return Token::Function(ident);
305 }
306
307 let start = self.cur_start();
308 let mut owned: Option<String> = None;
309 let mut end = self.input.len();
310
311 loop {
312 match self.peek() {
313 None => break, // EOF without a closing paren
314 Some(')') => {
315 end = self.cur_start();
316 self.bump();
317 break;
318 }
319 Some('\\') => {
320 let buf = owned
321 .get_or_insert_with(|| self.input[start..self.cur_start()].to_string());
322 if let Some(escaped) = self.consume_escape() {
323 buf.push(escaped);
324 } else {
325 // A lone trailing backslash: bad URL, but keep the
326 // eventual closing paren in sync.
327 end = self.cur_start();
328 self.bump();
329 break;
330 }
331 }
332 Some(c) => {
333 if let Some(buf) = owned.as_mut() {
334 buf.push(c);
335 }
336 self.bump();
337 }
338 }
339 }
340
341 let value: Cow<'a, str> = match owned {
342 Some(s) => Cow::Owned(s.trim().to_string()),
343 None => Cow::Borrowed(self.input[start..end].trim()),
344 };
345
346 Token::Url(value)
347 }
348
349 fn consume_string_like(&mut self) -> Token<'a> {
350 let quote = self.peek().unwrap(); // '"' or '\''
351 self.bump(); // consume opening quote
352
353 let start = self.cur_start();
354 let mut owned: Option<String> = None;
355 let mut end = self.input.len();
356
357 while let Some(c) = self.peek() {
358 if c == quote {
359 end = self.cur_start();
360 self.bump(); // consume closing quote
361 break;
362 }
363
364 if c == '\\' {
365 let buf =
366 owned.get_or_insert_with(|| self.input[start..self.cur_start()].to_string());
367 if let Some(escaped) = self.consume_escape() {
368 buf.push(escaped);
369 }
370 continue;
371 }
372
373 if let Some(buf) = owned.as_mut() {
374 buf.push(c);
375 }
376 self.bump();
377 }
378
379 let value: Cow<'a, str> = match owned {
380 Some(s) => Cow::Owned(s),
381 None => Cow::Borrowed(&self.input[start..end]),
382 };
383
384 Token::String(value)
385 }
386
387 /// Consume a number-like token.
388 ///
389 /// This may produce:
390 /// - `Token::Number`
391 /// - `Token::Dimension` (including `%`)
392 ///
393 /// The numeric value is parsed straight from the input slice rather than
394 /// through a scratch buffer, avoiding per-token `String` churn.
395 fn consume_number_like(&mut self) -> Token<'a> {
396 let start = self.cur_start();
397
398 let mut has_dot = if self.peek() == Some('.') {
399 self.bump();
400 true
401 } else {
402 false
403 };
404
405 if self.peek() == Some('-') {
406 self.bump();
407 }
408
409 while let Some(c) = self.peek() {
410 if c.is_ascii_digit() {
411 self.bump();
412 } else if c == '.' && !has_dot {
413 has_dot = true;
414 self.bump();
415 } else {
416 break;
417 }
418 }
419
420 let value: f32 = self.input[start..self.cur_start()].parse().unwrap_or(0.0);
421
422 // --- unit / percentage branching ---
423 match self.peek() {
424 Some('%') => {
425 self.bump();
426 Token::Dimension(value, Cow::Borrowed("%"))
427 }
428 Some(c) if is_ident_start(c) => {
429 let unit_start = self.cur_start();
430 let mut owned: Option<String> = None;
431
432 while let Some(c) = self.peek() {
433 if c == '\\' {
434 let buf = owned.get_or_insert_with(|| {
435 self.input[unit_start..self.cur_start()].to_string()
436 });
437 if let Some(escaped) = self.consume_escape() {
438 buf.push(escaped);
439 }
440 } else if is_ident_continue(c) {
441 if let Some(buf) = owned.as_mut() {
442 buf.push(c);
443 }
444 self.bump();
445 } else {
446 break;
447 }
448 }
449
450 let unit: Cow<'a, str> = match owned {
451 Some(s) => Cow::Owned(s),
452 None => Cow::Borrowed(&self.input[unit_start..self.cur_start()]),
453 };
454
455 Token::Dimension(value, unit)
456 }
457 _ => Token::Number(value),
458 }
459 }
460
461 /// Consume a CSS comment.
462 ///
463 /// Assumes the opening `/*` has already been consumed.
464 fn consume_comment(&mut self) -> Token<'a> {
465 let start = self.cur_start();
466
467 while let Some(c) = self.peek() {
468 if c == '*' && self.peek_next() == Some('/') {
469 self.bump(); // consume '*'
470 self.bump(); // consume '/'
471 break;
472 } else {
473 self.bump();
474 }
475 }
476
477 Token::Comment(Cow::Borrowed(&self.input[start..self.cur_start()]))
478 }
479
480 fn consume_escape(&mut self) -> Option<char> {
481 self.bump(); // consume '\'
482
483 // 1. Line continuation: backslash + newline => nothing
484 match self.peek() {
485 Some('\n') => {
486 self.bump();
487 return None;
488 }
489 Some('\r') => {
490 self.bump();
491 if self.peek() == Some('\n') {
492 self.bump(); // CRLF
493 }
494 return None;
495 }
496 _ => {}
497 }
498
499 // 2. Unicode escape
500 let mut code: u32 = 0;
501 let mut hex_count = 0;
502 for _ in 0..6 {
503 match self.peek() {
504 Some(c) if c.is_ascii_hexdigit() => {
505 if let Some(digit) = c.to_digit(16) {
506 code = code * 16 + digit;
507 hex_count += 1;
508 self.bump();
509 }
510 }
511 _ => break,
512 }
513 }
514
515 if hex_count > 0 {
516 if matches!(self.peek(), Some(c) if c.is_whitespace()) {
517 self.bump(); // optional whitespace
518 }
519
520 return std::char::from_u32(code).or(Some('\u{FFFD}'));
521 }
522
523 // 3. Simple escape
524 if let Some(c) = self.peek() {
525 self.bump();
526 Some(c)
527 } else {
528 None
529 }
530 }
531}
532
533/// Returns true if the character can start an identifier.
534///
535/// This is a simplified CSS identifier start check.
536/// It supports:
537/// - ASCII letters (A–Z, a–z)
538/// - underscore (`_`)
539/// - hyphen (`-`)
540/// - non-ASCII characters
541fn is_ident_start(c: char) -> bool {
542 c.is_ascii_alphabetic() || c == '\\' || c == '_' || c == '-' || !c.is_ascii()
543}
544
545/// Returns true if the character is a CSS string delimiter.
546///
547/// CSS strings are delimited by either double quotes (`"`)
548/// or single quotes (`'`).
549fn is_string_delimiter(c: char) -> bool {
550 matches!(c, '"' | '\'')
551}
552
553/// Returns true if the character can continue an identifier.
554///
555/// - ASCII letters (A–Z, a–z)
556/// - ASCII digits (0–9)
557/// - Underscore (`_`)
558/// - Hyphen (`-`)
559/// - Non-ASCII characters
560fn is_ident_continue(c: char) -> bool {
561 c.is_ascii_alphanumeric() || c == '_' || c == '-' || !c.is_ascii()
562}
563
564/// Returns true if the character is a CSS number start.
565///
566/// - ASCII digits (0-9)
567/// - A dot followed by a digit (e.g. `.5`)
568/// - A hyphen followed by a digit or dot (e.g. `-1`, `-.5`)
569fn is_number_start(current: char, next: Option<char>) -> bool {
570 current.is_ascii_digit()
571 || (current == '.' && matches!(next, Some(c) if c.is_ascii_digit()))
572 || (current == '-' && matches!(next, Some(c) if c.is_ascii_digit() || c == '.'))
573}