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}