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
1 file src/api.zig+123 −5
src/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 }

view file