Skip to main content

active_core/parser/
lexer.rs

1//! Complete lexer for the Active alpha.
2//!
3//! Handles:
4//! - Identifiers, keywords (let, true, false, null, env, secret)
5//! - Strings (double & single quoted) with escape sequences
6//! - Integers (with underscores) and floats
7//! - Unit suffixes immediately after numbers (5s, 512MiB, 100/req/s …)
8//! - Operators: =  .  ,  ?:  + - * /  == != < <= > >=  && ||  !
9//! - Delimiters: [ ] { } ( )
10//! - Newlines and full-line comments (# …)
11//! - Exact source spans on every token
12//!
13//! Healing: the lexer detects unterminated strings and records a repair
14//! diagnostic (H001) rather than rejecting the input. All other healing
15//! phases live in the parser (comma and bracket repairs) and in
16//! `crate::heal` (semantic and type healing).
17
18use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
19
20/// Internal sentinel for an escaped dollar (`\$`) in a double-quoted string.
21///
22/// The lexer decodes escapes, which would otherwise erase the difference
23/// between `\$` (a literal dollar) and `\\` followed by `$` (a backslash and
24/// an interpolation opener). The sentinel preserves that distinction to the
25/// parser. It is rendered back as `$` in every string value, and the formatter
26/// re-emits it as `\$`, so it never reaches user data.
27pub const ESCAPED_DOLLAR: char = '\u{E000}';
28
29/// Position-map mark for a character that came from a `\$` escape.
30///
31/// The mark rides in the per-character position vector alongside
32/// `ESCAPED_DOLLAR`. A genuine U+E000 in source keeps its real position,
33/// so the parser can tell `\$` (marked) from user data (unmarked) and a
34/// literal U+E000 is never rewritten to `$` (spec/grammar.md §1.4: string
35/// content is preserved; only listed escapes decode). Real positions always
36/// have small components, so this value can never collide with one.
37/// Consumers that do position arithmetic must substitute marked entries
38/// (see `pos_entry` in the parser) instead of adding them.
39pub const SENTINEL_POS: (u32, u32) = (u32::MAX, u32::MAX);
40
41/// Result of tokenizing: the token stream, hard lexical errors, healing
42/// diagnostics, and the comments found along the way (trivia for the
43/// formatter).
44pub struct Lexed {
45    pub tokens: Vec<Token>,
46    pub repairs: Vec<Diagnostic>,
47    pub errors: Vec<Diagnostic>,
48    pub comments: Vec<(String, Span)>,
49}
50
51/// Build the H001 repair for a string that reached EOF without its closing
52/// delimiter: insert the missing delimiter. Safe repair (confidence ≥ 0.95).
53fn unclosed_string_repair(line: u32, col: u32, delimiter: &str) -> Diagnostic {
54    Diagnostic {
55        severity: Severity::Info, // safe repair: ≥ AUTO_APPLY
56        code: "H001".into(),
57        message: format!(
58            "string literal reached end of input without its closing {}; inserted",
59            delimiter
60        ),
61        path: None,
62        span: Some(Span {
63            line,
64            column: col,
65            end_line: line,
66            end_column: col + 1,
67        }),
68        repair: Some(Repair {
69            original: String::new(),
70            suggested: delimiter.to_string(),
71            kind: RepairKind::InsertQuote,
72            safe: true,
73        }),
74        confidence: thresholds::AUTO_APPLY + 0.03,
75        // The parser stamps the session file (includes get their own).
76        file: None,
77    }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum TokenKind {
82    Eof,
83    Newline,
84    /// Opened an indented block (spec/dual-syntax.md §4).
85    Indent,
86    /// Closed an indented block.
87    Dedent,
88
89    // Literals & identifiers
90    Ident,
91    /// Double-quoted string: escapes processed, `${…}` interpolated.
92    /// Carries the source position (line offset, column) of every decoded
93    /// character, exactly like MlString: nested interpolation inside
94    /// sub-lexed expression text needs the same position fidelity.
95    String(Vec<(u32, u32)>),
96    /// Single-quoted string: escapes processed, `${…}` literal (spec §2.5).
97    SingleString,
98    /// Triple-double-quoted multi-line string: escapes processed, `${…}`
99    /// interpolated, common-indent stripping at parse time (spec §1.4.1).
100    /// Carries the source position (line offset, column) of every decoded
101    /// character: escapes decode to characters of different source width
102    /// (`\n` is two source columns becoming one decoded newline on the
103    /// same physical line), so only a per-character map keeps diagnostics
104    /// on their true positions.
105    MlString(Vec<(u32, u32)>),
106    /// Triple-single-quoted multi-line string: fully literal — backslashes
107    /// kept verbatim, `${…}` never interpolated (spec §1.4.1).
108    MlSingleString,
109    Integer,
110    Float,
111    True,
112    False,
113    Null,
114
115    // Keywords
116    Let,
117    Env,
118    Secret,
119
120    // Operators
121    Assign,        // =
122    Dot,           // .
123    Comma,         // ,
124    QuestionColon, // ?:
125    Plus,          // +
126    Minus,         // -
127    Star,          // *
128    Slash,         // /
129    EqEq,          // ==
130    NotEq,         // !=
131    Lt,            // <
132    LtEq,          // <=
133    Gt,            // >
134    GtEq,          // >=
135    AndAnd,        // &&
136    OrOr,          // ||
137    Bang,          // !
138
139    // Delimiters
140    LBracket, // [
141    RBracket, // ]
142    LBrace,   // {
143    RBrace,   // }
144    LParen,   // (
145    RParen,   // )
146    Colon,    // :
147    At,       // @ (semantic-block tags, spec §1.4.1)
148
149    // Unit suffix emitted right after a number when a known unit is present
150    Unit(String),
151}
152
153#[derive(Debug, Clone)]
154pub struct Token {
155    pub kind: TokenKind,
156    pub text: String,
157    pub line: u32,
158    pub col: u32,
159    pub end_line: u32,
160    pub end_col: u32,
161}
162
163#[derive(Debug, Clone, Copy, PartialEq, Eq)]
164pub enum LineClass {
165    /// Blank line, or a line whose first content is a comment: no tokens,
166    /// no indentation-stack change.
167    Blank,
168    /// A line carrying real content.
169    Content,
170}
171
172pub struct Lexer {
173    chars: Vec<char>,
174    pos: usize,
175    line: u32,
176    col: u32,
177    /// Stack of open `(`/`[`/`{` delimiters. Indentation is measured only
178    /// when this is empty (spec/dual-syntax.md §3): inside brackets, layout
179    /// is cosmetic. Only a *matching* closer pops, so malformed input cannot
180    /// change layout state.
181    open_delims: Vec<char>,
182    /// Indentation columns of the open blocks, initialised to `[0]`.
183    indent_stack: Vec<usize>,
184    /// True at the start of a physical line that still needs measurement.
185    at_line_start: bool,
186}
187
188impl Lexer {
189    pub fn new(src: &str) -> Self {
190        // A leading byte-order mark is not content (research HIST-YAML-021;
191        // JSON RFC policy: parsers may ignore it). Strip one U+FEFF so it
192        // never becomes an E005 token or shifts the first column.
193        let src = src.strip_prefix('\u{FEFF}').unwrap_or(src);
194        Self {
195            chars: src.chars().collect(),
196            pos: 0,
197            line: 1,
198            col: 1,
199            open_delims: Vec::new(),
200            indent_stack: vec![0],
201            at_line_start: true,
202        }
203    }
204
205    fn peek(&self) -> Option<char> {
206        self.chars.get(self.pos).copied()
207    }
208
209    fn peek_ahead(&self, n: usize) -> Option<char> {
210        self.chars.get(self.pos + n).copied()
211    }
212
213    fn bump(&mut self) -> Option<char> {
214        let c = self.peek()?;
215        self.pos += 1;
216        if c == '\n' {
217            self.line += 1;
218            self.col = 1;
219        } else {
220            self.col += 1;
221        }
222        Some(c)
223    }
224
225    fn make_token(&self, kind: TokenKind, text: String, start_line: u32, start_col: u32) -> Token {
226        Token {
227            kind,
228            text,
229            line: start_line,
230            col: start_col,
231            end_line: self.line,
232            end_col: self.col,
233        }
234    }
235
236    /// Lex a triple-double-quoted body after the opening `"""`. Processes
237    /// escapes exactly like `"…"` (including the `\$` sentinel) and leaves
238    /// `${…}` intact for the parser. Returns the decoded text, whether the
239    /// closing `"""` was found, and the source position (physical line
240    /// offset, column) of every decoded character for diagnostic mapping.
241    /// Newlines, brackets, braces, and `#` inside
242    /// the body are content — they never affect layout, delimiter, or
243    /// comment state, because the loop below consumes them directly.
244    fn lex_ml_string(&mut self, quote: char) -> (String, bool, Vec<(u32, u32)>) {
245        let mut s = String::new();
246        // Source position (physical line offset, column) of every decoded
247        // character, recorded *before* consuming: self.col is the column
248        // of the current peek character and advances with each bump().
249        let mut pos: Vec<(u32, u32)> = Vec::new();
250        let mut real_off: u32 = 0;
251        let mut escaped = false;
252        while let Some(ch) = self.peek() {
253            if escaped {
254                // The decoded character takes the backslash's position;
255                // bump() then advances past both source characters.
256                let at = (real_off, self.col);
257                match ch {
258                    'n' => {
259                        s.push('\n');
260                        pos.push(at);
261                    }
262                    '\n' => {
263                        // Backslash followed by a physical newline: an
264                        // unknown escape, so both characters are kept — and
265                        // the physical line still advances for mapping.
266                        s.push('\\');
267                        pos.push(at);
268                        s.push('\n');
269                        pos.push((real_off, self.col + 1));
270                        real_off += 1;
271                    }
272                    't' => {
273                        s.push('\t');
274                        pos.push(at);
275                    }
276                    'r' => {
277                        s.push('\r');
278                        pos.push(at);
279                    }
280                    '\\' => {
281                        s.push('\\');
282                        pos.push(at);
283                    }
284                    '"' => {
285                        s.push('"');
286                        pos.push(at);
287                    }
288                    '0' => {
289                        s.push('\0');
290                        pos.push(at);
291                    }
292                    '$' => {
293                        s.push(ESCAPED_DOLLAR);
294                        pos.push(SENTINEL_POS);
295                    }
296                    other => {
297                        s.push('\\');
298                        pos.push(at);
299                        s.push(other);
300                        pos.push((real_off, self.col + 1));
301                    }
302                }
303                escaped = false;
304                self.bump();
305            } else if ch == '\\' {
306                escaped = true;
307                self.bump();
308            } else if ch == quote
309                && self.peek_ahead(1) == Some(quote)
310                && self.peek_ahead(2) == Some(quote)
311            {
312                self.bump();
313                self.bump();
314                self.bump();
315                return (s, true, pos);
316            } else if ch == '\r' {
317                // Same CRLF normalization as single-line strings.
318                if self.peek_ahead(1) == Some('\n') {
319                    self.bump();
320                    continue;
321                }
322                pos.push((real_off, self.col));
323                s.push('\n');
324                self.bump();
325            } else {
326                if ch == '\n' {
327                    pos.push((real_off, self.col));
328                    real_off += 1;
329                } else {
330                    pos.push((real_off, self.col));
331                }
332                s.push(ch);
333                self.bump();
334            }
335        }
336        if escaped {
337            s.push('\\');
338            pos.push((real_off, self.col));
339        }
340        (s, false, pos)
341    }
342
343    /// Lex a triple-single-quoted body after the opening `'''`. Fully
344    /// literal: no escapes, no interpolation. Returns the raw text and
345    /// whether the closing `'''` was found.
346    fn lex_ml_literal(&mut self, quote: char) -> (String, bool) {
347        let mut s = String::new();
348        while let Some(ch) = self.peek() {
349            if ch == quote && self.peek_ahead(1) == Some(quote) && self.peek_ahead(2) == Some(quote)
350            {
351                self.bump();
352                self.bump();
353                self.bump();
354                return (s, true);
355            }
356            // Same CRLF normalization; fully literal otherwise.
357            if ch == '\r' {
358                if self.peek_ahead(1) == Some('\n') {
359                    self.bump();
360                    continue;
361                }
362                s.push('\n');
363                self.bump();
364                continue;
365            }
366            s.push(ch);
367            self.bump();
368        }
369        (s, false)
370    }
371
372    /// Measure one line's indentation and update the stack, emitting Indent
373    /// and Dedent tokens (spec/dual-syntax.md §4). Returns whether the line
374    /// carries content.
375    fn measure_indentation(
376        &mut self,
377        tokens: &mut Vec<Token>,
378        errors: &mut Vec<Diagnostic>,
379        repairs: &mut Vec<Diagnostic>,
380    ) -> LineClass {
381        let line = self.line;
382        let start_col = self.col;
383        let mut column: usize = 0;
384        let mut had_tab = false;
385        let mut tab_span: Option<(u32, u32)> = None;
386
387        while let Some(ch) = self.peek() {
388            match ch {
389                ' ' => {
390                    column += 1;
391                    self.bump();
392                }
393                '\t' => {
394                    // Each tab counts as one column; the first one is E006.
395                    if !had_tab {
396                        had_tab = true;
397                        tab_span = Some((self.line, self.col));
398                    }
399                    column += 1;
400                    self.bump();
401                }
402                _ => break,
403            }
404        }
405
406        if let Some((l, col)) = tab_span {
407            errors.push(Diagnostic {
408                severity: Severity::Error,
409                code: "E006".into(),
410                message: "tab character in leading whitespace; use spaces".into(),
411                path: None,
412                span: Some(Span {
413                    line: l,
414                    column: col,
415                    end_line: l,
416                    end_column: col + 1,
417                }),
418                repair: None,
419                confidence: 0.0,
420                // The parser stamps the session file (includes get theirs).
421                file: None,
422            });
423        }
424
425        // Blank line, or a line whose first content is a comment: no tokens
426        // and no stack change. (A comment-only line's comment is skipped by
427        // the main loop, which then sees the newline.)
428        match self.peek() {
429            None | Some('\n') | Some('#') => return LineClass::Blank,
430            _ => {}
431        }
432
433        let top = *self.indent_stack.last().unwrap_or(&0);
434        if column > top {
435            self.indent_stack.push(column);
436            tokens.push(self.make_token(TokenKind::Indent, column.to_string(), line, start_col));
437        } else if column < top {
438            while let Some(&level) = self.indent_stack.last() {
439                if level <= column {
440                    break;
441                }
442                self.indent_stack.pop();
443                let target = self.indent_stack.last().copied().unwrap_or(0);
444                tokens.push(self.make_token(
445                    TokenKind::Dedent,
446                    target.to_string(),
447                    line,
448                    start_col,
449                ));
450            }
451            if self.indent_stack.last().copied() != Some(column) {
452                // Unmatched dedentation: the stack has already popped to the
453                // nearest level below, and the column is not pushed.
454                errors.push(Diagnostic {
455                    severity: Severity::Error,
456                    code: "E007".into(),
457                    message: format!("inconsistent dedentation to column {column}"),
458                    path: None,
459                    span: Some(Span {
460                        line,
461                        column: start_col,
462                        end_line: line,
463                        end_column: start_col + column as u32,
464                    }),
465                    repair: None,
466                    confidence: 0.0,
467                    // The parser stamps the session file.
468                    file: None,
469                });
470            }
471        }
472
473        let _ = repairs; // lexical repairs are recorded elsewhere in the loop
474        LineClass::Content
475    }
476
477    pub fn tokenize(&mut self) -> Lexed {
478        let mut tokens = Vec::new();
479        let mut repairs: Vec<Diagnostic> = Vec::new();
480        let mut errors: Vec<Diagnostic> = Vec::new();
481        let mut comments: Vec<(String, Span)> = Vec::new();
482
483        loop {
484            // Indentation is measured at the start of each physical line, and
485            // only at bracket depth 0 (spec/dual-syntax.md §3).
486            if self.at_line_start {
487                self.at_line_start = false;
488                if self.open_delims.is_empty() {
489                    let class = self.measure_indentation(&mut tokens, &mut errors, &mut repairs);
490                    if class == LineClass::Blank {
491                        // Blank or comment-only line: the stack must not
492                        // change, and the next physical line is measured.
493                        self.at_line_start = true;
494                    }
495                }
496            }
497
498            let c = match self.peek() {
499                Some(c) => c,
500                None => break,
501            };
502
503            match c {
504                // Horizontal whitespace
505                ' ' | '\t' | '\r' => {
506                    self.bump();
507                }
508
509                // Newline
510                '\n' => {
511                    let (l, col) = (self.line, self.col);
512                    self.bump();
513                    tokens.push(self.make_token(TokenKind::Newline, "\n".into(), l, col));
514                    self.at_line_start = true;
515                }
516
517                // Full-line or trailing comment: retained as trivia with its
518                // span so the formatter can re-emit it (§7-§8). A
519                // trailing carriage return (CRLF documents) is not
520                // content: keeping it would poison annotation payloads
521                // (`true\r` is not `true`) and formatter output.
522                '#' => {
523                    let (l, col) = (self.line, self.col);
524                    let mut text = String::new();
525                    while let Some(ch) = self.peek() {
526                        if ch == '\n' {
527                            break;
528                        }
529                        text.push(ch);
530                        self.bump();
531                    }
532                    // Span end tracks the text: a stripped terminator
533                    // is one character column, ASCII by definition.
534                    let stripped = text.ends_with('\r');
535                    if stripped {
536                        text.pop();
537                    }
538                    comments.push((
539                        text,
540                        Span {
541                            line: l,
542                            column: col,
543                            end_line: self.line,
544                            end_column: if stripped {
545                                self.col.saturating_sub(1)
546                            } else {
547                                self.col
548                            },
549                        },
550                    ));
551                }
552
553                // Multi-character operators
554                '?' if self.peek_ahead(1) == Some(':') => {
555                    let (l, col) = (self.line, self.col);
556                    self.bump();
557                    self.bump();
558                    tokens.push(self.make_token(TokenKind::QuestionColon, "?:".into(), l, col));
559                }
560                '=' if self.peek_ahead(1) == Some('=') => {
561                    let (l, col) = (self.line, self.col);
562                    self.bump();
563                    self.bump();
564                    tokens.push(self.make_token(TokenKind::EqEq, "==".into(), l, col));
565                }
566                '!' if self.peek_ahead(1) == Some('=') => {
567                    let (l, col) = (self.line, self.col);
568                    self.bump();
569                    self.bump();
570                    tokens.push(self.make_token(TokenKind::NotEq, "!=".into(), l, col));
571                }
572                '<' if self.peek_ahead(1) == Some('=') => {
573                    let (l, col) = (self.line, self.col);
574                    self.bump();
575                    self.bump();
576                    tokens.push(self.make_token(TokenKind::LtEq, "<=".into(), l, col));
577                }
578                '>' if self.peek_ahead(1) == Some('=') => {
579                    let (l, col) = (self.line, self.col);
580                    self.bump();
581                    self.bump();
582                    tokens.push(self.make_token(TokenKind::GtEq, ">=".into(), l, col));
583                }
584                '&' if self.peek_ahead(1) == Some('&') => {
585                    let (l, col) = (self.line, self.col);
586                    self.bump();
587                    self.bump();
588                    tokens.push(self.make_token(TokenKind::AndAnd, "&&".into(), l, col));
589                }
590                '|' if self.peek_ahead(1) == Some('|') => {
591                    let (l, col) = (self.line, self.col);
592                    self.bump();
593                    self.bump();
594                    tokens.push(self.make_token(TokenKind::OrOr, "||".into(), l, col));
595                }
596
597                // Single-character operators & delimiters
598                '=' => {
599                    let (l, col) = (self.line, self.col);
600                    self.bump();
601                    tokens.push(self.make_token(TokenKind::Assign, "=".into(), l, col));
602                }
603                '.' => {
604                    let (l, col) = (self.line, self.col);
605                    self.bump();
606                    tokens.push(self.make_token(TokenKind::Dot, ".".into(), l, col));
607                }
608                ',' => {
609                    let (l, col) = (self.line, self.col);
610                    self.bump();
611                    tokens.push(self.make_token(TokenKind::Comma, ",".into(), l, col));
612                }
613                '+' => {
614                    let (l, col) = (self.line, self.col);
615                    self.bump();
616                    tokens.push(self.make_token(TokenKind::Plus, "+".into(), l, col));
617                }
618                '-' => {
619                    let (l, col) = (self.line, self.col);
620                    self.bump();
621                    tokens.push(self.make_token(TokenKind::Minus, "-".into(), l, col));
622                }
623                '*' => {
624                    let (l, col) = (self.line, self.col);
625                    self.bump();
626                    tokens.push(self.make_token(TokenKind::Star, "*".into(), l, col));
627                }
628                '/' => {
629                    let (l, col) = (self.line, self.col);
630                    self.bump();
631                    tokens.push(self.make_token(TokenKind::Slash, "/".into(), l, col));
632                }
633                '<' => {
634                    let (l, col) = (self.line, self.col);
635                    self.bump();
636                    tokens.push(self.make_token(TokenKind::Lt, "<".into(), l, col));
637                }
638                '>' => {
639                    let (l, col) = (self.line, self.col);
640                    self.bump();
641                    tokens.push(self.make_token(TokenKind::Gt, ">".into(), l, col));
642                }
643                '!' => {
644                    let (l, col) = (self.line, self.col);
645                    self.bump();
646                    tokens.push(self.make_token(TokenKind::Bang, "!".into(), l, col));
647                }
648                '[' => {
649                    let (l, col) = (self.line, self.col);
650                    self.bump();
651                    self.open_delims.push('[');
652                    tokens.push(self.make_token(TokenKind::LBracket, "[".into(), l, col));
653                }
654                ']' => {
655                    let (l, col) = (self.line, self.col);
656                    self.bump();
657                    if self.open_delims.last() == Some(&'[') {
658                        self.open_delims.pop();
659                    }
660                    tokens.push(self.make_token(TokenKind::RBracket, "]".into(), l, col));
661                }
662                '{' => {
663                    let (l, col) = (self.line, self.col);
664                    self.bump();
665                    self.open_delims.push('{');
666                    tokens.push(self.make_token(TokenKind::LBrace, "{".into(), l, col));
667                }
668                '}' => {
669                    let (l, col) = (self.line, self.col);
670                    self.bump();
671                    if self.open_delims.last() == Some(&'{') {
672                        self.open_delims.pop();
673                    }
674                    tokens.push(self.make_token(TokenKind::RBrace, "}".into(), l, col));
675                }
676                '(' => {
677                    let (l, col) = (self.line, self.col);
678                    self.bump();
679                    self.open_delims.push('(');
680                    tokens.push(self.make_token(TokenKind::LParen, "(".into(), l, col));
681                }
682                ')' => {
683                    let (l, col) = (self.line, self.col);
684                    self.bump();
685                    if self.open_delims.last() == Some(&'(') {
686                        self.open_delims.pop();
687                    }
688                    tokens.push(self.make_token(TokenKind::RParen, ")".into(), l, col));
689                }
690                ':' => {
691                    // A bare colon (block headers, `key: value`). The `?:`
692                    // combination is matched earlier as QuestionColon.
693                    let (l, col) = (self.line, self.col);
694                    self.bump();
695                    tokens.push(self.make_token(TokenKind::Colon, ":".into(), l, col));
696                }
697                '@' => {
698                    let (l, col) = (self.line, self.col);
699                    self.bump();
700                    tokens.push(self.make_token(TokenKind::At, "@".into(), l, col));
701                }
702
703                // Triple-quoted multi-line strings (spec §1.4.1). The
704                // triple-quote check must come before the single-quote
705                // arms: `"""` and `'''` open a multi-line string.
706                '"' if self.peek_ahead(1) == Some('"') && self.peek_ahead(2) == Some('"') => {
707                    let (l, col) = (self.line, self.col);
708                    self.bump();
709                    self.bump();
710                    self.bump(); // opening """
711                    let (raw, terminated, src_pos) = self.lex_ml_string('"');
712                    if !terminated {
713                        repairs.push(unclosed_string_repair(l, col, "\"\"\""));
714                    }
715                    tokens.push(self.make_token(TokenKind::MlString(src_pos), raw, l, col));
716                }
717                '\'' if self.peek_ahead(1) == Some('\'') && self.peek_ahead(2) == Some('\'') => {
718                    let (l, col) = (self.line, self.col);
719                    self.bump();
720                    self.bump();
721                    self.bump(); // opening '''
722                    let (raw, terminated) = self.lex_ml_literal('\'');
723                    if !terminated {
724                        repairs.push(unclosed_string_repair(l, col, "'''"));
725                    }
726                    tokens.push(self.make_token(TokenKind::MlSingleString, raw, l, col));
727                }
728
729                // Double-quoted strings (escapes + ${…} left intact for the parser)
730                '"' => {
731                    let (l, col) = (self.line, self.col);
732                    self.bump(); // opening "
733                    let mut s = String::new();
734                    // Per-character source positions (physical line offset,
735                    // column), recorded before consuming: self.col is the
736                    // column of the current peek character.
737                    let mut pos: Vec<(u32, u32)> = Vec::new();
738                    let mut real_off: u32 = 0;
739                    let mut escaped = false;
740                    let mut terminated = false;
741                    while let Some(ch) = self.peek() {
742                        if escaped {
743                            let at = (real_off, self.col);
744                            match ch {
745                                'n' => {
746                                    s.push('\n');
747                                    pos.push(at);
748                                }
749                                '\n' => {
750                                    // Backslash followed by a physical
751                                    // newline: an unknown escape, so both
752                                    // characters are kept — and the
753                                    // physical line still advances.
754                                    s.push('\\');
755                                    pos.push(at);
756                                    s.push('\n');
757                                    pos.push((real_off, self.col + 1));
758                                    real_off += 1;
759                                }
760                                't' => {
761                                    s.push('\t');
762                                    pos.push(at);
763                                }
764                                'r' => {
765                                    s.push('\r');
766                                    pos.push(at);
767                                }
768                                '\\' => {
769                                    s.push('\\');
770                                    pos.push(at);
771                                }
772                                '"' => {
773                                    s.push('"');
774                                    pos.push(at);
775                                }
776                                '0' => {
777                                    s.push('\0');
778                                    pos.push(at);
779                                }
780                                // `\$` keeps its own identity through the
781                                // escape table so the parser can tell it
782                                // apart from a real backslash before `${`.
783                                '$' => {
784                                    s.push(ESCAPED_DOLLAR);
785                                    pos.push(SENTINEL_POS);
786                                }
787                                other => {
788                                    s.push('\\');
789                                    pos.push(at);
790                                    s.push(other);
791                                    pos.push((real_off, self.col + 1));
792                                }
793                            }
794                            escaped = false;
795                            self.bump();
796                        } else if ch == '\\' {
797                            escaped = true;
798                            self.bump();
799                        } else if ch == '"' {
800                            self.bump();
801                            terminated = true;
802                            break;
803                        } else if ch == '\r' {
804                            // CRLF normalization (research HIST-YAML-014):
805                            // `\r\n` contributes one newline; a lone CR
806                            // becomes LF. Explicit `\r` escapes decode
807                            // elsewhere and never reach this branch.
808                            if self.peek_ahead(1) == Some('\n') {
809                                self.bump();
810                                continue;
811                            }
812                            pos.push((real_off, self.col));
813                            s.push('\n');
814                            self.bump();
815                        } else {
816                            if ch == '\n' {
817                                pos.push((real_off, self.col));
818                                real_off += 1;
819                            } else {
820                                pos.push((real_off, self.col));
821                            }
822                            s.push(ch);
823                            self.bump();
824                        }
825                    }
826                    if !terminated {
827                        // A dangling escape (string ended inside `\`) has
828                        // nothing to escape: preserve the backslash so the
829                        // content is not silently altered.
830                        if escaped {
831                            s.push('\\');
832                            pos.push((real_off, self.col));
833                        }
834                        // Healing phase 1: insert the missing closing quote.
835                        repairs.push(unclosed_string_repair(l, col, "\""));
836                    }
837                    tokens.push(self.make_token(TokenKind::String(pos), s, l, col));
838                }
839
840                // Single-quoted strings (no interpolation semantics)
841                '\'' => {
842                    let (l, col) = (self.line, self.col);
843                    self.bump();
844                    let mut s = String::new();
845                    let mut escaped = false;
846                    let mut terminated = false;
847                    while let Some(ch) = self.peek() {
848                        if escaped {
849                            match ch {
850                                '\\' => s.push('\\'),
851                                '\'' => s.push('\''),
852                                other => {
853                                    s.push('\\');
854                                    s.push(other);
855                                }
856                            }
857                            escaped = false;
858                            self.bump();
859                        } else if ch == '\\' {
860                            escaped = true;
861                            self.bump();
862                        } else if ch == '\'' {
863                            self.bump();
864                            terminated = true;
865                            break;
866                        } else if ch == '\r' {
867                            // Same CRLF normalization as double-quoted.
868                            if self.peek_ahead(1) == Some('\n') {
869                                self.bump();
870                                continue;
871                            }
872                            s.push('\n');
873                            self.bump();
874                        } else {
875                            s.push(ch);
876                            self.bump();
877                        }
878                    }
879                    if !terminated {
880                        // A dangling escape has nothing to escape; preserve
881                        // the backslash so the content is not altered.
882                        if escaped {
883                            s.push('\\');
884                        }
885                        // Healing phase 1: insert the missing closing quote.
886                        repairs.push(unclosed_string_repair(l, col, "'"));
887                    }
888                    tokens.push(self.make_token(TokenKind::SingleString, s, l, col));
889                }
890
891                // Numbers + optional unit
892                c if c.is_ascii_digit() => {
893                    let (l, col) = (self.line, self.col);
894                    let mut s = String::new();
895                    let mut is_float = false;
896
897                    while let Some(ch) = self.peek() {
898                        if ch.is_ascii_digit() || ch == '_' {
899                            s.push(ch);
900                            self.bump();
901                        } else if ch == '.' && !is_float {
902                            if let Some(next) = self.peek_ahead(1) {
903                                if next.is_ascii_digit() {
904                                    is_float = true;
905                                    s.push(ch);
906                                    self.bump();
907                                    continue;
908                                }
909                            }
910                            break;
911                        } else {
912                            break;
913                        }
914                    }
915
916                    let cleaned: String = s.chars().filter(|c| *c != '_').collect();
917                    let kind = if is_float {
918                        TokenKind::Float
919                    } else {
920                        TokenKind::Integer
921                    };
922                    tokens.push(self.make_token(kind, cleaned, l, col));
923
924                    // Unit must be attached with no intervening space
925                    if let Some(u) = self.try_consume_unit() {
926                        let (ul, uc) = (self.line, self.col);
927                        tokens.push(self.make_token(TokenKind::Unit(u.clone()), u, ul, uc));
928                    }
929                }
930
931                // Identifiers & keywords
932                c if c.is_ascii_alphabetic() || c == '_' => {
933                    let (l, col) = (self.line, self.col);
934                    let mut s = String::new();
935                    while let Some(ch) = self.peek() {
936                        if ch.is_ascii_alphanumeric() || ch == '_' {
937                            s.push(ch);
938                            self.bump();
939                        } else {
940                            break;
941                        }
942                    }
943                    let kind = match s.as_str() {
944                        "let" => TokenKind::Let,
945                        "true" => TokenKind::True,
946                        "false" => TokenKind::False,
947                        "null" => TokenKind::Null,
948                        "env" => TokenKind::Env,
949                        "secret" => TokenKind::Secret,
950                        _ => TokenKind::Ident,
951                    };
952                    tokens.push(self.make_token(kind, s, l, col));
953                }
954
955                // Unknown character — a hard error in every mode: no rule
956                // accepts it, and skipping it would silently change the
957                // configuration. Reported with a precise span.
958                _ => {
959                    let (l, col) = (self.line, self.col);
960                    self.bump();
961                    errors.push(Diagnostic {
962                        severity: Severity::Error,
963                        code: "E005".into(),
964                        message: format!("unexpected character {:?}", c),
965                        path: None,
966                        span: Some(Span {
967                            line: l,
968                            column: col,
969                            end_line: l,
970                            end_column: col + 1,
971                        }),
972                        repair: None,
973                        confidence: 0.0,
974                        // The parser stamps the session file.
975                        file: None,
976                    });
977                }
978            }
979        }
980
981        // End of input closes every open indentation block
982        // (spec/dual-syntax.md §4.2).
983        while self.indent_stack.len() > 1 {
984            self.indent_stack.pop();
985            let target = self.indent_stack.last().copied().unwrap_or(0);
986            tokens.push(self.make_token(
987                TokenKind::Dedent,
988                target.to_string(),
989                self.line,
990                self.col,
991            ));
992        }
993
994        tokens.push(self.make_token(TokenKind::Eof, String::new(), self.line, self.col));
995        Lexed {
996            tokens,
997            repairs,
998            errors,
999            comments,
1000        }
1001    }
1002
1003    /// Consume a known unit suffix if present.
1004    fn try_consume_unit(&mut self) -> Option<String> {
1005        let start_pos = self.pos;
1006        let start_line = self.line;
1007        let start_col = self.col;
1008
1009        let mut s = String::new();
1010        while let Some(ch) = self.peek() {
1011            // Unicode-aware so the documented `µs` microsecond suffix is
1012            // collectable (the whitelist carries it).
1013            if ch.is_alphanumeric() || ch == '/' || ch == '_' || ch == '%' {
1014                s.push(ch);
1015                self.bump();
1016            } else {
1017                break;
1018            }
1019        }
1020
1021        if s.is_empty() {
1022            return None;
1023        }
1024
1025        const KNOWN: &[&str] = &[
1026            "ns", "us", "µs", "ms", "s", "m", "h", "d", "B", "KiB", "MiB", "GiB", "TiB", "KB",
1027            "MB", "GB", "TB", "/s", "/m", "/h", "per_s", "per_m", "per_h", "req/s", "req_s",
1028            "/req/s", "/req/m", "/req/h", "req/m", "req_m", "req/h", "req_h", "%", "Hz", "kHz",
1029            "MHz", "GHz", "THz", "b", "Kb", "Mb", "Gb", "bps", "Kbps", "Mbps", "Gbps", "MB/s",
1030            "week", "sec", "min", "tokens", "ktokens", "Mtokens", "tok/s", "FLOPS", "TFLOPS",
1031            "PFLOPS", "Pa", "bar", "psi", "V", "A", "W", "J", "kWh", "N", "deg", "rad", "rpm",
1032            "meter", "km", "kg", "g", "PiB", "EiB", "K", "Cel", "Fahr", "dB",
1033        ];
1034
1035        if KNOWN.contains(&s.as_str()) {
1036            Some(s)
1037        } else {
1038            // rollback
1039            self.pos = start_pos;
1040            self.line = start_line;
1041            self.col = start_col;
1042            None
1043        }
1044    }
1045}