Skip to main content

active_core/parser/
mod.rs

1//! Full recursive-descent parser for the Active alpha.
2//!
3//! Supported surface language:
4//! - `let name = expr`
5//! - `[table]` blocks with `key = expr`
6//! - top-level and nested `key = expr`
7//! - `env.NAME ?: default`
8//! - `secret("…")` / `secret(env.NAME)`
9//! - integers, floats, bools, null, strings (single & double quoted)
10//! - unit suffixes on numbers (`5s`, `512MiB`, `100/req/s` …) → Quantity nodes
11//! - full string interpolation `"…${ident}…"` (and nested simple expressions)
12//! - arrays `[ … ]`
13//! - inline tables `{ k = v, … }`
14//! - binary operators with correct precedence:
15//!   `||` `&&` `==` `!=` `<` `<=` `>` `>=` `+` `-` `*` `/`
16//! - unary `-` and `!`
17//! - parentheses for grouping
18//!
19//! After parsing, a resolution pass turns Ident nodes that match `let`
20//! bindings into Ref nodes and builds the dependency graph.
21
22mod lexer;
23
24use lexer::ESCAPED_DOLLAR;
25use lexer::SENTINEL_POS;
26pub use lexer::{Lexed, Lexer, Token, TokenKind};
27
28/// Extract the span of a token.
29fn span_of(tok: &Token) -> Span {
30    Span {
31        line: tok.line,
32        column: tok.col,
33        end_line: tok.end_line,
34        end_column: tok.end_col,
35    }
36}
37
38/// Parse an `@intent` payload as one string literal, mirroring the
39/// language's escape rules (double-quoted: `\n \t \r \\ \" \0 \$`;
40/// single-quoted: `\\ \'`; anything else kept verbatim). Unlike lexer
41/// strings there is no healing inside comments: an unterminated
42/// literal or trailing text is an error for E021.
43fn parse_annotation_string(payload: &str) -> Result<String, String> {
44    let mut chars = payload.chars().peekable();
45    let quote = chars.next().ok_or("expected a string literal")?;
46    if quote != '"' && quote != '\'' {
47        return Err("expected a string literal".to_string());
48    }
49    let mut out = String::new();
50    let mut escaped = false;
51    for c in chars.by_ref() {
52        if escaped {
53            match (quote, c) {
54                ('"', 'n') => out.push('\n'),
55                ('"', 't') => out.push('\t'),
56                ('"', 'r') => out.push('\r'),
57                ('"', '0') => out.push('\0'),
58                ('"', '$') => out.push('$'),
59                (_, '\\') => out.push('\\'),
60                ('"', '"') => out.push('"'),
61                ('\'', '\'') => out.push('\''),
62                _ => {
63                    out.push('\\');
64                    out.push(c);
65                }
66            }
67            escaped = false;
68        } else if c == '\\' {
69            escaped = true;
70        } else if c == quote {
71            // Closing quote: only trailing whitespace may follow.
72            for rest in chars.by_ref() {
73                if !rest.is_whitespace() {
74                    return Err("unexpected text after the string literal".to_string());
75                }
76            }
77            return Ok(out);
78        } else {
79            out.push(c);
80        }
81    }
82    Err("unterminated string literal".to_string())
83}
84
85/// Maximum expression nesting depth (parenthesized expressions, unary
86/// operator chains, nested arrays, nested inline tables). A hard limit
87/// that prevents stack exhaustion from pathological input — the
88/// parser-side half of the execution budget (see C-037).
89const MAX_EXPR_DEPTH: usize = 64;
90
91/// Maximum structural (table/block) nesting depth. Bounds parser recursion
92/// on pathological input; exceeding it is E011 (see C-037).
93const MAX_BLOCK_DEPTH: usize = 64;
94
95/// Maximum schema-type nesting depth (`Array<Array<…>>`,
96/// `Table { … Table { … } … }`). Bounds parser recursion on pathological
97/// input; exceeding it is E004 (see C-037).
98const MAX_SCHEMA_DEPTH: usize = 64;
99
100use crate::diag::{thresholds, Diagnostic, Repair, RepairKind, Severity, Span};
101use crate::ir::{NodeId, SchemaField, SemanticTag, *};
102use crate::types::{ActiveType, Unit};
103use indexmap::IndexMap;
104
105/// Host-provided include source resolver (F-016): maps a canonical path
106/// string to file content. Replaces ONLY the filesystem read; path
107/// canonicalization, parent-escape policy, and trust verification around
108/// the read are unchanged. `Err` surfaces exactly like a read failure.
109///
110/// The `arc_with_non_send_sync` lint is allowed here with justification:
111/// the parser is single-threaded by construction and the FFI contract
112/// confines modules to the calling thread, so this Arc is intra-thread
113/// shared ownership (cheap cloning into sub-parsers), never cross-thread
114/// sharing.
115#[allow(clippy::arc_with_non_send_sync)]
116pub type IncludeLoader = std::sync::Arc<dyn Fn(&str) -> Result<String, String>>;
117
118/// Options controlling parsing and healing behavior.
119#[derive(Clone)]
120pub struct ParseOptions {
121    /// When true, medium-confidence repairs (0.70–0.94) are applied and
122    /// reported as warnings. When false (strict, the default), they are
123    /// declined and the underlying syntax diagnostic stands.
124    pub resilient: bool,
125    /// When true, `include` statements are loaded from the filesystem
126    /// (spec §2.7). False by default: string-based parsing stays hermetic
127    /// (the fuzz harness depends on this). `parse_file` enables it.
128    pub enable_includes: bool,
129    /// When true (default), recorded includes are resolved and merged.
130    /// `active-fmt` disables it so formatting preserves include statements
131    /// alongside local keys instead of a composed tree.
132    pub resolve_includes: bool,
133    /// When true, include paths may be absolute or escape the including
134    /// directory via `..`. False by default (E015 otherwise).
135    pub allow_parent_escapes: bool,
136    /// Host-provided source resolver consulted INSTEAD of the filesystem
137    /// read when set (F-016). `None` preserves all legacy behavior.
138    pub include_loader: Option<IncludeLoader>,
139}
140
141impl Default for ParseOptions {
142    fn default() -> Self {
143        Self {
144            resilient: false,
145            enable_includes: false,
146            resolve_includes: true,
147            allow_parent_escapes: false,
148            include_loader: None,
149        }
150    }
151}
152
153impl ParseOptions {
154    /// Strict defaults (const-compatible for test constants).
155    pub const fn strict() -> Self {
156        Self {
157            resilient: false,
158            enable_includes: false,
159            resolve_includes: true,
160            allow_parent_escapes: false,
161            include_loader: None,
162        }
163    }
164
165    /// Resilient-mode defaults (const-compatible for test constants).
166    pub const fn resilient_mode() -> Self {
167        Self {
168            resilient: true,
169            enable_includes: false,
170            resolve_includes: true,
171            allow_parent_escapes: false,
172            include_loader: None,
173        }
174    }
175}
176
177pub fn parse(source: &str, name: &str) -> Result<Module, Vec<Diagnostic>> {
178    parse_with(source, name, ParseOptions::default())
179}
180
181pub fn parse_with(
182    source: &str,
183    name: &str,
184    options: ParseOptions,
185) -> Result<Module, Vec<Diagnostic>> {
186    let (module, errors) = parse_recovering(source, name, options);
187    if errors.is_empty() {
188        Ok(module)
189    } else {
190        Err(errors)
191    }
192}
193
194/// Parse buffer text as if it were the file at `path`: `include`
195/// statements resolve against the file's directory (spec §2.7), and
196/// diagnostics carry the file's identity. The language server's
197/// dirty-buffer path (C-043): unsaved edits analyze with the saved
198/// file's include context. Includes are enabled regardless of the
199/// passed options' `enable_includes` flag; every other option is
200/// honored as given.
201pub fn parse_text_with_file(
202    source: &str,
203    name: &str,
204    options: ParseOptions,
205    path: &std::path::Path,
206) -> Result<Module, Vec<Diagnostic>> {
207    let mut options = options;
208    options.enable_includes = true;
209    let (module, errors) = parse_recovering_impl(source, name, options, Some(path), None);
210    if errors.is_empty() {
211        Ok(module)
212    } else {
213        Err(errors)
214    }
215}
216
217/// Parse buffer text as above but always return the module, even
218/// when diagnostics were recorded. Tooling (editor diagnostics on
219/// broken files) needs the partial tree; crucially, the file context
220/// is preserved so `include` statements never fall back to resolving
221/// from the process working directory.
222pub fn parse_text_recovering_with_file(
223    source: &str,
224    name: &str,
225    options: ParseOptions,
226    path: &std::path::Path,
227) -> (Module, Vec<Diagnostic>) {
228    let mut options = options;
229    options.enable_includes = true;
230    parse_recovering_impl(source, name, options, Some(path), None)
231}
232
233/// Parse a file from the filesystem, resolving `include` statements
234/// against the file's directory (spec §2.7). Includes are enabled
235/// regardless of the passed options' `enable_includes` flag; every other
236/// option is honored as given.
237pub fn parse_file(
238    path: &std::path::Path,
239    options: ParseOptions,
240) -> Result<Module, Vec<Diagnostic>> {
241    let src = std::fs::read_to_string(path).map_err(|e| {
242        vec![Diagnostic {
243            severity: Severity::Error,
244            code: "E017".into(),
245            message: format!("cannot read file {}: {e}", path.display()),
246            path: None,
247            span: None,
248            repair: None,
249            confidence: 1.0,
250            file: Some(path.to_string_lossy().into_owned()),
251        }]
252    })?;
253    let mut options = options;
254    options.enable_includes = true;
255    let (module, errors) =
256        parse_recovering_impl(&src, &path.to_string_lossy(), options, Some(path), None);
257    if errors.is_empty() {
258        Ok(module)
259    } else {
260        Err(errors)
261    }
262}
263
264/// Parse a file with overlay enforcement (C-052): identical to
265/// `parse_file`, but every `include` must carry a valid sibling
266/// signature from `trusted` (E032 otherwise). Use with
267/// `--trust-keys`; without it, `parse_file` preserves legacy
268/// behavior exactly.
269pub fn parse_file_with_trust(
270    path: &std::path::Path,
271    options: ParseOptions,
272    trusted: &crate::sign::TrustedKeys,
273) -> Result<Module, Vec<Diagnostic>> {
274    let src = std::fs::read_to_string(path).map_err(|e| {
275        vec![Diagnostic {
276            severity: Severity::Error,
277            code: "E017".into(),
278            message: format!("cannot read file {}: {e}", path.display()),
279            path: None,
280            span: None,
281            repair: None,
282            confidence: 1.0,
283            file: Some(path.to_string_lossy().into_owned()),
284        }]
285    })?;
286    let mut options = options;
287    options.enable_includes = true;
288    let (module, errors) = parse_recovering_impl(
289        &src,
290        &path.to_string_lossy(),
291        options,
292        Some(path),
293        Some(trusted.clone()),
294    );
295    if errors.is_empty() {
296        Ok(module)
297    } else {
298        Err(errors)
299    }
300}
301
302/// Parse and return the module even when diagnostics were recorded.
303///
304/// Tooling (editor diagnostics on broken files) and recovery tests need the
305/// partial tree; `parse` stays the strict entry point that fails on any
306/// error. Trivia and spans remain accurate for the parts that parsed.
307pub fn parse_recovering(
308    source: &str,
309    name: &str,
310    options: ParseOptions,
311) -> (Module, Vec<Diagnostic>) {
312    parse_recovering_impl(source, name, options, None, None)
313}
314
315fn parse_recovering_impl(
316    source: &str,
317    name: &str,
318    options: ParseOptions,
319    root_file: Option<&std::path::Path>,
320    trust: Option<crate::sign::TrustedKeys>,
321) -> (Module, Vec<Diagnostic>) {
322    let mut lexer = Lexer::new(source);
323    let mut lexed = lexer.tokenize();
324    let n_comments = lexed.comments.len();
325    // Root comments stamp with the session file (None for string
326    // input): annotation block scans match on (line, file), so the
327    // stamp must equal `current_file_name()` during the root parse.
328    let root_stamp = root_file.map(|p| p.to_string_lossy().into_owned());
329    let mut parser = Parser {
330        tokens: lexed.tokens,
331        pos: 0,
332        module: Module::new(name),
333        errors: Vec::new(),
334        ident_scopes: Vec::new(),
335        depth: 0,
336        options,
337        block_depth: 0,
338        schema_depth: 0,
339        comments: lexed.comments,
340        comment_files: vec![root_stamp; n_comments],
341        statements: Vec::new(),
342        blocks: Vec::new(),
343        current_path: Vec::new(),
344        brace_tables: 0,
345        indent_tables: 0,
346        current_file: root_file.map(|p| p.to_path_buf()),
347        include_stack: Vec::new(),
348        in_include: false,
349        in_profile: false,
350        array_depth: 0,
351        binding_spliced: std::collections::HashSet::new(),
352        pending_profiles: Vec::new(),
353        trust,
354    };
355    // Lexer diagnostics carry no file; stamp the session file (includes
356    // attribute their own file when their sub-parse runs).
357    let lex_file = parser.current_file_name();
358    for d in lexed.errors.iter_mut().chain(lexed.repairs.iter_mut()) {
359        if d.file.is_none() {
360            d.file = lex_file.clone();
361        }
362    }
363    parser.module.healing.extend(lexed.repairs);
364    parser.errors.extend(lexed.errors);
365    parser.parse_document();
366    // Schema extraction before graph build: `schema` tables leave value
367    // fields for the schema tree (spec §2.10), so neither sibling
368    // resolution nor evaluation ever sees them as data.
369    parser.extract_schemas();
370    // Dependency metadata leaves value fields the same way (spec §2.11).
371    parser.extract_depends_on();
372    parser.attach_trivia();
373    parser.module.source_style = if parser.indent_tables > parser.brace_tables {
374        Style::Indent
375    } else {
376        Style::Brace
377    };
378    parser.build_graph();
379    (parser.module, parser.errors)
380}
381
382/// Position-vector read with sentinel substitution: marked entries inherit
383/// the nearest preceding real position, because marks must never reach
384/// line/column arithmetic. `fallback` covers missing entries, as before.
385fn pos_entry(pos: &[(u32, u32)], k: usize, fallback: (u32, u32)) -> (u32, u32) {
386    let mut j = k;
387    loop {
388        match pos.get(j) {
389            Some(&e) if e != SENTINEL_POS => return e,
390            Some(_) => {
391                if j == 0 {
392                    return fallback;
393                }
394                j -= 1;
395            }
396            None => return fallback,
397        }
398    }
399}
400
401/// Compute each comment's normative attachment (spec/dual-syntax.md §7.2)
402/// and store it in the module's trivia list.
403impl Parser {
404    fn attach_trivia(&mut self) {
405        let comments = std::mem::take(&mut self.comments);
406        // Kept aligned with `comments` (taken together): files stamp
407        // every comment so attachment matches (line, file).
408        let comment_files = std::mem::take(&mut self.comment_files);
409        let statements = std::mem::take(&mut self.statements);
410        let blocks = std::mem::take(&mut self.blocks);
411
412        let mut trivia = Vec::with_capacity(comments.len());
413        for ((text, span), file) in comments.into_iter().zip(comment_files) {
414            let attachment = self.attach_one(&span, &file, &statements, &blocks);
415            trivia.push(TriviaComment {
416                text,
417                span,
418                attachment,
419                file,
420            });
421        }
422        self.module.trivia = trivia;
423    }
424
425    fn attach_one(
426        &self,
427        span: &Span,
428        file: &Option<String>,
429        stmts: &[StmtInfo],
430        blocks: &[BlockInfo],
431    ) -> Attachment {
432        let line = span.line;
433        let level = span.column.saturating_sub(1) as usize;
434
435        // Rule 1: after code on the same line as any statement — either the
436        // line the statement begins on (a table header, `[app] # note`) or
437        // the line it ends on (a multi-line value). Same file only:
438        // line numbers repeat across includes.
439        if let Some(stmt) = stmts
440            .iter()
441            .filter(|s| &s.file == file && (s.start_line == line || s.end_line == line))
442            .max_by_key(|s| s.start_line)
443        {
444            return Attachment::Trailing(qualified(&stmt.path, &stmt.key));
445        }
446
447        // Determine the comment's block (spec §7.1): the innermost block
448        // that still owns the comment's line, preferring one whose body is
449        // at exactly the comment's own indentation level.
450        let owner = self.owning_block(line, level, file, stmts, blocks);
451        if let Some(b) = &owner {
452            // Rule 2: any statement (including a nested table header)
453            // follows within the same block.
454            if let Some(stmt) = stmts
455                .iter()
456                .filter(|s| &s.file == file && s.start_line > line && s.path == b.path)
457                .min_by_key(|s| s.start_line)
458            {
459                return Attachment::Leading(qualified(&stmt.path, &stmt.key));
460            }
461            // Rule 3: trailing comment of that block.
462            return Attachment::TrailingBlock(b.path.clone());
463        }
464
465        // Rule 2 at document level, then rule 4.
466        if let Some(stmt) = stmts
467            .iter()
468            .filter(|s| &s.file == file && s.start_line > line && s.path.is_empty())
469            .min_by_key(|s| s.start_line)
470        {
471            return Attachment::Leading(qualified(&stmt.path, &stmt.key));
472        }
473        Attachment::TrailingDocument
474    }
475
476    /// The innermost block owning `line` at indentation `level`, per §7.1.
477    fn owning_block(
478        &self,
479        line: u32,
480        level: usize,
481        file: &Option<String>,
482        stmts: &[StmtInfo],
483        blocks: &[BlockInfo],
484    ) -> Option<BlockInfo> {
485        // Score prefers an exact indentation-level match, then a deeper
486        // block, then a later start.
487        fn score(b: &BlockInfo, level: usize) -> (u8, usize, u32) {
488            (if b.level == level { 1 } else { 0 }, b.level, b.start_line)
489        }
490        let mut best: Option<&BlockInfo> = None;
491        for b in blocks {
492            if &b.file != file || b.start_line > line {
493                continue;
494            }
495            // The block owns lines up to the next statement at or above its
496            // own level *after this comment*.
497            let next_sibling = stmts
498                .iter()
499                .filter(|s| &s.file == file && s.start_line > line && s.level <= b.level)
500                .min_by_key(|s| s.start_line)
501                .map(|s| s.start_line)
502                .unwrap_or(u32::MAX);
503            if line >= next_sibling {
504                continue;
505            }
506            let better = match best {
507                None => true,
508                Some(prev) => score(b, level) >= score(prev, level),
509            };
510            if better {
511                best = Some(b);
512            }
513        }
514        best.cloned()
515    }
516}
517
518/// The fully qualified key of a statement (`table.key`, or just `key` at
519/// document level) — the identity comments attach to.
520fn qualified(path: &str, key: &str) -> String {
521    if path.is_empty() {
522        key.to_string()
523    } else {
524        format!("{path}.{key}")
525    }
526}
527
528/// Validate and split a patch path (`a.b[0].c`) into segments.
529/// Keys follow identifier syntax (ASCII letter or `_` first, then ASCII
530/// alphanumerics or `_`); an index suffix `[n]` may repeat (`a[0][1]`
531/// walks nested arrays). Empty paths/segments, malformed brackets, and
532/// non-integer indices are E019 at parse time; missing targets are W004
533/// at apply.
534pub fn validate_patch_path(path: &str) -> Result<Vec<PathSeg>, String> {
535    fn is_key(s: &str) -> bool {
536        let mut chars = s.chars();
537        match chars.next() {
538            Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
539            _ => return false,
540        }
541        chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
542    }
543    if path.is_empty() {
544        return Err("empty path".into());
545    }
546    let mut segs = Vec::new();
547    for part in path.split('.') {
548        if part.is_empty() {
549            return Err("empty segment".into());
550        }
551        // Split `key`, `key[0]`, `key[0][1]` (multi-index, applied left
552        // to right).
553        let mut rest = part;
554        let key_end = rest.find('[').unwrap_or(rest.len());
555        let key = &rest[..key_end];
556        if !is_key(key) {
557            return Err(format!("invalid key `{key}` in segment `{part}`"));
558        }
559        segs.push(PathSeg::Key(key.to_string()));
560        rest = &rest[key_end..];
561        while !rest.is_empty() {
562            if !rest.starts_with('[') {
563                return Err(format!("unexpected text in segment `{part}`"));
564            }
565            let close = rest
566                .find(']')
567                .ok_or_else(|| format!("unclosed bracket in segment `{part}`"))?;
568            let idx: usize = rest[1..close]
569                .parse()
570                .map_err(|_| format!("array index must be a non-negative integer in `{part}`"))?;
571            segs.push(PathSeg::Index(idx));
572            rest = &rest[close + 1..];
573        }
574    }
575    Ok(segs)
576}
577
578/// One segment of a patch path (spec §2.9): a table key, optionally
579/// followed by an array index (`servers[0]` lexes as Key+Index).
580#[derive(Debug, Clone, PartialEq, Eq)]
581pub enum PathSeg {
582    Key(String),
583    Index(usize),
584}
585struct Parser {
586    tokens: Vec<Token>,
587    pos: usize,
588    module: Module,
589    errors: Vec<Diagnostic>,
590    /// Stack of per-scope (owning key, Ident node id) pairs, used to resolve
591    /// sibling-key references (e.g. `can_scale = !is_prod` inside the same
592    /// table). The owning key prevents a key's own value from resolving to
593    /// itself.
594    ident_scopes: Vec<Vec<(String, NodeId)>>,
595    /// Current expression nesting depth, guarded by MAX_EXPR_DEPTH.
596    depth: usize,
597    /// Healing mode for this parse.
598    options: ParseOptions,
599    /// Current block nesting depth, guarded by MAX_BLOCK_DEPTH.
600    block_depth: usize,
601    /// Current schema-type nesting depth (`Array<…>` / `Table {…}`),
602    /// guarded by MAX_SCHEMA_DEPTH. Schema recursion bypasses both the
603    /// expression and block guards, so it needs its own counter.
604    schema_depth: usize,
605    /// Comments from the lexer, awaiting attachment computation.
606    comments: Vec<(String, Span)>,
607    /// Source file per comment, parallel to `comments`: `None` for the
608    /// root document, `Some` for included files (C-033). Lines repeat
609    /// across files, so annotation block scans must match on
610    /// (line, file) — never bare lines.
611    comment_files: Vec<Option<String>>,
612    /// Statement records used for comment attachment (spec §7).
613    statements: Vec<StmtInfo>,
614    /// Block (table) records used for comment attachment (spec §7).
615    blocks: Vec<BlockInfo>,
616    /// Enclosing table path while parsing.
617    current_path: Vec<String>,
618    /// Counts of brace-style and indentation-style table declarations.
619    brace_tables: usize,
620    indent_tables: usize,
621    /// File currently being parsed (None for string input). Diagnostics
622    /// record it; include paths resolve against its directory.
623    current_file: Option<std::path::PathBuf>,
624    /// Canonicalized files on the include stack (cycle detection, C-025).
625    include_stack: Vec<std::path::PathBuf>,
626    /// True while parsing an included file: `let` bindings use
627    /// first-wins (local) semantics instead of overwriting.
628    in_include: bool,
629    /// True while parsing a profile body: `schema` tables are E020
630    /// there (profiles merge values; the root schema validates).
631    in_profile: bool,
632    /// Array nesting depth: `schema` blocks are E020 inside array
633    /// elements (spec §2.10) — element types use `Array<T>`, so inline
634    /// metadata blocks would only evade schema extraction and leak
635    /// `"schema": null` into exports.
636    array_depth: usize,
637    /// Binding names contributed by includes (module-global). A local
638    /// `let` clears its mark, so later includes skip it (local-wins)
639    /// while earlier-only names cascade.
640    binding_spliced: std::collections::HashSet<String>,
641    /// Profile scopes awaiting the final resolution pass: their ident
642    /// nodes plus a copy of their fields. Resolved after the document
643    /// root completes so profiles see root keys (forward-safe).
644    pending_profiles: Vec<ProfileScope>,
645    /// Trusted overlay keys (C-052): when set, every `include` load
646    /// must carry a valid sibling signature from this set (E032
647    /// otherwise). `None` preserves all legacy behavior.
648    trust: Option<crate::sign::TrustedKeys>,
649}
650
651impl Parser {
652    /// The dotted path of the enclosing table (empty at document level).
653    fn path_str(&self) -> String {
654        self.current_path.join(".")
655    }
656
657    /// End line of the most recently consumed token.
658    fn last_end_line(&self) -> u32 {
659        self.tokens
660            .get(self.pos.saturating_sub(1))
661            .map(|t| t.end_line)
662            .unwrap_or(1)
663    }
664
665    /// Record a statement for comment attachment.
666    fn record_stmt(&mut self, key: &str, start_line: u32, end_line: u32, level: usize) {
667        self.statements.push(StmtInfo {
668            key: key.to_string(),
669            path: self.path_str(),
670            start_line,
671            end_line,
672            level,
673            file: self.current_file_name(),
674        });
675    }
676}
677
678/// A statement's source extent, for comment attachment.
679#[derive(Debug, Clone)]
680struct StmtInfo {
681    key: String,
682    path: String,
683    start_line: u32,
684    end_line: u32,
685    level: usize,
686    /// Source file (includes parse as their own documents, C-025):
687    /// line numbers repeat across files, so scans match (line, file).
688    file: Option<String>,
689}
690
691/// A table/block's source extent, for comment attachment.
692#[derive(Debug, Clone)]
693struct BlockInfo {
694    path: String,
695    level: usize,
696    start_line: u32,
697    /// Source file, like statements: block matching is (line, file).
698    file: Option<String>,
699}
700
701/// An `include` statement recorded while parsing a body, resolved and
702/// merged when the enclosing scope closes (spec §2.7).
703#[derive(Debug, Clone)]
704struct PendingInclude {
705    path: String,
706    alias: Option<String>,
707    private: bool,
708    tok: Token,
709    /// Length of `module.patches` when the statement was encountered:
710    /// included patches splice in at this position so application order
711    /// matches textual source order across files (spec §2.9).
712    patch_mark: usize,
713}
714
715/// What terminates a schema entry list (spec §2.10).
716#[derive(Debug, Clone, Copy, PartialEq, Eq)]
717enum SchemaEnd {
718    /// `}` closes an inline or brace-form schema table.
719    Brace,
720    /// A Dedent (or EOF) closes an indentation-form schema body.
721    Indent,
722}
723
724/// A profile scope stashed for the final resolution pass: ident nodes
725/// (with owning keys) plus a copy of the profile's fields.
726type ProfileScope = (Vec<(String, NodeId)>, indexmap::IndexMap<String, NodeId>);
727
728/// What terminates a statement body (spec/dual-syntax.md §5.1).
729#[derive(Debug, Clone, Copy, PartialEq, Eq)]
730enum BodyEnd {
731    /// Document root: Dedents are the EOF flush and carry no block meaning.
732    Document,
733    /// An indented block at indentation level `n`: ends at a Dedent whose
734    /// target level is below `n`, which it consumes.
735    Block(usize),
736    /// A brace block (`key { … }`): ends at and consumes its `}`.
737    Brace,
738    /// The body of a `[header]` written at column `n`: ends (without
739    /// consuming) when the next statement belongs to the enclosing scope.
740    HeaderBody(usize),
741}
742
743/// The indentation level an Indent/Dedent token refers to (its text).
744fn indent_level(tok: &Token) -> usize {
745    tok.text.parse().unwrap_or(0)
746}
747
748impl Parser {
749    // ------------------------------------------------------------------
750    // Low-level token helpers
751    // ------------------------------------------------------------------
752
753    fn peek(&self) -> &Token {
754        // The lexer always appends a trailing Eof token and `bump` never
755        // advances past it, so the fallback borrows from the token stream
756        // itself instead of a function-local temporary (fixes E0515).
757        self.tokens.get(self.pos).unwrap_or_else(|| {
758            self.tokens
759                .last()
760                .expect("token stream is never empty; the lexer appends Eof")
761        })
762    }
763
764    fn peek_kind(&self) -> &TokenKind {
765        &self.peek().kind
766    }
767
768    fn bump(&mut self) -> Token {
769        let t = self.peek().clone();
770        if self.pos < self.tokens.len() {
771            self.pos += 1;
772        }
773        t
774    }
775
776    fn expect(&mut self, kind: TokenKind) -> Option<Token> {
777        // Compare by discriminant so Unit(String) still matches Unit(_)
778        if std::mem::discriminant(self.peek_kind()) == std::mem::discriminant(&kind) {
779            Some(self.bump())
780        } else {
781            self.error(
782                "E001",
783                format!("expected {:?}, found {:?}", kind, self.peek_kind()),
784            );
785            None
786        }
787    }
788
789    fn error(&mut self, code: &str, message: impl Into<String>) {
790        let t = self.peek();
791        self.errors.push(Diagnostic {
792            severity: Severity::Error,
793            code: code.into(),
794            message: message.into(),
795            path: None,
796            span: Some(Span {
797                line: t.line,
798                column: t.col,
799                end_line: t.end_line,
800                end_column: t.end_col,
801            }),
802            repair: None,
803            confidence: 1.0,
804            file: self.current_file_name(),
805        });
806    }
807
808    fn skip_newlines(&mut self) {
809        while matches!(self.peek_kind(), TokenKind::Newline) {
810            self.bump();
811        }
812    }
813
814    /// Token kind `n` positions ahead (0 = current). Used for the two-token
815    /// lookahead that distinguishes `key =`, `key:`, and `key {`.
816    fn peek_kind_at(&self, n: usize) -> TokenKind {
817        self.peek_at(n)
818            .map(|t| t.kind.clone())
819            .unwrap_or(TokenKind::Eof)
820    }
821
822    fn peek_at(&self, n: usize) -> Option<Token> {
823        self.tokens.get(self.pos + n).cloned()
824    }
825
826    /// Parse statements into `fields` until the body terminator
827    /// (spec/dual-syntax.md §2, §5). Structural nesting is bounded by
828    /// MAX_BLOCK_DEPTH so pathological input cannot exhaust the stack.
829    /// `pending` collects `include` statements for this scope; the caller
830    /// splices them when the scope closes (spec §2.7).
831    fn parse_body(
832        &mut self,
833        fields: &mut indexmap::IndexMap<String, NodeId>,
834        end: BodyEnd,
835        pending: &mut Vec<PendingInclude>,
836    ) {
837        self.block_depth += 1;
838        if self.block_depth > MAX_BLOCK_DEPTH {
839            self.error(
840                "E011",
841                format!("maximum block nesting depth ({MAX_BLOCK_DEPTH}) exceeded"),
842            );
843            self.block_depth -= 1;
844            self.recover_block(end);
845            return;
846        }
847        self.parse_body_inner(fields, end, pending);
848        self.block_depth -= 1;
849    }
850
851    /// Skip tokens until the terminator of an over-deep body, so recovery
852    /// makes progress without recursing further. Terminator- and
853    /// level-aware: a Dedent that closes a deeper child must not stop the
854    /// skip early, or later statements would leak into an outer scope.
855    fn recover_block(&mut self, end: BodyEnd) {
856        let mut open_braces = 0usize;
857        loop {
858            match self.peek_kind() {
859                TokenKind::Eof => return,
860                TokenKind::LBrace => {
861                    open_braces += 1;
862                    self.bump();
863                }
864                TokenKind::RBrace => {
865                    if open_braces > 0 {
866                        open_braces -= 1;
867                        self.bump();
868                    } else if end == BodyEnd::Brace {
869                        self.bump();
870                        return;
871                    } else {
872                        self.bump();
873                    }
874                }
875                TokenKind::Dedent => {
876                    let tok = self.peek().clone();
877                    let target = indent_level(&tok);
878                    match end {
879                        BodyEnd::Block(level) | BodyEnd::HeaderBody(level) => {
880                            self.bump();
881                            if target < level {
882                                return;
883                            }
884                        }
885                        _ => {
886                            self.bump();
887                        }
888                    }
889                }
890                TokenKind::LBracket | TokenKind::Let if matches!(end, BodyEnd::HeaderBody(_)) => {
891                    return
892                }
893                _ => {
894                    self.bump();
895                }
896            }
897        }
898    }
899
900    fn parse_body_inner(
901        &mut self,
902        fields: &mut indexmap::IndexMap<String, NodeId>,
903        end: BodyEnd,
904        pending: &mut Vec<PendingInclude>,
905    ) {
906        loop {
907            self.skip_newlines();
908            match self.peek_kind() {
909                TokenKind::Eof => {
910                    if end == BodyEnd::Brace {
911                        self.error("E001", "expected `}`, found end of input");
912                    }
913                    return;
914                }
915                TokenKind::Dedent => {
916                    let tok = self.peek().clone();
917                    let target = indent_level(&tok);
918                    match end {
919                        // The EOF flush emits Dedent tokens; they carry no
920                        // block meaning at document level.
921                        BodyEnd::Document | BodyEnd::Brace => {
922                            self.bump();
923                        }
924                        BodyEnd::Block(level) => {
925                            self.bump();
926                            if target >= level {
927                                // Closes a nested block that was recovered
928                                // under E010 (§6), not this block.
929                                continue;
930                            }
931                            return;
932                        }
933                        BodyEnd::HeaderBody(level) => {
934                            if target >= level {
935                                self.bump();
936                                continue;
937                            }
938                            return;
939                        }
940                    }
941                }
942                TokenKind::RBrace => match end {
943                    BodyEnd::Brace => {
944                        self.bump();
945                        return;
946                    }
947                    // §5.3: a brace cannot close an indentation block.
948                    BodyEnd::Block(_) => {
949                        self.error("E001", "expected Dedent to close this block, found `}`");
950                        self.bump();
951                        return;
952                    }
953                    BodyEnd::HeaderBody(_) => return,
954                    BodyEnd::Document => {
955                        self.error("E002", "unexpected `}`");
956                        self.bump();
957                    }
958                },
959                // A header body ends (without consuming) when the next
960                // statement belongs to the enclosing scope: a new header,
961                // a `let`, or a document-level `profile`/`include`
962                // statement (spec §2.7, §2.8). Only column-0 (header-level)
963                // statements terminate: indented content stays nested, so
964                // a nested `profile` still earns E018 and a nested
965                // `include` merges into its true scope.
966                TokenKind::LBracket | TokenKind::Let if matches!(end, BodyEnd::HeaderBody(_)) => {
967                    return
968                }
969                TokenKind::Ident => {
970                    let t = self.peek().clone();
971                    if let BodyEnd::HeaderBody(level) = end {
972                        if (t.text == "profile" || t.text == "include")
973                            && t.col.saturating_sub(1) as usize <= level
974                            && !matches!(
975                                self.peek_kind_at(1),
976                                TokenKind::Assign | TokenKind::Colon | TokenKind::LBrace
977                            )
978                        {
979                            return;
980                        }
981                    }
982                    self.parse_ident_statement(fields, pending);
983                }
984                TokenKind::Let => self.parse_let(),
985                TokenKind::LBracket => self.parse_table_header(fields),
986                TokenKind::At => {
987                    // `@…` statements: patch directives (`@patch`, `@remove`,
988                    // `@rename` with `(`) or bare semantic blocks. Patches
989                    // live at document root only (spec §2.9). A
990                    // document-level patch directive ends a header body
991                    // (like profile/include/let); nested content stays in
992                    // its scope.
993                    if let BodyEnd::HeaderBody(level) = end {
994                        let is_patch = matches!(self.peek_kind_at(1), TokenKind::Ident) && {
995                            let tag = self.peek_at(1).map(|t| t.text.clone()).unwrap_or_default();
996                            (tag == "patch" || tag == "remove" || tag == "rename")
997                                && matches!(self.peek_kind_at(2), TokenKind::LParen)
998                        };
999                        let t = self.peek().clone();
1000                        if is_patch && t.col.saturating_sub(1) as usize <= level {
1001                            return;
1002                        }
1003                    }
1004                    let at_tok = self.peek().clone();
1005                    if matches!(self.peek_kind_at(1), TokenKind::Ident) {
1006                        let tag = self.peek_at(1).map(|t| t.text.clone()).unwrap_or_default();
1007                        if tag == "patch" || tag == "remove" || tag == "rename" {
1008                            // Reserved directives (spec §2.9): only `(` can
1009                            // follow. Keys cannot contain `@`, so no
1010                            // ordinary-key fallback exists — E019.
1011                            if !matches!(self.peek_kind_at(2), TokenKind::LParen) {
1012                                let tag_tok = self.peek_at(1).unwrap_or_else(|| at_tok.clone());
1013                                self.error_at(
1014                                    "E019",
1015                                    format!("expected `(` after `@{tag}`"),
1016                                    &tag_tok,
1017                                );
1018                                self.bump();
1019                                self.bump();
1020                                continue;
1021                            }
1022                            self.parse_patch_statement(&at_tok);
1023                            continue;
1024                        }
1025                    }
1026                    self.bump();
1027                    let id = self.parse_semantic_block(&at_tok);
1028                    if let Some(Node::Semantic { tag, .. }) = self.module.get(id) {
1029                        let key = tag.as_str().to_string();
1030                        self.record_stmt(
1031                            &key,
1032                            at_tok.line,
1033                            self.last_end_line(),
1034                            at_tok.col.saturating_sub(1) as usize,
1035                        );
1036                        fields.insert(key, id);
1037                    }
1038                }
1039                TokenKind::Indent => {
1040                    // E010 unexpected indentation (§6): consume the Indent,
1041                    // parse the line in the enclosing block, and let the
1042                    // level-aware Dedent rule above discard its matching
1043                    // Dedent (the enclosing block's level comparison).
1044                    self.error("E010", "unexpected indentation");
1045                    self.bump();
1046                }
1047                TokenKind::String(_)
1048                | TokenKind::SingleString
1049                | TokenKind::MlString(_)
1050                | TokenKind::MlSingleString => {
1051                    // Quoted keys are not part of the grammar (identifiers
1052                    // only): a string literal cannot start a statement.
1053                    // Skipping it would silently drop data (research
1054                    // HIST-YAML-020), so this is a loud E002 instead.
1055                    let tok = self.peek().clone();
1056                    self.error_at(
1057                        "E002",
1058                        "unexpected string literal at statement position (keys must be identifiers)",
1059                        &tok,
1060                    );
1061                    self.bump();
1062                }
1063                _ => {
1064                    // Alpha resilience: skip an unexpected token.
1065                    // NOTE (research P1-ST-002): stray value tokens after
1066                    // a complete statement are silently absorbed here by
1067                    // explicit design (spec/grammar.md §4.1 quirks:
1068                    // `x = 5 5` evaluates to `5`). Only string literals
1069                    // above are loud (no quirk protects them).
1070                    //
1071                    // Exception: a VALUE token starting a statement (at
1072                    // the body's statement column) can never be a quirk
1073                    // stray — nothing routes value-first tokens — so a
1074                    // bare `true = 1` or `2 = 1` line would vanish with
1075                    // zero diagnostics. Loud E002 instead (research
1076                    // P1-TI-011); same-line strays keep the quirk.
1077                    let tok = self.peek().clone();
1078                    let stmt_col = match end {
1079                        BodyEnd::Block(l) | BodyEnd::HeaderBody(l) => (l + 1) as u32,
1080                        _ => 1,
1081                    };
1082                    match &tok.kind {
1083                        TokenKind::Integer
1084                        | TokenKind::Float
1085                        | TokenKind::True
1086                        | TokenKind::False
1087                        | TokenKind::Null
1088                        | TokenKind::Env
1089                        | TokenKind::Secret
1090                        | TokenKind::Minus
1091                        | TokenKind::Bang
1092                        | TokenKind::LParen
1093                            if tok.col == stmt_col =>
1094                        {
1095                            self.error_at(
1096                                "E002",
1097                                "unexpected value at statement position (values need a key: `name = …`)",
1098                                &tok,
1099                            );
1100                        }
1101                        _ => {}
1102                    }
1103                    self.bump();
1104                }
1105            }
1106        }
1107    }
1108
1109    /// Dispatch a statement that begins with an identifier: `key = value`,
1110    /// `key: value`, `key:` block header, `key { … }` table block, an
1111    /// `include` statement, or a `profile` block (`include`/`profile` are
1112    /// contextual: followed by `=`, `:`, or `{` they stay ordinary keys).
1113    fn parse_ident_statement(
1114        &mut self,
1115        fields: &mut indexmap::IndexMap<String, NodeId>,
1116        pending: &mut Vec<PendingInclude>,
1117    ) {
1118        let start = self.peek().clone();
1119        let level = start.col.saturating_sub(1) as usize;
1120        if start.text == "include"
1121            && !matches!(
1122                self.peek_kind_at(1),
1123                TokenKind::Assign | TokenKind::Colon | TokenKind::LBrace
1124            )
1125        {
1126            self.parse_include(pending);
1127            return;
1128        }
1129        if start.text == "profile"
1130            && !matches!(
1131                self.peek_kind_at(1),
1132                TokenKind::Assign | TokenKind::Colon | TokenKind::LBrace
1133            )
1134        {
1135            self.parse_profile();
1136            return;
1137        }
1138        match self.peek_kind_at(1) {
1139            TokenKind::Assign => {
1140                if let Some((k, id)) = self.parse_key_value_sep(TokenKind::Assign) {
1141                    let end = self.last_end_line();
1142                    self.record_stmt(&k, start.line, end, level);
1143                    self.extract_annotations(&k, id, start.line);
1144                    if self.check_depends_on_statement(&k, id) {
1145                        self.note_duplicate_key(&k, Some(span_of(&start)), fields.contains_key(&k));
1146                        fields.insert(k, id);
1147                    }
1148                }
1149            }
1150            TokenKind::Colon => {
1151                if matches!(self.peek_kind_at(2), TokenKind::Newline | TokenKind::Eof) {
1152                    self.parse_block_header(fields);
1153                } else if let Some((k, id)) = self.parse_key_value_sep(TokenKind::Colon) {
1154                    let end = self.last_end_line();
1155                    self.record_stmt(&k, start.line, end, level);
1156                    self.extract_annotations(&k, id, start.line);
1157                    if self.check_depends_on_statement(&k, id) {
1158                        self.note_duplicate_key(&k, Some(span_of(&start)), fields.contains_key(&k));
1159                        fields.insert(k, id);
1160                    }
1161                }
1162            }
1163            TokenKind::LBrace => self.parse_table_block(fields),
1164            _ => {
1165                // Not a statement form the grammar accepts; keep progress.
1166                let tok = self.peek().clone();
1167                self.error_at("E002", "unexpected token after identifier", &tok);
1168                self.bump();
1169            }
1170        }
1171    }
1172
1173    // ------------------------------------------------------------------
1174    // profile "name" { … }  (spec §2.8, C-027)
1175    // ------------------------------------------------------------------
1176
1177    /// Parse a profile block. Profiles live at document root only and
1178    /// merge into the root value at evaluation; they never enter the
1179    /// enclosing fields. References to document-root keys resolve in a
1180    /// final pass after the root completes (forward-safe).
1181    fn parse_profile(&mut self) {
1182        let kw = self.bump(); // consume `profile`
1183        if !self.current_path.is_empty() {
1184            self.error_at(
1185                "E018",
1186                "profile blocks may only appear at document root",
1187                &kw,
1188            );
1189            return;
1190        }
1191        let name_tok = self.peek().clone();
1192        let name = match &name_tok.kind {
1193            TokenKind::String(_)
1194            | TokenKind::SingleString
1195            | TokenKind::MlString(_)
1196            | TokenKind::MlSingleString => {
1197                self.bump();
1198                // A profile name is a static label; interpolation in it is
1199                // meaningless, so require a plain name.
1200                if name_tok.text.contains("${") {
1201                    self.error_at(
1202                        "E018",
1203                        "profile names must be plain string literals",
1204                        &name_tok,
1205                    );
1206                    return;
1207                }
1208                name_tok.text.clone()
1209            }
1210            _ => {
1211                self.error_at("E018", "profile blocks need a string name", &name_tok);
1212                return;
1213            }
1214        };
1215        // Normalize the name before anything else: it is a static label,
1216        // never interpolated, so an escaped dollar is a plain dollar here.
1217        // Normalizing first keeps duplicate detection sound (`"$"` vs
1218        // `"\$"` collide instead of silently replacing). Only marked
1219        // sentinels convert; a literal U+E000 keeps its identity.
1220        let name = Self::render_marked(&name, &Self::sentinel_marks(&name, &name_tok.kind));
1221        if self.module.profiles.contains_key(&name) {
1222            self.error_at("E018", format!("duplicate profile \"{name}\""), &name_tok);
1223            return;
1224        }
1225        // Brace form (`profile "n" { … }`) or indentation form
1226        // (`profile "n":` + indented body, spec §2.8).
1227        enum ProfileBody {
1228            Brace,
1229            Block(usize),
1230        }
1231        let body = match self.peek_kind() {
1232            TokenKind::LBrace => {
1233                self.bump();
1234                ProfileBody::Brace
1235            }
1236            TokenKind::Colon => {
1237                let colon = self.bump();
1238                self.skip_newlines();
1239                match self.peek_kind() {
1240                    TokenKind::Indent => {
1241                        let indent = self.bump();
1242                        ProfileBody::Block(indent_level(&indent))
1243                    }
1244                    _ => {
1245                        self.error_at("E018", "profile block has no indented body", &colon);
1246                        return;
1247                    }
1248                }
1249            }
1250            _ => {
1251                let tok = self.peek().clone();
1252                self.error_at("E018", "expected `{` or `:` after the profile name", &tok);
1253                return;
1254            }
1255        };
1256        self.record_stmt(
1257            &name,
1258            kw.line,
1259            self.last_end_line(),
1260            kw.col.saturating_sub(1) as usize,
1261        );
1262        let profile_stmt = self.statements.len() - 1;
1263        self.current_path.push(name.clone());
1264        self.ident_scopes.push(Vec::new());
1265        let mut fields = indexmap::IndexMap::new();
1266        let mut pending = Vec::new();
1267        let saved_in_profile = self.in_profile;
1268        self.in_profile = true;
1269        match body {
1270            ProfileBody::Brace => self.parse_body(&mut fields, BodyEnd::Brace, &mut pending),
1271            ProfileBody::Block(level) => {
1272                self.parse_body(&mut fields, BodyEnd::Block(level), &mut pending)
1273            }
1274        }
1275        self.in_profile = saved_in_profile;
1276        self.splice_includes(&mut fields, pending, true);
1277        let idents = self.ident_scopes.pop().unwrap_or_default();
1278        // Sibling resolution against the profile's own fields happens in
1279        // the final pass (together with the root fallback) so forward
1280        // references work uniformly.
1281        self.pending_profiles.push((idents, fields.clone()));
1282        let table_id = self.module.add_node(Node::Table { fields });
1283        self.statements[profile_stmt].end_line = self.last_end_line();
1284        self.current_path.pop();
1285        self.module.profiles.insert(name, table_id);
1286    }
1287
1288    // ------------------------------------------------------------------
1289    // include "path" [as alias] [private]  (spec §2.7)
1290    // ------------------------------------------------------------------
1291
1292    /// Maximum include nesting depth (files within files).
1293    const MAX_INCLUDE_DEPTH: usize = 32;
1294
1295    /// Parse an `include` statement. The path must be a string literal;
1296    /// anything else is E015. The statement is recorded for resolution
1297    /// when the enclosing scope closes (includes are file-level defaults
1298    /// merged after all local keys are known).
1299    fn parse_include(&mut self, pending: &mut Vec<PendingInclude>) {
1300        let kw = self.bump(); // consume `include`
1301        let path_tok = self.peek().clone();
1302        let path = match &path_tok.kind {
1303            TokenKind::String(_)
1304            | TokenKind::SingleString
1305            | TokenKind::MlString(_)
1306            | TokenKind::MlSingleString => {
1307                self.bump();
1308                path_tok.text.clone()
1309            }
1310            _ => {
1311                self.error_at("E015", "include paths must be string literals", &path_tok);
1312                self.bump(); // guarantee progress
1313                return;
1314            }
1315        };
1316        if path.contains("${")
1317            || Self::has_marked(&path, &Self::sentinel_marks(&path, &path_tok.kind))
1318        {
1319            self.error_at(
1320                "E015",
1321                "interpolation in include paths is a hard error; paths must be static string literals",
1322                &path_tok,
1323            );
1324            return;
1325        }
1326        // Optional `as alias` and `private`, in either order.
1327        let mut alias: Option<String> = None;
1328        let mut private = false;
1329        loop {
1330            let t = self.peek().clone();
1331            match &t.kind {
1332                TokenKind::Ident if t.text == "as" && alias.is_none() => {
1333                    self.bump();
1334                    match self.expect(TokenKind::Ident) {
1335                        Some(name) => alias = Some(name.text),
1336                        None => return,
1337                    }
1338                }
1339                TokenKind::Ident if t.text == "private" && !private => {
1340                    self.bump();
1341                    private = true;
1342                }
1343                _ => break,
1344            }
1345        }
1346        let key = alias.clone().unwrap_or_else(|| path.clone());
1347        self.record_stmt(
1348            &key,
1349            kw.line,
1350            self.last_end_line(),
1351            kw.col.saturating_sub(1) as usize,
1352        );
1353        self.module.includes.push(IncludeStmt {
1354            path: path.clone(),
1355            alias: alias.clone(),
1356            private,
1357            scope_path: self.current_path.clone(),
1358            line: kw.line,
1359        });
1360        pending.push(PendingInclude {
1361            path,
1362            alias,
1363            private,
1364            tok: path_tok,
1365            patch_mark: self.module.patches.len(),
1366        });
1367    }
1368
1369    /// Load and sub-parse one include, returning its root table node.
1370    /// Returns None after recording E015/E016/E017. The sub-parse shares
1371    /// the arena, bindings, graph, and diagnostics; scope stacks, file
1372    /// identity, style counters, and the module root are saved and
1373    /// restored so the included file parses as its own document.
1374    fn load_include(&mut self, inc: &PendingInclude) -> Option<NodeId> {
1375        use std::path::{Component, PathBuf};
1376        let fail = |me: &mut Parser, code: &str, msg: String| {
1377            me.error_at(code, msg, &inc.tok);
1378            None
1379        };
1380        // Policy: literal relative paths stay inside the tree by default.
1381        let raw = std::path::Path::new(&inc.path);
1382        if !self.options.allow_parent_escapes {
1383            if raw.is_absolute() {
1384                return fail(
1385                    self,
1386                    "E015",
1387                    format!(
1388                        "include paths must be relative (got `{}`); pass --allow-parent-escapes to override",
1389                        inc.path
1390                    ),
1391                );
1392            }
1393            if raw.components().any(|c| matches!(c, Component::ParentDir)) {
1394                return fail(
1395                    self,
1396                    "E015",
1397                    format!(
1398                        "include paths must not escape the including directory via `..` (got `{}`); pass --allow-parent-escapes to override",
1399                        inc.path
1400                    ),
1401                );
1402            }
1403        }
1404        let base_dir: PathBuf = self
1405            .current_file
1406            .as_ref()
1407            .and_then(|f| f.parent().map(|p| p.to_path_buf()))
1408            .unwrap_or_else(|| PathBuf::from("."));
1409        let joined = base_dir.join(raw);
1410        // Canonicalization splits by source (F-016): the filesystem path
1411        // needs existence, so a host loader (virtual FS: browser blobs,
1412        // archives, test fixtures) takes the lexical branch, which never
1413        // touches the disk. `..`/absolute were already gated above (or
1414        // explicitly allowed); symlinks do not resolve on the lexical
1415        // branch, a documented delta the parent-escape check below covers
1416        // by comparing normalized stems on both sides.
1417        fn lexical_join(base: &std::path::Path, raw: &std::path::Path) -> PathBuf {
1418            let mut out = base.to_path_buf();
1419            for c in raw.components() {
1420                match c {
1421                    Component::CurDir => {}
1422                    Component::ParentDir => {
1423                        out.pop();
1424                    }
1425                    Component::Normal(s) => out.push(s),
1426                    Component::RootDir | Component::Prefix(_) => {
1427                        out = PathBuf::from(c.as_os_str());
1428                    }
1429                }
1430            }
1431            out
1432        }
1433        let has_loader = self.options.include_loader.is_some();
1434        let canonical = if has_loader {
1435            lexical_join(&base_dir, raw)
1436        } else {
1437            match joined.canonicalize() {
1438                Ok(p) => p,
1439                Err(e) => {
1440                    return fail(
1441                        self,
1442                        "E017",
1443                        format!("cannot read include file `{}`: {e}", joined.display()),
1444                    );
1445                }
1446            }
1447        };
1448        if !self.options.allow_parent_escapes {
1449            let contained = if has_loader {
1450                canonical.starts_with(lexical_join(&base_dir, std::path::Path::new("")))
1451            } else if let Ok(base_canon) = base_dir.canonicalize() {
1452                canonical.starts_with(&base_canon)
1453            } else {
1454                true
1455            };
1456            if !contained {
1457                return fail(
1458                        self,
1459                        "E015",
1460                        format!(
1461                            "include target `{}` escapes the including directory; pass --allow-parent-escapes to override",
1462                            canonical.display()
1463                        ),
1464                    );
1465            }
1466        }
1467        if self.include_stack.contains(&canonical) {
1468            return fail(
1469                self,
1470                "E016",
1471                format!(
1472                    "circular include detected: `{}` is already being parsed ({})",
1473                    canonical.display(),
1474                    self.include_stack
1475                        .iter()
1476                        .map(|p| p.display().to_string())
1477                        .collect::<Vec<_>>()
1478                        .join(" -> ")
1479                ),
1480            );
1481        }
1482        if self.include_stack.len() >= Self::MAX_INCLUDE_DEPTH {
1483            return fail(
1484                self,
1485                "E016",
1486                format!(
1487                    "maximum include depth ({}) exceeded",
1488                    Self::MAX_INCLUDE_DEPTH
1489                ),
1490            );
1491        }
1492        // Signed overlays (C-052): with a trusted set, the include
1493        // must carry a valid sibling signature — fail closed before
1494        // a single byte is parsed. The content below is read once
1495        // and verified in memory (no check/use gap); only the
1496        // sidecar itself is read separately, which cannot smuggle
1497        // unverified content past the check. (Cloned: the failure
1498        // path needs `self` mutably.)
1499        let trust = self.trust.clone();
1500        // Host loader hook (F-016): replaces ONLY the filesystem read.
1501        // Canonicalization, depth, and trust verification around it are
1502        // unchanged; a hook failure surfaces exactly like a read failure.
1503        let src = match self.options.include_loader.clone() {
1504            Some(loader) => match loader(&canonical.to_string_lossy()) {
1505                Ok(s) => s,
1506                Err(e) => {
1507                    return fail(
1508                        self,
1509                        "E017",
1510                        format!("include loader refused `{}`: {e}", canonical.display()),
1511                    );
1512                }
1513            },
1514            None => match std::fs::read_to_string(&canonical) {
1515                Ok(s) => s,
1516                Err(e) => {
1517                    return fail(
1518                        self,
1519                        "E017",
1520                        format!("cannot read include file `{}`: {e}", canonical.display()),
1521                    );
1522                }
1523            },
1524        };
1525        if let Some(keys) = &trust {
1526            let label = canonical.to_string_lossy().into_owned();
1527            match crate::sign::read_sidecar(&canonical, &label) {
1528                Err(reason) => return fail(self, "E032", reason),
1529                Ok(sidecar) => {
1530                    if let Err(reason) =
1531                        crate::sign::verify_bytes(&label, src.as_bytes(), &sidecar, keys)
1532                    {
1533                        return fail(self, "E032", reason);
1534                    }
1535                }
1536            }
1537        }
1538        // Save per-file session state; the arena, bindings, graph,
1539        // diagnostics, and options are intentionally shared.
1540        let saved_file = self.current_file.clone();
1541        let saved_root = self.module.root;
1542        let saved_scopes = std::mem::take(&mut self.ident_scopes);
1543        let saved_path = self.current_path.clone();
1544        let saved_brace = self.brace_tables;
1545        let saved_indent = self.indent_tables;
1546        let saved_in_include = self.in_include;
1547        let saved_in_profile = self.in_profile;
1548        self.current_file = Some(canonical.clone());
1549        self.include_stack.push(canonical);
1550        self.in_include = true;
1551        // An included file is a fresh document: profiles allowed at its
1552        // root, with resolution deferred to the outer document end.
1553        self.in_profile = false;
1554        // Included files lex fresh (their own Indent/Dedent stacks).
1555        let mut lexer = Lexer::new(&src);
1556        let lexed = lexer.tokenize();
1557        let lex_file = self.current_file_name();
1558        // Included comments join the shared stream with their file
1559        // stamped, so annotation extraction sees included bindings'
1560        // contracts (C-033) without cross-file line collisions.
1561        let n_lex_comments = lexed.comments.len();
1562        self.comments.extend(lexed.comments);
1563        self.comment_files
1564            .extend(std::iter::repeat_with(|| lex_file.clone()).take(n_lex_comments));
1565        // Aliased includes nest under the alias: scope the sub-parse
1566        // path so annotation paths match the merged tree (C-033).
1567        if let Some(alias) = inc.alias.clone() {
1568            self.current_path.push(alias);
1569        }
1570        let mut sub_errors = lexed.errors;
1571        let mut sub_repairs = lexed.repairs;
1572        for d in sub_errors.iter_mut().chain(sub_repairs.iter_mut()) {
1573            if d.file.is_none() {
1574                d.file = lex_file.clone();
1575            }
1576        }
1577        self.errors.extend(sub_errors);
1578        self.module.healing.extend(sub_repairs);
1579        // The outer token stream is NOT exhausted (table bodies end
1580        // mid-stream), so save and restore it around the sub-parse.
1581        let saved_tokens = std::mem::take(&mut self.tokens);
1582        let saved_pos = self.pos;
1583        self.tokens = lexed.tokens;
1584        self.pos = 0;
1585        self.parse_document();
1586        self.tokens = saved_tokens;
1587        self.pos = saved_pos;
1588        // Restore the outer session; keep the shared arena, bindings,
1589        // graph, diagnostics, includes list, and trivia.
1590        let sub_root = self.module.root;
1591        self.module.root = saved_root;
1592        self.current_file = saved_file;
1593        self.include_stack.pop();
1594        self.current_path = saved_path;
1595        self.brace_tables = saved_brace;
1596        self.indent_tables = saved_indent;
1597        self.in_include = saved_in_include;
1598        self.in_profile = saved_in_profile;
1599        self.ident_scopes = saved_scopes;
1600        sub_root
1601    }
1602
1603    /// Resolve and merge a scope's recorded includes (spec §2.7).
1604    /// Included files provide defaults: explicit local keys always win
1605    /// regardless of position; among several included files the LAST one
1606    /// wins (cascade). Tables merge recursively with local-wins at every
1607    /// level (see merge_tables_at). An included file's `let` bindings
1608    /// merge into the shared bindings without overwriting local ones;
1609    /// private includes contribute only bindings, never fields.
1610    /// `profile_scope` is true when the target scope is a profile body:
1611    /// schemas arriving through those includes are E020 (spec §2.10).
1612    fn splice_includes(
1613        &mut self,
1614        fields: &mut indexmap::IndexMap<String, NodeId>,
1615        mut pending: Vec<PendingInclude>,
1616        profile_scope: bool,
1617    ) {
1618        if pending.is_empty() {
1619            return;
1620        }
1621        if !self.options.enable_includes {
1622            for inc in &pending {
1623                self.error_at(
1624                    "E015",
1625                    "include statements require file-based parsing (parse_file); string input stays hermetic",
1626                    &inc.tok,
1627                );
1628            }
1629            return;
1630        }
1631        if !self.options.resolve_includes {
1632            // Formatting mode: keep the statements, skip the merge.
1633            return;
1634        }
1635        // Dotted paths of keys contributed by includes in this scope
1636        // (spec §2.7). A marked path cascades (later includes win); an
1637        // unmarked path is local content that never loses. Subtree paths
1638        // are marked on insert so nested levels decide correctly.
1639        let mut spliced: std::collections::HashSet<String> = std::collections::HashSet::new();
1640        let mut pi = 0usize;
1641        while pi < pending.len() {
1642            let inc = pending[pi].clone();
1643            let patches_before = self.module.patches.len();
1644            let loaded = self.load_include(&inc);
1645            // Included patches belong textually at the include statement:
1646            // move everything the load appended back to the recorded mark
1647            // so cross-file application order matches source order.
1648            // Layout: [head..mark] ++ [mark..old] ++ [new...] becomes
1649            // [head..mark] ++ [new...] ++ [mark..old]. Marks recorded
1650            // after this insertion point shift by the inserted count so
1651            // later includes keep stable source-order positions.
1652            let mark = inc.patch_mark.min(patches_before);
1653            let old_tail_len = patches_before - mark;
1654            let mut inserted = 0usize;
1655            if self.module.patches.len() > patches_before {
1656                let mut tail: Vec<NodeId> = self.module.patches.drain(mark..).collect();
1657                let new_part: Vec<NodeId> = tail.split_off(old_tail_len.min(tail.len()));
1658                inserted = new_part.len();
1659                self.module.patches.extend(new_part);
1660                self.module.patches.extend(tail);
1661            }
1662            for later in pending.iter_mut().skip(pi + 1) {
1663                if later.patch_mark >= mark {
1664                    later.patch_mark += inserted;
1665                }
1666            }
1667            pi += 1;
1668            let Some(sub_root) = loaded else {
1669                continue;
1670            };
1671            let sub_fields = match self.module.get(sub_root) {
1672                Some(Node::Table { fields }) => fields.clone(),
1673                _ => continue,
1674            };
1675            if inc.private {
1676                match inc.alias {
1677                    Some(name) => {
1678                        // The alias itself becomes a referenceable binding;
1679                        // a local `let` of the same name always wins, and
1680                        // parse_let clears the mark so later includes skip.
1681                        if !self.module.bindings.contains_key(&name)
1682                            || self.binding_spliced.contains(&name)
1683                        {
1684                            self.module.bindings.insert(name.clone(), sub_root);
1685                            self.binding_spliced.insert(name);
1686                        }
1687                    }
1688                    None => {
1689                        for (k, v) in sub_fields {
1690                            if !self.module.bindings.contains_key(&k)
1691                                || self.binding_spliced.contains(&k)
1692                            {
1693                                self.module.bindings.insert(k.clone(), v);
1694                                self.binding_spliced.insert(k);
1695                            }
1696                        }
1697                    }
1698                }
1699                continue;
1700            }
1701            match inc.alias {
1702                Some(name) => {
1703                    // Aliased public includes nest under the alias with
1704                    // the same defaults rule as bare keys.
1705                    self.merge_key(fields, name, sub_root, &mut spliced);
1706                }
1707                None => {
1708                    for (k, v) in sub_fields {
1709                        self.merge_key(fields, k, v, &mut spliced);
1710                    }
1711                }
1712            }
1713        }
1714        if profile_scope {
1715            self.reject_profile_schemas(fields);
1716        }
1717    }
1718
1719    /// Reject schemas that entered a profile through an include (spec
1720    /// §2.10, E020). Direct schema blocks are already banned inside
1721    /// profiles (`in_profile`), but included files parse fresh with
1722    /// `in_profile = false`, so without this check an included schema
1723    /// would survive as a SchemaTable value, evaluate to Null, and leak
1724    /// `"schema": null` into profile exports. Every offending schema is
1725    /// reported at its own span (with its file) and detached so profile
1726    /// values stay clean; the rest of the included data merges normally.
1727    /// Ancestor data tables are preserved: only the schema keys leave.
1728    fn reject_profile_schemas(&mut self, fields: &mut IndexMap<String, NodeId>) {
1729        let keys: Vec<(String, NodeId)> = fields.iter().map(|(k, v)| (k.clone(), *v)).collect();
1730        for (key, id) in keys {
1731            if matches!(self.module.get(id), Some(Node::SchemaTable { .. })) {
1732                self.report_profile_schema(id);
1733                fields.shift_remove(&key);
1734                continue;
1735            }
1736            let mut hits = Vec::new();
1737            self.strip_schema_tables(id, &mut hits);
1738            for hit in hits {
1739                self.report_profile_schema(hit);
1740            }
1741        }
1742    }
1743
1744    /// Report one include-smuggled profile schema at its own span.
1745    fn report_profile_schema(&mut self, schema_id: NodeId) {
1746        let span = self.module.node_spans.get(&schema_id).cloned();
1747        let file = self.module.node_files.get(&schema_id).cloned();
1748        self.errors.push(Diagnostic {
1749            severity: Severity::Error,
1750            code: "E020".into(),
1751            message: "schemas may not appear inside profiles, including through includes; move the schema to the document root".into(),
1752            path: None,
1753            span,
1754            repair: None,
1755            confidence: 1.0,
1756            file,
1757        });
1758    }
1759
1760    /// Remove SchemaTable nodes under the value table `id`, recursing
1761    /// into nested tables (an aliased include can nest a schema under
1762    /// data). Direct schema children leave by key; deeper hits are
1763    /// removed in place so ancestor data tables survive. Collects every
1764    /// removed schema node for diagnostics.
1765    fn strip_schema_tables(&mut self, id: NodeId, out: &mut Vec<NodeId>) {
1766        let children: Vec<(String, NodeId)> = match self.module.get(id) {
1767            Some(Node::Table { fields }) => fields.clone().into_iter().collect(),
1768            // A schema node here means strip was called on one directly;
1769            // the caller drops its key, so just record it.
1770            Some(Node::SchemaTable { .. }) => {
1771                out.push(id);
1772                return;
1773            }
1774            _ => return,
1775        };
1776        for (k, v) in &children {
1777            if matches!(self.module.get(*v), Some(Node::SchemaTable { .. })) {
1778                if let Some(Node::Table { fields }) = self.module.get_mut(id) {
1779                    fields.shift_remove(k);
1780                }
1781                out.push(*v);
1782            } else {
1783                self.strip_schema_tables(*v, out);
1784            }
1785        }
1786    }
1787
1788    /// Merge one included value under `key` (spec §2.7 defaults rule):
1789    /// fresh keys are taken (whole subtree marked); conflicts on marked
1790    /// paths cascade (later file wins, tables recurse); conflicts on
1791    /// unmarked paths fill gaps only (tables recurse with local-wins,
1792    /// scalars untouched).
1793    fn merge_key(
1794        &mut self,
1795        fields: &mut indexmap::IndexMap<String, NodeId>,
1796        key: String,
1797        inc_id: NodeId,
1798        spliced: &mut std::collections::HashSet<String>,
1799    ) {
1800        match fields.get(&key) {
1801            None => {
1802                fields.insert(key.clone(), inc_id);
1803                self.mark_subtree(&key, inc_id, spliced);
1804            }
1805            Some(_) if spliced.contains(&key) => {
1806                self.merge_tables_at(fields, &key, inc_id, spliced, true);
1807            }
1808            Some(_) => {
1809                self.merge_tables_at(fields, &key, inc_id, spliced, false);
1810            }
1811        }
1812    }
1813
1814    /// Merge table `inc_id` into the table under `key` in place when both
1815    /// sides are tables; on a scalar conflict overwrite only in cascade
1816    /// mode. Anything else is a no-op (the defaults rule already
1817    /// decided). Merging in place keeps edges and references valid;
1818    /// merged-in fields gain dependency edges to their new parent.
1819    fn merge_tables_at(
1820        &mut self,
1821        fields: &mut indexmap::IndexMap<String, NodeId>,
1822        key: &str,
1823        inc_id: NodeId,
1824        spliced: &mut std::collections::HashSet<String>,
1825        overwrite: bool,
1826    ) {
1827        // SchemaTable × SchemaTable unions field-wise like value tables
1828        // (spec §2.10): an included schema merges with a local one
1829        // instead of clobbering it. Schema-vs-data conflicts fall
1830        // through to the scalar rules below.
1831        if matches!(self.module.get(inc_id), Some(Node::SchemaTable { .. })) {
1832            if let Some(&local_id) = fields.get(key) {
1833                if local_id != inc_id
1834                    && matches!(self.module.get(local_id), Some(Node::SchemaTable { .. }))
1835                {
1836                    if self.merge_schemas_inner(local_id, inc_id, key, spliced, overwrite) {
1837                        spliced.insert(key.to_string());
1838                    }
1839                    return;
1840                }
1841            }
1842        }
1843        let Some(Node::Table { fields: inc_fields }) = self.module.get(inc_id) else {
1844            // Scalar-vs-table conflict: replace only when cascading over a
1845            // fully include-provided table. A table with any local
1846            // (unmarked) content keeps local-wins and ignores the scalar.
1847            if overwrite {
1848                if let Some(&local_id) = fields.get(key) {
1849                    if !self.has_unmarked(local_id, key, spliced) {
1850                        fields.insert(key.to_string(), inc_id);
1851                        self.mark_subtree(key, inc_id, spliced);
1852                    }
1853                }
1854            }
1855            return;
1856        };
1857        let inc_fields = inc_fields.clone();
1858        let Some(&local_id) = fields.get(key) else {
1859            return;
1860        };
1861        if local_id == inc_id {
1862            return;
1863        }
1864        let Some(Node::Table { .. }) = self.module.get(local_id) else {
1865            // Table-vs-scalar conflict: a marked (previously included)
1866            // scalar cascades, a local scalar always wins.
1867            if overwrite && spliced.contains(key) {
1868                fields.insert(key.to_string(), inc_id);
1869                self.mark_subtree(key, inc_id, spliced);
1870            }
1871            return;
1872        };
1873        let mut merged = match self.module.get(local_id) {
1874            Some(Node::Table { fields: lf }) => lf.clone(),
1875            _ => return,
1876        };
1877        let mut changed = false;
1878        for (k, v) in &inc_fields {
1879            let child = format!("{key}.{k}");
1880            match merged.get(k) {
1881                None => {
1882                    merged.insert(k.clone(), *v);
1883                    self.module.add_edge(*v, local_id);
1884                    self.mark_subtree(&child, *v, spliced);
1885                    changed = true;
1886                }
1887                Some(&sub_local) => {
1888                    if sub_local == *v {
1889                        continue;
1890                    }
1891                    let local_kind = self.module.get(sub_local).cloned();
1892                    let inc_kind = self.module.get(*v).cloned();
1893                    // Nested schemas union like their parents (spec §2.10).
1894                    if matches!(
1895                        (&local_kind, &inc_kind),
1896                        (
1897                            Some(Node::SchemaTable { .. }),
1898                            Some(Node::SchemaTable { .. })
1899                        )
1900                    ) {
1901                        let sub_overwrite = overwrite && spliced.contains(&child);
1902                        if self.merge_schemas_inner(sub_local, *v, &child, spliced, sub_overwrite) {
1903                            changed = true;
1904                        }
1905                        continue;
1906                    }
1907                    let both_tables = matches!(
1908                        (&local_kind, &inc_kind),
1909                        (Some(Node::Table { .. }), Some(Node::Table { .. }))
1910                    );
1911                    if !both_tables {
1912                        if overwrite && spliced.contains(&child) {
1913                            merged.insert(k.clone(), *v);
1914                            self.module.add_edge(*v, local_id);
1915                            changed = true;
1916                        }
1917                        continue;
1918                    }
1919                    // Recurse: cascade only through included paths, so a
1920                    // purely local nested table keeps local-wins even
1921                    // inside a composition.
1922                    let sub_overwrite = overwrite && spliced.contains(&child);
1923                    if self.merge_tables_inner(sub_local, *v, &child, spliced, sub_overwrite) {
1924                        changed = true;
1925                    }
1926                }
1927            }
1928        }
1929        if changed {
1930            if let Some(Node::Table { fields: slot }) = self.module.get_mut(local_id) {
1931                *slot = merged;
1932            }
1933            spliced.insert(key.to_string());
1934        }
1935    }
1936
1937    /// Merge table `inc_id`'s fields into table `local_id` in place.
1938    /// Shared worker for nested recursion. Returns true when the tree
1939    /// was actually mutated, so callers mark provenance precisely.
1940    fn merge_tables_inner(
1941        &mut self,
1942        local_id: NodeId,
1943        inc_id: NodeId,
1944        path: &str,
1945        spliced: &mut std::collections::HashSet<String>,
1946        overwrite: bool,
1947    ) -> bool {
1948        let Some(Node::Table { fields: inc_fields }) = self.module.get(inc_id) else {
1949            return false;
1950        };
1951        let inc_fields = inc_fields.clone();
1952        let mut merged = match self.module.get(local_id) {
1953            Some(Node::Table { fields: lf }) => lf.clone(),
1954            _ => return false,
1955        };
1956        let mut changed = false;
1957        for (k, v) in &inc_fields {
1958            let child = format!("{path}.{k}");
1959            match merged.get(k) {
1960                None => {
1961                    merged.insert(k.clone(), *v);
1962                    self.module.add_edge(*v, local_id);
1963                    self.mark_subtree(&child, *v, spliced);
1964                    changed = true;
1965                }
1966                Some(&sub_local) => {
1967                    if sub_local == *v {
1968                        continue;
1969                    }
1970                    let local_kind = self.module.get(sub_local).cloned();
1971                    let inc_kind = self.module.get(*v).cloned();
1972                    // Nested schemas union like their parents (spec §2.10).
1973                    if matches!(
1974                        (&local_kind, &inc_kind),
1975                        (
1976                            Some(Node::SchemaTable { .. }),
1977                            Some(Node::SchemaTable { .. })
1978                        )
1979                    ) {
1980                        let sub_overwrite = overwrite && spliced.contains(&child);
1981                        if self.merge_schemas_inner(sub_local, *v, &child, spliced, sub_overwrite) {
1982                            changed = true;
1983                        }
1984                        continue;
1985                    }
1986                    let both_tables = matches!(
1987                        (&local_kind, &inc_kind),
1988                        (Some(Node::Table { .. }), Some(Node::Table { .. }))
1989                    );
1990                    if !both_tables {
1991                        if overwrite && spliced.contains(&child) {
1992                            merged.insert(k.clone(), *v);
1993                            self.module.add_edge(*v, local_id);
1994                            changed = true;
1995                        }
1996                        continue;
1997                    }
1998                    let sub_overwrite = overwrite && spliced.contains(&child);
1999                    if self.merge_tables_inner(sub_local, *v, &child, spliced, sub_overwrite) {
2000                        changed = true;
2001                    }
2002                }
2003            }
2004        }
2005        if changed {
2006            if let Some(Node::Table { fields: slot }) = self.module.get_mut(local_id) {
2007                *slot = merged;
2008            }
2009        }
2010        changed
2011    }
2012
2013    /// Union an included schema table into a local one in place (spec
2014    /// §2.10): missing fields are taken, conflicting leaf fields follow
2015    /// local-wins (or cascade when `overwrite` over an included path),
2016    /// nested schema tables recurse, and `closed` follows the winner.
2017    /// Merging in place keeps edges valid; returns true on mutation.
2018    fn merge_schemas_inner(
2019        &mut self,
2020        local_id: NodeId,
2021        inc_id: NodeId,
2022        path: &str,
2023        spliced: &mut std::collections::HashSet<String>,
2024        overwrite: bool,
2025    ) -> bool {
2026        let (local_fields, local_closed) = match self.module.get(local_id) {
2027            Some(Node::SchemaTable { fields, closed }) => (fields.clone(), *closed),
2028            _ => return false,
2029        };
2030        let (inc_fields, inc_closed) = match self.module.get(inc_id) {
2031            Some(Node::SchemaTable { fields, closed }) => (fields.clone(), *closed),
2032            _ => return false,
2033        };
2034        let mut merged = local_fields;
2035        let mut merged_closed = local_closed;
2036        let mut changed = false;
2037        if overwrite && merged_closed != inc_closed {
2038            merged_closed = inc_closed;
2039            changed = true;
2040        }
2041        for (k, v) in &inc_fields {
2042            let child = format!("{path}.{k}");
2043            match merged.get(k) {
2044                None => {
2045                    merged.insert(k.clone(), *v);
2046                    self.module.add_edge(*v, local_id);
2047                    self.mark_subtree(&child, *v, spliced);
2048                    changed = true;
2049                }
2050                Some(&sub_local) => {
2051                    if sub_local == *v {
2052                        continue;
2053                    }
2054                    let both_schema = matches!(
2055                        (self.module.get(sub_local), self.module.get(*v)),
2056                        (
2057                            Some(Node::SchemaTable { .. }),
2058                            Some(Node::SchemaTable { .. })
2059                        )
2060                    );
2061                    if both_schema {
2062                        let sub_overwrite = overwrite && spliced.contains(&child);
2063                        if self.merge_schemas_inner(sub_local, *v, &child, spliced, sub_overwrite) {
2064                            changed = true;
2065                        }
2066                        continue;
2067                    }
2068                    if overwrite && spliced.contains(&child) {
2069                        merged.insert(k.clone(), *v);
2070                        self.module.add_edge(*v, local_id);
2071                        changed = true;
2072                    }
2073                }
2074            }
2075        }
2076        if changed {
2077            if let Some(Node::SchemaTable {
2078                fields: slot,
2079                closed,
2080            }) = self.module.get_mut(local_id)
2081            {
2082                *slot = merged;
2083                *closed = merged_closed;
2084            }
2085        }
2086        changed
2087    }
2088
2089    /// True when a table subtree contains any local (unmarked) content.
2090    /// Guards whole-table replacement: a later include's scalar never
2091    /// discards a table that still holds explicit local keys.
2092    fn has_unmarked(
2093        &self,
2094        id: NodeId,
2095        path: &str,
2096        spliced: &std::collections::HashSet<String>,
2097    ) -> bool {
2098        if !spliced.contains(path) {
2099            return true;
2100        }
2101        match self.module.get(id) {
2102            Some(Node::Table { fields }) => {
2103                let fields = fields.clone();
2104                fields.iter().any(|(k, v)| {
2105                    let child = format!("{path}.{k}");
2106                    self.has_unmarked(*v, &child, spliced)
2107                })
2108            }
2109            _ => false,
2110        }
2111    }
2112
2113    /// Mark a key path and every table path below it as include-provided,
2114    /// so nested cascade decisions see the full provenance. Schema
2115    /// tables descend like value tables: without this, a later include
2116    /// could never cascade over an earlier include's schema fields.
2117    fn mark_subtree(
2118        &self,
2119        path: &str,
2120        id: NodeId,
2121        spliced: &mut std::collections::HashSet<String>,
2122    ) {
2123        spliced.insert(path.to_string());
2124        let children: Vec<(String, NodeId)> = match self.module.get(id) {
2125            Some(Node::Table { fields }) | Some(Node::SchemaTable { fields, .. }) => {
2126                fields.clone().into_iter().collect()
2127            }
2128            _ => return,
2129        };
2130        for (k, v) in &children {
2131            let child = format!("{path}.{k}");
2132            self.mark_subtree(&child, *v, spliced);
2133        }
2134    }
2135
2136    /// `key:` followed by an indented block (indentation style, §2 and §5).
2137    fn parse_block_header(&mut self, parent: &mut indexmap::IndexMap<String, NodeId>) {
2138        let key_tok = self.peek().clone();
2139        let key = self
2140            .expect(TokenKind::Ident)
2141            .map(|t| t.text)
2142            .unwrap_or_default();
2143        let colon = self.expect(TokenKind::Colon);
2144        self.skip_newlines();
2145        let level = if matches!(self.peek_kind(), TokenKind::Indent) {
2146            let indent = self.bump();
2147            indent_level(&indent)
2148        } else {
2149            // E008: the header has no indented body (including at EOF).
2150            let tok = colon.unwrap_or_else(|| self.peek().clone());
2151            self.error_at(
2152                "E008",
2153                format!("block header `{key}:` has no indented body"),
2154                &tok,
2155            );
2156            return;
2157        };
2158        // A `schema:` header parses its indented body as a schema
2159        // (spec §2.10).
2160        if key == "schema" {
2161            if self.in_profile {
2162                self.error_at("E020", "schemas may not appear inside profiles", &key_tok);
2163                return;
2164            }
2165            self.indent_tables += 1;
2166            self.record_stmt(
2167                &key,
2168                key_tok.line,
2169                self.last_end_line(),
2170                key_tok.col.saturating_sub(1) as usize,
2171            );
2172            let node = self.parse_schema_indent(&key_tok, level);
2173            parent.insert(key, node);
2174            return;
2175        }
2176        self.indent_tables += 1;
2177        let stmt_idx = self.statements.len();
2178        self.statements.push(StmtInfo {
2179            key: key.clone(),
2180            path: self.path_str(),
2181            start_line: key_tok.line,
2182            end_line: key_tok.line,
2183            level: key_tok.col.saturating_sub(1) as usize,
2184            file: self.current_file_name(),
2185        });
2186        self.current_path.push(key.clone());
2187        self.blocks.push(BlockInfo {
2188            path: self.path_str(),
2189            level,
2190            start_line: key_tok.line,
2191            file: self.current_file_name(),
2192        });
2193        self.ident_scopes.push(Vec::new());
2194        let mut fields = indexmap::IndexMap::new();
2195        let mut pending = Vec::new();
2196        self.parse_body(&mut fields, BodyEnd::Block(level), &mut pending);
2197        self.splice_includes(&mut fields, pending, false);
2198        let idents = self.ident_scopes.pop().unwrap_or_default();
2199        let _ = self.resolve_sibling_refs(idents, &fields);
2200        // Tables carry their name-token span (and file stamp) like
2201        // every value node: tooling scopes extents off them (C-043).
2202        let table_id = self.add_spanned(Node::Table { fields }, &key_tok);
2203        self.statements[stmt_idx].end_line = self.last_end_line();
2204        self.current_path.pop();
2205        self.extract_annotations(&key, table_id, key_tok.line);
2206        self.note_duplicate_key(&key, Some(span_of(&key_tok)), parent.contains_key(&key));
2207        parent.insert(key, table_id);
2208    }
2209
2210    /// `key { … }` brace-style table block (HCL-style, §2).
2211    fn parse_table_block(&mut self, parent: &mut indexmap::IndexMap<String, NodeId>) {
2212        let key_tok = self.peek().clone();
2213        let key = self
2214            .expect(TokenKind::Ident)
2215            .map(|t| t.text)
2216            .unwrap_or_default();
2217        // A `schema` table block parses as a schema (spec §2.10).
2218        // The `{` is still next: parse_schema_brace consumes it.
2219        if key == "schema" {
2220            if self.in_profile {
2221                self.error_at("E020", "schemas may not appear inside profiles", &key_tok);
2222                return;
2223            }
2224            self.brace_tables += 1;
2225            self.record_stmt(
2226                &key,
2227                key_tok.line,
2228                self.last_end_line(),
2229                key_tok.col.saturating_sub(1) as usize,
2230            );
2231            let node = self.parse_schema_brace(&key_tok);
2232            parent.insert(key, node);
2233            return;
2234        }
2235        self.expect(TokenKind::LBrace);
2236        self.brace_tables += 1;
2237        let stmt_idx = self.statements.len();
2238        self.statements.push(StmtInfo {
2239            key: key.clone(),
2240            path: self.path_str(),
2241            start_line: key_tok.line,
2242            end_line: key_tok.line,
2243            level: key_tok.col.saturating_sub(1) as usize,
2244            file: self.current_file_name(),
2245        });
2246        self.current_path.push(key.clone());
2247        self.blocks.push(BlockInfo {
2248            path: self.path_str(),
2249            level: key_tok.col.saturating_sub(1) as usize,
2250            start_line: key_tok.line,
2251            file: self.current_file_name(),
2252        });
2253        self.ident_scopes.push(Vec::new());
2254        let mut fields = indexmap::IndexMap::new();
2255        let mut pending = Vec::new();
2256        self.parse_body(&mut fields, BodyEnd::Brace, &mut pending);
2257        self.splice_includes(&mut fields, pending, false);
2258        let idents = self.ident_scopes.pop().unwrap_or_default();
2259        let _ = self.resolve_sibling_refs(idents, &fields);
2260        // Name-token span and file stamp, as above (C-043 scopes).
2261        let table_id = self.add_spanned(Node::Table { fields }, &key_tok);
2262        self.statements[stmt_idx].end_line = self.last_end_line();
2263        self.current_path.pop();
2264        self.extract_annotations(&key, table_id, key_tok.line);
2265        self.note_duplicate_key(&key, Some(span_of(&key_tok)), parent.contains_key(&key));
2266        parent.insert(key, table_id);
2267    }
2268
2269    /// Diagnostic at an explicit token's span.
2270    fn error_at(&mut self, code: &str, message: impl Into<String>, tok: &Token) {
2271        self.errors.push(Diagnostic {
2272            severity: Severity::Error,
2273            code: code.into(),
2274            message: message.into(),
2275            path: None,
2276            span: Some(span_of(tok)),
2277            repair: None,
2278            confidence: 1.0,
2279            file: self.current_file_name(),
2280        });
2281    }
2282
2283    /// Diagnostic at an explicit span (for comment-anchored diagnostics
2284    /// such as malformed annotations, which have no token).
2285    fn error_at_span(&mut self, code: &str, message: impl Into<String>, span: &Span) {
2286        self.errors.push(Diagnostic {
2287            severity: Severity::Error,
2288            code: code.into(),
2289            message: message.into(),
2290            path: None,
2291            span: Some(span.clone()),
2292            repair: None,
2293            confidence: 1.0,
2294            file: self.current_file_name(),
2295        });
2296    }
2297
2298    /// Duplicate-key notice (W011): a same-scope redefinition keeps
2299    /// last-wins (long-standing behavior everywhere), but silence is not
2300    /// allowed (research P1-TI-012/HIST-YAML-004). Warning-class, recorded
2301    /// on module.healing so strict parsing still succeeds and the notice
2302    /// surfaces through evaluation diagnostics. Call BEFORE overwriting;
2303    /// pass whether the key already exists. Include-merge paths never call
2304    /// this (cross-file composition is designed, not accidental).
2305    fn note_duplicate_key(&mut self, key: &str, span: Option<Span>, existed: bool) {
2306        if existed {
2307            self.module.healing.push(Diagnostic {
2308                severity: Severity::Warning,
2309                code: "W011".into(),
2310                message: format!("duplicate key '{key}'; last wins"),
2311                path: None,
2312                span,
2313                repair: None,
2314                confidence: 1.0,
2315                file: self.current_file_name(),
2316            });
2317        }
2318    }
2319
2320    /// Extract `schema` tables from value fields into the schema tree
2321    /// (spec §2.10, C-030). Runs once per document after the body parses
2322    /// and includes splice, before the dependency graph builds. A `schema`
2323    /// key holding a SchemaTable node becomes schema (never data); any
2324    /// other `schema`-keyed value stays ordinary data. Nested schemas
2325    /// attach under their parent's schema node, creating open parents
2326    /// as needed. Runs only at the top-level document; included files
2327    /// contribute through the shared fields.
2328    fn extract_schemas(&mut self) {
2329        if self.in_include {
2330            return;
2331        }
2332        let Some(root) = self.module.root else {
2333            return;
2334        };
2335        if let Some(schema_id) = self.extract_table_schema(root) {
2336            self.module.schema = Some(schema_id);
2337        }
2338    }
2339
2340    /// Extract schemas from one value table node. Returns the schema
2341    /// node for this table when it has a direct `schema` child or any
2342    /// nested schemas, creating an open parent as needed.
2343    fn extract_table_schema(&mut self, table_id: NodeId) -> Option<NodeId> {
2344        // Snapshot the fields: extraction mutates the table in place.
2345        let fields = match self.module.get(table_id) {
2346            Some(Node::Table { fields }) => fields.clone(),
2347            _ => return None,
2348        };
2349        // Direct `schema` child?
2350        let mut schema_id = match fields.get("schema") {
2351            Some(&id) => match self.module.get(id) {
2352                Some(Node::SchemaTable { .. }) => {
2353                    // Detach from value fields so it never exports.
2354                    if let Some(Node::Table { fields }) = self.module.get_mut(table_id) {
2355                        fields.shift_remove("schema");
2356                    }
2357                    Some(id)
2358                }
2359                _ => None,
2360            },
2361            None => None,
2362        };
2363        // Recurse into value-table children for nested schemas.
2364        let mut nested: IndexMap<String, NodeId> = IndexMap::new();
2365        for (k, v) in &fields {
2366            if *k == "schema" {
2367                continue;
2368            }
2369            if matches!(self.module.get(*v), Some(Node::Table { .. })) {
2370                if let Some(sub) = self.extract_table_schema(*v) {
2371                    nested.insert(k.clone(), sub);
2372                }
2373            }
2374        }
2375        if nested.is_empty() {
2376            return schema_id;
2377        }
2378        // Nested schemas need a parent: reuse the direct one or create
2379        // an open table.
2380        let parent = match schema_id {
2381            Some(id) => id,
2382            None => {
2383                let id = self.module.add_node(Node::SchemaTable {
2384                    fields: IndexMap::new(),
2385                    closed: false,
2386                });
2387                schema_id = Some(id);
2388                id
2389            }
2390        };
2391        if let Some(Node::SchemaTable { fields, .. }) = self.module.get_mut(parent) {
2392            for (k, v) in nested {
2393                fields.insert(k, v);
2394            }
2395        }
2396        schema_id
2397    }
2398
2399    /// Extract `depends_on` arrays from value fields into the metadata
2400    /// side table (spec §2.11, C-042). Runs once per document alongside
2401    /// schema extraction, before the dependency graph builds. A
2402    /// `depends_on` key holding an array of string literals detaches
2403    /// into `Module.depends_on` (never data, never exported); any
2404    /// other value stays ordinary data. Non-string elements are E022.
2405    /// Nested tables recurse; included files contribute through the
2406    /// shared fields.
2407    fn extract_depends_on(&mut self) {
2408        if self.in_include {
2409            return;
2410        }
2411        let Some(root) = self.module.root else {
2412            return;
2413        };
2414        self.extract_table_depends_on(root);
2415    }
2416
2417    /// Extract `depends_on` from one value table node and recurse.
2418    fn extract_table_depends_on(&mut self, table_id: NodeId) {
2419        let fields = match self.module.get(table_id) {
2420            Some(Node::Table { fields }) => fields.clone(),
2421            _ => return,
2422        };
2423        if let Some(&id) = fields.get("depends_on") {
2424            if let Some(Node::Array { elements }) = self.module.get(id).cloned() {
2425                let mut paths = Vec::new();
2426                let mut bad = false;
2427                for element in &elements {
2428                    match self.module.get(*element) {
2429                        Some(Node::Literal(Value::String(path))) => paths.push(path.clone()),
2430                        _ => {
2431                            bad = true;
2432                            if let Some(span) = self.module.node_spans.get(element).cloned() {
2433                                self.error_at_span(
2434                                    "E022",
2435                                    "depends_on entries must be string literals",
2436                                    &span,
2437                                );
2438                            }
2439                        }
2440                    }
2441                }
2442                if !bad {
2443                    if let Some(Node::Table { fields }) = self.module.get_mut(table_id) {
2444                        fields.shift_remove("depends_on");
2445                    }
2446                    let span =
2447                        self.module
2448                            .node_spans
2449                            .get(&id)
2450                            .cloned()
2451                            .unwrap_or(crate::diag::Span {
2452                                line: 1,
2453                                column: 1,
2454                                end_line: 1,
2455                                end_column: 1,
2456                            });
2457                    self.module
2458                        .depends_on
2459                        .insert(table_id, DependsOn { paths, span });
2460                }
2461            }
2462        }
2463        // Recurse into remaining value-table children (inline tables
2464        // included — inline `depends_on` arrays already errored at
2465        // parse, so only statement-level blocks arrive here).
2466        let fields = match self.module.get(table_id) {
2467            Some(Node::Table { fields }) => fields.clone(),
2468            _ => return,
2469        };
2470        for (k, v) in &fields {
2471            if *k == "depends_on" {
2472                continue;
2473            }
2474            if matches!(self.module.get(*v), Some(Node::Table { .. })) {
2475                self.extract_table_depends_on(*v);
2476            }
2477        }
2478    }
2479
2480    /// Display name of the file currently being parsed, for diagnostics.
2481    fn current_file_name(&self) -> Option<String> {
2482        self.current_file
2483            .as_ref()
2484            .map(|p| p.to_string_lossy().into_owned())
2485    }
2486
2487    /// Add a node and record the span of the token that produced it, so
2488    /// evaluation-time diagnostics can point at real source positions.
2489    fn add_spanned(&mut self, node: Node, tok: &Token) -> NodeId {
2490        let id = self.module.add_node(node);
2491        self.module.record_span(id, span_of(tok));
2492        if let Some(file) = self.current_file_name() {
2493            self.module.record_file(id, file);
2494        }
2495        id
2496    }
2497
2498    // ------------------------------------------------------------------
2499    // Healing (C-016): syntactic repairs with confidence scoring
2500    // ------------------------------------------------------------------
2501
2502    /// Record a healing diagnostic. The confidence policy decides whether
2503    /// the repair is applied at all:
2504    /// - ≥ 0.95: applied in every mode (Info)
2505    /// - 0.85–0.94: applied in every mode (Warning; strict fails on it at
2506    ///   the exit-code level)
2507    /// - 0.70–0.84: applied only in resilient mode
2508    /// - < 0.70: never applied — the caller's error path stands
2509    fn heal(
2510        &mut self,
2511        code: &str,
2512        message: String,
2513        tok: &Token,
2514        repair: Repair,
2515        confidence: f64,
2516    ) -> bool {
2517        let applies = confidence >= thresholds::HIGH
2518            || (self.options.resilient && confidence >= thresholds::MEDIUM);
2519        if !applies {
2520            return false;
2521        }
2522        self.module.healing.push(Diagnostic {
2523            severity: if confidence >= thresholds::AUTO_APPLY {
2524                Severity::Info
2525            } else {
2526                Severity::Warning
2527            },
2528            code: code.into(),
2529            message,
2530            path: None,
2531            span: Some(span_of(tok)),
2532            repair: Some(repair),
2533            confidence,
2534            file: self.current_file_name(),
2535        });
2536        true
2537    }
2538
2539    /// Consume a closing delimiter, healing a bracket/brace mismatch when
2540    /// the mode allows it (H004, medium confidence: single-token
2541    /// substitution). Returns true when the delimiter was consumed.
2542    fn expect_closing_healing(&mut self, expected: TokenKind, heal_code: &str) -> bool {
2543        let next = self.peek().clone();
2544        if next.kind == expected {
2545            self.bump();
2546            return true;
2547        }
2548        let healable = matches!(
2549            (&expected, &next.kind),
2550            (TokenKind::RBracket, TokenKind::RBrace) | (TokenKind::RBrace, TokenKind::RBracket)
2551        );
2552        if healable {
2553            let applied = self.heal(
2554                heal_code,
2555                format!(
2556                    "mismatched closing delimiter; replaced {:?} with {:?}",
2557                    next.kind, expected
2558                ),
2559                &next,
2560                Repair {
2561                    original: format!("{:?}", next.kind),
2562                    suggested: format!("{:?}", expected),
2563                    kind: RepairKind::Other("bracket_mismatch".into()),
2564                    safe: false,
2565                },
2566                thresholds::MEDIUM + 0.12, // 0.82 — resilient mode only
2567            );
2568            if applied {
2569                self.bump();
2570                return true;
2571            }
2572        }
2573        // Not healed: report the missing delimiter and leave the token for
2574        // the caller's error path.
2575        self.error(
2576            "E001",
2577            format!("expected {:?}, found {:?}", expected, next.kind),
2578        );
2579        false
2580    }
2581
2582    // ------------------------------------------------------------------
2583    // Document entry point
2584    // ------------------------------------------------------------------
2585
2586    fn parse_document(&mut self) {
2587        let mut root_fields = indexmap::IndexMap::new();
2588        self.ident_scopes.push(Vec::new());
2589
2590        let mut pending = Vec::new();
2591        self.parse_body(&mut root_fields, BodyEnd::Document, &mut pending);
2592        self.splice_includes(&mut root_fields, pending, false);
2593
2594        let root_idents = self.ident_scopes.pop().unwrap_or_default();
2595        let _ = self.resolve_sibling_refs(root_idents, &root_fields);
2596
2597        // Final profile pass (spec §2.6): each profile scope resolves
2598        // against its own fields, then the leftovers fall back to the
2599        // completed root fields, so profiles reference base keys forward
2600        // and backward. Anything still unresolved falls through to `let`
2601        // bindings in build_graph, then W001. Runs only at the top-level
2602        // document: included files stash theirs for the outer pass so
2603        // every profile sees the composed root.
2604        if !self.in_include {
2605            let profiles = std::mem::take(&mut self.pending_profiles);
2606            for (idents, prof_fields) in profiles {
2607                let leftover = self.resolve_sibling_refs(idents, &prof_fields);
2608                // Root fallback: a profile key may shadow a base key of
2609                // the same name (`port = port`), so the self-reference
2610                // exclusion is lifted here — the nodes differ, no cycle
2611                // is possible (base keys never reference profiles).
2612                let unowned: Vec<(String, NodeId)> = leftover
2613                    .into_iter()
2614                    .map(|(_, id)| (String::new(), id))
2615                    .collect();
2616                let _ = self.resolve_sibling_refs(unowned, &root_fields);
2617            }
2618        }
2619
2620        let root_id = self.module.add_node(Node::Table {
2621            fields: root_fields,
2622        });
2623        self.module.root = Some(root_id);
2624    }
2625
2626    // ------------------------------------------------------------------
2627    // let name = expr
2628    // ------------------------------------------------------------------
2629
2630    fn parse_let(&mut self) {
2631        let let_tok = self.peek().clone();
2632        self.bump(); // consume `let`
2633        let name_tok = match self.expect(TokenKind::Ident) {
2634            Some(t) => t,
2635            None => return,
2636        };
2637        if self.expect(TokenKind::Assign).is_none() {
2638            return;
2639        }
2640        let expr = self.parse_expression();
2641        self.record_stmt(
2642            &name_tok.text,
2643            let_tok.line,
2644            self.last_end_line(),
2645            let_tok.col.saturating_sub(1) as usize,
2646        );
2647        self.extract_annotations(&name_tok.text, expr, let_tok.line);
2648        if self.in_include {
2649            // Included files provide defaults: a local `let` of the same
2650            // name always wins (spec §2.7).
2651            self.module.bindings.entry(name_tok.text).or_insert(expr);
2652        } else {
2653            self.note_duplicate_key(
2654                &name_tok.text,
2655                Some(span_of(&name_tok)),
2656                self.module.bindings.contains_key(&name_tok.text),
2657            );
2658            self.module.bindings.insert(name_tok.text.clone(), expr);
2659            // A local `let` reclaims its name: later includes must skip it.
2660            self.binding_spliced.remove(&name_tok.text);
2661        }
2662    }
2663
2664    // ------------------------------------------------------------------
2665    // [table] … key = expr
2666    // ------------------------------------------------------------------
2667
2668    fn parse_table_header(&mut self, parent: &mut indexmap::IndexMap<String, NodeId>) {
2669        let open = self.peek().clone();
2670        // Source columns are 1-based; indentation levels are 0-based.
2671        let header_level = open.col.saturating_sub(1) as usize;
2672        self.bump(); // [
2673        let name_tok = match self.expect(TokenKind::Ident) {
2674            Some(t) => t,
2675            None => return,
2676        };
2677        // Dotted table paths [a.b.c]: each further segment descends
2678        // into (or creates) a nested table; the body belongs to the
2679        // leaf. Single-segment headers behave exactly as before.
2680        let mut segments = vec![name_tok.text.clone()];
2681        while matches!(self.peek_kind(), TokenKind::Dot) {
2682            self.bump(); // .
2683            match self.expect(TokenKind::Ident) {
2684                Some(t) => segments.push(t.text.clone()),
2685                None => return,
2686            }
2687        }
2688        let close = match self.expect(TokenKind::RBracket) {
2689            Some(t) => t,
2690            None => return,
2691        };
2692        self.brace_tables += 1;
2693        // Descend to the leaf container, creating intermediate tables
2694        // (span-less synthetics, like the document root). A present
2695        // non-table is a structural conflict, not a silent overwrite.
2696        // The descent resolves owned ids with short-lived borrows only,
2697        // so no map borrow is held across the body parse below.
2698        let mut scope: Option<NodeId> = None;
2699        for seg in &segments[..segments.len() - 1] {
2700            let found = match scope {
2701                None => parent.get(seg).copied(),
2702                Some(id) => match self.module.get(id) {
2703                    Some(Node::Table { fields }) => fields.get(seg).copied(),
2704                    _ => None,
2705                },
2706            };
2707            let next = match found {
2708                Some(id) => match self.module.get(id) {
2709                    Some(Node::Table { .. }) => id,
2710                    _ => {
2711                        self.error(
2712                            "E001",
2713                            format!(
2714                                "table path '{}' conflicts with a non-table value at '{}'",
2715                                segments.join("."),
2716                                seg
2717                            ),
2718                        );
2719                        return;
2720                    }
2721                },
2722                None => {
2723                    let id = self.module.add_node(Node::Table {
2724                        fields: indexmap::IndexMap::new(),
2725                    });
2726                    match scope {
2727                        None => {
2728                            parent.insert(seg.clone(), id);
2729                        }
2730                        Some(pid) => match self.module.get_mut(pid) {
2731                            Some(Node::Table { fields }) => {
2732                                fields.insert(seg.clone(), id);
2733                            }
2734                            _ => {
2735                                self.error(
2736                                    "E001",
2737                                    format!(
2738                                        "table path '{}' conflicts with a non-table value",
2739                                        segments.join(".")
2740                                    ),
2741                                );
2742                                return;
2743                            }
2744                        },
2745                    }
2746                    id
2747                }
2748            };
2749            scope = Some(next);
2750        }
2751        let name = segments.join(".");
2752        let leaf = segments.last().cloned().unwrap_or_default();
2753        let stmt_idx = self.statements.len();
2754        self.statements.push(StmtInfo {
2755            key: name.clone(),
2756            path: self.path_str(),
2757            start_line: open.line,
2758            end_line: open.line,
2759            level: header_level,
2760            file: self.current_file_name(),
2761        });
2762        for seg in &segments {
2763            self.current_path.push(seg.clone());
2764        }
2765        self.blocks.push(BlockInfo {
2766            path: self.path_str(),
2767            level: header_level,
2768            start_line: open.line,
2769            file: self.current_file_name(),
2770        });
2771
2772        self.ident_scopes.push(Vec::new());
2773        let mut fields = indexmap::IndexMap::new();
2774        let mut pending = Vec::new();
2775        self.parse_body(&mut fields, BodyEnd::HeaderBody(header_level), &mut pending);
2776        self.splice_includes(&mut fields, pending, false);
2777        let idents = self.ident_scopes.pop().unwrap_or_default();
2778        let _ = self.resolve_sibling_refs(idents, &fields);
2779        // Name-token span and file stamp, as above (C-043 scopes),
2780        // widened to the full header so dotted tables point at
2781        // `[a.b]`, not just `a`.
2782        let table_id = self.module.add_node(Node::Table { fields });
2783        self.module.record_span(
2784            table_id,
2785            Span {
2786                line: open.line,
2787                column: open.col,
2788                end_line: close.end_line,
2789                end_column: close.end_col,
2790            },
2791        );
2792        if let Some(file) = self.current_file_name() {
2793            self.module.record_file(table_id, file);
2794        }
2795        self.statements[stmt_idx].end_line = self.last_end_line();
2796        for _ in &segments {
2797            self.current_path.pop();
2798        }
2799        self.extract_annotations(&name, table_id, open.line);
2800        match scope {
2801            None => {
2802                self.note_duplicate_key(&leaf, Some(span_of(&open)), parent.contains_key(&leaf));
2803                parent.insert(leaf, table_id);
2804            }
2805            Some(pid) => {
2806                let existed = matches!(
2807                    self.module.get(pid),
2808                    Some(Node::Table { fields }) if fields.contains_key(&leaf)
2809                );
2810                self.note_duplicate_key(&leaf, Some(span_of(&open)), existed);
2811                match self.module.get_mut(pid) {
2812                    Some(Node::Table { fields }) => {
2813                        fields.insert(leaf, table_id);
2814                    }
2815                    _ => {
2816                        self.error(
2817                            "E001",
2818                            format!(
2819                                "table path '{}' conflicts with a non-table value",
2820                                segments.join(".")
2821                            ),
2822                        );
2823                    }
2824                }
2825            }
2826        }
2827    }
2828
2829    // ------------------------------------------------------------------
2830    // key = expr
2831    // ------------------------------------------------------------------
2832
2833    /// Statement-level `depends_on` gate (spec/linter.md §2): array
2834    /// values are dependency metadata (extracted post-parse, never
2835    /// exported); anything else stays ordinary data. Inside profiles
2836    /// the metadata form is E022 and dropped. Returns false when the
2837    /// entry must not enter the value fields.
2838    fn check_depends_on_statement(&mut self, key: &str, id: NodeId) -> bool {
2839        if key != "depends_on" || !matches!(self.module.get(id), Some(Node::Array { .. })) {
2840            return true;
2841        }
2842        if self.in_profile {
2843            if let Some(span) = self.module.node_spans.get(&id).cloned() {
2844                self.error_at_span("E022", "depends_on is not allowed inside profiles", &span);
2845            }
2846            return false;
2847        }
2848        true
2849    }
2850
2851    fn parse_key_value(&mut self) -> Option<(String, NodeId)> {
2852        let (key, id) = self.parse_key_value_sep(TokenKind::Assign)?;
2853        // Dependency blocks belong to named scopes only: an inline or
2854        // array-element `depends_on` array is E022 (spec/linter.md §2).
2855        // Non-array values stay ordinary data wherever they appear.
2856        if key == "depends_on" && matches!(self.module.get(id), Some(Node::Array { .. })) {
2857            if let Some(span) = self.module.node_spans.get(&id).cloned() {
2858                self.error_at_span(
2859                    "E022",
2860                    "depends_on is only allowed as a direct table member",
2861                    &span,
2862                );
2863            }
2864        }
2865        Some((key, id))
2866    }
2867
2868    /// `key = expr` or `key: expr` (spec/dual-syntax.md §2).
2869    fn parse_key_value_sep(&mut self, sep: TokenKind) -> Option<(String, NodeId)> {
2870        let key_tok = self.expect(TokenKind::Ident)?;
2871        let key = key_tok.text.clone();
2872        self.expect(sep)?;
2873        // A `schema` key with a table value parses as a schema block
2874        // (spec §2.10); anything else stays ordinary data.
2875        if key == "schema" && matches!(self.peek_kind(), TokenKind::LBrace) {
2876            if self.array_depth > 0 {
2877                self.error_at(
2878                    "E020",
2879                    "schemas may not appear inside arrays; describe element types with Array<T>",
2880                    &key_tok,
2881                );
2882                return None;
2883            }
2884            if self.in_profile {
2885                self.error_at("E020", "schemas may not appear inside profiles", &key_tok);
2886                return None;
2887            }
2888            let node = self.parse_schema_inline(&key_tok);
2889            self.record_stmt(
2890                &key,
2891                key_tok.line,
2892                self.last_end_line(),
2893                key_tok.col.saturating_sub(1) as usize,
2894            );
2895            return Some((key, node));
2896        }
2897        // Tag Ident nodes created directly in this key's value expression
2898        // with the owning key so sibling resolution can skip self-references.
2899        let scope_start = self.ident_scopes.last().map_or(0, |s| s.len());
2900        let value = self.parse_expression();
2901        if let Some(scope) = self.ident_scopes.last_mut() {
2902            for entry in scope.iter_mut().skip(scope_start) {
2903                entry.0 = key.clone();
2904            }
2905        }
2906        Some((key, value))
2907    }
2908
2909    /// Rewrite Ident nodes collected in `idents` that match a sibling key of
2910    /// the just-parsed scope into Ref nodes with dependency edges. Runs after
2911    /// the whole scope is parsed, so forward references within the scope
2912    /// resolve too. Returns the idents that matched nothing, for fallback
2913    /// passes (profile root-fallback, spec §2.6).
2914    fn resolve_sibling_refs(
2915        &mut self,
2916        idents: Vec<(String, NodeId)>,
2917        fields: &indexmap::IndexMap<String, NodeId>,
2918    ) -> Vec<(String, NodeId)> {
2919        let mut leftover = Vec::new();
2920        for (owner_key, id) in idents {
2921            let name = match self.module.get(id) {
2922                Some(Node::Ident(n)) => n.clone(),
2923                _ => continue,
2924            };
2925            // A key's own value expression must not resolve to its own
2926            // binding; it falls through to let-binding resolution instead.
2927            if name == owner_key {
2928                leftover.push((owner_key, id));
2929                continue;
2930            }
2931            if let Some(&target) = fields.get(&name) {
2932                if let Some(slot) = self.module.get_mut(id) {
2933                    *slot = Node::Ref(target);
2934                }
2935                self.module.add_edge(target, id);
2936            } else {
2937                leftover.push((owner_key, id));
2938            }
2939        }
2940        leftover
2941    }
2942
2943    // ------------------------------------------------------------------
2944    // # @intent / @constraint / @ai_immutable  (spec/annotations.md, C-033)
2945    // ------------------------------------------------------------------
2946
2947    /// Extract annotations for a just-parsed statement (spec §1). The
2948    /// contiguous comment block directly above `stmt_line` is scanned;
2949    /// known `@…` lines attach to `node` (schemas never do), everything
2950    /// else stays an ordinary comment for the formatter. `node`'s scope
2951    /// path plus `key` becomes the enforcement path. Call sites are
2952    /// statement-level only — inline-table values never attach (their
2953    /// comments cannot precede a binding line).
2954    fn extract_annotations(&mut self, key: &str, node: NodeId, stmt_line: u32) {
2955        if matches!(self.module.get(node), Some(Node::SchemaTable { .. })) {
2956            return;
2957        }
2958        // Profile bodies take no annotations (spec §1): their paths
2959        // are variant-qualified and enforcement works on document
2960        // paths. Profiles keep their comments as plain trivia.
2961        if self.in_profile {
2962            return;
2963        }
2964        let scope = self.path_str();
2965        let full_path = if scope.is_empty() {
2966            key.to_string()
2967        } else {
2968            format!("{scope}.{key}")
2969        };
2970        // Walk upward through consecutive comment lines. A comment
2971        // sharing its line with a statement end is trailing on that
2972        // line — it belongs there, never to the block above. Matching
2973        // is by (line, file): line numbers repeat across includes.
2974        let here = self.current_file_name();
2975        let mut line = stmt_line.saturating_sub(1);
2976        let mut block: Vec<(String, Span)> = Vec::new();
2977        while line >= 1 {
2978            let comment = self
2979                .comments
2980                .iter()
2981                .zip(self.comment_files.iter())
2982                .find(|((_, s), f)| s.line == line && *f == &here)
2983                .map(|((text, span), _)| (text.clone(), span.clone()));
2984            let Some((text, span)) = comment else {
2985                break;
2986            };
2987            if self
2988                .statements
2989                .iter()
2990                .any(|st| st.end_line == line && st.file == here)
2991            {
2992                break;
2993            }
2994            block.push((text, span));
2995            if line == 1 {
2996                break;
2997            }
2998            line -= 1;
2999        }
3000        // Source order, so multi-annotation nodes read deterministically.
3001        for (text, span) in block.into_iter().rev() {
3002            if let Some(kind) = self.parse_annotation_line(&text, &span, node) {
3003                self.module
3004                    .annotations
3005                    .entry(node)
3006                    .or_default()
3007                    .push(Annotation {
3008                        kind,
3009                        path: full_path.clone(),
3010                        span,
3011                    });
3012            }
3013        }
3014    }
3015
3016    /// Parse one comment line as an annotation. Ordinary comments —
3017    /// including unknown `@…` kinds — return None silently. Malformed
3018    /// known annotations are E021 at the comment's span.
3019    fn parse_annotation_line(
3020        &mut self,
3021        text: &str,
3022        span: &Span,
3023        target: NodeId,
3024    ) -> Option<AnnotationKind> {
3025        let body = text.strip_prefix('#').unwrap_or(text).trim_start();
3026        let rest = body.strip_prefix('@')?;
3027        let kind_len = rest
3028            .chars()
3029            .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
3030            .count();
3031        let (kind, after) = rest.split_at(kind_len);
3032        match kind {
3033            "intent" | "constraint" | "ai_immutable" => {}
3034            _ => return None,
3035        }
3036        // Bare `# @ai_immutable` (no payload at all) is the flag form
3037        // of `true` — older documents used it as a plain comment, and
3038        // annotations must never turn previously-valid documents into
3039        // errors.
3040        if kind == "ai_immutable" && after.trim_start().is_empty() {
3041            return Some(AnnotationKind::AiImmutable(true));
3042        }
3043        let payload = match after.trim_start().strip_prefix(':') {
3044            Some(p) => p.trim_start(),
3045            None => {
3046                self.error_at_span(
3047                    "E021",
3048                    format!("invalid @{kind} annotation: expected ':' after the kind"),
3049                    span,
3050                );
3051                return None;
3052            }
3053        };
3054        match kind {
3055            // String literals are preferred, but bare prose is also an
3056            // intent: documentation must never punish informality, and
3057            // older documents already used free-text `@intent` comments.
3058            // A payload that opens a quote is held to literal rules.
3059            "intent" => {
3060                let wants_literal = payload.starts_with('"') || payload.starts_with('\'');
3061                match parse_annotation_string(payload) {
3062                    Ok(documented) => Some(AnnotationKind::Intent(documented)),
3063                    Err(_) if !wants_literal && !payload.is_empty() => {
3064                        Some(AnnotationKind::Intent(payload.to_string()))
3065                    }
3066                    Err(detail) => {
3067                        self.error_at_span(
3068                            "E021",
3069                            format!("invalid @intent annotation: {detail}"),
3070                            span,
3071                        );
3072                        None
3073                    }
3074                }
3075            }
3076            "ai_immutable" => match payload {
3077                "true" => Some(AnnotationKind::AiImmutable(true)),
3078                "false" => Some(AnnotationKind::AiImmutable(false)),
3079                _ => {
3080                    self.error_at_span(
3081                        "E021",
3082                        "invalid @ai_immutable annotation: expected 'true' or 'false'",
3083                        span,
3084                    );
3085                    None
3086                }
3087            },
3088            "constraint" => {
3089                if payload.is_empty() {
3090                    self.error_at_span(
3091                        "E021",
3092                        "invalid @constraint annotation: empty predicate",
3093                        span,
3094                    );
3095                    return None;
3096                }
3097                // 1-based column where the payload starts: the comment's
3098                // column plus the characters before the payload.
3099                let payload_col =
3100                    span.column + (text.chars().count() - payload.chars().count()) as u32;
3101                self.parse_constraint(payload, span, payload_col, target)
3102                    .map(|expr| AnnotationKind::Constraint {
3103                        source: payload.to_string(),
3104                        expr,
3105                    })
3106            }
3107            _ => None,
3108        }
3109    }
3110
3111    /// Parse a `@constraint` payload as a boolean expression, splicing
3112    /// nodes into the shared arena (single-line sibling of
3113    /// parse_embedded_expression: same arena swap, ident transfer, and
3114    /// span rebase, but anchored on the comment position). `value`
3115    /// references rewrite to `target` before ident transfer so the name
3116    /// can never resolve as a sibling or binding. Returns None after
3117    /// recording E021.
3118    fn parse_constraint(
3119        &mut self,
3120        payload: &str,
3121        comment: &Span,
3122        payload_col: u32,
3123        target: NodeId,
3124    ) -> Option<NodeId> {
3125        let mut lexer = Lexer::new(payload);
3126        let lexed = lexer.tokenize();
3127        let module = std::mem::replace(&mut self.module, Module::new(""));
3128        let inherited_len = self.ident_scopes.last().map_or(0, |s| s.len());
3129        let mut sub = Parser {
3130            tokens: lexed.tokens,
3131            pos: 0,
3132            module,
3133            errors: Vec::new(),
3134            ident_scopes: self.ident_scopes.clone(),
3135            depth: self.depth,
3136            options: self.options.clone(),
3137            block_depth: 0,
3138            schema_depth: self.schema_depth,
3139            array_depth: self.array_depth,
3140            comments: Vec::new(),
3141            comment_files: Vec::new(),
3142            statements: Vec::new(),
3143            blocks: Vec::new(),
3144            current_path: self.current_path.clone(),
3145            brace_tables: 0,
3146            indent_tables: 0,
3147            current_file: self.current_file.clone(),
3148            include_stack: Vec::new(),
3149            in_include: self.in_include,
3150            in_profile: self.in_profile,
3151            binding_spliced: self.binding_spliced.clone(),
3152            pending_profiles: Vec::new(),
3153            trust: self.trust.clone(),
3154        };
3155        let node_base = sub.module.arena.len() as u32;
3156        let id = sub.parse_expression();
3157        while matches!(sub.peek_kind(), TokenKind::Newline) {
3158            sub.bump();
3159        }
3160        if !matches!(sub.peek_kind(), TokenKind::Eof) {
3161            let leftover = sub.peek().clone();
3162            sub.error_at(
3163                "E021",
3164                format!(
3165                    "invalid @constraint annotation: unexpected {:?} after the predicate",
3166                    leftover.kind
3167                ),
3168                &leftover,
3169            );
3170        }
3171        // `value` ≡ the annotated node (spec §3): rewrite direct
3172        // references so they never enter sibling/binding resolution.
3173        let mut rewritten = std::collections::HashSet::new();
3174        for i in node_base..sub.module.arena.len() as u32 {
3175            let nid = NodeId(i);
3176            if matches!(sub.module.get(nid), Some(Node::Ident(name)) if name == "value") {
3177                if let Some(slot) = sub.module.get_mut(nid) {
3178                    *slot = Node::Ref(target);
3179                }
3180                sub.module.add_edge(target, nid);
3181                rewritten.insert(nid);
3182            }
3183        }
3184        let new_idents: Vec<(String, NodeId)> = sub
3185            .ident_scopes
3186            .last()
3187            .map(|s| {
3188                s[inherited_len.min(s.len())..]
3189                    .iter()
3190                    .filter(|(_, nid)| !rewritten.contains(nid))
3191                    .cloned()
3192                    .collect()
3193            })
3194            .unwrap_or_default();
3195        // Payload-local spans (line 1) land on the comment line with
3196        // columns shifted by the payload start. Payloads are single
3197        // line by construction (comment text never holds `\n`).
3198        let lift = |line_1: u32, col_1: u32| -> (u32, u32) {
3199            let line = comment.line + line_1.saturating_sub(1);
3200            let col = if line_1 <= 1 {
3201                payload_col + col_1.saturating_sub(1)
3202            } else {
3203                col_1
3204            };
3205            (line, col)
3206        };
3207        for (node, span) in sub.module.node_spans.iter_mut() {
3208            if node.0 >= node_base {
3209                let (line, col) = lift(span.line, span.column);
3210                span.line = line;
3211                span.column = col;
3212                let (line, col) = lift(span.end_line, span.end_column);
3213                span.end_line = line;
3214                span.end_column = col;
3215            }
3216        }
3217        let mut sub_errors = std::mem::take(&mut sub.errors);
3218        let mut sub_lex_errors = lexed.errors;
3219        for d in sub_errors.iter_mut().chain(sub_lex_errors.iter_mut()) {
3220            if let Some(s) = &mut d.span {
3221                let (line, col) = lift(s.line, s.column);
3222                s.line = line;
3223                s.column = col;
3224                let (line, col) = lift(s.end_line, s.end_column);
3225                s.end_line = line;
3226                s.end_column = col;
3227            }
3228            if d.file.is_none() {
3229                d.file = self.current_file_name();
3230            }
3231        }
3232        self.errors.extend(sub_lex_errors);
3233        self.errors.extend(sub_errors);
3234        self.module = std::mem::replace(&mut sub.module, Module::new(""));
3235        if let Some(scope) = self.ident_scopes.last_mut() {
3236            scope.extend(new_idents);
3237        }
3238        Some(id)
3239    }
3240
3241    // ------------------------------------------------------------------
3242    // Expression parser — classic precedence climbing
3243    // ------------------------------------------------------------------
3244
3245    fn parse_expression(&mut self) -> NodeId {
3246        let guard_tok = self.peek().clone();
3247        self.depth += 1;
3248        if self.depth > MAX_EXPR_DEPTH {
3249            self.error(
3250                "E004",
3251                format!("maximum expression nesting depth ({MAX_EXPR_DEPTH}) exceeded"),
3252            );
3253            self.depth -= 1;
3254            self.bump(); // consume the current token to guarantee progress
3255            return self.add_spanned(Node::Literal(Value::Null), &guard_tok);
3256        }
3257        let node = self.parse_or();
3258        self.depth -= 1;
3259        node
3260    }
3261
3262    fn parse_or(&mut self) -> NodeId {
3263        let mut left = self.parse_and();
3264        while matches!(self.peek_kind(), TokenKind::OrOr) {
3265            let op_tok = self.bump();
3266            let right = self.parse_and();
3267            left = self.add_spanned(
3268                Node::Binary {
3269                    op: BinOp::Or,
3270                    lhs: left,
3271                    rhs: right,
3272                },
3273                &op_tok,
3274            );
3275        }
3276        left
3277    }
3278
3279    fn parse_and(&mut self) -> NodeId {
3280        let mut left = self.parse_equality();
3281        while matches!(self.peek_kind(), TokenKind::AndAnd) {
3282            let op_tok = self.bump();
3283            let right = self.parse_equality();
3284            left = self.add_spanned(
3285                Node::Binary {
3286                    op: BinOp::And,
3287                    lhs: left,
3288                    rhs: right,
3289                },
3290                &op_tok,
3291            );
3292        }
3293        left
3294    }
3295
3296    fn parse_equality(&mut self) -> NodeId {
3297        let mut left = self.parse_comparison();
3298        loop {
3299            let op = match self.peek_kind() {
3300                TokenKind::EqEq => BinOp::Eq,
3301                TokenKind::NotEq => BinOp::Ne,
3302                _ => break,
3303            };
3304            let op_tok = self.bump();
3305            let right = self.parse_comparison();
3306            left = self.add_spanned(
3307                Node::Binary {
3308                    op,
3309                    lhs: left,
3310                    rhs: right,
3311                },
3312                &op_tok,
3313            );
3314        }
3315        left
3316    }
3317
3318    fn parse_comparison(&mut self) -> NodeId {
3319        let mut left = self.parse_term();
3320        loop {
3321            let op = match self.peek_kind() {
3322                TokenKind::Lt => BinOp::Lt,
3323                TokenKind::LtEq => BinOp::Le,
3324                TokenKind::Gt => BinOp::Gt,
3325                TokenKind::GtEq => BinOp::Ge,
3326                _ => break,
3327            };
3328            let op_tok = self.bump();
3329            let right = self.parse_term();
3330            left = self.add_spanned(
3331                Node::Binary {
3332                    op,
3333                    lhs: left,
3334                    rhs: right,
3335                },
3336                &op_tok,
3337            );
3338        }
3339        left
3340    }
3341
3342    fn parse_term(&mut self) -> NodeId {
3343        let mut left = self.parse_factor();
3344        loop {
3345            let op = match self.peek_kind() {
3346                TokenKind::Plus => BinOp::Add,
3347                TokenKind::Minus => BinOp::Sub,
3348                _ => break,
3349            };
3350            let op_tok = self.bump();
3351            let right = self.parse_factor();
3352            left = self.add_spanned(
3353                Node::Binary {
3354                    op,
3355                    lhs: left,
3356                    rhs: right,
3357                },
3358                &op_tok,
3359            );
3360        }
3361        left
3362    }
3363
3364    fn parse_factor(&mut self) -> NodeId {
3365        let mut left = self.parse_unary();
3366        loop {
3367            let op = match self.peek_kind() {
3368                TokenKind::Star => BinOp::Mul,
3369                TokenKind::Slash => BinOp::Div,
3370                _ => break,
3371            };
3372            let op_tok = self.bump();
3373            let right = self.parse_unary();
3374            left = self.add_spanned(
3375                Node::Binary {
3376                    op,
3377                    lhs: left,
3378                    rhs: right,
3379                },
3380                &op_tok,
3381            );
3382        }
3383        left
3384    }
3385
3386    fn parse_unary(&mut self) -> NodeId {
3387        match self.peek_kind() {
3388            TokenKind::Minus | TokenKind::Bang => {
3389                let op_tok = self.peek().clone();
3390                // Unary chains recurse through parse_unary, so the depth
3391                // guard must live here too (parse_expression cannot see it).
3392                self.depth += 1;
3393                if self.depth > MAX_EXPR_DEPTH {
3394                    self.error(
3395                        "E004",
3396                        format!("maximum expression nesting depth ({MAX_EXPR_DEPTH}) exceeded"),
3397                    );
3398                    self.depth -= 1;
3399                    self.bump(); // consume the operator to guarantee progress
3400                    return self.add_spanned(Node::Literal(Value::Null), &op_tok);
3401                }
3402                let op = if matches!(self.peek_kind(), TokenKind::Minus) {
3403                    UnOp::Neg
3404                } else {
3405                    UnOp::Not
3406                };
3407                self.bump();
3408                let expr = self.parse_unary();
3409                self.depth -= 1;
3410                // Affine units are order-sensitive: `-40Fahr` must negate the
3411                // literal BEFORE the Fahrenheit→kelvin transform, otherwise
3412                // the famous crossover silently breaks (spec Ch. 4 §4.1).
3413                // Linear units are unaffected (negation commutes with scaling
3414                // and clamps), so only Celsius/Fahrenheit fold here.
3415                if op == UnOp::Neg {
3416                    if let Some(folded) = self.fold_affine_negation(expr) {
3417                        return folded;
3418                    }
3419                }
3420                self.add_spanned(Node::Unary { op, expr }, &op_tok)
3421            }
3422            _ => self.parse_primary(),
3423        }
3424    }
3425
3426    /// Fold unary minus into an affine-unit quantity literal.
3427    ///
3428    /// `-40Fahr` must mean negative-forty-Fahrenheit (233.15 K), not the
3429    /// negation of positive-forty-Fahrenheit in kelvin: affine transforms
3430    /// do not commute with negation (spec Ch. 4 §4.1). Linear units are
3431    /// unaffected (negation commutes with scaling), so only
3432    /// Celsius/Fahrenheit fold here. Returns None for any other operand.
3433    fn fold_affine_negation(&mut self, expr: NodeId) -> Option<NodeId> {
3434        let value_id = match self.module.get(expr) {
3435            Some(Node::Quantity {
3436                value,
3437                unit: Unit::Celsius | Unit::Fahrenheit,
3438            }) => *value,
3439            _ => return None,
3440        };
3441        match self.module.get_mut(value_id) {
3442            Some(Node::Literal(Value::Int(n))) => {
3443                *n = n.saturating_neg();
3444            }
3445            Some(Node::Literal(Value::Float(f))) => {
3446                *f = -*f;
3447            }
3448            _ => return None,
3449        }
3450        Some(expr)
3451    }
3452
3453    // ------------------------------------------------------------------
3454    // Primary expressions
3455    // ------------------------------------------------------------------
3456
3457    fn parse_primary(&mut self) -> NodeId {
3458        let tok = self.peek().clone();
3459        match &tok.kind {
3460            TokenKind::String(src_pos) => {
3461                self.bump();
3462                let linemap = linemap_from_charmap(&tok.text, tok.line, src_pos);
3463                let marks = Self::sentinel_marks(&tok.text, &tok.kind);
3464                self.parse_string_literal(&tok.text, &tok, Some(&linemap), &marks)
3465            }
3466
3467            TokenKind::SingleString => {
3468                // Single-quoted strings are literal: `${…}` is not special
3469                // (spec/grammar.md §2.5).
3470                self.bump();
3471                let text = tok.text.clone();
3472                self.add_spanned(Node::Literal(Value::String(text)), &tok)
3473            }
3474
3475            TokenKind::MlString(src_pos) => {
3476                // Triple-double-quoted: common-indent stripping, then full
3477                // `${…}` expression interpolation (spec §1.4.1). The line
3478                // map keeps interpolation diagnostics on their true source
3479                // positions after stripping; src_pos corrects for `\n`
3480                // escapes decoded into newlines.
3481                self.bump();
3482                let (stripped, linemap, marks) =
3483                    strip_common_indent_mapped(&tok.text, tok.line, tok.col, 3, Some(src_pos));
3484                self.parse_string_literal(&stripped, &tok, Some(&linemap), &marks)
3485            }
3486
3487            TokenKind::MlSingleString => {
3488                // Triple-single-quoted: common-indent stripping, fully
3489                // literal (spec §1.4.1).
3490                self.bump();
3491                let stripped = strip_common_indent(&tok.text);
3492                self.add_spanned(Node::Literal(Value::String(stripped)), &tok)
3493            }
3494
3495            TokenKind::At => {
3496                self.bump();
3497                // Patch directives are statements, never values.
3498                if matches!(self.peek_kind(), TokenKind::Ident) {
3499                    let tag_tok = self.peek().clone();
3500                    let tag = tag_tok.text.clone();
3501                    if (tag == "patch" || tag == "remove" || tag == "rename")
3502                        && matches!(self.peek_kind_at(1), TokenKind::LParen)
3503                    {
3504                        self.error_at(
3505                            "E019",
3506                            "patch statements may only appear at document root, never as values",
3507                            &tag_tok,
3508                        );
3509                        return self.add_spanned(Node::Literal(Value::Null), &tok);
3510                    }
3511                }
3512                self.parse_semantic_block(&tok)
3513            }
3514
3515            TokenKind::Integer => {
3516                self.bump();
3517                // Out-of-range literals must never silently become 0:
3518                // E023 records the overflow and the value saturates at
3519                // i64::MAX (spec/specification.md §1.3). A leading `-`
3520                // is a separate unary operator, so `-9223372036854775808`
3521                // evaluates to `i64::MIN + 1`, still with E023.
3522                let n: i64 = match tok.text.parse() {
3523                    Ok(n) => n,
3524                    Err(_) => {
3525                        self.error_at(
3526                            "E023",
3527                            format!("integer literal out of i64 range: `{}`", tok.text),
3528                            &tok,
3529                        );
3530                        i64::MAX
3531                    }
3532                };
3533                let mut node = self.add_spanned(Node::Literal(Value::Int(n)), &tok);
3534                // optional unit suffix
3535                if let TokenKind::Unit(_) = self.peek_kind() {
3536                    let unit_tok = self.bump();
3537                    if let Some(unit) = Unit::from_suffix(&unit_tok.text) {
3538                        node = self.add_spanned(Node::Quantity { value: node, unit }, &tok);
3539                    }
3540                }
3541                node
3542            }
3543
3544            TokenKind::Float => {
3545                self.bump();
3546                let f: f64 = tok.text.parse().unwrap_or(0.0);
3547                let mut node = self.add_spanned(Node::Literal(Value::Float(f)), &tok);
3548                if let TokenKind::Unit(_) = self.peek_kind() {
3549                    let unit_tok = self.bump();
3550                    if let Some(unit) = Unit::from_suffix(&unit_tok.text) {
3551                        node = self.add_spanned(Node::Quantity { value: node, unit }, &tok);
3552                    }
3553                }
3554                node
3555            }
3556
3557            TokenKind::True => {
3558                self.bump();
3559                self.add_spanned(Node::Literal(Value::Bool(true)), &tok)
3560            }
3561            TokenKind::False => {
3562                self.bump();
3563                self.add_spanned(Node::Literal(Value::Bool(false)), &tok)
3564            }
3565            TokenKind::Null => {
3566                self.bump();
3567                self.add_spanned(Node::Literal(Value::Null), &tok)
3568            }
3569
3570            TokenKind::Env => {
3571                self.bump();
3572                self.expect(TokenKind::Dot);
3573                let var = self
3574                    .expect(TokenKind::Ident)
3575                    .map(|t| t.text)
3576                    .unwrap_or_default();
3577                let default = if matches!(self.peek_kind(), TokenKind::QuestionColon) {
3578                    self.bump();
3579                    Some(self.parse_expression())
3580                } else {
3581                    None
3582                };
3583                self.add_spanned(Node::Env { name: var, default }, &tok)
3584            }
3585
3586            TokenKind::Secret => {
3587                self.bump();
3588                // `secret(expr)` — locator is the expression; or
3589                // `secret.env(name)` — name is a string literal and the
3590                // secret is the env var's value.
3591                let from_env = if matches!(self.peek_kind(), TokenKind::Dot) {
3592                    self.bump();
3593                    if !matches!(self.peek_kind(), TokenKind::Env) {
3594                        self.error(
3595                            "E003",
3596                            "expected `env` after `secret.` (use secret.env(name))",
3597                        );
3598                    } else {
3599                        self.bump();
3600                    }
3601                    true
3602                } else {
3603                    false
3604                };
3605                self.expect(TokenKind::LParen);
3606                let arg = self.parse_expression();
3607                self.expect(TokenKind::RParen);
3608                let locator = if from_env {
3609                    match self.module.get(arg) {
3610                        Some(Node::Literal(Value::String(name))) => self.add_spanned(
3611                            Node::Env {
3612                                name: name.clone(),
3613                                default: None,
3614                            },
3615                            &tok,
3616                        ),
3617                        _ => {
3618                            self.error(
3619                                "E003",
3620                                "secret.env() requires a string literal env var name",
3621                            );
3622                            arg
3623                        }
3624                    }
3625                } else {
3626                    arg
3627                };
3628                self.add_spanned(Node::Secret { locator }, &tok)
3629            }
3630
3631            TokenKind::Ident => {
3632                self.bump();
3633                // Will be turned into Ref during build_graph if it matches a
3634                // let, or during sibling resolution if it matches a key in the
3635                // enclosing table scope.
3636                let text = tok.text.clone();
3637                let id = self.add_spanned(Node::Ident(text), &tok);
3638                if let Some(scope) = self.ident_scopes.last_mut() {
3639                    scope.push((String::new(), id));
3640                }
3641                id
3642            }
3643
3644            TokenKind::LBracket => self.parse_array(),
3645            TokenKind::LBrace => self.parse_inline_table(),
3646
3647            TokenKind::LParen => {
3648                self.bump();
3649                let expr = self.parse_expression();
3650                self.expect(TokenKind::RParen);
3651                expr
3652            }
3653
3654            _ => {
3655                self.error("E002", format!("unexpected token {:?}", tok.kind));
3656                self.bump();
3657                self.module.add_node(Node::Literal(Value::Null))
3658            }
3659        }
3660    }
3661
3662    /// Parse `@tag """…"""` — a multi-line string with a semantic tag
3663    /// (spec §1.4.1). The tag is tool treatment only; the value is the
3664    /// inner string. Unknown tags and non-multi-line payloads are E014.
3665    fn parse_semantic_block(&mut self, at_tok: &Token) -> NodeId {
3666        let tag_tok = self.peek().clone();
3667        let tag = match &tag_tok.kind {
3668            TokenKind::Ident => {
3669                self.bump();
3670                tag_tok.text.clone()
3671            }
3672            _ => {
3673                self.error_at(
3674                    "E014",
3675                    "expected a semantic tag (prompt, policy, template) after `@`",
3676                    &tag_tok,
3677                );
3678                String::new()
3679            }
3680        };
3681        let tag = match SemanticTag::parse_tag(&tag) {
3682            Some(t) => t,
3683            None => {
3684                self.error_at(
3685                    "E014",
3686                    format!("unknown semantic tag `@{tag}` (expected prompt, policy, or template)"),
3687                    &tag_tok,
3688                );
3689                SemanticTag::Prompt
3690            }
3691        };
3692        let str_tok = self.peek().clone();
3693        let inner = match &str_tok.kind {
3694            TokenKind::MlString(src_pos) => {
3695                self.bump();
3696                let (stripped, linemap, marks) = strip_common_indent_mapped(
3697                    &str_tok.text,
3698                    str_tok.line,
3699                    str_tok.col,
3700                    3,
3701                    Some(src_pos),
3702                );
3703                self.parse_string_literal(&stripped, &str_tok, Some(&linemap), &marks)
3704            }
3705            TokenKind::MlSingleString => {
3706                self.bump();
3707                let stripped = strip_common_indent(&str_tok.text);
3708                self.add_spanned(Node::Literal(Value::String(stripped)), &str_tok)
3709            }
3710            _ => {
3711                self.error_at(
3712                    "E014",
3713                    "a semantic tag must be followed by a multi-line string (`\"\"\"…\"\"\"` or `'''…'''`)",
3714                    &str_tok,
3715                );
3716                self.add_spanned(Node::Literal(Value::String(String::new())), at_tok)
3717            }
3718        };
3719        let id = self.add_spanned(Node::Semantic { tag, inner }, at_tok);
3720        // The inner→outer edge is recorded in build_graph with every other
3721        // node kind (single place for edge creation).
3722        id
3723    }
3724}
3725
3726/// Strip the common leading-whitespace prefix from a multi-line string
3727/// (spec §1.4.1): drop the leading newline when the first line is blank,
3728/// remove the longest common whitespace prefix of all non-blank lines
3729/// (tabs and spaces are distinct characters — a line whose indentation
3730/// diverges keeps its excess), and drop a trailing whitespace-only line
3731/// before the closing delimiter.
3732fn strip_common_indent(raw: &str) -> String {
3733    strip_common_indent_mapped(raw, 1, 1, 0, None).0
3734}
3735/// Where one stripped line sits in the source: the source line number and
3736/// the source column of every stripped character on that line.
3737#[derive(Debug, Clone)]
3738struct MlLineMap {
3739    src_line: u32,
3740    src_cols: Vec<u32>,
3741}
3742
3743/// Build a line map from a token's per-character source positions:
3744/// group decoded characters into lines, recording each line's source
3745/// line and per-character source columns. Used for single-line strings
3746/// (whose escapes still decode (\n, \t) and shift positions).
3747fn linemap_from_charmap(text: &str, tok_line: u32, charmap: &[(u32, u32)]) -> Vec<MlLineMap> {
3748    let chars: Vec<char> = text.chars().collect();
3749    let mut lines: Vec<(usize, usize)> = Vec::new();
3750    let mut start = 0usize;
3751    for (i, c) in chars.iter().enumerate() {
3752        if *c == '\n' {
3753            lines.push((start, i));
3754            start = i + 1;
3755        }
3756    }
3757    lines.push((start, chars.len()));
3758    lines
3759        .iter()
3760        .map(|&(s, e)| {
3761            let src_line = tok_line + pos_entry(charmap, s, (0, 0)).0;
3762            let src_cols = (s..e).map(|k| pos_entry(charmap, k, (0, 0)).1).collect();
3763            MlLineMap { src_line, src_cols }
3764        })
3765        .collect()
3766}
3767
3768/// Strip common indent and record, for every stripped line, its source
3769/// position. `tok_line`/`tok_col` locate the opening delimiter;
3770/// `src_pos` holds the source position (physical line offset, column) of
3771/// every decoded character (`None` means decoded and physical positions
3772/// coincide, as for verbatim strings). Also returns sentinel marks parallel
3773/// to the stripped text: `\$`-sentinels survive stripping and stay marked.
3774fn strip_common_indent_mapped(
3775    raw: &str,
3776    tok_line: u32,
3777    tok_col: u32,
3778    delim_len: u32,
3779    src_pos: Option<&[(u32, u32)]>,
3780) -> (String, Vec<MlLineMap>, Vec<bool>) {
3781    let mut lines: Vec<&str> = raw.split('\n').collect();
3782    // Raw line indices that survive: drop a blank first line (the newline
3783    // after the delimiter) and a whitespace-only last line (indentation
3784    // before the closing delimiter).
3785    let mut kept: Vec<usize> = (0..lines.len()).collect();
3786    if !lines.is_empty() && lines[0].trim().is_empty() {
3787        kept.remove(0);
3788        lines.remove(0);
3789    }
3790    if !lines.is_empty() && lines.last().is_some_and(|l| l.trim().is_empty()) {
3791        kept.pop();
3792        lines.pop();
3793    }
3794    // Longest shared whitespace prefix over the surviving content lines.
3795    let mut prefix: Vec<char> = Vec::new();
3796    let mut seen_content = false;
3797    for line in lines.iter() {
3798        if line.trim().is_empty() {
3799            continue;
3800        }
3801        let indent: Vec<char> = line
3802            .chars()
3803            .take_while(|c| *c == ' ' || *c == '\t')
3804            .collect();
3805        if !seen_content {
3806            prefix = indent;
3807            seen_content = true;
3808        } else {
3809            let shared = prefix
3810                .iter()
3811                .zip(indent.iter())
3812                .take_while(|(a, b)| a == b)
3813                .count();
3814            prefix.truncate(shared);
3815        }
3816        if prefix.is_empty() {
3817            break;
3818        }
3819    }
3820    let mut out = Vec::with_capacity(lines.len());
3821    let mut map = Vec::with_capacity(lines.len());
3822    // Sentinel marks parallel to the stripped text: `\$`-sentinels survive
3823    // stripping (they are never whitespace), carried per kept character.
3824    let mut out_marks: Vec<Vec<bool>> = Vec::with_capacity(lines.len());
3825    // Decoded character index of each surviving line's first character,
3826    // for looking up per-character source positions. Recomputed from the
3827    // original split (before blank-line removal): each line starts after
3828    // the previous line's characters plus its newline.
3829    let all: Vec<&str> = raw.split('\n').collect();
3830    let mut line_start: Vec<usize> = Vec::with_capacity(all.len());
3831    let mut acc = 0usize;
3832    for l in &all {
3833        line_start.push(acc);
3834        acc += l.chars().count() + 1; // +1 for the newline
3835    }
3836    let line_start_for =
3837        |raw_idx: usize| -> usize { line_start.get(raw_idx).copied().unwrap_or(0) };
3838    for (j, l) in lines.iter().enumerate() {
3839        let raw_idx = kept[j];
3840        let decoded_start = line_start_for(raw_idx);
3841        let at = |k: usize| -> (u32, u32) {
3842            match src_pos {
3843                // Marked sentinel entries inherit a real position: marks
3844                // must never reach line/column arithmetic.
3845                Some(p) => pos_entry(p, k, (raw_idx as u32, k as u32)),
3846                None => (raw_idx as u32, k as u32),
3847            }
3848        };
3849        let is_marked =
3850            |k: usize| -> bool { src_pos.and_then(|p| p.get(k)).copied() == Some(SENTINEL_POS) };
3851        let shared = if l.trim().is_empty() {
3852            0
3853        } else {
3854            prefix
3855                .iter()
3856                .zip(l.chars())
3857                .take_while(|(a, b)| *a == b)
3858                .count()
3859        };
3860        // Whitespace-only interior lines normalize to empty (spec §1.4.1).
3861        if l.trim().is_empty() {
3862            let (line_off, _) = at(decoded_start);
3863            map.push(MlLineMap {
3864                src_line: tok_line + line_off,
3865                src_cols: Vec::new(),
3866            });
3867            out.push(String::new());
3868            out_marks.push(Vec::new());
3869            continue;
3870        }
3871        let text: String = l.chars().skip(shared).collect();
3872        // Source line/columns of the kept characters.
3873        let first_kept = decoded_start + shared;
3874        let (line_off, _) = at(first_kept.min(decoded_start + l.chars().count()));
3875        let src_line = tok_line + line_off;
3876        let cols: Vec<u32> = if src_pos.is_some() {
3877            (0..text.chars().count())
3878                .map(|c| at(first_kept + c).1)
3879                .collect()
3880        } else if raw_idx == 0 {
3881            // Synthetic positions: content follows the opening delimiter.
3882            (0..text.chars().count())
3883                .map(|c| tok_col + delim_len + shared as u32 + c as u32)
3884                .collect()
3885        } else {
3886            (0..text.chars().count())
3887                .map(|c| shared as u32 + 1 + c as u32)
3888                .collect()
3889        };
3890        // Marks for the kept characters: a kept `\$`-sentinel stays marked
3891        // exactly when its source entry is marked.
3892        let mline: Vec<bool> = text
3893            .chars()
3894            .enumerate()
3895            .map(|(c, ch)| ch == ESCAPED_DOLLAR && is_marked(first_kept + c))
3896            .collect();
3897        out.push(text);
3898        out_marks.push(mline);
3899        map.push(MlLineMap {
3900            src_line,
3901            src_cols: cols,
3902        });
3903    }
3904    let mut marks = Vec::new();
3905    for (j, mline) in out_marks.iter().enumerate() {
3906        if j > 0 {
3907            marks.push(false);
3908        }
3909        marks.extend(mline.iter().copied());
3910    }
3911    (out.join("\n"), map, marks)
3912}
3913
3914impl Parser {
3915    // ------------------------------------------------------------------
3916    // @patch / @remove / @rename  (spec §2.9, C-028)
3917    // ------------------------------------------------------------------
3918
3919    /// Parse a patch directive statement. The `@tag` and `(` are already
3920    /// confirmed by the caller; `at_tok` is the `@` token for spans.
3921    /// Directives live at document root only and are recorded in
3922    /// encounter order on `module.patches`. The full statement is always
3923    /// consumed (even on error) so the enclosing loop makes progress.
3924    fn parse_patch_statement(&mut self, at_tok: &Token) {
3925        let nested = !self.current_path.is_empty();
3926        self.bump(); // consume `@`
3927        let tag_tok = self.bump(); // consume patch/remove/rename
3928        let tag = tag_tok.text.clone();
3929        if self.expect(TokenKind::LParen).is_none() {
3930            return;
3931        }
3932        let path_tok = self.peek().clone();
3933        let path = match &path_tok.kind {
3934            TokenKind::String(_) | TokenKind::SingleString => {
3935                self.bump();
3936                path_tok.text.clone()
3937            }
3938            _ => {
3939                self.error_at("E019", "patch paths must be string literals", &path_tok);
3940                return;
3941            }
3942        };
3943        if path.contains("${")
3944            || Self::has_marked(&path, &Self::sentinel_marks(&path, &path_tok.kind))
3945        {
3946            self.error_at(
3947                "E019",
3948                "interpolation in patch paths is a hard error; paths must be static",
3949                &path_tok,
3950            );
3951            return;
3952        }
3953        if let Err(why) = validate_patch_path(&path) {
3954            self.error_at(
3955                "E019",
3956                format!("invalid patch path `{path}`: {why}"),
3957                &path_tok,
3958            );
3959            return;
3960        }
3961        let op = match tag.as_str() {
3962            "remove" => {
3963                if self.expect(TokenKind::RParen).is_none() {
3964                    return;
3965                }
3966                PatchOp::Remove { path: path.clone() }
3967            }
3968            "rename" => {
3969                if self.expect(TokenKind::Comma).is_none() {
3970                    return;
3971                }
3972                let new_tok = self.peek().clone();
3973                let new_name = match &new_tok.kind {
3974                    TokenKind::String(_) | TokenKind::SingleString => {
3975                        self.bump();
3976                        Self::render_marked(
3977                            &new_tok.text,
3978                            &Self::sentinel_marks(&new_tok.text, &new_tok.kind),
3979                        )
3980                    }
3981                    _ => {
3982                        self.error_at(
3983                            "E019",
3984                            "rename needs a new key name as a string literal",
3985                            &new_tok,
3986                        );
3987                        return;
3988                    }
3989                };
3990                if new_name.contains('.') || new_name.contains('[') || new_name.is_empty() {
3991                    self.error_at(
3992                        "E019",
3993                        "rename target must be a plain key name in the same parent",
3994                        &new_tok,
3995                    );
3996                    return;
3997                }
3998                if self.expect(TokenKind::RParen).is_none() {
3999                    return;
4000                }
4001                PatchOp::Rename {
4002                    from: path.clone(),
4003                    to: new_name,
4004                }
4005            }
4006            _ => {
4007                // patch
4008                if self.expect(TokenKind::Comma).is_none() {
4009                    return;
4010                }
4011                let value = self.parse_expression();
4012                if self.expect(TokenKind::RParen).is_none() {
4013                    return;
4014                }
4015                PatchOp::Set {
4016                    path: path.clone(),
4017                    value,
4018                }
4019            }
4020        };
4021        let stmt_key = match &op {
4022            PatchOp::Set { path, .. } => format!("@patch({path})"),
4023            PatchOp::Remove { path } => format!("@remove({path})"),
4024            PatchOp::Rename { from, .. } => format!("@rename({from})"),
4025        };
4026        if nested {
4027            self.error_at(
4028                "E019",
4029                "patch statements may only appear at document root",
4030                at_tok,
4031            );
4032            return;
4033        }
4034        self.record_stmt(
4035            &stmt_key,
4036            at_tok.line,
4037            self.last_end_line(),
4038            at_tok.col.saturating_sub(1) as usize,
4039        );
4040        let id = self.add_spanned(Node::PatchDirective { op }, at_tok);
4041        // The value expression participates in the graph so patches are
4042        // dependency-aware; paths are static and need no edges.
4043        if let Some(Node::PatchDirective {
4044            op: PatchOp::Set { value, .. },
4045        }) = self.module.get(id)
4046        {
4047            let value = *value;
4048            self.module.add_edge(value, id);
4049        }
4050        self.module.patches.push(id);
4051    }
4052
4053    // ------------------------------------------------------------------
4054    // Schema blocks (spec §2.10, C-030)
4055    // ------------------------------------------------------------------
4056
4057    /// Parse an inline schema table after `schema =` (the `{` is next).
4058    /// Returns a SchemaTable node for later extraction.
4059    fn parse_schema_inline(&mut self, key_tok: &Token) -> NodeId {
4060        let open = self
4061            .expect(TokenKind::LBrace)
4062            .unwrap_or_else(|| key_tok.clone());
4063        let (fields, closed) = self.parse_schema_entries(SchemaEnd::Brace);
4064        self.add_spanned(Node::SchemaTable { fields, closed }, &open)
4065    }
4066
4067    /// Parse a `schema { … }` brace block (the `{` is next).
4068    fn parse_schema_brace(&mut self, key_tok: &Token) -> NodeId {
4069        let open = self
4070            .expect(TokenKind::LBrace)
4071            .unwrap_or_else(|| key_tok.clone());
4072        self.brace_tables += 1;
4073        let (fields, closed) = self.parse_schema_entries(SchemaEnd::Brace);
4074        self.add_spanned(Node::SchemaTable { fields, closed }, &open)
4075    }
4076
4077    /// Parse a `schema:` indented body (the Indent is next).
4078    fn parse_schema_indent(&mut self, key_tok: &Token, _level: usize) -> NodeId {
4079        let (fields, closed) = self.parse_schema_entries(SchemaEnd::Indent);
4080        self.add_spanned(Node::SchemaTable { fields, closed }, key_tok)
4081    }
4082
4083    /// Parse `name = type` entries (plus the reserved `closed` flag)
4084    /// until the terminator. Returns the fields and the closed flag.
4085    fn parse_schema_entries(&mut self, end: SchemaEnd) -> (IndexMap<String, NodeId>, bool) {
4086        let mut fields = IndexMap::new();
4087        let mut closed = false;
4088        loop {
4089            self.skip_newlines();
4090            match self.peek_kind() {
4091                TokenKind::Eof => {
4092                    // An indentation-form schema ends naturally at EOF
4093                    // (like any indent block), but a brace form without
4094                    // its closer is a syntax error (E001), never silent.
4095                    if matches!(end, SchemaEnd::Brace) {
4096                        self.error(
4097                            "E001",
4098                            "unclosed schema block: expected `}` before end of file",
4099                        );
4100                    }
4101                    break;
4102                }
4103                TokenKind::RBrace if matches!(end, SchemaEnd::Brace) => {
4104                    self.bump();
4105                    break;
4106                }
4107                TokenKind::Dedent if matches!(end, SchemaEnd::Indent) => {
4108                    self.bump();
4109                    break;
4110                }
4111                TokenKind::RBrace | TokenKind::Dedent => {
4112                    // Wrong closer for this form: stop and let the caller
4113                    // diagnose (keeps progress either way).
4114                    break;
4115                }
4116                TokenKind::Comma => {
4117                    self.bump();
4118                    continue;
4119                }
4120                TokenKind::Ident => {
4121                    let name_tok = self.bump();
4122                    let name = name_tok.text.clone();
4123                    if self.expect(TokenKind::Assign).is_none() {
4124                        continue;
4125                    }
4126                    if name == "closed" {
4127                        match self.peek_kind() {
4128                            TokenKind::True => {
4129                                self.bump();
4130                                closed = true;
4131                            }
4132                            TokenKind::False => {
4133                                self.bump();
4134                                closed = false;
4135                            }
4136                            _ => {
4137                                let tok = self.peek().clone();
4138                                self.error_at("E020", "closed must be true or false", &tok);
4139                            }
4140                        }
4141                        self.skip_newlines();
4142                        if matches!(self.peek_kind(), TokenKind::Comma) {
4143                            self.bump();
4144                        }
4145                        continue;
4146                    }
4147                    let id = self.parse_schema_type(&name_tok);
4148                    // Tag arg idents with the entry name for sibling
4149                    // resolution parity with value parsing.
4150                    // Schema entries are statements for comment
4151                    // attachment (spec/dual-syntax.md §7): per-field
4152                    // documentation comments attach like value
4153                    // bindings, so the C-048 docgen renders them.
4154                    // Top-level tables only (schema_depth == 0):
4155                    // nested inline element fields would record
4156                    // document-level statements under bare names the
4157                    // docgen never renders, shadowing real ones.
4158                    if self.schema_depth == 0 {
4159                        self.record_stmt(
4160                            &name,
4161                            name_tok.line,
4162                            self.last_end_line(),
4163                            name_tok.col.saturating_sub(1) as usize,
4164                        );
4165                    }
4166                    fields.insert(name, id);
4167                    self.skip_newlines();
4168                    if matches!(self.peek_kind(), TokenKind::Comma) {
4169                        self.bump();
4170                    }
4171                }
4172                _ => {
4173                    let tok = self.peek().clone();
4174                    self.error_at("E020", "expected a schema entry (`name = Type`)", &tok);
4175                    self.bump();
4176                }
4177            }
4178        }
4179        (fields, closed)
4180    }
4181
4182    /// Guard one level of schema-type recursion (`Array<…>` nesting,
4183    /// nested `Table {…}`) against stack exhaustion. Returns true when
4184    /// the limit is exceeded: the counter is already restored and E004
4185    /// recorded, so the caller bails out with a fallback field. Every
4186    /// `true`…`false` pair brackets exactly one recursive call, so a
4187    /// matching `exit_schema_depth` must follow the non-bail path.
4188    fn enter_schema_depth(&mut self, tok: &Token) -> bool {
4189        self.schema_depth += 1;
4190        if self.schema_depth > MAX_SCHEMA_DEPTH {
4191            self.schema_depth -= 1;
4192            self.error_at(
4193                "E004",
4194                format!("maximum schema nesting depth ({MAX_SCHEMA_DEPTH}) exceeded"),
4195                tok,
4196            );
4197            return true;
4198        }
4199        false
4200    }
4201
4202    fn exit_schema_depth(&mut self) {
4203        self.schema_depth = self.schema_depth.saturating_sub(1);
4204    }
4205
4206    /// Parse one schema type expression into a SchemaField,
4207    /// SchemaTable, or SchemaArray node (spec §2.10). `name_tok`
4208    /// positions the entry for diagnostics.
4209    fn parse_schema_type(&mut self, _name_tok: &Token) -> NodeId {
4210        let tok = self.peek().clone();
4211        let TokenKind::Ident = &tok.kind else {
4212            self.error_at("E020", "expected a schema type", &tok);
4213            self.bump();
4214            return self.fallback_schema_field(&tok);
4215        };
4216        let type_name = tok.text.clone();
4217        // Array<T>: element type in angle brackets (nestable).
4218        if type_name == "Array" {
4219            self.bump();
4220            if self.expect(TokenKind::Lt).is_none() {
4221                self.error_at("E020", "Array needs an element type: Array<T>", &tok);
4222                return self.fallback_schema_field(&tok);
4223            }
4224            let elem_tok = self.peek().clone();
4225            if self.enter_schema_depth(&tok) {
4226                return self.fallback_schema_field(&tok);
4227            }
4228            let element = self.parse_schema_type(&elem_tok);
4229            self.exit_schema_depth();
4230            if self.expect(TokenKind::Gt).is_none() {
4231                return self.fallback_schema_field(&tok);
4232            }
4233            return self.add_spanned(Node::SchemaArray { element }, &tok);
4234        }
4235        // Enum("a", "b"): positional strings, at least one.
4236        if type_name == "Enum" {
4237            self.bump();
4238            if self.expect(TokenKind::LParen).is_none() {
4239                self.error_at("E020", "Enum needs a value list: Enum(\"a\", ...)", &tok);
4240                return self.fallback_schema_field(&tok);
4241            }
4242            let mut values = Vec::new();
4243            let mut deprecated: Option<String> = None;
4244            loop {
4245                self.skip_newlines();
4246                match self.peek_kind() {
4247                    TokenKind::RParen | TokenKind::Eof => break,
4248                    TokenKind::Comma => {
4249                        self.bump();
4250                        continue;
4251                    }
4252                    TokenKind::String(_) | TokenKind::SingleString => {
4253                        let v = self.bump();
4254                        values.push(Self::render_marked(
4255                            &v.text,
4256                            &Self::sentinel_marks(&v.text, &v.kind),
4257                        ));
4258                    }
4259                    TokenKind::Ident if self.peek().text == "deprecated" => {
4260                        // Trailing `deprecated = true | "msg"` inside
4261                        // Enum parens (spec/linter.md §3): every leaf
4262                        // type accepts it, including Enum.
4263                        self.bump();
4264                        if self.expect(TokenKind::Assign).is_none() {
4265                            continue;
4266                        }
4267                        deprecated = match self.peek_kind() {
4268                            TokenKind::True => {
4269                                self.bump();
4270                                Some(String::new())
4271                            }
4272                            TokenKind::String(_) | TokenKind::SingleString => {
4273                                let v = self.bump();
4274                                Some(Self::render_marked(
4275                                    &v.text,
4276                                    &Self::sentinel_marks(&v.text, &v.kind),
4277                                ))
4278                            }
4279                            _ => {
4280                                let bad = self.peek().clone();
4281                                self.error_at(
4282                                    "E020",
4283                                    "deprecated takes `true` or a string message",
4284                                    &bad,
4285                                );
4286                                None
4287                            }
4288                        };
4289                    }
4290                    _ => {
4291                        let bad = self.peek().clone();
4292                        self.error_at("E020", "Enum values must be string literals", &bad);
4293                        self.bump();
4294                    }
4295                }
4296                self.skip_newlines();
4297                if matches!(self.peek_kind(), TokenKind::Comma) {
4298                    self.bump();
4299                }
4300            }
4301            self.expect(TokenKind::RParen);
4302            if values.is_empty() {
4303                self.error_at("E020", "Enum needs at least one value", &tok);
4304            }
4305            let field = SchemaField {
4306                typ: ActiveType::Enum(values),
4307                min: None,
4308                max: None,
4309                min_len: None,
4310                max_len: None,
4311                pattern: None,
4312                default: None,
4313                deprecated,
4314                span: Some(span_of(&tok)),
4315            };
4316            return self.add_spanned(Node::SchemaField { field }, &tok);
4317        }
4318        // Table { ... }: nested schema table (closed flag included).
4319        if type_name == "Table" && matches!(self.peek_kind_at(1), TokenKind::LBrace) {
4320            self.bump();
4321            let open = self
4322                .expect(TokenKind::LBrace)
4323                .unwrap_or_else(|| tok.clone());
4324            if self.enter_schema_depth(&tok) {
4325                return self.fallback_schema_field(&tok);
4326            }
4327            let (fields, closed) = self.parse_schema_entries(SchemaEnd::Brace);
4328            self.exit_schema_depth();
4329            return self.add_spanned(Node::SchemaTable { fields, closed }, &open);
4330        }
4331        // Bare or call-form type.
4332        let Some(typ) = ActiveType::bare(&type_name) else {
4333            self.error_at("E020", format!("unknown schema type `{type_name}`"), &tok);
4334            self.bump();
4335            return self.fallback_schema_field(&tok);
4336        };
4337        self.bump();
4338        if !matches!(self.peek_kind(), TokenKind::LParen) {
4339            let field = SchemaField::bare(typ, Some(span_of(&tok)));
4340            return self.add_spanned(Node::SchemaField { field }, &tok);
4341        }
4342        // `Table` takes no call arguments (spec §2.10): nested schemas
4343        // use `Table { … }`, handled above. Anything parenthesized here
4344        // is E020; the balanced list is skipped so recovery stays clean.
4345        if matches!(typ, ActiveType::Table { .. }) {
4346            self.error_at(
4347                "E020",
4348                "Table takes no call arguments; write Table { ... } for a nested schema",
4349                &tok,
4350            );
4351            self.bump(); // consume `(`
4352            let mut paren_depth = 1usize;
4353            while paren_depth > 0 {
4354                match self.peek_kind() {
4355                    TokenKind::LParen => {
4356                        self.bump();
4357                        paren_depth += 1;
4358                    }
4359                    TokenKind::RParen => {
4360                        self.bump();
4361                        paren_depth -= 1;
4362                    }
4363                    TokenKind::Eof => break,
4364                    _ => {
4365                        self.bump();
4366                    }
4367                }
4368            }
4369            return self.fallback_schema_field(&tok);
4370        }
4371        self.bump(); // consume `(`
4372        let mut field = SchemaField::bare(typ.clone(), Some(span_of(&tok)));
4373        loop {
4374            self.skip_newlines();
4375            match self.peek_kind() {
4376                TokenKind::RParen | TokenKind::Eof => break,
4377                TokenKind::Comma => {
4378                    self.bump();
4379                    continue;
4380                }
4381                TokenKind::Ident => {
4382                    let arg_tok = self.bump();
4383                    let arg = arg_tok.text.clone();
4384                    if self.expect(TokenKind::Assign).is_none() {
4385                        continue;
4386                    }
4387                    // `deprecated` is static text on every leaf type
4388                    // (spec/linter.md §3, C-042): `true` or a string
4389                    // literal migration message — never an expression.
4390                    if arg == "deprecated" {
4391                        let value = self.parse_expression();
4392                        // First wins, like every other argument.
4393                        if field.deprecated.is_none() {
4394                            match self.module.get(value) {
4395                                Some(Node::Literal(Value::Bool(true))) => {
4396                                    field.deprecated = Some(String::new());
4397                                }
4398                                Some(Node::Literal(Value::String(msg))) => {
4399                                    field.deprecated = Some(msg.clone());
4400                                }
4401                                _ => {
4402                                    self.error_at(
4403                                        "E020",
4404                                        "deprecated takes `true` or a string message",
4405                                        &arg_tok,
4406                                    );
4407                                }
4408                            }
4409                        }
4410                        continue;
4411                    }
4412                    let value = self.parse_expression();
4413                    if !Self::store_schema_arg(&mut field, &typ, &arg, value) {
4414                        self.error_at(
4415                            "E020",
4416                            format!("unknown argument `{arg}` for {}", typ.name()),
4417                            &arg_tok,
4418                        );
4419                    }
4420                }
4421                _ => {
4422                    let bad = self.peek().clone();
4423                    self.error_at("E020", "expected a named argument (`name = value`)", &bad);
4424                    self.bump();
4425                }
4426            }
4427            self.skip_newlines();
4428            if matches!(self.peek_kind(), TokenKind::Comma) {
4429                self.bump();
4430            }
4431        }
4432        self.expect(TokenKind::RParen);
4433        self.add_spanned(Node::SchemaField { field }, &tok)
4434    }
4435
4436    /// Placeholder field used for error recovery (E020 already recorded).
4437    fn fallback_schema_field(&mut self, tok: &Token) -> NodeId {
4438        let field = SchemaField::bare(ActiveType::Any, Some(span_of(tok)));
4439        self.add_spanned(Node::SchemaField { field }, tok)
4440    }
4441
4442    /// Store one named constraint argument if the type allows it.
4443    /// Returns false for unknown arguments (E020 by the caller).
4444    /// A repeated argument keeps the first value.
4445    fn store_schema_arg(
4446        field: &mut SchemaField,
4447        typ: &ActiveType,
4448        arg: &str,
4449        value: NodeId,
4450    ) -> bool {
4451        use ActiveType::*;
4452        let slot = match (typ, arg) {
4453            (Int | Float | Duration | Bytes, "min") => &mut field.min,
4454            (Int | Float | Duration | Bytes, "max") => &mut field.max,
4455            (String, "min_len") => &mut field.min_len,
4456            (String, "max_len") => &mut field.max_len,
4457            (String, "pattern") => &mut field.pattern,
4458            (_, "default") => &mut field.default,
4459            _ => return false,
4460        };
4461        if slot.is_none() {
4462            *slot = Some(value);
4463        }
4464        true
4465    }
4466    /// Sentinel-mark helpers (spec/grammar.md §1.4 string fidelity).
4467    ///
4468    /// The lexer pushes SENTINEL_POS for characters decoded from a `\$` escape
4469    /// and real positions otherwise. A genuine U+E000 in source keeps its real
4470    /// position, so the parser can tell `\$` (marked) from user data (unmarked):
4471    /// marked sentinels render as `$`, every other char — including a literal
4472    /// U+E000 — is kept verbatim. Kinds without a position vector can never
4473    /// carry a sentinel.
4474    /// Per-character marks parallel to `text.chars()`: true where the char is a
4475    /// `\$`-sentinel.
4476    fn sentinel_marks(text: &str, kind: &TokenKind) -> Vec<bool> {
4477        let pos = match kind {
4478            TokenKind::String(p) | TokenKind::MlString(p) => p,
4479            _ => return vec![false; text.chars().count()],
4480        };
4481        text.chars()
4482            .enumerate()
4483            .map(|(i, c)| c == ESCAPED_DOLLAR && pos.get(i) == Some(&SENTINEL_POS))
4484            .collect()
4485    }
4486
4487    /// Render string text: marked sentinels become `$`; everything else stays.
4488    fn render_marked(text: &str, marks: &[bool]) -> String {
4489        let mut out = String::with_capacity(text.len());
4490        for (i, c) in text.chars().enumerate() {
4491            if c == ESCAPED_DOLLAR && marks.get(i) == Some(&true) {
4492                out.push('$');
4493            } else {
4494                out.push(c);
4495            }
4496        }
4497        out
4498    }
4499
4500    /// Render one already-split char.
4501    fn render_char(c: char, marked: bool) -> char {
4502        if c == ESCAPED_DOLLAR && marked {
4503            '$'
4504        } else {
4505            c
4506        }
4507    }
4508
4509    /// True if the text holds at least one `\$`-sentinel.
4510    fn has_marked(text: &str, marks: &[bool]) -> bool {
4511        text.chars()
4512            .enumerate()
4513            .any(|(i, c)| c == ESCAPED_DOLLAR && marks.get(i) == Some(&true))
4514    }
4515
4516    fn parse_string_literal(
4517        &mut self,
4518        raw: &str,
4519        tok: &Token,
4520        linemap: Option<&[MlLineMap]>,
4521        marks: &[bool],
4522    ) -> NodeId {
4523        // The lexer marks `\$` with a sentinel; convert only marked chars so
4524        // a literal U+E000 in user data survives verbatim.
4525        if !raw.contains("${") && !Self::has_marked(raw, marks) {
4526            return self.add_spanned(Node::Literal(Value::String(raw.to_string())), tok);
4527        }
4528
4529        let chars: Vec<char> = raw.chars().collect();
4530        let marked = |i: usize| -> bool { marks.get(i) == Some(&true) };
4531        let mut parts: Vec<InterpPart> = Vec::new();
4532        let mut literal = String::new();
4533        let mut i = 0usize;
4534
4535        while i < chars.len() {
4536            // An escaped dollar is literal text, never interpolation — and
4537            // because the lexer marked it, a real `\` before `${` stays a
4538            // backslash (parity is preserved).
4539            if chars[i] == ESCAPED_DOLLAR && marked(i) {
4540                literal.push('$');
4541                i += 1;
4542                continue;
4543            }
4544            if chars[i] == '$' && i + 1 < chars.len() && chars[i + 1] == '{' {
4545                match self.find_interpolation_end(&chars, i + 2) {
4546                    Some(end) => {
4547                        if !literal.is_empty() {
4548                            parts.push(InterpPart::Literal(std::mem::take(&mut literal)));
4549                        }
4550                        let inner: String = chars[i + 2..end].iter().collect();
4551                        // Offset of the expression's first character inside
4552                        // the token, for precise diagnostic spans.
4553                        let offset = i + 2;
4554                        let id = self.parse_embedded_expression(&inner, tok, raw, offset, linemap);
4555                        parts.push(InterpPart::Expr(id));
4556                        i = end + 1;
4557                        continue;
4558                    }
4559                    None => {
4560                        // Unterminated interpolation: diagnose and treat the
4561                        // remainder as literal text (sentinels still render).
4562                        let span = self.interp_span(tok, raw, i, linemap);
4563                        self.error_at(
4564                            "E012",
4565                            "unterminated interpolation: `${` has no matching `}`",
4566                            &span,
4567                        );
4568                        for (k, c) in chars[i..].iter().enumerate() {
4569                            literal.push(Self::render_char(*c, marked(i + k)));
4570                        }
4571                        break;
4572                    }
4573                }
4574            }
4575            literal.push(Self::render_char(chars[i], marked(i)));
4576            i += 1;
4577        }
4578        if !literal.is_empty() {
4579            parts.push(InterpPart::Literal(literal));
4580        }
4581
4582        self.add_spanned(Node::Interpolate { parts }, tok)
4583    }
4584
4585    /// Find the `}` matching a `${` that starts at `start`, skipping string
4586    /// literals, nested interpolations, and plain brace nesting (an inline
4587    /// table inside `${ … }` is an expression with its own braces).
4588    fn find_interpolation_end(&self, chars: &[char], start: usize) -> Option<usize> {
4589        let mut i = start;
4590        let mut depth = 0usize;
4591        let mut in_string: Option<char> = None;
4592        let mut escaped = false;
4593        while i < chars.len() {
4594            let c = chars[i];
4595            if let Some(quote) = in_string {
4596                if escaped {
4597                    escaped = false;
4598                } else if c == '\\' {
4599                    escaped = true;
4600                } else if c == quote {
4601                    in_string = None;
4602                }
4603                i += 1;
4604                continue;
4605            }
4606            match c {
4607                '"' | '\'' => in_string = Some(c),
4608                '$' if i + 1 < chars.len() && chars[i + 1] == '{' => {
4609                    depth += 1;
4610                    i += 2;
4611                    continue;
4612                }
4613                '{' => depth += 1,
4614                '}' => {
4615                    if depth == 0 {
4616                        return Some(i);
4617                    }
4618                    depth -= 1;
4619                }
4620                _ => {}
4621            }
4622            i += 1;
4623        }
4624        None
4625    }
4626
4627    /// A span pointing inside the string token at character offset `offset`
4628    /// (offset 0 is the first character after the opening quote; the lexer's
4629    /// token text is the decoded content, so offsets are approximate when the
4630    /// source contained escapes). With a line map (multi-line strings),
4631    /// `stripped` locates the offset's stripped line and the map converts
4632    /// it to the true source position.
4633    fn interp_span(
4634        &self,
4635        tok: &Token,
4636        stripped: &str,
4637        offset: usize,
4638        linemap: Option<&[MlLineMap]>,
4639    ) -> Token {
4640        let mut t = tok.clone();
4641        match linemap {
4642            Some(map) => {
4643                // Walk the stripped text to the offset's stripped line and
4644                // column, then map through the per-character source columns.
4645                let mut line = 0usize;
4646                let mut col = 0usize;
4647                for c in stripped.chars().take(offset) {
4648                    if c == '\n' {
4649                        line += 1;
4650                        col = 0;
4651                    } else {
4652                        col += 1;
4653                    }
4654                }
4655                match map
4656                    .get(line)
4657                    .and_then(|m| m.src_cols.get(col).map(|&c| (m.src_line, c)))
4658                {
4659                    Some((src_line, src_col)) => {
4660                        t.line = src_line;
4661                        t.col = src_col;
4662                        t.end_line = src_line;
4663                        t.end_col = src_col + 1;
4664                    }
4665                    None => {
4666                        t.col = tok.col + 1 + offset as u32;
4667                        t.end_col = t.col + 1;
4668                    }
4669                }
4670            }
4671            None => {
4672                t.col = tok.col + 1 + offset as u32;
4673                t.end_col = t.col + 1;
4674            }
4675        }
4676        t
4677    }
4678
4679    /// Parse the contents of `${ … }` as a full expression, splicing the
4680    /// resulting nodes into this module's arena. Supports nested
4681    /// interpolation, env defaults, arithmetic, and every other expression
4682    /// form; sub-expression diagnostics have their spans rebased onto the
4683    /// enclosing string's position (`stripped` is the scanned string and
4684    /// `offset` the expression's first character within it; `linemap`
4685    /// maps stripped lines to source positions for multi-line strings).
4686    fn parse_embedded_expression(
4687        &mut self,
4688        text: &str,
4689        tok: &Token,
4690        stripped: &str,
4691        offset: usize,
4692        linemap: Option<&[MlLineMap]>,
4693    ) -> NodeId {
4694        // Embedded expressions are not laid out: trim the padding so the
4695        // sub-lexer never measures indentation inside `${ … }`. Count
4696        // trimmed characters (not bytes) — `offset` is a char count used
4697        // by span rebasing.
4698        let leading_ws = text.chars().count() - text.trim_start().chars().count();
4699        let text = text.trim();
4700        let offset = offset + leading_ws;
4701        if text.is_empty() {
4702            let span = self.interp_span(tok, stripped, offset, linemap);
4703            self.error_at(
4704                "E013",
4705                "empty interpolation: `${}` has no expression",
4706                &span,
4707            );
4708            return self.add_spanned(Node::Literal(Value::Null), tok);
4709        }
4710        let mut lexer = Lexer::new(text);
4711        let lexed = lexer.tokenize();
4712
4713        // Anchor of this expression within the stripped string: the
4714        // stripped line/column where the expression's first character
4715        // sits. Sub-lexer spans are expression-local; adding the anchor
4716        // lands them in the stripped frame, and the line map converts
4717        // stripped coordinates to source positions exactly once — for
4718        // direct spans and (recursively) for nested spans alike, because
4719        // nested calls first land their spans in *their* parent
4720        // expression's frame, which this same step then lifts.
4721        let (anchor_line, anchor_col) = if linemap.is_some() {
4722            let mut al = 0u32;
4723            let mut ac = 0u32;
4724            for c in stripped.chars().take(offset) {
4725                if c == '\n' {
4726                    al += 1;
4727                    ac = 0;
4728                } else {
4729                    ac += 1;
4730                }
4731            }
4732            (al, ac)
4733        } else {
4734            (0, 0)
4735        };
4736
4737        // Move the arena into a sub-parser so embedded nodes land in the
4738        // same module.
4739        let module = std::mem::replace(&mut self.module, Module::new(""));
4740        // Length of the inherited scope: only identifiers created *by this
4741        // expression* are transferred back, so repeated interpolations cannot
4742        // duplicate the enclosing scope.
4743        let inherited_len = self.ident_scopes.last().map_or(0, |s| s.len());
4744        let mut sub = Parser {
4745            tokens: lexed.tokens,
4746            pos: 0,
4747            module,
4748            errors: Vec::new(),
4749            ident_scopes: self.ident_scopes.clone(),
4750            depth: self.depth,
4751            options: self.options.clone(),
4752            block_depth: 0,
4753            schema_depth: self.schema_depth,
4754            comments: Vec::new(),
4755            comment_files: Vec::new(),
4756            statements: Vec::new(),
4757            blocks: Vec::new(),
4758            current_path: self.current_path.clone(),
4759            brace_tables: 0,
4760            indent_tables: 0,
4761            current_file: self.current_file.clone(),
4762            include_stack: Vec::new(),
4763            in_include: self.in_include,
4764            in_profile: self.in_profile,
4765            array_depth: self.array_depth,
4766            binding_spliced: self.binding_spliced.clone(),
4767            pending_profiles: Vec::new(),
4768            trust: self.trust.clone(),
4769        };
4770        // Arena ids below this base predate the embedded expression (the
4771        // sub-parser shares the outer arena); only spans at or above it
4772        // may be rebased.
4773        let node_base = sub.module.arena.len() as u32;
4774        let id = sub.parse_expression();
4775        // Anything left over that is not a newline is unconsumed input.
4776        while matches!(sub.peek_kind(), TokenKind::Newline) {
4777            sub.bump();
4778        }
4779        if !matches!(sub.peek_kind(), TokenKind::Eof) {
4780            let leftover = sub.peek().clone();
4781            sub.error_at(
4782                "E002",
4783                format!(
4784                    "unexpected {:?} after the interpolated expression",
4785                    leftover.kind
4786                ),
4787                &leftover,
4788            );
4789        }
4790
4791        let new_idents: Vec<(String, NodeId)> = sub
4792            .ident_scopes
4793            .last()
4794            .map(|s| s[inherited_len.min(s.len())..].to_vec())
4795            .unwrap_or_default();
4796        let mut sub_errors = std::mem::take(&mut sub.errors);
4797        let mut sub_repairs = std::mem::take(&mut sub.module.healing);
4798        let mut sub_lex_errors = lexed.errors;
4799        let mut sub_lex_repairs = lexed.repairs;
4800        // Embedded nodes carry sub-parser-relative spans in node_spans;
4801        // rebase only the spans created by this embedded expression
4802        // (arena ids at or above node_base) so evaluation-time errors
4803        // point correctly without touching outer nodes.
4804        // Must run before the module swap below. With a line map the
4805        // tokens were pre-shifted into the stripped frame, so the map
4806        // lookup suffices; without one, the same tok+offset shift as
4807        // diagnostics applies (this also lands nested spans in the
4808        // stripped frame for the outer map pass).
4809        // Lift an expression-local span into source coordinates. With a
4810        // line map, the anchor lands the span in the stripped frame and
4811        // the map converts stripped coordinates to source positions;
4812        // nested spans compose because each level's map targets its own
4813        // parent frame. Without a map, the tok+offset shift lands it in
4814        // source coordinates directly.
4815        let lift = |line_1: u32, col_1: u32| -> Option<(u32, u32)> {
4816            match linemap {
4817                Some(map) => {
4818                    let sl = anchor_line as usize + (line_1 as usize).saturating_sub(1);
4819                    let sc = if line_1 <= 1 {
4820                        anchor_col as usize + (col_1 as usize).saturating_sub(1)
4821                    } else {
4822                        (col_1 as usize).saturating_sub(1)
4823                    };
4824                    let m = map.get(sl)?;
4825                    let col = m.src_cols.get(sc).copied()?;
4826                    Some((m.src_line, col))
4827                }
4828                None => {
4829                    let line = tok.line + line_1.saturating_sub(1);
4830                    let col = if line_1 <= 1 {
4831                        tok.col + offset as u32 + col_1.saturating_sub(1)
4832                    } else {
4833                        col_1
4834                    };
4835                    Some((line, col))
4836                }
4837            }
4838        };
4839        for (node, span) in sub.module.node_spans.iter_mut() {
4840            if node.0 >= node_base {
4841                if let Some((line, col)) = lift(span.line, span.column) {
4842                    span.line = line;
4843                    span.column = col;
4844                }
4845                if let Some((line, col)) = lift(span.end_line, span.end_column) {
4846                    span.end_line = line;
4847                    span.end_column = col;
4848                }
4849            }
4850        }
4851        self.module = std::mem::replace(&mut sub.module, Module::new(""));
4852
4853        if let Some(scope) = self.ident_scopes.last_mut() {
4854            scope.extend(new_idents);
4855        }
4856        // Rebase embedded diagnostics onto the string's source position
4857        // through the same lift: expression-local → stripped → source.
4858        let rebase = |d: &mut Diagnostic| {
4859            if let Some(s) = &mut d.span {
4860                if let Some((line, col)) = lift(s.line, s.column) {
4861                    s.line = line;
4862                    s.column = col;
4863                }
4864                if let Some((line, col)) = lift(s.end_line, s.end_column) {
4865                    s.end_line = line;
4866                    s.end_column = col;
4867                }
4868            }
4869        };
4870        for d in sub_lex_errors
4871            .iter_mut()
4872            .chain(sub_lex_repairs.iter_mut())
4873            .chain(sub_errors.iter_mut())
4874            .chain(sub_repairs.iter_mut())
4875        {
4876            rebase(d);
4877        }
4878        // Sub-lexer diagnostics belong to the same file as the enclosing
4879        // string (current_file already tracks include nesting).
4880        let sub_file = self.current_file_name();
4881        for d in sub_lex_errors.iter_mut().chain(sub_lex_repairs.iter_mut()) {
4882            if d.file.is_none() {
4883                d.file = sub_file.clone();
4884            }
4885        }
4886        self.errors.extend(sub_lex_errors);
4887        self.errors.extend(sub_errors);
4888        self.module.healing.extend(sub_lex_repairs);
4889        self.module.healing.extend(sub_repairs);
4890        id
4891    }
4892
4893    fn parse_array(&mut self) -> NodeId {
4894        let open = self.peek().clone();
4895        self.bump(); // [
4896                     // Schema blocks are banned inside array elements (spec §2.10):
4897                     // the counter covers inline tables at any depth within.
4898        self.array_depth += 1;
4899        let mut elements = Vec::new();
4900        loop {
4901            self.skip_newlines();
4902            // RBrace is a mismatched closer: stop so the closing-delimiter
4903            // healer (H004) can see it instead of parse_primary eating it.
4904            if matches!(
4905                self.peek_kind(),
4906                TokenKind::RBracket | TokenKind::RBrace | TokenKind::Eof
4907            ) {
4908                break;
4909            }
4910            if matches!(self.peek_kind(), TokenKind::Comma) {
4911                // Stray or repeated comma: heal as a removal.
4912                let comma = self.bump();
4913                if !self.heal(
4914                    "H002",
4915                    "removed stray comma".into(),
4916                    &comma,
4917                    Repair {
4918                        original: ",".into(),
4919                        suggested: String::new(),
4920                        kind: RepairKind::RemoveTrailingComma,
4921                        safe: true,
4922                    },
4923                    thresholds::AUTO_APPLY + 0.03,
4924                ) {
4925                    self.error("E002", "unexpected comma");
4926                }
4927                continue;
4928            }
4929            elements.push(self.parse_expression());
4930            self.skip_newlines();
4931            if matches!(self.peek_kind(), TokenKind::Comma) {
4932                let comma = self.bump();
4933                self.skip_newlines();
4934                if matches!(
4935                    self.peek_kind(),
4936                    TokenKind::RBracket | TokenKind::RBrace | TokenKind::Eof
4937                ) {
4938                    self.heal(
4939                        "H002",
4940                        "removed trailing comma before closing bracket".into(),
4941                        &comma,
4942                        Repair {
4943                            original: ",".into(),
4944                            suggested: String::new(),
4945                            kind: RepairKind::RemoveTrailingComma,
4946                            safe: true,
4947                        },
4948                        thresholds::AUTO_APPLY + 0.03,
4949                    );
4950                }
4951            } else if !matches!(
4952                self.peek_kind(),
4953                TokenKind::RBracket | TokenKind::RBrace | TokenKind::Eof
4954            ) {
4955                // Missing comma between elements.
4956                let tok = self.peek().clone();
4957                if !self.heal(
4958                    "H003",
4959                    "inserted missing comma between array elements".into(),
4960                    &tok,
4961                    Repair {
4962                        original: String::new(),
4963                        suggested: ",".into(),
4964                        kind: RepairKind::Other("insert_comma".into()),
4965                        safe: false,
4966                    },
4967                    thresholds::HIGH + 0.05, // 0.90 — auto-apply with warning
4968                ) {
4969                    self.error(
4970                        "E001",
4971                        format!("expected `,` between array elements, found {:?}", tok.kind),
4972                    );
4973                }
4974            }
4975        }
4976        self.expect_closing_healing(TokenKind::RBracket, "H004");
4977        self.array_depth = self.array_depth.saturating_sub(1);
4978        self.add_spanned(Node::Array { elements }, &open)
4979    }
4980
4981    fn parse_inline_table(&mut self) -> NodeId {
4982        let open = self.peek().clone();
4983        self.bump(); // {
4984        self.ident_scopes.push(Vec::new());
4985        let mut fields = indexmap::IndexMap::new();
4986        loop {
4987            self.skip_newlines();
4988            if matches!(
4989                self.peek_kind(),
4990                TokenKind::RBrace | TokenKind::RBracket | TokenKind::Eof
4991            ) {
4992                break;
4993            }
4994            if matches!(self.peek_kind(), TokenKind::Comma) {
4995                let comma = self.bump();
4996                if !self.heal(
4997                    "H002",
4998                    "removed stray comma".into(),
4999                    &comma,
5000                    Repair {
5001                        original: ",".into(),
5002                        suggested: String::new(),
5003                        kind: RepairKind::RemoveTrailingComma,
5004                        safe: true,
5005                    },
5006                    thresholds::AUTO_APPLY + 0.03,
5007                ) {
5008                    self.error("E002", "unexpected comma");
5009                }
5010                continue;
5011            }
5012            if let Some((k, id)) = self.parse_key_value() {
5013                let span = self.module.node_spans.get(&id).cloned();
5014                self.note_duplicate_key(&k, span, fields.contains_key(&k));
5015                fields.insert(k, id);
5016            } else {
5017                break;
5018            }
5019            self.skip_newlines();
5020            if matches!(self.peek_kind(), TokenKind::Comma) {
5021                let comma = self.bump();
5022                self.skip_newlines();
5023                if matches!(
5024                    self.peek_kind(),
5025                    TokenKind::RBrace | TokenKind::RBracket | TokenKind::Eof
5026                ) {
5027                    self.heal(
5028                        "H002",
5029                        "removed trailing comma before closing brace".into(),
5030                        &comma,
5031                        Repair {
5032                            original: ",".into(),
5033                            suggested: String::new(),
5034                            kind: RepairKind::RemoveTrailingComma,
5035                            safe: true,
5036                        },
5037                        thresholds::AUTO_APPLY + 0.03,
5038                    );
5039                }
5040            } else if !matches!(
5041                self.peek_kind(),
5042                TokenKind::RBrace | TokenKind::RBracket | TokenKind::Eof
5043            ) {
5044                let tok = self.peek().clone();
5045                if !self.heal(
5046                    "H003",
5047                    "inserted missing comma between table entries".into(),
5048                    &tok,
5049                    Repair {
5050                        original: String::new(),
5051                        suggested: ",".into(),
5052                        kind: RepairKind::Other("insert_comma".into()),
5053                        safe: false,
5054                    },
5055                    thresholds::HIGH + 0.05, // 0.90 — auto-apply with warning
5056                ) {
5057                    self.error(
5058                        "E001",
5059                        format!("expected `,` between table entries, found {:?}", tok.kind),
5060                    );
5061                }
5062            }
5063        }
5064        self.expect_closing_healing(TokenKind::RBrace, "H004");
5065        let idents = self.ident_scopes.pop().unwrap_or_default();
5066        let _ = self.resolve_sibling_refs(idents, &fields);
5067        self.add_spanned(Node::Table { fields }, &open)
5068    }
5069
5070    // ------------------------------------------------------------------
5071    // Dependency-graph construction & Ident → Ref resolution
5072    // ------------------------------------------------------------------
5073
5074    fn build_graph(&mut self) {
5075        let bindings = self.module.bindings.clone();
5076        // Declared dependencies become edges (spec/linter.md §2):
5077        // unresolvable paths are skipped here and reported as L002 by
5078        // the linter; declared cycles surface as S001 like all cycles.
5079        // Cloned up front: resolution borrows the module.
5080        let declared: Vec<(NodeId, Vec<String>)> = self
5081            .module
5082            .depends_on
5083            .iter()
5084            .map(|(id, dep)| (*id, dep.paths.clone()))
5085            .collect();
5086        for (scope, paths) in &declared {
5087            for path in paths {
5088                if let Some(target) = self.module.resolve_value_path(path) {
5089                    self.module.add_edge(target, *scope);
5090                }
5091            }
5092        }
5093
5094        for i in 0..self.module.arena.len() {
5095            let id = NodeId(i as u32);
5096            let node = self.module.get(id).cloned();
5097            match node {
5098                Some(Node::Ident(name)) => {
5099                    if let Some(&target) = bindings.get(&name) {
5100                        if let Some(slot) = self.module.get_mut(id) {
5101                            *slot = Node::Ref(target);
5102                        }
5103                        // edge: dependency → dependent
5104                        self.module.add_edge(target, id);
5105                    }
5106                }
5107                Some(Node::Env {
5108                    default: Some(def), ..
5109                }) => {
5110                    self.module.add_edge(def, id);
5111                }
5112                Some(Node::Interpolate { parts }) => {
5113                    for part in &parts {
5114                        if let InterpPart::Expr(expr_id) = part {
5115                            self.module.add_edge(*expr_id, id);
5116                        }
5117                    }
5118                }
5119                Some(Node::Binary { lhs, rhs, .. }) => {
5120                    self.module.add_edge(lhs, id);
5121                    self.module.add_edge(rhs, id);
5122                }
5123                Some(Node::Unary { expr, .. }) => {
5124                    self.module.add_edge(expr, id);
5125                }
5126                Some(Node::Quantity { value, .. }) => {
5127                    self.module.add_edge(value, id);
5128                }
5129                Some(Node::Semantic { inner, .. }) => {
5130                    self.module.add_edge(inner, id);
5131                }
5132                Some(Node::SchemaField { field }) => {
5133                    // Constraint arguments evaluate with the document;
5134                    // C-031 reads their values during validation.
5135                    for arg in [
5136                        field.min,
5137                        field.max,
5138                        field.min_len,
5139                        field.max_len,
5140                        field.pattern,
5141                        field.default,
5142                    ]
5143                    .into_iter()
5144                    .flatten()
5145                    {
5146                        self.module.add_edge(arg, id);
5147                    }
5148                }
5149                Some(Node::SchemaTable { fields, .. }) => {
5150                    for child in fields.values() {
5151                        self.module.add_edge(*child, id);
5152                    }
5153                }
5154                Some(Node::SchemaArray { element }) => {
5155                    self.module.add_edge(element, id);
5156                }
5157                Some(Node::Array { elements }) => {
5158                    for e in &elements {
5159                        self.module.add_edge(*e, id);
5160                    }
5161                }
5162                Some(Node::Table { fields }) => {
5163                    for (_, fid) in &fields {
5164                        self.module.add_edge(*fid, id);
5165                    }
5166                }
5167                Some(Node::Secret { locator }) => {
5168                    self.module.add_edge(locator, id);
5169                }
5170                _ => {}
5171            }
5172        }
5173    }
5174}