use crate::ast_graph::{AstGraph, AstNodeForm, AstNodeKind, AttrEntry, NodeId as AstNodeId};
use crate::module_graph::{
ConfigSetter, EnvPrefixBinding, EnvPrefixKind, ImportEdge, ModuleId, ModuleNode, OptionDecl,
SetterId,
};
#[derive(Debug, thiserror::Error)]
pub enum ModuleCompilerError {
#[error("module root expression is not an attrset or lambda — got {kind:?}")]
UnexpectedRootShape { kind: &'static str },
}
pub fn compile_module(
label: &str,
ast: &AstGraph,
id: ModuleId,
) -> Result<ModuleNode, ModuleCompilerError> {
let mut node = ModuleNode {
id,
label: label.to_string(),
ast_graph_hash: ast.canonical_hash.bytes,
option_decls: Vec::new(),
setters: Vec::new(),
imports: Vec::new(),
body_env_prefix: Vec::new(),
};
let (body_id_opt, prefix) = resolve_module_body_with_prefix(ast)?;
node.body_env_prefix = prefix;
let body_id = match body_id_opt {
Some(id) => id,
None => return Ok(node), };
let body = node_at(ast, body_id);
if let AstNodeKind::AttrSet { entries, .. } = &body.kind {
for entry in entries {
classify_top_level_entry(ast, entry, &mut node);
}
}
Ok(node)
}
fn node_at(ast: &AstGraph, id: AstNodeId) -> &AstNodeForm {
&ast.nodes[id as usize]
}
fn resolve_module_body_with_prefix(
ast: &AstGraph,
) -> Result<(Option<AstNodeId>, Vec<EnvPrefixBinding>), ModuleCompilerError> {
let mut cursor = ast.root_id;
let mut prefix: Vec<EnvPrefixBinding> = Vec::new();
let mut with_depth = 0u32;
for _ in 0..32 {
let node = node_at(ast, cursor);
match &node.kind {
AstNodeKind::Lambda { body, .. } => {
cursor = *body;
continue;
}
AstNodeKind::LetIn { bindings, body, .. } => {
for entry in bindings {
if entry.path.len() == 1 {
prefix.push(EnvPrefixBinding {
name: entry.path[0].clone(),
value_node_id: entry.value,
kind: EnvPrefixKind::Let,
});
}
}
cursor = *body;
continue;
}
AstNodeKind::With { env: scope, body } => {
prefix.push(EnvPrefixBinding {
name: format!("__with_{with_depth}"),
value_node_id: *scope,
kind: EnvPrefixKind::With,
});
with_depth += 1;
cursor = *body;
continue;
}
AstNodeKind::Assert { body, .. } => {
cursor = *body;
continue;
}
AstNodeKind::AttrSet { .. } => return Ok((Some(cursor), prefix)),
AstNodeKind::Unknown { .. } | AstNodeKind::Null => {
return Ok((None, prefix));
}
other => {
return Err(ModuleCompilerError::UnexpectedRootShape {
kind: kind_name(other),
});
}
}
}
Ok((None, prefix))
}
fn kind_name(k: &AstNodeKind) -> &'static str {
match k {
AstNodeKind::Int(_) => "Int",
AstNodeKind::Float(_) => "Float",
AstNodeKind::Bool(_) => "Bool",
AstNodeKind::Null => "Null",
AstNodeKind::Str { .. } => "Str",
AstNodeKind::IndentedStr { .. } => "IndentedStr",
AstNodeKind::Path(_) => "Path",
AstNodeKind::Ident(_) => "Ident",
AstNodeKind::Select { .. } => "Select",
AstNodeKind::HasAttr { .. } => "HasAttr",
AstNodeKind::List(_) => "List",
AstNodeKind::AttrSet { .. } => "AttrSet",
AstNodeKind::LetIn { .. } => "LetIn",
AstNodeKind::With { .. } => "With",
AstNodeKind::Assert { .. } => "Assert",
AstNodeKind::Lambda { .. } => "Lambda",
AstNodeKind::Apply { .. } => "Apply",
AstNodeKind::IfThenElse { .. } => "IfThenElse",
AstNodeKind::BinOp { .. } => "BinOp",
AstNodeKind::UnaryOp { .. } => "UnaryOp",
AstNodeKind::Unknown { .. } => "Unknown",
}
}
fn classify_top_level_entry(ast: &AstGraph, entry: &AttrEntry, node: &mut ModuleNode) {
if entry.path.is_empty() {
return;
}
match entry.path[0].as_str() {
"options" => harvest_options(ast, &entry.path[1..], entry.value, node),
"config" => harvest_config(ast, &entry.path[1..], entry.value, node, None, 100),
"imports" => harvest_imports(ast, entry.value, node),
_ => {
harvest_config(ast, &entry.path, entry.value, node, None, 100);
}
}
}
fn harvest_options(ast: &AstGraph, path: &[String], value: AstNodeId, node: &mut ModuleNode) {
let v = node_at(ast, value);
match &v.kind {
AstNodeKind::Apply { function, argument } => {
if is_call_to(ast, *function, "mkOption") {
if let Some(decl) = mk_option_decl(ast, path, *argument) {
node.option_decls.push(decl);
}
}
}
AstNodeKind::AttrSet { entries, .. } => {
for child in entries {
let mut sub = path.to_vec();
sub.extend(child.path.iter().cloned());
harvest_options(ast, &sub, child.value, node);
}
}
_ => {
node.option_decls.push(OptionDecl {
path: path.to_vec(),
type_tag: "unknown".to_string(),
has_default: false,
description: None,
});
}
}
}
fn mk_option_decl(ast: &AstGraph, path: &[String], args: AstNodeId) -> Option<OptionDecl> {
let a = node_at(ast, args);
if let AstNodeKind::AttrSet { entries, .. } = &a.kind {
let mut type_tag = "unknown".to_string();
let mut has_default = false;
let mut description: Option<String> = None;
for e in entries {
if e.path.len() != 1 {
continue;
}
match e.path[0].as_str() {
"type" => {
let t = node_at(ast, e.value);
if let AstNodeKind::Ident(name) = &t.kind {
type_tag = name.clone();
} else if let AstNodeKind::Select { path: p, .. } = &t.kind {
if let Some(last) = p.last() {
type_tag = last.clone();
}
}
}
"default" => has_default = true,
"description" => {
let d = node_at(ast, e.value);
if let AstNodeKind::Str { segments } = &d.kind {
let mut buf = String::new();
for s in segments {
if let crate::ast_graph::StrSegment::Literal(t) = s {
buf.push_str(t);
}
}
description = Some(buf);
}
}
_ => {}
}
}
Some(OptionDecl {
path: path.to_vec(),
type_tag,
has_default,
description,
})
} else {
None
}
}
fn harvest_config(
ast: &AstGraph,
path: &[String],
value: AstNodeId,
node: &mut ModuleNode,
condition: Option<AstNodeId>,
priority: u32,
) {
let v = node_at(ast, value);
match &v.kind {
AstNodeKind::Apply { function, argument } => {
if is_call_to(ast, *function, "mkIf") {
if let Some((cond, body)) = split_mkif_args(ast, *function, *argument) {
harvest_config(ast, path, body, node, Some(cond), priority);
return;
}
}
if is_call_to(ast, *function, "mkForce") {
harvest_config(ast, path, *argument, node, condition, 50);
return;
}
if is_call_to(ast, *function, "mkVMOverride") {
harvest_config(ast, path, *argument, node, condition, 10);
return;
}
if is_call_to(ast, *function, "mkMerge") {
let arg = node_at(ast, *argument);
if let AstNodeKind::List(items) = &arg.kind {
for item in items {
harvest_config(ast, path, *item, node, condition, priority);
}
return;
}
}
emit_setter(ast, node, path, value, condition, priority);
}
AstNodeKind::AttrSet { entries, .. } => {
for child in entries {
let mut sub = path.to_vec();
sub.extend(child.path.iter().cloned());
harvest_config(ast, &sub, child.value, node, condition, priority);
}
}
_ => {
emit_setter(ast, node, path, value, condition, priority);
}
}
}
fn split_mkif_args(
ast: &AstGraph,
function: AstNodeId,
body: AstNodeId,
) -> Option<(AstNodeId, AstNodeId)> {
let f = node_at(ast, function);
if let AstNodeKind::Apply { argument, .. } = &f.kind {
Some((*argument, body))
} else {
None
}
}
fn emit_setter(
ast: &AstGraph,
node: &mut ModuleNode,
path: &[String],
body_ast_root: AstNodeId,
condition_ast_root: Option<AstNodeId>,
priority: u32,
) {
let id = node.setters.len() as SetterId;
let mut slice = collect_config_read_slice(ast, body_ast_root);
if let Some(cond_id) = condition_ast_root {
let cond_slice = collect_config_read_slice(ast, cond_id);
slice.extend(cond_slice);
slice.sort();
slice.dedup();
}
node.setters.push(ConfigSetter {
id,
assigns_path: path.to_vec(),
slice,
body_ast_root,
condition_ast_root,
priority,
});
}
#[must_use]
pub fn collect_config_read_slice(ast: &AstGraph, body_ast_root: AstNodeId) -> Vec<Vec<String>> {
let mut out: Vec<Vec<String>> = Vec::new();
walk_for_config_reads(ast, body_ast_root, &mut out);
out.sort();
out.dedup();
out
}
fn walk_for_config_reads(ast: &AstGraph, id: AstNodeId, out: &mut Vec<Vec<String>>) {
let n = node_at(ast, id);
match &n.kind {
AstNodeKind::Select { target, path, fallback } => {
let t = node_at(ast, *target);
if matches!(&t.kind, AstNodeKind::Ident(s) if s == "config") {
out.push(path.clone());
} else {
walk_for_config_reads(ast, *target, out);
}
if let Some(f) = fallback {
walk_for_config_reads(ast, *f, out);
}
}
AstNodeKind::HasAttr { target, path } => {
let t = node_at(ast, *target);
if matches!(&t.kind, AstNodeKind::Ident(s) if s == "config") {
out.push(path.clone());
} else {
walk_for_config_reads(ast, *target, out);
}
}
AstNodeKind::Apply { function, argument } => {
walk_for_config_reads(ast, *function, out);
walk_for_config_reads(ast, *argument, out);
}
AstNodeKind::List(items) => {
for item in items {
walk_for_config_reads(ast, *item, out);
}
}
AstNodeKind::AttrSet { entries, inherits, .. } => {
for e in entries {
walk_for_config_reads(ast, e.value, out);
}
for i in inherits {
if let Some(s) = i.source {
walk_for_config_reads(ast, s, out);
}
}
}
AstNodeKind::LetIn { bindings, inherits, body } => {
for b in bindings {
walk_for_config_reads(ast, b.value, out);
}
for i in inherits {
if let Some(s) = i.source {
walk_for_config_reads(ast, s, out);
}
}
walk_for_config_reads(ast, *body, out);
}
AstNodeKind::With { env, body } => {
walk_for_config_reads(ast, *env, out);
walk_for_config_reads(ast, *body, out);
}
AstNodeKind::Assert { condition, body } => {
walk_for_config_reads(ast, *condition, out);
walk_for_config_reads(ast, *body, out);
}
AstNodeKind::Lambda { body, .. } => {
walk_for_config_reads(ast, *body, out);
}
AstNodeKind::IfThenElse {
condition,
then_branch,
else_branch,
} => {
walk_for_config_reads(ast, *condition, out);
walk_for_config_reads(ast, *then_branch, out);
walk_for_config_reads(ast, *else_branch, out);
}
AstNodeKind::BinOp { left, right, .. } => {
walk_for_config_reads(ast, *left, out);
walk_for_config_reads(ast, *right, out);
}
AstNodeKind::UnaryOp { operand, .. } => {
walk_for_config_reads(ast, *operand, out);
}
AstNodeKind::Str { segments } | AstNodeKind::IndentedStr { segments } => {
for s in segments {
if let crate::ast_graph::StrSegment::Interpolation(id) = s {
walk_for_config_reads(ast, *id, out);
}
}
}
AstNodeKind::Int(_)
| AstNodeKind::Float(_)
| AstNodeKind::Bool(_)
| AstNodeKind::Null
| AstNodeKind::Path(_)
| AstNodeKind::Ident(_)
| AstNodeKind::Unknown { .. } => {}
}
}
fn harvest_imports(ast: &AstGraph, value: AstNodeId, node: &mut ModuleNode) {
let v = node_at(ast, value);
if let AstNodeKind::List(items) = &v.kind {
for item in items {
node.imports.push(ImportEdge {
target: u32::MAX, condition_ast_root: None,
});
let _ = item; }
}
}
fn is_call_to(ast: &AstGraph, function: AstNodeId, name: &str) -> bool {
let f = node_at(ast, function);
match &f.kind {
AstNodeKind::Ident(s) => s == name,
AstNodeKind::Select { path, .. } => {
path.last().map_or(false, |last| last == name)
}
AstNodeKind::Apply { function: inner, .. } => {
is_call_to(ast, *inner, name)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn ast(src: &str) -> AstGraph {
AstGraph::from_source(src).expect("parse")
}
#[test]
fn empty_module_compiles_partial() {
let m = compile_module("empty.nix", &ast("{ }"), 0).unwrap();
assert_eq!(m.label, "empty.nix");
assert!(m.option_decls.is_empty());
assert!(m.setters.is_empty());
assert!(m.imports.is_empty());
}
#[test]
fn lambda_wrapped_module_dives_to_body() {
let m = compile_module(
"wrapped.nix",
&ast("{ config, lib, pkgs, ... }: { config.networking.hostName = \"rio\"; }"),
0,
)
.unwrap();
assert_eq!(m.setters.len(), 1);
assert_eq!(
m.setters[0].assigns_path,
vec!["networking", "hostName"]
);
}
#[test]
fn top_level_sugar_treats_as_config() {
let m = compile_module(
"sugar.nix",
&ast("{ networking.hostName = \"rio\"; }"),
0,
)
.unwrap();
assert_eq!(m.setters.len(), 1);
assert_eq!(
m.setters[0].assigns_path,
vec!["networking", "hostName"]
);
}
#[test]
fn mkOption_extracts_typed_decl() {
let m = compile_module(
"opt.nix",
&ast(
"{ config, ... }: { options.services.atticd.enable = \
mkOption { type = types.bool; default = false; \
description = \"enable atticd\"; }; }",
),
0,
)
.unwrap();
assert_eq!(m.option_decls.len(), 1);
let d = &m.option_decls[0];
assert_eq!(d.path, vec!["services", "atticd", "enable"]);
assert_eq!(d.type_tag, "bool");
assert!(d.has_default);
assert_eq!(d.description.as_deref(), Some("enable atticd"));
}
#[test]
fn mkForce_sets_priority_to_50() {
let m = compile_module(
"force.nix",
&ast(
"{ config, ... }: { config.services.atticd.enable = mkForce true; }",
),
0,
)
.unwrap();
assert_eq!(m.setters.len(), 1);
assert_eq!(m.setters[0].priority, 50);
}
#[test]
fn mkMerge_decomposes_into_per_attr_setters() {
let m = compile_module(
"merge.nix",
&ast(
"{ config, ... }: { config = mkMerge [ \
{ networking.hostName = \"rio\"; } \
{ boot.kernelParams = []; } \
]; }",
),
0,
)
.unwrap();
let paths: Vec<&[String]> =
m.setters.iter().map(|s| s.assigns_path.as_slice()).collect();
assert!(paths.iter().any(|p| p == &["networking", "hostName"]));
assert!(paths.iter().any(|p| p == &["boot", "kernelParams"]));
}
#[test]
fn slice_analysis_picks_up_config_reads() {
let g = ast(
"{ config, ... }: { config.boot.kernelParams = \
if config.networking.hostName == \"rio\" \
then [ \"amd_pstate=active\" ] \
else []; }",
);
let m = compile_module("slice.nix", &g, 0).unwrap();
assert_eq!(m.setters.len(), 1);
assert!(
m.setters[0]
.slice
.iter()
.any(|p| p == &vec!["networking", "hostName"]),
"expected setter.slice to contain networking.hostName; got {:?}",
m.setters[0].slice
);
let walker_slice =
collect_config_read_slice(&g, m.setters[0].body_ast_root);
assert!(walker_slice
.iter()
.any(|p| p == &vec!["networking", "hostName"]));
}
#[test]
fn slice_includes_mkif_condition_reads() {
let g = ast(
"{ config, ... }: { config.boot.kernelParams = \
mkIf config.services.atticd.enable [ \"foo\" ]; }",
);
let m = compile_module("slice_cond.nix", &g, 0).unwrap();
assert_eq!(m.setters.len(), 1);
assert!(
m.setters[0]
.slice
.iter()
.any(|p| p == &vec!["services", "atticd", "enable"]),
"expected slice to include condition's reads; got {:?}",
m.setters[0].slice
);
}
#[test]
fn imports_list_captures_count() {
let m = compile_module(
"with-imports.nix",
&ast(
"{ config, ... }: { \
imports = [ ./a.nix ./b.nix ./c.nix ]; \
config.x = 1; }",
),
0,
)
.unwrap();
assert_eq!(m.imports.len(), 3);
for edge in &m.imports {
assert_eq!(edge.target, u32::MAX);
}
}
#[test]
fn id_threads_through() {
let m = compile_module("x.nix", &ast("{ }"), 42).unwrap();
assert_eq!(m.id, 42);
}
#[test]
fn ast_graph_hash_carries_to_module_node() {
let g = ast("{ x = 1; }");
let m = compile_module("hash.nix", &g, 0).unwrap();
assert_eq!(m.ast_graph_hash, g.canonical_hash.bytes);
}
}