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