Skip to main content

weavatrix_parse/
token.rs

1//! Lossless tokenizer.
2//!
3//! Every byte of the input belongs to exactly one token, including whitespace
4//! and comments. Concatenating the text of all tokens reproduces the source
5//! exactly - a property the tests assert - so the same token stream serves a
6//! compiler front end, a formatter and an evidence extractor. Skipping trivia
7//! would make the stream cheaper and permanently unable to round-trip.
8
9use crate::syntax::{Language, Syntax};
10
11/// What a token is, at the lexical level only.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum TokenKind {
15    /// Spaces and tabs; newlines are their own kind.
16    Whitespace,
17    Newline,
18    /// Leading whitespace of a line in an indentation-sensitive language.
19    Indent,
20    LineComment,
21    BlockComment,
22    /// String, character, template or raw-string literal, quotes included.
23    String,
24    /// Interpolated section of a template literal, `${` and `}` included.
25    Interpolation,
26    Number,
27    Identifier,
28    /// Regular-expression literal in languages that have them.
29    Regex,
30    Punctuation,
31    /// A block comment or string that the file ends inside.
32    Unterminated,
33}
34
35/// One lexical unit and its exact position in the source.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct Token {
38    pub kind: TokenKind,
39    /// Byte range in the source; `source[start..end]` is the token text.
40    pub start: usize,
41    pub end: usize,
42    /// One-based line of the first byte.
43    pub line: u32,
44    /// One-based column, counted in characters, of the first byte.
45    pub column: u32,
46}
47
48impl Token {
49    /// The token's text.
50    #[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    /// Whether this token carries no program meaning.
56    #[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/// How much of the source the stream carries.
70///
71/// The two modes exist because the consumers genuinely differ. A compiler
72/// front end, a formatter or a source-to-source translator must be able to
73/// rebuild the input, so they need every byte. Evidence extraction throws
74/// trivia away immediately, so carrying it only costs allocations.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
76pub enum Mode {
77    /// Every byte belongs to a token; the stream rebuilds the source exactly.
78    #[default]
79    Lossless,
80    /// Whitespace and comments are skipped. Positions stay exact, so spans
81    /// remain usable, but the stream no longer round-trips.
82    Lite,
83}
84
85/// Tokenizes a whole source file losslessly.
86#[must_use]
87pub fn tokenize(source: &str, language: Language) -> Vec<Token> {
88    Tokenizer::new(source, language).collect()
89}
90
91/// Tokenizes a source file, dropping trivia.
92#[must_use]
93pub fn tokenize_lite(source: &str, language: Language) -> Vec<Token> {
94    Tokenizer::new(source, language).mode(Mode::Lite).collect()
95}
96
97/// Streaming tokenizer over one source file.
98pub 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    /// Whether the previous meaningful token can end an expression, which is
107    /// what decides between division and a regular-expression literal.
108    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    /// Selects how much of the source the stream carries.
129    #[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    /// Advances past `count` bytes, keeping the line and column current.
144    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    /// Consumes a string literal, returning whether it terminated.
169    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                    // SQL writes a literal quote by doubling it.
193                    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    /// Consumes a JavaScript template, including nested templates inside its
209    /// `${...}` expressions.
210    ///
211    /// A backtick inside an interpolation starts a nested template rather than
212    /// closing the outer one. Treating it as the outer close exposed the nested
213    /// template's text as code, so text such as `file(s)` became a call fact.
214    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    /// Consumes the balanced expression after `${`, stopping after its `}`.
240    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                // A division operator has no closing slash. Restore the
278                // scanner and consume it normally.
279                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    /// Length of the character literal at the cursor, or `None` when the quote
298    /// opens something else.
299    ///
300    /// A lifetime and a character literal start identically, so what tells
301    /// them apart is the closing quote: `'a'` has one and `'a` does not.
302    /// Deciding by the closing quote rather than by what follows the opening
303    /// one is what keeps `'"'` a literal - the case that silently shifted
304    /// every string boundary in a file when `'` was treated as punctuation.
305    fn char_literal_length(&self) -> Option<usize> {
306        if self.peek(1) == Some(b'\\') {
307            // The escaped character can itself be a quote, so the search for
308            // the closing quote starts after it.
309            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        // One character, however many bytes it takes to write.
324        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    /// Consumes a raw string such as `r"..."` or `r#"..."#`.
334    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                    // Trailing flags belong to the literal.
402                    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            // Not a regular expression after all: fall through as punctuation.
531            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        // A closing bracket or paren ends a value, so a following `/` divides.
583        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    /// The stream must reproduce the source byte for byte. A compiler front
595    /// end and a formatter both depend on this; an extractor that silently
596    /// dropped bytes could never be reused for either.
597    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}