Skip to main content

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}