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