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                    self.bump();
458                    match self.peek() {
459                        // String gap: backslash, whitespace, backslash.
460                        Some(w) if w.is_whitespace() => {
461                            while self.peek().is_some_and(|c| c.is_whitespace()) {
462                                self.bump();
463                            }
464                            if self.peek() == Some('\\') {
465                                self.bump();
466                            } else {
467                                self.error("unterminated string gap", pos);
468                                break;
469                            }
470                        }
471                        Some(e) => {
472                            self.bump();
473                            value.push(unescape(e));
474                        }
475                        None => {
476                            self.error("unterminated string literal", pos);
477                            break;
478                        }
479                    }
480                }
481                Some(c) => {
482                    self.bump();
483                    value.push(c);
484                }
485            }
486        }
487        self.push(Tok::StringLit(value), pos, start);
488    }
489
490    /// `'a'`, `'\n'`, `'\x41'`. A lone `'` that doesn't close within a few
491    /// chars is not a char literal (identifiers consume their own primes, so
492    /// this only triggers at expression positions).
493    fn char_lit(&mut self, pos: Pos) {
494        let start = self.byte;
495        // Lookahead: find closing quote within a short window.
496        let mut j = self.i + 1;
497        let mut escaped = false;
498        let mut ok = false;
499        let window_end = (self.i + 12).min(self.chars.len());
500        while j < window_end {
501            match self.chars[j] {
502                '\\' if !escaped => escaped = true,
503                '\'' if !escaped => {
504                    ok = j > self.i + 1;
505                    break;
506                }
507                '\n' => break,
508                _ => escaped = false,
509            }
510            j += 1;
511        }
512        if !ok {
513            self.bump();
514            self.error("stray single quote", pos);
515            return;
516        }
517        self.bump(); // opening '
518        let mut value = String::new();
519        while self.peek() != Some('\'') {
520            let c = self.bump().unwrap();
521            if c == '\\' {
522                if let Some(e) = self.bump() {
523                    value.push(unescape(e));
524                }
525            } else {
526                value.push(c);
527            }
528        }
529        self.bump(); // closing '
530        self.push(Tok::CharLit(value), pos, start);
531    }
532
533    fn number(&mut self, pos: Pos) {
534        let start = self.byte;
535        let mut text = String::new();
536        if self.peek() == Some('0') && matches!(self.peek_at(1), Some('x' | 'X')) {
537            text.push(self.bump().unwrap());
538            text.push(self.bump().unwrap());
539            while self
540                .peek()
541                .is_some_and(|c| c.is_ascii_hexdigit() || c == '_')
542            {
543                text.push(self.bump().unwrap());
544            }
545            self.push(Tok::IntLit(text), pos, start);
546            return;
547        }
548        while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '_') {
549            text.push(self.bump().unwrap());
550        }
551        let mut decimal = false;
552        // `1.5` is a decimal but `1..5` or `1.foo` is not.
553        if self.peek() == Some('.') && self.peek_at(1).is_some_and(|c| c.is_ascii_digit()) {
554            decimal = true;
555            text.push(self.bump().unwrap());
556            while self.peek().is_some_and(|c| c.is_ascii_digit() || c == '_') {
557                text.push(self.bump().unwrap());
558            }
559        }
560        if matches!(self.peek(), Some('e' | 'E'))
561            && (self.peek_at(1).is_some_and(|c| c.is_ascii_digit())
562                || (matches!(self.peek_at(1), Some('+' | '-'))
563                    && self.peek_at(2).is_some_and(|c| c.is_ascii_digit())))
564        {
565            decimal = true;
566            text.push(self.bump().unwrap());
567            if matches!(self.peek(), Some('+' | '-')) {
568                text.push(self.bump().unwrap());
569            }
570            while self.peek().is_some_and(|c| c.is_ascii_digit()) {
571                text.push(self.bump().unwrap());
572            }
573        }
574        if decimal {
575            self.push(Tok::DecimalLit(text), pos, start);
576        } else {
577            self.push(Tok::IntLit(text), pos, start);
578        }
579    }
580
581    /// Identifiers, with greedy qualification: `DA.Set.fromList` is one
582    /// token (qualifier "DA.Set", name "fromList").
583    fn identifier(&mut self, pos: Pos) {
584        let start = self.byte;
585        let mut segments: Vec<String> = Vec::new();
586        loop {
587            let mut seg = String::new();
588            while self.peek().is_some_and(is_ident_char) {
589                seg.push(self.bump().unwrap());
590            }
591            let seg_is_upper = seg.chars().next().is_some_and(|c| c.is_uppercase());
592            segments.push(seg);
593            // Continue qualification only after an Upper segment: `Foo.bar`
594            // is qualified, `foo.bar` is composition/projection.
595            if seg_is_upper
596                && self.peek() == Some('.')
597                && self.peek_at(1).is_some_and(is_ident_start)
598            {
599                self.bump(); // .
600                continue;
601            }
602            break;
603        }
604        let name = segments.pop().unwrap();
605        let qualifier = if segments.is_empty() {
606            None
607        } else {
608            Some(segments.join("."))
609        };
610        let tok = if name.chars().next().is_some_and(|c| c.is_uppercase()) {
611            Tok::UpperId { qualifier, name }
612        } else {
613            Tok::LowerId { qualifier, name }
614        };
615        self.push(tok, pos, start);
616    }
617
618    fn operator(&mut self, pos: Pos) {
619        let start = self.i;
620        let byte_start = self.byte;
621        while self.peek().is_some_and(is_symbol_char) {
622            // `{-` inside an operator run can't happen ({ isn't a symbol
623            // char), but `--` comment detection needs the full run first.
624            // Symbol chars are all ASCII, so one char is one byte.
625            self.i += 1;
626            self.column += 1;
627            self.byte += 1;
628        }
629        let text: String = self.chars[start..self.i].iter().collect();
630        // A run of 2+ dashes and nothing else is a line comment (Haskell
631        // rule: `-->` is an operator, `--` and `---` start comments).
632        if text.len() >= 2 && text.chars().all(|c| c == '-') {
633            while self.peek().is_some_and(|c| c != '\n') {
634                self.bump();
635            }
636            self.push_trivia(TriviaKind::LineComment, pos, byte_start);
637            return;
638        }
639        self.push(Tok::Op(text), pos, byte_start);
640    }
641}
642
643const fn unescape(c: char) -> char {
644    match c {
645        'n' => '\n',
646        't' => '\t',
647        'r' => '\r',
648        '0' => '\0',
649        other => other,
650    }
651}
652
653impl Tok {
654    /// The identifier text if this is an unqualified lowercase identifier —
655    /// how the parser checks for (contextual) keywords.
656    pub const fn keyword(&self) -> Option<&str> {
657        match self {
658            Self::LowerId {
659                qualifier: None,
660                name,
661            } => Some(name.as_str()),
662            _ => None,
663        }
664    }
665
666    pub fn is_keyword(&self, kw: &str) -> bool {
667        self.keyword() == Some(kw)
668    }
669
670    pub fn is_op(&self, op: &str) -> bool {
671        matches!(self, Self::Op(o) if o == op)
672    }
673}
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    fn toks(src: &str) -> Vec<Tok> {
680        let (tokens, errors) = lex(src);
681        assert!(errors.is_empty(), "lex errors: {errors:?}");
682        tokens.into_iter().map(|t| t.tok).collect()
683    }
684
685    fn lower(name: &str) -> Tok {
686        Tok::LowerId {
687            qualifier: None,
688            name: name.to_string(),
689        }
690    }
691
692    fn upper(name: &str) -> Tok {
693        Tok::UpperId {
694            qualifier: None,
695            name: name.to_string(),
696        }
697    }
698
699    #[test]
700    fn line_comment_with_keywords_produces_no_tokens() {
701        assert_eq!(toks("-- electing to exercise the option"), vec![]);
702        assert_eq!(toks("--- template Foo"), vec![]);
703    }
704
705    #[test]
706    fn arrow_like_operator_is_not_comment() {
707        assert_eq!(
708            toks("a --> b"),
709            vec![lower("a"), Tok::Op("-->".into()), lower("b")]
710        );
711    }
712
713    #[test]
714    fn nested_block_comment() {
715        assert_eq!(toks("{- outer {- inner -} still -} x"), vec![lower("x")]);
716    }
717
718    #[test]
719    fn string_with_keyword_and_escapes() {
720        assert_eq!(
721            toks(r#""template \"Foo\" \n""#),
722            vec![Tok::StringLit("template \"Foo\" \n".into())]
723        );
724    }
725
726    #[test]
727    fn qualified_identifiers() {
728        assert_eq!(
729            toks("DA.Set.fromList Map.Map foo"),
730            vec![
731                Tok::LowerId {
732                    qualifier: Some("DA.Set".into()),
733                    name: "fromList".into()
734                },
735                Tok::UpperId {
736                    qualifier: Some("Map".into()),
737                    name: "Map".into()
738                },
739                lower("foo"),
740            ]
741        );
742    }
743
744    #[test]
745    fn numbers() {
746        assert_eq!(
747            toks("42 1.5 0x1F 2e3 1_000"),
748            vec![
749                Tok::IntLit("42".into()),
750                Tok::DecimalLit("1.5".into()),
751                Tok::IntLit("0x1F".into()),
752                Tok::DecimalLit("2e3".into()),
753                Tok::IntLit("1_000".into()),
754            ]
755        );
756    }
757
758    #[test]
759    fn enum_from_to_is_not_decimal() {
760        assert_eq!(
761            toks("[1..5]"),
762            vec![
763                Tok::LBracket,
764                Tok::IntLit("1".into()),
765                Tok::Op("..".into()),
766                Tok::IntLit("5".into()),
767                Tok::RBracket,
768            ]
769        );
770    }
771
772    #[test]
773    fn primes_stay_in_identifier_and_char_lit_works() {
774        assert_eq!(
775            toks(r"foo' 'a' '\n'"),
776            vec![
777                lower("foo'"),
778                Tok::CharLit("a".into()),
779                Tok::CharLit("\n".into())
780            ]
781        );
782    }
783
784    #[test]
785    fn operators_and_punctuation() {
786        assert_eq!(
787            toks("x <- f (y, z) `div` 2"),
788            vec![
789                lower("x"),
790                Tok::Op("<-".into()),
791                lower("f"),
792                Tok::LParen,
793                lower("y"),
794                Tok::Comma,
795                lower("z"),
796                Tok::RParen,
797                Tok::Backtick,
798                lower("div"),
799                Tok::Backtick,
800                Tok::IntLit("2".into()),
801            ]
802        );
803    }
804
805    #[test]
806    fn spans_are_one_based() {
807        let (tokens, _) = lex("ab\n  cd");
808        assert_eq!(tokens[0].pos, Pos { line: 1, column: 1 });
809        assert_eq!(tokens[1].pos, Pos { line: 2, column: 3 });
810    }
811
812    #[test]
813    fn tab_advances_to_stop() {
814        let (tokens, _) = lex("\tx");
815        assert_eq!(tokens[0].pos, Pos { line: 1, column: 9 });
816    }
817
818    #[test]
819    fn unterminated_string_is_error_not_hang() {
820        let (_, errors) = lex("x = \"oops\ny");
821        assert_eq!(errors.len(), 1);
822    }
823
824    #[test]
825    fn unterminated_block_comment_is_error_not_hang() {
826        let (_, errors) = lex("{- never closed");
827        assert_eq!(errors.len(), 1);
828    }
829
830    fn trivia_of(src: &str) -> Vec<Trivia> {
831        let (_, trivia, _) = lex_with_trivia(src);
832        trivia
833    }
834
835    /// The lossless oracle on one source: spans must tile the file and the
836    /// reconstruction must be byte-identical.
837    fn assert_round_trip(src: &str) {
838        let (tokens, trivia, errors) = lex_with_trivia(src);
839        assert!(errors.is_empty(), "lex errors: {errors:?}");
840        assert_eq!(
841            render_lossless(src, &tokens, &trivia).as_deref(),
842            Ok(src),
843            "round trip failed for {src:?}"
844        );
845    }
846
847    #[test]
848    fn line_comment_becomes_trivia_with_exact_text_and_span() {
849        let src = "x = 1 -- electing to exercise\ny = 2\n";
850        let trivia = trivia_of(src);
851        assert_eq!(trivia.len(), 1);
852        assert_eq!(trivia[0].kind, TriviaKind::LineComment);
853        assert_eq!(trivia[0].text, "-- electing to exercise");
854        assert_eq!(&src[trivia[0].start..trivia[0].end], trivia[0].text);
855        assert_eq!(trivia[0].pos, Pos { line: 1, column: 7 });
856    }
857
858    #[test]
859    fn nested_block_comment_becomes_one_trivia() {
860        let src = "{- outer {- inner -} still -} x";
861        let trivia = trivia_of(src);
862        assert_eq!(trivia.len(), 1);
863        assert_eq!(trivia[0].kind, TriviaKind::BlockComment);
864        assert_eq!(trivia[0].text, "{- outer {- inner -} still -}");
865    }
866
867    #[test]
868    fn unterminated_block_comment_still_yields_trivia_to_eof() {
869        let (_, trivia, errors) = lex_with_trivia("x {- never closed");
870        assert_eq!(errors.len(), 1);
871        assert_eq!(trivia.len(), 1);
872        assert_eq!(trivia[0].text, "{- never closed");
873    }
874
875    #[test]
876    fn blank_lines_between_items_counted() {
877        let src = "x = 1\n\n\ny = 2\n";
878        let trivia = trivia_of(src);
879        assert_eq!(trivia.len(), 1);
880        assert_eq!(trivia[0].kind, TriviaKind::BlankLines(2));
881        assert_eq!(trivia[0].pos, Pos { line: 2, column: 1 });
882    }
883
884    #[test]
885    fn blank_line_at_file_start_counted() {
886        let trivia = trivia_of("\nx = 1\n");
887        assert_eq!(trivia.len(), 1);
888        assert_eq!(trivia[0].kind, TriviaKind::BlankLines(1));
889        assert_eq!(trivia[0].pos, Pos { line: 1, column: 1 });
890    }
891
892    #[test]
893    fn blank_looking_lines_inside_block_comment_are_not_blank_trivia() {
894        let src = "x = 1 {- a\n\nb -}\ny = 2\n";
895        let trivia = trivia_of(src);
896        assert_eq!(trivia.len(), 1, "{trivia:?}");
897        assert_eq!(trivia[0].kind, TriviaKind::BlockComment);
898    }
899
900    #[test]
901    fn blank_line_between_comments_counted() {
902        let src = "-- a\n\n-- b\nx = 1\n";
903        let kinds: Vec<_> = trivia_of(src).into_iter().map(|t| t.kind).collect();
904        assert_eq!(
905            kinds,
906            vec![
907                TriviaKind::LineComment,
908                TriviaKind::BlankLines(1),
909                TriviaKind::LineComment,
910            ]
911        );
912    }
913
914    #[test]
915    fn cpp_directive_becomes_trivia() {
916        let src = "#ifdef DAML_BIGNUMERIC\nx = 1\n#endif\n";
917        let trivia = trivia_of(src);
918        assert_eq!(trivia.len(), 2);
919        assert!(trivia.iter().all(|t| t.kind == TriviaKind::CppDirective));
920        assert_eq!(trivia[0].text, "#ifdef DAML_BIGNUMERIC");
921    }
922
923    #[test]
924    fn round_trip_is_byte_identical() {
925        assert_round_trip("module M where\n\n-- doc\nf : Int -> Int\nf x = x + 1\n");
926        assert_round_trip("x = \"tem\\\"plate \\n\" {- block {- nested -} -}\r\ny = 'a'\r\n");
927        assert_round_trip("\tärger = [1..5] -- ütf\n");
928        assert_round_trip("s = \"gap \\  \\ here\"\n");
929        assert_round_trip("#ifdef X\nf = 0x1F\n#endif\n");
930        assert_round_trip("\n\n  \nf = 1  \n   ");
931        assert_round_trip("");
932    }
933
934    #[test]
935    fn render_lossless_detects_lost_bytes() {
936        let src = "x = 1 -- comment\n";
937        let (tokens, mut trivia, _) = lex_with_trivia(src);
938        trivia.clear(); // simulate a lexer that drops the comment
939        assert!(render_lossless(src, &tokens, &trivia).is_err());
940    }
941
942    #[test]
943    fn unicode_identifier() {
944        assert_eq!(
945            toks("ärger = 1"),
946            vec![lower("ärger"), Tok::Op("=".into()), Tok::IntLit("1".into())]
947        );
948        let _ = upper("Ülf"); // helper used
949    }
950}