1use crate::syntax::{Language, Syntax};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum TokenKind {
15 Whitespace,
17 Newline,
18 Indent,
20 LineComment,
21 BlockComment,
22 String,
24 Interpolation,
26 Number,
27 Identifier,
28 Regex,
30 Punctuation,
31 Unterminated,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Token {
38 pub kind: TokenKind,
39 pub start: usize,
41 pub end: usize,
42 pub line: u32,
44 pub column: u32,
46}
47
48impl Token {
49 #[must_use]
51 pub fn text<'source>(&self, source: &'source str) -> &'source str {
52 source.get(self.start..self.end).unwrap_or_default()
53 }
54
55 #[must_use]
57 pub const fn is_trivia(&self) -> bool {
58 matches!(
59 self.kind,
60 TokenKind::Whitespace
61 | TokenKind::Newline
62 | TokenKind::Indent
63 | TokenKind::LineComment
64 | TokenKind::BlockComment
65 )
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum Mode {
77 #[default]
79 Lossless,
80 Lite,
83}
84
85#[must_use]
87pub fn tokenize(source: &str, language: Language) -> Vec<Token> {
88 Tokenizer::new(source, language).collect()
89}
90
91#[must_use]
93pub fn tokenize_lite(source: &str, language: Language) -> Vec<Token> {
94 Tokenizer::new(source, language).mode(Mode::Lite).collect()
95}
96
97pub struct Tokenizer<'source> {
99 source: &'source str,
100 syntax: Syntax,
101 mode: Mode,
102 bytes: &'source [u8],
103 offset: usize,
104 line: u32,
105 column: u32,
106 value_before: bool,
109 at_line_start: bool,
110}
111
112impl<'source> Tokenizer<'source> {
113 #[must_use]
114 pub fn new(source: &'source str, language: Language) -> Self {
115 Self {
116 source,
117 syntax: language.syntax(),
118 mode: Mode::Lossless,
119 bytes: source.as_bytes(),
120 offset: 0,
121 line: 1,
122 column: 1,
123 value_before: false,
124 at_line_start: true,
125 }
126 }
127
128 #[must_use]
130 pub const fn mode(mut self, mode: Mode) -> Self {
131 self.mode = mode;
132 self
133 }
134
135 fn peek(&self, ahead: usize) -> Option<u8> {
136 self.bytes.get(self.offset + ahead).copied()
137 }
138
139 fn starts_with(&self, needle: &str) -> bool {
140 self.source[self.offset..].starts_with(needle)
141 }
142
143 fn advance(&mut self, count: usize) {
145 let end = (self.offset + count).min(self.bytes.len());
146 while self.offset < end {
147 let character = self.source[self.offset..].chars().next().unwrap_or('\0');
148 if character == '\n' {
149 self.line += 1;
150 self.column = 1;
151 } else {
152 self.column += 1;
153 }
154 self.offset += character.len_utf8();
155 }
156 }
157
158 fn emit(&mut self, kind: TokenKind, start: usize, line: u32, column: u32) -> Token {
159 Token {
160 kind,
161 start,
162 end: self.offset,
163 line,
164 column,
165 }
166 }
167
168 fn scan_string(&mut self, quote: u8) -> bool {
170 if quote == b'`' {
171 return self.scan_template();
172 }
173 let triple =
174 self.syntax.triple_quotes && self.peek(1) == Some(quote) && self.peek(2) == Some(quote);
175 let closing = if triple { 3 } else { 1 };
176 self.advance(closing);
177 loop {
178 let Some(current) = self.peek(0) else {
179 return false;
180 };
181 if self.syntax.escapes && current == b'\\' {
182 self.advance(2);
183 continue;
184 }
185 if current == quote {
186 if triple {
187 if self.peek(1) == Some(quote) && self.peek(2) == Some(quote) {
188 self.advance(3);
189 return true;
190 }
191 } else {
192 if !self.syntax.escapes && self.peek(1) == Some(quote) {
194 self.advance(2);
195 continue;
196 }
197 self.advance(1);
198 return true;
199 }
200 }
201 if current == b'\n' && !triple && !self.syntax.escapes {
202 return false;
203 }
204 self.advance(1);
205 }
206 }
207
208 fn scan_template(&mut self) -> bool {
215 self.advance(1);
216 loop {
217 let Some(current) = self.peek(0) else {
218 return false;
219 };
220 if current == b'\\' {
221 self.advance(2);
222 continue;
223 }
224 if current == b'`' {
225 self.advance(1);
226 return true;
227 }
228 if current == b'$' && self.peek(1) == Some(b'{') {
229 self.advance(2);
230 if !self.scan_template_interpolation() {
231 return false;
232 }
233 continue;
234 }
235 self.advance(1);
236 }
237 }
238
239 fn scan_template_interpolation(&mut self) -> bool {
241 let mut depth = 1_u32;
242 loop {
243 let Some(current) = self.peek(0) else {
244 return false;
245 };
246 if matches!(current, b'\'' | b'"') {
247 if !self.scan_string(current) {
248 return false;
249 }
250 continue;
251 }
252 if current == b'`' {
253 if !self.scan_template() {
254 return false;
255 }
256 continue;
257 }
258 if self.starts_with("//") {
259 while self.peek(0).is_some_and(|byte| byte != b'\n') {
260 self.advance(1);
261 }
262 continue;
263 }
264 if self.starts_with("/*") {
265 if !self.scan_block_comment("/*", "*/") {
266 return false;
267 }
268 continue;
269 }
270 if current == b'/' {
271 let start = self.offset;
272 let line = self.line;
273 let column = self.column;
274 if self.scan_regex() {
275 continue;
276 }
277 self.offset = start;
280 self.line = line;
281 self.column = column;
282 }
283 if current == b'{' {
284 depth += 1;
285 } else if current == b'}' {
286 depth -= 1;
287 self.advance(1);
288 if depth == 0 {
289 return true;
290 }
291 continue;
292 }
293 self.advance(1);
294 }
295 }
296
297 fn char_literal_length(&self) -> Option<usize> {
306 if self.peek(1) == Some(b'\\') {
307 let mut offset = 3;
310 while offset < 12 {
311 match self.peek(offset) {
312 Some(b'\'') => return Some(offset + 1),
313 Some(b'\n') | None => return None,
314 _ => offset += 1,
315 }
316 }
317 return None;
318 }
319 let first = self.peek(1)?;
320 if first == b'\'' || first == b'\n' {
321 return None;
322 }
323 let width = match first {
325 0x00..=0x7f => 1,
326 0xc0..=0xdf => 2,
327 0xe0..=0xef => 3,
328 _ => 4,
329 };
330 (self.peek(1 + width) == Some(b'\'')).then_some(2 + width)
331 }
332
333 fn scan_raw_string(&mut self) -> bool {
335 let mut hashes = 0;
336 self.advance(1);
337 while self.peek(0) == Some(b'#') {
338 hashes += 1;
339 self.advance(1);
340 }
341 if self.peek(0) != Some(b'"') {
342 return true;
343 }
344 self.advance(1);
345 loop {
346 let Some(current) = self.peek(0) else {
347 return false;
348 };
349 if current == b'"' {
350 let closes = (1..=hashes).all(|index| self.peek(index) == Some(b'#'));
351 if closes {
352 self.advance(1 + hashes);
353 return true;
354 }
355 }
356 self.advance(1);
357 }
358 }
359
360 fn scan_block_comment(&mut self, open: &str, close: &str) -> bool {
361 self.advance(open.len());
362 let mut depth = 1_usize;
363 loop {
364 if self.offset >= self.bytes.len() {
365 return false;
366 }
367 if self.starts_with(close) {
368 self.advance(close.len());
369 depth -= 1;
370 if depth == 0 {
371 return true;
372 }
373 continue;
374 }
375 if self.syntax.nested_block_comments && self.starts_with(open) {
376 self.advance(open.len());
377 depth += 1;
378 continue;
379 }
380 self.advance(1);
381 }
382 }
383
384 fn scan_regex(&mut self) -> bool {
385 self.advance(1);
386 let mut in_class = false;
387 loop {
388 let Some(current) = self.peek(0) else {
389 return false;
390 };
391 match current {
392 b'\\' => {
393 self.advance(2);
394 continue;
395 }
396 b'\n' => return false,
397 b'[' => in_class = true,
398 b']' => in_class = false,
399 b'/' if !in_class => {
400 self.advance(1);
401 while self.peek(0).is_some_and(|byte| byte.is_ascii_alphabetic()) {
403 self.advance(1);
404 }
405 return true;
406 }
407 _ => {}
408 }
409 self.advance(1);
410 }
411 }
412
413 fn is_identifier_start(&self, character: char) -> bool {
414 character.is_alphabetic() || self.syntax.identifier_extra.contains(&character)
415 }
416
417 fn is_identifier_part(&self, character: char) -> bool {
418 character.is_alphanumeric() || self.syntax.identifier_extra.contains(&character)
419 }
420}
421
422impl Iterator for Tokenizer<'_> {
423 type Item = Token;
424
425 fn next(&mut self) -> Option<Token> {
426 loop {
427 let token = self.scan()?;
428 if self.mode == Mode::Lite && token.is_trivia() {
429 continue;
430 }
431 return Some(token);
432 }
433 }
434}
435
436impl Tokenizer<'_> {
437 #[allow(clippy::too_many_lines)]
438 fn scan(&mut self) -> Option<Token> {
439 if self.offset >= self.bytes.len() {
440 return None;
441 }
442 let start = self.offset;
443 let line = self.line;
444 let column = self.column;
445 let current = self.peek(0)?;
446
447 if current == b'\n' || (current == b'\r' && self.peek(1) == Some(b'\n')) {
448 self.advance(if current == b'\r' { 2 } else { 1 });
449 self.value_before = false;
450 self.at_line_start = true;
451 return Some(self.emit(TokenKind::Newline, start, line, column));
452 }
453
454 if current == b' ' || current == b'\t' || current == b'\r' {
455 let indent = self.at_line_start && self.syntax.significant_indentation;
456 while matches!(self.peek(0), Some(b' ' | b'\t' | b'\r')) {
457 self.advance(1);
458 }
459 self.at_line_start = false;
460 let kind = if indent {
461 TokenKind::Indent
462 } else {
463 TokenKind::Whitespace
464 };
465 return Some(self.emit(kind, start, line, column));
466 }
467 self.at_line_start = false;
468
469 for marker in self.syntax.line_comments {
470 if self.starts_with(marker) {
471 while !matches!(self.peek(0), None | Some(b'\n')) {
472 self.advance(1);
473 }
474 return Some(self.emit(TokenKind::LineComment, start, line, column));
475 }
476 }
477
478 if let Some((open, close)) = self.syntax.block_comment
479 && self.starts_with(open)
480 {
481 let terminated = self.scan_block_comment(open, close);
482 let kind = if terminated {
483 TokenKind::BlockComment
484 } else {
485 TokenKind::Unterminated
486 };
487 return Some(self.emit(kind, start, line, column));
488 }
489
490 if self.syntax.raw_strings
491 && (current == b'r' || current == b'b')
492 && matches!(self.peek(1), Some(b'"' | b'#'))
493 {
494 let terminated = self.scan_raw_string();
495 let kind = if terminated {
496 TokenKind::String
497 } else {
498 TokenKind::Unterminated
499 };
500 self.value_before = true;
501 return Some(self.emit(kind, start, line, column));
502 }
503
504 if self.syntax.char_literals
505 && current == b'\''
506 && let Some(length) = self.char_literal_length()
507 {
508 self.advance(length);
509 self.value_before = true;
510 return Some(self.emit(TokenKind::String, start, line, column));
511 }
512
513 if self.syntax.quotes.contains(&(current as char)) {
514 let terminated = self.scan_string(current);
515 let kind = if terminated {
516 TokenKind::String
517 } else {
518 TokenKind::Unterminated
519 };
520 self.value_before = true;
521 return Some(self.emit(kind, start, line, column));
522 }
523
524 if self.syntax.regex_literals && current == b'/' && !self.value_before {
525 let terminated = self.scan_regex();
526 if terminated {
527 self.value_before = true;
528 return Some(self.emit(TokenKind::Regex, start, line, column));
529 }
530 self.offset = start;
532 self.line = line;
533 self.column = column;
534 }
535
536 if current.is_ascii_digit() {
537 while self
538 .peek(0)
539 .is_some_and(|byte| byte.is_ascii_alphanumeric() || byte == b'.' || byte == b'_')
540 {
541 self.advance(1);
542 }
543 self.value_before = true;
544 return Some(self.emit(TokenKind::Number, start, line, column));
545 }
546
547 let character = self.source[self.offset..].chars().next()?;
548 if self.is_identifier_start(character) {
549 while self.source[self.offset..]
550 .chars()
551 .next()
552 .is_some_and(|value| self.is_identifier_part(value))
553 {
554 let width = self.source[self.offset..]
555 .chars()
556 .next()
557 .map_or(1, char::len_utf8);
558 self.advance(width);
559 }
560 let identifier = &self.source[start..self.offset];
561 self.value_before = !self.syntax.regex_literals
562 || !matches!(
563 identifier,
564 "await"
565 | "case"
566 | "delete"
567 | "do"
568 | "else"
569 | "in"
570 | "instanceof"
571 | "new"
572 | "of"
573 | "return"
574 | "throw"
575 | "typeof"
576 | "void"
577 | "yield"
578 );
579 return Some(self.emit(TokenKind::Identifier, start, line, column));
580 }
581
582 self.value_before = matches!(current, b')' | b']' | b'}');
584 self.advance(character.len_utf8());
585 Some(self.emit(TokenKind::Punctuation, start, line, column))
586 }
587}
588
589#[cfg(test)]
590mod tests {
591 use super::{Token, TokenKind, tokenize};
592 use crate::syntax::Language;
593
594 fn assert_round_trip(source: &str, language: Language) {
598 let tokens = tokenize(source, language);
599 let rebuilt = tokens
600 .iter()
601 .map(|token| token.text(source))
602 .collect::<String>();
603 assert_eq!(rebuilt, source, "token stream must be lossless");
604 let mut cursor = 0;
605 for token in &tokens {
606 assert_eq!(token.start, cursor, "tokens must be contiguous");
607 assert!(token.end > token.start, "tokens must be non-empty");
608 cursor = token.end;
609 }
610 assert_eq!(cursor, source.len(), "tokens must cover the whole source");
611 }
612
613 #[test]
614 fn javascript_separates_code_from_comments_strings_and_regexes() {
615 let source = "// route: app.get('/fake')\nconst re = /ab\\/c[/]/g;\nconst s = \"a // b\";\nconst t = `x ${y} z`;\napp.get('/real', h);\n";
616 assert_round_trip(source, Language::JavaScript);
617 let tokens = tokenize(source, Language::JavaScript);
618 let strings = tokens
619 .iter()
620 .filter(|token| token.kind == TokenKind::String)
621 .map(|token| token.text(source))
622 .collect::<Vec<_>>();
623 assert_eq!(strings, ["\"a // b\"", "`x ${y} z`", "'/real'"]);
624 assert_eq!(
625 tokens
626 .iter()
627 .filter(|token| token.kind == TokenKind::Regex)
628 .map(|token| token.text(source))
629 .collect::<Vec<_>>(),
630 ["/ab\\/c[/]/g"],
631 "a slash inside a character class does not end the literal"
632 );
633 assert_eq!(
634 tokens
635 .iter()
636 .filter(|token| token.kind == TokenKind::LineComment)
637 .count(),
638 1,
639 "the // inside a string is not a comment"
640 );
641 }
642
643 #[test]
644 fn division_is_not_mistaken_for_a_regex() {
645 let source = "const ratio = total / count / 2;\n";
646 assert_round_trip(source, Language::JavaScript);
647 assert!(
648 !tokenize(source, Language::JavaScript)
649 .iter()
650 .any(|token| token.kind == TokenKind::Regex),
651 "a slash after a value divides"
652 );
653 }
654
655 #[test]
656 fn regex_after_return_can_hold_quotes_braces_and_backticks() {
657 let source = concat!(
658 "function winQuote(value) {\n",
659 " const s = String(value)\n",
660 " return /[\\s&()[\\]{}^=;!'+,`~|<>\"]/.test(s) ",
661 "? `\"${s.replace(/\"/g, '\"\"')}\"` : s\n",
662 "}\n",
663 "export function runCommand(command, args = [], options = {}) {}\n",
664 );
665 assert_round_trip(source, Language::JavaScript);
666 let tokens = tokenize(source, Language::JavaScript);
667 assert_eq!(
668 tokens
669 .iter()
670 .filter(|token| token.kind == TokenKind::Regex)
671 .count(),
672 1,
673 "the regex inside the template interpolation belongs to its string token"
674 );
675 assert!(
676 !tokens
677 .iter()
678 .any(|token| token.kind == TokenKind::Unterminated)
679 );
680 assert!(
681 tokens.iter().any(|token| {
682 token.kind == TokenKind::Identifier && token.text(source) == "runCommand"
683 }),
684 "the regex and template above must not swallow the next declaration"
685 );
686 }
687
688 #[test]
689 fn rust_block_comments_nest_and_raw_strings_hold_quotes() {
690 let source = "/* outer /* inner */ still */ let s = r#\"a \"quoted\" b\"#;\n";
691 assert_round_trip(source, Language::Rust);
692 let tokens = tokenize(source, Language::Rust);
693 assert_eq!(
694 tokens
695 .iter()
696 .filter(|token| token.kind == TokenKind::BlockComment)
697 .map(|token| token.text(source))
698 .collect::<Vec<_>>(),
699 ["/* outer /* inner */ still */"],
700 "a nested comment must not end the outer one early"
701 );
702 assert_eq!(
703 tokens
704 .iter()
705 .filter(|token| token.kind == TokenKind::String)
706 .map(|token| token.text(source))
707 .collect::<Vec<_>>(),
708 ["r#\"a \"quoted\" b\"#"]
709 );
710 }
711
712 #[test]
713 fn python_triple_quotes_span_lines_and_indentation_is_marked() {
714 let source = "def run():\n \"\"\"doc\n # not a comment\n \"\"\"\n return 1\n";
715 assert_round_trip(source, Language::Python);
716 let tokens = tokenize(source, Language::Python);
717 assert_eq!(
718 tokens
719 .iter()
720 .filter(|token| token.kind == TokenKind::String)
721 .count(),
722 1,
723 "the docstring is one token, so the hash inside it is not a comment"
724 );
725 assert!(
726 !tokens
727 .iter()
728 .any(|token| token.kind == TokenKind::LineComment),
729 "no comment exists outside the docstring"
730 );
731 assert!(
732 tokens.iter().any(|token| token.kind == TokenKind::Indent),
733 "leading whitespace is marked in an indentation-sensitive language"
734 );
735 }
736
737 #[test]
738 fn graphql_and_protobuf_contract_sources_round_trip_losslessly() {
739 let graphql = concat!(
740 "\"\"\"A description with # inside\"\"\"\n",
741 "type Query { user(id: ID!): User } # schema comment\n",
742 "query Get($id: ID!) { user(id: $id) { id } }\n",
743 );
744 assert_round_trip(graphql, Language::Graphql);
745 let graphql_tokens = tokenize(graphql, Language::Graphql);
746 assert_eq!(
747 graphql_tokens
748 .iter()
749 .filter(|token| token.kind == TokenKind::String)
750 .count(),
751 1,
752 "a GraphQL block description is one lossless token"
753 );
754 assert_eq!(
755 graphql_tokens
756 .iter()
757 .filter(|token| token.kind == TokenKind::LineComment)
758 .count(),
759 1,
760 "only the hash outside the block description is a comment"
761 );
762
763 let protobuf = concat!(
764 "syntax = \"proto3\";\n",
765 "/* contract */ service Stream { // rpc\n",
766 " rpc Watch(stream Request) returns (stream Response);\n",
767 "}\n",
768 );
769 assert_round_trip(protobuf, Language::Protobuf);
770 let protobuf_tokens = tokenize(protobuf, Language::Protobuf);
771 assert!(
772 protobuf_tokens
773 .iter()
774 .any(|token| token.kind == TokenKind::BlockComment)
775 );
776 assert!(
777 protobuf_tokens
778 .iter()
779 .any(|token| token.kind == TokenKind::LineComment)
780 );
781 }
782
783 #[test]
784 fn sql_doubles_quotes_to_escape_them() {
785 let source = "SELECT 'it''s fine' -- trailing\nFROM users;\n";
786 assert_round_trip(source, Language::Sql);
787 let tokens = tokenize(source, Language::Sql);
788 assert_eq!(
789 tokens
790 .iter()
791 .filter(|token| token.kind == TokenKind::String)
792 .map(|token| token.text(source))
793 .collect::<Vec<_>>(),
794 ["'it''s fine'"]
795 );
796 assert_eq!(
797 tokens
798 .iter()
799 .filter(|token| token.kind == TokenKind::LineComment)
800 .count(),
801 1
802 );
803 }
804
805 #[test]
806 fn unterminated_constructs_are_reported_rather_than_swallowing_the_file() {
807 for (source, language) in [
808 ("const s = \"never closed\n", Language::JavaScript),
809 ("/* never closed", Language::Rust),
810 ] {
811 assert_round_trip(source, language);
812 assert!(
813 tokenize(source, language)
814 .iter()
815 .any(|token| token.kind == TokenKind::Unterminated),
816 "an unterminated construct is explicit: {source:?}"
817 );
818 }
819 }
820
821 #[test]
822 fn lite_mode_drops_trivia_without_moving_positions() {
823 let source = "// note\nconst a = 1; /* mid */ const b = 2;\n";
824 let full = tokenize(source, Language::JavaScript);
825 let lite = super::tokenize_lite(source, Language::JavaScript);
826 assert!(
827 lite.len() < full.len(),
828 "the lite stream is smaller: {} vs {}",
829 lite.len(),
830 full.len()
831 );
832 assert!(
833 !lite.iter().any(Token::is_trivia),
834 "no trivia survives in lite mode"
835 );
836 let meaningful = full
837 .iter()
838 .filter(|token| !token.is_trivia())
839 .cloned()
840 .collect::<Vec<_>>();
841 assert_eq!(
842 lite, meaningful,
843 "lite mode keeps the same tokens with the same spans"
844 );
845 }
846
847 #[test]
848 fn positions_are_one_based_and_track_lines() {
849 let source = "a\nbb\n ccc\n";
850 assert_round_trip(source, Language::JavaScript);
851 let tokens = tokenize(source, Language::JavaScript);
852 let identifiers = tokens
853 .iter()
854 .filter(|token| token.kind == TokenKind::Identifier)
855 .map(|token| (token.text(source), token.line, token.column))
856 .collect::<Vec<_>>();
857 assert_eq!(identifiers, [("a", 1, 1), ("bb", 2, 1), ("ccc", 3, 3)]);
858 }
859}