sui-eval 0.1.191

Clean-room Nix language evaluator — lazy tree-walker + bytecode VM with construction-guaranteed Lazy<T>
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Bridge between sui-eval's lazy `Value` type and
//! `sui_spec::module_system::eval_modules`.
//!
//! Exposes `builtins.sui.evalModules` — a Nix-callable surface
//! that takes a list of module attrsets, drives them through the
//! M2.1 minimal interpreter, and returns the merged config.  This
//! is the first integration bridge: substrate primitives crossing
//! into the sui-eval surface that flakes can actually invoke.
//!
//! ## M3.1 scope
//!
//! - Modules are attrsets shaped `{ options ?, config ?, imports ? }`.
//! - Option declarations are attrsets `{ type, default ?, description ? }`
//!   where `type` is a STRING naming the OptionTypeSpec
//!   (`"bool"`, `"int"`, `"str"`, `"path"`, `"listOf"`, ...).
//!   Real cppnix uses `lib.types.<X>` typed objects — that shim
//!   lands in M3.2.
//! - Config paths are flat dotted-strings in the attrset keys
//!   (`"services.foo.enable" = true;`).  Recursive shorthand
//!   (`services.foo.enable = true;` without quotes) is the
//!   parser's job and works equivalently.
//! - `imports` is M3.2 — for M3.1 the caller pre-flattens.
//! - `mkIf` / `mkForce` / `mkDefault` wrappers are M3.2.  M3.1
//!   accepts only bare definitions at the normal priority (100).
//!
//! Once this bridge is in place, future M3.x ratchets extend it
//! to handle the real cppnix authoring shapes without breaking
//! the M3.1 contract — M3.1 modules continue to evaluate
//! unchanged.

use std::collections::HashMap;
use std::rc::Rc;

use super::*;
use sui_spec::module_system::{
    self, Definition, Module, NixValue, OptionDecl,
};

/// Register the bridge builtin under `builtins.sui.evalModules`.
///
/// The caller wires this from `sui_ext::register` (or directly
/// from `mod.rs`) so it lands at `builtins.sui.evalModules`.
pub(crate) fn register(sui_ext: &mut NixAttrs) {
    register_builtin(sui_ext, "evalModules", |args| {
        eval_modules_builtin(&args[0])
    });
}

/// The actual builtin implementation.  Takes one arg: a list of
/// module attrsets.
fn eval_modules_builtin(modules_arg: &Value) -> Result<Value, EvalError> {
    let forced = crate::eval::force_value(modules_arg)?;
    let list = match forced {
        Value::List(l) => l,
        other => {
            return Err(EvalError::type_error(format!(
                "builtins.sui.evalModules: expected a list of module attrsets, got {}",
                other.type_name(),
            )));
        }
    };

    let mut modules: Vec<Module> = Vec::with_capacity(list.len());
    for (i, m) in list.iter().enumerate() {
        modules.push(parse_module(m, i)?);
    }

    let registry = module_system::load_canonical()
        .map_err(|e| EvalError::type_error(format!(
            "builtins.sui.evalModules: registry load: {e:?}",
        )))?
        .types;

    let config = module_system::eval_modules(&modules, &registry)
        .map_err(|e| EvalError::type_error(format!(
            "builtins.sui.evalModules: {e:?}",
        )))?;

    Ok(config_to_value(config))
}

/// Convert one Nix attrset (the module shape) into a typed Module.
fn parse_module(value: &Value, idx: usize) -> Result<Module, EvalError> {
    let forced = crate::eval::force_value(value)?;
    let attrs = match forced {
        Value::Attrs(a) => a,
        other => {
            return Err(EvalError::type_error(format!(
                "builtins.sui.evalModules: module[{idx}] must be an attrset, got {}",
                other.type_name(),
            )));
        }
    };

    let mut module = Module::default();

    // Walk the `options` attrset → HashMap<String, OptionDecl>.
    if let Some(opts) = attrs.get("options") {
        let forced_opts = crate::eval::force_value(opts)?;
        let opts_attrs = match forced_opts {
            Value::Attrs(a) => a,
            other => {
                return Err(EvalError::type_error(format!(
                    "builtins.sui.evalModules: module[{idx}].options must be an attrset, got {}",
                    other.type_name(),
                )));
            }
        };
        for (path, decl_val) in opts_attrs.iter() {
            module
                .options
                .insert(path.to_string(), parse_option_decl(decl_val, &path)?);
        }
    }

    // Walk the `config` attrset → Vec<Definition>.
    if let Some(cfg) = attrs.get("config") {
        let forced_cfg = crate::eval::force_value(cfg)?;
        let cfg_attrs = match forced_cfg {
            Value::Attrs(a) => a,
            other => {
                return Err(EvalError::type_error(format!(
                    "builtins.sui.evalModules: module[{idx}].config must be an attrset, got {}",
                    other.type_name(),
                )));
            }
        };
        for (path, value) in cfg_attrs.iter() {
            module.config.push(Definition {
                path: path.to_string(),
                value: crate::eval::force_value(value)?.to_json(),
                priority: 100, // M3.1: only normal-priority defs (mkIf/mkForce land in M3.2)
                cond: None,
            });
        }
    }

    // `imports` — M3.1 accepts the field but ignores it (caller pre-flattens).
    // Validation: it must be a list of strings if present.
    if let Some(imports) = attrs.get("imports") {
        let forced = crate::eval::force_value(imports)?;
        match forced {
            Value::List(l) => {
                for item in l.iter() {
                    let forced_item = crate::eval::force_value(item)?;
                    if !matches!(forced_item, Value::String(_)) {
                        return Err(EvalError::type_error(format!(
                            "builtins.sui.evalModules: module[{idx}].imports \
                             items must be strings (M3.1); attrset/function imports land in M3.2",
                        )));
                    }
                }
            }
            _ => {
                return Err(EvalError::type_error(format!(
                    "builtins.sui.evalModules: module[{idx}].imports must be a list",
                )));
            }
        }
    }

    Ok(module)
}

/// Parse one option-declaration attrset.
fn parse_option_decl(decl_val: &Value, path: &str) -> Result<OptionDecl, EvalError> {
    let forced = crate::eval::force_value(decl_val)?;
    let attrs = match forced {
        Value::Attrs(a) => a,
        other => {
            return Err(EvalError::type_error(format!(
                "builtins.sui.evalModules: option `{path}` declaration must be an \
                 attrset, got {}",
                other.type_name(),
            )));
        }
    };

    let type_name = match attrs.get("type") {
        Some(v) => parse_type_field(v, path)?,
        None => {
            return Err(EvalError::type_error(format!(
                "builtins.sui.evalModules: option `{path}` missing required `type` field",
            )));
        }
    };

    let default = match attrs.get("default") {
        Some(v) => Some(crate::eval::force_value(v)?.to_json()),
        None => None,
    };

    let description = match attrs.get("description") {
        Some(v) => match crate::eval::force_value(v)? {
            Value::String(s) => s.chars.to_string(),
            _ => String::new(),
        },
        None => String::new(),
    };

    Ok(OptionDecl {
        type_name,
        default,
        description,
        submodule: None,
    })
}

/// Parse the option's `type` field.  Accepts two shapes:
///
/// - **M3.1 bare string**: `type = "bool"` — direct
///   OptionTypeSpec.name reference.
/// - **M3.2 cppnix typed object**: `type = { name = "bool"; ... }` —
///   the cppnix `lib.types.<X>` convention.  The bridge looks up
///   the `name` field; other fields (check, merge, description)
///   are ignored here because the M2.1 interpreter dispatches by
///   the type's OptionTypeSpec rather than cppnix-style typed
///   check/merge functions.
///
/// The dual-shape support lets operators consume real-world
/// cppnix modules unchanged while keeping the M3.1 bare-string
/// shape working.
fn parse_type_field(v: &Value, path: &str) -> Result<String, EvalError> {
    let forced = crate::eval::force_value(v)?;
    match forced {
        // M3.1 string-typed field.
        Value::String(s) => Ok(s.chars.to_string()),
        // M3.2 cppnix typed-object: extract the `name` field.
        Value::Attrs(attrs) => match attrs.get("name") {
            Some(name_val) => match crate::eval::force_value(name_val)? {
                Value::String(s) => Ok(s.chars.to_string()),
                other => Err(EvalError::type_error(format!(
                    "builtins.sui.evalModules: option `{path}`.type is a typed \
                     object but its `name` field is {}, expected string",
                    other.type_name(),
                ))),
            },
            None => Err(EvalError::type_error(format!(
                "builtins.sui.evalModules: option `{path}`.type is an attrset \
                 but has no `name` field — typed-object types (M3.2) must \
                 carry their type name in `name`",
            ))),
        },
        other => Err(EvalError::type_error(format!(
            "builtins.sui.evalModules: option `{path}`.type must be a string \
             (e.g. \"bool\") or a typed object with a `name` field; got {}",
            other.type_name(),
        ))),
    }
}

/// Convert the typed Config back into a `Value::Attrs`.
fn config_to_value(config: HashMap<String, NixValue>) -> Value {
    let mut attrs = NixAttrs::new();
    for (k, v) in config {
        attrs.insert(k, json_to_value(&v));
    }
    Value::Attrs(Rc::new(attrs))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Build a tiny module-list Value (the input shape for the
    /// builtin) directly via Value constructors.
    fn module_list(modules: Vec<Value>) -> Value {
        Value::List(Rc::new(NixList::new(modules)))
    }

    fn attrs_of(pairs: &[(&str, Value)]) -> Value {
        let mut a = NixAttrs::new();
        for (k, v) in pairs {
            a.insert(k.to_string(), v.clone());
        }
        Value::Attrs(Rc::new(a))
    }

    #[test]
    fn trivial_bool_evaluates_through_the_bridge() {
        let opt_decl = attrs_of(&[("type", Value::string("bool"))]);
        let options = attrs_of(&[("enable", opt_decl)]);
        let config = attrs_of(&[("enable", Value::Bool(true))]);
        let module = attrs_of(&[("options", options), ("config", config)]);
        let result = eval_modules_builtin(&module_list(vec![module])).unwrap();
        let attrs = match result {
            Value::Attrs(a) => a,
            _ => panic!("expected attrs result"),
        };
        match attrs.get("enable") {
            Some(Value::Bool(b)) => assert!(*b),
            other => panic!("expected enable=true, got {other:?}"),
        }
    }

    #[test]
    fn default_surfaces_when_undefined() {
        let opt_decl = attrs_of(&[
            ("type", Value::string("int")),
            ("default", Value::Int(80)),
        ]);
        let options = attrs_of(&[("port", opt_decl)]);
        // No config — default should kick in.
        let module = attrs_of(&[("options", options)]);
        let result = eval_modules_builtin(&module_list(vec![module])).unwrap();
        let attrs = match result {
            Value::Attrs(a) => a,
            _ => panic!("expected attrs"),
        };
        match attrs.get("port") {
            Some(Value::Int(n)) => assert_eq!(*n, 80),
            other => panic!("expected port=80, got {other:?}"),
        }
    }

    #[test]
    fn rejects_non_list_arg() {
        let bogus = Value::Bool(true);
        let err = eval_modules_builtin(&bogus).unwrap_err();
        let msg = format!("{err:?}");
        assert!(msg.contains("list of module attrsets"));
    }

    #[test]
    fn rejects_module_with_non_attrset() {
        let result = eval_modules_builtin(&module_list(vec![Value::Int(42)]));
        assert!(result.is_err());
    }

    #[test]
    fn rejects_option_with_missing_type() {
        let opt_decl = attrs_of(&[]);  // no `type` field
        let options = attrs_of(&[("foo", opt_decl)]);
        let module = attrs_of(&[("options", options)]);
        let err = eval_modules_builtin(&module_list(vec![module])).unwrap_err();
        let msg = format!("{err:?}");
        assert!(msg.contains("missing required `type`"));
    }

    #[test]
    fn m32_accepts_typed_object_with_name_field() {
        // cppnix shape: `type = { name = "bool"; check = ...; ... }`.
        // M3.2 extracts the `name` field.
        let typed_obj = attrs_of(&[
            ("name", Value::string("bool")),
            ("check", Value::string("<fake-fn>")),  // ignored
        ]);
        let opt_decl = attrs_of(&[("type", typed_obj)]);
        let options = attrs_of(&[("enable", opt_decl)]);
        let config = attrs_of(&[("enable", Value::Bool(true))]);
        let module = attrs_of(&[("options", options), ("config", config)]);
        let result = eval_modules_builtin(&module_list(vec![module])).unwrap();
        let attrs = match result {
            Value::Attrs(a) => a,
            _ => panic!("expected attrs"),
        };
        match attrs.get("enable") {
            Some(Value::Bool(b)) => assert!(*b),
            other => panic!("expected enable=true, got {other:?}"),
        }
    }

    #[test]
    fn m32_rejects_typed_object_without_name_field() {
        let typed_obj = attrs_of(&[("check", Value::string("<fn>"))]);
        let opt_decl = attrs_of(&[("type", typed_obj)]);
        let options = attrs_of(&[("foo", opt_decl)]);
        let module = attrs_of(&[("options", options)]);
        let err = eval_modules_builtin(&module_list(vec![module])).unwrap_err();
        let msg = format!("{err:?}");
        assert!(msg.contains("no `name` field"));
    }

    #[test]
    fn m32_rejects_typed_object_with_non_string_name() {
        let typed_obj = attrs_of(&[("name", Value::Int(42))]);
        let opt_decl = attrs_of(&[("type", typed_obj)]);
        let options = attrs_of(&[("foo", opt_decl)]);
        let module = attrs_of(&[("options", options)]);
        let err = eval_modules_builtin(&module_list(vec![module])).unwrap_err();
        let msg = format!("{err:?}");
        assert!(msg.contains("`name` field is"));
    }

    #[test]
    fn type_mismatch_surfaces_through_bridge() {
        // bool option, but config gives an int — the underlying
        // eval_modules type-check should surface.
        let opt_decl = attrs_of(&[("type", Value::string("bool"))]);
        let options = attrs_of(&[("enable", opt_decl)]);
        let config = attrs_of(&[("enable", Value::Int(42))]);
        let module = attrs_of(&[("options", options), ("config", config)]);
        let err = eval_modules_builtin(&module_list(vec![module])).unwrap_err();
        let msg = format!("{err:?}");
        assert!(msg.contains("type-check") || msg.contains("bool"));
    }

    #[test]
    fn list_of_concatenates_across_modules() {
        let opt_decl = attrs_of(&[("type", Value::string("listOf"))]);
        let options = attrs_of(&[("xs", opt_decl)]);

        let cfg1 = attrs_of(&[("xs", Value::list(vec![Value::Int(1), Value::Int(2)]))]);
        let mod1 = attrs_of(&[("options", options), ("config", cfg1)]);

        let cfg2 = attrs_of(&[("xs", Value::list(vec![Value::Int(3), Value::Int(4)]))]);
        let mod2 = attrs_of(&[("config", cfg2)]);

        let result = eval_modules_builtin(&module_list(vec![mod1, mod2])).unwrap();
        let attrs = match result {
            Value::Attrs(a) => a,
            _ => panic!("expected attrs"),
        };
        let list_val = attrs.get("xs").expect("xs must resolve");
        let items = match list_val {
            Value::List(l) => l,
            _ => panic!("expected list, got {list_val:?}"),
        };
        assert_eq!(items.len(), 4);
    }
}