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