d55b75fe962b
searchOptions builds a working nix eval --impure --expr invocation (nixosSystem + optionAttrSetToDocList) instead of the previous nix eval nixpkgs#lib.optionAttrSetToDocList nixpkgs#nixosModules call that could never succeed (two positionals, a non-serializable function, an unevaluable output path); filterOptions now also accepts the literalExpression-wrapped default shape alongside plain strings; best-effort fix verified only via fixture-driven parsing tests (no nix on this host) — needs real-host/VM confirmation before fully resolved (NINA-009)
- time
- 2026-07-02 14:16 UTC · 3 months ago
- machine
- koh2
- agent
- claude-sonnet-5
- session
- fable audit fix — api.zig
- parent
- e5a03023f758
↓ nina-d55b75fe.tar.gzatlas at this save
files changed1 file
src/api.zig+123 −5src/api.zig+123 −5
@@ -242,9 +242,34 @@242242 return "";243243 }244244 245+// nix eval takes exactly one installable/expression and its result must be246+// JSON-serializable, so (unlike searchPackages) this can't point straight at a247+// flake attribute: lib.optionAttrSetToDocList is a function, not a value, and248+// nixpkgs#nixosModules isn't an output path nix eval can resolve at all. This249+// builds a minimal NixOS system from an empty module list and feeds its250+// accumulated options tree to optionAttrSetToDocList ourselves — the same251+// technique nixos-option and the NixOS manual generator use. --impure is252+// required for builtins.getFlake on the bare registry name and for253+// builtins.currentSystem.254+//255+// NOTE: not exercised against real nix (none available on this host/dev256+// machine); verified only via the fixture-driven parsing test below against257+// hand-written JSON shaped like optionAttrSetToDocList's documented output.258+// Needs a real-host / VM run before this is considered fully resolved.259+const OPTIONS_DOC_LIST_EXPR =260+ \\let261+ \\ flake = builtins.getFlake "nixpkgs";262+ \\ eval = flake.lib.nixosSystem {263+ \\ system = builtins.currentSystem;264+ \\ modules = [ { } ];265+ \\ };266+ \\in267+ \\ flake.lib.optionAttrSetToDocList eval.options268+;269+245270 pub fn searchOptions(gpa: std.mem.Allocator, io: std.Io, query: []const u8) ![]NixOption {246271 const result = std.process.run(gpa, io, .{247- .argv = &.{ "nix", "eval", "--json", "nixpkgs#lib.optionAttrSetToDocList", "nixpkgs#nixosModules" },272+ .argv = &.{ "nix", "eval", "--json", "--impure", "--expr", OPTIONS_DOC_LIST_EXPR },248273 }) catch return error.SearchFailed;249274 defer gpa.free(result.stderr);250275 defer gpa.free(result.stdout);@@ -264,6 +289,19 @@264289 description: []const u8,265290 };266291 292+// optionAttrSetToDocList's "default" field is a plain string on older293+// nixpkgs, or a { _type = "literalExpression"; text = "..."; } wrapper on294+// nixos-render-docs era nixpkgs. Handle both; anything else (e.g. a295+// submodule's structured default) renders as "no default" rather than296+// guessing at a representation.297+fn optionDefaultText(v: std.json.Value) ?[]const u8 {298+ return switch (v) {299+ .string => |s| s,300+ .object => |obj| if (obj.get("text")) |t| (if (t == .string) t.string else null) else null,301+ else => null,302+ };303+}304+267305 fn filterOptions(gpa: std.mem.Allocator, json_str: []const u8, query: []const u8) ![]NixOption {268306 const parsed = std.json.parseFromSlice(std.json.Value, gpa, json_str, .{}) catch return error.SearchFailed;269307 defer parsed.deinit();@@ -278,10 +316,7 @@278316 279317 const type_str = if (item.object.get("type")) |v| if (v == .string) v.string else "any" else "any";280318 const description = if (item.object.get("description")) |v| if (v == .string) v.string else "" else "";281- const default_val: ?[]const u8 = if (item.object.get("default")) |v| switch (v) {282- .string => |s| s,283- else => null,284- } else null;319+ const default_val: ?[]const u8 = if (item.object.get("default")) |v| optionDefaultText(v) else null;285320 286321 try opts.append(gpa, .{287322 .name = try gpa.dupe(u8, name),@@ -303,4 +338,87 @@303338 if (std.mem.indexOf(u8, line, "systemPackages") != null) return line_num;304339 }305340 return null;341+}342+343+// ─── Tests ───────────────────────────────────────────────────────────────────344+//345+// searchOptions itself is not exercised here (no nix on this host); these346+// pin filterOptions's parsing against JSON shaped like optionAttrSetToDocList's347+// real output (NINA-009), covering both the modern literalExpression-wrapped348+// default and the older plain-string default.349+350+test "filterOptions extracts a known option from realistic doc-list JSON (NINA-009)" {351+ const fixture =352+ \\[353+ \\ {354+ \\ "name": "services.openssh.enable",355+ \\ "type": "boolean",356+ \\ "default": { "_type": "literalExpression", "text": "false" },357+ \\ "description": "Whether to enable the OpenSSH secure shell daemon."358+ \\ },359+ \\ {360+ \\ "name": "services.nginx.enable",361+ \\ "type": "boolean",362+ \\ "default": { "_type": "literalExpression", "text": "false" },363+ \\ "description": "Whether to enable nginx."364+ \\ }365+ \\]366+ ;367+368+ const opts = try filterOptions(std.testing.allocator, fixture, "openssh");369+ defer {370+ for (opts) |o| {371+ std.testing.allocator.free(o.name);372+ std.testing.allocator.free(o.type_str);373+ std.testing.allocator.free(o.description);374+ if (o.default) |d| std.testing.allocator.free(d);375+ }376+ std.testing.allocator.free(opts);377+ }378+379+ try std.testing.expectEqual(@as(usize, 1), opts.len);380+ try std.testing.expectEqualStrings("services.openssh.enable", opts[0].name);381+ try std.testing.expectEqualStrings("boolean", opts[0].type_str);382+ try std.testing.expect(opts[0].default != null);383+ try std.testing.expectEqualStrings("false", opts[0].default.?);384+ try std.testing.expect(std.mem.indexOf(u8, opts[0].description, "OpenSSH") != null);385+}386+387+test "filterOptions still reads a plain-string default (pre-literalExpression nixpkgs)" {388+ const fixture =389+ \\[390+ \\ {391+ \\ "name": "services.openssh.ports",392+ \\ "type": "list of signed integer",393+ \\ "default": "[ 22 ]",394+ \\ "description": "Ports on which to listen."395+ \\ }396+ \\]397+ ;398+399+ const opts = try filterOptions(std.testing.allocator, fixture, "");400+ defer {401+ for (opts) |o| {402+ std.testing.allocator.free(o.name);403+ std.testing.allocator.free(o.type_str);404+ std.testing.allocator.free(o.description);405+ if (o.default) |d| std.testing.allocator.free(d);406+ }407+ std.testing.allocator.free(opts);408+ }409+410+ try std.testing.expectEqual(@as(usize, 1), opts.len);411+ try std.testing.expectEqualStrings("[ 22 ]", opts[0].default.?);412+}413+414+test "filterOptions returns no match for a query that isn't a substring of any option name" {415+ const fixture =416+ \\[417+ \\ { "name": "services.openssh.enable", "type": "boolean", "default": null, "description": "" }418+ \\]419+ ;420+421+ const opts = try filterOptions(std.testing.allocator, fixture, "postgres");422+ defer std.testing.allocator.free(opts);423+ try std.testing.expectEqual(@as(usize, 0), opts.len);306424 }