1use 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}