Skip to main content

omena_parser/
lex.rs

1//! Public lexer result types and token wrappers.
2//!
3//! Lexing remains dialect-aware, but this module exposes a stable token surface
4//! for summaries and parser fact collection.
5
6use cstree::text::{TextRange, TextSize};
7use omena_syntax::{StyleDialect, SyntaxKind};
8
9use crate::{
10    DialectExtension, ParseError, ParseErrorCode, TemplatePlaceholderMode,
11    matches_ignore_ascii_case,
12};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub(crate) struct Token<'text> {
16    pub(crate) kind: SyntaxKind,
17    pub(crate) text: &'text str,
18    pub(crate) range: TextRange,
19}
20
21pub(crate) struct Tokenizer<'text, 'extension, E> {
22    pub(crate) text: &'text str,
23    pub(crate) extension: &'extension E,
24    pub(crate) offset: usize,
25    pub(crate) template_placeholder: Option<TemplatePlaceholderMode>,
26    pub(crate) template_interpolation_depth: usize,
27    pub(crate) scss_interpolation_depth: usize,
28    pub(crate) less_interpolation_depth: usize,
29    pub(crate) sass_indent_stack: Vec<usize>,
30    pub(crate) tokens: Vec<Token<'text>>,
31    pub(crate) errors: Vec<ParseError>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct LexResult {
36    tokens: Vec<LexedToken>,
37    errors: Vec<ParseError>,
38    dialect: StyleDialect,
39}
40
41impl LexResult {
42    pub(crate) fn new(
43        tokens: Vec<LexedToken>,
44        errors: Vec<ParseError>,
45        dialect: StyleDialect,
46    ) -> Self {
47        Self {
48            tokens,
49            errors,
50            dialect,
51        }
52    }
53
54    pub fn tokens(&self) -> &[LexedToken] {
55        &self.tokens
56    }
57
58    pub fn errors(&self) -> &[ParseError] {
59        &self.errors
60    }
61
62    pub fn dialect(&self) -> StyleDialect {
63        self.dialect
64    }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct LexedToken {
69    pub kind: SyntaxKind,
70    pub range: TextRange,
71    pub text: String,
72}
73
74pub(crate) fn public_token_text(text: &str) -> String {
75    text.chars()
76        .map(css_syntax_preprocessed_char)
77        .collect::<String>()
78}
79
80pub(crate) fn is_name_start(char: char) -> bool {
81    let char = css_syntax_preprocessed_char(char);
82    char == '_' || char == '-' || char.is_alphabetic() || !char.is_ascii()
83}
84
85pub(crate) fn is_name_continue(char: char) -> bool {
86    is_name_start(char) || char.is_ascii_digit()
87}
88
89pub(crate) fn is_non_printable_code_point(char: char) -> bool {
90    let char = css_syntax_preprocessed_char(char);
91    matches!(char, '\u{0000}'..='\u{0008}' | '\u{000b}' | '\u{000e}'..='\u{001f}' | '\u{007f}')
92}
93
94pub(crate) fn is_custom_property_name_text(text: &str) -> bool {
95    let Some(rest) = text.strip_prefix("--") else {
96        return false;
97    };
98    let Some(first) = rest.chars().next() else {
99        return false;
100    };
101    first == '-' || is_name_start(first) || starts_valid_escape_text(rest)
102}
103
104pub(crate) fn is_css_at_rule_name(text: &str) -> bool {
105    matches_ignore_ascii_case(
106        text,
107        &[
108            "@charset",
109            "@container",
110            "@font-face",
111            "@font-feature-values",
112            "@function",
113            "@font-palette-values",
114            "@import",
115            "@keyframes",
116            "@layer",
117            "@media",
118            "@namespace",
119            "@page",
120            "@property",
121            "@scope",
122            "@starting-style",
123            "@supports",
124            "@counter-style",
125            "@custom-media",
126            "@color-profile",
127            "@nest",
128            "@position-try",
129            "@view-transition",
130            "@stylistic",
131            "@styleset",
132            "@character-variant",
133            "@swash",
134            "@ornaments",
135            "@annotation",
136            "@historical-forms",
137            "@when",
138            "@else",
139        ],
140    )
141}
142
143pub(crate) fn sass_token_can_end_statement(kind: SyntaxKind) -> bool {
144    !matches!(
145        kind,
146        SyntaxKind::Whitespace
147            | SyntaxKind::LineComment
148            | SyntaxKind::BlockComment
149            | SyntaxKind::SassIndentedNewline
150            | SyntaxKind::SassIndent
151            | SyntaxKind::SassDedent
152            | SyntaxKind::SassOptionalSemicolon
153            | SyntaxKind::Comma
154            | SyntaxKind::Colon
155            | SyntaxKind::DoubleColon
156            | SyntaxKind::LeftBrace
157            | SyntaxKind::LeftParen
158            | SyntaxKind::LeftBracket
159            | SyntaxKind::Plus
160            | SyntaxKind::Minus
161            | SyntaxKind::Star
162            | SyntaxKind::Slash
163            | SyntaxKind::GreaterThan
164            | SyntaxKind::LessThan
165            | SyntaxKind::Equals
166            | SyntaxKind::Arrow
167            | SyntaxKind::Pipe
168            | SyntaxKind::Tilde
169            | SyntaxKind::Caret
170            | SyntaxKind::Ampersand
171            | SyntaxKind::DoubleAmpersand
172            | SyntaxKind::ColumnCombinator
173            | SyntaxKind::IncludesMatch
174            | SyntaxKind::DashMatch
175            | SyntaxKind::PrefixMatch
176            | SyntaxKind::SuffixMatch
177            | SyntaxKind::SubstringMatch
178            | SyntaxKind::PlusEquals
179            | SyntaxKind::MinusEquals
180            | SyntaxKind::SlashEquals
181    )
182}
183
184pub(crate) fn text_range(start: usize, end: usize) -> TextRange {
185    TextRange::new(TextSize::from(start as u32), TextSize::from(end as u32))
186}
187
188impl<'text, 'extension, E> Tokenizer<'text, 'extension, E>
189where
190    E: DialectExtension,
191{
192    pub(crate) fn new(text: &'text str, extension: &'extension E) -> Self {
193        Self {
194            text,
195            extension,
196            offset: 0,
197            template_placeholder: extension.template_placeholder(),
198            template_interpolation_depth: 0,
199            scss_interpolation_depth: 0,
200            less_interpolation_depth: 0,
201            sass_indent_stack: vec![0],
202            tokens: Vec::new(),
203            errors: Vec::new(),
204        }
205    }
206
207    pub(crate) fn tokenize(&mut self) {
208        while let Some(current) = self.current_char() {
209            let start = self.offset;
210            match current {
211                '\u{feff}' if start == 0 => self.bump_current(),
212                '\r' | '\n' if self.extension.dialect() == StyleDialect::Sass => {
213                    self.consume_sass_indented_newline(start)
214                }
215                char if char.is_whitespace() => {
216                    self.consume_while(SyntaxKind::Whitespace, |c| c.is_whitespace())
217                }
218                '/' if self.starts_with("/*") => self.consume_block_comment(),
219                '/' if self.starts_with("//") && self.extension.dialect() != StyleDialect::Css => {
220                    self.consume_line_comment()
221                }
222                '$' if self.starts_with("${")
223                    && self.template_placeholder == Some(TemplatePlaceholderMode::Brace) =>
224                {
225                    self.consume_template_interpolation_start(start)
226                }
227                '$' if self.starts_with("${")
228                    && self.template_placeholder
229                        == Some(TemplatePlaceholderMode::AtomicIndexed) =>
230                {
231                    self.consume_template_placeholder(start)
232                }
233                '#' if self.starts_with("#{") && self.supports_scss_interpolation() => {
234                    self.consume_scss_interpolation_start(start)
235                }
236                '@' if self.starts_with("@{") && self.supports_less_interpolation() => {
237                    self.consume_less_interpolation_start(start)
238                }
239                '!' if self.starts_with_ascii_keyword("!important") => {
240                    self.consume_static(SyntaxKind::Important, start, "!important".len())
241                }
242                '<' if self.starts_with("<!--") => {
243                    self.consume_static(SyntaxKind::Cdo, start, "<!--".len())
244                }
245                '-' if self.starts_with("-->") => {
246                    self.consume_static(SyntaxKind::Cdc, start, "-->".len())
247                }
248                '"' | '\'' => self.consume_string(current),
249                'u' | 'U' if self.starts_unicode_range() => self.consume_unicode_range(),
250                '0'..='9' => self.consume_number(),
251                '$' if matches!(
252                    self.extension.dialect(),
253                    StyleDialect::Scss | StyleDialect::Sass
254                ) =>
255                {
256                    self.consume_prefixed_name(SyntaxKind::ScssVariable)
257                }
258                '@' if self.extension.dialect() == StyleDialect::Less => {
259                    self.consume_less_at_name()
260                }
261                '@' => self.consume_at_keyword(),
262                '!' => self.consume_static(SyntaxKind::Delim, start, 1),
263                '.' if self.current_starts_number() => self.consume_number(),
264                '.' => self.consume_static(SyntaxKind::Dot, start, 1),
265                ',' => self.consume_static(SyntaxKind::Comma, start, 1),
266                ':' if self.starts_with("::") => {
267                    self.consume_static(SyntaxKind::DoubleColon, start, 2)
268                }
269                ':' => self.consume_static(SyntaxKind::Colon, start, 1),
270                ';' => self.consume_static(SyntaxKind::Semicolon, start, 1),
271                '{' => self.consume_static(SyntaxKind::LeftBrace, start, 1),
272                '}' if self.template_interpolation_depth > 0 => {
273                    self.consume_template_interpolation_end(start)
274                }
275                '}' if self.scss_interpolation_depth > 0 => {
276                    self.consume_scss_interpolation_end(start)
277                }
278                '}' if self.less_interpolation_depth > 0 => {
279                    self.consume_less_interpolation_end(start)
280                }
281                '}' => self.consume_static(SyntaxKind::RightBrace, start, 1),
282                '(' => self.consume_static(SyntaxKind::LeftParen, start, 1),
283                ')' => self.consume_static(SyntaxKind::RightParen, start, 1),
284                '[' => self.consume_static(SyntaxKind::LeftBracket, start, 1),
285                ']' => self.consume_static(SyntaxKind::RightBracket, start, 1),
286                '+' if self.starts_with("+=") => {
287                    self.consume_static(SyntaxKind::PlusEquals, start, 2)
288                }
289                '+' if self.current_starts_number() => self.consume_number(),
290                '+' => self.consume_static(SyntaxKind::Plus, start, 1),
291                '-' if self.starts_with("-=") => {
292                    self.consume_static(SyntaxKind::MinusEquals, start, 2)
293                }
294                '-' if self.current_starts_number() => self.consume_number(),
295                '-' if self.current_starts_ident_sequence() => self.consume_ident_like(),
296                '-' => self.consume_static(SyntaxKind::Minus, start, 1),
297                '*' if self.starts_with("*=") => {
298                    self.consume_static(SyntaxKind::SubstringMatch, start, 2)
299                }
300                '*' => self.consume_static(SyntaxKind::Star, start, 1),
301                '/' if self.starts_with("/=") => {
302                    self.consume_static(SyntaxKind::SlashEquals, start, 2)
303                }
304                '/' => self.consume_static(SyntaxKind::Slash, start, 1),
305                '%' if self.starts_scss_placeholder() => {
306                    self.consume_prefixed_name(SyntaxKind::ScssPlaceholder)
307                }
308                '%' => self.consume_static(SyntaxKind::Percent, start, 1),
309                '=' if self.starts_with("=>") => self.consume_static(SyntaxKind::Arrow, start, 2),
310                '=' => self.consume_static(SyntaxKind::Equals, start, 1),
311                '~' if self.starts_less_escaped_string() => self.consume_less_escaped_string(start),
312                '~' if self.starts_with("~=") => {
313                    self.consume_static(SyntaxKind::IncludesMatch, start, 2)
314                }
315                '~' => self.consume_static(SyntaxKind::Tilde, start, 1),
316                '|' if self.starts_with("|=") => {
317                    self.consume_static(SyntaxKind::DashMatch, start, 2)
318                }
319                '|' if self.starts_with("||") => {
320                    self.consume_static(SyntaxKind::ColumnCombinator, start, 2)
321                }
322                '|' => self.consume_static(SyntaxKind::Pipe, start, 1),
323                '^' if self.starts_with("^=") => {
324                    self.consume_static(SyntaxKind::PrefixMatch, start, 2)
325                }
326                '^' => self.consume_static(SyntaxKind::Caret, start, 1),
327                '$' if self.starts_with("$=") => {
328                    self.consume_static(SyntaxKind::SuffixMatch, start, 2)
329                }
330                '$' if self.starts_less_property_variable() => {
331                    self.consume_prefixed_name(SyntaxKind::LessPropertyVariableToken)
332                }
333                '&' if self.starts_with("&&") => {
334                    self.consume_static(SyntaxKind::DoubleAmpersand, start, 2)
335                }
336                '&' => self.consume_static(SyntaxKind::Ampersand, start, 1),
337                '>' => self.consume_static(SyntaxKind::GreaterThan, start, 1),
338                '<' => self.consume_static(SyntaxKind::LessThan, start, 1),
339                '#' if self.current_hash_starts_name() => self.consume_name_like(SyntaxKind::Hash),
340                '#' => self.consume_static(SyntaxKind::Delim, start, 1),
341                '\\' if self.current_starts_valid_escape() => {
342                    self.consume_name_like(SyntaxKind::Ident)
343                }
344                char if is_name_start(char) => self.consume_ident_like(),
345                char => self.consume_unexpected(char),
346            }
347        }
348        self.consume_pending_sass_dedents();
349    }
350}
351
352impl<'text, 'extension, E> Tokenizer<'text, 'extension, E>
353where
354    E: DialectExtension,
355{
356    fn consume_static(&mut self, kind: SyntaxKind, start: usize, byte_len: usize) {
357        self.offset += byte_len;
358        self.push(kind, start, self.offset);
359    }
360
361    fn consume_while(&mut self, kind: SyntaxKind, predicate: impl Fn(char) -> bool) {
362        let start = self.offset;
363        while let Some(char) = self.current_char() {
364            if !predicate(char) {
365                break;
366            }
367            self.bump_char(char);
368        }
369        self.push(kind, start, self.offset);
370    }
371
372    fn consume_block_comment(&mut self) {
373        let start = self.offset;
374        self.offset += 2;
375        while self.offset < self.text.len() {
376            if self.starts_with("*/") {
377                self.offset += 2;
378                self.push(SyntaxKind::BlockComment, start, self.offset);
379                return;
380            }
381            match self.current_char() {
382                Some(char) => self.bump_char(char),
383                None => break,
384            }
385        }
386        self.push(SyntaxKind::BlockComment, start, self.offset);
387        self.error(
388            ParseErrorCode::UnterminatedBlockComment,
389            start,
390            self.offset,
391            "unterminated block comment",
392        );
393    }
394
395    fn consume_line_comment(&mut self) {
396        let start = self.offset;
397        while let Some(char) = self.current_char() {
398            if char == '\n' {
399                break;
400            }
401            if char == '\r' {
402                break;
403            }
404            self.bump_char(char);
405        }
406        self.push(SyntaxKind::LineComment, start, self.offset);
407    }
408
409    fn consume_sass_indented_newline(&mut self, start: usize) {
410        self.consume_line_break();
411        let indent = self.consume_sass_line_indent();
412        let line_start = self.offset;
413        let current_indent = self.sass_indent_stack.last().copied().unwrap_or(0);
414
415        if indent > current_indent {
416            self.push(SyntaxKind::SassIndentedNewline, start, line_start);
417            self.sass_indent_stack.push(indent);
418            self.push(SyntaxKind::SassIndent, line_start, line_start);
419            return;
420        }
421
422        if self.previous_significant_sass_token_can_end_statement() {
423            self.push(SyntaxKind::SassOptionalSemicolon, start, start);
424        }
425        self.push(SyntaxKind::SassIndentedNewline, start, line_start);
426
427        while self.sass_indent_stack.len() > 1
428            && self
429                .sass_indent_stack
430                .last()
431                .is_some_and(|current| indent < *current)
432        {
433            self.sass_indent_stack.pop();
434            self.push(SyntaxKind::SassDedent, line_start, line_start);
435        }
436
437        if self
438            .sass_indent_stack
439            .last()
440            .is_some_and(|current| indent != *current)
441        {
442            self.error(
443                ParseErrorCode::UnexpectedCharacter,
444                line_start,
445                line_start,
446                "inconsistent Sass indentation",
447            );
448        }
449    }
450
451    fn consume_line_break(&mut self) {
452        if self.starts_with("\r\n") {
453            self.offset += "\r\n".len();
454            return;
455        }
456        if let Some(char @ ('\r' | '\n')) = self.current_char() {
457            self.bump_char(char);
458        }
459    }
460
461    fn consume_sass_line_indent(&mut self) -> usize {
462        let mut indent = 0usize;
463        while let Some(char) = self.current_char() {
464            match char {
465                ' ' => {
466                    indent += 1;
467                    self.bump_char(char);
468                }
469                '\t' => {
470                    indent += 4;
471                    self.bump_char(char);
472                }
473                _ => break,
474            }
475        }
476        indent
477    }
478
479    fn consume_pending_sass_dedents(&mut self) {
480        if self.extension.dialect() != StyleDialect::Sass {
481            return;
482        }
483        while self.sass_indent_stack.len() > 1 {
484            self.sass_indent_stack.pop();
485            self.push(SyntaxKind::SassDedent, self.offset, self.offset);
486        }
487    }
488
489    fn previous_significant_sass_token_can_end_statement(&self) -> bool {
490        self.tokens
491            .iter()
492            .rev()
493            .find(|token| !token.kind.is_trivia())
494            .is_some_and(|token| sass_token_can_end_statement(token.kind))
495    }
496
497    fn consume_scss_interpolation_start(&mut self, start: usize) {
498        self.offset += "#{".len();
499        self.scss_interpolation_depth += 1;
500        self.push(SyntaxKind::ScssInterpolationStart, start, self.offset);
501    }
502
503    fn consume_template_interpolation_start(&mut self, start: usize) {
504        self.offset += "${".len();
505        self.template_interpolation_depth += 1;
506        self.push(SyntaxKind::TemplateInterpolationStart, start, self.offset);
507    }
508
509    fn consume_scss_interpolation_end(&mut self, start: usize) {
510        self.offset += '}'.len_utf8();
511        self.scss_interpolation_depth = self.scss_interpolation_depth.saturating_sub(1);
512        self.push(SyntaxKind::ScssInterpolationEnd, start, self.offset);
513    }
514
515    fn consume_template_interpolation_end(&mut self, start: usize) {
516        self.offset += '}'.len_utf8();
517        self.template_interpolation_depth = self.template_interpolation_depth.saturating_sub(1);
518        self.push(SyntaxKind::TemplateInterpolationEnd, start, self.offset);
519    }
520
521    fn consume_less_interpolation_start(&mut self, start: usize) {
522        self.offset += "@{".len();
523        self.less_interpolation_depth += 1;
524        self.push(SyntaxKind::LessInterpolationStart, start, self.offset);
525    }
526
527    fn consume_less_interpolation_end(&mut self, start: usize) {
528        self.offset += '}'.len_utf8();
529        self.less_interpolation_depth = self.less_interpolation_depth.saturating_sub(1);
530        self.push(SyntaxKind::LessInterpolationEnd, start, self.offset);
531    }
532
533    fn consume_template_placeholder(&mut self, start: usize) {
534        self.offset += "${".len();
535        while let Some(char) = self.current_char() {
536            self.bump_char(char);
537            if char == '}' {
538                break;
539            }
540        }
541        self.push(SyntaxKind::TemplatePlaceholder, start, self.offset);
542    }
543
544    fn consume_string(&mut self, quote: char) {
545        let start = self.offset;
546        self.bump_char(quote);
547        while let Some(char) = self.current_char() {
548            self.bump_char(char);
549            if matches!(char, '\n' | '\r' | '\u{000c}') {
550                self.push(SyntaxKind::BadString, start, self.offset);
551                self.error(
552                    ParseErrorCode::UnterminatedString,
553                    start,
554                    self.offset,
555                    "unterminated string",
556                );
557                return;
558            }
559            if char == quote {
560                self.push(SyntaxKind::String, start, self.offset);
561                return;
562            }
563            if char == '\\'
564                && let Some(escaped) = self.current_char()
565            {
566                self.bump_char(escaped);
567            }
568        }
569        self.push(SyntaxKind::BadString, start, self.offset);
570        self.error(
571            ParseErrorCode::UnterminatedString,
572            start,
573            self.offset,
574            "unterminated string",
575        );
576    }
577
578    fn consume_less_escaped_string(&mut self, start: usize) {
579        self.offset += '~'.len_utf8();
580        let Some(quote @ ('"' | '\'')) = self.current_char() else {
581            self.push(SyntaxKind::Tilde, start, self.offset);
582            return;
583        };
584        self.bump_char(quote);
585        while let Some(char) = self.current_char() {
586            self.bump_char(char);
587            if matches!(char, '\n' | '\r' | '\u{000c}') {
588                self.push(SyntaxKind::BadString, start, self.offset);
589                self.error(
590                    ParseErrorCode::UnterminatedString,
591                    start,
592                    self.offset,
593                    "unterminated Less escaped string",
594                );
595                return;
596            }
597            if char == quote {
598                self.push(SyntaxKind::LessEscapedString, start, self.offset);
599                return;
600            }
601            if char == '\\'
602                && let Some(escaped) = self.current_char()
603            {
604                self.bump_char(escaped);
605            }
606        }
607        self.push(SyntaxKind::BadString, start, self.offset);
608        self.error(
609            ParseErrorCode::UnterminatedString,
610            start,
611            self.offset,
612            "unterminated Less escaped string",
613        );
614    }
615
616    fn consume_number(&mut self) {
617        let start = self.offset;
618        if matches!(self.current_char(), Some('+' | '-')) {
619            self.bump_current();
620        }
621        self.consume_digits();
622        if self.current_char() == Some('.') && self.char_after_current_is_ascii_digit() {
623            self.bump_current();
624            self.consume_digits();
625        }
626        if self.current_starts_number_exponent() {
627            self.bump_current();
628            if matches!(self.current_char(), Some('+' | '-')) {
629                self.bump_current();
630            }
631            self.consume_digits();
632        }
633        if self.current_char() == Some('%') {
634            self.offset += 1;
635            self.push(SyntaxKind::Percentage, start, self.offset);
636            return;
637        }
638        if self.current_starts_ident_sequence() {
639            self.consume_name_continue_sequence();
640            self.push(SyntaxKind::Dimension, start, self.offset);
641            return;
642        }
643        self.push(SyntaxKind::Number, start, self.offset);
644    }
645
646    fn consume_unicode_range(&mut self) {
647        let start = self.offset;
648        self.bump_current();
649        self.offset += '+'.len_utf8();
650        self.consume_unicode_range_codepoints(true);
651        if self.current_char() == Some('-') && self.next_char_is_hex_digit() {
652            self.bump_current();
653            self.consume_unicode_range_codepoints(false);
654        }
655        self.push(SyntaxKind::UnicodeRange, start, self.offset);
656    }
657
658    fn consume_unicode_range_codepoints(&mut self, allow_question_mark: bool) {
659        let mut consumed = 0usize;
660        while consumed < 6 {
661            match self.current_char() {
662                Some(char) if char.is_ascii_hexdigit() => {
663                    self.bump_char(char);
664                    consumed += 1;
665                }
666                Some('?') if allow_question_mark => {
667                    self.bump_current();
668                    consumed += 1;
669                }
670                _ => break,
671            }
672        }
673    }
674
675    fn consume_digits(&mut self) {
676        while matches!(self.current_char(), Some('0'..='9')) {
677            self.offset += 1;
678        }
679    }
680
681    fn consume_prefixed_name(&mut self, preferred_kind: SyntaxKind) {
682        let start = self.offset;
683        self.bump_current();
684        while matches!(self.current_char(), Some(char) if is_name_continue(char)) {
685            self.bump_current();
686        }
687        let text = &self.text[start..self.offset];
688        let kind = self
689            .extension
690            .classify_variable_token(text)
691            .unwrap_or(preferred_kind);
692        self.push(kind, start, self.offset);
693    }
694
695    fn consume_less_at_name(&mut self) {
696        let start = self.offset;
697        self.bump_current();
698        while matches!(self.current_char(), Some(char) if is_name_continue(char)) {
699            self.bump_current();
700        }
701        let text = &self.text[start..self.offset];
702        let kind = if is_css_at_rule_name(text) {
703            SyntaxKind::AtKeyword
704        } else {
705            self.extension
706                .classify_variable_token(text)
707                .unwrap_or(SyntaxKind::LessVariable)
708        };
709        self.push(kind, start, self.offset);
710    }
711
712    fn consume_at_keyword(&mut self) {
713        let start = self.offset;
714        self.bump_current();
715        while matches!(self.current_char(), Some(char) if is_name_continue(char)) {
716            self.bump_current();
717        }
718        self.push(SyntaxKind::AtKeyword, start, self.offset);
719    }
720
721    fn consume_name_like(&mut self, kind: SyntaxKind) {
722        let start = self.offset;
723        self.consume_name_start();
724        self.consume_name_continue_sequence();
725        self.push(kind, start, self.offset);
726    }
727
728    fn consume_ident_like(&mut self) {
729        let start = self.offset;
730        self.consume_name_continue_sequence();
731        let ident = &self.text[start..self.offset];
732        if matches_ignore_ascii_case(ident, &["url"])
733            && self.current_char() == Some('(')
734            && !self.url_starts_with_quoted_argument()
735        {
736            self.consume_url_token(start);
737            return;
738        }
739        let kind = if is_custom_property_name_text(ident) {
740            SyntaxKind::CustomPropertyName
741        } else {
742            SyntaxKind::Ident
743        };
744        self.push(kind, start, self.offset);
745    }
746
747    fn consume_name_start(&mut self) {
748        if self.current_starts_valid_escape() {
749            self.consume_name_escape();
750        } else {
751            self.bump_current();
752        }
753    }
754
755    fn consume_name_continue_sequence(&mut self) {
756        loop {
757            if self.current_starts_valid_escape() {
758                self.consume_name_escape();
759            } else if matches!(self.current_char(), Some(char) if is_name_continue(char)) {
760                self.bump_current();
761            } else {
762                break;
763            }
764        }
765    }
766
767    fn consume_name_escape(&mut self) {
768        self.bump_current();
769        let mut hex_digits = 0usize;
770        while hex_digits < 6
771            && matches!(self.current_char(), Some(char) if char.is_ascii_hexdigit())
772        {
773            self.bump_current();
774            hex_digits += 1;
775        }
776        if hex_digits > 0 {
777            if matches!(self.current_char(), Some(char) if char.is_whitespace()) {
778                self.bump_current();
779            }
780        } else if self.current_char().is_some() {
781            self.bump_current();
782        }
783    }
784
785    fn consume_url_token(&mut self, start: usize) {
786        self.bump_current();
787        while matches!(self.current_char(), Some(char) if char.is_whitespace()) {
788            self.bump_current();
789        }
790        while let Some(char) = self.current_char() {
791            match char {
792                ')' => {
793                    self.bump_current();
794                    self.push(SyntaxKind::Url, start, self.offset);
795                    return;
796                }
797                char if char.is_whitespace() => {
798                    self.bump_current();
799                    while matches!(self.current_char(), Some(char) if char.is_whitespace()) {
800                        self.bump_current();
801                    }
802                    if self.current_char() == Some(')') {
803                        self.bump_current();
804                        self.push(SyntaxKind::Url, start, self.offset);
805                        return;
806                    }
807                    self.consume_bad_url(start);
808                    return;
809                }
810                '"' | '\'' | '(' => {
811                    self.consume_bad_url(start);
812                    return;
813                }
814                '\\' if self.current_starts_valid_escape() => {
815                    self.consume_name_escape();
816                }
817                '\\' => {
818                    self.consume_bad_url(start);
819                    return;
820                }
821                char if is_non_printable_code_point(char) => {
822                    self.consume_bad_url(start);
823                    return;
824                }
825                _ => self.bump_current(),
826            }
827        }
828        self.push(SyntaxKind::BadUrl, start, self.offset);
829        self.error(
830            ParseErrorCode::UnexpectedCharacter,
831            start,
832            self.offset,
833            "unterminated url token",
834        );
835    }
836
837    fn consume_bad_url(&mut self, start: usize) {
838        while let Some(char) = self.current_char() {
839            if char == ')' {
840                self.bump_current();
841                break;
842            }
843            if self.current_starts_valid_escape() {
844                self.consume_name_escape();
845            } else {
846                self.bump_current();
847            }
848        }
849        self.push(SyntaxKind::BadUrl, start, self.offset);
850        self.error(
851            ParseErrorCode::UnexpectedCharacter,
852            start,
853            self.offset,
854            "bad url token",
855        );
856    }
857
858    fn url_starts_with_quoted_argument(&self) -> bool {
859        let Some(mut rest) = self.text.get(self.offset + '('.len_utf8()..) else {
860            return false;
861        };
862        rest = rest.trim_start_matches(char::is_whitespace);
863        matches!(rest.chars().next(), Some('"' | '\''))
864    }
865
866    fn starts_less_property_variable(&self) -> bool {
867        self.extension.dialect() == StyleDialect::Less
868            && self.text[self.offset + '$'.len_utf8()..]
869                .chars()
870                .next()
871                .is_some_and(is_name_start)
872    }
873
874    fn starts_scss_placeholder(&self) -> bool {
875        matches!(
876            self.extension.dialect(),
877            StyleDialect::Scss | StyleDialect::Sass
878        ) && self.text[self.offset + '%'.len_utf8()..]
879            .chars()
880            .next()
881            .is_some_and(is_name_start)
882    }
883
884    fn current_hash_starts_name(&self) -> bool {
885        if self.current_char() != Some('#') {
886            return false;
887        }
888        let next_offset = self.offset + '#'.len_utf8();
889        self.text[next_offset..]
890            .chars()
891            .next()
892            .is_some_and(is_name_continue)
893            || self.escape_starts_at(next_offset)
894    }
895
896    fn consume_unexpected(&mut self, char: char) {
897        let start = self.offset;
898        self.bump_char(char);
899        self.push(SyntaxKind::Delim, start, self.offset);
900        self.error(
901            ParseErrorCode::UnexpectedCharacter,
902            start,
903            self.offset,
904            "unexpected character",
905        );
906    }
907
908    fn push(&mut self, kind: SyntaxKind, start: usize, end: usize) {
909        self.tokens.push(Token {
910            kind,
911            text: &self.text[start..end],
912            range: text_range(start, end),
913        });
914    }
915
916    fn error(&mut self, code: ParseErrorCode, start: usize, end: usize, message: &'static str) {
917        self.errors.push(ParseError {
918            code,
919            range: text_range(start, end),
920            message,
921        });
922    }
923}
924
925impl<'text, 'extension, E> Tokenizer<'text, 'extension, E>
926where
927    E: DialectExtension,
928{
929    pub(crate) fn starts_with(&self, pattern: &str) -> bool {
930        self.text[self.offset..].starts_with(pattern)
931    }
932
933    pub(crate) fn current_starts_valid_escape(&self) -> bool {
934        self.escape_starts_at(self.offset)
935    }
936
937    pub(crate) fn current_starts_number(&self) -> bool {
938        self.starts_number_at(self.offset)
939    }
940
941    pub(crate) fn current_starts_number_exponent(&self) -> bool {
942        let Some('e' | 'E') = self.current_char() else {
943            return false;
944        };
945        let exponent_offset = self.offset + 'e'.len_utf8();
946        self.char_at(exponent_offset)
947            .is_some_and(|char| char.is_ascii_digit())
948            || (matches!(self.char_at(exponent_offset), Some('+' | '-'))
949                && self.char_after_offset_is_ascii_digit(exponent_offset))
950    }
951
952    pub(crate) fn starts_number_at(&self, offset: usize) -> bool {
953        let Some(first) = self.char_at(offset) else {
954            return false;
955        };
956        let second_offset = offset + first.len_utf8();
957        match first {
958            '+' | '-' => {
959                self.char_at(second_offset)
960                    .is_some_and(|char| char.is_ascii_digit())
961                    || (self.char_at(second_offset) == Some('.')
962                        && self.char_after_offset_is_ascii_digit(second_offset))
963            }
964            '.' => self.char_after_offset_is_ascii_digit(offset),
965            char => char.is_ascii_digit(),
966        }
967    }
968
969    pub(crate) fn current_starts_ident_sequence(&self) -> bool {
970        self.starts_ident_sequence_at(self.offset)
971    }
972
973    pub(crate) fn starts_ident_sequence_at(&self, offset: usize) -> bool {
974        let Some(first) = self.char_at(offset) else {
975            return false;
976        };
977        let second_offset = offset + first.len_utf8();
978        match first {
979            '-' => {
980                self.char_at(second_offset)
981                    .is_some_and(|char| char == '-' || is_name_start(char))
982                    || self.escape_starts_at(second_offset)
983            }
984            '\\' => self.escape_starts_at(offset),
985            char => is_name_start(char),
986        }
987    }
988
989    pub(crate) fn escape_starts_at(&self, offset: usize) -> bool {
990        if !self
991            .text
992            .get(offset..)
993            .is_some_and(|remaining| remaining.starts_with('\\'))
994        {
995            return false;
996        }
997        self.text[offset + '\\'.len_utf8()..]
998            .chars()
999            .next()
1000            .is_some_and(|char| !matches!(char, '\n' | '\r' | '\u{000c}'))
1001    }
1002
1003    pub(crate) fn char_at(&self, offset: usize) -> Option<char> {
1004        self.text.get(offset..)?.chars().next()
1005    }
1006
1007    pub(crate) fn char_after_current_is_ascii_digit(&self) -> bool {
1008        self.char_after_offset_is_ascii_digit(self.offset)
1009    }
1010
1011    pub(crate) fn char_after_offset_is_ascii_digit(&self, offset: usize) -> bool {
1012        let Some(char) = self.char_at(offset) else {
1013            return false;
1014        };
1015        self.char_at(offset + char.len_utf8())
1016            .is_some_and(|char| char.is_ascii_digit())
1017    }
1018
1019    pub(crate) fn starts_with_ascii_keyword(&self, keyword: &str) -> bool {
1020        let remaining = &self.text[self.offset..];
1021        let Some(prefix) = remaining.get(..keyword.len()) else {
1022            return false;
1023        };
1024        if !matches_ignore_ascii_case(prefix, &[keyword]) {
1025            return false;
1026        }
1027        remaining[keyword.len()..]
1028            .chars()
1029            .next()
1030            .is_none_or(|char| !is_name_continue(char))
1031    }
1032
1033    pub(crate) fn supports_scss_interpolation(&self) -> bool {
1034        matches!(
1035            self.extension.dialect(),
1036            StyleDialect::Scss | StyleDialect::Sass
1037        )
1038    }
1039
1040    pub(crate) fn supports_less_interpolation(&self) -> bool {
1041        self.extension.dialect() == StyleDialect::Less
1042    }
1043
1044    pub(crate) fn starts_less_escaped_string(&self) -> bool {
1045        self.extension.dialect() == StyleDialect::Less
1046            && (self.starts_with("~\"") || self.starts_with("~'"))
1047    }
1048
1049    pub(crate) fn starts_unicode_range(&self) -> bool {
1050        let mut chars = self.text[self.offset..].chars();
1051        matches!(chars.next(), Some('u' | 'U'))
1052            && chars.next() == Some('+')
1053            && chars
1054                .next()
1055                .is_some_and(|char| char.is_ascii_hexdigit() || char == '?')
1056    }
1057
1058    pub(crate) fn current_char(&self) -> Option<char> {
1059        self.text[self.offset..].chars().next()
1060    }
1061
1062    pub(crate) fn next_char_is_hex_digit(&self) -> bool {
1063        let offset = self.offset + '-'.len_utf8();
1064        self.text
1065            .get(offset..)
1066            .and_then(|tail| tail.chars().next())
1067            .is_some_and(|char| char.is_ascii_hexdigit())
1068    }
1069
1070    pub(crate) fn bump_current(&mut self) {
1071        if let Some(char) = self.current_char() {
1072            self.bump_char(char);
1073        }
1074    }
1075
1076    pub(crate) fn bump_char(&mut self, char: char) {
1077        self.offset += char.len_utf8();
1078    }
1079}
1080
1081fn css_syntax_preprocessed_char(char: char) -> char {
1082    if char == '\0' { '\u{fffd}' } else { char }
1083}
1084
1085fn starts_valid_escape_text(text: &str) -> bool {
1086    text.starts_with('\\')
1087        && text['\\'.len_utf8()..]
1088            .chars()
1089            .next()
1090            .is_some_and(|char| !matches!(char, '\n' | '\r' | '\u{000c}'))
1091}