Skip to main content

meta_language/translation/
lexer.rs

1//! Tokenizer shared by the portable-core translation frontends.
2//!
3//! Every token keeps its UTF-16 offsets so diagnostics and source mappings agree with the
4//! JavaScript runtime; comments are retained separately because `JSDoc` types
5//! are part of the JavaScript frontend's input.
6//!
7//! Mirrors `js/src/translation/lexer.js`.
8
9use std::sync::OnceLock;
10
11use regex::Regex;
12
13use super::decimal::Decimal;
14use super::diagnostics::{ErrorKind, Result, TranslationError};
15use super::frontend_rules::{UnicodeEscape, decode_unicode_escape};
16use super::js_number::js_number_literal;
17use super::{Language, Span};
18
19/// Source text addressed by UTF-16 code units, as JavaScript strings are.
20#[derive(Clone, Debug, Default, PartialEq, Eq)]
21pub struct Source {
22    units: Vec<u16>,
23}
24
25impl Source {
26    #[must_use]
27    pub fn new(text: &str) -> Self {
28        Self {
29            units: text.encode_utf16().collect(),
30        }
31    }
32
33    #[must_use]
34    pub const fn len(&self) -> usize {
35        self.units.len()
36    }
37
38    #[must_use]
39    pub const fn is_empty(&self) -> bool {
40        self.units.is_empty()
41    }
42
43    #[must_use]
44    pub fn units(&self) -> &[u16] {
45        &self.units
46    }
47
48    /// The code unit at `index`, if any.
49    #[must_use]
50    pub fn at(&self, index: usize) -> Option<u16> {
51        self.units.get(index).copied()
52    }
53
54    /// The code unit at `index` as a character; lone surrogates are `None`.
55    #[must_use]
56    pub fn char_at(&self, index: usize) -> Option<char> {
57        self.at(index)
58            .and_then(|unit| char::from_u32(u32::from(unit)))
59    }
60
61    /// True when the unit at `index` is the ASCII or BMP character `ch`.
62    #[must_use]
63    pub fn is_char(&self, index: usize, ch: char) -> bool {
64        self.char_at(index) == Some(ch)
65    }
66
67    #[must_use]
68    pub fn starts_with(&self, pattern: &str, index: usize) -> bool {
69        pattern
70            .encode_utf16()
71            .enumerate()
72            .all(|(offset, unit)| self.at(index + offset) == Some(unit))
73    }
74
75    /// `source.slice(start, end)`, clamped like JavaScript's.
76    #[must_use]
77    pub fn slice(&self, start: usize, end: usize) -> String {
78        let end = end.min(self.units.len());
79        let start = start.min(end);
80        String::from_utf16_lossy(&self.units[start..end])
81    }
82
83    /// `source.indexOf(pattern, from)`.
84    #[must_use]
85    pub fn index_of(&self, pattern: &str, from: usize) -> Option<usize> {
86        (from..self.units.len()).find(|&index| self.starts_with(pattern, index))
87    }
88
89    /// `String.fromCodePoint(source.codePointAt(index))`: the whole character
90    /// when `index` starts a surrogate pair.
91    #[must_use]
92    pub fn code_point_text(&self, index: usize) -> String {
93        let high = self.units[index];
94        if (0xD800..0xDC00).contains(&high)
95            && let Some(low) = self.at(index + 1)
96            && (0xDC00..0xE000).contains(&low)
97        {
98            return String::from_utf16_lossy(&self.units[index..index + 2]);
99        }
100        String::from_utf16_lossy(&self.units[index..=index])
101    }
102}
103
104#[derive(Clone, Copy, Debug, PartialEq, Eq)]
105pub enum TokenKind {
106    Number,
107    Identifier,
108    Macro,
109    Punct,
110    String,
111    Interpolation,
112    Template,
113    Eof,
114}
115
116/// A `${…}` substitution in a JavaScript template literal.
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct TemplateExpression {
119    pub source: String,
120    pub offset: usize,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq)]
124pub struct TemplatePart {
125    pub text: String,
126    pub expression: Option<TemplateExpression>,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq)]
130pub struct Token {
131    pub kind: TokenKind,
132    pub value: String,
133    /// The letters after a number's digits (`n`, `u32`, `x1fn`).
134    pub suffix: String,
135    pub raw: String,
136    pub start: usize,
137    pub end: usize,
138    pub parts: Vec<TemplatePart>,
139    /// Layout positions, filled in by the Lean frontend.
140    pub line: usize,
141    pub col: usize,
142    pub first: bool,
143}
144
145impl Token {
146    const fn new(kind: TokenKind, value: String, raw: String, start: usize, end: usize) -> Self {
147        Self {
148            kind,
149            value,
150            suffix: String::new(),
151            raw,
152            start,
153            end,
154            parts: Vec::new(),
155            line: 0,
156            col: 0,
157            first: false,
158        }
159    }
160
161    #[must_use]
162    pub const fn span(&self) -> Span {
163        Span {
164            start: self.start,
165            end: self.end,
166        }
167    }
168}
169
170#[derive(Clone, Debug, PartialEq, Eq)]
171pub struct Comment {
172    pub text: String,
173    pub start: usize,
174    pub end: usize,
175}
176
177#[derive(Clone, Debug)]
178pub struct Tokens {
179    pub tokens: Vec<Token>,
180    pub comments: Vec<Comment>,
181}
182
183fn operators(language: Language) -> Vec<&'static str> {
184    let mut list: Vec<&'static str> = match language {
185        Language::JavaScript => vec![
186            ">>>=", "===", "!==", "...", "**=", "<<=", ">>=", ">>>", "&&=", "||=", "??=", "**",
187            "&&", "||", "==", "!=", "<=", ">=", "=>", "+=", "-=", "*=", "/=", "%=", "&=", "|=",
188            "^=", "++", "--", "??", "?.", "<<", ">>",
189        ],
190        Language::Rust => vec![
191            "..=", "::", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "+=", "-=", "*=", "/=",
192            "%=", "..",
193        ],
194        Language::Lean => vec![
195            ":=", "++", "->", "=>", "==", "!=", "<=", ">=", "&&", "||", "<;>", "<|", "|>", "::",
196            "..", "←", "→", "∀", "∧", "≤", "≥", "≠", "×",
197        ],
198        Language::Rocq => vec![
199            ":=", "=>", "->", "<->", "<=?", "<?", "=?", "<=", ">=", "<>", "/\\", "\\/", "&&", "||",
200            "++", "::", "%",
201        ],
202    };
203    // Longest first, keeping the listed order among equal lengths (a stable
204    // sort by UTF-16 length, like the JavaScript runtime's).
205    list.sort_by_key(|operator| std::cmp::Reverse(operator.encode_utf16().count()));
206    list
207}
208
209struct CommentSyntax {
210    line: Option<&'static str>,
211    open: &'static str,
212    close: &'static str,
213    nested: bool,
214}
215
216const fn comment_syntax(language: Language) -> CommentSyntax {
217    match language {
218        Language::JavaScript => CommentSyntax {
219            line: Some("//"),
220            open: "/*",
221            close: "*/",
222            nested: false,
223        },
224        Language::Rust => CommentSyntax {
225            line: Some("//"),
226            open: "/*",
227            close: "*/",
228            nested: true,
229        },
230        Language::Lean => CommentSyntax {
231            line: Some("--"),
232            open: "/-",
233            close: "-/",
234            nested: true,
235        },
236        Language::Rocq => CommentSyntax {
237            line: None,
238            open: "(*",
239            close: "*)",
240            nested: true,
241        },
242    }
243}
244
245/// JavaScript's `/\s/u` on one code unit.
246#[must_use]
247pub const fn is_js_space(unit: u16) -> bool {
248    matches!(
249        unit,
250        0x09..=0x0D
251            | 0x20
252            | 0xA0
253            | 0x1680
254            | 0x2000..=0x200A
255            | 0x2028
256            | 0x2029
257            | 0x202F
258            | 0x205F
259            | 0x3000
260            | 0xFEFF
261    )
262}
263
264fn letter_regex() -> &'static Regex {
265    static LETTER: OnceLock<Regex> = OnceLock::new();
266    LETTER.get_or_init(|| Regex::new(r"^\p{L}$").expect("letter class"))
267}
268
269fn letter_or_number_regex() -> &'static Regex {
270    static LETTER: OnceLock<Regex> = OnceLock::new();
271    LETTER.get_or_init(|| Regex::new(r"^[\p{L}\p{N}]$").expect("letter or number class"))
272}
273
274const LEAN_SYMBOLS: &str = "∀→∧≤≥≠λ×";
275
276fn unicode_test(unit: Option<u16>, regex: &Regex) -> bool {
277    unit.and_then(|unit| char::from_u32(u32::from(unit)))
278        .is_some_and(|ch| !LEAN_SYMBOLS.contains(ch) && regex.is_match(ch.encode_utf8(&mut [0; 4])))
279}
280
281fn is_identifier_start(unit: Option<u16>, language: Language) -> bool {
282    let Some(value) = unit else { return false };
283    if u8::try_from(value).is_ok_and(|byte| byte.is_ascii_alphabetic() || byte == b'_') {
284        return true;
285    }
286    if language == Language::JavaScript && value == u16::from(b'$') {
287        return true;
288    }
289    matches!(language, Language::Lean | Language::Rocq) && unicode_test(unit, letter_regex())
290}
291
292fn is_identifier_part(unit: Option<u16>, language: Language) -> bool {
293    let Some(value) = unit else { return false };
294    if u8::try_from(value).is_ok_and(|byte| byte.is_ascii_alphanumeric() || byte == b'_') {
295        return true;
296    }
297    let is = |ch: u8| value == u16::from(ch);
298    if language == Language::JavaScript && is(b'$') {
299        return true;
300    }
301    if language == Language::Rocq && is(b'\'') {
302        return true;
303    }
304    if language == Language::Lean && (is(b'\'') || is(b'!') || is(b'?')) {
305        return true;
306    }
307    matches!(language, Language::Lean | Language::Rocq)
308        && unicode_test(unit, letter_or_number_regex())
309}
310
311const fn span(start: usize, end: usize) -> Span {
312    Span { start, end }
313}
314
315/// Splits `text` into tokens and comments.
316///
317/// # Errors
318///
319/// Returns a syntax error for malformed numbers, unsupported escapes and
320/// unterminated strings, template literals or block comments.
321pub fn tokenize(text: &str, language: Language) -> Result<Tokens> {
322    tokenize_source(&Source::new(text), language)
323}
324
325/// [`tokenize`] over an already encoded source.
326///
327/// # Errors
328///
329/// See [`tokenize`].
330pub fn tokenize_source(source: &Source, language: Language) -> Result<Tokens> {
331    let operators = operators(language);
332    let comments = comment_syntax(language);
333    let mut tokens: Vec<Token> = Vec::new();
334    let mut comment_list = Vec::new();
335    let mut index = 0;
336    while index < source.len() {
337        let unit = source.units[index];
338        if is_js_space(unit) {
339            index += 1;
340            continue;
341        }
342        if let Some(line) = comments.line
343            && source.starts_with(line, index)
344        {
345            let stop = source.index_of("\n", index).unwrap_or(source.len());
346            comment_list.push(Comment {
347                text: source.slice(index, stop),
348                start: index,
349                end: stop,
350            });
351            index = stop;
352            continue;
353        }
354        if source.starts_with(comments.open, index) {
355            let stop = block_comment_end(source, index, &comments)?;
356            comment_list.push(Comment {
357                text: source.slice(index, stop),
358                start: index,
359                end: stop,
360            });
361            index = stop;
362            continue;
363        }
364        let start = index;
365        let ch = source.char_at(index);
366        if language == Language::JavaScript && ch == Some('`') {
367            let token = template_token(source, index)?;
368            index = token.end;
369            tokens.push(token);
370            continue;
371        }
372        if ch == Some('"') || (language == Language::JavaScript && ch == Some('\'')) {
373            let token = string_token(source, index, language)?;
374            index = token.end;
375            tokens.push(token);
376            continue;
377        }
378        if language == Language::Lean && source.starts_with("s!\"", index) {
379            let mut token = string_token(source, index + 2, language)?;
380            token.kind = TokenKind::Interpolation;
381            token.start = start;
382            token.raw = source.slice(start, token.end);
383            index = token.end;
384            tokens.push(token);
385            continue;
386        }
387        if language == Language::JavaScript
388            && (ch.is_some_and(|ch| ch.is_ascii_digit())
389                || (ch == Some('.')
390                    && source
391                        .char_at(index + 1)
392                        .is_some_and(|ch| ch.is_ascii_digit())))
393        {
394            let token = javascript_number(source, index)?;
395            index = token.end;
396            tokens.push(token);
397            continue;
398        }
399        if ch.is_some_and(|ch| ch.is_ascii_digit()) {
400            let mut stop = index;
401            while source
402                .char_at(stop)
403                .is_some_and(|ch| ch.is_ascii_digit() || ch == '_')
404            {
405                stop += 1;
406            }
407            let digits_end = stop;
408            if source
409                .char_at(stop)
410                .is_some_and(|ch| ch.is_ascii_lowercase())
411            {
412                stop += 1;
413                while source
414                    .char_at(stop)
415                    .is_some_and(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
416                {
417                    stop += 1;
418                }
419            }
420            let raw = source.slice(start, stop);
421            let digits = source.slice(start, digits_end);
422            if digits.ends_with('_') {
423                return Err(TranslationError::syntax(
424                    format!("malformed number {raw}"),
425                    Some(span(start, stop)),
426                ));
427            }
428            let mut token =
429                Token::new(TokenKind::Number, digits.replace('_', ""), raw, start, stop);
430            token.suffix = source.slice(digits_end, stop);
431            tokens.push(token);
432            index = stop;
433            continue;
434        }
435        if is_identifier_start(Some(unit), language) {
436            let mut stop = index + 1;
437            loop {
438                while stop < source.len() && is_identifier_part(source.at(stop), language) {
439                    stop += 1;
440                }
441                // Lean and Rocq qualified names (`Tree.node`, `Nat.add`) are single tokens.
442                let qualified = matches!(language, Language::Lean | Language::Rocq)
443                    && source.is_char(stop, '.')
444                    && stop + 1 < source.len()
445                    && is_identifier_start(source.at(stop + 1), language);
446                if !qualified {
447                    break;
448                }
449                stop += 2;
450            }
451            if language == Language::Rust
452                && source.is_char(stop, '!')
453                && !source.is_char(stop + 1, '=')
454            {
455                tokens.push(Token::new(
456                    TokenKind::Macro,
457                    source.slice(index, stop),
458                    source.slice(index, stop + 1),
459                    start,
460                    stop + 1,
461                ));
462                index = stop + 1;
463                continue;
464            }
465            let value = source.slice(index, stop);
466            tokens.push(Token::new(
467                TokenKind::Identifier,
468                value.clone(),
469                value,
470                start,
471                stop,
472            ));
473            index = stop;
474            continue;
475        }
476        let text = operators
477            .iter()
478            .find(|operator| source.starts_with(operator, index))
479            .map_or_else(
480                || source.code_point_text(index),
481                |operator| (*operator).to_owned(),
482            );
483        let length = text.encode_utf16().count();
484        tokens.push(Token::new(
485            TokenKind::Punct,
486            text.clone(),
487            text,
488            start,
489            start + length,
490        ));
491        index += length;
492    }
493    tokens.push(Token::new(
494        TokenKind::Eof,
495        String::new(),
496        String::new(),
497        source.len(),
498        source.len(),
499    ));
500    Ok(Tokens {
501        tokens,
502        comments: comment_list,
503    })
504}
505
506fn javascript_number_regex() -> &'static Regex {
507    static NUMBER: OnceLock<Regex> = OnceLock::new();
508    NUMBER.get_or_init(|| {
509        let run = |digit: &str| format!("{digit}(?:_?{digit})*");
510        let decimal = run("[0-9]");
511        Regex::new(&format!(
512            r"^(?:0[xX]{}|0[oO]{}|0[bB]{}|(?:(?:0|[1-9](?:_?{decimal})?)(?:\.(?:{decimal})?)?|\.{decimal})(?:[eE][+-]?{decimal})?)n?",
513            run("[0-9a-fA-F]"),
514            run("[0-7]"),
515            run("[01]"),
516        ))
517        .expect("JavaScript numeric literal")
518    })
519}
520
521/// An ECMAScript numeric literal. A `BigInt` (`5n`, `0x1fn`) keeps its decimal
522/// digits as `value` with suffix `n`; a Number keeps `String(Number(raw))`, the
523/// value JavaScript prints, with an empty suffix.
524fn javascript_number(source: &Source, index: usize) -> Result<Token> {
525    let rest = source.slice(index, source.len());
526    let found = javascript_number_regex().find(&rest);
527    let raw = found.map_or_else(|| source.slice(index, index + 1), |m| m.as_str().to_owned());
528    let end = index + raw.len();
529    let next = source.char_at(end);
530    let malformed = || {
531        let stop = if end < source.len() { end + 1 } else { end };
532        TranslationError::syntax(
533            format!("malformed number {}", source.slice(index, stop)),
534            Some(span(index, stop)),
535        )
536    };
537    // `08`, `1_`, `3in` and `1.5n` are SyntaxErrors in JavaScript.
538    if found.is_none()
539        || next.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '$')
540    {
541        return Err(malformed());
542    }
543    let text = raw.replace('_', "");
544    let token = if let Some(digits) = text.strip_suffix('n') {
545        let hex = digits.starts_with("0x") || digits.starts_with("0X");
546        if !hex && digits.contains(['.', 'e', 'E']) {
547            return Err(malformed());
548        }
549        let value = Decimal::parse(digits).ok_or_else(malformed)?;
550        let mut token = Token::new(TokenKind::Number, value.digits, raw, index, end);
551        token.suffix = String::from("n");
552        token
553    } else {
554        let value = js_number_literal(&text).ok_or_else(malformed)?;
555        Token::new(TokenKind::Number, value, raw, index, end)
556    };
557    Ok(token)
558}
559
560fn block_comment_end(source: &Source, index: usize, comments: &CommentSyntax) -> Result<usize> {
561    let open = comments.open.encode_utf16().count();
562    let close = comments.close.encode_utf16().count();
563    let mut depth = 0usize;
564    let mut cursor = index;
565    while cursor < source.len() {
566        if source.starts_with(comments.open, cursor) {
567            depth += 1;
568            cursor += open;
569            if !comments.nested && depth > 1 {
570                depth = 1;
571            }
572            continue;
573        }
574        if source.starts_with(comments.close, cursor) {
575            // A close before any open (impossible from `tokenize`) would go negative in JavaScript.
576            depth = depth.saturating_sub(1);
577            cursor += close;
578            if depth == 0 {
579                return Ok(cursor);
580            }
581            continue;
582        }
583        cursor += 1;
584    }
585    Err(TranslationError::syntax(
586        "unterminated block comment",
587        Some(span(index, source.len())),
588    ))
589}
590
591fn string_token(source: &Source, index: usize, language: Language) -> Result<Token> {
592    let quote = source.units[index];
593    let mut cursor = index + 1;
594    let mut value: Vec<u16> = Vec::new();
595    while cursor < source.len() {
596        let unit = source.units[cursor];
597        if unit == quote {
598            if language == Language::Rocq && source.is_char(cursor + 1, '"') {
599                value.push(u16::from(b'"'));
600                cursor += 2;
601                continue;
602            }
603            let mut token = Token::new(
604                TokenKind::String,
605                String::from_utf16_lossy(&value),
606                source.slice(index, cursor + 1),
607                index,
608                cursor + 1,
609            );
610            token.raw = source.slice(index, cursor + 1);
611            return Ok(token);
612        }
613        if unit == u16::from(b'\\') && language != Language::Rocq {
614            let escaped = source.char_at(cursor + 1);
615            if escaped == Some('u') {
616                let units = source.units()[cursor..source.len().min(cursor + 12)]
617                    .iter()
618                    .map(|unit| f64::from(*unit))
619                    .collect();
620                match decode_unicode_escape(units, language == Language::JavaScript) {
621                    UnicodeEscape::Scalar(code, end) => {
622                        // The generated decoder returns integral scalar values and
623                        // offsets inside the twelve-code-unit input window.
624                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
625                        let character =
626                            char::from_u32(code as u32).expect("decoded Unicode scalar");
627                        let mut units = [0; 2];
628                        value.extend_from_slice(character.encode_utf16(&mut units));
629                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
630                        {
631                            cursor += end as usize;
632                        }
633                        continue;
634                    }
635                    UnicodeEscape::Malformed => {
636                        return Err(TranslationError::syntax(
637                            "malformed unicode escape",
638                            Some(span(cursor, cursor + 2)),
639                        ));
640                    }
641                    UnicodeEscape::Unsupported(end, escape_length) => {
642                        #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
643                        let (end, escape_length) = (end as usize, escape_length as usize);
644                        return Err(TranslationError::new(
645                            ErrorKind::Unsupported,
646                            format!(
647                                "unicode escape {} is not a scalar value; Rust strings hold scalar values only",
648                                source.slice(cursor, cursor + escape_length)
649                            ),
650                            Some(span(cursor, cursor + end)),
651                        ));
652                    }
653                }
654            }
655            let replacement = match escaped {
656                Some('n') => "\n",
657                Some('t') => "\t",
658                Some('r') => "\r",
659                Some('\\') => "\\",
660                Some('"') => "\"",
661                Some('\'') => "'",
662                Some('0') => "\0",
663                Some('{') => "\\{",
664                _ => {
665                    return Err(TranslationError::syntax(
666                        format!(
667                            "unsupported string escape \\{}",
668                            escaped_text(source, cursor + 1)
669                        ),
670                        Some(span(cursor, cursor + 2)),
671                    ));
672                }
673            };
674            value.extend(replacement.encode_utf16());
675            cursor += 2;
676            continue;
677        }
678        if unit == u16::from(b'\n') && language == Language::JavaScript {
679            break;
680        }
681        value.push(unit);
682        cursor += 1;
683    }
684    Err(TranslationError::syntax(
685        "unterminated string literal",
686        Some(span(index, source.len())),
687    ))
688}
689
690fn template_token(source: &Source, index: usize) -> Result<Token> {
691    let mut parts = Vec::new();
692    let mut cursor = index + 1;
693    let mut text: Vec<u16> = Vec::new();
694    while cursor < source.len() {
695        let unit = source.units[cursor];
696        if unit == u16::from(b'`') {
697            parts.push(TemplatePart {
698                text: String::from_utf16_lossy(&text),
699                expression: None,
700            });
701            let raw = source.slice(index, cursor + 1);
702            let mut token = Token::new(TokenKind::Template, String::new(), raw, index, cursor + 1);
703            token.parts = parts;
704            return Ok(token);
705        }
706        if unit == u16::from(b'\\') {
707            let replacement = match source.char_at(cursor + 1) {
708                Some('n') => '\n',
709                Some('t') => '\t',
710                Some('\\') => '\\',
711                Some('`') => '`',
712                Some('$') => '$',
713                _ => {
714                    return Err(TranslationError::syntax(
715                        format!(
716                            "unsupported template escape \\{}",
717                            escaped_text(source, cursor + 1)
718                        ),
719                        Some(span(cursor, cursor + 2)),
720                    ));
721                }
722            };
723            text.push(replacement as u16);
724            cursor += 2;
725            continue;
726        }
727        if source.starts_with("${", cursor) {
728            let close = matching_brace(source, cursor + 1)?;
729            parts.push(TemplatePart {
730                text: String::from_utf16_lossy(&text),
731                expression: Some(TemplateExpression {
732                    source: source.slice(cursor + 2, close),
733                    offset: cursor + 2,
734                }),
735            });
736            text.clear();
737            cursor = close + 1;
738            continue;
739        }
740        text.push(unit);
741        cursor += 1;
742    }
743    Err(TranslationError::syntax(
744        "unterminated template literal",
745        Some(span(index, source.len())),
746    ))
747}
748
749/// The escaped unit as JavaScript interpolates it (`undefined` past the end).
750fn escaped_text(source: &Source, index: usize) -> String {
751    if index < source.len() {
752        source.slice(index, index + 1)
753    } else {
754        "undefined".to_owned()
755    }
756}
757
758/// The index of the `}` closing the `{` at `open`.
759///
760/// # Errors
761///
762/// Returns a syntax error when the braces are unbalanced.
763pub fn matching_brace(source: &Source, open: usize) -> Result<usize> {
764    let mut depth = 0i64;
765    for cursor in open..source.len() {
766        if source.is_char(cursor, '{') {
767            depth += 1;
768        }
769        if source.is_char(cursor, '}') {
770            depth -= 1;
771            if depth == 0 {
772                return Ok(cursor);
773            }
774        }
775    }
776    Err(TranslationError::syntax(
777        "unbalanced braces",
778        Some(span(open, source.len())),
779    ))
780}
781
782/// Cursor over a token list with the helpers every frontend needs.
783#[derive(Clone, Debug)]
784pub struct TokenCursor {
785    pub tokens: Vec<Token>,
786    pub language: Language,
787    pub index: usize,
788}
789
790impl TokenCursor {
791    #[must_use]
792    pub const fn new(tokens: Vec<Token>, language: Language) -> Self {
793        Self {
794            tokens,
795            language,
796            index: 0,
797        }
798    }
799
800    #[must_use]
801    pub fn peek(&self) -> &Token {
802        self.peek_at(0)
803    }
804
805    #[must_use]
806    pub fn peek_at(&self, offset: usize) -> &Token {
807        &self.tokens[(self.index + offset).min(self.tokens.len() - 1)]
808    }
809
810    pub fn advance(&mut self) -> Token {
811        let token = self.peek().clone();
812        if token.kind != TokenKind::Eof {
813            self.index += 1;
814        }
815        token
816    }
817
818    #[must_use]
819    pub fn is(&self, value: &str) -> bool {
820        self.is_at(value, 0)
821    }
822
823    #[must_use]
824    pub fn is_at(&self, value: &str, offset: usize) -> bool {
825        let token = self.peek_at(offset);
826        matches!(token.kind, TokenKind::Punct | TokenKind::Identifier) && token.value == value
827    }
828
829    #[must_use]
830    pub fn is_kind(&self, kind: TokenKind) -> bool {
831        self.peek().kind == kind
832    }
833
834    #[must_use]
835    pub fn is_kind_at(&self, kind: TokenKind, offset: usize) -> bool {
836        self.peek_at(offset).kind == kind
837    }
838
839    pub fn eat(&mut self, value: &str) -> Option<Token> {
840        if self.is(value) {
841            Some(self.advance())
842        } else {
843            None
844        }
845    }
846
847    /// Consumes `value`.
848    ///
849    /// # Errors
850    ///
851    /// Returns a syntax error naming the token found instead.
852    pub fn expect(&mut self, value: &str, context: Option<&str>) -> Result<Token> {
853        if self.is(value) {
854            return Ok(self.advance());
855        }
856        let token = self.peek();
857        Err(TranslationError::syntax(
858            format!(
859                "expected {value}{} but found {}",
860                context.map_or_else(String::new, |context| format!(" in {context}")),
861                describe(token)
862            ),
863            Some(token.span()),
864        ))
865    }
866
867    /// Consumes an identifier.
868    ///
869    /// # Errors
870    ///
871    /// Returns a syntax error naming the token found instead.
872    pub fn identifier(&mut self, context: Option<&str>) -> Result<Token> {
873        let token = self.peek();
874        if token.kind != TokenKind::Identifier {
875            return Err(TranslationError::syntax(
876                format!(
877                    "expected identifier{} but found {}",
878                    context.map_or_else(String::new, |context| format!(" in {context}")),
879                    describe(token)
880                ),
881                Some(token.span()),
882            ));
883        }
884        Ok(self.advance())
885    }
886
887    #[must_use]
888    pub fn at_end(&self) -> bool {
889        self.peek().kind == TokenKind::Eof
890    }
891}
892
893/// `JSON.stringify(token.raw)`, or `end of input`.
894#[must_use]
895pub fn describe(token: &Token) -> String {
896    if token.kind == TokenKind::Eof {
897        "end of input".to_owned()
898    } else {
899        json_string(&token.raw)
900    }
901}
902
903/// `JSON.stringify` of a string.
904#[must_use]
905pub fn json_string(text: &str) -> String {
906    serde_json::to_string(text).unwrap_or_default()
907}