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}