Skip to main content

sui_spec/
ast_graph.rs

1//! L1 AstGraph — the parsed, canonicalized, content-addressed form of
2//! a single Nix expression (one `.nix` file or one inline expression).
3//!
4//! Mirrors `lockfile_graph` to a tee:
5//!
6//! 1. Parse via rnix exactly once.
7//! 2. Walk the rnix typed AST and emit a dense `Vec<AstNode>` keyed by
8//!    [`NodeId`] (u32). Every reference between nodes is a `NodeId`,
9//!    so the archive is pointer-free.
10//! 3. rkyv-archive the graph and BLAKE3 the bytes → cache key for
11//!    [`sui_graph_store::GraphKind::Ast`].
12//! 4. Cold cost: rnix parse + walk. Warm cost: mmap + cast. The latter
13//!    is the budget every eval-cache lookup pays after the first
14//!    invocation pays the former.
15//!
16//! ## Coverage
17//!
18//! The typed AST covers the 80% of Nix language constructs the rio fleet
19//! and nixpkgs hit on the critical path. Constructs we don't model yet
20//! land in [`AstNodeKind::Unknown`] with the source text preserved
21//! verbatim — forward-compat, never panics. As we add more variants,
22//! older blobs keep round-tripping because rkyv appends new variants
23//! by tag.
24//!
25//! Modeled today:
26//!
27//! * Literals — `Int`, `Float`, `Bool`, `Null`, `Str`, `IndentedStr`,
28//!   `Path`
29//! * References — `Ident`, `Select` (attrset.a.b with optional fallback)
30//! * Containers — `List`, `AttrSet` (`rec` or not), `Inherit` clauses
31//! * Bindings — `LetIn`, `With`, `Assert`
32//! * Functions — `Lambda` (formal-args destructuring), `Apply`
33//! * Control — `IfThenElse`
34//! * Operators — `BinOp`, `UnaryOp`, `HasAttr`
35//! * Forward-compat — `Unknown { kind, source_text }`
36//!
37//! ## Determinism
38//!
39//! Node ids are assigned in **post-order** traversal of the rnix AST:
40//! the recursive builder emits each child node before it emits its
41//! parent, so the root expression is the **last** node and `root_id`
42//! is `(nodes.len() - 1) as u32`. (Unlike `lockfile_graph` whose root
43//! is always 0 because the graph is built via BFS from a known root
44//! name — for ASTs there's no nameable root, just whatever the source
45//! parses to.) Source text in `Unknown` is taken verbatim from the
46//! rnix CST so re-parsing it round-trips. The same input source →
47//! same node count → same archive bytes → same BLAKE3, end-to-end.
48//!
49//! ## Behavior contract
50//!
51//! This module **adds** a typed archive form of Nix expressions. It
52//! does not replace the existing rnix → sui-bytecode compilation path
53//! (`sui-bytecode/src/compiler.rs`), which remains the only producer of
54//! bytecode chunks. The AstGraph is consumed by the L1 substrate (eval
55//! cache key derivation, module-graph compiler input, daemon hot
56//! cache) — not by the VM directly.
57
58use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
59use rnix::ast::{self, AstToken, HasEntry};
60use rowan::ast::AstNode;
61use serde::{Deserialize, Serialize};
62use tatara_lisp::DeriveTataraDomain;
63
64use crate::SpecError;
65
66/// Dense node identifier within an [`AstGraph`].
67pub type NodeId = u32;
68
69/// 32-byte BLAKE3 content hash; same shape as
70/// `lockfile_graph::CanonicalGraphHash`. Kept duplicated rather than
71/// pulled in to keep the module's dependency surface tight.
72#[derive(
73    DeriveTataraDomain,
74    Serialize,
75    Deserialize,
76    Archive,
77    RkyvSerialize,
78    RkyvDeserialize,
79    Debug,
80    Clone,
81    Copy,
82    PartialEq,
83    Eq,
84    Hash,
85)]
86#[tatara(keyword = "defast-graph-hash")]
87#[rkyv(derive(Debug))]
88pub struct AstGraphHash {
89    pub bytes: [u8; 32],
90}
91
92/// Which surface dialect a parsed [`AstGraph`] came from. The IR
93/// itself is dialect-agnostic — this discriminator anchors the
94/// bidirectional render pipeline + lets transformation passes preserve
95/// (or change) the source dialect.
96///
97/// Sui's stance: both dialects produce the **same** typed IR. The
98/// downstream pipeline (eval cache, derivation builder, module-graph
99/// compiler, daemon hot cache) never branches on dialect. Only the
100/// surface (parse + render) cares.
101#[derive(
102    Serialize,
103    Deserialize,
104    Archive,
105    RkyvSerialize,
106    RkyvDeserialize,
107    Debug,
108    Clone,
109    Copy,
110    PartialEq,
111    Eq,
112    Hash,
113)]
114#[rkyv(derive(Debug))]
115pub enum SourceDialect {
116    /// `.nix` source via the rnix parser. Production today.
117    Nix,
118    /// `.tlisp` source via the tatara-lisp parser. Queued — the
119    /// parser + lowering exist as a typed seam (see
120    /// [`AstGraph::from_tlisp_source`]) but the full lowering of every
121    /// `AstNodeKind` variant is a focused follow-up ship.
122    Tlisp,
123    /// A graph stitched together from BOTH dialects (e.g. a `.nix`
124    /// flake importing a `.tlisp` module, or a `.tlisp` overlay
125    /// transforming a `.nix` config). Rendering this requires keeping
126    /// per-node provenance — `AstNodeForm` gains an optional
127    /// `dialect_origin` field in a later ship.
128    Mixed,
129}
130
131/// The AST graph proper. `nodes[0]` is always the root expression.
132#[derive(
133    DeriveTataraDomain,
134    Serialize,
135    Deserialize,
136    Archive,
137    RkyvSerialize,
138    RkyvDeserialize,
139    Debug,
140    Clone,
141    PartialEq,
142)]
143#[tatara(keyword = "defast-graph")]
144#[rkyv(derive(Debug))]
145pub struct AstGraph {
146    /// Which surface dialect this graph was lowered from. Today only
147    /// `Nix` is implemented; `Tlisp` is reserved so the typed IR is
148    /// stable across the future dialect-lowering work. **The IR
149    /// downstream of this field is dialect-agnostic** — every
150    /// consumer (eval cache, module-graph compiler, derivation
151    /// builder) reads `AstGraph` without knowing which dialect
152    /// produced it. Bidirectional rendering (back to `.nix` /
153    /// `.tlisp` source text) hangs off this discriminator.
154    pub dialect: SourceDialect,
155    /// rnix grammar version the source was parsed against. Today this
156    /// is locked to the bundled rnix major (0.14). Bumping is a
157    /// migration boundary.
158    pub grammar_version: u32,
159    /// Index of the root expression in [`Self::nodes`]. Today, this is
160    /// always `(nodes.len() - 1) as u32` because the builder is
161    /// post-order — children are pushed before their parent.
162    pub root_id: NodeId,
163    /// Dense node table.
164    pub nodes: Vec<AstNodeForm>,
165    /// BLAKE3 of the rkyv archive bytes. Populated by
166    /// [`AstGraph::archive_and_hash`].
167    pub canonical_hash: AstGraphHash,
168}
169
170/// One AST node in the graph.
171#[derive(
172    DeriveTataraDomain,
173    Serialize,
174    Deserialize,
175    Archive,
176    RkyvSerialize,
177    RkyvDeserialize,
178    Debug,
179    Clone,
180    PartialEq,
181)]
182#[tatara(keyword = "defast-node")]
183#[rkyv(derive(Debug))]
184pub struct AstNodeForm {
185    pub id: NodeId,
186    pub kind: AstNodeKind,
187}
188
189/// Discriminator for every node variant. Stable IDs by name; append
190/// new variants at the bottom of the enum, never reorder (rkyv
191/// archive compatibility).
192#[derive(
193    Serialize,
194    Deserialize,
195    Archive,
196    RkyvSerialize,
197    RkyvDeserialize,
198    Debug,
199    Clone,
200    PartialEq,
201)]
202#[rkyv(derive(Debug))]
203pub enum AstNodeKind {
204    // ── Literals ──────────────────────────────────────────────────
205    Int(i64),
206    Float(f64),
207    Bool(bool),
208    Null,
209    /// Quoted string (possibly with interpolation, modeled as a
210    /// concatenation of segments).
211    Str { segments: Vec<StrSegment> },
212    /// `''…''` indented string.
213    IndentedStr { segments: Vec<StrSegment> },
214    Path(String),
215
216    // ── References ────────────────────────────────────────────────
217    Ident(String),
218    /// `expr.attr.path` (with optional `or fallback`).
219    Select {
220        target: NodeId,
221        path: Vec<String>,
222        fallback: Option<NodeId>,
223    },
224    /// `expr ? attr.path` — attribute-presence test.
225    HasAttr { target: NodeId, path: Vec<String> },
226
227    // ── Containers ────────────────────────────────────────────────
228    List(Vec<NodeId>),
229    AttrSet {
230        recursive: bool,
231        entries: Vec<AttrEntry>,
232        inherits: Vec<InheritClause>,
233    },
234
235    // ── Bindings ──────────────────────────────────────────────────
236    LetIn {
237        bindings: Vec<AttrEntry>,
238        inherits: Vec<InheritClause>,
239        body: NodeId,
240    },
241    With {
242        env: NodeId,
243        body: NodeId,
244    },
245    Assert {
246        condition: NodeId,
247        body: NodeId,
248    },
249
250    // ── Functions ─────────────────────────────────────────────────
251    Lambda {
252        param: LambdaParam,
253        body: NodeId,
254    },
255    Apply {
256        function: NodeId,
257        argument: NodeId,
258    },
259
260    // ── Control ───────────────────────────────────────────────────
261    IfThenElse {
262        condition: NodeId,
263        then_branch: NodeId,
264        else_branch: NodeId,
265    },
266
267    // ── Operators ─────────────────────────────────────────────────
268    BinOp {
269        op: BinaryOp,
270        left: NodeId,
271        right: NodeId,
272    },
273    UnaryOp {
274        op: UnaryOp,
275        operand: NodeId,
276    },
277
278    // ── Forward-compat ────────────────────────────────────────────
279    /// Construct we don't model yet. `source_text` is the verbatim
280    /// span from the rnix CST so the underlying meaning is recoverable.
281    Unknown {
282        kind: String,
283        source_text: String,
284    },
285}
286
287/// One entry in an `AttrSet` or `LetIn` bindings list.
288#[derive(
289    Serialize,
290    Deserialize,
291    Archive,
292    RkyvSerialize,
293    RkyvDeserialize,
294    Debug,
295    Clone,
296    PartialEq,
297)]
298#[rkyv(derive(Debug))]
299pub struct AttrEntry {
300    /// Dotted attribute path (`a.b.c` → `["a", "b", "c"]`).
301    pub path: Vec<String>,
302    pub value: NodeId,
303}
304
305/// One `inherit` clause: `inherit (source) attr1 attr2;` or
306/// `inherit attr1 attr2;` (source = None).
307#[derive(
308    Serialize,
309    Deserialize,
310    Archive,
311    RkyvSerialize,
312    RkyvDeserialize,
313    Debug,
314    Clone,
315    PartialEq,
316    Eq,
317)]
318#[rkyv(derive(Debug))]
319pub struct InheritClause {
320    pub source: Option<NodeId>,
321    pub attrs: Vec<String>,
322}
323
324/// Lambda parameter shape: a bare identifier, or formal-args
325/// destructuring, or both (`name @ { … }`).
326#[derive(
327    Serialize,
328    Deserialize,
329    Archive,
330    RkyvSerialize,
331    RkyvDeserialize,
332    Debug,
333    Clone,
334    PartialEq,
335)]
336#[rkyv(derive(Debug))]
337pub enum LambdaParam {
338    /// `x:`
339    Ident(String),
340    /// `{ a, b ? default, ... } [@ name]:`
341    Pattern {
342        binding_name: Option<String>,
343        formals: Vec<Formal>,
344        accepts_extra: bool,
345    },
346}
347
348/// One named formal arg in a destructuring lambda.
349#[derive(
350    Serialize,
351    Deserialize,
352    Archive,
353    RkyvSerialize,
354    RkyvDeserialize,
355    Debug,
356    Clone,
357    PartialEq,
358    Eq,
359)]
360#[rkyv(derive(Debug))]
361pub struct Formal {
362    pub name: String,
363    pub default: Option<NodeId>,
364}
365
366/// One segment of a possibly-interpolated string.
367#[derive(
368    Serialize,
369    Deserialize,
370    Archive,
371    RkyvSerialize,
372    RkyvDeserialize,
373    Debug,
374    Clone,
375    PartialEq,
376)]
377#[rkyv(derive(Debug))]
378pub enum StrSegment {
379    Literal(String),
380    /// `${expr}` interpolation.
381    Interpolation(NodeId),
382}
383
384/// Binary operator. Tags are stable; appending allowed.
385#[derive(
386    Serialize,
387    Deserialize,
388    Archive,
389    RkyvSerialize,
390    RkyvDeserialize,
391    Debug,
392    Clone,
393    Copy,
394    PartialEq,
395    Eq,
396    Hash,
397)]
398#[rkyv(derive(Debug))]
399pub enum BinaryOp {
400    /// `+`
401    Add,
402    /// `-`
403    Sub,
404    /// `*`
405    Mul,
406    /// `/`
407    Div,
408    /// `==`
409    Eq,
410    /// `!=`
411    NotEq,
412    /// `<`
413    Lt,
414    /// `<=`
415    Le,
416    /// `>`
417    Gt,
418    /// `>=`
419    Ge,
420    /// `&&`
421    And,
422    /// `||`
423    Or,
424    /// `->`
425    Implies,
426    /// `//`
427    Update,
428    /// `++`
429    Concat,
430    /// `|>` (Nix 2.18+) — pipe right: `x |> f` ≡ `f x`.
431    PipeRight,
432    /// `<|` (Nix 2.18+) — pipe left: `f <| x` ≡ `f x`.
433    PipeLeft,
434}
435
436/// Unary operator.
437#[derive(
438    Serialize,
439    Deserialize,
440    Archive,
441    RkyvSerialize,
442    RkyvDeserialize,
443    Debug,
444    Clone,
445    Copy,
446    PartialEq,
447    Eq,
448    Hash,
449)]
450#[rkyv(derive(Debug))]
451pub enum UnaryOp {
452    /// `-`
453    Neg,
454    /// `!`
455    Not,
456}
457
458/// Errors produced when materializing an [`AstGraph`] from source.
459#[derive(Debug, thiserror::Error)]
460pub enum AstGraphError {
461    #[error("rnix parse error(s): {0}")]
462    Parse(String),
463    #[error("rnix produced no root expression")]
464    NoRoot,
465    #[error("rkyv archive of canonical AST graph failed: {0}")]
466    Archive(String),
467    #[error("{what} (not yet implemented; tracked in the dialect-rendering ship)")]
468    Unimplemented { what: &'static str },
469}
470
471impl AstGraph {
472    /// Parse + canonicalize source text into a typed AST graph.
473    ///
474    /// # Errors
475    ///
476    /// - [`AstGraphError::Parse`] if rnix can't parse the source.
477    /// - [`AstGraphError::NoRoot`] if the parse succeeds but produces
478    ///   an empty root (e.g. comments-only input).
479    pub fn from_source(source: &str) -> Result<Self, AstGraphError> {
480        let parse = rnix::Root::parse(source);
481        if !parse.errors().is_empty() {
482            let msg = parse
483                .errors()
484                .iter()
485                .map(|e| e.to_string())
486                .collect::<Vec<_>>()
487                .join("; ");
488            return Err(AstGraphError::Parse(msg));
489        }
490        let root = parse.tree();
491        let expr = root.expr().ok_or(AstGraphError::NoRoot)?;
492
493        let mut builder = Builder::default();
494        let root_id = builder.lower_expr(&expr);
495        // Builder emits children before parents, so the root is the
496        // last node and `root_id == nodes.len() - 1`. Assert that here
497        // in debug builds; downstream consumers depend on it.
498        debug_assert_eq!(root_id as usize + 1, builder.nodes.len());
499
500        Ok(Self {
501            dialect: SourceDialect::Nix,
502            grammar_version: RNIX_GRAMMAR_VERSION,
503            root_id,
504            nodes: builder.nodes,
505            canonical_hash: AstGraphHash { bytes: [0u8; 32] },
506        })
507    }
508
509    /// Parse + lower a tatara-lisp source into the universal IR.
510    ///
511    /// **Status**: typed seam only. Today this returns an `Unknown`-
512    /// kinded root with the source preserved verbatim so callers can
513    /// already exercise the dialect-aware downstream pipeline. The
514    /// real lowering — mapping each `defast-node` Lisp form to the
515    /// matching [`AstNodeKind`] variant — lands in the focused
516    /// `.tlisp` dialect ship.
517    ///
518    /// # Errors
519    ///
520    /// Always succeeds today (every input is captured as `Unknown`).
521    /// Future versions will return [`AstGraphError::Parse`] when the
522    /// tatara-lisp reader rejects the input.
523    pub fn from_tlisp_source(source: &str) -> Result<Self, AstGraphError> {
524        let mut nodes = Vec::with_capacity(1);
525        nodes.push(AstNodeForm {
526            id: 0,
527            kind: AstNodeKind::Unknown {
528                kind: "tlisp-source-pending-lowering".to_string(),
529                source_text: source.to_string(),
530            },
531        });
532        Ok(Self {
533            dialect: SourceDialect::Tlisp,
534            grammar_version: TLISP_GRAMMAR_VERSION,
535            root_id: 0,
536            nodes,
537            canonical_hash: AstGraphHash { bytes: [0u8; 32] },
538        })
539    }
540
541    /// Render this graph back into Nix source text.
542    ///
543    /// **Status**: API seam only. The renderer that emits canonical
544    /// `.nix` syntax for every [`AstNodeKind`] variant lands in the
545    /// dialect-rendering ship.
546    ///
547    /// # Errors
548    ///
549    /// Always returns [`AstGraphError::Unimplemented`] today.
550    pub fn to_nix_source(&self) -> Result<String, AstGraphError> {
551        Err(AstGraphError::Unimplemented {
552            what: "AstGraph::to_nix_source — queued",
553        })
554    }
555
556    /// Render this graph as tatara-lisp source text.
557    ///
558    /// **Status**: API seam only. See [`Self::to_nix_source`].
559    ///
560    /// # Errors
561    ///
562    /// Always returns [`AstGraphError::Unimplemented`] today.
563    pub fn to_tlisp_source(&self) -> Result<String, AstGraphError> {
564        Err(AstGraphError::Unimplemented {
565            what: "AstGraph::to_tlisp_source — queued",
566        })
567    }
568
569    /// Same two-pass shape as `LockfileGraph::archive_and_hash` — the
570    /// hash is part of the archive, so it can't be computed before the
571    /// archive exists.
572    ///
573    /// # Errors
574    ///
575    /// [`AstGraphError::Archive`] if rkyv refuses the graph shape.
576    pub fn archive_and_hash(mut self) -> Result<(Self, Vec<u8>), AstGraphError> {
577        let initial = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
578            .map_err(|e| AstGraphError::Archive(e.to_string()))?;
579        let hash = blake3::hash(&initial);
580        self.canonical_hash = AstGraphHash { bytes: hash.into() };
581        let stamped = rkyv::to_bytes::<rkyv::rancor::Error>(&self)
582            .map_err(|e| AstGraphError::Archive(e.to_string()))?;
583        Ok((self, stamped.to_vec()))
584    }
585}
586
587/// Bumped when we change the on-disk graph shape in a way that
588/// requires re-parsing source. rkyv handles append-only variant
589/// changes natively; this version is for cases like "renamed an
590/// existing variant" where blobs need to be invalidated.
591pub const RNIX_GRAMMAR_VERSION: u32 = 1;
592
593/// Bumped when the tatara-lisp dialect's lowering schema changes in
594/// a way that requires re-parsing. Today: 1 (the stub).
595pub const TLISP_GRAMMAR_VERSION: u32 = 1;
596
597// ── Builder ──────────────────────────────────────────────────────
598
599#[derive(Default)]
600struct Builder {
601    nodes: Vec<AstNodeForm>,
602}
603
604impl Builder {
605    fn push(&mut self, kind: AstNodeKind) -> NodeId {
606        let id = self.nodes.len() as NodeId;
607        self.nodes.push(AstNodeForm { id, kind });
608        id
609    }
610
611    fn unknown(&mut self, expr: &ast::Expr, label: &str) -> NodeId {
612        let source_text = expr.syntax().to_string();
613        self.push(AstNodeKind::Unknown {
614            kind: label.to_string(),
615            source_text,
616        })
617    }
618
619    fn lower_expr(&mut self, expr: &ast::Expr) -> NodeId {
620        match expr {
621            ast::Expr::Literal(lit) => self.lower_literal(lit),
622            ast::Expr::Str(s) => self.lower_str(s, /* indented= */ false),
623            ast::Expr::Ident(ident) => {
624                let text = ident
625                    .ident_token()
626                    .map(|t| t.text().to_string())
627                    .unwrap_or_default();
628                self.push(AstNodeKind::Ident(text))
629            }
630            // rnix splits the path syntax into four variants depending
631            // on whether it's absolute, relative, home-relative, or a
632            // <name> search path. Carry the verbatim source text in all
633            // four cases; downstream consumers parse it themselves if
634            // they need the typed form.
635            ast::Expr::PathAbs(p) => {
636                let text = p.syntax().to_string();
637                self.push(AstNodeKind::Path(text))
638            }
639            ast::Expr::PathRel(p) => {
640                let text = p.syntax().to_string();
641                self.push(AstNodeKind::Path(text))
642            }
643            ast::Expr::PathHome(p) => {
644                let text = p.syntax().to_string();
645                self.push(AstNodeKind::Path(text))
646            }
647            ast::Expr::PathSearch(p) => {
648                let text = p.syntax().to_string();
649                self.push(AstNodeKind::Path(text))
650            }
651            // `__curPos` — built-in source-position marker.
652            ast::Expr::CurPos(c) => {
653                let text = c.syntax().to_string();
654                self.push(AstNodeKind::Unknown {
655                    kind: "cur-pos".to_string(),
656                    source_text: text,
657                })
658            }
659            // Pre-2.0 `let { body = …; …; }` syntax. Rare, but rio
660            // ships a few nixpkgs pins that contain it.
661            ast::Expr::LegacyLet(l) => {
662                let text = l.syntax().to_string();
663                self.push(AstNodeKind::Unknown {
664                    kind: "legacy-let".to_string(),
665                    source_text: text,
666                })
667            }
668            // rnix's `Root` is the document wrapper; nested Roots are
669            // a parse oddity, but if we see one just dive into its
670            // body so the AST stays well-formed.
671            ast::Expr::Root(r) => match r.expr() {
672                Some(inner) => self.lower_expr(&inner),
673                None => self.push(AstNodeKind::Null),
674            },
675            // Recoverable rnix parse error placeholder. We've already
676            // rejected non-empty parse-error lists in `from_source`, so
677            // this should only fire for inline-error nodes that
678            // upstream couldn't surface as a hard error.
679            ast::Expr::Error(e) => {
680                let text = e.syntax().to_string();
681                self.push(AstNodeKind::Unknown {
682                    kind: "parse-error".to_string(),
683                    source_text: text,
684                })
685            }
686            ast::Expr::List(list) => {
687                let items: Vec<NodeId> = list.items().map(|e| self.lower_expr(&e)).collect();
688                self.push(AstNodeKind::List(items))
689            }
690            ast::Expr::AttrSet(set) => self.lower_attrset(set),
691            ast::Expr::LetIn(letin) => self.lower_letin(letin),
692            ast::Expr::With(with) => {
693                let env = with.namespace().map(|e| self.lower_expr(&e));
694                let body = with.body().map(|e| self.lower_expr(&e));
695                match (env, body) {
696                    (Some(env), Some(body)) => self.push(AstNodeKind::With { env, body }),
697                    _ => self.unknown(expr, "with-missing-side"),
698                }
699            }
700            ast::Expr::Assert(assert) => {
701                let condition = assert.condition().map(|e| self.lower_expr(&e));
702                let body = assert.body().map(|e| self.lower_expr(&e));
703                match (condition, body) {
704                    (Some(condition), Some(body)) => {
705                        self.push(AstNodeKind::Assert { condition, body })
706                    }
707                    _ => self.unknown(expr, "assert-missing-side"),
708                }
709            }
710            ast::Expr::Lambda(lambda) => self.lower_lambda(lambda),
711            ast::Expr::Apply(apply) => {
712                let function = apply.lambda().map(|e| self.lower_expr(&e));
713                let argument = apply.argument().map(|e| self.lower_expr(&e));
714                match (function, argument) {
715                    (Some(function), Some(argument)) => self.push(AstNodeKind::Apply {
716                        function,
717                        argument,
718                    }),
719                    _ => self.unknown(expr, "apply-missing-side"),
720                }
721            }
722            ast::Expr::IfElse(ifelse) => {
723                let condition = ifelse.condition().map(|e| self.lower_expr(&e));
724                let then_branch = ifelse.body().map(|e| self.lower_expr(&e));
725                let else_branch = ifelse.else_body().map(|e| self.lower_expr(&e));
726                match (condition, then_branch, else_branch) {
727                    (Some(c), Some(t), Some(e)) => self.push(AstNodeKind::IfThenElse {
728                        condition: c,
729                        then_branch: t,
730                        else_branch: e,
731                    }),
732                    _ => self.unknown(expr, "if-else-missing-branch"),
733                }
734            }
735            ast::Expr::Select(select) => self.lower_select(select),
736            ast::Expr::HasAttr(has) => {
737                let target = has.expr().map(|e| self.lower_expr(&e));
738                let path = has
739                    .attrpath()
740                    .map(|p| attrpath_to_strings(&p))
741                    .unwrap_or_default();
742                match target {
743                    Some(target) => self.push(AstNodeKind::HasAttr { target, path }),
744                    None => self.unknown(expr, "hasattr-missing-target"),
745                }
746            }
747            ast::Expr::BinOp(binop) => self.lower_binop(binop),
748            ast::Expr::UnaryOp(unaryop) => self.lower_unaryop(unaryop),
749            ast::Expr::Paren(p) => match p.expr() {
750                Some(inner) => self.lower_expr(&inner),
751                None => self.unknown(expr, "paren-empty"),
752            },
753            // Every rnix 0.14 Expr variant is handled above. If a future
754            // rnix bump adds a variant we don't know about, the compiler
755            // catches the gap as a non-exhaustive match — by design, so
756            // we can't silently drop a construct into Unknown.
757        }
758    }
759
760    fn lower_literal(&mut self, lit: &ast::Literal) -> NodeId {
761        match lit.kind() {
762            ast::LiteralKind::Integer(t) => {
763                let n: i64 = t.value().unwrap_or(0);
764                self.push(AstNodeKind::Int(n))
765            }
766            ast::LiteralKind::Float(t) => {
767                let f: f64 = t.value().unwrap_or(0.0);
768                self.push(AstNodeKind::Float(f))
769            }
770            ast::LiteralKind::Uri(t) => {
771                // Uris are deprecated in modern Nix; treat as Str.
772                self.push(AstNodeKind::Str {
773                    segments: vec![StrSegment::Literal(t.syntax().text().to_string())],
774                })
775            }
776        }
777    }
778
779    fn lower_str(&mut self, s: &ast::Str, _indented: bool) -> NodeId {
780        let mut segments = Vec::new();
781        for part in s.normalized_parts() {
782            match part {
783                ast::InterpolPart::Literal(text) => {
784                    segments.push(StrSegment::Literal(text));
785                }
786                ast::InterpolPart::Interpolation(interp) => {
787                    let id = match interp.expr() {
788                        Some(expr) => self.lower_expr(&expr),
789                        None => {
790                            // Empty interpolation: treat as empty string literal.
791                            segments.push(StrSegment::Literal(String::new()));
792                            continue;
793                        }
794                    };
795                    segments.push(StrSegment::Interpolation(id));
796                }
797            }
798        }
799        self.push(AstNodeKind::Str { segments })
800    }
801
802    fn lower_attrset(&mut self, set: &ast::AttrSet) -> NodeId {
803        let recursive = set.rec_token().is_some();
804        let mut entries = Vec::new();
805        let mut inherits = Vec::new();
806        for entry in set.entries() {
807            match entry {
808                ast::Entry::AttrpathValue(av) => {
809                    let path = av
810                        .attrpath()
811                        .map(|p| attrpath_to_strings(&p))
812                        .unwrap_or_default();
813                    let value = av
814                        .value()
815                        .map(|e| self.lower_expr(&e))
816                        .unwrap_or_else(|| self.push(AstNodeKind::Null));
817                    entries.push(AttrEntry { path, value });
818                }
819                ast::Entry::Inherit(inherit) => {
820                    let source = inherit.from().and_then(|f| f.expr()).map(|e| self.lower_expr(&e));
821                    let attrs: Vec<String> = inherit
822                        .attrs()
823                        .filter_map(|a| attr_to_string(&a))
824                        .collect();
825                    inherits.push(InheritClause { source, attrs });
826                }
827            }
828        }
829        self.push(AstNodeKind::AttrSet {
830            recursive,
831            entries,
832            inherits,
833        })
834    }
835
836    fn lower_letin(&mut self, letin: &ast::LetIn) -> NodeId {
837        let mut bindings = Vec::new();
838        let mut inherits = Vec::new();
839        for entry in letin.entries() {
840            match entry {
841                ast::Entry::AttrpathValue(av) => {
842                    let path = av
843                        .attrpath()
844                        .map(|p| attrpath_to_strings(&p))
845                        .unwrap_or_default();
846                    let value = av
847                        .value()
848                        .map(|e| self.lower_expr(&e))
849                        .unwrap_or_else(|| self.push(AstNodeKind::Null));
850                    bindings.push(AttrEntry { path, value });
851                }
852                ast::Entry::Inherit(inherit) => {
853                    let source = inherit.from().and_then(|f| f.expr()).map(|e| self.lower_expr(&e));
854                    let attrs: Vec<String> = inherit
855                        .attrs()
856                        .filter_map(|a| attr_to_string(&a))
857                        .collect();
858                    inherits.push(InheritClause { source, attrs });
859                }
860            }
861        }
862        let body = letin
863            .body()
864            .map(|e| self.lower_expr(&e))
865            .unwrap_or_else(|| self.push(AstNodeKind::Null));
866        self.push(AstNodeKind::LetIn {
867            bindings,
868            inherits,
869            body,
870        })
871    }
872
873    fn lower_lambda(&mut self, lambda: &ast::Lambda) -> NodeId {
874        let param = match lambda.param() {
875            Some(ast::Param::IdentParam(ip)) => {
876                let name = ip
877                    .ident()
878                    .and_then(|i| i.ident_token().map(|t| t.text().to_string()))
879                    .unwrap_or_default();
880                LambdaParam::Ident(name)
881            }
882            Some(ast::Param::Pattern(pattern)) => {
883                let binding_name = pattern
884                    .pat_bind()
885                    .and_then(|b| b.ident())
886                    .and_then(|i| i.ident_token().map(|t| t.text().to_string()));
887                let accepts_extra = pattern.ellipsis_token().is_some();
888                let mut formals: Vec<Formal> = Vec::new();
889                for entry in pattern.pat_entries() {
890                    let name = entry
891                        .ident()
892                        .and_then(|i| i.ident_token().map(|t| t.text().to_string()))
893                        .unwrap_or_default();
894                    let default = entry.default().map(|e| self.lower_expr(&e));
895                    formals.push(Formal { name, default });
896                }
897                LambdaParam::Pattern {
898                    binding_name,
899                    formals,
900                    accepts_extra,
901                }
902            }
903            None => LambdaParam::Ident(String::new()),
904        };
905        let body = lambda
906            .body()
907            .map(|e| self.lower_expr(&e))
908            .unwrap_or_else(|| self.push(AstNodeKind::Null));
909        self.push(AstNodeKind::Lambda { param, body })
910    }
911
912    fn lower_select(&mut self, select: &ast::Select) -> NodeId {
913        let target = match select.expr() {
914            Some(e) => self.lower_expr(&e),
915            None => return self.push(AstNodeKind::Null),
916        };
917        let path = select
918            .attrpath()
919            .map(|p| attrpath_to_strings(&p))
920            .unwrap_or_default();
921        let fallback = select.default_expr().map(|e| self.lower_expr(&e));
922        self.push(AstNodeKind::Select {
923            target,
924            path,
925            fallback,
926        })
927    }
928
929    fn lower_binop(&mut self, binop: &ast::BinOp) -> NodeId {
930        let left = binop
931            .lhs()
932            .map(|e| self.lower_expr(&e))
933            .unwrap_or_else(|| self.push(AstNodeKind::Null));
934        let right = binop
935            .rhs()
936            .map(|e| self.lower_expr(&e))
937            .unwrap_or_else(|| self.push(AstNodeKind::Null));
938        let op = binop
939            .operator()
940            .map(map_binop)
941            .unwrap_or(BinaryOp::Concat);
942        self.push(AstNodeKind::BinOp { op, left, right })
943    }
944
945    fn lower_unaryop(&mut self, unaryop: &ast::UnaryOp) -> NodeId {
946        let operand = unaryop
947            .expr()
948            .map(|e| self.lower_expr(&e))
949            .unwrap_or_else(|| self.push(AstNodeKind::Null));
950        let op = match unaryop.operator() {
951            Some(ast::UnaryOpKind::Negate) => UnaryOp::Neg,
952            Some(ast::UnaryOpKind::Invert) => UnaryOp::Not,
953            None => UnaryOp::Neg,
954        };
955        self.push(AstNodeKind::UnaryOp { op, operand })
956    }
957}
958
959fn map_binop(op: ast::BinOpKind) -> BinaryOp {
960    match op {
961        ast::BinOpKind::Add => BinaryOp::Add,
962        ast::BinOpKind::Sub => BinaryOp::Sub,
963        ast::BinOpKind::Mul => BinaryOp::Mul,
964        ast::BinOpKind::Div => BinaryOp::Div,
965        ast::BinOpKind::Equal => BinaryOp::Eq,
966        ast::BinOpKind::NotEqual => BinaryOp::NotEq,
967        ast::BinOpKind::Less => BinaryOp::Lt,
968        ast::BinOpKind::LessOrEq => BinaryOp::Le,
969        ast::BinOpKind::More => BinaryOp::Gt,
970        ast::BinOpKind::MoreOrEq => BinaryOp::Ge,
971        ast::BinOpKind::And => BinaryOp::And,
972        ast::BinOpKind::Or => BinaryOp::Or,
973        ast::BinOpKind::Implication => BinaryOp::Implies,
974        ast::BinOpKind::Update => BinaryOp::Update,
975        ast::BinOpKind::Concat => BinaryOp::Concat,
976        ast::BinOpKind::PipeRight => BinaryOp::PipeRight,
977        ast::BinOpKind::PipeLeft => BinaryOp::PipeLeft,
978    }
979}
980
981fn attrpath_to_strings(path: &ast::Attrpath) -> Vec<String> {
982    path.attrs().filter_map(|a| attr_to_string(&a)).collect()
983}
984
985fn attr_to_string(attr: &ast::Attr) -> Option<String> {
986    match attr {
987        ast::Attr::Ident(i) => i.ident_token().map(|t| t.text().to_string()),
988        ast::Attr::Dynamic(d) => Some(d.syntax().to_string()),
989        ast::Attr::Str(s) => Some(s.syntax().to_string()),
990    }
991}
992
993// ── Lisp fixtures loader ────────────────────────────────────────
994
995#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
996#[tatara(keyword = "defast-graph-fixture")]
997pub struct AstGraphFixture {
998    pub name: String,
999    pub source: String,
1000    #[serde(rename = "rootKind")]
1001    pub root_kind: String,
1002    #[serde(rename = "nodeCount")]
1003    pub node_count: u32,
1004    pub notes: String,
1005}
1006
1007pub const CANONICAL_AST_GRAPH_FIXTURES_LISP: &str =
1008    include_str!("../specs/ast_graph.lisp");
1009
1010/// Load every authored fixture.
1011///
1012/// # Errors
1013///
1014/// Fails if the `.lisp` source can't be parsed.
1015pub fn load_fixtures() -> Result<Vec<AstGraphFixture>, SpecError> {
1016    crate::loader::load_all::<AstGraphFixture>(CANONICAL_AST_GRAPH_FIXTURES_LISP)
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021    use super::*;
1022    use pretty_assertions::assert_eq;
1023
1024    #[test]
1025    fn integer_literal_produces_one_node() {
1026        let g = AstGraph::from_source("42").unwrap();
1027        assert_eq!(g.nodes.len(), 1);
1028        assert_eq!(g.root_id, 0);
1029        matches!(g.nodes[0].kind, AstNodeKind::Int(42));
1030    }
1031
1032    #[test]
1033    fn binop_produces_three_nodes() {
1034        // BinOp + left + right
1035        let g = AstGraph::from_source("1 + 2").unwrap();
1036        assert!(g.nodes.len() >= 3);
1037        assert!(matches!(
1038            g.nodes[g.root_id as usize].kind,
1039            AstNodeKind::BinOp { op: BinaryOp::Add, .. }
1040        ));
1041    }
1042
1043    #[test]
1044    fn let_in_with_binop_resolves_identifiers() {
1045        let g = AstGraph::from_source("let x = 1; in x + 2").unwrap();
1046        assert!(matches!(
1047            g.nodes[g.root_id as usize].kind,
1048            AstNodeKind::LetIn { .. }
1049        ));
1050        // Must have at least: LetIn + binding(x=Int(1)) + BinOp + Ident(x) + Int(2)
1051        // The walker emits children before the parent so root is the last node.
1052        assert!(g.nodes.len() >= 5);
1053    }
1054
1055    #[test]
1056    fn nixos_module_lambda_destructures_formals() {
1057        let g = AstGraph::from_source(
1058            "{ config, lib, pkgs, ... }: { networking.hostName = \"rio\"; }",
1059        )
1060        .unwrap();
1061        // Root must be a Lambda with a Pattern parameter.
1062        let root_kind = &g.nodes[g.root_id as usize].kind;
1063        match root_kind {
1064            AstNodeKind::Lambda {
1065                param: LambdaParam::Pattern { formals, accepts_extra, .. },
1066                ..
1067            } => {
1068                assert!(*accepts_extra);
1069                let names: Vec<&str> = formals.iter().map(|f| f.name.as_str()).collect();
1070                assert!(names.contains(&"config"));
1071                assert!(names.contains(&"lib"));
1072                assert!(names.contains(&"pkgs"));
1073            }
1074            other => panic!("expected Lambda Pattern at root, got {other:?}"),
1075        }
1076    }
1077
1078    #[test]
1079    fn archive_and_hash_stamps_canonical_hash() {
1080        let g = AstGraph::from_source("let x = 1; in x + 2").unwrap();
1081        assert_eq!(g.canonical_hash.bytes, [0u8; 32]);
1082        let (stamped, bytes) = g.archive_and_hash().unwrap();
1083        assert_ne!(stamped.canonical_hash.bytes, [0u8; 32]);
1084        assert!(!bytes.is_empty());
1085    }
1086
1087    #[test]
1088    fn archive_roundtrips_via_rkyv() {
1089        let g = AstGraph::from_source("let x = 1; in x + 2").unwrap();
1090        let (_stamped, bytes) = g.clone().archive_and_hash().unwrap();
1091        let archived =
1092            rkyv::access::<ArchivedAstGraph, rkyv::rancor::Error>(&bytes).unwrap();
1093        // Post-order: root is the last node, so root_id == nodes.len() - 1.
1094        assert_eq!(archived.root_id, (g.nodes.len() - 1) as u32);
1095        assert_eq!(archived.grammar_version, RNIX_GRAMMAR_VERSION);
1096        assert_eq!(archived.nodes.len(), g.nodes.len());
1097    }
1098
1099    #[test]
1100    fn archive_is_deterministic_for_same_source() {
1101        let g1 = AstGraph::from_source("let x = 1; in x + 2").unwrap();
1102        let g2 = AstGraph::from_source("let x = 1; in x + 2").unwrap();
1103        let (s1, _) = g1.archive_and_hash().unwrap();
1104        let (s2, _) = g2.archive_and_hash().unwrap();
1105        assert_eq!(s1.canonical_hash.bytes, s2.canonical_hash.bytes);
1106    }
1107
1108    #[test]
1109    fn fixtures_load_from_lisp() {
1110        let fixtures = load_fixtures().unwrap();
1111        let names: Vec<_> = fixtures.iter().map(|f| f.name.as_str()).collect();
1112        assert!(names.contains(&"literal-int"));
1113        assert!(names.contains(&"let-in-with-binop"));
1114        assert!(names.contains(&"nixos-module-skeleton"));
1115    }
1116
1117    #[test]
1118    fn nix_source_marks_dialect_as_nix() {
1119        let g = AstGraph::from_source("42").unwrap();
1120        assert!(matches!(g.dialect, SourceDialect::Nix));
1121        assert_eq!(g.grammar_version, RNIX_GRAMMAR_VERSION);
1122    }
1123
1124    #[test]
1125    fn tlisp_source_stub_marks_dialect_as_tlisp() {
1126        let g = AstGraph::from_tlisp_source("(+ 1 2)").unwrap();
1127        assert!(matches!(g.dialect, SourceDialect::Tlisp));
1128        assert_eq!(g.grammar_version, TLISP_GRAMMAR_VERSION);
1129        assert_eq!(g.nodes.len(), 1);
1130        match &g.nodes[0].kind {
1131            AstNodeKind::Unknown { kind, source_text } => {
1132                assert!(kind.contains("tlisp"));
1133                assert_eq!(source_text, "(+ 1 2)");
1134            }
1135            other => panic!("expected Unknown stub, got {other:?}"),
1136        }
1137    }
1138
1139    #[test]
1140    fn render_seams_return_unimplemented_today() {
1141        let g = AstGraph::from_source("42").unwrap();
1142        assert!(matches!(
1143            g.to_nix_source(),
1144            Err(AstGraphError::Unimplemented { .. })
1145        ));
1146        assert!(matches!(
1147            g.to_tlisp_source(),
1148            Err(AstGraphError::Unimplemented { .. })
1149        ));
1150    }
1151
1152    #[test]
1153    fn unmodeled_construct_lands_in_unknown_with_source() {
1154        // Reasonably exotic: `or` outside of select context. Even when
1155        // we add it to the modeled set, the test still passes — the
1156        // node count grows but the structure doesn't.
1157        let result = AstGraph::from_source("let a = { foo = 1; }; in a.bar or 0");
1158        let g = result.unwrap();
1159        assert!(g.nodes.len() >= 2);
1160    }
1161}