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        if value.chars().count() != 1 {
551            self.error("character literal must contain exactly one character", pos);
552        }
553        self.push(Tok::CharLit(value), pos, start);
554    }
555
556    fn number(&mut self, pos: Pos) {
557        let start = self.byte;
558        let mut text = String::new();
559        if self.peek() == Some('0') && matches!(self.peek_at(1), Some('x' | 'X')) {
560            text.push(self.bump().unwrap());
561            text.push(self.bump().unwrap());
562            let mut has_hex_digit = false;
563            while self
564                .peek()
565                .is_some_and(|c| c.is_ascii_hexdigit() || c == '_')
566            {
567                let c = self.bump().unwrap();
568                has_hex_digit |= c.is_ascii_hexdigit();
569                text.push(c);
570            }
571            if !has_hex_digit {
572                self.error("hex literal requires at least one digit", pos);
573            }
574            self.push(Tok::IntLit(text), pos, start);
575            return;
576        }
577        while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '_') {
578            text.push(self.bump().unwrap());
579        }
580        let mut decimal = false;
581        // `1.5` is a decimal but `1..5` or `1.foo` is not.
582        if self.peek() == Some('.') && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) {
583            decimal = true;
584            text.push(self.bump().unwrap());
585            while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '_') {
586                text.push(self.bump().unwrap());
587            }
588        }
589        if matches!(self.peek(), Some('e' | 'E')) {
590            decimal = true;
591            if self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
592                || (matches!(self.peek_at(1), Some('+' | '-'))
593                    && self.peek_at(2).is_some_and(|c| c.is_ascii_digit()))
594            {
595                text.push(self.bump().unwrap());
596                if matches!(self.peek(), Some('+' | '-')) {
597                    text.push(self.bump().unwrap());
598                }
599                while self.peek().is_some_and(|c| c.is_ascii_digit()) {
600                    text.push(self.bump().unwrap());
601                }
602            } else {
603                text.push(self.bump().unwrap());
604                if matches!(self.peek(), Some('+' | '-')) {
605                    text.push(self.bump().unwrap());
606                }
607                self.error("decimal exponent requires at least one digit", pos);
608            }
609        }
610        if decimal {
611            self.push(Tok::DecimalLit(text), pos, start);
612        } else {
613            self.push(Tok::IntLit(text), pos, start);
614        }
615    }
616
617    /// Identifiers, with greedy qualification: `DA.Set.fromList` is one
618    /// token (qualifier "DA.Set", name "fromList").
619    fn identifier(&mut self, pos: Pos) {
620        let start = self.byte;
621        let mut segments: Vec<String> = Vec::new();
622        loop {
623            let mut seg = String::new();
624            while self.peek().is_some_and(is_ident_char) {
625                seg.push(self.bump().unwrap());
626            }
627            let seg_is_upper = seg.chars().next().is_some_and(|c| c.is_uppercase());
628            segments.push(seg);
629            // Continue qualification only after an Upper segment: `Foo.bar`
630            // is qualified, `foo.bar` is composition/projection.
631            if seg_is_upper
632                && self.peek() == Some('.')
633                && self.peek_at(1).is_some_and(is_ident_start)
634            {
635                self.bump(); // .
636                continue;
637            }
638            break;
639        }
640        let name = segments.pop().unwrap();
641        let qualifier = if segments.is_empty() {
642            None
643        } else {
644            Some(segments.join("."))
645        };
646        let tok = if name.chars().next().is_some_and(|c| c.is_uppercase()) {
647            Tok::UpperId { qualifier, name }
648        } else {
649            Tok::LowerId { qualifier, name }
650        };
651        self.push(tok, pos, start);
652    }
653
654    fn operator(&mut self, pos: Pos) {
655        let start = self.i;
656        let byte_start = self.byte;
657        while self.peek().is_some_and(is_symbol_char) {
658            // `{-` inside an operator run can't happen ({ isn't a symbol
659            // char), but `--` comment detection needs the full run first.
660            self.bump();
661        }
662        let text: String = self.chars[start..self.i].iter().collect();
663        // A run of 2+ dashes and nothing else is a line comment (Haskell
664        // rule: `-->` is an operator, `--` and `---` start comments).
665        if text.len() >= 2 && text.chars().all(|c| c == '-') {
666            while self.peek().is_some_and(|c| c != '\n') {
667                self.bump();
668            }
669            self.push_trivia(TriviaKind::LineComment, pos, byte_start);
670            return;
671        }
672        self.push(Tok::Op(text), pos, byte_start);
673    }
674}
675
676const fn unescape(c: char) -> Option<char> {
677    match c {
678        'n' => Some('\n'),
679        't' => Some('\t'),
680        'r' => Some('\r'),
681        '0' => Some('\0'),
682        'a' => Some('\u{07}'),
683        'b' => Some('\u{08}'),
684        'f' => Some('\u{0c}'),
685        'v' => Some('\u{0b}'),
686        '"' => Some('"'),
687        '\'' => Some('\''),
688        '\\' => Some('\\'),
689        '&' => Some('&'),
690        // DAML follows Haskell-style text escapes, including numeric escapes
691        // (`\123`, `\o173`, `\x7B`) and named ASCII escapes (`\NUL`, `\SOH`,
692        // ...). This lexer preserves source spans rather than fully decoding
693        // multi-character escapes here, so accept the leading escape character
694        // and let the remaining source characters flow through unchanged.
695        '1'..='9' | 'o' | 'x' | 'A'..='Z' => Some(c),
696        _ => None,
697    }
698}
699
700impl Tok {
701    /// The identifier text if this is an unqualified lowercase identifier —
702    /// how the parser checks for (contextual) keywords.
703    pub const fn keyword(&self) -> Option<&str> {
704        match self {
705            Self::LowerId {
706                qualifier: None,
707                name,
708            } => Some(name.as_str()),
709            _ => None,
710        }
711    }
712
713    pub fn is_keyword(&self, kw: &str) -> bool {
714        self.keyword() == Some(kw)
715    }
716
717    pub fn is_op(&self, op: &str) -> bool {
718        matches!(self, Self::Op(o) if o == op)
719    }
720}
721
722#[cfg(test)]
723mod tests {
724    use super::*;
725
726    fn toks(src: &str) -> Vec<Tok> {
727        let (tokens, errors) = lex(src);
728        assert!(errors.is_empty(), "lex errors: {errors:?}");
729        tokens.into_iter().map(|t| t.tok).collect()
730    }
731
732    fn lex_error_messages(src: &str) -> Vec<String> {
733        let (_, errors) = lex(src);
734        errors.into_iter().map(|e| e.message).collect()
735    }
736
737    fn lower(name: &str) -> Tok {
738        Tok::LowerId {
739            qualifier: None,
740            name: name.to_string(),
741        }
742    }
743
744    fn upper(name: &str) -> Tok {
745        Tok::UpperId {
746            qualifier: None,
747            name: name.to_string(),
748        }
749    }
750
751    #[test]
752    fn line_comment_with_keywords_produces_no_tokens() {
753        assert_eq!(toks("-- electing to exercise the option"), vec![]);
754        assert_eq!(toks("--- template Foo"), vec![]);
755    }
756
757    #[test]
758    fn arrow_like_operator_is_not_comment() {
759        assert_eq!(
760            toks("a --> b"),
761            vec![lower("a"), Tok::Op("-->".into()), lower("b")]
762        );
763    }
764
765    #[test]
766    fn nested_block_comment() {
767        assert_eq!(toks("{- outer {- inner -} still -} x"), vec![lower("x")]);
768    }
769
770    #[test]
771    fn string_with_keyword_and_escapes() {
772        assert_eq!(
773            toks(r#""template \"Foo\" \n""#),
774            vec![Tok::StringLit("template \"Foo\" \n".into())]
775        );
776    }
777
778    #[test]
779    fn qualified_identifiers() {
780        assert_eq!(
781            toks("DA.Set.fromList Map.Map foo"),
782            vec![
783                Tok::LowerId {
784                    qualifier: Some("DA.Set".into()),
785                    name: "fromList".into()
786                },
787                Tok::UpperId {
788                    qualifier: Some("Map".into()),
789                    name: "Map".into()
790                },
791                lower("foo"),
792            ]
793        );
794    }
795
796    #[test]
797    fn numbers() {
798        assert_eq!(
799            toks("42 1.5 0x1F 2e3 1_000"),
800            vec![
801                Tok::IntLit("42".into()),
802                Tok::DecimalLit("1.5".into()),
803                Tok::IntLit("0x1F".into()),
804                Tok::DecimalLit("2e3".into()),
805                Tok::IntLit("1_000".into()),
806            ]
807        );
808    }
809
810    #[test]
811    fn malformed_hex_literal_reports_error() {
812        assert_eq!(
813            lex_error_messages("0x 0x_"),
814            vec![
815                "hex literal requires at least one digit",
816                "hex literal requires at least one digit",
817            ]
818        );
819    }
820
821    #[test]
822    fn malformed_decimal_exponent_reports_error() {
823        assert_eq!(
824            lex_error_messages("1e 1e+ 1e-"),
825            vec![
826                "decimal exponent requires at least one digit",
827                "decimal exponent requires at least one digit",
828                "decimal exponent requires at least one digit",
829            ]
830        );
831    }
832
833    #[test]
834    fn enum_from_to_is_not_decimal() {
835        assert_eq!(
836            toks("[1..5]"),
837            vec![
838                Tok::LBracket,
839                Tok::IntLit("1".into()),
840                Tok::Op("..".into()),
841                Tok::IntLit("5".into()),
842                Tok::RBracket,
843            ]
844        );
845    }
846
847    #[test]
848    fn primes_stay_in_identifier_and_char_lit_works() {
849        assert_eq!(
850            toks(r"foo' 'a' '\n'"),
851            vec![
852                lower("foo'"),
853                Tok::CharLit("a".into()),
854                Tok::CharLit("\n".into())
855            ]
856        );
857    }
858
859    #[test]
860    fn invalid_escape_sequences_report_errors() {
861        assert_eq!(
862            lex_error_messages(r#""\q" '\q'"#),
863            vec!["invalid escape sequence \\q", "invalid escape sequence \\q"]
864        );
865    }
866
867    #[test]
868    fn multi_character_char_literal_reports_error() {
869        let (tokens, errors) = lex("'ab'");
870        assert_eq!(
871            tokens.iter().map(|t| t.tok.clone()).collect::<Vec<_>>(),
872            vec![Tok::CharLit("ab".into())]
873        );
874        assert_eq!(
875            errors
876                .iter()
877                .map(|e| e.message.as_str())
878                .collect::<Vec<_>>(),
879            vec!["character literal must contain exactly one character"]
880        );
881    }
882
883    #[test]
884    fn operators_and_punctuation() {
885        assert_eq!(
886            toks("x <- f (y, z) `div` 2"),
887            vec![
888                lower("x"),
889                Tok::Op("<-".into()),
890                lower("f"),
891                Tok::LParen,
892                lower("y"),
893                Tok::Comma,
894                lower("z"),
895                Tok::RParen,
896                Tok::Backtick,
897                lower("div"),
898                Tok::Backtick,
899                Tok::IntLit("2".into()),
900            ]
901        );
902    }
903
904    #[test]
905    fn spans_are_one_based() {
906        let (tokens, _) = lex("ab\n  cd");
907        assert_eq!(tokens[0].pos, Pos { line: 1, column: 1 });
908        assert_eq!(tokens[1].pos, Pos { line: 2, column: 3 });
909    }
910
911    #[test]
912    fn tab_advances_to_stop() {
913        let (tokens, _) = lex("\tx");
914        assert_eq!(tokens[0].pos, Pos { line: 1, column: 9 });
915    }
916
917    #[test]
918    fn unterminated_string_is_error_not_hang() {
919        let (_, errors) = lex("x = \"oops\ny");
920        assert_eq!(errors.len(), 1);
921    }
922
923    #[test]
924    fn unterminated_block_comment_is_error_not_hang() {
925        let (_, errors) = lex("{- never closed");
926        assert_eq!(errors.len(), 1);
927    }
928
929    fn trivia_of(src: &str) -> Vec<Trivia> {
930        let (_, trivia, _) = lex_with_trivia(src);
931        trivia
932    }
933
934    /// The lossless oracle on one source: spans must tile the file and the
935    /// reconstruction must be byte-identical.
936    fn assert_round_trip(src: &str) {
937        let (tokens, trivia, errors) = lex_with_trivia(src);
938        assert!(errors.is_empty(), "lex errors: {errors:?}");
939        assert_eq!(
940            render_lossless(src, &tokens, &trivia).as_deref(),
941            Ok(src),
942            "round trip failed for {src:?}"
943        );
944    }
945
946    #[test]
947    fn line_comment_becomes_trivia_with_exact_text_and_span() {
948        let src = "x = 1 -- electing to exercise\ny = 2\n";
949        let trivia = trivia_of(src);
950        assert_eq!(trivia.len(), 1);
951        assert_eq!(trivia[0].kind, TriviaKind::LineComment);
952        assert_eq!(trivia[0].text, "-- electing to exercise");
953        assert_eq!(&src[trivia[0].start..trivia[0].end], trivia[0].text);
954        assert_eq!(trivia[0].pos, Pos { line: 1, column: 7 });
955    }
956
957    #[test]
958    fn nested_block_comment_becomes_one_trivia() {
959        let src = "{- outer {- inner -} still -} x";
960        let trivia = trivia_of(src);
961        assert_eq!(trivia.len(), 1);
962        assert_eq!(trivia[0].kind, TriviaKind::BlockComment);
963        assert_eq!(trivia[0].text, "{- outer {- inner -} still -}");
964    }
965
966    #[test]
967    fn unterminated_block_comment_still_yields_trivia_to_eof() {
968        let (_, trivia, errors) = lex_with_trivia("x {- never closed");
969        assert_eq!(errors.len(), 1);
970        assert_eq!(trivia.len(), 1);
971        assert_eq!(trivia[0].text, "{- never closed");
972    }
973
974    #[test]
975    fn blank_lines_between_items_counted() {
976        let src = "x = 1\n\n\ny = 2\n";
977        let trivia = trivia_of(src);
978        assert_eq!(trivia.len(), 1);
979        assert_eq!(trivia[0].kind, TriviaKind::BlankLines(2));
980        assert_eq!(trivia[0].pos, Pos { line: 2, column: 1 });
981    }
982
983    #[test]
984    fn blank_line_at_file_start_counted() {
985        let trivia = trivia_of("\nx = 1\n");
986        assert_eq!(trivia.len(), 1);
987        assert_eq!(trivia[0].kind, TriviaKind::BlankLines(1));
988        assert_eq!(trivia[0].pos, Pos { line: 1, column: 1 });
989    }
990
991    #[test]
992    fn blank_looking_lines_inside_block_comment_are_not_blank_trivia() {
993        let src = "x = 1 {- a\n\nb -}\ny = 2\n";
994        let trivia = trivia_of(src);
995        assert_eq!(trivia.len(), 1, "{trivia:?}");
996        assert_eq!(trivia[0].kind, TriviaKind::BlockComment);
997    }
998
999    #[test]
1000    fn blank_line_between_comments_counted() {
1001        let src = "-- a\n\n-- b\nx = 1\n";
1002        let kinds: Vec<_> = trivia_of(src).into_iter().map(|t| t.kind).collect();
1003        assert_eq!(
1004            kinds,
1005            vec![
1006                TriviaKind::LineComment,
1007                TriviaKind::BlankLines(1),
1008                TriviaKind::LineComment,
1009            ]
1010        );
1011    }
1012
1013    #[test]
1014    fn cpp_directive_becomes_trivia() {
1015        let src = "#ifdef DAML_BIGNUMERIC\nx = 1\n#endif\n";
1016        let trivia = trivia_of(src);
1017        assert_eq!(trivia.len(), 2);
1018        assert!(trivia.iter().all(|t| t.kind == TriviaKind::CppDirective));
1019        assert_eq!(trivia[0].text, "#ifdef DAML_BIGNUMERIC");
1020    }
1021
1022    #[test]
1023    fn round_trip_is_byte_identical() {
1024        assert_round_trip("module M where\n\n-- doc\nf : Int -> Int\nf x = x + 1\n");
1025        assert_round_trip("x = \"tem\\\"plate \\n\" {- block {- nested -} -}\r\ny = 'a'\r\n");
1026        assert_round_trip("\tärger = [1..5] -- ütf\n");
1027        assert_round_trip("s = \"gap \\  \\ here\"\n");
1028        assert_round_trip("#ifdef X\nf = 0x1F\n#endif\n");
1029        assert_round_trip("\n\n  \nf = 1  \n   ");
1030        assert_round_trip("");
1031    }
1032
1033    #[test]
1034    fn render_lossless_detects_lost_bytes() {
1035        let src = "x = 1 -- comment\n";
1036        let (tokens, mut trivia, _) = lex_with_trivia(src);
1037        trivia.clear(); // simulate a lexer that drops the comment
1038        assert!(render_lossless(src, &tokens, &trivia).is_err());
1039    }
1040
1041    #[test]
1042    fn unicode_identifier() {
1043        assert_eq!(
1044            toks("ärger = 1"),
1045            vec![lower("ärger"), Tok::Op("=".into()), Tok::IntLit("1".into())]
1046        );
1047        let _ = upper("Ülf"); // helper used
1048    }
1049}