1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum NumericType {
15 Integer,
17 Number,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23#[non_exhaustive]
24pub enum HashType {
25 Id,
28 Unrestricted,
30}
31
32#[derive(Debug, Clone, PartialEq)]
35#[non_exhaustive]
36pub enum Token {
37 Ident(String),
39 Function(String),
42 AtKeyword(String),
44 Hash {
46 value: String,
48 type_flag: HashType,
50 },
51 String(String),
53 BadString,
56 Url(String),
58 BadUrl,
61 Delim(char),
63 Number {
65 value: f64,
67 type_flag: NumericType,
69 },
70 Percentage {
72 value: f64,
74 },
75 Dimension {
77 value: f64,
79 type_flag: NumericType,
81 unit: String,
83 },
84 Whitespace,
86 Cdo,
88 Cdc,
90 Colon,
92 Semicolon,
94 Comma,
96 OpenSquare,
98 CloseSquare,
100 OpenParen,
102 CloseParen,
104 OpenCurly,
106 CloseCurly,
108 Eof,
111}
112
113fn 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
142fn is_letter(c: char) -> bool {
144 c.is_ascii_alphabetic()
145}
146
147fn is_non_ascii(c: char) -> bool {
149 !c.is_ascii()
150}
151
152fn is_ident_start(c: char) -> bool {
154 is_letter(c) || is_non_ascii(c) || c == '_'
155}
156
157fn is_ident_code_point(c: char) -> bool {
159 is_ident_start(c) || c.is_ascii_digit() || c == '-'
160}
161
162fn is_non_printable(c: char) -> bool {
164 matches!(c, '\u{0000}'..='\u{0008}' | '\u{000B}' | '\u{000E}'..='\u{001F}' | '\u{007F}')
165}
166
167fn 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
177pub struct Tokenizer {
185 input: Vec<char>,
186 pos: usize,
187 done: bool,
188}
189
190impl Tokenizer {
191 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 fn skip_whitespace(&mut self) {
213 while matches!(self.peek(), Some(w) if is_whitespace(w)) {
214 self.pos += 1;
215 }
216 }
217
218 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 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 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 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, Some('*') if self.peek_n(1) == Some('/') => {
264 self.pos += 2;
265 break;
266 }
267 _ => self.pos += 1,
268 }
269 }
270 }
271 }
272
273 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}', }
306 }
307
308 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 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 let value = repr
378 .parse::<f64>()
379 .expect("consume_number produced a valid number literal");
380 (value, type_flag)
381 }
382
383 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 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 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), 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), _ => {
441 self.consume_bad_url_remnants();
442 return Token::BadUrl;
443 }
444 }
445 }
446 Some('"') | Some('\'') | Some('(') => {
447 self.pos += 1; self.consume_bad_url_remnants();
449 return Token::BadUrl;
450 }
451 Some(c) if is_non_printable(c) => {
452 self.pos += 1; 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; 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 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 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), Some('\n') => return Token::BadString, 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 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; 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
684pub 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}