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/// CSS token produced by the tokenizer.
25///
26/// This represents *syntactic units* only.
27/// No semantic interpretation (length, color, etc.) is performed here.
28#[derive(Debug, Clone, PartialEq)]
29pub enum Token {
30    /// Identifier token (e.g. `div`, `color`, `--custom`)
31    Ident(String),
32
33    /// Function token (e.g. `calc`, `var`)
34    Function(String),
35
36    /// Unquoted `url(...)` token (e.g. `url(data:image/png;base64,...)`). The
37    /// raw inner content is captured as a single lexeme, including characters
38    /// that would otherwise be tokenized further (such as `:` or `%` in a
39    /// base64 `data:` URL).
40    Url(String),
41
42    /// Plain number without unit (e.g. `0`, `1.5`)
43    Number(f32),
44
45    /// Quoted string token (e.g. `"hello"`, `'world'`)
46    String(String),
47
48    /// Dimension token (e.g. `10px`, `50%`, `2em`)
49    ///
50    /// Percentages are also represented as a dimension
51    /// with `%` as the unit.
52    Dimension(f32, String),
53
54    /// Delimiter token (single-character symbols such as `:`, `;`, `>`, `+`)
55    Delim(char),
56
57    /// Hash with String (e.g. `#fff`)
58    Hash(String),
59
60    /// AtKeyword (e.g. `@media`)
61    AtKeyword(String),
62
63    /// One or more whitespace characters
64    Whitespace,
65
66    /// Comment
67    Comment(String),
68
69    /// End-of-input marker
70    EOF,
71}
72
73/// CSS tokenizer.
74///
75/// This struct is responsible for converting a CSS source string
76/// into a stream of `Token`s.
77///
78/// Responsibilities:
79/// - Consume raw characters
80/// - Produce syntactic tokens
81///
82/// Non-responsibilities:
83/// - Parsing declarations or selectors
84/// - Interpreting values (length, color, etc.)
85/// - Building trees or higher-level structures
86#[derive(Clone)]
87pub struct Tokenizer<'a> {
88    /// Iterator over the input characters
89    chars: std::str::Chars<'a>,
90
91    /// Current character under examination
92    current: Option<char>,
93}
94
95impl<'a> Tokenizer<'a> {
96    /// Create a new tokenizer from a CSS source string.
97    pub fn new(input: &'a str) -> Self {
98        let mut chars = input.chars();
99        let current = chars.next();
100
101        Self { chars, current }
102    }
103
104    /// Advance to the next character.
105    ///
106    /// This method should update `self.current`.
107    fn bump(&mut self) {
108        self.current = self.chars.next();
109    }
110
111    /// Peek the current character without consuming it.
112    fn peek(&self) -> Option<char> {
113        self.current
114    }
115
116    /// Peek the next character from the current one without consuming it.
117    fn peek_next(&self) -> Option<char> {
118        self.chars.clone().next()
119    }
120
121    /// Consume and return the next token from the input.
122    ///
123    /// This is the main entry point used by the parser.
124    pub fn next_token(&mut self) -> Token {
125        let token = match self.peek() {
126            Some(c) if c.is_whitespace() => self.consume_whitespace(),
127            Some(c) if is_number_start(c, self.peek_next()) => self.consume_number_like(),
128            Some(c) if is_ident_start(c) => self.consume_ident_like(),
129            Some(c) if is_string_delimiter(c) => self.consume_string_like(),
130            Some('/') => {
131                if self.peek_next() == Some('*') {
132                    self.bump(); // consume '/'
133                    self.bump(); // consume '*'
134                    self.consume_comment()
135                } else {
136                    self.bump();
137                    Token::Delim('/')
138                }
139            }
140            Some('#') => {
141                self.bump(); // consume '#'
142                let mut value = String::new();
143                while let Some(c) = self.peek() {
144                    if is_ident_continue(c) {
145                        value.push(c);
146                        self.bump();
147                    } else {
148                        break;
149                    }
150                }
151                Token::Hash(value)
152            }
153            Some('@') => {
154                self.bump();
155                let mut value = String::new();
156                while let Some(c) = self.peek() {
157                    if is_ident_continue(c) {
158                        value.push(c);
159                        self.bump();
160                    } else {
161                        break;
162                    }
163                }
164                Token::AtKeyword(value)
165            }
166            Some(c) => {
167                self.bump();
168                Token::Delim(c)
169            }
170            None => Token::EOF,
171        };
172
173        log::debug!(target: "CssTokenizer", "Tokenized: {:?}", token);
174
175        token
176    }
177
178    /// Consume consecutive whitespace characters.
179    ///
180    /// Produces a single `Token::Whitespace`.
181    fn consume_whitespace(&mut self) -> Token {
182        while matches!(self.current, Some(c) if c.is_whitespace()) {
183            self.bump();
184        }
185        Token::Whitespace
186    }
187
188    /// Consume an identifier or function token.
189    ///
190    /// If an identifier is immediately followed by `(`,
191    /// this method should produce a `Token::Function`.
192    fn consume_ident_like(&mut self) -> Token {
193        let mut ident = String::new();
194
195        while let Some(c) = self.peek() {
196            if c == '\\' {
197                if let Some(escaped) = self.consume_escape() {
198                    ident.push(escaped);
199                }
200            } else if is_ident_continue(c) {
201                ident.push(c);
202                self.bump();
203            } else {
204                break;
205            }
206        }
207        if self.peek() == Some('(') {
208            if ident.eq_ignore_ascii_case("url") {
209                self.consume_url_after_name(ident)
210            } else {
211                Token::Function(ident)
212            }
213        } else {
214            Token::Ident(ident)
215        }
216    }
217
218    /// Consume the body of an unquoted `url(...)` token after the name has
219    /// already been read.
220    ///
221    /// Per the CSS Syntax spec, `url(` followed by a string delimiter is a
222    /// plain function token whose quoted string argument is handled by the
223    /// parser. Otherwise we consume the raw content up to the closing `)`,
224    /// resolving escapes, so that the URL is preserved byte-for-byte.
225    fn consume_url_after_name(&mut self, ident: String) -> Token {
226        self.bump(); // consume '('
227
228        if let Some(c) = self.peek()
229            && is_string_delimiter(c)
230        {
231            // Quoted URL (e.g. `url("foo.png")`) — behave as a function token
232            // so the parser handles the string argument.
233            return Token::Function(ident);
234        }
235
236        let mut value = String::new();
237
238        loop {
239            match self.peek() {
240                None => break, // EOF without a closing paren
241                Some(')') => {
242                    self.bump();
243                    break;
244                }
245                Some('\\') => {
246                    if let Some(escaped) = self.consume_escape() {
247                        value.push(escaped);
248                    } else {
249                        // A lone trailing backslash: bad URL, but keep the
250                        // eventual closing paren in sync.
251                        self.bump();
252                        break;
253                    }
254                }
255                Some(c) => {
256                    value.push(c);
257                    self.bump();
258                }
259            }
260        }
261
262        Token::Url(value.trim().to_string())
263    }
264
265    fn consume_string_like(&mut self) -> Token {
266        let quote = self.peek().unwrap(); // '"' or '\''
267        self.bump(); // consume opening quote
268
269        let mut value = String::new();
270
271        while let Some(c) = self.peek() {
272            if c == quote {
273                self.bump(); // consume closing quote
274                break;
275            }
276
277            if c == '\\' {
278                if let Some(escaped) = self.consume_escape() {
279                    value.push(escaped);
280                }
281                continue;
282            }
283
284            value.push(c);
285            self.bump();
286        }
287
288        Token::String(value)
289    }
290
291    /// Consume a number-like token.
292    ///
293    /// This may produce:
294    /// - `Token::Number`
295    /// - `Token::Dimension` (including `%`)
296    fn consume_number_like(&mut self) -> Token {
297        let mut buf = String::new();
298
299        let mut has_dot = if self.peek() == Some('.') {
300            buf.push('.');
301            self.bump();
302            true
303        } else {
304            false
305        };
306
307        if self.peek() == Some('-') {
308            buf.push('-');
309            self.bump();
310        }
311
312        while let Some(c) = self.peek() {
313            if c.is_ascii_digit() {
314                buf.push(c);
315                self.bump();
316            } else if c == '.' && !has_dot {
317                has_dot = true;
318                buf.push(c);
319                self.bump();
320            } else {
321                break;
322            }
323        }
324
325        let value: f32 = buf.parse().unwrap_or(0.0);
326
327        // --- unit / percentage branching ---
328        match self.peek() {
329            Some('%') => {
330                self.bump();
331                Token::Dimension(value, "%".to_string())
332            }
333            Some(c) if is_ident_start(c) => {
334                let mut unit = String::new();
335                while let Some(c) = self.peek() {
336                    if is_ident_continue(c) {
337                        unit.push(c);
338                        self.bump();
339                    } else {
340                        break;
341                    }
342                }
343                Token::Dimension(value, unit)
344            }
345            _ => Token::Number(value),
346        }
347    }
348
349    /// Consume a CSS comment.
350    ///
351    /// Assumes the opening `/*` has already been consumed.
352    fn consume_comment(&mut self) -> Token {
353        let mut value = String::new();
354
355        while let Some(c) = self.peek() {
356            if c == '*' && self.peek_next() == Some('/') {
357                self.bump(); // consume '*'
358                self.bump(); // consume '/'
359                break;
360            } else {
361                value.push(c);
362                self.bump();
363            }
364        }
365
366        Token::Comment(value)
367    }
368
369    fn consume_escape(&mut self) -> Option<char> {
370        self.bump(); // consume '\'
371
372        // 1. Line continuation: backslash + newline => nothing
373        match self.peek() {
374            Some('\n') => {
375                self.bump();
376                return None;
377            }
378            Some('\r') => {
379                self.bump();
380                if self.peek() == Some('\n') {
381                    self.bump(); // CRLF
382                }
383                return None;
384            }
385            _ => {}
386        }
387
388        // 2. Unicode escape
389        let mut hex = String::new();
390        for _ in 0..6 {
391            match self.peek() {
392                Some(c) if c.is_ascii_hexdigit() => {
393                    hex.push(c);
394                    self.bump();
395                }
396                _ => break,
397            }
398        }
399
400        if !hex.is_empty() {
401            if matches!(self.peek(), Some(c) if c.is_whitespace()) {
402                self.bump(); // optional whitespace
403            }
404
405            let code = u32::from_str_radix(&hex, 16).ok()?;
406            return std::char::from_u32(code).or(Some('\u{FFFD}'));
407        }
408
409        // 3. Simple escape
410        if let Some(c) = self.peek() {
411            self.bump();
412            Some(c)
413        } else {
414            None
415        }
416    }
417}
418
419/// Returns true if the character can start an identifier.
420///
421/// This is a simplified CSS identifier start check.
422/// It supports:
423/// - ASCII letters (A–Z, a–z)
424/// - underscore (`_`)
425/// - hyphen (`-`)
426/// - non-ASCII characters
427fn is_ident_start(c: char) -> bool {
428    c.is_ascii_alphabetic() || c == '\\' || c == '_' || c == '-' || !c.is_ascii()
429}
430
431/// Returns true if the character is a CSS string delimiter.
432///
433/// CSS strings are delimited by either double quotes (`"`)
434/// or single quotes (`'`).
435fn is_string_delimiter(c: char) -> bool {
436    matches!(c, '"' | '\'')
437}
438
439/// Returns true if the character can continue an identifier.
440///
441/// - ASCII letters (A–Z, a–z)
442/// - ASCII digits (0–9)
443/// - Underscore (`_`)
444/// - Hyphen (`-`)
445/// - Non-ASCII characters
446fn is_ident_continue(c: char) -> bool {
447    c.is_ascii_alphanumeric() || c == '_' || c == '-' || !c.is_ascii()
448}
449
450/// Returns true if the character is a CSS number start.
451///
452/// - ASCII digits (0-9)
453/// - A dot followed by a digit (e.g. `.5`)
454/// - A hyphen followed by a digit or dot (e.g. `-1`, `-.5`)
455fn is_number_start(current: char, next: Option<char>) -> bool {
456    current.is_ascii_digit()
457        || (current == '.' && matches!(next, Some(c) if c.is_ascii_digit()))
458        || (current == '-' && matches!(next, Some(c) if c.is_ascii_digit() || c == '.'))
459}