Skip to main content

daml_parser/
lexer.rs

1//! DAML lexer: source text → tokens with spans.
2//!
3//! First stage of the real parser pipeline (lexer → layout → parse). Comments
4//! (line `--`, nested block `{- -}`) and string/char literals are resolved
5//! here, so no later stage can ever mistake `-- exercise the option` for a
6//! ledger action.
7
8/// 1-based source position of a token's first character.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct Pos {
11    pub line: usize,
12    pub column: usize,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum Tok {
17    /// Lowercase-initial identifier, possibly qualified: `foo`, `Map.lookup`.
18    LowerId {
19        qualifier: Option<String>,
20        name: String,
21    },
22    /// Uppercase-initial identifier, possibly qualified: `Foo`, `DA.Set.Set`.
23    UpperId {
24        qualifier: Option<String>,
25        name: String,
26    },
27    /// Symbolic operator: `+`, `<-`, `->`, `=`, `=>`, `::`, `.`, `\`, ...
28    Op(String),
29    IntLit(String),
30    DecimalLit(String),
31    StringLit(String),
32    CharLit(String),
33    LParen,
34    RParen,
35    LBracket,
36    RBracket,
37    LBrace,
38    RBrace,
39    Comma,
40    Semi,
41    Backtick,
42    /// Layout-inserted virtual open brace (block start).
43    VLBrace,
44    /// Layout-inserted virtual close brace (block end).
45    VRBrace,
46    /// Layout-inserted virtual semicolon (new item at block indentation).
47    VSemi,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct Token {
52    pub tok: Tok,
53    pub pos: Pos,
54    /// Byte offset of the token's first character in the source.
55    /// Virtual layout tokens are zero-width (`start == end`).
56    pub start: usize,
57    /// Byte offset one past the token's last character.
58    pub end: usize,
59}
60
61impl Token {
62    /// Layout-inserted tokens carry no source bytes (they are zero-width);
63    /// AST node-span computation skips them so spans tile the real source.
64    pub const fn is_virtual(&self) -> bool {
65        matches!(self.tok, Tok::VLBrace | Tok::VRBrace | Tok::VSemi)
66    }
67}
68
69/// Source text the lexer consumes but the parser never sees.
70///
71/// Carries exact byte spans so a printer can re-attach comments to nearby AST
72/// nodes (which already have positions) and reproduce the original bytes.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum TriviaKind {
75    /// `-- ...` to end of line (newline not included).
76    LineComment,
77    /// `{- ... -}`, possibly nested; unterminated runs to EOF.
78    BlockComment,
79    /// `#ifdef`/`#endif`/... preprocessor line at column 1.
80    CppDirective,
81    /// A run of N whitespace-only lines between tokens/comments.
82    BlankLines(usize),
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct Trivia {
87    pub kind: TriviaKind,
88    /// Exact source slice (delimiters included; empty for `BlankLines`).
89    pub text: String,
90    pub pos: Pos,
91    pub start: usize,
92    pub end: usize,
93}
94
95/// A lexical error. The scan must survive these: the caller reports the
96/// diagnostic and works with the tokens produced so far.
97#[derive(Debug, Clone)]
98pub struct LexError {
99    pub message: String,
100    pub pos: Pos,
101}
102
103const fn is_symbol_char(c: char) -> bool {
104    matches!(
105        c,
106        '!' | '#'
107            | '$'
108            | '%'
109            | '&'
110            | '*'
111            | '+'
112            | '.'
113            | '/'
114            | '<'
115            | '='
116            | '>'
117            | '?'
118            | '@'
119            | '\\'
120            | '^'
121            | '|'
122            | '-'
123            | '~'
124            | ':'
125    )
126}
127
128fn is_ident_start(c: char) -> bool {
129    c.is_alphabetic() || c == '_'
130}
131
132fn is_ident_char(c: char) -> bool {
133    c.is_alphanumeric() || c == '_' || c == '\''
134}
135
136pub(crate) const TAB_STOP: usize = 8;
137
138struct Lexer<'a> {
139    chars: Vec<char>,
140    src: &'a str,
141    i: usize,
142    byte: usize,
143    line: usize,
144    column: usize,
145    tokens: Vec<Token>,
146    trivia: Vec<Trivia>,
147    errors: Vec<LexError>,
148}
149
150pub fn lex(source: &str) -> (Vec<Token>, Vec<LexError>) {
151    let (tokens, _, errors) = lex_with_trivia(source);
152    (tokens, errors)
153}
154
155pub fn lex_with_trivia(source: &str) -> (Vec<Token>, Vec<Trivia>, Vec<LexError>) {
156    let mut lexer = Lexer {
157        chars: source.chars().collect(),
158        src: source,
159        i: 0,
160        byte: 0,
161        line: 1,
162        column: 1,
163        tokens: Vec::new(),
164        trivia: Vec::new(),
165        errors: Vec::new(),
166    };
167    lexer.scan_tokens();
168    let mut trivia = lexer.trivia;
169    add_blank_line_trivia(source, &lexer.tokens, &mut trivia);
170    trivia.sort_by_key(|t| t.start);
171    (lexer.tokens, trivia, lexer.errors)
172}
173
174/// Reconstruct the source from token and trivia spans.
175///
176/// `Ok` only when the spans tile the file — every non-whitespace byte inside
177/// exactly one token or comment span — in which case the result is
178/// byte-identical to `source`. This is the lossless-trivia oracle for the
179/// formatter.
180pub fn render_lossless(
181    source: &str,
182    tokens: &[Token],
183    trivia: &[Trivia],
184) -> Result<String, String> {
185    let mut items: Vec<(usize, usize)> = tokens
186        .iter()
187        .filter(|t| !matches!(t.tok, Tok::VLBrace | Tok::VRBrace | Tok::VSemi))
188        .map(|t| (t.start, t.end))
189        .chain(
190            trivia
191                .iter()
192                .filter(|t| !matches!(t.kind, TriviaKind::BlankLines(_)))
193                .map(|t| (t.start, t.end)),
194        )
195        .collect();
196    items.sort_unstable();
197    let mut out = String::with_capacity(source.len());
198    let mut prev = 0usize;
199    for (start, end) in items {
200        if start < prev {
201            return Err(format!("overlapping spans at byte {start}"));
202        }
203        let gap = &source[prev..start];
204        if !gap.chars().all(char::is_whitespace) {
205            return Err(format!(
206                "bytes {prev}..{start} lost (not covered by any token/trivia): {gap:?}"
207            ));
208        }
209        out.push_str(gap);
210        out.push_str(&source[start..end]);
211        prev = end;
212    }
213    let tail = &source[prev..];
214    if !tail.chars().all(char::is_whitespace) {
215        return Err(format!("bytes {prev}.. lost at EOF: {tail:?}"));
216    }
217    out.push_str(tail);
218    Ok(out)
219}
220
221/// A blank line is a whitespace-only line lying entirely between spans (so a
222/// blank-looking line inside a block comment or multiline string does not
223/// count). Emitted as one `BlankLines(n)` per maximal run so the printer can
224/// preserve paragraph breaks.
225fn add_blank_line_trivia(source: &str, tokens: &[Token], trivia: &mut Vec<Trivia>) {
226    let mut spans: Vec<(usize, usize)> = tokens
227        .iter()
228        .map(|t| (t.start, t.end))
229        .chain(trivia.iter().map(|t| (t.start, t.end)))
230        .collect();
231    spans.sort_unstable();
232    let bytes = source.as_bytes();
233    let mut blanks = Vec::new();
234    let mut gap_start = 0usize;
235    let emit_gap = |from: usize, to: usize, out: &mut Vec<Trivia>| {
236        // Newline offsets inside the gap. Full lines between two newlines
237        // (or before the first newline when the gap starts the file) are
238        // blank by construction: the gap holds no token/comment bytes, and
239        // any stray non-whitespace there fails the lossless render anyway.
240        let newlines: Vec<usize> = (from..to).filter(|&i| bytes[i] == b'\n').collect();
241        // Interior gap: the partial line before the first newline belongs to
242        // the preceding token's line, so only lines between newlines count.
243        // A gap at byte 0 has no such partial line — line 1 itself is blank.
244        let count = if from == 0 {
245            newlines.len()
246        } else {
247            newlines.len().saturating_sub(1)
248        };
249        if count == 0 {
250            return;
251        }
252        let region_start = if from == 0 { 0 } else { newlines[0] + 1 };
253        let region_end = newlines[newlines.len() - 1] + 1;
254        let line = source[..region_start].matches('\n').count() + 1;
255        out.push(Trivia {
256            kind: TriviaKind::BlankLines(count),
257            text: String::new(),
258            pos: Pos { line, column: 1 },
259            start: region_start,
260            end: region_end,
261        });
262    };
263    for &(s, e) in &spans {
264        if s > gap_start {
265            emit_gap(gap_start, s, &mut blanks);
266        }
267        gap_start = gap_start.max(e);
268    }
269    if source.len() > gap_start {
270        emit_gap(gap_start, source.len(), &mut blanks);
271    }
272    trivia.extend(blanks);
273}
274
275impl<'a> Lexer<'a> {
276    fn peek(&self) -> Option<char> {
277        self.chars.get(self.i).copied()
278    }
279
280    fn peek_at(&self, n: usize) -> Option<char> {
281        self.chars.get(self.i + n).copied()
282    }
283
284    fn bump(&mut self) -> Option<char> {
285        let c = self.chars.get(self.i).copied()?;
286        self.i += 1;
287        self.byte += c.len_utf8();
288        match c {
289            '\n' => {
290                self.line += 1;
291                self.column = 1;
292            }
293            '\t' => {
294                // Tab advances to the next multiple-of-8 stop, matching GHC,
295                // so mixed tabs/spaces don't silently corrupt layout.
296                self.column = ((self.column - 1) / TAB_STOP + 1) * TAB_STOP + 1;
297            }
298            _ => self.column += 1,
299        }
300        Some(c)
301    }
302
303    const fn pos(&self) -> Pos {
304        Pos {
305            line: self.line,
306            column: self.column,
307        }
308    }
309
310    /// `start` is the token's first byte; its end is wherever the cursor is
311    /// now, so call this immediately after consuming the token.
312    fn push(&mut self, tok: Tok, pos: Pos, start: usize) {
313        self.tokens.push(Token {
314            tok,
315            pos,
316            start,
317            end: self.byte,
318        });
319    }
320
321    fn push_trivia(&mut self, kind: TriviaKind, pos: Pos, start: usize) {
322        self.trivia.push(Trivia {
323            kind,
324            text: self.src[start..self.byte].to_string(),
325            pos,
326            start,
327            end: self.byte,
328        });
329    }
330
331    fn error(&mut self, message: impl Into<String>, pos: Pos) {
332        self.errors.push(LexError {
333            message: message.into(),
334            pos,
335        });
336    }
337
338    fn scan_tokens(&mut self) {
339        while let Some(c) = self.peek() {
340            let pos = self.pos();
341            let start = self.byte;
342            match c {
343                ' ' | '\t' | '\n' | '\r' => {
344                    self.bump();
345                }
346                '(' => {
347                    self.bump();
348                    self.push(Tok::LParen, pos, start);
349                }
350                ')' => {
351                    self.bump();
352                    self.push(Tok::RParen, pos, start);
353                }
354                '[' => {
355                    self.bump();
356                    self.push(Tok::LBracket, pos, start);
357                }
358                ']' => {
359                    self.bump();
360                    self.push(Tok::RBracket, pos, start);
361                }
362                ',' => {
363                    self.bump();
364                    self.push(Tok::Comma, pos, start);
365                }
366                ';' => {
367                    self.bump();
368                    self.push(Tok::Semi, pos, start);
369                }
370                '`' => {
371                    self.bump();
372                    self.push(Tok::Backtick, pos, start);
373                }
374                '{' => {
375                    if self.peek_at(1) == Some('-') {
376                        self.block_comment(pos);
377                    } else {
378                        self.bump();
379                        self.push(Tok::LBrace, pos, start);
380                    }
381                }
382                '}' => {
383                    self.bump();
384                    self.push(Tok::RBrace, pos, start);
385                }
386                // CPP preprocessor directive (#ifdef/#endif/#include...) at
387                // column 1 — daml-prim/stdlib sources use {-# LANGUAGE CPP #-};
388                // directives are line-based, skip the whole line.
389                '#' if self.column == 1
390                    && self.peek_at(1).is_some_and(|c| c.is_ascii_lowercase()) =>
391                {
392                    while self.peek().is_some_and(|c| c != '\n') {
393                        self.bump();
394                    }
395                    self.push_trivia(TriviaKind::CppDirective, pos, start);
396                }
397                '"' => self.string_lit(pos),
398                '\'' => self.char_lit(pos),
399                c if c.is_ascii_digit() => self.number(pos),
400                c if is_ident_start(c) => self.identifier(pos),
401                c if is_symbol_char(c) => self.operator(pos),
402                _ => {
403                    self.bump();
404                    self.error(format!("unexpected character '{c}'"), pos);
405                }
406            }
407        }
408    }
409
410    /// `{- ... -}`, nested as in Haskell. Unterminated comment is an error
411    /// but consumes to EOF (no hang, no panic).
412    fn block_comment(&mut self, pos: Pos) {
413        let start = self.byte;
414        self.bump(); // {
415        self.bump(); // -
416        let mut depth = 1usize;
417        while depth > 0 {
418            match self.peek() {
419                None => {
420                    self.error("unterminated block comment", pos);
421                    self.push_trivia(TriviaKind::BlockComment, pos, start);
422                    return;
423                }
424                Some('{') if self.peek_at(1) == Some('-') => {
425                    self.bump();
426                    self.bump();
427                    depth += 1;
428                }
429                Some('-') if self.peek_at(1) == Some('}') => {
430                    self.bump();
431                    self.bump();
432                    depth -= 1;
433                }
434                Some(_) => {
435                    self.bump();
436                }
437            }
438        }
439        self.push_trivia(TriviaKind::BlockComment, pos, start);
440    }
441
442    fn string_lit(&mut self, pos: Pos) {
443        let start = self.byte;
444        self.bump(); // opening "
445        let mut value = String::new();
446        loop {
447            match self.peek() {
448                None | Some('\n') => {
449                    self.error("unterminated string literal", pos);
450                    break;
451                }
452                Some('"') => {
453                    self.bump();
454                    break;
455                }
456                Some('\\') => {
457                    let escape_pos = self.pos();
458                    self.bump();
459                    match self.peek() {
460                        // String gap: backslash, whitespace, backslash.
461                        Some(w) if w.is_whitespace() => {
462                            while self.peek().is_some_and(|c| c.is_whitespace()) {
463                                self.bump();
464                            }
465                            if self.peek() == Some('\\') {
466                                self.bump();
467                            } else {
468                                self.error("unterminated string gap", pos);
469                                break;
470                            }
471                        }
472                        Some(e) => {
473                            self.bump();
474                            match unescape(e) {
475                                Some(c) => value.push(c),
476                                None => {
477                                    self.error(
478                                        format!("invalid escape sequence \\{e}"),
479                                        escape_pos,
480                                    );
481                                    value.push(e);
482                                }
483                            }
484                        }
485                        None => {
486                            self.error("unterminated string literal", pos);
487                            break;
488                        }
489                    }
490                }
491                Some(c) => {
492                    self.bump();
493                    value.push(c);
494                }
495            }
496        }
497        self.push(Tok::StringLit(value), pos, start);
498    }
499
500    /// `'a'`, `'\n'`, `'\x41'`. A lone `'` that doesn't close within a few
501    /// chars is not a char literal (identifiers consume their own primes, so
502    /// this only triggers at expression positions).
503    fn char_lit(&mut self, pos: Pos) {
504        let start = self.byte;
505        // Lookahead: find closing quote within a short window.
506        let mut j = self.i + 1;
507        let mut escaped = false;
508        let mut ok = false;
509        let window_end = (self.i + 12).min(self.chars.len());
510        while j < window_end {
511            match self.chars[j] {
512                '\\' if !escaped => escaped = true,
513                '\'' if !escaped => {
514                    ok = j > self.i + 1;
515                    break;
516                }
517                '\n' => break,
518                _ => escaped = false,
519            }
520            j += 1;
521        }
522        if !ok {
523            self.bump();
524            self.error("stray single quote", pos);
525            return;
526        }
527        self.bump(); // opening '
528        let mut value = String::new();
529        while self.peek() != Some('\'') {
530            let c = self.bump().unwrap();
531            if c == '\\' {
532                let escape_pos = Pos {
533                    line: self.line,
534                    column: self.column.saturating_sub(1),
535                };
536                if let Some(e) = self.bump() {
537                    match unescape(e) {
538                        Some(c) => value.push(c),
539                        None => {
540                            self.error(format!("invalid escape sequence \\{e}"), escape_pos);
541                            value.push(e);
542                        }
543                    }
544                }
545            } else {
546                value.push(c);
547            }
548        }
549        self.bump(); // closing '
550        self.push(Tok::CharLit(value), pos, start);
551    }
552
553    fn number(&mut self, pos: Pos) {
554        let start = self.byte;
555        let mut text = String::new();
556        if self.peek() == Some('0') && matches!(self.peek_at(1), Some('x' | 'X')) {
557            text.push(self.bump().unwrap());
558            text.push(self.bump().unwrap());
559            let mut has_hex_digit = false;
560            while self
561                .peek()
562                .is_some_and(|c| c.is_ascii_hexdigit() || c == '_')
563            {
564                let c = self.bump().unwrap();
565                has_hex_digit |= c.is_ascii_hexdigit();
566                text.push(c);
567            }
568            if !has_hex_digit {
569                self.error("hex literal requires at least one digit", pos);
570            }
571            self.push(Tok::IntLit(text), pos, start);
572            return;
573        }
574        while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '_') {
575            text.push(self.bump().unwrap());
576        }
577        let mut decimal = false;
578        // `1.5` is a decimal but `1..5` or `1.foo` is not.
579        if self.peek() == Some('.') && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) {
580            decimal = true;
581            text.push(self.bump().unwrap());
582            while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '_') {
583                text.push(self.bump().unwrap());
584            }
585        }
586        if matches!(self.peek(), Some('e' | 'E')) {
587            decimal = true;
588            if self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
589                || (matches!(self.peek_at(1), Some('+' | '-'))
590                    && self.peek_at(2).is_some_and(|c| c.is_ascii_digit()))
591            {
592                text.push(self.bump().unwrap());
593                if matches!(self.peek(), Some('+' | '-')) {
594                    text.push(self.bump().unwrap());
595                }
596                while self.peek().is_some_and(|c| c.is_ascii_digit()) {
597                    text.push(self.bump().unwrap());
598                }
599            } else {
600                text.push(self.bump().unwrap());
601                if matches!(self.peek(), Some('+' | '-')) {
602                    text.push(self.bump().unwrap());
603                }
604                self.error("decimal exponent requires at least one digit", pos);
605            }
606        }
607        if decimal {
608            self.push(Tok::DecimalLit(text), pos, start);
609        } else {
610            self.push(Tok::IntLit(text), pos, start);
611        }
612    }
613
614    /// Identifiers, with greedy qualification: `DA.Set.fromList` is one
615    /// token (qualifier "DA.Set", name "fromList").
616    fn identifier(&mut self, pos: Pos) {
617        let start = self.byte;
618        let mut segments: Vec<String> = Vec::new();
619        loop {
620            let mut seg = String::new();
621            while self.peek().is_some_and(is_ident_char) {
622                seg.push(self.bump().unwrap());
623            }
624            let seg_is_upper = seg.chars().next().is_some_and(|c| c.is_uppercase());
625            segments.push(seg);
626            // Continue qualification only after an Upper segment: `Foo.bar`
627            // is qualified, `foo.bar` is composition/projection.
628            if seg_is_upper
629                && self.peek() == Some('.')
630                && self.peek_at(1).is_some_and(is_ident_start)
631            {
632                self.bump(); // .
633                continue;
634            }
635            break;
636        }
637        let name = segments.pop().unwrap();
638        let qualifier = if segments.is_empty() {
639            None
640        } else {
641            Some(segments.join("."))
642        };
643        let tok = if name.chars().next().is_some_and(|c| c.is_uppercase()) {
644            Tok::UpperId { qualifier, name }
645        } else {
646            Tok::LowerId { qualifier, name }
647        };
648        self.push(tok, pos, start);
649    }
650
651    fn operator(&mut self, pos: Pos) {
652        let start = self.i;
653        let byte_start = self.byte;
654        while self.peek().is_some_and(is_symbol_char) {
655            // `{-` inside an operator run can't happen ({ isn't a symbol
656            // char), but `--` comment detection needs the full run first.
657            self.bump();
658        }
659        let text: String = self.chars[start..self.i].iter().collect();
660        // A run of 2+ dashes and nothing else is a line comment (Haskell
661        // rule: `-->` is an operator, `--` and `---` start comments).
662        if text.len() >= 2 && text.chars().all(|c| c == '-') {
663            while self.peek().is_some_and(|c| c != '\n') {
664                self.bump();
665            }
666            self.push_trivia(TriviaKind::LineComment, pos, byte_start);
667            return;
668        }
669        self.push(Tok::Op(text), pos, byte_start);
670    }
671}
672
673const fn unescape(c: char) -> Option<char> {
674    match c {
675        'n' => Some('\n'),
676        't' => Some('\t'),
677        'r' => Some('\r'),
678        '0' => Some('\0'),
679        'a' => Some('\u{07}'),
680        'b' => Some('\u{08}'),
681        'f' => Some('\u{0c}'),
682        'v' => Some('\u{0b}'),
683        '"' => Some('"'),
684        '\'' => Some('\''),
685        '\\' => Some('\\'),
686        '&' => Some('&'),
687        // DAML follows Haskell-style text escapes, including numeric escapes
688        // (`\123`, `\o173`, `\x7B`) and named ASCII escapes (`\NUL`, `\SOH`,
689        // ...). This lexer preserves source spans rather than fully decoding
690        // multi-character escapes here, so accept the leading escape character
691        // and let the remaining source characters flow through unchanged.
692        '1'..='9' | 'o' | 'x' | 'A'..='Z' => Some(c),
693        _ => None,
694    }
695}
696
697impl Tok {
698    /// The identifier text if this is an unqualified lowercase identifier —
699    /// how the parser checks for (contextual) keywords.
700    pub const fn keyword(&self) -> Option<&str> {
701        match self {
702            Self::LowerId {
703                qualifier: None,
704                name,
705            } => Some(name.as_str()),
706            _ => None,
707        }
708    }
709
710    pub fn is_keyword(&self, kw: &str) -> bool {
711        self.keyword() == Some(kw)
712    }
713
714    pub fn is_op(&self, op: &str) -> bool {
715        matches!(self, Self::Op(o) if o == op)
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    fn toks(src: &str) -> Vec<Tok> {
724        let (tokens, errors) = lex(src);
725        assert!(errors.is_empty(), "lex errors: {errors:?}");
726        tokens.into_iter().map(|t| t.tok).collect()
727    }
728
729    fn lex_error_messages(src: &str) -> Vec<String> {
730        let (_, errors) = lex(src);
731        errors.into_iter().map(|e| e.message).collect()
732    }
733
734    fn lower(name: &str) -> Tok {
735        Tok::LowerId {
736            qualifier: None,
737            name: name.to_string(),
738        }
739    }
740
741    fn upper(name: &str) -> Tok {
742        Tok::UpperId {
743            qualifier: None,
744            name: name.to_string(),
745        }
746    }
747
748    #[test]
749    fn line_comment_with_keywords_produces_no_tokens() {
750        assert_eq!(toks("-- electing to exercise the option"), vec![]);
751        assert_eq!(toks("--- template Foo"), vec![]);
752    }
753
754    #[test]
755    fn arrow_like_operator_is_not_comment() {
756        assert_eq!(
757            toks("a --> b"),
758            vec![lower("a"), Tok::Op("-->".into()), lower("b")]
759        );
760    }
761
762    #[test]
763    fn nested_block_comment() {
764        assert_eq!(toks("{- outer {- inner -} still -} x"), vec![lower("x")]);
765    }
766
767    #[test]
768    fn string_with_keyword_and_escapes() {
769        assert_eq!(
770            toks(r#""template \"Foo\" \n""#),
771            vec![Tok::StringLit("template \"Foo\" \n".into())]
772        );
773    }
774
775    #[test]
776    fn qualified_identifiers() {
777        assert_eq!(
778            toks("DA.Set.fromList Map.Map foo"),
779            vec![
780                Tok::LowerId {
781                    qualifier: Some("DA.Set".into()),
782                    name: "fromList".into()
783                },
784                Tok::UpperId {
785                    qualifier: Some("Map".into()),
786                    name: "Map".into()
787                },
788                lower("foo"),
789            ]
790        );
791    }
792
793    #[test]
794    fn numbers() {
795        assert_eq!(
796            toks("42 1.5 0x1F 2e3 1_000"),
797            vec![
798                Tok::IntLit("42".into()),
799                Tok::DecimalLit("1.5".into()),
800                Tok::IntLit("0x1F".into()),
801                Tok::DecimalLit("2e3".into()),
802                Tok::IntLit("1_000".into()),
803            ]
804        );
805    }
806
807    #[test]
808    fn malformed_hex_literal_reports_error() {
809        assert_eq!(
810            lex_error_messages("0x 0x_"),
811            vec![
812                "hex literal requires at least one digit",
813                "hex literal requires at least one digit",
814            ]
815        );
816    }
817
818    #[test]
819    fn malformed_decimal_exponent_reports_error() {
820        assert_eq!(
821            lex_error_messages("1e 1e+ 1e-"),
822            vec![
823                "decimal exponent requires at least one digit",
824                "decimal exponent requires at least one digit",
825                "decimal exponent requires at least one digit",
826            ]
827        );
828    }
829
830    #[test]
831    fn enum_from_to_is_not_decimal() {
832        assert_eq!(
833            toks("[1..5]"),
834            vec![
835                Tok::LBracket,
836                Tok::IntLit("1".into()),
837                Tok::Op("..".into()),
838                Tok::IntLit("5".into()),
839                Tok::RBracket,
840            ]
841        );
842    }
843
844    #[test]
845    fn primes_stay_in_identifier_and_char_lit_works() {
846        assert_eq!(
847            toks(r"foo' 'a' '\n'"),
848            vec![
849                lower("foo'"),
850                Tok::CharLit("a".into()),
851                Tok::CharLit("\n".into())
852            ]
853        );
854    }
855
856    #[test]
857    fn invalid_escape_sequences_report_errors() {
858        assert_eq!(
859            lex_error_messages(r#""\q" '\q'"#),
860            vec!["invalid escape sequence \\q", "invalid escape sequence \\q"]
861        );
862    }
863
864    #[test]
865    fn operators_and_punctuation() {
866        assert_eq!(
867            toks("x <- f (y, z) `div` 2"),
868            vec![
869                lower("x"),
870                Tok::Op("<-".into()),
871                lower("f"),
872                Tok::LParen,
873                lower("y"),
874                Tok::Comma,
875                lower("z"),
876                Tok::RParen,
877                Tok::Backtick,
878                lower("div"),
879                Tok::Backtick,
880                Tok::IntLit("2".into()),
881            ]
882        );
883    }
884
885    #[test]
886    fn spans_are_one_based() {
887        let (tokens, _) = lex("ab\n  cd");
888        assert_eq!(tokens[0].pos, Pos { line: 1, column: 1 });
889        assert_eq!(tokens[1].pos, Pos { line: 2, column: 3 });
890    }
891
892    #[test]
893    fn tab_advances_to_stop() {
894        let (tokens, _) = lex("\tx");
895        assert_eq!(tokens[0].pos, Pos { line: 1, column: 9 });
896    }
897
898    #[test]
899    fn unterminated_string_is_error_not_hang() {
900        let (_, errors) = lex("x = \"oops\ny");
901        assert_eq!(errors.len(), 1);
902    }
903
904    #[test]
905    fn unterminated_block_comment_is_error_not_hang() {
906        let (_, errors) = lex("{- never closed");
907        assert_eq!(errors.len(), 1);
908    }
909
910    fn trivia_of(src: &str) -> Vec<Trivia> {
911        let (_, trivia, _) = lex_with_trivia(src);
912        trivia
913    }
914
915    /// The lossless oracle on one source: spans must tile the file and the
916    /// reconstruction must be byte-identical.
917    fn assert_round_trip(src: &str) {
918        let (tokens, trivia, errors) = lex_with_trivia(src);
919        assert!(errors.is_empty(), "lex errors: {errors:?}");
920        assert_eq!(
921            render_lossless(src, &tokens, &trivia).as_deref(),
922            Ok(src),
923            "round trip failed for {src:?}"
924        );
925    }
926
927    #[test]
928    fn line_comment_becomes_trivia_with_exact_text_and_span() {
929        let src = "x = 1 -- electing to exercise\ny = 2\n";
930        let trivia = trivia_of(src);
931        assert_eq!(trivia.len(), 1);
932        assert_eq!(trivia[0].kind, TriviaKind::LineComment);
933        assert_eq!(trivia[0].text, "-- electing to exercise");
934        assert_eq!(&src[trivia[0].start..trivia[0].end], trivia[0].text);
935        assert_eq!(trivia[0].pos, Pos { line: 1, column: 7 });
936    }
937
938    #[test]
939    fn nested_block_comment_becomes_one_trivia() {
940        let src = "{- outer {- inner -} still -} x";
941        let trivia = trivia_of(src);
942        assert_eq!(trivia.len(), 1);
943        assert_eq!(trivia[0].kind, TriviaKind::BlockComment);
944        assert_eq!(trivia[0].text, "{- outer {- inner -} still -}");
945    }
946
947    #[test]
948    fn unterminated_block_comment_still_yields_trivia_to_eof() {
949        let (_, trivia, errors) = lex_with_trivia("x {- never closed");
950        assert_eq!(errors.len(), 1);
951        assert_eq!(trivia.len(), 1);
952        assert_eq!(trivia[0].text, "{- never closed");
953    }
954
955    #[test]
956    fn blank_lines_between_items_counted() {
957        let src = "x = 1\n\n\ny = 2\n";
958        let trivia = trivia_of(src);
959        assert_eq!(trivia.len(), 1);
960        assert_eq!(trivia[0].kind, TriviaKind::BlankLines(2));
961        assert_eq!(trivia[0].pos, Pos { line: 2, column: 1 });
962    }
963
964    #[test]
965    fn blank_line_at_file_start_counted() {
966        let trivia = trivia_of("\nx = 1\n");
967        assert_eq!(trivia.len(), 1);
968        assert_eq!(trivia[0].kind, TriviaKind::BlankLines(1));
969        assert_eq!(trivia[0].pos, Pos { line: 1, column: 1 });
970    }
971
972    #[test]
973    fn blank_looking_lines_inside_block_comment_are_not_blank_trivia() {
974        let src = "x = 1 {- a\n\nb -}\ny = 2\n";
975        let trivia = trivia_of(src);
976        assert_eq!(trivia.len(), 1, "{trivia:?}");
977        assert_eq!(trivia[0].kind, TriviaKind::BlockComment);
978    }
979
980    #[test]
981    fn blank_line_between_comments_counted() {
982        let src = "-- a\n\n-- b\nx = 1\n";
983        let kinds: Vec<_> = trivia_of(src).into_iter().map(|t| t.kind).collect();
984        assert_eq!(
985            kinds,
986            vec![
987                TriviaKind::LineComment,
988                TriviaKind::BlankLines(1),
989                TriviaKind::LineComment,
990            ]
991        );
992    }
993
994    #[test]
995    fn cpp_directive_becomes_trivia() {
996        let src = "#ifdef DAML_BIGNUMERIC\nx = 1\n#endif\n";
997        let trivia = trivia_of(src);
998        assert_eq!(trivia.len(), 2);
999        assert!(trivia.iter().all(|t| t.kind == TriviaKind::CppDirective));
1000        assert_eq!(trivia[0].text, "#ifdef DAML_BIGNUMERIC");
1001    }
1002
1003    #[test]
1004    fn round_trip_is_byte_identical() {
1005        assert_round_trip("module M where\n\n-- doc\nf : Int -> Int\nf x = x + 1\n");
1006        assert_round_trip("x = \"tem\\\"plate \\n\" {- block {- nested -} -}\r\ny = 'a'\r\n");
1007        assert_round_trip("\tärger = [1..5] -- ütf\n");
1008        assert_round_trip("s = \"gap \\  \\ here\"\n");
1009        assert_round_trip("#ifdef X\nf = 0x1F\n#endif\n");
1010        assert_round_trip("\n\n  \nf = 1  \n   ");
1011        assert_round_trip("");
1012    }
1013
1014    #[test]
1015    fn render_lossless_detects_lost_bytes() {
1016        let src = "x = 1 -- comment\n";
1017        let (tokens, mut trivia, _) = lex_with_trivia(src);
1018        trivia.clear(); // simulate a lexer that drops the comment
1019        assert!(render_lossless(src, &tokens, &trivia).is_err());
1020    }
1021
1022    #[test]
1023    fn unicode_identifier() {
1024        assert_eq!(
1025            toks("ärger = 1"),
1026            vec![lower("ärger"), Tok::Op("=".into()), Tok::IntLit("1".into())]
1027        );
1028        let _ = upper("Ülf"); // helper used
1029    }
1030}