api.zig

16.9 KB · raw

1// api.zig — nix data access: search, eval, options2// No HTTP. No curl. nix is on PATH by definition on NixOS.3const std = @import("std");4const types = @import("types.zig");5const errors = @import("errors.zig");6const output = @import("output.zig");78// Pin the nixpkgs registry once so `nix search nixpkgs` resolves locally instead9// of refetching github on every query. Gated by a ~/.nina-registry-pinned flag:10// absent -> pin + announce + create the flag; present -> no-op, silent. Failure11// to pin is non-fatal — search still runs (just slower), and the flag is left12// absent so a later run retries.13pub fn ensureRegistryPinned(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) void {14    const home = environ.get("HOME") orelse return;15    const flag_path = std.fmt.allocPrint(gpa, "{s}/.nina-registry-pinned", .{home}) catch return;16    defer gpa.free(flag_path);1718    if (std.Io.Dir.openFile(.cwd(), io, flag_path, .{})) |f| {19        f.close(io);20        return; // already pinned21    } else |_| {}2223    output.pinningRegistry();2425    const r = std.process.run(gpa, io, .{26        .argv = &.{ "nix", "registry", "pin", "nixpkgs" },27    }) catch return;28    defer gpa.free(r.stdout);29    defer gpa.free(r.stderr);30    switch (r.term) {31        .exited => |code| if (code != 0) return,32        else => return,33    }3435    const file = std.Io.Dir.createFile(.cwd(), io, flag_path, .{}) catch return;36    file.close(io);37}3839pub fn searchPackages(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, query: []const u8) ![]types.NixPackage {40    const result = std.process.run(gpa, io, .{41        .argv = &.{ "nix", "search", "nixpkgs", query, "--json", "--no-update-lock-file" },42    }) catch {43        errors.error_info.setSuggestion("run nix-channel --update and try again", .{});44        return error.SearchFailed;45    };46    defer gpa.free(result.stderr);47    defer gpa.free(result.stdout);4849    switch (result.term) {50        .exited => |code| if (code != 0) {51            // nix evaluates nixpkgs.legacyPackages during search and rejects unfree52            // packages even when the user has allowUnfree set system-wide. Retry53            // with NIXPKGS_ALLOW_UNFREE=1 so the package appears in results, and54            // tag it so the install step can pass the same override.55            if (isSearchUnfreeError(result.stderr)) {56                return searchPackagesUnfree(gpa, io, environ, query);57            }58            errors.error_info.setSuggestion("run nix-channel --update and try again", .{});59            return error.SearchFailed;60        },61        else => return error.SearchFailed,62    }6364    return parseSearchJson(gpa, result.stdout, false);65}6667fn isSearchUnfreeError(stderr: []const u8) bool {68    return std.mem.indexOf(u8, stderr, "requires unfree license") != null or69        std.mem.indexOf(u8, stderr, "has an unfree license") != null;70}7172fn searchPackagesUnfree(gpa: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map, query: []const u8) ![]types.NixPackage {73    var env = try environ.clone(gpa);74    defer env.deinit();75    try env.put("NIXPKGS_ALLOW_UNFREE", "1");7677    const result = std.process.run(gpa, io, .{78        // --impure allows builtins.getEnv in nixpkgs's unfree check to read79        // NIXPKGS_ALLOW_UNFREE from the environment; pure flake eval ignores it.80        .argv = &.{ "nix", "search", "nixpkgs", query, "--json", "--no-update-lock-file", "--impure" },81        .environ_map = &env,82    }) catch {83        errors.error_info.setSuggestion("run nix-channel --update and try again", .{});84        return error.SearchFailed;85    };86    defer gpa.free(result.stderr);87    defer gpa.free(result.stdout);8889    switch (result.term) {90        .exited => |code| if (code != 0) {91            errors.error_info.setSuggestion("run nix-channel --update and try again", .{});92            return error.SearchFailed;93        },94        else => return error.SearchFailed,95    }9697    return parseSearchJson(gpa, result.stdout, true);98}99100fn parseSearchJson(gpa: std.mem.Allocator, json_str: []const u8, unfree: bool) ![]types.NixPackage {101    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json_str, .{}) catch return error.SearchFailed;102    defer parsed.deinit();103104    if (parsed.value != .object) return error.SearchFailed;105106    var packages: std.ArrayList(types.NixPackage) = .empty;107    var it = parsed.value.object.iterator();108    while (it.next()) |entry| {109        const key = entry.key_ptr.*;110        const val = entry.value_ptr.*;111        if (val != .object) continue;112113        // Key format: "legacyPackages.x86_64-linux.firefox"114        const last_dot = std.mem.lastIndexOfScalar(u8, key, '.') orelse continue;115        const attr = key[last_dot + 1 ..];116117        const pname = if (val.object.get("pname")) |v|118            if (v == .string) v.string else attr119        else120            attr;121122        const version = if (val.object.get("version")) |v|123            if (v == .string) v.string else ""124        else125            "";126127        const description = if (val.object.get("description")) |v|128            if (v == .string) v.string else ""129        else130            "";131132        try packages.append(gpa, .{133            .attr = try gpa.dupe(u8, attr),134            .pname = try gpa.dupe(u8, pname),135            .version = try gpa.dupe(u8, version),136            .description = try gpa.dupe(u8, description),137            .unfree = unfree,138        });139    }140141    return packages.toOwnedSlice(gpa);142}143144pub const PkgMeta = struct {145    homepage: []const u8 = "",146    license: []const u8 = "",147};148149// Evaluate a package's meta for the expanded search detail pane. One150// `nix eval --json .meta` call; returns null if the package can't be evaluated151// (broken, renamed, etc.). Returned strings are duped into `arena`.152pub fn packageMeta(arena: std.mem.Allocator, io: std.Io, attr: []const u8) ?PkgMeta {153    const ref = std.fmt.allocPrint(arena, "nixpkgs#{s}.meta", .{attr}) catch return null;154    const result = std.process.run(arena, io, .{155        .argv = &.{ "nix", "eval", "--json", ref },156    }) catch return null;157    switch (result.term) {158        .exited => |code| if (code != 0) return null,159        else => return null,160    }161    const parsed = std.json.parseFromSlice(std.json.Value, arena, result.stdout, .{}) catch return null;162    defer parsed.deinit();163    if (parsed.value != .object) return null;164    const o = parsed.value.object;165    return .{166        .homepage = jsonFirstString(arena, o.get("homepage")),167        .license = licenseStr(arena, o.get("license")),168    };169}170171// homepage is usually a string but occasionally a list — take the first string.172fn jsonFirstString(arena: std.mem.Allocator, v: ?std.json.Value) []const u8 {173    const val = v orelse return "";174    switch (val) {175        .string => |s| return arena.dupe(u8, s) catch "",176        .array => |arr| {177            if (arr.items.len > 0 and arr.items[0] == .string) return arena.dupe(u8, arr.items[0].string) catch "";178            return "";179        },180        else => return "",181    }182}183184// license may be a single attrset, a list of attrsets, or a bare string. Join the185// fullName (or shortName) fields into one readable line. Result is duped into arena.186fn licenseStr(arena: std.mem.Allocator, v: ?std.json.Value) []const u8 {187    const val = v orelse return "";188    switch (val) {189        .string => |s| return arena.dupe(u8, s) catch "",190        .object => |obj| return arena.dupe(u8, licenseName(obj)) catch "",191        .array => |arr| {192            var buf: std.ArrayList(u8) = .empty;193            for (arr.items) |item| {194                if (item != .object) continue;195                const name = licenseName(item.object);196                if (name.len == 0) continue;197                if (buf.items.len > 0) buf.appendSlice(arena, ", ") catch {};198                buf.appendSlice(arena, name) catch {};199            }200            return buf.toOwnedSlice(arena) catch "";201        },202        else => return "",203    }204}205206// Returns a slice into the parsed JSON arena (caller dupes before that frees).207fn licenseName(obj: std.json.ObjectMap) []const u8 {208    if (obj.get("fullName")) |n| {209        if (n == .string) return n.string;210    }211    if (obj.get("shortName")) |n| {212        if (n == .string) return n.string;213    }214    return "";215}216217// nix eval takes exactly one installable/expression and its result must be218// JSON-serializable, so (unlike searchPackages) this can't point straight at a219// flake attribute: lib.optionAttrSetToDocList is a function, not a value, and220// nixpkgs#nixosModules isn't an output path nix eval can resolve at all. This221// builds a minimal NixOS system from an empty module list and feeds its222// accumulated options tree to optionAttrSetToDocList ourselves — the same223// technique nixos-option and the NixOS manual generator use. --impure is224// required for builtins.getFlake on the bare registry name and for225// builtins.currentSystem.226fn nixStringLiteral(gpa: std.mem.Allocator, s: []const u8) ![]u8 {227    var out: std.ArrayList(u8) = .empty;228    errdefer out.deinit(gpa);229    try out.append(gpa, '"');230    var i: usize = 0;231    while (i < s.len) : (i += 1) {232        switch (s[i]) {233            '\\' => try out.appendSlice(gpa, "\\\\"),234            '"' => try out.appendSlice(gpa, "\\\""),235            '\n' => try out.appendSlice(gpa, "\\n"),236            '\r' => try out.appendSlice(gpa, "\\r"),237            '\t' => try out.appendSlice(gpa, "\\t"),238            '$' => if (i + 1 < s.len and s[i + 1] == '{') {239                try out.appendSlice(gpa, "\\${");240                i += 1;241            } else {242                try out.append(gpa, s[i]);243            },244            else => |ch| try out.append(gpa, ch),245        }246    }247    try out.append(gpa, '"');248    return out.toOwnedSlice(gpa);249}250251fn optionsDocListExpr(gpa: std.mem.Allocator, query: []const u8) ![]u8 {252    const q = try nixStringLiteral(gpa, query);253    defer gpa.free(q);254    return std.fmt.allocPrint(gpa,255        \\let256        \\  flake = builtins.getFlake "nixpkgs";257        \\  query = {s};258        \\  queryRegex = ".*" + flake.lib.escapeRegex query + ".*";259        \\  eval = flake.lib.nixosSystem {{260        \\    system = builtins.currentSystem;261        \\    modules = [ {{ }} ];262        \\  }};263        \\  docs = flake.lib.optionAttrSetToDocList eval.options;264        \\in265        \\  builtins.filter (opt: builtins.match queryRegex opt.name != null) docs266    , .{q});267}268269pub fn searchOptions(gpa: std.mem.Allocator, io: std.Io, query: []const u8) ![]NixOption {270    const expr = try optionsDocListExpr(gpa, query);271    defer gpa.free(expr);272    const result = std.process.run(gpa, io, .{273        .argv = &.{ "nix", "eval", "--json", "--impure", "--expr", expr },274    }) catch return error.SearchFailed;275    defer gpa.free(result.stderr);276    defer gpa.free(result.stdout);277278    switch (result.term) {279        .exited => |code| if (code != 0) return error.SearchFailed,280        else => return error.SearchFailed,281    }282283    return filterOptions(gpa, result.stdout, query);284}285286pub const NixOption = struct {287    name: []const u8,288    type_str: []const u8,289    default: ?[]const u8,290    description: []const u8,291};292293// optionAttrSetToDocList's "default" field is a plain string on older294// nixpkgs, or a { _type = "literalExpression"; text = "..."; } wrapper on295// nixos-render-docs era nixpkgs. Handle both; anything else (e.g. a296// submodule's structured default) renders as "no default" rather than297// guessing at a representation.298fn optionDefaultText(v: std.json.Value) ?[]const u8 {299    return switch (v) {300        .string => |s| s,301        .object => |obj| if (obj.get("text")) |t| (if (t == .string) t.string else null) else null,302        else => null,303    };304}305306fn filterOptions(gpa: std.mem.Allocator, json_str: []const u8, query: []const u8) ![]NixOption {307    const parsed = std.json.parseFromSlice(std.json.Value, gpa, json_str, .{}) catch return error.SearchFailed;308    defer parsed.deinit();309310    if (parsed.value != .array) return error.SearchFailed;311312    var opts: std.ArrayList(NixOption) = .empty;313    for (parsed.value.array.items) |item| {314        if (item != .object) continue;315        const name = if (item.object.get("name")) |v| if (v == .string) v.string else continue else continue;316        if (query.len > 0 and std.mem.indexOf(u8, name, query) == null) continue;317318        const type_str = if (item.object.get("type")) |v| if (v == .string) v.string else "any" else "any";319        const description = if (item.object.get("description")) |v| if (v == .string) v.string else "" else "";320        const default_val: ?[]const u8 = if (item.object.get("default")) |v| optionDefaultText(v) else null;321322        try opts.append(gpa, .{323            .name = try gpa.dupe(u8, name),324            .type_str = try gpa.dupe(u8, type_str),325            .default = if (default_val) |d| try gpa.dupe(u8, d) else null,326            .description = try gpa.dupe(u8, description),327        });328    }329330    return opts.toOwnedSlice(gpa);331}332333pub fn findSystemPackagesLine(io: std.Io, config_path: []const u8) ?u32 {334    var buf: [65536]u8 = undefined;335    const content = std.Io.Dir.readFile(.cwd(), io, config_path, &buf) catch return null;336    var lines = std.mem.splitScalar(u8, content, '\n');337    var line_num: u32 = 1;338    while (lines.next()) |line| : (line_num += 1) {339        if (std.mem.indexOf(u8, line, "systemPackages") != null) return line_num;340    }341    return null;342}343344// ─── Tests ───────────────────────────────────────────────────────────────────345//346// searchOptions itself is not exercised here (no nix on this host); these347// pin filterOptions's parsing against JSON shaped like optionAttrSetToDocList's348// real output (NINA-009), covering both the modern literalExpression-wrapped349// default and the older plain-string default.350351test "filterOptions extracts a known option from realistic doc-list JSON (NINA-009)" {352    const fixture =353        \\[354        \\  {355        \\    "name": "services.openssh.enable",356        \\    "type": "boolean",357        \\    "default": { "_type": "literalExpression", "text": "false" },358        \\    "description": "Whether to enable the OpenSSH secure shell daemon."359        \\  },360        \\  {361        \\    "name": "services.nginx.enable",362        \\    "type": "boolean",363        \\    "default": { "_type": "literalExpression", "text": "false" },364        \\    "description": "Whether to enable nginx."365        \\  }366        \\]367    ;368369    const opts = try filterOptions(std.testing.allocator, fixture, "openssh");370    defer {371        for (opts) |o| {372            std.testing.allocator.free(o.name);373            std.testing.allocator.free(o.type_str);374            std.testing.allocator.free(o.description);375            if (o.default) |d| std.testing.allocator.free(d);376        }377        std.testing.allocator.free(opts);378    }379380    try std.testing.expectEqual(@as(usize, 1), opts.len);381    try std.testing.expectEqualStrings("services.openssh.enable", opts[0].name);382    try std.testing.expectEqualStrings("boolean", opts[0].type_str);383    try std.testing.expect(opts[0].default != null);384    try std.testing.expectEqualStrings("false", opts[0].default.?);385    try std.testing.expect(std.mem.indexOf(u8, opts[0].description, "OpenSSH") != null);386}387388test "optionsDocListExpr escapes query text inside Nix string literal" {389    const expr = try optionsDocListExpr(std.testing.allocator, "foo\"${bar}\\baz");390    defer std.testing.allocator.free(expr);391392    try std.testing.expect(std.mem.indexOf(u8, expr, "query = \"foo\\\"\\${bar}\\\\baz\";") != null);393    try std.testing.expect(std.mem.indexOf(u8, expr, "builtins.filter") != null);394    try std.testing.expect(std.mem.indexOf(u8, expr, "escapeRegex") != null);395}396397test "filterOptions still reads a plain-string default (pre-literalExpression nixpkgs)" {398    const fixture =399        \\[400        \\  {401        \\    "name": "services.openssh.ports",402        \\    "type": "list of signed integer",403        \\    "default": "[ 22 ]",404        \\    "description": "Ports on which to listen."405        \\  }406        \\]407    ;408409    const opts = try filterOptions(std.testing.allocator, fixture, "");410    defer {411        for (opts) |o| {412            std.testing.allocator.free(o.name);413            std.testing.allocator.free(o.type_str);414            std.testing.allocator.free(o.description);415            if (o.default) |d| std.testing.allocator.free(d);416        }417        std.testing.allocator.free(opts);418    }419420    try std.testing.expectEqual(@as(usize, 1), opts.len);421    try std.testing.expectEqualStrings("[ 22 ]", opts[0].default.?);422}423424test "filterOptions returns no match for a query that isn't a substring of any option name" {425    const fixture =426        \\[427        \\  { "name": "services.openssh.enable", "type": "boolean", "default": null, "description": "" }428        \\]429    ;430431    const opts = try filterOptions(std.testing.allocator, fixture, "postgres");432    defer std.testing.allocator.free(opts);433    try std.testing.expectEqual(@as(usize, 0), opts.len);434}