123const std = @import("std");4const types = @import("types.zig");5const errors = @import("errors.zig");6const output = @import("output.zig");78910111213pub 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; 21 } 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 52 53 54 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 79 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 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};148149150151152pub 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}170171172fn 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}183184185186fn 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}205206207fn 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}216217218219220221222223224225226fn 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};292293294295296297298fn 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}343344345346347348349350351test "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}