Skip to main content

sui_spec/
module_compiler.rs

1//! Module-system compiler — extracts the typed [`ModuleNode`] shape
2//! from a raw [`AstGraph`].
3//!
4//! ## What this does today
5//!
6//! Pattern-recognizes the canonical NixOS / nix-darwin / home-manager
7//! module shape:
8//!
9//! ```nix
10//! { config, lib, pkgs, ... }:
11//! {
12//!   imports = [ ./profile.nix ./component.nix ];
13//!   options.services.atticd.enable = mkOption { type = bool; default = false; };
14//!   config.services.atticd.enable = mkForce true;
15//!   config.boot.kernelParams = mkIf config.services.atticd.enable [ "amd_pstate=active" ];
16//! }
17//! ```
18//!
19//! and emits typed [`OptionDecl`] / [`ConfigSetter`] / [`ImportEdge`]
20//! lists that fill a [`ModuleNode`]. Slice analysis walks each setter
21//! body collecting every `Select(config, .a.b.c)` reference — this is
22//! the "input slice" the worker/wrapper-split fixed-point solver fires
23//! against.
24//!
25//! ## What this does NOT do (queued)
26//!
27//! 1. **Defunctionalization** — higher-order setter functions stay
28//!    AST-pointer-only for now. The full transform lands when the
29//!    bytecode VM grows the supporting opcodes.
30//! 2. **NbE (normalize-by-evaluation)** on the compiled closure.
31//!    Cache-key uses the ModuleGraph's BLAKE3 (good enough for
32//!    structural change detection); NbE-driven canonicalization adds
33//!    structural-equality across alpha-renaming etc.
34//! 3. **Slice-keyed re-firing execution** — the data is captured
35//!    today; the solver that fires only changed-slice setters lives
36//!    in the eval-engine integration ship.
37//! 4. **Full topological discovery via resolved imports** — today's
38//!    builder accepts modules in caller order. Symmetric to the
39//!    follows-resolution shape lockfile_graph uses; lands when
40//!    import-target resolution gets a typed pass.
41//!
42//! ## Why this exists
43//!
44//! Cppnix re-evaluates the entire module fixed point on every rebuild.
45//! Tvix hasn't tackled the module system. The first step toward making
46//! `nixos-rebuild` warm-path sub-second is **lifting the module shape
47//! into the typed substrate** — exactly what this compiler does. The
48//! IR (shipped in 8d07d77) is the anchor; this compiler fills it; the
49//! eval-engine ship fires it.
50
51use crate::ast_graph::{AstGraph, AstNodeForm, AstNodeKind, AttrEntry, NodeId as AstNodeId};
52use crate::module_graph::{
53    ConfigSetter, EnvPrefixBinding, EnvPrefixKind, ImportEdge, ModuleId, ModuleNode, OptionDecl,
54    SetterId,
55};
56
57/// Errors from the compiler.
58#[derive(Debug, thiserror::Error)]
59pub enum ModuleCompilerError {
60    #[error("module root expression is not an attrset or lambda — got {kind:?}")]
61    UnexpectedRootShape { kind: &'static str },
62}
63
64/// Compile one module's [`AstGraph`] into a typed [`ModuleNode`].
65///
66/// `label` is the caller-supplied identifier (the file path relative
67/// to the flake root is the canonical choice).
68///
69/// `id` is the module id this node will be assigned in its containing
70/// [`ModuleGraph`]; the compiler stamps it through so the caller can
71/// just `g.push_module(compile_module(label, &ast, expected_id)?)`.
72///
73/// # Errors
74///
75/// [`ModuleCompilerError::UnexpectedRootShape`] if the root node is
76/// neither a lambda nor an attrset. Real NixOS modules are always one
77/// of those two shapes, so a mismatch is a content bug, not a compiler
78/// bug — caller should skip the module and surface the error to the
79/// operator.
80pub fn compile_module(
81    label: &str,
82    ast: &AstGraph,
83    id: ModuleId,
84) -> Result<ModuleNode, ModuleCompilerError> {
85    let mut node = ModuleNode {
86        id,
87        label: label.to_string(),
88        ast_graph_hash: ast.canonical_hash.bytes,
89        option_decls: Vec::new(),
90        setters: Vec::new(),
91        imports: Vec::new(),
92        body_env_prefix: Vec::new(),
93    };
94
95    // Step into the lambda body if the root is `{ config, lib, pkgs, ... }: ...`.
96    // The walker also captures every let-binding and with-scope it
97    // unwraps, baking them into node.body_env_prefix so the evaluator
98    // can re-seed each setter's env.
99    let (body_id_opt, prefix) = resolve_module_body_with_prefix(ast)?;
100    node.body_env_prefix = prefix;
101    let body_id = match body_id_opt {
102        Some(id) => id,
103        None => return Ok(node), // empty body, partial module — return partial node
104    };
105
106    let body = node_at(ast, body_id);
107
108    // The body should be an AttrSet (the conventional shape). If it's
109    // not, leave the node partial — forward-compat: callers can still
110    // see the module exists via its ast_graph_hash, just not the
111    // structured surface.
112    if let AstNodeKind::AttrSet { entries, .. } = &body.kind {
113        for entry in entries {
114            classify_top_level_entry(ast, entry, &mut node);
115        }
116    }
117
118    Ok(node)
119}
120
121// ── helpers ───────────────────────────────────────────────────────
122
123fn node_at(ast: &AstGraph, id: AstNodeId) -> &AstNodeForm {
124    &ast.nodes[id as usize]
125}
126
127/// Resolve the "module body" — the attrset that holds options /
128/// config / imports — AND capture the env-prefix bindings from the
129/// outer wrappers along the way.
130///
131/// Unwraps the common shells:
132/// * `Lambda { … }: BODY` — formal-args wrapper. Args (`config`,
133///   `lib`, `pkgs`) come from the evaluator's caller; nothing to
134///   capture here.
135/// * `let foo = …; in BODY` — captures each single-segment binding
136///   as `EnvPrefixKind::Let`.
137/// * `with X; BODY` — captures `X`'s AST id as a synthetic
138///   `__with_N` binding tagged `EnvPrefixKind::With`. The evaluator
139///   unpacks X's attrset attrs as top-level idents when it
140///   re-applies the prefix.
141/// * `assert cond; BODY` — ignored for prefix purposes.
142///
143/// Stops at the first `AttrSet`. Bounded loop depth. Returns
144/// `(None, prefix)` if we can't reach an attrset within the bound.
145fn resolve_module_body_with_prefix(
146    ast: &AstGraph,
147) -> Result<(Option<AstNodeId>, Vec<EnvPrefixBinding>), ModuleCompilerError> {
148    let mut cursor = ast.root_id;
149    let mut prefix: Vec<EnvPrefixBinding> = Vec::new();
150    let mut with_depth = 0u32;
151    for _ in 0..32 {
152        let node = node_at(ast, cursor);
153        match &node.kind {
154            AstNodeKind::Lambda { body, .. } => {
155                cursor = *body;
156                continue;
157            }
158            AstNodeKind::LetIn { bindings, body, .. } => {
159                for entry in bindings {
160                    if entry.path.len() == 1 {
161                        prefix.push(EnvPrefixBinding {
162                            name: entry.path[0].clone(),
163                            value_node_id: entry.value,
164                            kind: EnvPrefixKind::Let,
165                        });
166                    }
167                }
168                cursor = *body;
169                continue;
170            }
171            AstNodeKind::With { env: scope, body } => {
172                prefix.push(EnvPrefixBinding {
173                    name: format!("__with_{with_depth}"),
174                    value_node_id: *scope,
175                    kind: EnvPrefixKind::With,
176                });
177                with_depth += 1;
178                cursor = *body;
179                continue;
180            }
181            AstNodeKind::Assert { body, .. } => {
182                cursor = *body;
183                continue;
184            }
185            AstNodeKind::AttrSet { .. } => return Ok((Some(cursor), prefix)),
186            // Forward-compat: anything else means we don't recognize
187            // the module shape. Return None so the caller emits a
188            // partial node rather than a hard error.
189            AstNodeKind::Unknown { .. } | AstNodeKind::Null => {
190                return Ok((None, prefix));
191            }
192            other => {
193                return Err(ModuleCompilerError::UnexpectedRootShape {
194                    kind: kind_name(other),
195                });
196            }
197        }
198    }
199    Ok((None, prefix))
200}
201
202fn kind_name(k: &AstNodeKind) -> &'static str {
203    match k {
204        AstNodeKind::Int(_) => "Int",
205        AstNodeKind::Float(_) => "Float",
206        AstNodeKind::Bool(_) => "Bool",
207        AstNodeKind::Null => "Null",
208        AstNodeKind::Str { .. } => "Str",
209        AstNodeKind::IndentedStr { .. } => "IndentedStr",
210        AstNodeKind::Path(_) => "Path",
211        AstNodeKind::Ident(_) => "Ident",
212        AstNodeKind::Select { .. } => "Select",
213        AstNodeKind::HasAttr { .. } => "HasAttr",
214        AstNodeKind::List(_) => "List",
215        AstNodeKind::AttrSet { .. } => "AttrSet",
216        AstNodeKind::LetIn { .. } => "LetIn",
217        AstNodeKind::With { .. } => "With",
218        AstNodeKind::Assert { .. } => "Assert",
219        AstNodeKind::Lambda { .. } => "Lambda",
220        AstNodeKind::Apply { .. } => "Apply",
221        AstNodeKind::IfThenElse { .. } => "IfThenElse",
222        AstNodeKind::BinOp { .. } => "BinOp",
223        AstNodeKind::UnaryOp { .. } => "UnaryOp",
224        AstNodeKind::Unknown { .. } => "Unknown",
225    }
226}
227
228/// Classify one top-level entry in the module body. Recognizes
229/// `options`, `config`, `imports` as the well-known keys; everything
230/// else is currently ignored (forward-compat: future extensions like
231/// `disabledModules` get their own arm).
232fn classify_top_level_entry(ast: &AstGraph, entry: &AttrEntry, node: &mut ModuleNode) {
233    if entry.path.is_empty() {
234        return;
235    }
236    match entry.path[0].as_str() {
237        "options" => harvest_options(ast, &entry.path[1..], entry.value, node),
238        "config" => harvest_config(ast, &entry.path[1..], entry.value, node, None, 100),
239        "imports" => harvest_imports(ast, entry.value, node),
240        _ => {
241            // Some modules write `services.foo.enable = ...;` at the
242            // top level (sugar — implicitly `config.services.foo`).
243            // Treat the entire entry as a config assignment.
244            harvest_config(ast, &entry.path, entry.value, node, None, 100);
245        }
246    }
247}
248
249/// Walk an `options.*` subtree and emit `OptionDecl`s.
250///
251/// Cases handled:
252///   options = { foo = mkOption { ... }; };
253///   options.foo = mkOption { ... };
254///   options.foo.bar = mkOption { ... };
255///   options = { foo = { bar = mkOption { ... }; }; }; (nested attrset)
256fn harvest_options(ast: &AstGraph, path: &[String], value: AstNodeId, node: &mut ModuleNode) {
257    let v = node_at(ast, value);
258    match &v.kind {
259        // Direct mkOption call: `options.foo = mkOption { ... };`
260        AstNodeKind::Apply { function, argument } => {
261            if is_call_to(ast, *function, "mkOption") {
262                if let Some(decl) = mk_option_decl(ast, path, *argument) {
263                    node.option_decls.push(decl);
264                }
265            }
266        }
267        // Nested attrset under `options`: walk further.
268        AstNodeKind::AttrSet { entries, .. } => {
269            for child in entries {
270                let mut sub = path.to_vec();
271                sub.extend(child.path.iter().cloned());
272                harvest_options(ast, &sub, child.value, node);
273            }
274        }
275        _ => {
276            // Anything else: treat as an undocumented option whose
277            // declaration shape we don't yet recognize. Still emit a
278            // typed entry so the operator's coverage report sees it.
279            node.option_decls.push(OptionDecl {
280                path: path.to_vec(),
281                type_tag: "unknown".to_string(),
282                has_default: false,
283                description: None,
284            });
285        }
286    }
287}
288
289fn mk_option_decl(ast: &AstGraph, path: &[String], args: AstNodeId) -> Option<OptionDecl> {
290    let a = node_at(ast, args);
291    if let AstNodeKind::AttrSet { entries, .. } = &a.kind {
292        let mut type_tag = "unknown".to_string();
293        let mut has_default = false;
294        let mut description: Option<String> = None;
295        for e in entries {
296            if e.path.len() != 1 {
297                continue;
298            }
299            match e.path[0].as_str() {
300                "type" => {
301                    let t = node_at(ast, e.value);
302                    if let AstNodeKind::Ident(name) = &t.kind {
303                        type_tag = name.clone();
304                    } else if let AstNodeKind::Select { path: p, .. } = &t.kind {
305                        // `types.bool`, `types.attrsOf …`
306                        if let Some(last) = p.last() {
307                            type_tag = last.clone();
308                        }
309                    }
310                }
311                "default" => has_default = true,
312                "description" => {
313                    let d = node_at(ast, e.value);
314                    if let AstNodeKind::Str { segments } = &d.kind {
315                        let mut buf = String::new();
316                        for s in segments {
317                            if let crate::ast_graph::StrSegment::Literal(t) = s {
318                                buf.push_str(t);
319                            }
320                        }
321                        description = Some(buf);
322                    }
323                }
324                _ => {}
325            }
326        }
327        Some(OptionDecl {
328            path: path.to_vec(),
329            type_tag,
330            has_default,
331            description,
332        })
333    } else {
334        None
335    }
336}
337
338/// Walk a `config.*` subtree and emit `ConfigSetter`s.
339///
340/// Cases handled:
341///   config = { foo = bar; };
342///   config.foo = bar;
343///   config.foo = mkIf cond value;
344///   config.foo = mkForce value;
345///   config.foo = mkOverride 50 value;
346///   config = mkMerge [ { a = 1; } { b = 2; } ];
347///
348/// `condition` and `priority` propagate from outer mkIf/mkOverride
349/// wrappers — same setter writes the same path with whatever
350/// wrappers stack up.
351fn harvest_config(
352    ast: &AstGraph,
353    path: &[String],
354    value: AstNodeId,
355    node: &mut ModuleNode,
356    condition: Option<AstNodeId>,
357    priority: u32,
358) {
359    let v = node_at(ast, value);
360    match &v.kind {
361        AstNodeKind::Apply { function, argument } => {
362            if is_call_to(ast, *function, "mkIf") {
363                // `mkIf cond value` — unwrap into `mkIf cond` applied
364                // to `value`. The rnix shape: Apply(Apply(mkIf, cond),
365                // value).
366                if let Some((cond, body)) = split_mkif_args(ast, *function, *argument) {
367                    harvest_config(ast, path, body, node, Some(cond), priority);
368                    return;
369                }
370            }
371            if is_call_to(ast, *function, "mkForce") {
372                harvest_config(ast, path, *argument, node, condition, 50);
373                return;
374            }
375            if is_call_to(ast, *function, "mkVMOverride") {
376                harvest_config(ast, path, *argument, node, condition, 10);
377                return;
378            }
379            if is_call_to(ast, *function, "mkMerge") {
380                // `mkMerge [ ... ]` — iterate the list elements as
381                // sibling configs.
382                let arg = node_at(ast, *argument);
383                if let AstNodeKind::List(items) = &arg.kind {
384                    for item in items {
385                        harvest_config(ast, path, *item, node, condition, priority);
386                    }
387                    return;
388                }
389            }
390            // Default: treat as a leaf assignment whose RHS is this
391            // Apply expression.
392            emit_setter(ast, node, path, value, condition, priority);
393        }
394        AstNodeKind::AttrSet { entries, .. } => {
395            // Walk every child as a deeper path.
396            for child in entries {
397                let mut sub = path.to_vec();
398                sub.extend(child.path.iter().cloned());
399                harvest_config(ast, &sub, child.value, node, condition, priority);
400            }
401        }
402        _ => {
403            // Leaf assignment.
404            emit_setter(ast, node, path, value, condition, priority);
405        }
406    }
407}
408
409fn split_mkif_args(
410    ast: &AstGraph,
411    function: AstNodeId,
412    body: AstNodeId,
413) -> Option<(AstNodeId, AstNodeId)> {
414    // `mkIf cond value` → Apply(Apply(mkIf, cond), value). Function arg
415    // here is the inner Apply(mkIf, cond) — pull `cond` out of it.
416    let f = node_at(ast, function);
417    if let AstNodeKind::Apply { argument, .. } = &f.kind {
418        Some((*argument, body))
419    } else {
420        None
421    }
422}
423
424fn emit_setter(
425    ast: &AstGraph,
426    node: &mut ModuleNode,
427    path: &[String],
428    body_ast_root: AstNodeId,
429    condition_ast_root: Option<AstNodeId>,
430    priority: u32,
431) {
432    let id = node.setters.len() as SetterId;
433    // Slice = every config.* path read inside the body (the worker/
434    // wrapper-split's input projection). Plus every config.* path
435    // read inside the mkIf condition, because the condition is part of
436    // what determines whether the setter fires.
437    let mut slice = collect_config_read_slice(ast, body_ast_root);
438    if let Some(cond_id) = condition_ast_root {
439        let cond_slice = collect_config_read_slice(ast, cond_id);
440        slice.extend(cond_slice);
441        slice.sort();
442        slice.dedup();
443    }
444    node.setters.push(ConfigSetter {
445        id,
446        assigns_path: path.to_vec(),
447        slice,
448        body_ast_root,
449        condition_ast_root,
450        priority,
451    });
452}
453
454/// Public slice analysis entry — caller passes the AST + a body node id;
455/// receives the deduplicated list of `config.*` paths read.
456///
457/// Used by the eval-engine integration (next ship) and exported here so
458/// `compile_module` callers can run it post-compilation if they need
459/// the slice without re-walking the AST.
460#[must_use]
461pub fn collect_config_read_slice(ast: &AstGraph, body_ast_root: AstNodeId) -> Vec<Vec<String>> {
462    let mut out: Vec<Vec<String>> = Vec::new();
463    walk_for_config_reads(ast, body_ast_root, &mut out);
464    out.sort();
465    out.dedup();
466    out
467}
468
469fn walk_for_config_reads(ast: &AstGraph, id: AstNodeId, out: &mut Vec<Vec<String>>) {
470    let n = node_at(ast, id);
471    match &n.kind {
472        AstNodeKind::Select { target, path, fallback } => {
473            let t = node_at(ast, *target);
474            if matches!(&t.kind, AstNodeKind::Ident(s) if s == "config") {
475                out.push(path.clone());
476            } else {
477                walk_for_config_reads(ast, *target, out);
478            }
479            if let Some(f) = fallback {
480                walk_for_config_reads(ast, *f, out);
481            }
482        }
483        AstNodeKind::HasAttr { target, path } => {
484            let t = node_at(ast, *target);
485            if matches!(&t.kind, AstNodeKind::Ident(s) if s == "config") {
486                out.push(path.clone());
487            } else {
488                walk_for_config_reads(ast, *target, out);
489            }
490        }
491        AstNodeKind::Apply { function, argument } => {
492            walk_for_config_reads(ast, *function, out);
493            walk_for_config_reads(ast, *argument, out);
494        }
495        AstNodeKind::List(items) => {
496            for item in items {
497                walk_for_config_reads(ast, *item, out);
498            }
499        }
500        AstNodeKind::AttrSet { entries, inherits, .. } => {
501            for e in entries {
502                walk_for_config_reads(ast, e.value, out);
503            }
504            for i in inherits {
505                if let Some(s) = i.source {
506                    walk_for_config_reads(ast, s, out);
507                }
508            }
509        }
510        AstNodeKind::LetIn { bindings, inherits, body } => {
511            for b in bindings {
512                walk_for_config_reads(ast, b.value, out);
513            }
514            for i in inherits {
515                if let Some(s) = i.source {
516                    walk_for_config_reads(ast, s, out);
517                }
518            }
519            walk_for_config_reads(ast, *body, out);
520        }
521        AstNodeKind::With { env, body } => {
522            walk_for_config_reads(ast, *env, out);
523            walk_for_config_reads(ast, *body, out);
524        }
525        AstNodeKind::Assert { condition, body } => {
526            walk_for_config_reads(ast, *condition, out);
527            walk_for_config_reads(ast, *body, out);
528        }
529        AstNodeKind::Lambda { body, .. } => {
530            walk_for_config_reads(ast, *body, out);
531        }
532        AstNodeKind::IfThenElse {
533            condition,
534            then_branch,
535            else_branch,
536        } => {
537            walk_for_config_reads(ast, *condition, out);
538            walk_for_config_reads(ast, *then_branch, out);
539            walk_for_config_reads(ast, *else_branch, out);
540        }
541        AstNodeKind::BinOp { left, right, .. } => {
542            walk_for_config_reads(ast, *left, out);
543            walk_for_config_reads(ast, *right, out);
544        }
545        AstNodeKind::UnaryOp { operand, .. } => {
546            walk_for_config_reads(ast, *operand, out);
547        }
548        AstNodeKind::Str { segments } | AstNodeKind::IndentedStr { segments } => {
549            for s in segments {
550                if let crate::ast_graph::StrSegment::Interpolation(id) = s {
551                    walk_for_config_reads(ast, *id, out);
552                }
553            }
554        }
555        // Leaves and forms that don't recurse:
556        AstNodeKind::Int(_)
557        | AstNodeKind::Float(_)
558        | AstNodeKind::Bool(_)
559        | AstNodeKind::Null
560        | AstNodeKind::Path(_)
561        | AstNodeKind::Ident(_)
562        | AstNodeKind::Unknown { .. } => {}
563    }
564}
565
566/// Walk `imports = [ … ];` — each element is a path / function-call
567/// that lands as an import. Today we capture each list element's
568/// AST root id as a synthetic import edge whose `target` is `u32::MAX`
569/// — a sentinel meaning "unresolved; the ModuleGraph builder fills it
570/// in when it sees the matching label." Symmetric to lockfile_graph's
571/// follows-resolution pattern.
572///
573/// Returns the imports list so the ModuleGraph builder can compose.
574fn harvest_imports(ast: &AstGraph, value: AstNodeId, node: &mut ModuleNode) {
575    let v = node_at(ast, value);
576    if let AstNodeKind::List(items) = &v.kind {
577        for item in items {
578            node.imports.push(ImportEdge {
579                target: u32::MAX, // unresolved sentinel
580                condition_ast_root: None,
581            });
582            let _ = item; // body AST id captured by the IR-extension ship
583        }
584    }
585}
586
587/// Pattern match: is `function` a call to an Ident named `name`? Most
588/// modules write `mkOption`/`mkIf`/etc. as bare identifiers (after a
589/// `with lib;` outer wrapping or via `lib.mkOption`); both shapes are
590/// handled.
591fn is_call_to(ast: &AstGraph, function: AstNodeId, name: &str) -> bool {
592    let f = node_at(ast, function);
593    match &f.kind {
594        AstNodeKind::Ident(s) => s == name,
595        AstNodeKind::Select { path, .. } => {
596            path.last().map_or(false, |last| last == name)
597        }
598        AstNodeKind::Apply { function: inner, .. } => {
599            // `mkIf cond value` — outer Apply.function is itself an
600            // Apply(mkIf, cond). Match the innermost.
601            is_call_to(ast, *inner, name)
602        }
603        _ => false,
604    }
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use pretty_assertions::assert_eq;
611
612    fn ast(src: &str) -> AstGraph {
613        AstGraph::from_source(src).expect("parse")
614    }
615
616    #[test]
617    fn empty_module_compiles_partial() {
618        let m = compile_module("empty.nix", &ast("{ }"), 0).unwrap();
619        assert_eq!(m.label, "empty.nix");
620        assert!(m.option_decls.is_empty());
621        assert!(m.setters.is_empty());
622        assert!(m.imports.is_empty());
623    }
624
625    #[test]
626    fn lambda_wrapped_module_dives_to_body() {
627        let m = compile_module(
628            "wrapped.nix",
629            &ast("{ config, lib, pkgs, ... }: { config.networking.hostName = \"rio\"; }"),
630            0,
631        )
632        .unwrap();
633        assert_eq!(m.setters.len(), 1);
634        assert_eq!(
635            m.setters[0].assigns_path,
636            vec!["networking", "hostName"]
637        );
638    }
639
640    #[test]
641    fn top_level_sugar_treats_as_config() {
642        // `{ networking.hostName = "rio"; }` (no explicit `config = ...`)
643        // is the cppnix sugar form. Treat the entire entry as
644        // `config.networking.hostName`.
645        let m = compile_module(
646            "sugar.nix",
647            &ast("{ networking.hostName = \"rio\"; }"),
648            0,
649        )
650        .unwrap();
651        assert_eq!(m.setters.len(), 1);
652        assert_eq!(
653            m.setters[0].assigns_path,
654            vec!["networking", "hostName"]
655        );
656    }
657
658    #[test]
659    fn mkOption_extracts_typed_decl() {
660        // `options.services.atticd.enable = mkOption { type = types.bool; default = false; description = "enable atticd"; };`
661        let m = compile_module(
662            "opt.nix",
663            &ast(
664                "{ config, ... }: { options.services.atticd.enable = \
665                 mkOption { type = types.bool; default = false; \
666                 description = \"enable atticd\"; }; }",
667            ),
668            0,
669        )
670        .unwrap();
671        assert_eq!(m.option_decls.len(), 1);
672        let d = &m.option_decls[0];
673        assert_eq!(d.path, vec!["services", "atticd", "enable"]);
674        assert_eq!(d.type_tag, "bool");
675        assert!(d.has_default);
676        assert_eq!(d.description.as_deref(), Some("enable atticd"));
677    }
678
679    #[test]
680    fn mkForce_sets_priority_to_50() {
681        let m = compile_module(
682            "force.nix",
683            &ast(
684                "{ config, ... }: { config.services.atticd.enable = mkForce true; }",
685            ),
686            0,
687        )
688        .unwrap();
689        assert_eq!(m.setters.len(), 1);
690        assert_eq!(m.setters[0].priority, 50);
691    }
692
693    #[test]
694    fn mkMerge_decomposes_into_per_attr_setters() {
695        let m = compile_module(
696            "merge.nix",
697            &ast(
698                "{ config, ... }: { config = mkMerge [ \
699                 { networking.hostName = \"rio\"; } \
700                 { boot.kernelParams = []; } \
701                 ]; }",
702            ),
703            0,
704        )
705        .unwrap();
706        // mkMerge splits into one setter per leaf assignment.
707        let paths: Vec<&[String]> =
708            m.setters.iter().map(|s| s.assigns_path.as_slice()).collect();
709        assert!(paths.iter().any(|p| p == &["networking", "hostName"]));
710        assert!(paths.iter().any(|p| p == &["boot", "kernelParams"]));
711    }
712
713    #[test]
714    fn slice_analysis_picks_up_config_reads() {
715        let g = ast(
716            "{ config, ... }: { config.boot.kernelParams = \
717             if config.networking.hostName == \"rio\" \
718             then [ \"amd_pstate=active\" ] \
719             else []; }",
720        );
721        let m = compile_module("slice.nix", &g, 0).unwrap();
722        assert_eq!(m.setters.len(), 1);
723        // The slice should now be populated on the setter itself —
724        // no need to re-walk via the standalone helper.
725        assert!(
726            m.setters[0]
727                .slice
728                .iter()
729                .any(|p| p == &vec!["networking", "hostName"]),
730            "expected setter.slice to contain networking.hostName; got {:?}",
731            m.setters[0].slice
732        );
733        // The standalone walker remains useful and returns the same
734        // result (idempotent — both paths converge on the same set).
735        let walker_slice =
736            collect_config_read_slice(&g, m.setters[0].body_ast_root);
737        assert!(walker_slice
738            .iter()
739            .any(|p| p == &vec!["networking", "hostName"]));
740    }
741
742    #[test]
743    fn slice_includes_mkif_condition_reads() {
744        // The mkIf condition reads `config.services.atticd.enable`;
745        // even though the body is a constant, the slice must include
746        // the condition's reads so the solver fires when atticd's
747        // enable flag changes.
748        let g = ast(
749            "{ config, ... }: { config.boot.kernelParams = \
750             mkIf config.services.atticd.enable [ \"foo\" ]; }",
751        );
752        let m = compile_module("slice_cond.nix", &g, 0).unwrap();
753        assert_eq!(m.setters.len(), 1);
754        assert!(
755            m.setters[0]
756                .slice
757                .iter()
758                .any(|p| p == &vec!["services", "atticd", "enable"]),
759            "expected slice to include condition's reads; got {:?}",
760            m.setters[0].slice
761        );
762    }
763
764    #[test]
765    fn imports_list_captures_count() {
766        let m = compile_module(
767            "with-imports.nix",
768            &ast(
769                "{ config, ... }: { \
770                 imports = [ ./a.nix ./b.nix ./c.nix ]; \
771                 config.x = 1; }",
772            ),
773            0,
774        )
775        .unwrap();
776        assert_eq!(m.imports.len(), 3);
777        for edge in &m.imports {
778            // Unresolved sentinel until the IR-extension ship.
779            assert_eq!(edge.target, u32::MAX);
780        }
781    }
782
783    #[test]
784    fn id_threads_through() {
785        let m = compile_module("x.nix", &ast("{ }"), 42).unwrap();
786        assert_eq!(m.id, 42);
787    }
788
789    #[test]
790    fn ast_graph_hash_carries_to_module_node() {
791        let g = ast("{ x = 1; }");
792        let m = compile_module("hash.nix", &g, 0).unwrap();
793        assert_eq!(m.ast_graph_hash, g.canonical_hash.bytes);
794    }
795}