Skip to main content

media_query_parse/
tokenizer.rs

1//! CSS tokenizer, implementing [CSS Syntax Module Level 3][spec] §3
2//! (Tokenizing and Parsing CSS) and §4 (Tokenization).
3//!
4//! This module only turns a `&str` into a stream of tokens. It has no
5//! knowledge of the Media Queries grammar (feature names, `and`/`or`/
6//! `not`, range syntax, ...) — that belongs to the parser (phase 03).
7//!
8//! [spec]: https://www.w3.org/TR/css-syntax-3/
9
10/// Numeric type flag, as used by [`Token::Number`] and
11/// [`Token::Dimension`] (spec §4.3.12, "Consume a number").
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum NumericType {
15    /// The number had no fractional part or exponent.
16    Integer,
17    /// The number had a fractional part and/or an exponent.
18    Number,
19}
20
21/// Type flag of a [`Token::Hash`] (spec §4.3.1, the `#` branch).
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum HashType {
25    /// The hash's value would be a valid ID selector (starts with an
26    /// ident sequence).
27    Id,
28    /// The hash's value does not qualify as an ID selector.
29    Unrestricted,
30}
31
32/// A single CSS token, per spec §4.3.1 ("Consume a token") and the
33/// token types defined at the start of §4.3.
34#[derive(Debug, Clone, PartialEq)]
35#[non_exhaustive]
36pub enum Token {
37    /// An identifier, e.g. `screen`.
38    Ident(String),
39    /// An identifier immediately followed by `(`, e.g. `calc(`. The
40    /// `(` itself is consumed but not part of the stored string.
41    Function(String),
42    /// `@` followed by an ident sequence, e.g. `@media`.
43    AtKeyword(String),
44    /// `#` followed by an ident-code-point/escape sequence, e.g. `#foo`.
45    Hash {
46        /// The value after `#`.
47        value: String,
48        /// Whether the value would itself be a valid ID selector.
49        type_flag: HashType,
50    },
51    /// A quoted string, e.g. `"screen"`.
52    String(String),
53    /// A string that was not terminated correctly (e.g. an unescaped
54    /// newline before the closing quote).
55    BadString,
56    /// A `url(...)` token with an unquoted value.
57    Url(String),
58    /// A `url(...)` construct that could not be tokenized as a valid
59    /// URL token (e.g. an unescaped space in the unquoted value).
60    BadUrl,
61    /// A single code point that didn't start any other token, e.g. `^`.
62    Delim(char),
63    /// A numeric literal with no unit or `%` suffix, e.g. `42`.
64    Number {
65        /// The parsed numeric value.
66        value: f64,
67        /// Whether the literal had a fractional part/exponent.
68        type_flag: NumericType,
69    },
70    /// A numeric literal followed by `%`, e.g. `50%`.
71    Percentage {
72        /// The parsed numeric value (before the `%`).
73        value: f64,
74    },
75    /// A numeric literal followed by a unit, e.g. `10px`.
76    Dimension {
77        /// The parsed numeric value (before the unit).
78        value: f64,
79        /// Whether the literal had a fractional part/exponent.
80        type_flag: NumericType,
81        /// The unit identifier, e.g. `px`.
82        unit: String,
83    },
84    /// One or more consecutive whitespace code points.
85    Whitespace,
86    /// `<!--`
87    Cdo,
88    /// `-->`
89    Cdc,
90    /// `:`
91    Colon,
92    /// `;`
93    Semicolon,
94    /// `,`
95    Comma,
96    /// `[`
97    OpenSquare,
98    /// `]`
99    CloseSquare,
100    /// `(`
101    OpenParen,
102    /// `)`
103    CloseParen,
104    /// `{`
105    OpenCurly,
106    /// `}`
107    CloseCurly,
108    /// The end of the input stream. Always the last token produced by
109    /// [`Tokenizer`], never followed by another token.
110    Eof,
111}
112
113/// Preprocesses the input stream per spec §3.3 ("Preprocessing the
114/// input stream"):
115///
116/// - CR, FF, and CRLF are normalized to a single LF.
117/// - U+0000 NULL is replaced with U+FFFD REPLACEMENT CHARACTER.
118///
119/// The spec also requires replacing lone surrogates with U+FFFD, but
120/// that step is a no-op here: `input` is a Rust `&str`, which is
121/// guaranteed to be valid UTF-8, and surrogate code points cannot
122/// occur in valid UTF-8. There is no surrogate case to handle.
123fn preprocess(input: &str) -> String {
124    let mut out = String::with_capacity(input.len());
125    let mut chars = input.chars().peekable();
126    while let Some(c) = chars.next() {
127        match c {
128            '\r' => {
129                if chars.peek() == Some(&'\n') {
130                    chars.next();
131                }
132                out.push('\n');
133            }
134            '\u{000C}' => out.push('\n'),
135            '\u{0000}' => out.push('\u{FFFD}'),
136            other => out.push(other),
137        }
138    }
139    out
140}
141
142/// Spec §4.2: "letter" is an uppercase or lowercase ASCII letter.
143fn is_letter(c: char) -> bool {
144    c.is_ascii_alphabetic()
145}
146
147/// Spec §4.2: "non-ASCII code point" is any code point >= U+0080.
148fn is_non_ascii(c: char) -> bool {
149    !c.is_ascii()
150}
151
152/// Spec §4.2: "ident-start code point".
153fn is_ident_start(c: char) -> bool {
154    is_letter(c) || is_non_ascii(c) || c == '_'
155}
156
157/// Spec §4.2: "ident code point".
158fn is_ident_code_point(c: char) -> bool {
159    is_ident_start(c) || c.is_ascii_digit() || c == '-'
160}
161
162/// Spec §4.2: "non-printable code point".
163fn is_non_printable(c: char) -> bool {
164    matches!(c, '\u{0000}'..='\u{0008}' | '\u{000B}' | '\u{000E}'..='\u{001F}' | '\u{007F}')
165}
166
167/// Spec §4.2: "whitespace" (newline, tab, or space; CR/FF have already
168/// been normalized away by [`preprocess`]).
169fn is_whitespace(c: char) -> bool {
170    matches!(c, '\n' | '\t' | ' ')
171}
172
173fn is_surrogate(value: u32) -> bool {
174    (0xD800..=0xDFFF).contains(&value)
175}
176
177/// A tokenizer over a preprocessed input stream, per spec §4.3.
178///
179/// Implements [`Iterator`], yielding tokens in order and ending with a
180/// single [`Token::Eof`], after which it yields `None`. See
181/// `plan/DECISIONS.md` for why this shape (iterator + terminal EOF
182/// token, plus the [`tokenize`] convenience function) was chosen over
183/// alternatives.
184pub struct Tokenizer {
185    input: Vec<char>,
186    pos: usize,
187    done: bool,
188}
189
190impl Tokenizer {
191    /// Creates a tokenizer over `input`, applying the spec §3.3
192    /// preprocessing step (newline normalization, NULL replacement) up
193    /// front.
194    pub fn new(input: &str) -> Self {
195        Self {
196            input: preprocess(input).chars().collect(),
197            pos: 0,
198            done: false,
199        }
200    }
201
202    fn peek_n(&self, n: usize) -> Option<char> {
203        self.input.get(self.pos + n).copied()
204    }
205
206    fn peek(&self) -> Option<char> {
207        self.peek_n(0)
208    }
209
210    /// Consumes as much whitespace as possible. Shared substep of several
211    /// spec algorithms (not itself a named spec algorithm).
212    fn skip_whitespace(&mut self) {
213        while matches!(self.peek(), Some(w) if is_whitespace(w)) {
214            self.pos += 1;
215        }
216    }
217
218    /// Spec §4.3.8: "Check if two code points are a valid escape",
219    /// applied to the two code points starting at `offset`.
220    fn is_valid_escape_at(&self, offset: usize) -> bool {
221        match self.peek_n(offset) {
222            Some('\\') => !matches!(self.peek_n(offset + 1), Some('\n')),
223            _ => false,
224        }
225    }
226
227    /// Spec §4.3.9: "Check if three code points would start an ident
228    /// sequence", applied starting at `offset`.
229    fn starts_ident_sequence(&self, offset: usize) -> bool {
230        match self.peek_n(offset) {
231            Some('-') => match self.peek_n(offset + 1) {
232                Some(c) if is_ident_start(c) || c == '-' => true,
233                _ => self.is_valid_escape_at(offset + 1),
234            },
235            Some(c) if is_ident_start(c) => true,
236            Some('\\') => self.is_valid_escape_at(offset),
237            _ => false,
238        }
239    }
240
241    /// Spec §4.3.10: "Check if three code points would start a
242    /// number", applied starting at `offset`.
243    fn starts_number(&self, offset: usize) -> bool {
244        match self.peek_n(offset) {
245            Some('+') | Some('-') => match self.peek_n(offset + 1) {
246                Some(c) if c.is_ascii_digit() => true,
247                Some('.') => matches!(self.peek_n(offset + 2), Some(c) if c.is_ascii_digit()),
248                _ => false,
249            },
250            Some('.') => matches!(self.peek_n(offset + 1), Some(c) if c.is_ascii_digit()),
251            Some(c) if c.is_ascii_digit() => true,
252            _ => false,
253        }
254    }
255
256    /// Spec §4.3.2: "Consume comments". Comments produce no token.
257    fn consume_comments(&mut self) {
258        while self.peek() == Some('/') && self.peek_n(1) == Some('*') {
259            self.pos += 2;
260            loop {
261                match self.peek() {
262                    None => return, // parse error: EOF inside comment
263                    Some('*') if self.peek_n(1) == Some('/') => {
264                        self.pos += 2;
265                        break;
266                    }
267                    _ => self.pos += 1,
268                }
269            }
270        }
271    }
272
273    /// Spec §4.3.7: "Consume an escaped code point". Assumes the
274    /// leading backslash has already been consumed.
275    fn consume_escaped_code_point(&mut self) -> char {
276        match self.peek() {
277            Some(c) if c.is_ascii_hexdigit() => {
278                let mut hex = String::new();
279                hex.push(c);
280                self.pos += 1;
281                for _ in 0..5 {
282                    match self.peek() {
283                        Some(h) if h.is_ascii_hexdigit() => {
284                            hex.push(h);
285                            self.pos += 1;
286                        }
287                        _ => break,
288                    }
289                }
290                if matches!(self.peek(), Some(w) if is_whitespace(w)) {
291                    self.pos += 1;
292                }
293                let value = u32::from_str_radix(&hex, 16).unwrap_or(0);
294                if value == 0 || is_surrogate(value) || value > 0x10FFFF {
295                    '\u{FFFD}'
296                } else {
297                    char::from_u32(value).unwrap_or('\u{FFFD}')
298                }
299            }
300            Some(c) => {
301                self.pos += 1;
302                c
303            }
304            None => '\u{FFFD}', // parse error: EOF
305        }
306    }
307
308    /// Spec §4.3.11: "Consume an ident sequence".
309    fn consume_ident_sequence(&mut self) -> String {
310        let mut result = String::new();
311        loop {
312            match self.peek() {
313                Some(c) if is_ident_code_point(c) => {
314                    result.push(c);
315                    self.pos += 1;
316                }
317                Some('\\') if self.is_valid_escape_at(0) => {
318                    self.pos += 1;
319                    let ch = self.consume_escaped_code_point();
320                    result.push(ch);
321                }
322                _ => break,
323            }
324        }
325        result
326    }
327
328    /// Spec §4.3.12: "Consume a number". Returns the (grammar-exact)
329    /// representation string together with its type flag.
330    ///
331    /// The representation is parsed with `str::parse::<f64>`, which
332    /// implements the same sign/integer/fraction/exponent semantics
333    /// as spec §4.3.13 ("Convert a string to a number") for any
334    /// string this algorithm can produce.
335    fn consume_number(&mut self) -> (f64, NumericType) {
336        let mut repr = String::new();
337        let mut type_flag = NumericType::Integer;
338
339        if matches!(self.peek(), Some('+') | Some('-')) {
340            repr.push(self.peek().unwrap());
341            self.pos += 1;
342        }
343        while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
344            repr.push(self.peek().unwrap());
345            self.pos += 1;
346        }
347        if self.peek() == Some('.') && matches!(self.peek_n(1), Some(c) if c.is_ascii_digit()) {
348            repr.push('.');
349            self.pos += 1;
350            type_flag = NumericType::Number;
351            while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
352                repr.push(self.peek().unwrap());
353                self.pos += 1;
354            }
355        }
356        if matches!(self.peek(), Some('e') | Some('E')) {
357            let has_sign = matches!(self.peek_n(1), Some('+') | Some('-'));
358            let digit_offset = if has_sign { 2 } else { 1 };
359            if matches!(self.peek_n(digit_offset), Some(c) if c.is_ascii_digit()) {
360                repr.push(self.peek().unwrap());
361                self.pos += 1;
362                if has_sign {
363                    repr.push(self.peek().unwrap());
364                    self.pos += 1;
365                }
366                type_flag = NumericType::Number;
367                while matches!(self.peek(), Some(c) if c.is_ascii_digit()) {
368                    repr.push(self.peek().unwrap());
369                    self.pos += 1;
370                }
371            }
372        }
373
374        // `repr` is only reached via `starts_number`, which guarantees
375        // a syntactically valid CSS number literal, so this always
376        // parses.
377        let value = repr
378            .parse::<f64>()
379            .expect("consume_number produced a valid number literal");
380        (value, type_flag)
381    }
382
383    /// Spec §4.3.3: "Consume a numeric token".
384    fn consume_numeric_token(&mut self) -> Token {
385        let (value, type_flag) = self.consume_number();
386        if self.starts_ident_sequence(0) {
387            let unit = self.consume_ident_sequence();
388            Token::Dimension {
389                value,
390                type_flag,
391                unit,
392            }
393        } else if self.peek() == Some('%') {
394            self.pos += 1;
395            Token::Percentage { value }
396        } else {
397            Token::Number { value, type_flag }
398        }
399    }
400
401    /// Spec §4.3.14: "Consume the remnants of a bad url", used for
402    /// error recovery after a bad-url-token.
403    fn consume_bad_url_remnants(&mut self) {
404        loop {
405            match self.peek() {
406                Some(')') => {
407                    self.pos += 1;
408                    return;
409                }
410                None => return,
411                _ if self.is_valid_escape_at(0) => {
412                    self.pos += 1;
413                    self.consume_escaped_code_point();
414                }
415                _ => self.pos += 1,
416            }
417        }
418    }
419
420    /// Spec §4.3.6: "Consume a url token". Assumes the leading `url(`
421    /// has already been consumed.
422    fn consume_url_token(&mut self) -> Token {
423        let mut value = String::new();
424        self.skip_whitespace();
425        loop {
426            match self.peek() {
427                None => return Token::Url(value), // parse error: EOF
428                Some(')') => {
429                    self.pos += 1;
430                    return Token::Url(value);
431                }
432                Some(c) if is_whitespace(c) => {
433                    self.skip_whitespace();
434                    match self.peek() {
435                        Some(')') => {
436                            self.pos += 1;
437                            return Token::Url(value);
438                        }
439                        None => return Token::Url(value), // parse error: EOF
440                        _ => {
441                            self.consume_bad_url_remnants();
442                            return Token::BadUrl;
443                        }
444                    }
445                }
446                Some('"') | Some('\'') | Some('(') => {
447                    self.pos += 1; // parse error
448                    self.consume_bad_url_remnants();
449                    return Token::BadUrl;
450                }
451                Some(c) if is_non_printable(c) => {
452                    self.pos += 1; // parse error
453                    self.consume_bad_url_remnants();
454                    return Token::BadUrl;
455                }
456                Some('\\') => {
457                    if self.is_valid_escape_at(0) {
458                        self.pos += 1;
459                        let ch = self.consume_escaped_code_point();
460                        value.push(ch);
461                    } else {
462                        self.pos += 1; // parse error
463                        self.consume_bad_url_remnants();
464                        return Token::BadUrl;
465                    }
466                }
467                Some(c) => {
468                    value.push(c);
469                    self.pos += 1;
470                }
471            }
472        }
473    }
474
475    /// Spec §4.3.4: "Consume an ident-like token".
476    fn consume_ident_like_token(&mut self) -> Token {
477        let s = self.consume_ident_sequence();
478        if s.eq_ignore_ascii_case("url") && self.peek() == Some('(') {
479            self.pos += 1;
480            while matches!(self.peek(), Some(a) if is_whitespace(a))
481                && matches!(self.peek_n(1), Some(b) if is_whitespace(b))
482            {
483                self.pos += 1;
484            }
485            let starts_quoted = matches!(self.peek(), Some('"') | Some('\''))
486                || (matches!(self.peek(), Some(a) if is_whitespace(a))
487                    && matches!(self.peek_n(1), Some('"') | Some('\'')));
488            if starts_quoted {
489                Token::Function(s)
490            } else {
491                self.consume_url_token()
492            }
493        } else if self.peek() == Some('(') {
494            self.pos += 1;
495            Token::Function(s)
496        } else {
497            Token::Ident(s)
498        }
499    }
500
501    /// Spec §4.3.5: "Consume a string token", with `ending` as the
502    /// ending code point.
503    fn consume_string_token(&mut self, ending: char) -> Token {
504        let mut value = String::new();
505        loop {
506            match self.peek() {
507                Some(c) if c == ending => {
508                    self.pos += 1;
509                    return Token::String(value);
510                }
511                None => return Token::String(value), // parse error: EOF
512                Some('\n') => return Token::BadString, // parse error; reconsume the newline
513                Some('\\') => {
514                    self.pos += 1;
515                    match self.peek() {
516                        None => {}
517                        Some('\n') => self.pos += 1,
518                        Some(_) => {
519                            let ch = self.consume_escaped_code_point();
520                            value.push(ch);
521                        }
522                    }
523                }
524                Some(c) => {
525                    value.push(c);
526                    self.pos += 1;
527                }
528            }
529        }
530    }
531
532    /// Spec §4.3.1: "Consume a token".
533    fn consume_token(&mut self) -> Token {
534        self.consume_comments();
535        match self.peek() {
536            None => Token::Eof,
537            Some(c) if is_whitespace(c) => {
538                self.skip_whitespace();
539                Token::Whitespace
540            }
541            Some('"') => {
542                self.pos += 1;
543                self.consume_string_token('"')
544            }
545            Some('#') => {
546                self.pos += 1;
547                if matches!(self.peek(), Some(c) if is_ident_code_point(c))
548                    || self.is_valid_escape_at(0)
549                {
550                    let type_flag = if self.starts_ident_sequence(0) {
551                        HashType::Id
552                    } else {
553                        HashType::Unrestricted
554                    };
555                    let value = self.consume_ident_sequence();
556                    Token::Hash { value, type_flag }
557                } else {
558                    Token::Delim('#')
559                }
560            }
561            Some('\'') => {
562                self.pos += 1;
563                self.consume_string_token('\'')
564            }
565            Some('(') => {
566                self.pos += 1;
567                Token::OpenParen
568            }
569            Some(')') => {
570                self.pos += 1;
571                Token::CloseParen
572            }
573            Some('+') => {
574                if self.starts_number(0) {
575                    self.consume_numeric_token()
576                } else {
577                    self.pos += 1;
578                    Token::Delim('+')
579                }
580            }
581            Some(',') => {
582                self.pos += 1;
583                Token::Comma
584            }
585            Some('-') => {
586                if self.starts_number(0) {
587                    self.consume_numeric_token()
588                } else if self.peek_n(1) == Some('-') && self.peek_n(2) == Some('>') {
589                    self.pos += 3;
590                    Token::Cdc
591                } else if self.starts_ident_sequence(0) {
592                    self.consume_ident_like_token()
593                } else {
594                    self.pos += 1;
595                    Token::Delim('-')
596                }
597            }
598            Some('.') => {
599                if self.starts_number(0) {
600                    self.consume_numeric_token()
601                } else {
602                    self.pos += 1;
603                    Token::Delim('.')
604                }
605            }
606            Some(':') => {
607                self.pos += 1;
608                Token::Colon
609            }
610            Some(';') => {
611                self.pos += 1;
612                Token::Semicolon
613            }
614            Some('<') => {
615                if self.peek_n(1) == Some('!')
616                    && self.peek_n(2) == Some('-')
617                    && self.peek_n(3) == Some('-')
618                {
619                    self.pos += 4;
620                    Token::Cdo
621                } else {
622                    self.pos += 1;
623                    Token::Delim('<')
624                }
625            }
626            Some('@') => {
627                self.pos += 1;
628                if self.starts_ident_sequence(0) {
629                    let value = self.consume_ident_sequence();
630                    Token::AtKeyword(value)
631                } else {
632                    Token::Delim('@')
633                }
634            }
635            Some('[') => {
636                self.pos += 1;
637                Token::OpenSquare
638            }
639            Some('\\') => {
640                if self.is_valid_escape_at(0) {
641                    self.consume_ident_like_token()
642                } else {
643                    self.pos += 1; // parse error
644                    Token::Delim('\\')
645                }
646            }
647            Some(']') => {
648                self.pos += 1;
649                Token::CloseSquare
650            }
651            Some('{') => {
652                self.pos += 1;
653                Token::OpenCurly
654            }
655            Some('}') => {
656                self.pos += 1;
657                Token::CloseCurly
658            }
659            Some(c) if c.is_ascii_digit() => self.consume_numeric_token(),
660            Some(c) if is_ident_start(c) => self.consume_ident_like_token(),
661            Some(c) => {
662                self.pos += 1;
663                Token::Delim(c)
664            }
665        }
666    }
667}
668
669impl Iterator for Tokenizer {
670    type Item = Token;
671
672    fn next(&mut self) -> Option<Token> {
673        if self.done {
674            return None;
675        }
676        let token = self.consume_token();
677        if token == Token::Eof {
678            self.done = true;
679        }
680        Some(token)
681    }
682}
683
684/// Tokenizes `input` into a `Vec<Token>`, ending with a single
685/// [`Token::Eof`]. Convenience wrapper around [`Tokenizer`] for
686/// callers that don't need streaming/lazy tokenization.
687pub fn tokenize(input: &str) -> Vec<Token> {
688    Tokenizer::new(input).collect()
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    fn tokens(input: &str) -> Vec<Token> {
696        tokenize(input)
697    }
698
699    #[test]
700    fn ident_token() {
701        assert_eq!(tokens("foo"), vec![Token::Ident("foo".into()), Token::Eof]);
702    }
703
704    #[test]
705    fn ident_token_with_escape() {
706        assert_eq!(
707            tokens(r"\41 bc"),
708            vec![Token::Ident("Abc".into()), Token::Eof]
709        );
710    }
711
712    #[test]
713    fn function_token() {
714        assert_eq!(
715            tokens("foo("),
716            vec![Token::Function("foo".into()), Token::Eof]
717        );
718    }
719
720    #[test]
721    fn at_keyword_token() {
722        assert_eq!(
723            tokens("@media"),
724            vec![Token::AtKeyword("media".into()), Token::Eof]
725        );
726    }
727
728    #[test]
729    fn hash_token_id() {
730        assert_eq!(
731            tokens("#foo"),
732            vec![
733                Token::Hash {
734                    value: "foo".into(),
735                    type_flag: HashType::Id
736                },
737                Token::Eof
738            ]
739        );
740    }
741
742    #[test]
743    fn hash_token_unrestricted() {
744        assert_eq!(
745            tokens("#1"),
746            vec![
747                Token::Hash {
748                    value: "1".into(),
749                    type_flag: HashType::Unrestricted
750                },
751                Token::Eof
752            ]
753        );
754    }
755
756    #[test]
757    fn string_token() {
758        assert_eq!(
759            tokens("\"hello\""),
760            vec![Token::String("hello".into()), Token::Eof]
761        );
762        assert_eq!(
763            tokens("'hello'"),
764            vec![Token::String("hello".into()), Token::Eof]
765        );
766    }
767
768    #[test]
769    fn bad_string_token_on_unterminated_newline() {
770        assert_eq!(
771            tokens("\"abc\ndef\""),
772            vec![
773                Token::BadString,
774                Token::Whitespace,
775                Token::Ident("def".into()),
776                Token::String("".into()),
777                Token::Eof,
778            ]
779        );
780    }
781
782    #[test]
783    fn url_token() {
784        assert_eq!(
785            tokens("url(foo.png)"),
786            vec![Token::Url("foo.png".into()), Token::Eof]
787        );
788    }
789
790    #[test]
791    fn url_token_quoted_is_a_function() {
792        assert_eq!(
793            tokens("url(\"foo.png\")"),
794            vec![
795                Token::Function("url".into()),
796                Token::String("foo.png".into()),
797                Token::CloseParen,
798                Token::Eof
799            ]
800        );
801    }
802
803    #[test]
804    fn bad_url_token_on_unescaped_space() {
805        assert_eq!(tokens("url(foo bar)"), vec![Token::BadUrl, Token::Eof]);
806    }
807
808    #[test]
809    fn delim_token() {
810        assert_eq!(tokens("^"), vec![Token::Delim('^'), Token::Eof]);
811    }
812
813    #[test]
814    fn number_token_integer() {
815        assert_eq!(
816            tokens("42"),
817            vec![
818                Token::Number {
819                    value: 42.0,
820                    type_flag: NumericType::Integer
821                },
822                Token::Eof
823            ]
824        );
825    }
826
827    #[test]
828    fn number_token_fractional() {
829        assert_eq!(
830            tokens("4.2"),
831            vec![
832                Token::Number {
833                    value: 4.2,
834                    type_flag: NumericType::Number
835                },
836                Token::Eof
837            ]
838        );
839    }
840
841    #[test]
842    fn percentage_token() {
843        assert_eq!(
844            tokens("50%"),
845            vec![Token::Percentage { value: 50.0 }, Token::Eof]
846        );
847    }
848
849    #[test]
850    fn dimension_token() {
851        assert_eq!(
852            tokens("10px"),
853            vec![
854                Token::Dimension {
855                    value: 10.0,
856                    type_flag: NumericType::Integer,
857                    unit: "px".into()
858                },
859                Token::Eof
860            ]
861        );
862    }
863
864    #[test]
865    fn whitespace_token() {
866        assert_eq!(tokens("  \t\n"), vec![Token::Whitespace, Token::Eof]);
867    }
868
869    #[test]
870    fn cdo_token() {
871        assert_eq!(tokens("<!--"), vec![Token::Cdo, Token::Eof]);
872    }
873
874    #[test]
875    fn cdc_token() {
876        assert_eq!(tokens("-->"), vec![Token::Cdc, Token::Eof]);
877    }
878
879    #[test]
880    fn colon_semicolon_comma_tokens() {
881        assert_eq!(
882            tokens(":;,"),
883            vec![Token::Colon, Token::Semicolon, Token::Comma, Token::Eof]
884        );
885    }
886
887    #[test]
888    fn bracket_tokens() {
889        assert_eq!(
890            tokens("[](){}"),
891            vec![
892                Token::OpenSquare,
893                Token::CloseSquare,
894                Token::OpenParen,
895                Token::CloseParen,
896                Token::OpenCurly,
897                Token::CloseCurly,
898                Token::Eof,
899            ]
900        );
901    }
902
903    #[test]
904    fn eof_token_on_empty_input() {
905        assert_eq!(tokens(""), vec![Token::Eof]);
906    }
907
908    #[test]
909    fn comments_are_transparent() {
910        assert_eq!(
911            tokens("/* comment */foo"),
912            vec![Token::Ident("foo".into()), Token::Eof]
913        );
914    }
915
916    #[test]
917    fn preprocessing_normalizes_newlines() {
918        assert_eq!(tokens("a\r\nb"), tokens("a\nb"));
919        assert_eq!(tokens("a\rb"), tokens("a\nb"));
920        assert_eq!(tokens("a\u{000C}b"), tokens("a\nb"));
921    }
922
923    #[test]
924    fn preprocessing_replaces_null_with_replacement_character() {
925        assert_eq!(
926            tokens("\u{0000}"),
927            vec![Token::Ident("\u{FFFD}".into()), Token::Eof]
928        );
929    }
930}