commit 27d50783b9f968de96a425fdaa212471e74c8cd3 from: mtmn date: Mon Sep 14 23:54:36 2026 UTC replace ripgrep with ugrep, add tests commit - 901ed9b95d701f2ea92f2192810727a510191ff0 commit + 27d50783b9f968de96a425fdaa212471e74c8cd3 blob - 37ae75008306305032d1674861c875bc7c8644fd blob + c61b4bb40e8d5eab8df4f7620c62914300444eed --- README.md +++ README.md @@ -13,10 +13,7 @@ A command-line tool for interactive shell navigation a ## Dependencies - Zig 0.16.0 -- ripgrep -- fzf -- bat -- fd +- ugrep - memcached (optional) ## Building @@ -30,16 +27,16 @@ zig build -Doptimize=ReleaseSafe ## Usage ``` -magdalena recent-dirs +magdalena recent-directories magdalena recent-files magdalena favorites -magdalena goto-dir +magdalena goto-directory magdalena goto-file -magdalena log-dir +magdalena log-directory magdalena log-file [type] [action] magdalena search magdalena look-file [--depth ] -magdalena look-dir [--depth ] +magdalena look-directory [--depth ] magdalena grep ``` @@ -55,11 +52,11 @@ magdalena grep ./zig-out/bin/magdalena # Show recent directories -./zig-out/bin/magdalena recent-dirs +./zig-out/bin/magdalena recent-directories # Search history ./zig-out/bin/magdalena search "myquery" # Explore directory with depth 3 -./zig-out/bin/magdalena look-dir --depth 3 +./zig-out/bin/magdalena look-directory --depth 3 ``` blob - 74868a0de07d2ee2d5c9b477726259bfd2ffdd33 blob + 6cbfc90996f0b0b60d8faf73d688c16ce443c780 --- build.zig +++ build.zig @@ -4,11 +4,16 @@ pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); - const zul = b.dependency("zul", .{ + const vaxis = b.dependency("vaxis", .{ .target = target, .optimize = optimize, }); + const fuzzig = b.dependency("fuzzig", .{ + .target = target, + .optimize = optimize, + }); + const exe = b.addExecutable(.{ .name = "magdalena", .root_module = b.createModule(.{ @@ -16,12 +21,18 @@ pub fn build(b: *std.Build) void { .target = target, .optimize = optimize, .link_libc = true, + .strip = optimize != .Debug, + .stack_protector = optimize != .Debug, .imports = &.{ - .{ .name = "zul", .module = zul.module("zul") }, + .{ .name = "vaxis", .module = vaxis.module("vaxis") }, + .{ .name = "fuzzig", .module = fuzzig.module("fuzzig") }, }, }), }); + exe.pie = true; + exe.link_z_relro = true; + b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); @@ -36,10 +47,11 @@ pub fn build(b: *std.Build) void { .root_module = b.createModule(.{ .root_source_file = b.path("src/main.zig"), .target = target, - .optimize = optimize, + .optimize = .Debug, .link_libc = true, .imports = &.{ - .{ .name = "zul", .module = zul.module("zul") }, + .{ .name = "vaxis", .module = vaxis.module("vaxis") }, + .{ .name = "fuzzig", .module = fuzzig.module("fuzzig") }, }, }), }); blob - 2fb866ba65a9b9d83f4c6dd35c8bd6527be9c300 blob + c2d8bc1a4db577a6b9094766c01577ed5a679318 --- build.zig.zon +++ build.zig.zon @@ -4,10 +4,14 @@ .fingerprint = 0xd065e535ca3c8b31, .minimum_zig_version = "0.16.0", .dependencies = .{ - .zul = .{ - .url = "https://github.com/karlseguin/zul/archive/146f9d5b2238c3a621b96345adf03490900c2fe2.tar.gz", - .hash = "zul-0.0.0-1oDot2KwBwC9c43wC7V9y4-xxg0a_d9okFDcJPhqmije", + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis#f37c42a3b324131c131d066767968e1f5b976453", + .hash = "vaxis-0.6.0-BWNV_AIQDADLTDo5jCn27pBWJBvi-sTrSkSKwITQWgUV", }, + .fuzzig = .{ + .url = "git+https://codeberg.org/fjebaker/fuzzig#97ed5ef57c2a11cbc21db061a2ea093c3df64de2", + .hash = "fuzzig-0.1.3-Ji0xit9oAQAxavWq4UDsI9S0nsUVOsqZbxUbZUzlKu4x", + }, }, .paths = .{ "build.zig", blob - a4b9994dc0d8b8f2550eb2485707c42db3286f04 blob + fcc7b2269b621e0e8345d106c9f2567f8b0264b7 --- config.json +++ config.json @@ -2,16 +2,6 @@ "editor": "vim", "max_depth": 5, "ignored_patterns": [".git", ".jj", ".zig-cache", "node_modules", "target"], - "fzf_opts": [ - "--highlight-line", - "--ansi", - "--layout=reverse", - "--border=rounded", - "--color=bg+:#2a1f2e,bg:-1,spinner:#c084b8,hl:#e08060", - "--color=fg:#c8b8bf,header:#c4607a,info:#7a6e7a,pointer:#c084b8", - "--color=marker:#e06fad,fg+:#f0dfe5,prompt:#7a9fd4,hl+:#f0a070", - "--color=border:#3d2a42" - ], "favorites": ["~/src"], "openers": [ { blob - 4d2ce948ed673c91f5b3f0a8b15d30ecfe05723b (mode 644) blob + /dev/null --- src/cfg.zig +++ /dev/null @@ -1,125 +0,0 @@ -const std = @import("std"); -const io = @import("io.zig"); - -pub const Config = struct { - openers: []Opener = &.{}, - ignored_patterns: []const []const u8 = &.{}, - fzf_opts: []const []const u8 = &.{}, - favorites: []const []const u8 = &.{}, - editor: ?[]const u8 = null, - max_depth: usize = 3, - overrides: []FolderOverride = &.{}, - - pub const Opener = struct { - extensions: []const []const u8, - action: []const u8, - command: []const u8, - }; -}; - -// Per-directory settings applied when cwd matches `path` exactly. -// All override fields are optional; only non-null ones replace the base config. -pub const FolderOverride = struct { - path: []const u8, - openers: ?[]Config.Opener = null, - ignored_patterns: ?[]const []const u8 = null, - fzf_opts: ?[]const []const u8 = null, - favorites: ?[]const []const u8 = null, - editor: ?[]const u8 = null, - max_depth: ?usize = null, -}; - -// Return a Config with the first override whose path exactly matches cwd applied on top. -// The returned Config borrows memory from the parsed Config — no extra allocation needed. -pub fn applyFolderOverride(base: Config, cwd: []const u8) Config { - for (base.overrides) |*ov| { - if (!std.mem.eql(u8, cwd, ov.path)) continue; - var result = base; - if (ov.openers) |v| result.openers = v; - if (ov.ignored_patterns) |v| result.ignored_patterns = v; - if (ov.fzf_opts) |v| result.fzf_opts = v; - if (ov.favorites) |v| result.favorites = v; - if (ov.editor) |v| result.editor = v; - if (ov.max_depth) |v| result.max_depth = v; - return result; - } - return base; -} - -pub fn loadConfig(allocator: std.mem.Allocator) ?std.json.Parsed(Config) { - const home = io.getenv("HOME") orelse { - io.warn("HOME not set, skipping config load\n", .{}); - return null; - }; - const path = std.fs.path.join(allocator, &.{ home, ".config", "magdalena", "config.json" }) catch { - io.warn("failed to allocate config path\n", .{}); - return null; - }; - defer allocator.free(path); - - const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), path, allocator, .unlimited) catch |err| { - if (err != error.FileNotFound) { - io.warn("failed to read config {s}: {}\n", .{ path, err }); - } - return null; - }; - defer allocator.free(content); - - return std.json.parseFromSlice(Config, allocator, content, .{ - .ignore_unknown_fields = true, - .allocate = .alloc_always, - }) catch |err| { - io.warn("failed to parse config {s}: {}\n", .{ path, err }); - return null; - }; -} - -const testing = std.testing; - -test "applyFolderOverride: no overrides returns base" { - const base = Config{ .max_depth = 3, .editor = "vim" }; - const got = applyFolderOverride(base, "/anywhere"); - try testing.expectEqual(@as(usize, 3), got.max_depth); - try testing.expectEqualStrings("vim", got.editor.?); -} - -test "applyFolderOverride: matching path overrides only provided fields" { - const ignored = [_][]const u8{ "node_modules", ".git" }; - var overrides = [_]FolderOverride{ - .{ .path = "/work", .max_depth = 9, .editor = "nvim" }, - }; - const base = Config{ - .max_depth = 3, - .editor = "vim", - .ignored_patterns = &ignored, - .overrides = &overrides, - }; - - const got = applyFolderOverride(base, "/work"); - try testing.expectEqual(@as(usize, 9), got.max_depth); - try testing.expectEqualStrings("nvim", got.editor.?); - // A field left null on the override keeps the base value. - try testing.expectEqual(base.ignored_patterns.ptr, got.ignored_patterns.ptr); -} - -test "applyFolderOverride: non-matching path leaves base untouched" { - var overrides = [_]FolderOverride{ - .{ .path = "/work", .max_depth = 9 }, - }; - const base = Config{ .max_depth = 3, .editor = "vim", .overrides = &overrides }; - - const got = applyFolderOverride(base, "/elsewhere"); - try testing.expectEqual(@as(usize, 3), got.max_depth); - try testing.expectEqualStrings("vim", got.editor.?); -} - -test "applyFolderOverride: first matching override wins" { - var overrides = [_]FolderOverride{ - .{ .path = "/work", .max_depth = 5 }, - .{ .path = "/work", .max_depth = 8 }, - }; - const base = Config{ .max_depth = 3, .overrides = &overrides }; - - const got = applyFolderOverride(base, "/work"); - try testing.expectEqual(@as(usize, 5), got.max_depth); -} blob - /dev/null blob + 97ad8d0c25bcf0700acd252aeb7025c2bd3f2b59 (mode 644) --- /dev/null +++ src/command_line.zig @@ -0,0 +1,566 @@ +const std = @import("std"); +const assert = std.debug.assert; +const input_output = @import("input_output.zig"); + +pub const Action = enum { + recent_directories, + recent_files, + favorites, + search, + goto_directory, + goto_file, + look_file, + look_directory, + grep, + cleanup, + log_directory, + log_file, + help, +}; + +pub const Arguments = struct { + action: Action, + query: ?[]const u8 = null, + log_path: ?[]const u8 = null, + file_type: ?[]const u8 = null, + file_action: ?[]const u8 = null, + depth: ?usize = null, + + _command_line: CommandLineArguments, + + pub fn deinit(self: Arguments) void { + self._command_line.deinit(); + } +}; + +const subcommand_table: []const struct { + name: []const u8, + action: Action, +} = &.{ + .{ .name = "favorites", .action = .favorites }, + .{ .name = "goto-directory", .action = .goto_directory }, + .{ .name = "goto-file", .action = .goto_file }, + .{ .name = "grep", .action = .grep }, + .{ .name = "log-directory", .action = .log_directory }, + .{ .name = "log-file", .action = .log_file }, + .{ .name = "look-directory", .action = .look_directory }, + .{ .name = "look-file", .action = .look_file }, + .{ .name = "recent-directories", .action = .recent_directories }, + .{ .name = "recent-files", .action = .recent_files }, + .{ .name = "search", .action = .search }, +}; + +comptime { + for (subcommand_table, 0..) |subcommand, index| { + assert(subcommand.name.len > 0); + if (index > 0) { + const order = std.mem.order(u8, subcommand_table[index - 1].name, subcommand.name); + assert(order == .lt); + } + } +} + +pub const CommandLineArguments = struct { + _arena: *std.heap.ArenaAllocator, + _lookup: std.StringHashMapUnmanaged([]const u8), + + executable: []const u8, + tail: []const [:0]const u8, + + pub fn parse(parent: std.mem.Allocator, source: std.process.Args) !CommandLineArguments { + const arena = try parent.create(std.heap.ArenaAllocator); + errdefer parent.destroy(arena); + + arena.* = std.heap.ArenaAllocator.init(parent); + errdefer arena.deinit(); + + const items = try source.toSlice(arena.allocator()); + const parsed = try finishParse(arena, items); + assert(parsed._arena == arena); + return parsed; + } + + fn finishParse( + arena: *std.heap.ArenaAllocator, + items: []const [:0]const u8, + ) !CommandLineArguments { + const allocator = arena.allocator(); + + var lookup: std.StringHashMapUnmanaged([]const u8) = .{}; + + if (items.len == 0) { + return .{ .executable = "", .tail = &.{}, ._arena = arena, ._lookup = lookup }; + } + + const executable = items[0]; + + var index: usize = 1; + var tail_start: usize = 1; + + while (index < items.len) { + assert(index < items.len); + const argument = items[index]; + if (argument.len == 1) { + break; + } + if (argument[0] != '-') { + break; + } + assert(argument.len > 1); + if (argument[1] == '-') { + if (argument.len == 2) { + break; + } + const key_value = KeyValue.from(argument[2..], items, &index); + try lookup.put(allocator, key_value.key, key_value.value); + } else { + const key_value = KeyValue.from(argument[1..], items, &index); + const key = key_value.key; + assert(key.len > 0); + for (0..key.len - 1) |offset| { + assert(offset + 1 < key.len); + try lookup.put(allocator, key[offset .. offset + 1], ""); + } + try lookup.put(allocator, key[key.len - 1 ..], key_value.value); + } + tail_start = index; + } + assert(tail_start <= items.len); + + return .{ + .executable = executable, + .tail = items[tail_start..], + ._arena = arena, + ._lookup = lookup, + }; + } + + pub fn deinit(self: CommandLineArguments) void { + const arena = self._arena; + const allocator = arena.child_allocator; + arena.deinit(); + allocator.destroy(arena); + } + + pub fn contains(self: *const CommandLineArguments, name: []const u8) bool { + assert(name.len > 0); + return self._lookup.contains(name); + } + + pub fn get(self: *const CommandLineArguments, name: []const u8) ?[]const u8 { + assert(name.len > 0); + return self._lookup.get(name); + } +}; + +const KeyValue = struct { + key: []const u8, + value: []const u8, + + fn from(key: []const u8, items: []const [:0]const u8, index: *usize) KeyValue { + assert(key.len > 0); + assert(items.len > 0); + const item_index = index.*; + assert(item_index < items.len); + if (std.mem.indexOfScalarPos(u8, key, 0, '=')) |position| { + assert(position < key.len); + assert(position + 1 <= key.len); + index.* = item_index + 1; + return .{ .key = key[0..position], .value = key[position + 1 ..] }; + } + if (item_index == items.len - 1) { + index.* = item_index + 1; + return .{ .key = key, .value = "" }; + } + assert(item_index + 1 < items.len); + const next = items[item_index + 1]; + if (next.len > 0) { + if (next[0] == '-') { + index.* = item_index + 1; + return .{ .key = key, .value = "" }; + } + } + index.* = item_index + 2; + return .{ .key = key, .value = next }; + } +}; + +pub fn parseArguments(allocator: std.mem.Allocator, source: std.process.Args) !Arguments { + const command_line = try CommandLineArguments.parse(allocator, source); + errdefer command_line.deinit(); + const parsed = try resolveArguments(command_line); + assert(parsed._command_line._arena == command_line._arena); + return parsed; +} + +fn resolveArguments(command_line: CommandLineArguments) !Arguments { + var parsed_arguments = Arguments{ + .action = .help, + ._command_line = command_line, + }; + + if (command_line.contains("help")) { + return parsed_arguments; + } + if (command_line.contains("-h")) { + return parsed_arguments; + } + if (command_line.contains("--help")) { + return parsed_arguments; + } + if (command_line.contains("clean")) { + parsed_arguments.action = .cleanup; + return parsed_arguments; + } + if (command_line.contains("c")) { + parsed_arguments.action = .cleanup; + return parsed_arguments; + } + + parsed_arguments.depth = try parseDepthFlag(&command_line); + if (parsed_arguments.depth == null) { + parsed_arguments.depth = try scanTailDepth(command_line.tail); + } + + if (command_line.tail.len == 0) { + return parsed_arguments; + } + + const command_name = command_line.tail[0]; + assert(command_line.tail.len > 0); + var found = false; + for (subcommand_table, 0..) |subcommand, subcommand_index| { + assert(subcommand_index < subcommand_table.len); + assert(subcommand.name.len > 0); + if (std.mem.eql(u8, command_name, subcommand.name)) { + parsed_arguments.action = subcommand.action; + found = true; + break; + } + } + if (found) { + fillActionArguments(&parsed_arguments, command_line.tail); + } else { + parsed_arguments.action = .help; + } + if (!found) { + assert(parsed_arguments.action == .help); + } + return parsed_arguments; +} + +fn parseDepthFlag(command_line: *const CommandLineArguments) !?usize { + if (command_line.get("depth")) |text| { + return try std.fmt.parseInt(usize, text, 10); + } + if (command_line.get("d")) |text| { + return try std.fmt.parseInt(usize, text, 10); + } + return null; +} + +fn scanTailDepth(tail: []const [:0]const u8) !?usize { + const prefix = "--depth="; + assert(prefix.len > 0); + for (tail, 0..) |argument, index| { + assert(index < tail.len); + if (std.mem.startsWith(u8, argument, prefix)) { + assert(argument.len >= prefix.len); + return try std.fmt.parseInt(usize, argument[prefix.len..], 10); + } + if (std.mem.eql(u8, argument, "--depth")) { + if (index + 1 < tail.len) { + assert(index + 1 < tail.len); + return try std.fmt.parseInt(usize, tail[index + 1], 10); + } + return null; + } + if (std.mem.eql(u8, argument, "-d")) { + if (index + 1 < tail.len) { + assert(index + 1 < tail.len); + return try std.fmt.parseInt(usize, tail[index + 1], 10); + } + return null; + } + } + return null; +} + +fn fillActionArguments(parsed_arguments: *Arguments, tail: []const [:0]const u8) void { + assert(tail.len > 0); + assert(parsed_arguments.action != .help); + if (tail.len == 1) { + return; + } + const argument = tail[1]; + switch (parsed_arguments.action) { + .search => { + parsed_arguments.query = argument; + }, + .log_directory => { + parsed_arguments.log_path = argument; + }, + .log_file => { + parsed_arguments.log_path = argument; + if (tail.len > 2) { + parsed_arguments.file_type = tail[2]; + } + if (tail.len > 3) { + parsed_arguments.file_action = tail[3]; + } + }, + else => {}, + } +} + +pub fn printUsage() !void { + var buffer: [4096]u8 = undefined; + var writer = input_output.getStdout(&buffer); + try writer.print( + \\Usage: + \\ magdalena recent-directories + \\ magdalena recent-files + \\ magdalena favorites + \\ magdalena goto-directory + \\ magdalena goto-file + \\ magdalena log-directory + \\ magdalena log-file [type] [action] + \\ magdalena search + \\ magdalena look-file [--depth ] + \\ magdalena look-directory [--depth ] + \\ magdalena grep + \\ + \\Options: + \\ -c, --clean Perform cleanup + \\ -h, --help Show this help + \\ + , .{}); + try writer.end(); +} + +const testing = std.testing; + +fn testCommandLineArguments(items: []const [:0]const u8) !CommandLineArguments { + const arena = try testing.allocator.create(std.heap.ArenaAllocator); + errdefer testing.allocator.destroy(arena); + arena.* = std.heap.ArenaAllocator.init(testing.allocator); + errdefer arena.deinit(); + const parsed = try CommandLineArguments.finishParse(arena, items); + assert(parsed._arena == arena); + assert(parsed.tail.len <= items.len); + return parsed; +} + +fn testArguments(items: []const [:0]const u8) !Arguments { + const command_line = try testCommandLineArguments(items); + errdefer command_line.deinit(); + const parsed = try resolveArguments(command_line); + assert(parsed._command_line._arena == command_line._arena); + assert(parsed._command_line.tail.len <= items.len); + return parsed; +} + +test "CommandLineArguments: empty argv" { + var command_line = try testCommandLineArguments(&.{}); + defer command_line.deinit(); + try testing.expectEqualStrings("", command_line.executable); + try testing.expectEqual(@as(usize, 0), command_line.tail.len); +} + +test "CommandLineArguments: executable only" { + var command_line = try testCommandLineArguments(&[_][:0]const u8{"magdalena"}); + defer command_line.deinit(); + try testing.expectEqualStrings("magdalena", command_line.executable); + try testing.expectEqual(@as(usize, 0), command_line.tail.len); +} + +test "CommandLineArguments: flags with and without values" { + var command_line = try testCommandLineArguments(&[_][:0]const u8{ + "bin", + "--level", + "info", + "--silent", + "-p", + "5432", + "-x", + }); + defer command_line.deinit(); + try testing.expectEqualStrings("bin", command_line.executable); + try testing.expectEqual(@as(usize, 0), command_line.tail.len); + try testing.expectEqualStrings("info", command_line.get("level").?); + try testing.expect(command_line.contains("silent")); + try testing.expectEqualStrings("", command_line.get("silent").?); + try testing.expectEqualStrings("5432", command_line.get("p").?); + try testing.expect(command_line.contains("x")); +} + +test "CommandLineArguments: key=value form" { + var command_line = try testCommandLineArguments(&[_][:0]const u8{ + "bin", + "--level=error", + "-p=6669", + }); + defer command_line.deinit(); + try testing.expectEqualStrings("error", command_line.get("level").?); + try testing.expectEqualStrings("6669", command_line.get("p").?); +} + +test "CommandLineArguments: bundled single-char flags" { + var command_line = try testCommandLineArguments(&[_][:0]const u8{ + "bin", + "-xvf", + "file.tar.gz", + }); + defer command_line.deinit(); + try testing.expect(command_line.contains("x")); + try testing.expectEqualStrings("", command_line.get("x").?); + try testing.expect(command_line.contains("v")); + try testing.expectEqualStrings("file.tar.gz", command_line.get("f").?); +} + +test "CommandLineArguments: tail begins at first positional" { + var command_line = try testCommandLineArguments(&[_][:0]const u8{ + "bin", + "-l", + "--k", + "x", + "ts", + "-p=6669", + "hello", + }); + defer command_line.deinit(); + try testing.expect(command_line.contains("l")); + try testing.expectEqualStrings("x", command_line.get("k").?); + try testing.expectEqual(@as(usize, 3), command_line.tail.len); + try testing.expectEqualStrings("ts", command_line.tail[0]); + try testing.expectEqualStrings("-p=6669", command_line.tail[1]); + try testing.expectEqualStrings("hello", command_line.tail[2]); +} + +test "CommandLineArguments: bare double dash starts the tail" { + var command_line = try testCommandLineArguments(&[_][:0]const u8{ + "bin", + "--", + "--depth", + "4", + }); + defer command_line.deinit(); + try testing.expectEqual(@as(usize, 3), command_line.tail.len); + try testing.expectEqualStrings("--", command_line.tail[0]); +} + +test "CommandLineArguments: empty argument is consumed as the value" { + var command_line = try testCommandLineArguments(&[_][:0]const u8{ "bin", "--key", "", "tail" }); + defer command_line.deinit(); + try testing.expectEqualStrings("", command_line.get("key").?); + try testing.expectEqualStrings("tail", command_line.tail[0]); +} + +test "resolveArguments: no arguments is help" { + var arguments = try testArguments(&[_][:0]const u8{"magdalena"}); + defer arguments.deinit(); + try testing.expectEqual(Action.help, arguments.action); +} + +test "resolveArguments: --help and -h" { + for ([_][:0]const u8{ "--help", "-h", "help" }) |flag| { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", flag }); + defer arguments.deinit(); + try testing.expectEqual(Action.help, arguments.action); + } +} + +test "resolveArguments: unknown command falls back to help" { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "bogus" }); + defer arguments.deinit(); + try testing.expectEqual(Action.help, arguments.action); +} + +test "resolveArguments: cleanup is triggered by -c and --clean flags" { + for ([_][:0]const u8{ "-c", "--clean" }) |flag| { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", flag }); + defer arguments.deinit(); + try testing.expectEqual(Action.cleanup, arguments.action); + } + + var bare = try testArguments(&[_][:0]const u8{ "magdalena", "clean" }); + defer bare.deinit(); + try testing.expectEqual(Action.help, bare.action); +} + +test "resolveArguments: simple subcommands" { + const cases = [_]struct { command: [:0]const u8, action: Action }{ + .{ .command = "recent-directories", .action = .recent_directories }, + .{ .command = "recent-files", .action = .recent_files }, + .{ .command = "favorites", .action = .favorites }, + .{ .command = "goto-directory", .action = .goto_directory }, + .{ .command = "goto-file", .action = .goto_file }, + .{ .command = "grep", .action = .grep }, + }; + for (cases) |case| { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", case.command }); + defer arguments.deinit(); + try testing.expectEqual(case.action, arguments.action); + } +} + +test "resolveArguments: search carries query" { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "search", "needle" }); + defer arguments.deinit(); + try testing.expectEqual(Action.search, arguments.action); + try testing.expectEqualStrings("needle", arguments.query.?); +} + +test "resolveArguments: log-file carries path, type and action" { + var arguments = try testArguments(&[_][:0]const u8{ + "magdalena", + "log-file", + "/tmp/x.zig", + "zig", + "open", + }); + defer arguments.deinit(); + try testing.expectEqual(Action.log_file, arguments.action); + try testing.expectEqualStrings("/tmp/x.zig", arguments.log_path.?); + try testing.expectEqualStrings("zig", arguments.file_type.?); + try testing.expectEqualStrings("open", arguments.file_action.?); +} + +test "resolveArguments: depth before command via --depth, --depth= and -d" { + const cases = [_][:0]const u8{ "--depth", "--depth=4", "-d" }; + inline for (cases) |form| { + const items = if (std.mem.eql(u8, form, "--depth=4")) + &[_][:0]const u8{ "magdalena", "--depth=4", "look-directory" } + else + &[_][:0]const u8{ "magdalena", form, "4", "look-directory" }; + var arguments = try testArguments(items); + defer arguments.deinit(); + try testing.expectEqual(Action.look_directory, arguments.action); + try testing.expectEqual(@as(usize, 4), arguments.depth.?); + } +} + +test "resolveArguments: depth after command via tail scan" { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "look-file", "--depth", "7" }); + defer arguments.deinit(); + try testing.expectEqual(Action.look_file, arguments.action); + try testing.expectEqual(@as(usize, 7), arguments.depth.?); +} + +test "resolveArguments: invalid depth is an error, not a default" { + try testing.expectError( + error.InvalidCharacter, + testArguments(&[_][:0]const u8{ "magdalena", "--depth", "deep", "look-directory" }), + ); + try testing.expectError( + error.InvalidCharacter, + testArguments(&[_][:0]const u8{ "magdalena", "look-directory", "--depth=deep" }), + ); +} + +test "resolveArguments: missing depth value leaves depth null" { + var arguments = try testArguments(&[_][:0]const u8{ "magdalena", "look-directory", "--depth" }); + defer arguments.deinit(); + try testing.expectEqual(Action.look_directory, arguments.action); + try testing.expect(arguments.depth == null); +} blob - e7e32675b7303c9a6a5deb82d0def49d533d80bb (mode 644) blob + /dev/null --- src/cli.zig +++ /dev/null @@ -1,423 +0,0 @@ -const std = @import("std"); -const io = @import("io.zig"); - -pub const Action = enum { - recent_dirs, - recent_files, - favorites, - search, - goto_dir, - goto_file, - look_file, - look_dir, - grep, - cleanup, - log_dir, - log_file, - help, -}; - -pub const Args = struct { - action: Action, - query: ?[]const u8 = null, - log_path: ?[]const u8 = null, - file_type: ?[]const u8 = null, - file_action: ?[]const u8 = null, - depth: ?usize = null, - - _cla: CommandLineArgs, - - pub fn deinit(self: Args) void { - self._cla.deinit(); - } -}; - -// Minimal command-line parser. Replaces zul.CommandLineArgs, whose `parse` -// still calls the removed `std.process.argsWithAllocator`; the parsing -// semantics (flag/value/tail handling) are preserved exactly. -pub const CommandLineArgs = struct { - _arena: *std.heap.ArenaAllocator, - _lookup: std.StringHashMapUnmanaged([]const u8), - - exe: []const u8, - tail: []const [:0]const u8, - - pub fn parse(parent: std.mem.Allocator, source: std.process.Args) !CommandLineArgs { - const arena = try parent.create(std.heap.ArenaAllocator); - errdefer parent.destroy(arena); - - arena.* = std.heap.ArenaAllocator.init(parent); - errdefer arena.deinit(); - - const items = try source.toSlice(arena.allocator()); - return finishParse(arena, items); - } - - // Parses an already-collected argv. Split out from `parse` so tests can - // feed a literal slice without constructing a `std.process.Args`. - fn finishParse(arena: *std.heap.ArenaAllocator, items: []const [:0]const u8) !CommandLineArgs { - const allocator = arena.allocator(); - - var lookup: std.StringHashMapUnmanaged([]const u8) = .{}; - - if (items.len == 0) { - return .{ .exe = "", .tail = &.{}, ._arena = arena, ._lookup = lookup }; - } - - const exe = items[0]; - - var i: usize = 1; - var tail_start: usize = 1; - - while (i < items.len) { - const arg = items[i]; - if (arg.len == 1 or arg[0] != '-') { - // can't be a valid parameter, so it must be the start of our tail - break; - } - - if (arg[1] == '-') { - const kv = KeyValue.from(arg[2..], items, &i); - try lookup.put(allocator, kv.key, kv.value); - } else { - const kv = KeyValue.from(arg[1..], items, &i); - const key = kv.key; - - // -xvf file.tar.gz parses into x=>"", v=>"", f=>"file.tar.gz" - for (0..key.len - 1) |j| { - try lookup.put(allocator, key[j .. j + 1], ""); - } - try lookup.put(allocator, key[key.len - 1 ..], kv.value); - } - tail_start = i; - } - - return .{ - .exe = exe, - .tail = items[tail_start..], - ._arena = arena, - ._lookup = lookup, - }; - } - - pub fn deinit(self: CommandLineArgs) void { - const arena = self._arena; - const allocator = arena.child_allocator; - arena.deinit(); - allocator.destroy(arena); - } - - pub fn contains(self: *const CommandLineArgs, name: []const u8) bool { - return self._lookup.contains(name); - } - - pub fn get(self: *const CommandLineArgs, name: []const u8) ?[]const u8 { - return self._lookup.get(name); - } -}; - -const KeyValue = struct { - key: []const u8, - value: []const u8, - - fn from(key: []const u8, items: []const [:0]const u8, i: *usize) KeyValue { - const item_index = i.*; - if (std.mem.indexOfScalarPos(u8, key, 0, '=')) |pos| { - // this parameter is in the form of --key=value, or -k=value - i.* = item_index + 1; - return .{ .key = key[0..pos], .value = key[pos + 1 ..] }; - } - - if (item_index == items.len - 1 or items[item_index + 1][0] == '-') { - // key is at the end of the arguments OR the next argument starts - // with a '-'. This means this key has no value. - i.* = item_index + 1; - return .{ .key = key, .value = "" }; - } - - // skip the current key, and the next arg (which is our value) - i.* = item_index + 2; - return .{ .key = key, .value = items[item_index + 1] }; - } -}; - -pub fn parseArgs(allocator: std.mem.Allocator, source: std.process.Args) !Args { - const cla = try CommandLineArgs.parse(allocator, source); - errdefer cla.deinit(); - return resolveArgs(cla); -} - -// Maps a parsed command line onto an `Args`. Split from `parseArgs` so tests -// can drive it from a `CommandLineArgs` built off a literal slice. On success -// the returned `Args` owns `cla` (freed via `Args.deinit`). -fn resolveArgs(cla: CommandLineArgs) !Args { - var res = Args{ - .action = .help, - ._cla = cla, - }; - - if (cla.contains("help") or cla.contains("-h") or cla.contains("--help")) { - res.action = .help; - return res; - } - - if (cla.contains("clean") or cla.contains("c")) { - res.action = .cleanup; - return res; - } - - if (cla.get("depth")) |d| { - res.depth = try std.fmt.parseInt(usize, d, 10); - } else if (cla.get("d")) |d| { - res.depth = try std.fmt.parseInt(usize, d, 10); - } - - if (res.depth == null) { - for (cla.tail, 0..) |arg, i| { - if (std.mem.startsWith(u8, arg, "--depth=")) { - res.depth = try std.fmt.parseInt(usize, arg["--depth=".len..], 10); - } else if (std.mem.eql(u8, arg, "--depth") or std.mem.eql(u8, arg, "-d")) { - if (i + 1 < cla.tail.len) { - res.depth = try std.fmt.parseInt(usize, cla.tail[i + 1], 10); - } - } - if (res.depth != null) break; - } - } - - if (cla.tail.len == 0) { - return res; - } - - const cmd_str = cla.tail[0]; - if (std.mem.eql(u8, cmd_str, "recent-dirs")) { - res.action = .recent_dirs; - } else if (std.mem.eql(u8, cmd_str, "recent-files")) { - res.action = .recent_files; - } else if (std.mem.eql(u8, cmd_str, "favorites")) { - res.action = .favorites; - } else if (std.mem.eql(u8, cmd_str, "search")) { - res.action = .search; - } else if (std.mem.eql(u8, cmd_str, "goto-dir")) { - res.action = .goto_dir; - } else if (std.mem.eql(u8, cmd_str, "goto-file")) { - res.action = .goto_file; - } else if (std.mem.eql(u8, cmd_str, "look-file")) { - res.action = .look_file; - } else if (std.mem.eql(u8, cmd_str, "look-dir")) { - res.action = .look_dir; - } else if (std.mem.eql(u8, cmd_str, "grep")) { - res.action = .grep; - } else if (std.mem.eql(u8, cmd_str, "log-dir")) { - res.action = .log_dir; - } else if (std.mem.eql(u8, cmd_str, "log-file")) { - res.action = .log_file; - } else { - res.action = .help; - return res; - } - - if (cla.tail.len > 1) { - const arg = cla.tail[1]; - if (res.action == .search) { - res.query = arg; - } else if (res.action == .log_dir) { - res.log_path = arg; - } else if (res.action == .log_file) { - res.log_path = arg; - if (cla.tail.len > 2) res.file_type = cla.tail[2]; - if (cla.tail.len > 3) res.file_action = cla.tail[3]; - } - } - - return res; -} - -pub fn printUsage() !void { - var buf: [4096]u8 = undefined; - var writer = io.getStdout(&buf); - try writer.print( - \\Usage: - \\ magdalena recent-dirs - \\ magdalena recent-files - \\ magdalena favorites - \\ magdalena goto-dir - \\ magdalena goto-file - \\ magdalena log-dir - \\ magdalena log-file [type] [action] - \\ magdalena search - \\ magdalena look-file [--depth ] - \\ magdalena look-dir [--depth ] - \\ magdalena grep - \\ - \\Options: - \\ -c, --clean Perform cleanup - \\ -h, --help Show this help - \\ - , .{}); - try writer.end(); -} - -const testing = std.testing; - -// Builds a CommandLineArgs straight from a literal argv, bypassing -// std.process.Args. Mirrors `parse`'s ownership shape exactly (errdefer + -// error-union return). Caller must `defer cla.deinit()`. -fn testCla(items: []const [:0]const u8) !CommandLineArgs { - const arena = try testing.allocator.create(std.heap.ArenaAllocator); - errdefer testing.allocator.destroy(arena); - arena.* = std.heap.ArenaAllocator.init(testing.allocator); - errdefer arena.deinit(); - return CommandLineArgs.finishParse(arena, items); -} - -// Builds an Args from a literal argv, mirroring `parseArgs`. Caller must -// `defer args.deinit()`. -fn testArgs(items: []const [:0]const u8) !Args { - const cla = try testCla(items); - errdefer cla.deinit(); - return resolveArgs(cla); -} - -test "CommandLineArgs: empty argv" { - var cla = try testCla(&.{}); - defer cla.deinit(); - try testing.expectEqualStrings("", cla.exe); - try testing.expectEqual(@as(usize, 0), cla.tail.len); -} - -test "CommandLineArgs: exe only" { - var cla = try testCla(&[_][:0]const u8{"magdalena"}); - defer cla.deinit(); - try testing.expectEqualStrings("magdalena", cla.exe); - try testing.expectEqual(@as(usize, 0), cla.tail.len); -} - -test "CommandLineArgs: flags with and without values" { - var cla = try testCla(&[_][:0]const u8{ "bin", "--level", "info", "--silent", "-p", "5432", "-x" }); - defer cla.deinit(); - try testing.expectEqualStrings("bin", cla.exe); - try testing.expectEqual(@as(usize, 0), cla.tail.len); - try testing.expectEqualStrings("info", cla.get("level").?); - try testing.expect(cla.contains("silent")); - try testing.expectEqualStrings("", cla.get("silent").?); - try testing.expectEqualStrings("5432", cla.get("p").?); - try testing.expect(cla.contains("x")); -} - -test "CommandLineArgs: key=value form" { - var cla = try testCla(&[_][:0]const u8{ "bin", "--level=error", "-p=6669" }); - defer cla.deinit(); - try testing.expectEqualStrings("error", cla.get("level").?); - try testing.expectEqualStrings("6669", cla.get("p").?); -} - -test "CommandLineArgs: bundled single-char flags" { - var cla = try testCla(&[_][:0]const u8{ "bin", "-xvf", "file.tar.gz" }); - defer cla.deinit(); - try testing.expect(cla.contains("x")); - try testing.expectEqualStrings("", cla.get("x").?); - try testing.expect(cla.contains("v")); - try testing.expectEqualStrings("file.tar.gz", cla.get("f").?); -} - -test "CommandLineArgs: tail begins at first positional" { - // `-l` is followed by `--k`, which starts with '-', so `-l` takes no value; - // `--k` consumes the following `x`; the tail then starts at the first - // non-flag token. - var cla = try testCla(&[_][:0]const u8{ "bin", "-l", "--k", "x", "ts", "-p=6669", "hello" }); - defer cla.deinit(); - try testing.expect(cla.contains("l")); - try testing.expectEqualStrings("x", cla.get("k").?); - try testing.expectEqual(@as(usize, 3), cla.tail.len); - try testing.expectEqualStrings("ts", cla.tail[0]); - try testing.expectEqualStrings("-p=6669", cla.tail[1]); - try testing.expectEqualStrings("hello", cla.tail[2]); -} - -test "resolveArgs: no args is help" { - var args = try testArgs(&[_][:0]const u8{"magdalena"}); - defer args.deinit(); - try testing.expectEqual(Action.help, args.action); -} - -test "resolveArgs: --help and -h" { - for ([_][:0]const u8{ "--help", "-h", "help" }) |flag| { - var args = try testArgs(&[_][:0]const u8{ "magdalena", flag }); - defer args.deinit(); - try testing.expectEqual(Action.help, args.action); - } -} - -test "resolveArgs: unknown command falls back to help" { - var args = try testArgs(&[_][:0]const u8{ "magdalena", "bogus" }); - defer args.deinit(); - try testing.expectEqual(Action.help, args.action); -} - -test "resolveArgs: cleanup is triggered by -c and --clean flags" { - // Cleanup is a flag, not a subcommand: a bare `clean` positional is not - // recognized and falls through to help. - for ([_][:0]const u8{ "-c", "--clean" }) |flag| { - var args = try testArgs(&[_][:0]const u8{ "magdalena", flag }); - defer args.deinit(); - try testing.expectEqual(Action.cleanup, args.action); - } - - var bare = try testArgs(&[_][:0]const u8{ "magdalena", "clean" }); - defer bare.deinit(); - try testing.expectEqual(Action.help, bare.action); -} - -test "resolveArgs: simple subcommands" { - const cases = [_]struct { cmd: [:0]const u8, action: Action }{ - .{ .cmd = "recent-dirs", .action = .recent_dirs }, - .{ .cmd = "recent-files", .action = .recent_files }, - .{ .cmd = "favorites", .action = .favorites }, - .{ .cmd = "goto-dir", .action = .goto_dir }, - .{ .cmd = "goto-file", .action = .goto_file }, - .{ .cmd = "grep", .action = .grep }, - }; - for (cases) |c| { - var args = try testArgs(&[_][:0]const u8{ "magdalena", c.cmd }); - defer args.deinit(); - try testing.expectEqual(c.action, args.action); - } -} - -test "resolveArgs: search carries query" { - var args = try testArgs(&[_][:0]const u8{ "magdalena", "search", "needle" }); - defer args.deinit(); - try testing.expectEqual(Action.search, args.action); - try testing.expectEqualStrings("needle", args.query.?); -} - -test "resolveArgs: log-file carries path, type and action" { - var args = try testArgs(&[_][:0]const u8{ "magdalena", "log-file", "/tmp/x.zig", "zig", "open" }); - defer args.deinit(); - try testing.expectEqual(Action.log_file, args.action); - try testing.expectEqualStrings("/tmp/x.zig", args.log_path.?); - try testing.expectEqualStrings("zig", args.file_type.?); - try testing.expectEqualStrings("open", args.file_action.?); -} - -test "resolveArgs: depth before command via --depth, --depth= and -d" { - const cases = [_][:0]const u8{ "--depth", "--depth=4", "-d" }; - inline for (cases) |form| { - const items = if (std.mem.eql(u8, form, "--depth=4")) - &[_][:0]const u8{ "magdalena", "--depth=4", "look-dir" } - else - &[_][:0]const u8{ "magdalena", form, "4", "look-dir" }; - var args = try testArgs(items); - defer args.deinit(); - try testing.expectEqual(Action.look_dir, args.action); - try testing.expectEqual(@as(usize, 4), args.depth.?); - } -} - -test "resolveArgs: depth after command via tail scan" { - var args = try testArgs(&[_][:0]const u8{ "magdalena", "look-file", "--depth", "7" }); - defer args.deinit(); - try testing.expectEqual(Action.look_file, args.action); - try testing.expectEqual(@as(usize, 7), args.depth.?); -} blob - /dev/null blob + 05b833ff56a5ef435751169c25c42f33dd2290e7 (mode 644) --- /dev/null +++ src/config.zig @@ -0,0 +1,170 @@ +const std = @import("std"); +const assert = std.debug.assert; +const input_output = @import("input_output.zig"); + +pub const Theme = struct { + prompt: ?[]const u8 = null, + match: ?[]const u8 = null, + cursor_row: ?[]const u8 = null, + status: ?[]const u8 = null, + preview_current: ?[]const u8 = null, +}; + +pub const Config = struct { + openers: []Opener = &.{}, + ignored_patterns: []const []const u8 = &.{}, + favorites: []const []const u8 = &.{}, + editor: ?[]const u8 = null, + max_depth: usize = 3, + theme: Theme = .{}, + overrides: []FolderOverride = &.{}, + + pub const Opener = struct { + extensions: []const []const u8, + action: []const u8, + command: []const u8, + }; +}; + +pub const FolderOverride = struct { + path: []const u8, + openers: ?[]Config.Opener = null, + ignored_patterns: ?[]const []const u8 = null, + favorites: ?[]const []const u8 = null, + editor: ?[]const u8 = null, + max_depth: ?usize = null, + theme: ?Theme = null, +}; + +pub fn applyFolderOverride(base: Config, current_directory: []const u8) Config { + for (base.overrides, 0..) |*override, override_index| { + assert(override_index < base.overrides.len); + assert(override.path.len > 0); + if (!std.mem.eql(u8, current_directory, override.path)) continue; + var result = base; + if (override.openers) |openers| result.openers = openers; + if (override.ignored_patterns) |patterns| result.ignored_patterns = patterns; + if (override.favorites) |favorites| result.favorites = favorites; + if (override.editor) |editor| result.editor = editor; + if (override.max_depth) |depth| result.max_depth = depth; + if (override.theme) |theme| result.theme = theme; + return result; + } + return base; +} + +const max_config_bytes: usize = 1024 * 1024; + +pub fn loadConfig(allocator: std.mem.Allocator) ?std.json.Parsed(Config) { + const home = input_output.getenv("HOME") orelse { + input_output.warn("HOME not set, skipping config load\n", .{}); + return null; + }; + if (home.len == 0) { + input_output.warn("HOME is empty, skipping config load\n", .{}); + return null; + } + assert(home.len > 0); + const current_directory = std.Io.Dir.cwd(); + const path_parts = [_][]const u8{ home, ".config", "magdalena", "config.json" }; + const path = std.fs.path.join(allocator, &path_parts) catch { + input_output.warn("failed to allocate config path\n", .{}); + return null; + }; + defer allocator.free(path); + + const content = current_directory.readFileAlloc( + input_output.runtime(), + path, + allocator, + .limited(max_config_bytes), + ) catch |err| { + if (err == error.StreamTooLong) { + input_output.warn( + "config too large (over {d} bytes), ignoring {s}\n", + .{ max_config_bytes, path }, + ); + return null; + } + if (err != error.FileNotFound) { + input_output.warn("failed to read config {s}: {}\n", .{ path, err }); + } + return null; + }; + defer allocator.free(content); + + return std.json.parseFromSlice(Config, allocator, content, .{ + .ignore_unknown_fields = true, + .allocate = .alloc_always, + }) catch |err| { + input_output.warn("failed to parse config {s}: {}\n", .{ path, err }); + return null; + }; +} + +const testing = std.testing; + +test "applyFolderOverride: no overrides returns base" { + const base = Config{ .max_depth = 3, .editor = "vim" }; + const got = applyFolderOverride(base, "/anywhere"); + try testing.expectEqual(@as(usize, 3), got.max_depth); + try testing.expectEqualStrings("vim", got.editor.?); +} + +test "applyFolderOverride: matching path overrides only provided fields" { + const ignored = [_][]const u8{ "node_modules", ".git" }; + var overrides = [_]FolderOverride{ + .{ .path = "/work", .max_depth = 9, .editor = "nvim" }, + }; + const base = Config{ + .max_depth = 3, + .editor = "vim", + .ignored_patterns = &ignored, + .overrides = &overrides, + }; + + const got = applyFolderOverride(base, "/work"); + try testing.expectEqual(@as(usize, 9), got.max_depth); + try testing.expectEqualStrings("nvim", got.editor.?); + try testing.expectEqual(base.ignored_patterns.ptr, got.ignored_patterns.ptr); +} + +test "applyFolderOverride: non-matching path leaves base untouched" { + var overrides = [_]FolderOverride{ + .{ .path = "/work", .max_depth = 9 }, + }; + const base = Config{ .max_depth = 3, .editor = "vim", .overrides = &overrides }; + + const got = applyFolderOverride(base, "/elsewhere"); + try testing.expectEqual(@as(usize, 3), got.max_depth); + try testing.expectEqualStrings("vim", got.editor.?); +} + +test "applyFolderOverride: first matching override wins" { + var overrides = [_]FolderOverride{ + .{ .path = "/work", .max_depth = 5 }, + .{ .path = "/work", .max_depth = 8 }, + }; + const base = Config{ .max_depth = 3, .overrides = &overrides }; + + const got = applyFolderOverride(base, "/work"); + try testing.expectEqual(@as(usize, 5), got.max_depth); +} + +test "applyFolderOverride: theme replaces wholesale" { + var overrides = [_]FolderOverride{ + .{ .path = "/work", .theme = .{ .prompt = "9" } }, + }; + const base = Config{ + .theme = .{ .match = "3" }, + .overrides = &overrides, + }; + + const got = applyFolderOverride(base, "/work"); + try testing.expectEqualStrings("9", got.theme.prompt.?); + try testing.expect(got.theme.match == null); + + const kept = applyFolderOverride(base, "/elsewhere"); + try testing.expectEqualStrings("3", kept.theme.match.?); + try testing.expect(kept.theme.prompt == null); +} blob - 33160d1c8fe4ff92174d0103c610815c96dde818 (mode 644) blob + /dev/null --- src/db.zig +++ /dev/null @@ -1,421 +0,0 @@ -const std = @import("std"); -const zul = @import("zul"); -const io = @import("io.zig"); - -pub const DirectoryEntry = struct { - path: []const u8, - timestamp: ?[]const u8 = null, -}; - -pub const FileEntry = struct { - path: []const u8, - file_type: []const u8, - action: []const u8, - timestamp: ?[]const u8 = null, -}; - -pub const SearchResult = struct { - directories: []DirectoryEntry, - files: []FileEntry, -}; - -pub const Db = struct { - allocator: std.mem.Allocator, - base_path: []const u8, - dirs_path: []const u8, - files_path: []const u8, - - pub fn init(allocator: std.mem.Allocator, path: []const u8) !Db { - const base_path = try allocator.dupe(u8, path); - errdefer allocator.free(base_path); - - const dirs_path = try std.fs.path.join(allocator, &.{ base_path, "dirs.log" }); - errdefer allocator.free(dirs_path); - - const files_path = try std.fs.path.join(allocator, &.{ base_path, "files.log" }); - errdefer allocator.free(files_path); - - const res = Db{ - .allocator = allocator, - .base_path = base_path, - .dirs_path = dirs_path, - .files_path = files_path, - }; - - std.Io.Dir.cwd().createDirPath(io.rt(), base_path) catch |err| { - if (err != error.PathAlreadyExists) return err; - }; - restrictDirPerms(base_path); - - for (&[_][]const u8{ dirs_path, files_path }) |p| { - var f = std.Io.Dir.cwd().openFile(io.rt(), p, .{ .mode = .read_write }) catch |err| { - if (err == error.FileNotFound) { - const created = try std.Io.Dir.cwd().createFile(io.rt(), p, .{ .truncate = false, .permissions = .fromMode(0o600) }); - created.close(io.rt()); - continue; - } - return err; - }; - // Best effort: tighten perms on pre-existing files too. - f.setPermissions(io.rt(), .fromMode(0o600)) catch {}; - f.close(io.rt()); - } - - return res; - } - - // History logs hold absolute paths with timestamps. Restrict to owner on - // a best-effort basis; failures never fail init (read-only parent, ACLs). - fn restrictDirPerms(path: []const u8) void { - var dir = std.Io.Dir.cwd().openDir(io.rt(), path, .{ .iterate = true }) catch return; - defer dir.close(io.rt()); - dir.setPermissions(io.rt(), .fromMode(0o700)) catch {}; - } - - pub fn deinit(self: *Db) void { - self.allocator.free(self.base_path); - self.allocator.free(self.dirs_path); - self.allocator.free(self.files_path); - } - - pub fn recentDirs(self: *Db) !zul.Managed([]DirectoryEntry) { - const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), self.dirs_path, self.allocator, .limited(64 * 1024 * 1024)) catch |err| { - if (err == error.FileNotFound) return zul.Managed([]DirectoryEntry).fromJson(try std.json.parseFromSlice([]DirectoryEntry, self.allocator, "[]", .{})); - return err; - }; - defer self.allocator.free(content); - - var arena = try self.allocator.create(std.heap.ArenaAllocator); - errdefer { - arena.deinit(); - self.allocator.destroy(arena); - } - arena.* = std.heap.ArenaAllocator.init(self.allocator); - - return .{ - .arena = arena, - .value = try parseDirLog(arena.allocator(), content), - }; - } - - pub fn recentFiles(self: *Db) !zul.Managed([]FileEntry) { - const content = std.Io.Dir.cwd().readFileAlloc(io.rt(), self.files_path, self.allocator, .limited(64 * 1024 * 1024)) catch |err| { - if (err == error.FileNotFound) return zul.Managed([]FileEntry).fromJson(try std.json.parseFromSlice([]FileEntry, self.allocator, "[]", .{})); - return err; - }; - defer self.allocator.free(content); - - var arena = try self.allocator.create(std.heap.ArenaAllocator); - errdefer { - arena.deinit(); - self.allocator.destroy(arena); - } - arena.* = std.heap.ArenaAllocator.init(self.allocator); - - return .{ - .arena = arena, - .value = try parseFileLog(arena.allocator(), content), - }; - } - - pub fn searchHistory(self: *Db, query_str: []const u8) !zul.Managed(SearchResult) { - const managed_dirs = try self.recentDirs(); - defer managed_dirs.deinit(); - const managed_files = try self.recentFiles(); - defer managed_files.deinit(); - - var arena = try self.allocator.create(std.heap.ArenaAllocator); - errdefer { - arena.deinit(); - self.allocator.destroy(arena); - } - arena.* = std.heap.ArenaAllocator.init(self.allocator); - const allocator = arena.allocator(); - - var dir_list = std.ArrayListUnmanaged(DirectoryEntry).empty; - for (managed_dirs.value) |d| { - if (std.mem.indexOf(u8, d.path, query_str) != null) { - try dir_list.append(allocator, .{ - .path = try allocator.dupe(u8, d.path), - .timestamp = if (d.timestamp) |ts| try allocator.dupe(u8, ts) else null, - }); - } - } - - var file_list = std.ArrayListUnmanaged(FileEntry).empty; - for (managed_files.value) |f| { - if (std.mem.indexOf(u8, f.path, query_str) != null) { - try file_list.append(allocator, .{ - .path = try allocator.dupe(u8, f.path), - .file_type = try allocator.dupe(u8, f.file_type), - .action = try allocator.dupe(u8, f.action), - .timestamp = if (f.timestamp) |ts| try allocator.dupe(u8, ts) else null, - }); - } - } - - return .{ - .arena = arena, - .value = .{ - .directories = try dir_list.toOwnedSlice(allocator), - .files = try file_list.toOwnedSlice(allocator), - }, - }; - } - - fn resolvePath(self: *Db, path: []const u8) ![]const u8 { - return std.Io.Dir.cwd().realPathFileAlloc(io.rt(), path, self.allocator) catch |err| { - if (err == error.FileNotFound) { - io.warn("path not found for logging: {s}\n", .{path}); - } - return try std.fs.path.resolve(self.allocator, &.{ ".", path }); - }; - } - - pub fn logDir(self: *Db, dir_path: []const u8) !void { - const abs_path = try self.resolvePath(dir_path); - defer self.allocator.free(abs_path); - - const file = std.Io.Dir.cwd().openFile(io.rt(), self.dirs_path, .{ .mode = .write_only }) catch |err| switch (err) { - error.FileNotFound => try std.Io.Dir.cwd().createFile(io.rt(), self.dirs_path, .{ .truncate = false }), - else => return err, - }; - defer file.close(io.rt()); - - const now = zul.DateTime.now(io.rt()); - var ts_buf: [64]u8 = undefined; - var ts_writer = std.Io.Writer.fixed(&ts_buf); - try now.format(&ts_writer); - const ts = ts_buf[0..ts_writer.end]; - - var fbuf: [4096]u8 = undefined; - var fw = file.writer(io.rt(), &fbuf); - try fw.seekTo(try file.length(io.rt())); - try fw.interface.print("{s}|{s}\n", .{ ts, abs_path }); - try fw.interface.flush(); - } - - pub fn logFile(self: *Db, file_path: []const u8, file_type: []const u8, action: []const u8) !void { - const abs_path = try self.resolvePath(file_path); - defer self.allocator.free(abs_path); - - const file = std.Io.Dir.cwd().openFile(io.rt(), self.files_path, .{ .mode = .write_only }) catch |err| switch (err) { - error.FileNotFound => try std.Io.Dir.cwd().createFile(io.rt(), self.files_path, .{ .truncate = false }), - else => return err, - }; - defer file.close(io.rt()); - - const now = zul.DateTime.now(io.rt()); - var ts_buf: [64]u8 = undefined; - var ts_writer = std.Io.Writer.fixed(&ts_buf); - try now.format(&ts_writer); - const ts = ts_buf[0..ts_writer.end]; - - var fbuf: [4096]u8 = undefined; - var fw = file.writer(io.rt(), &fbuf); - try fw.seekTo(try file.length(io.rt())); - try fw.interface.print("{s}|{s}|{s}|{s}\n", .{ ts, file_type, action, abs_path }); - try fw.interface.flush(); - } - - pub fn cleanup(self: *Db) !void { - try self.cleanupFile(self.dirs_path, 1); - try self.cleanupFile(self.files_path, 3); - } - - fn cleanupFile(self: *Db, path: []const u8, path_col_idx: usize) !void { - io.warn("Cleaning up {s}...\n", .{path}); - - const content = try std.Io.Dir.cwd().readFileAlloc(io.rt(), path, self.allocator, .limited(64 * 1024 * 1024)); - defer self.allocator.free(content); - - var seen = std.StringHashMap(void).init(self.allocator); - defer seen.deinit(); - - var lines = std.ArrayListUnmanaged([]const u8).empty; - defer lines.deinit(self.allocator); - - var total_entries: usize = 0; - var missing_entries: usize = 0; - var inaccessible_entries: usize = 0; - var it = std.mem.splitBackwardsScalar(u8, content, '\n'); - while (it.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \r\t"); - if (trimmed.len == 0) continue; - total_entries += 1; - - var line_it = std.mem.splitScalar(u8, trimmed, '|'); - var item_path: ?[]const u8 = null; - var i: usize = 0; - while (line_it.next()) |col| : (i += 1) { - if (i == path_col_idx) { - item_path = col; - break; - } - } - - const p = item_path orelse continue; - - if (seen.contains(p)) continue; - - std.Io.Dir.cwd().access(io.rt(), p, .{}) catch |err| { - if (err == error.FileNotFound) { - missing_entries += 1; - continue; - } - io.warn("skipping inaccessible entry {s}: {}\n", .{ p, err }); - inaccessible_entries += 1; - continue; - }; - - try seen.put(p, {}); - try lines.append(self.allocator, trimmed); - } - - const duplicates = total_entries - lines.items.len - missing_entries - inaccessible_entries; - io.warn(" Entries: {d} total, {d} unique, {d} missing, {d} inaccessible, {d} duplicates removed\n", .{ total_entries, lines.items.len, missing_entries, inaccessible_entries, duplicates }); - - const write_file = try std.Io.Dir.cwd().createFile(io.rt(), path, .{ .truncate = true, .permissions = .fromMode(0o600) }); - defer write_file.close(io.rt()); - - var write_buf: [4096]u8 = undefined; - var writer = write_file.writer(io.rt(), &write_buf); - - var i: usize = lines.items.len; - while (i > 0) { - i -= 1; - try writer.interface.print("{s}\n", .{lines.items[i]}); - } - try writer.end(); - } -}; - -pub fn getDefaultDbPath(allocator: std.mem.Allocator) ![]const u8 { - if (io.getenv("HOME")) |home| { - return std.fs.path.join(allocator, &.{ home, ".magdalena" }); - } - return error.HomeNotFound; -} - -// Parses a `dirs.log` body (`timestamp|path` lines) newest-first, keeping only -// the first occurrence of each path. Pure logic, no IO — split out for testing. -fn parseDirLog(allocator: std.mem.Allocator, content: []const u8) ![]DirectoryEntry { - var list = std.ArrayListUnmanaged(DirectoryEntry).empty; - var seen = std.StringHashMap(void).init(allocator); - defer seen.deinit(); - - var it = std.mem.splitBackwardsScalar(u8, content, '\n'); - while (it.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \r\t"); - if (trimmed.len == 0) continue; - - var line_it = std.mem.splitScalar(u8, trimmed, '|'); - const ts = line_it.next() orelse continue; - const dir_path = line_it.next() orelse continue; - - if (seen.contains(dir_path)) continue; - try seen.put(dir_path, {}); - - try list.append(allocator, .{ - .path = try allocator.dupe(u8, dir_path), - .timestamp = try allocator.dupe(u8, ts), - }); - } - - return list.toOwnedSlice(allocator); -} - -// Parses a `files.log` body (`timestamp|type|action|path` lines) newest-first, -// keeping only the first occurrence of each path. -fn parseFileLog(allocator: std.mem.Allocator, content: []const u8) ![]FileEntry { - var list = std.ArrayListUnmanaged(FileEntry).empty; - var seen = std.StringHashMap(void).init(allocator); - defer seen.deinit(); - - var it = std.mem.splitBackwardsScalar(u8, content, '\n'); - while (it.next()) |line| { - const trimmed = std.mem.trim(u8, line, " \r\t"); - if (trimmed.len == 0) continue; - - var line_it = std.mem.splitScalar(u8, trimmed, '|'); - const ts = line_it.next() orelse continue; - const ftype = line_it.next() orelse continue; - const action = line_it.next() orelse continue; - const fpath = line_it.next() orelse continue; - - if (seen.contains(fpath)) continue; - try seen.put(fpath, {}); - - try list.append(allocator, .{ - .path = try allocator.dupe(u8, fpath), - .file_type = try allocator.dupe(u8, ftype), - .action = try allocator.dupe(u8, action), - .timestamp = try allocator.dupe(u8, ts), - }); - } - - return list.toOwnedSlice(allocator); -} - -const testing = std.testing; - -test "parseDirLog: newest-first, dedup by path" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const content = - "2024-01-01T00:00:00Z|/a\n" ++ - "2024-01-02T00:00:00Z|/b\n" ++ - "2024-01-03T00:00:00Z|/a\n"; - - const entries = try parseDirLog(arena.allocator(), content); - try testing.expectEqual(@as(usize, 2), entries.len); - try testing.expectEqualStrings("/a", entries[0].path); - try testing.expectEqualStrings("2024-01-03T00:00:00Z", entries[0].timestamp.?); - try testing.expectEqualStrings("/b", entries[1].path); -} - -test "parseDirLog: skips blank and malformed lines" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const content = "\n \n2024|/only\nno-pipe-here\n"; - const entries = try parseDirLog(arena.allocator(), content); - try testing.expectEqual(@as(usize, 1), entries.len); - try testing.expectEqualStrings("/only", entries[0].path); -} - -test "parseDirLog: empty input yields no entries" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - const entries = try parseDirLog(arena.allocator(), ""); - try testing.expectEqual(@as(usize, 0), entries.len); -} - -test "parseFileLog: newest-first dedup keeps type and action" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const content = - "t1|zig|open|/x\n" ++ - "t2|md|edit|/y\n" ++ - "t3|rs|run|/x\n"; - - const entries = try parseFileLog(arena.allocator(), content); - try testing.expectEqual(@as(usize, 2), entries.len); - try testing.expectEqualStrings("/x", entries[0].path); - try testing.expectEqualStrings("rs", entries[0].file_type); - try testing.expectEqualStrings("run", entries[0].action); - try testing.expectEqualStrings("t3", entries[0].timestamp.?); - try testing.expectEqualStrings("/y", entries[1].path); -} - -test "parseFileLog: ignores lines with too few columns" { - var arena = std.heap.ArenaAllocator.init(testing.allocator); - defer arena.deinit(); - - const content = "t1|zig|open\nt2|md|edit|/y\n"; - const entries = try parseFileLog(arena.allocator(), content); - try testing.expectEqual(@as(usize, 1), entries.len); - try testing.expectEqualStrings("/y", entries[0].path); -} blob - /dev/null blob + ed420fff23c845cd660318299a6f4d9e9acf5948 (mode 644) --- /dev/null +++ src/database.zig @@ -0,0 +1,799 @@ +const std = @import("std"); +const assert = std.debug.assert; +const input_output = @import("input_output.zig"); + +const max_log_bytes: usize = 64 * 1024 * 1024; + +comptime { + assert(max_log_bytes > 0); +} + +pub const DirectoryEntry = struct { + path: []const u8, + timestamp: ?[]const u8 = null, +}; + +pub const FileEntry = struct { + path: []const u8, + file_type: []const u8, + action: []const u8, + timestamp: ?[]const u8 = null, +}; + +pub const SearchResult = struct { + directories: []DirectoryEntry, + files: []FileEntry, +}; + +pub const Database = struct { + allocator: std.mem.Allocator, + base_path: []const u8, + directories_path: []const u8, + files_path: []const u8, + + pub fn init(allocator: std.mem.Allocator, path: []const u8) !Database { + assert(path.len > 0); + const base_path = try allocator.dupe(u8, path); + assert(base_path.len == path.len); + errdefer allocator.free(base_path); + + const directories_path = try std.fs.path.join(allocator, &.{ + base_path, + "directories.log", + }); + assert(directories_path.len > base_path.len); + errdefer allocator.free(directories_path); + + const files_path = try std.fs.path.join(allocator, &.{ base_path, "files.log" }); + assert(files_path.len > base_path.len); + errdefer allocator.free(files_path); + + const result = Database{ + .allocator = allocator, + .base_path = base_path, + .directories_path = directories_path, + .files_path = files_path, + }; + assert(result.base_path.len == path.len); + assert(result.directories_path.len > result.base_path.len); + assert(result.files_path.len > result.base_path.len); + + std.Io.Dir.cwd().createDirPath(input_output.runtime(), base_path) catch |err| { + if (err != error.PathAlreadyExists) return err; + }; + restrictDirectoryPerms(base_path); + migrateDirectoriesLog(allocator, base_path, directories_path); + + const log_paths = [_][]const u8{ directories_path, files_path }; + assert(log_paths.len == 2); + const working_directory = std.Io.Dir.cwd(); + const runtime_handle = input_output.runtime(); + for (log_paths, 0..) |log_path, log_index| { + assert(log_index < log_paths.len); + var log_file = working_directory.openFile(runtime_handle, log_path, .{ + .mode = .read_write, + .allow_directory = false, + .path_only = false, + }) catch |err| { + if (err == error.FileNotFound) { + const created = try working_directory.createFile(runtime_handle, log_path, .{ + .read = false, + .truncate = false, + .exclusive = false, + .lock = .none, + .lock_nonblocking = false, + .permissions = .fromMode(0o600), + .resolve_beneath = false, + }); + created.close(runtime_handle); + continue; + } + return err; + }; + log_file.setPermissions(runtime_handle, .fromMode(0o600)) catch |err| { + input_output.warn( + "failed to restrict permissions on {s}: {}\n", + .{ log_path, err }, + ); + }; + log_file.close(runtime_handle); + } + + return result; + } + + fn restrictDirectoryPerms(path: []const u8) void { + assert(path.len > 0); + var directory = std.Io.Dir.cwd().openDir(input_output.runtime(), path, .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }) catch |err| { + input_output.warn("failed to open history directory {s}: {}\n", .{ path, err }); + return; + }; + defer directory.close(input_output.runtime()); + directory.setPermissions(input_output.runtime(), .fromMode(0o700)) catch |err| { + input_output.warn("failed to restrict permissions on {s}: {}\n", .{ path, err }); + }; + } + + fn migrateDirectoriesLog( + allocator: std.mem.Allocator, + base_path: []const u8, + directories_path: []const u8, + ) void { + assert(base_path.len > 0); + assert(directories_path.len > 0); + const legacy_path = std.fs.path.join(allocator, &.{ base_path, "dirs.log" }) catch |err| { + input_output.warn("failed to build legacy history path: {}\n", .{err}); + return; + }; + defer allocator.free(legacy_path); + assert(legacy_path.len > base_path.len); + + const access_options: std.Io.Dir.AccessOptions = .{ + .follow_symlinks = true, + .read = false, + .write = false, + .execute = false, + }; + std.Io.Dir.cwd().access( + input_output.runtime(), + directories_path, + access_options, + ) catch |new_err| { + if (new_err != error.FileNotFound) return; + std.Io.Dir.cwd().access( + input_output.runtime(), + legacy_path, + access_options, + ) catch return; + std.Io.Dir.renameAbsolute( + legacy_path, + directories_path, + input_output.runtime(), + ) catch |err| { + input_output.warn( + "failed to migrate {s} to {s}: {}\n", + .{ legacy_path, directories_path, err }, + ); + }; + return; + }; + } + + pub fn deinit(self: *Database) void { + assert(self.base_path.len > 0); + assert(self.directories_path.len > 0); + assert(self.files_path.len > 0); + self.allocator.free(self.base_path); + self.allocator.free(self.directories_path); + self.allocator.free(self.files_path); + } + + fn createLogArena(self: *Database) !*std.heap.ArenaAllocator { + const arena = try self.allocator.create(std.heap.ArenaAllocator); + arena.* = std.heap.ArenaAllocator.init(self.allocator); + assert(arena.child_allocator.ptr == self.allocator.ptr); + return arena; + } + + fn readLogBody(self: *Database, path: []const u8) ![]u8 { + assert(path.len > 0); + return std.Io.Dir.cwd().readFileAlloc( + input_output.runtime(), + path, + self.allocator, + .limited(max_log_bytes), + ); + } + + fn emptyManagedList(self: *Database, comptime T: type) !std.json.Parsed(T) { + const parsed = try std.json.parseFromSlice(T, self.allocator, "[]", .{ + .allocate = .alloc_always, + .ignore_unknown_fields = true, + }); + const managed = parsed; + assert(managed.value.len == 0); + return managed; + } + + pub fn recentDirectories(self: *Database) !std.json.Parsed([]DirectoryEntry) { + assert(self.directories_path.len > 0); + const content = self.readLogBody(self.directories_path) catch |err| { + if (err == error.FileNotFound) { + return try self.emptyManagedList([]DirectoryEntry); + } + return err; + }; + defer self.allocator.free(content); + + const arena = try self.createLogArena(); + errdefer { + arena.deinit(); + self.allocator.destroy(arena); + } + assert(arena.child_allocator.ptr == self.allocator.ptr); + + return .{ + .arena = arena, + .value = try parseDirectoryLog(arena.allocator(), content), + }; + } + + pub fn recentFiles(self: *Database) !std.json.Parsed([]FileEntry) { + assert(self.files_path.len > 0); + const content = self.readLogBody(self.files_path) catch |err| { + if (err == error.FileNotFound) { + return try self.emptyManagedList([]FileEntry); + } + return err; + }; + defer self.allocator.free(content); + + const arena = try self.createLogArena(); + errdefer { + arena.deinit(); + self.allocator.destroy(arena); + } + assert(arena.child_allocator.ptr == self.allocator.ptr); + + return .{ + .arena = arena, + .value = try parseFileLog(arena.allocator(), content), + }; + } + + pub fn searchHistory(self: *Database, query: []const u8) !std.json.Parsed(SearchResult) { + const managed_directories = try self.recentDirectories(); + defer managed_directories.deinit(); + const managed_files = try self.recentFiles(); + defer managed_files.deinit(); + + const arena = try self.createLogArena(); + errdefer { + arena.deinit(); + self.allocator.destroy(arena); + } + assert(arena.child_allocator.ptr == self.allocator.ptr); + const allocator = arena.allocator(); + + var dir_list = std.ArrayListUnmanaged(DirectoryEntry).empty; + for (managed_directories.value, 0..) |entry, entry_index| { + assert(entry_index < managed_directories.value.len); + assert(entry.path.len > 0); + if (std.mem.indexOf(u8, entry.path, query) == null) { + continue; + } + try dir_list.append(allocator, .{ + .path = try allocator.dupe(u8, entry.path), + .timestamp = try dupeOptionalText(allocator, entry.timestamp), + }); + } + + var file_list = std.ArrayListUnmanaged(FileEntry).empty; + for (managed_files.value, 0..) |entry, entry_index| { + assert(entry_index < managed_files.value.len); + assert(entry.path.len > 0); + if (std.mem.indexOf(u8, entry.path, query) == null) { + continue; + } + try file_list.append(allocator, .{ + .path = try allocator.dupe(u8, entry.path), + .file_type = try allocator.dupe(u8, entry.file_type), + .action = try allocator.dupe(u8, entry.action), + .timestamp = try dupeOptionalText(allocator, entry.timestamp), + }); + } + + return .{ + .arena = arena, + .value = .{ + .directories = try dir_list.toOwnedSlice(allocator), + .files = try file_list.toOwnedSlice(allocator), + }, + }; + } + + fn dupeOptionalText(allocator: std.mem.Allocator, text: ?[]const u8) !?[]const u8 { + const value = text orelse return null; + const copy = try allocator.dupe(u8, value); + assert(copy.len == value.len); + return copy; + } + + fn resolvePath(self: *Database, path: []const u8) ![]const u8 { + assert(path.len > 0); + if (std.Io.Dir.cwd().realPathFileAlloc( + input_output.runtime(), + path, + self.allocator, + )) |absolute| { + assert(absolute.len > 0); + defer self.allocator.free(absolute); + const copy = try self.allocator.dupe(u8, absolute); + assert(copy.len == absolute.len); + return copy; + } else |err| { + if (err == error.FileNotFound) { + input_output.warn("path not found for logging: {s}\n", .{path}); + } + return try std.fs.path.resolve(self.allocator, &.{ ".", path }); + } + } + + fn openLogForAppend(path: []const u8) !std.Io.File { + assert(path.len > 0); + if (std.Io.Dir.cwd().openFile(input_output.runtime(), path, .{ + .mode = .write_only, + .allow_directory = false, + .path_only = false, + .lock = .exclusive, + .lock_nonblocking = false, + })) |file| { + return file; + } else |err| { + if (err == error.FileNotFound) { + return std.Io.Dir.cwd().createFile(input_output.runtime(), path, .{ + .read = false, + .truncate = false, + .exclusive = false, + .lock = .exclusive, + .lock_nonblocking = false, + .permissions = .fromMode(0o600), + .resolve_beneath = false, + }); + } + return err; + } + } + + fn writeTimestamp(buffer: *[64]u8) ![]const u8 { + assert(buffer.len == 64); + const micros_total = std.Io.Timestamp.now(input_output.runtime(), .real).toMicroseconds(); + assert(micros_total >= 0); + const total: u64 = @intCast(micros_total); + const secs: u64 = total / 1_000_000; + const sub_micros: u32 = @intCast(total % 1_000_000); + const epoch_secs = std.time.epoch.EpochSeconds{ .secs = secs }; + const year_day = epoch_secs.getEpochDay().calculateYearDay(); + const month_day = year_day.calculateMonthDay(); + const day_secs = epoch_secs.getDaySeconds(); + var timestamp_writer = std.Io.Writer.fixed(buffer); + try timestamp_writer.print("{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}", .{ + year_day.year, + month_day.month.numeric(), + month_day.day_index + 1, + day_secs.getHoursIntoDay(), + day_secs.getMinutesIntoHour(), + day_secs.getSecondsIntoMinute(), + }); + if (sub_micros != 0) { + if (sub_micros % 1000 == 0) { + try timestamp_writer.print(".{d:0>3}", .{sub_micros / 1000}); + } else { + try timestamp_writer.print(".{d:0>6}", .{sub_micros}); + } + } + try timestamp_writer.writeAll("Z"); + assert(timestamp_writer.end > 0); + return buffer[0..timestamp_writer.end]; + } + + pub fn logDirectory(self: *Database, directory_path: []const u8) !void { + if (directory_path.len == 0) { + return error.EmptyPath; + } + assert(self.directories_path.len > 0); + const abs_path = try self.resolvePath(directory_path); + assert(abs_path.len > 0); + defer self.allocator.free(abs_path); + + const log_file = try openLogForAppend(self.directories_path); + defer log_file.close(input_output.runtime()); + + var timestamp_buffer: [64]u8 = undefined; + const timestamp = try writeTimestamp(×tamp_buffer); + + var file_buffer: [4096]u8 = undefined; + var file_writer = log_file.writer(input_output.runtime(), &file_buffer); + try file_writer.seekTo(try log_file.length(input_output.runtime())); + try file_writer.interface.print("{s}|{s}\n", .{ timestamp, abs_path }); + try file_writer.interface.flush(); + } + + pub fn logFile( + self: *Database, + file_path: []const u8, + file_type: []const u8, + action: []const u8, + ) !void { + if (file_path.len == 0) { + return error.EmptyPath; + } + assert(self.files_path.len > 0); + const abs_path = try self.resolvePath(file_path); + assert(abs_path.len > 0); + defer self.allocator.free(abs_path); + + const log_file = try openLogForAppend(self.files_path); + defer log_file.close(input_output.runtime()); + + var timestamp_buffer: [64]u8 = undefined; + const timestamp = try writeTimestamp(×tamp_buffer); + + var file_buffer: [4096]u8 = undefined; + var file_writer = log_file.writer(input_output.runtime(), &file_buffer); + try file_writer.seekTo(try log_file.length(input_output.runtime())); + try file_writer.interface.print("{s}|{s}|{s}|{s}\n", .{ + timestamp, + file_type, + action, + abs_path, + }); + try file_writer.interface.flush(); + } + + pub fn cleanup(self: *Database) !void { + assert(self.directories_path.len > 0); + assert(self.files_path.len > 0); + try self.cleanupFile(self.directories_path, 1); + try self.cleanupFile(self.files_path, 3); + } + + fn cleanupFile(self: *Database, path: []const u8, path_column_index: usize) !void { + assert(path.len > 0); + if (path_column_index != 1) { + assert(path_column_index == 3); + } + input_output.warn("Cleaning up {s}...\n", .{path}); + + const content = try self.readLogBody(path); + defer self.allocator.free(content); + + const retained = try collectRetainedLines(self.allocator, content, path_column_index); + defer self.allocator.free(retained.lines); + + const unique_count = retained.lines.len; + const removed_count = retained.missing_count + retained.inaccessible_count; + assert(retained.total_count >= unique_count + removed_count); + const duplicates = retained.total_count - unique_count - removed_count; + input_output.warn( + " Entries: {d} total, {d} unique, {d} missing," ++ + " {d} inaccessible, {d} duplicates removed\n", + .{ + retained.total_count, + unique_count, + retained.missing_count, + retained.inaccessible_count, + duplicates, + }, + ); + + const temp_path = try std.fmt.allocPrint(self.allocator, "{s}.tmp", .{path}); + defer self.allocator.free(temp_path); + { + const write_file = try std.Io.Dir.cwd().createFile(input_output.runtime(), temp_path, .{ + .read = false, + .truncate = true, + .exclusive = false, + .lock = .exclusive, + .lock_nonblocking = false, + .permissions = .fromMode(0o600), + .resolve_beneath = false, + }); + defer write_file.close(input_output.runtime()); + + var write_buffer: [4096]u8 = undefined; + var writer = write_file.writer(input_output.runtime(), &write_buffer); + + var index: usize = retained.lines.len; + while (index > 0) { + assert(index <= retained.lines.len); + index -= 1; + try writer.interface.print("{s}\n", .{retained.lines[index]}); + } + try writer.end(); + } + std.Io.Dir.renameAbsolute(temp_path, path, input_output.runtime()) catch |err| { + std.Io.Dir.cwd().deleteFile(input_output.runtime(), temp_path) catch |cleanup_err| { + input_output.warn( + "failed to remove temp file {s}: {}\n", + .{ temp_path, cleanup_err }, + ); + }; + return err; + }; + } +}; + +const RetainedLogLines = struct { + lines: [][]const u8, + total_count: usize, + missing_count: usize, + inaccessible_count: usize, +}; + +fn collectRetainedLines( + allocator: std.mem.Allocator, + content: []const u8, + path_column_index: usize, +) !RetainedLogLines { + if (path_column_index != 1) { + assert(path_column_index == 3); + } + var seen = std.StringHashMap(void).init(allocator); + defer seen.deinit(); + + var lines = std.ArrayListUnmanaged([]const u8).empty; + errdefer lines.deinit(allocator); + + var total_count: usize = 0; + var missing_count: usize = 0; + var inaccessible_count: usize = 0; + var line_split = std.mem.splitBackwardsScalar(u8, content, '\n'); + while (line_split.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (trimmed.len == 0) { + continue; + } + total_count += 1; + assert(total_count <= content.len + 1); + + const entry_path = extractPathColumn(trimmed, path_column_index) orelse continue; + if (seen.contains(entry_path)) { + continue; + } + + std.Io.Dir.cwd().access(input_output.runtime(), entry_path, .{ + .follow_symlinks = true, + .read = false, + .write = false, + .execute = false, + }) catch |err| { + if (err == error.FileNotFound) { + missing_count += 1; + continue; + } + input_output.warn("skipping inaccessible entry {s}: {}\n", .{ entry_path, err }); + inaccessible_count += 1; + continue; + }; + + try seen.put(entry_path, {}); + try lines.append(allocator, trimmed); + } + + const retained = RetainedLogLines{ + .lines = try lines.toOwnedSlice(allocator), + .total_count = total_count, + .missing_count = missing_count, + .inaccessible_count = inaccessible_count, + }; + assert(retained.lines.len <= retained.total_count); + return retained; +} + +fn extractPathColumn(line: []const u8, path_column_index: usize) ?[]const u8 { + assert(line.len > 0); + var column_split = std.mem.splitScalar(u8, line, '|'); + var column_index: usize = 0; + while (column_split.next()) |column| { + if (column_index == path_column_index) { + return column; + } + column_index += 1; + } + assert(column_index <= path_column_index); + return null; +} + +pub fn getDefaultDatabasePath(allocator: std.mem.Allocator) ![]const u8 { + if (input_output.getenv("HOME")) |home| { + if (home.len == 0) { + return error.HomeNotFound; + } + const result = try std.fs.path.join(allocator, &.{ home, ".magdalena" }); + assert(result.len > home.len); + return result; + } + return error.HomeNotFound; +} + +fn seenInsert(seen: *std.StringHashMap(void), path: []const u8) !bool { + assert(path.len > 0); + if (seen.contains(path)) { + return false; + } + try seen.put(path, {}); + return true; +} + +fn parseDirectoryLog(allocator: std.mem.Allocator, content: []const u8) ![]DirectoryEntry { + var list = std.ArrayListUnmanaged(DirectoryEntry).empty; + errdefer list.deinit(allocator); + var seen = std.StringHashMap(void).init(allocator); + defer seen.deinit(); + + var line_split = std.mem.splitBackwardsScalar(u8, content, '\n'); + var line_count: usize = 0; + while (line_split.next()) |line| { + line_count += 1; + assert(line_count <= content.len + 1); + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (trimmed.len == 0) { + continue; + } + + var column_split = std.mem.splitScalar(u8, trimmed, '|'); + const timestamp = column_split.next() orelse continue; + const directory_path = column_split.next() orelse continue; + if (directory_path.len == 0) { + continue; + } + + if (!try seenInsert(&seen, directory_path)) { + continue; + } + + try list.append(allocator, .{ + .path = try allocator.dupe(u8, directory_path), + .timestamp = try allocator.dupe(u8, timestamp), + }); + assert(list.items[list.items.len - 1].path.len == directory_path.len); + assert(list.items[list.items.len - 1].timestamp.?.len == timestamp.len); + } + + return list.toOwnedSlice(allocator); +} + +fn parseFileLog(allocator: std.mem.Allocator, content: []const u8) ![]FileEntry { + var list = std.ArrayListUnmanaged(FileEntry).empty; + errdefer list.deinit(allocator); + var seen = std.StringHashMap(void).init(allocator); + defer seen.deinit(); + + var line_split = std.mem.splitBackwardsScalar(u8, content, '\n'); + var line_count: usize = 0; + while (line_split.next()) |line| { + line_count += 1; + assert(line_count <= content.len + 1); + const trimmed = std.mem.trim(u8, line, " \r\t"); + if (trimmed.len == 0) { + continue; + } + + var column_split = std.mem.splitScalar(u8, trimmed, '|'); + const timestamp = column_split.next() orelse continue; + const file_type = column_split.next() orelse continue; + const action = column_split.next() orelse continue; + const file_path = column_split.next() orelse continue; + if (file_path.len == 0) { + continue; + } + + if (!try seenInsert(&seen, file_path)) { + continue; + } + + try list.append(allocator, .{ + .path = try allocator.dupe(u8, file_path), + .file_type = try allocator.dupe(u8, file_type), + .action = try allocator.dupe(u8, action), + .timestamp = try allocator.dupe(u8, timestamp), + }); + assert(list.items[list.items.len - 1].path.len == file_path.len); + assert(list.items[list.items.len - 1].timestamp.?.len == timestamp.len); + } + + return list.toOwnedSlice(allocator); +} + +const testing = std.testing; + +test "parseDirectoryLog: newest-first, dedup by path" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const content = + "2024-01-01T00:00:00Z|/a\n" ++ + "2024-01-02T00:00:00Z|/b\n" ++ + "2024-01-03T00:00:00Z|/a\n"; + + const entries = try parseDirectoryLog(arena.allocator(), content); + try testing.expectEqual(@as(usize, 2), entries.len); + try testing.expectEqualStrings("/a", entries[0].path); + try testing.expectEqualStrings("2024-01-03T00:00:00Z", entries[0].timestamp.?); + try testing.expectEqualStrings("/b", entries[1].path); +} + +test "parseDirectoryLog: skips blank and malformed lines" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const content = "\n \n2024|/only\nno-pipe-here\n"; + const entries = try parseDirectoryLog(arena.allocator(), content); + try testing.expectEqual(@as(usize, 1), entries.len); + try testing.expectEqualStrings("/only", entries[0].path); +} + +test "parseDirectoryLog: skips entries with empty paths" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const content = "2024|\n2024|/kept\n"; + const entries = try parseDirectoryLog(arena.allocator(), content); + try testing.expectEqual(@as(usize, 1), entries.len); + try testing.expectEqualStrings("/kept", entries[0].path); +} + +test "parseDirectoryLog: empty input yields no entries" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const entries = try parseDirectoryLog(arena.allocator(), ""); + try testing.expectEqual(@as(usize, 0), entries.len); +} + +test "parseDirectoryLog: extra columns are ignored" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const entries = try parseDirectoryLog(arena.allocator(), "t|/p|unexpected\n"); + try testing.expectEqual(@as(usize, 1), entries.len); + try testing.expectEqualStrings("/p", entries[0].path); +} + +test "parseFileLog: newest-first dedup keeps type and action" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const content = + "t1|zig|open|/x\n" ++ + "t2|md|edit|/y\n" ++ + "t3|rs|run|/x\n"; + + const entries = try parseFileLog(arena.allocator(), content); + try testing.expectEqual(@as(usize, 2), entries.len); + try testing.expectEqualStrings("/x", entries[0].path); + try testing.expectEqualStrings("rs", entries[0].file_type); + try testing.expectEqualStrings("run", entries[0].action); + try testing.expectEqualStrings("t3", entries[0].timestamp.?); + try testing.expectEqualStrings("/y", entries[1].path); +} + +test "parseFileLog: ignores lines with too few columns" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const content = "t1|zig|open\nt2|md|edit|/y\n"; + const entries = try parseFileLog(arena.allocator(), content); + try testing.expectEqual(@as(usize, 1), entries.len); + try testing.expectEqualStrings("/y", entries[0].path); +} + +test "parseFileLog: skips entries with empty paths" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const entries = try parseFileLog(arena.allocator(), "t1|zig|open|\n"); + try testing.expectEqual(@as(usize, 0), entries.len); +} + +test "parseFileLog: empty fields are kept, not skipped" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const entries = try parseFileLog(arena.allocator(), "t1||open|/x\n"); + try testing.expectEqual(@as(usize, 1), entries.len); + try testing.expectEqualStrings("", entries[0].file_type); +} + +test "extractPathColumn: returns the indexed column" { + try testing.expectEqualStrings("/p", extractPathColumn("t|/p", 1).?); + try testing.expectEqualStrings("t", extractPathColumn("t|/p", 0).?); +} + +test "extractPathColumn: missing column yields null" { + try testing.expect(extractPathColumn("t|/p", 3) == null); + try testing.expect(extractPathColumn("t", 1) == null); +} blob - 258bee76c6beb8aae2d1ef555f6ae553207ff677 (mode 644) blob + /dev/null --- src/fzf.zig +++ /dev/null @@ -1,637 +0,0 @@ -const std = @import("std"); - -const cache = @import("memcached.zig"); -const cfg = @import("cfg.zig"); -const hist = @import("db.zig"); -const io = @import("io.zig"); - -fn gotoFile(allocator: std.mem.Allocator, db: ?*hist.Db, file_path: []const u8, line: ?[]const u8, config: *const cfg.Config) !void { - const ext = if (std.mem.lastIndexOfScalar(u8, file_path, '.')) |idx| file_path[idx + 1 ..] else ""; - - var action: []const u8 = "text_editor"; - var command: ?[]const u8 = null; - - for (config.openers) |opener| { - for (opener.extensions) |e| { - if (std.mem.eql(u8, e, ext)) { - action = opener.action; - command = opener.command; - break; - } - } - if (command != null) break; - } - - if (db) |d| { - d.logFile(file_path, ext, action) catch |err| io.warn("failed to log file: {}\n", .{err}); - } - - if (std.Io.Dir.openFileAbsolute(io.rt(), "/dev/tty", .{ .mode = .read_write })) |tty| { - if (std.c.dup2(tty.handle, std.posix.STDIN_FILENO) < 0) io.warn("failed to redirect stdin to tty\n", .{}); - if (std.c.dup2(tty.handle, std.posix.STDOUT_FILENO) < 0) io.warn("failed to redirect stdout to tty\n", .{}); - tty.close(io.rt()); - } else |err| { - io.warn("failed to open /dev/tty: {}\n", .{err}); - } - - const parent_dir = std.fs.path.dirname(file_path) orelse "."; - const base_name = std.fs.path.basename(file_path); - try std.process.setCurrentPath(io.rt(), parent_dir); - - if (command) |cmd| { - return std.process.replace(io.rt(), .{ .argv = &.{ cmd, base_name } }); - } - - const editor = config.editor orelse (io.getenv("EDITOR") orelse return error.EditorNotSet); - - if (line) |l| { - const line_arg = try std.fmt.allocPrint(allocator, "+{s}", .{l}); - defer allocator.free(line_arg); - return std.process.replace(io.rt(), .{ .argv = &.{ editor, line_arg, base_name } }); - } - - return std.process.replace(io.rt(), .{ .argv = &.{ editor, base_name } }); -} - -const StdIo = std.process.SpawnOptions.StdIo; - -const Fzf = struct { - argv: [][]const u8, - allocator: std.mem.Allocator, - stdin: StdIo = .pipe, - stdout: StdIo = .pipe, - stderr: StdIo = .inherit, - - pub fn init(allocator: std.mem.Allocator, fzf_opts: []const []const u8, extra_opts: []const []const u8) !Fzf { - var argv = try allocator.alloc([]const u8, 1 + fzf_opts.len + extra_opts.len); - errdefer allocator.free(argv); - - var i: usize = 0; - argv[i] = "fzf"; - i += 1; - for (fzf_opts) |opt| { - argv[i] = opt; - i += 1; - } - for (extra_opts) |opt| { - argv[i] = opt; - i += 1; - } - - return .{ - .argv = argv, - .allocator = allocator, - }; - } - - pub fn spawn(self: *Fzf) !std.process.Child { - return std.process.spawn(io.rt(), .{ - .argv = self.argv, - .stdin = self.stdin, - .stdout = self.stdout, - .stderr = self.stderr, - }); - } - - pub fn deinit(self: *Fzf) void { - self.allocator.free(self.argv); - } -}; - -// ignored_patterns entries are matched by exact path component name, not globs. -fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool { - var it = std.mem.splitScalar(u8, path, std.fs.path.sep); - while (it.next()) |component| { - for (ignored_patterns) |pattern| { - if (std.mem.eql(u8, component, pattern)) { - return true; - } - } - } - return false; -} - -fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts: []const []const u8, items: []const []const u8) ![]u8 { - var fzf = try Fzf.init(allocator, fzf_opts, &[_][]const u8{}); - defer fzf.deinit(); - var child = try fzf.spawn(); - - if (child.stdin) |stdin| { - for (items) |item| { - stdin.writeStreamingAll(io.rt(), item) catch |err| { - if (err == error.BrokenPipe) break; - return err; - }; - stdin.writeStreamingAll(io.rt(), "\n") catch |err| { - if (err == error.BrokenPipe) break; - return err; - }; - } - stdin.close(io.rt()); - child.stdin = null; - } - - var stdout_data: ?[]u8 = null; - defer if (stdout_data) |d| allocator.free(d); - - if (child.stdout) |stdout| { - var buffer: [4096]u8 = undefined; - var sr = stdout.reader(io.rt(), &buffer); - stdout_data = try sr.interface.allocRemaining(allocator, .limited(64 * 1024 * 1024)); - } - - const term = try child.wait(io.rt()); - if (term != .exited or term.exited != 0) return error.UserAbort; - const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t"); - if (selected.len == 0) return error.UserAbort; - - return allocator.dupe(u8, selected); -} - -pub fn recentDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { - const managed_dirs = try db.recentDirs(); - defer managed_dirs.deinit(); - const dirs = managed_dirs.value; - - if (dirs.len == 0) { - io.warn("No recent directories found.\n", .{}); - return; - } - - var items = std.ArrayListUnmanaged([]const u8).empty; - defer items.deinit(allocator); - - for (dirs) |dir| { - if (shouldSkip(dir.path, config.ignored_patterns)) continue; - try items.append(allocator, dir.path); - } - - const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); - defer allocator.free(selected); - - std.Io.Dir.accessAbsolute(io.rt(), selected, .{}) catch |err| { - io.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err }); - return error.UserAbort; - }; - - var out_buf: [4096]u8 = undefined; - var out = io.getStdout(&out_buf); - try out.print("{s}\n", .{selected}); - try out.end(); -} - -pub fn favorites(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { - if (config.favorites.len == 0) { - io.warn("No favorites found in config.\n", .{}); - return; - } - - var items = std.ArrayListUnmanaged([]const u8).empty; - defer { - for (items.items) |item| allocator.free(item); - items.deinit(allocator); - } - - for (config.favorites) |entry| { - const expanded = try expandPath(allocator, entry); - try items.append(allocator, expanded); - } - - const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); - defer allocator.free(selected); - - const is_dir = blk: { - var dir = std.Io.Dir.openDirAbsolute(io.rt(), selected, .{}) catch break :blk false; - dir.close(io.rt()); - break :blk true; - }; - - if (is_dir) { - try db.logDir(selected); - var out_buf: [4096]u8 = undefined; - var out = io.getStdout(&out_buf); - try out.print("{s}\n", .{selected}); - try out.end(); - } else { - try gotoFile(allocator, db, selected, null, config); - } -} - -fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { - var res = std.ArrayListUnmanaged(u8).empty; - errdefer res.deinit(allocator); - - const home = io.getenv("HOME") orelse return error.HomeNotFound; - - var i: usize = 0; - while (i < path.len) { - if (path[i] == '~' and (i == 0 or path[i - 1] == std.fs.path.sep)) { - try res.appendSlice(allocator, home); - i += 1; - } else if (std.mem.startsWith(u8, path[i..], "$HOME")) { - try res.appendSlice(allocator, home); - i += 5; - } else { - try res.append(allocator, path[i]); - i += 1; - } - } - - return res.toOwnedSlice(allocator); -} - -pub fn recentFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { - const managed_files = try db.recentFiles(); - defer managed_files.deinit(); - const files = managed_files.value; - - if (files.len == 0) { - io.warn("No recent files found.\n", .{}); - return; - } - - var items = std.ArrayListUnmanaged([]const u8).empty; - defer items.deinit(allocator); - - for (files) |file| { - if (shouldSkip(file.path, config.ignored_patterns)) continue; - try items.append(allocator, file.path); - } - - const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); - defer allocator.free(selected); - - try std.Io.Dir.accessAbsolute(io.rt(), selected, .{}); - - try gotoFile(allocator, db, selected, null, config); -} - -// fzf `--bind change:reload:...` runs via shell. Patterns come from config, -// so single-quote them safely: `'` becomes `'\''` inside outer `'...'`. -fn appendShellEscapedContent(buf: *std.ArrayListUnmanaged(u8), allocator: std.mem.Allocator, s: []const u8) !void { - for (s) |c| { - if (c == '\'') { - try buf.appendSlice(allocator, "'\\''"); - } else { - try buf.append(allocator, c); - } - } -} - -fn buildRgReloadCommand(allocator: std.mem.Allocator, ignored_patterns: []const []const u8) ![]u8 { - var rg_cmd = std.ArrayListUnmanaged(u8).empty; - errdefer rg_cmd.deinit(allocator); - try rg_cmd.appendSlice(allocator, "change:reload:rg --column --line-number --no-heading --color=always --smart-case"); - for (ignored_patterns) |pattern| { - try rg_cmd.appendSlice(allocator, " --glob '!"); - try appendShellEscapedContent(&rg_cmd, allocator, pattern); - try rg_cmd.appendSlice(allocator, "/*'"); - try rg_cmd.appendSlice(allocator, " --glob '!"); - try appendShellEscapedContent(&rg_cmd, allocator, pattern); - try rg_cmd.appendSlice(allocator, "'"); - } - try rg_cmd.appendSlice(allocator, " {q} || true"); - return rg_cmd.toOwnedSlice(allocator); -} - -pub fn grep(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void { - const initial_query = ""; - - const rg_reload = try buildRgReloadCommand(allocator, config.ignored_patterns); - defer allocator.free(rg_reload); - - var fzf = try Fzf.init(allocator, config.fzf_opts, &[_][]const u8{ - "--disabled", - "--query", - initial_query, - "--bind", - rg_reload, - "--delimiter", - ":", - "--preview", - "bat --highlight-line {2} {1}", - "--preview-window", - "right,60%,border-left,+{2}+3/3,~3", - }); - defer fzf.deinit(); - - fzf.stdin = .inherit; - fzf.stdout = .pipe; - fzf.stderr = .inherit; - - var child = try fzf.spawn(); - - var stdout_data: ?[]u8 = null; - defer if (stdout_data) |d| allocator.free(d); - - if (child.stdout) |stdout| { - var buffer: [4096]u8 = undefined; - var sr = stdout.reader(io.rt(), &buffer); - stdout_data = try sr.interface.allocRemaining(allocator, .limited(64 * 1024 * 1024)); - } - - const term = try child.wait(io.rt()); - if (term != .exited or term.exited != 0) return error.UserAbort; - const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t"); - if (selected.len == 0) return error.UserAbort; - - var it = std.mem.splitScalar(u8, selected, ':'); - if (it.next()) |file| { - const line = it.next(); - try gotoFile(allocator, db, file, line, config); - } -} - -pub fn lookFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void { - try runFzfWithWalker(allocator, db, .file, config, depth); -} - -pub fn lookDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void { - try runFzfWithWalker(allocator, db, .directory, config, depth); -} - -const Entry = struct { - path: []const u8, - mtime: i128, -}; - -fn getMaxDirMtime(ignored_patterns: []const []const u8, max_depth: usize) !i128 { - var max_mtime: i128 = 0; - - if (std.Io.Dir.cwd().statFile(io.rt(), ".", .{})) |stat| { - max_mtime = stat.mtime.nanoseconds; - } else |_| {} - - var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true }); - defer root.close(io.rt()); - try scanMaxMtime(&root, 0, max_depth, ignored_patterns, &max_mtime); - - return max_mtime; -} - -// Recurses one open directory per call frame, so each handle is closed exactly -// once by its own `defer` as the recursion unwinds. -fn scanMaxMtime(dir: *std.Io.Dir, depth: usize, max_depth: usize, ignored_patterns: []const []const u8, max_mtime: *i128) !void { - var it = dir.iterate(); - while (try it.next(io.rt())) |entry| { - var skip = false; - for (ignored_patterns) |pattern| { - if (std.mem.eql(u8, entry.name, pattern)) { - skip = true; - break; - } - } - if (skip) continue; - - const is_dir = entry.kind == .directory; - if (is_dir or entry.kind == .sym_link) { - const stat = dir.statFile(io.rt(), entry.name, .{}) catch |err| { - if (err != error.FileNotFound and err != error.AccessDenied) { - io.warn("failed to stat {s}: {}\n", .{ entry.name, err }); - } - continue; - }; - if (stat.kind == .directory) { - if (stat.mtime.nanoseconds > max_mtime.*) max_mtime.* = stat.mtime.nanoseconds; - - if (is_dir and depth + 1 < max_depth) { - var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| { - io.warn("failed to open directory {s}: {}\n", .{ entry.name, err }); - continue; - }; - defer sub_dir.close(io.rt()); - try scanMaxMtime(&sub_dir, depth + 1, max_depth, ignored_patterns, max_mtime); - } - } - } - } -} - -// Recurses one open directory per call frame (closed by its own `defer`), -// collecting matching entries with their path relative to the walk root. -fn collectEntries( - allocator: std.mem.Allocator, - dir: *std.Io.Dir, - prefix: ?[]const u8, - depth: usize, - max_depth: usize, - kind: std.Io.File.Kind, - ignored_patterns: []const []const u8, - entries: *std.ArrayListUnmanaged(Entry), -) !void { - var it = dir.iterate(); - while (try it.next(io.rt())) |entry| { - const entry_path = if (prefix) |p| - try std.fs.path.join(allocator, &.{ p, entry.name }) - else - try allocator.dupe(u8, entry.name); - defer allocator.free(entry_path); - - if (shouldSkip(entry_path, ignored_patterns)) continue; - - if (entry.kind == kind or entry.kind == .sym_link) { - if (dir.statFile(io.rt(), entry.name, .{})) |stat| { - if (stat.kind == kind) { - try entries.append(allocator, .{ - .path = try allocator.dupe(u8, entry_path), - .mtime = stat.mtime.nanoseconds, - }); - } - } else |err| { - if (err != error.FileNotFound and err != error.AccessDenied) { - io.warn("failed to stat {s}: {}\n", .{ entry.name, err }); - } - } - } - - if (entry.kind == .directory and depth + 1 < max_depth) { - var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| { - io.warn("failed to open directory {s}: {}\n", .{ entry.name, err }); - continue; - }; - defer sub_dir.close(io.rt()); - try collectEntries(allocator, &sub_dir, entry_path, depth + 1, max_depth, kind, ignored_patterns, entries); - } - } -} - -// Cached entries come from unauthenticated localhost memcached. Never trust -// them blindly: reject absolute paths, `..` escapes, empty/NUL paths. -fn isSafeCachedPath(p: []const u8) bool { - if (p.len == 0) return false; - if (std.mem.indexOfScalar(u8, p, 0) != null) return false; - if (std.fs.path.isAbsolute(p)) return false; - var it = std.mem.splitScalar(u8, p, std.fs.path.sep); - while (it.next()) |comp| { - if (std.mem.eql(u8, comp, "..")) return false; - } - return true; -} - -fn runFzfWithWalker(allocator: std.mem.Allocator, db: *hist.Db, kind: std.Io.File.Kind, config: *const cfg.Config, depth: ?usize) !void { - const is_dir = kind == .directory; - const max_depth = depth orelse config.max_depth; - - var entries = std.ArrayListUnmanaged(Entry).empty; - defer { - for (entries.items) |e| allocator.free(e.path); - entries.deinit(allocator); - } - - var mc = cache.Memcached.init(allocator, "127.0.0.1", 11211) catch |err| blk: { - if (err != error.ConnectionRefused) { - io.warn("failed to initialize memcached: {}\n", .{err}); - } - break :blk null; - }; - defer if (mc) |*m| m.deinit(); - - var cache_key: ?[]const u8 = null; - defer if (cache_key) |k| allocator.free(k); - - var cache_hit = false; - if (mc) |*m| { - const cwd = blk: { - break :blk std.process.currentPathAlloc(io.rt(), allocator) catch |err| { - io.warn("failed to get cwd for cache: {}\n", .{err}); - break :blk null; - }; - }; - if (cwd) |c| { - defer allocator.free(c); - - const hash = std.hash.Crc32.hash(c); - const max_dir_mtime = getMaxDirMtime(config.ignored_patterns, max_depth) catch blk: { - break :blk @as(i128, 0); - }; - const mtime = @divTrunc(max_dir_mtime, std.time.ns_per_s); - cache_key = try std.fmt.allocPrint(allocator, "magdalena:look:{s}:{x}:{d}:{d}", .{ if (is_dir) "dir" else "file", hash, mtime, max_depth }); - if (cache_key) |key| { - // Cache lives in unauthenticated localhost memcached. Any - // failure or corruption is a miss, never fatal. - const maybe_data: ?[]u8 = m.get(key) catch |err| blk: { - io.warn("ignoring cache read failure: {}\n", .{err}); - break :blk null; - }; - if (maybe_data) |data| { - defer allocator.free(data); - const maybe_parsed: ?std.json.Parsed([]Entry) = std.json.parseFromSlice([]Entry, allocator, data, .{}) catch |err| blk: { - io.warn("ignoring corrupt cache entry: {}\n", .{err}); - break :blk null; - }; - if (maybe_parsed) |parsed| { - defer parsed.deinit(); - var valid: usize = 0; - for (parsed.value) |e| { - if (!isSafeCachedPath(e.path)) continue; - try entries.append(allocator, .{ - .path = try allocator.dupe(u8, e.path), - .mtime = e.mtime, - }); - valid += 1; - } - // Parseable but fully bogus entries fall through to a - // fresh walk, which also overwrites the poisoned key. - if (valid > 0 or parsed.value.len == 0) cache_hit = true; - } - } - } - } - } - - if (!cache_hit) { - var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true }); - defer root.close(io.rt()); - try collectEntries(allocator, &root, null, 0, max_depth, kind, config.ignored_patterns, &entries); - - if (mc) |*m| { - if (cache_key) |key| { - const json_data = try std.json.Stringify.valueAlloc(allocator, entries.items, .{}); - defer allocator.free(json_data); - m.set(key, json_data, 3600) catch |err| { - io.warn("failed to write cache: {}\n", .{err}); - }; - } - } - } - - std.mem.sortUnstable(Entry, entries.items, {}, struct { - fn lessThan(_: void, a: Entry, b: Entry) bool { - return a.mtime > b.mtime; - } - }.lessThan); - - var items = std.ArrayListUnmanaged([]const u8).empty; - defer items.deinit(allocator); - for (entries.items) |entry| { - try items.append(allocator, entry.path); - } - - const selected = try runFzfPicker(allocator, config.fzf_opts, items.items); - defer allocator.free(selected); - - const clean_path = if (std.mem.startsWith(u8, selected, "./")) selected[2..] else selected; - - if (clean_path.len > 0) { - std.Io.Dir.cwd().access(io.rt(), clean_path, .{}) catch |err| { - io.warn("Selected item no longer exists or is inaccessible: {s} ({})\n", .{ clean_path, err }); - return error.UserAbort; - }; - if (is_dir) { - var out_buf: [4096]u8 = undefined; - var out = io.getStdout(&out_buf); - try out.print("{s}\n", .{clean_path}); - try out.end(); - } else { - try gotoFile(allocator, db, clean_path, null, config); - } - } -} - -const testing = std.testing; - -test "shouldSkip: matches an exact path component" { - const ignored = [_][]const u8{ "node_modules", ".git" }; - try testing.expect(shouldSkip("src/node_modules/pkg/x.js", &ignored)); - try testing.expect(shouldSkip(".git/config", &ignored)); - try testing.expect(shouldSkip("a/b/.git", &ignored)); -} - -test "shouldSkip: partial component is not a match" { - const ignored = [_][]const u8{"node_modules"}; - try testing.expect(!shouldSkip("src/lib/main.zig", &ignored)); - try testing.expect(!shouldSkip("src/node_modules_old/x", &ignored)); -} - -test "shouldSkip: empty pattern set never skips" { - try testing.expect(!shouldSkip("a/b/c", &.{})); -} - -test "isSafeCachedPath: accepts plain relative paths" { - try testing.expect(isSafeCachedPath("src/main.zig")); - try testing.expect(isSafeCachedPath("./a/b")); -} - -test "isSafeCachedPath: rejects escapes and junk" { - try testing.expect(!isSafeCachedPath("")); - try testing.expect(!isSafeCachedPath("/etc/passwd")); - try testing.expect(!isSafeCachedPath("a/../../etc/passwd")); - try testing.expect(!isSafeCachedPath("..")); - try testing.expect(!isSafeCachedPath("a\x00b")); -} - -test "buildRgReloadCommand: plain patterns stay single-quoted" { - const ignored = [_][]const u8{ "node_modules", ".git" }; - const cmd = try buildRgReloadCommand(testing.allocator, &ignored); - defer testing.allocator.free(cmd); - try testing.expect(std.mem.indexOf(u8, cmd, "--glob '!node_modules/*'") != null); - try testing.expect(std.mem.indexOf(u8, cmd, "--glob '!.git'") != null); -} - -test "buildRgReloadCommand: single quote cannot break out" { - const ignored = [_][]const u8{"a'b"}; - const cmd = try buildRgReloadCommand(testing.allocator, &ignored); - defer testing.allocator.free(cmd); - try testing.expect(std.mem.indexOf(u8, cmd, "'!a'\\''b/*'") != null); - try testing.expect(std.mem.indexOf(u8, cmd, "'!a'\\''b'") != null); -} blob - /dev/null blob + 7f1e27eee5b0f801e9b8bb86fa834f8a6e2c14f0 (mode 644) --- /dev/null +++ src/input_output.zig @@ -0,0 +1,83 @@ +const std = @import("std"); +const assert = std.debug.assert; + +var rt_handle: ?std.Io = null; +var env_map: ?*std.process.Environ.Map = null; + +pub fn init(io_handle: std.Io, environ: *std.process.Environ.Map) void { + rt_handle = io_handle; + env_map = environ; + assert(rt_handle != null); + assert(env_map != null); +} + +pub fn runtime() std.Io { + assert(rt_handle != null); + return rt_handle.?; +} + +pub fn getenv(key: []const u8) ?[]const u8 { + assert(key.len > 0); + assert(env_map != null); + if (env_map) |map| { + return map.get(key); + } + return null; +} + +pub fn environMap() *std.process.Environ.Map { + if (env_map) |map| { + return map; + } + fatal("io used before init", .{}); +} + +pub const Writer = struct { + inner: std.Io.File.Writer, + failed: bool = false, + + pub fn print(self: *Writer, comptime fmt: []const u8, args: anytype) !void { + assert(fmt.len > 0); + if (self.failed) return; + self.inner.interface.print(fmt, args) catch |err| { + if (err == error.WriteFailed) { + self.failed = true; + return; + } + return err; + }; + } + + pub fn end(self: *Writer) !void { + if (self.failed) return; + self.inner.interface.flush() catch |err| { + if (err == error.WriteFailed) { + self.failed = true; + return; + } + return err; + }; + } +}; + +pub fn getStdout(buffer: []u8) Writer { + assert(buffer.len > 0); + return .{ .inner = std.Io.File.stdout().writerStreaming(runtime(), buffer) }; +} + +pub fn warn(comptime fmt: []const u8, args: anytype) void { + assert(fmt.len > 0); + var buffer: [4096]u8 = undefined; + var writer = std.Io.File.stderr().writerStreaming(runtime(), &buffer); + writer.interface.print(fmt, args) catch |err| { + std.log.warn("failed to write to stderr: {}", .{err}); + }; + writer.interface.flush() catch |err| { + std.log.warn("failed to flush stderr writer: {}", .{err}); + }; +} + +pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { + warn(fmt ++ "\n", args); + std.process.exit(1); +} blob - afdb924d98de999b342f0f16c57e0be5b140a1b0 (mode 644) blob + /dev/null --- src/io.zig +++ /dev/null @@ -1,69 +0,0 @@ -const std = @import("std"); - -// Process-wide handles, populated once from `std.process.Init` in `main`. The -// app is a short-lived single-threaded CLI, so threading these through every -// call site would be pure noise; ambient state keeps the IO surface small. -// They are optionals (not `undefined`) so that any use before `init` is a -// checked unwrap panic rather than undefined behavior. -var rt_handle: ?std.Io = null; -var env_map: ?*std.process.Environ.Map = null; - -pub fn init(io_handle: std.Io, environ: *std.process.Environ.Map) void { - rt_handle = io_handle; - env_map = environ; -} - -pub fn rt() std.Io { - return rt_handle.?; -} - -pub fn getenv(key: []const u8) ?[]const u8 { - return env_map.?.get(key); -} - -pub const Writer = struct { - inner: std.Io.File.Writer, - failed: bool = false, - - pub fn print(self: *Writer, comptime fmt: []const u8, args: anytype) !void { - if (self.failed) return; - self.inner.interface.print(fmt, args) catch |err| { - if (err == error.WriteFailed) { - self.failed = true; - return; - } - return err; - }; - } - - pub fn end(self: *Writer) !void { - if (self.failed) return; - self.inner.interface.flush() catch |err| { - if (err == error.WriteFailed) { - self.failed = true; - return; - } - return err; - }; - } -}; - -pub fn getStdout(buf: []u8) Writer { - return .{ .inner = std.Io.File.stdout().writerStreaming(rt(), buf) }; -} - -pub fn warn(comptime fmt: []const u8, args: anytype) void { - var buf: [4096]u8 = undefined; - var writer = std.Io.File.stderr().writerStreaming(rt(), &buf); - writer.interface.print(fmt, args) catch |err| { - std.log.warn("failed to write to stderr: {}", .{err}); - }; - writer.interface.flush() catch |err| { - std.log.warn("failed to flush stderr writer: {}", .{err}); - }; -} - -pub fn fatal(comptime fmt: []const u8, args: anytype) noreturn { - warn(fmt ++ "\n", args); - std.process.exit(1); -} blob - 649121766a95937a1654f74083b5709f43ce17e7 blob + 35e914d18d1d839d5c5f9357afe5a232f55bc653 --- src/main.zig +++ src/main.zig @@ -1,133 +1,176 @@ const std = @import("std"); -const cli = @import("cli.zig"); -const cfg = @import("cfg.zig"); -const fzf = @import("fzf.zig"); -const hist = @import("db.zig"); -const io = @import("io.zig"); +const assert = std.debug.assert; +const command_line = @import("command_line.zig"); +const config = @import("config.zig"); +const navigate = @import("navigate.zig"); +const database = @import("database.zig"); +const input_output = @import("input_output.zig"); -test { - _ = cli; - _ = cfg; - _ = fzf; - _ = hist; -} - pub fn main(init: std.process.Init) void { - io.init(init.io, init.environ_map); + input_output.init(init.io, init.environ_map); const allocator = init.gpa; - const args = cli.parseArgs(allocator, init.minimal.args) catch |err| { - io.fatal("failed to parse arguments: {}", .{err}); + const arguments = command_line.parseArguments(allocator, init.minimal.args) catch |err| { + input_output.fatal("failed to parse arguments: {}", .{err}); }; - defer args.deinit(); + defer arguments.deinit(); - if (args.action == .help) { - cli.printUsage() catch |err| { - io.fatal("failed to print usage: {}", .{err}); + if (arguments.action == .help) { + command_line.printUsage() catch |err| { + input_output.fatal("failed to print usage: {}", .{err}); }; return; } - const parsed_config = cfg.loadConfig(allocator); - defer if (parsed_config) |c| c.deinit(); - const default_config = cfg.Config{}; - const base_config = if (parsed_config) |c| c.value else default_config; + const parsed_config = config.loadConfig(allocator); + defer if (parsed_config) |app_config| app_config.deinit(); + const default_config = config.Config{}; + const base_config = if (parsed_config) |app_config| app_config.value else default_config; - var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; - const cwd = blk: { - const n = std.process.currentPath(io.rt(), &cwd_buf) catch |err| { - io.warn("failed to get cwd for folder overrides: {}\n", .{err}); - break :blk ""; - }; - break :blk cwd_buf[0..n]; + var current_directory_buffer: [std.fs.max_path_bytes]u8 = undefined; + const current_directory = currentDirectory(¤t_directory_buffer); + const app_config = config.applyFolderOverride(base_config, current_directory); + + const database_path = database.getDefaultDatabasePath(allocator) catch |err| { + input_output.fatal("failed to get database path: {}", .{err}); }; - const config = cfg.applyFolderOverride(base_config, cwd); + defer allocator.free(database_path); - const db_path = hist.getDefaultDbPath(allocator) catch |err| { - io.fatal("failed to get database path: {}", .{err}); + var history = database.Database.init(allocator, database_path) catch |err| { + input_output.fatal("failed to initialize database: {}", .{err}); }; - defer allocator.free(db_path); + defer history.deinit(); - var db = hist.Db.init(allocator, db_path) catch |err| { - io.fatal("failed to initialize database: {}", .{err}); + runAction(allocator, &history, arguments, &app_config) catch |err| { + if (err == error.UserAbort) std.process.exit(1); + input_output.fatal("action failed: {}", .{err}); }; - defer db.deinit(); +} - runAction(allocator, &db, args, &config) catch |err| { - if (err == error.UserAbort) { - std.process.exit(1); - } - io.fatal("action failed: {}", .{err}); +fn currentDirectory(buffer: *[std.fs.max_path_bytes]u8) []const u8 { + const count = std.process.currentPath(input_output.runtime(), buffer) catch |err| { + input_output.warn("failed to get working directory for folder overrides: {}\n", .{err}); + return ""; }; + assert(count <= buffer.len); + return buffer[0..count]; } -fn runAction(allocator: std.mem.Allocator, db: *hist.Db, args: cli.Args, config: *const cfg.Config) !void { - var buf: [4096]u8 = undefined; - var writer = io.getStdout(&buf); +fn runAction( + allocator: std.mem.Allocator, + history: *database.Database, + arguments: command_line.Arguments, + app_config: *const config.Config, +) !void { + assert(history.base_path.len > 0); + var buffer: [4096]u8 = undefined; + var writer = input_output.getStdout(&buffer); + assert(!writer.failed); - switch (args.action) { - .recent_dirs => { - const managed = try db.recentDirs(); - defer managed.deinit(); - for (managed.value) |d| { - try writer.print("{s}|{s}\n", .{ d.timestamp orelse "-", d.path }); - } + switch (arguments.action) { + .recent_directories => { + try printRecentDirectories(&writer, history); }, .recent_files => { - const managed = try db.recentFiles(); - defer managed.deinit(); - for (managed.value) |f| { - try writer.print("{s}|{s}|{s}|{s}\n", .{ f.timestamp orelse "-", f.file_type, f.action, f.path }); - } + try printRecentFiles(&writer, history); }, .favorites => { - try fzf.favorites(allocator, db, config); + try navigate.favorites(allocator, history, app_config); }, .search => { - if (args.query) |query| { - const managed = try db.searchHistory(query); - defer managed.deinit(); - const results = managed.value; - - for (results.directories) |d| { - try writer.print("D|{s}\n", .{d.path}); - } - for (results.files) |f| { - try writer.print("F|{s}|{s}|{s}\n", .{ f.file_type, f.action, f.path }); - } - } + try printSearchResults(&writer, history, arguments.query); }, - .goto_dir => { - try fzf.recentDir(allocator, db, config); + .goto_directory => { + try navigate.recentDirectory(allocator, history, app_config); }, .goto_file => { - try fzf.recentFile(allocator, db, config); + try navigate.recentFile(allocator, history, app_config); }, .look_file => { - try fzf.lookFile(allocator, db, config, args.depth); + try navigate.lookFile(allocator, history, app_config, arguments.depth); }, - .look_dir => { - try fzf.lookDir(allocator, db, config, args.depth); + .look_directory => { + try navigate.lookDirectory(allocator, history, app_config, arguments.depth); }, .grep => { - try fzf.grep(allocator, db, config); + try navigate.grep(allocator, history, app_config); }, .cleanup => { - try db.cleanup(); + try history.cleanup(); }, - .log_dir => { - if (args.log_path) |path| { - try db.logDir(path); + .log_directory => { + if (arguments.log_path) |path| { + try history.logDirectory(path); } }, .log_file => { - if (args.log_path) |path| { - try db.logFile(path, args.file_type orelse "other", args.file_action orelse "open"); + if (arguments.log_path) |path| { + const file_type = arguments.file_type orelse "other"; + const file_action = arguments.file_action orelse "open"; + try history.logFile(path, file_type, file_action); } }, .help => { - try cli.printUsage(); + try command_line.printUsage(); }, } + if (writer.failed) { + return error.WriteFailed; + } try writer.end(); } + +fn printRecentDirectories(writer: *input_output.Writer, history: *database.Database) !void { + assert(history.base_path.len > 0); + const managed = try history.recentDirectories(); + defer managed.deinit(); + for (managed.value) |entry| { + assert(entry.path.len > 0); + try writer.print("{s}|{s}\n", .{ entry.timestamp orelse "-", entry.path }); + } +} + +fn printRecentFiles(writer: *input_output.Writer, history: *database.Database) !void { + assert(history.base_path.len > 0); + const managed = try history.recentFiles(); + defer managed.deinit(); + for (managed.value) |entry| { + assert(entry.path.len > 0); + const timestamp = entry.timestamp orelse "-"; + try writer.print("{s}|{s}|{s}|{s}\n", .{ + timestamp, + entry.file_type, + entry.action, + entry.path, + }); + } +} + +fn printSearchResults( + writer: *input_output.Writer, + history: *database.Database, + query: ?[]const u8, +) !void { + assert(history.base_path.len > 0); + const needle = query orelse return; + const managed = try history.searchHistory(needle); + defer managed.deinit(); + const results = managed.value; + + for (results.directories) |entry| { + assert(entry.path.len > 0); + try writer.print("D|{s}\n", .{entry.path}); + } + for (results.files) |entry| { + assert(entry.path.len > 0); + const line = .{ entry.file_type, entry.action, entry.path }; + try writer.print("F|{s}|{s}|{s}\n", line); + } +} + +test { + _ = command_line; + _ = config; + _ = navigate; + _ = database; +} blob - cbb414bde50183b33c6ff57c94a3f2bb1812b46a blob + 8bfcbe892c0f88f389d9b48d9f163522e89981a3 --- src/memcached.zig +++ src/memcached.zig @@ -1,20 +1,30 @@ const std = @import("std"); -const io = @import("io.zig"); +const assert = std.debug.assert; +const input_output = @import("input_output.zig"); pub const Memcached = struct { allocator: std.mem.Allocator, stream: std.Io.net.Stream, - read_buf: [65536]u8 = @splat(0), - write_buf: [4096]u8 = @splat(0), - // `null` until first use; the Stream's buffered Reader/Writer hold pointers - // into this struct's own buffers, so they can only be created once the - // struct sits at its final address (i.e. after `init` returns by value). - reader: ?std.Io.net.Stream.Reader = null, - writer: ?std.Io.net.Stream.Writer = null, + read_buffer: [65536]u8 = @splat(0), + write_buffer: [4096]u8 = @splat(0), + stream_reader: ?std.Io.net.Stream.Reader = null, + stream_writer: ?std.Io.net.Stream.Writer = null, + const chunk_size: usize = 900 * 1024; + const chunk_prefix = "\x00chunked:"; + + pub const max_value_bytes: usize = 64 * 1024 * 1024; + + comptime { + assert(chunk_size > 0); + assert(max_value_bytes > chunk_size); + } + pub fn init(allocator: std.mem.Allocator, host: []const u8, port: u16) !Memcached { + assert(host.len > 0); + assert(port > 0); const address = try std.Io.net.IpAddress.parseIp4(host, port); - const stream = try address.connect(io.rt(), .{ .mode = .stream }); + const stream = try address.connect(input_output.runtime(), .{ .mode = .stream }); return .{ .allocator = allocator, .stream = stream, @@ -22,73 +32,91 @@ pub const Memcached = struct { } pub fn deinit(self: *Memcached) void { - if (self.writer) |*writer| { - writer.interface.flush() catch |err| { - io.warn("failed to flush memcached on deinit: {}\n", .{err}); + if (self.stream_writer) |*pending| { + pending.interface.flush() catch |err| { + input_output.warn("failed to flush memcached on deinit: {}\n", .{err}); }; } - self.stream.close(io.rt()); + self.stream.close(input_output.runtime()); } fn ensureStarted(self: *Memcached) void { - if (self.reader != null) return; - self.reader = self.stream.reader(io.rt(), &self.read_buf); - self.writer = self.stream.writer(io.rt(), &self.write_buf); + if (self.stream_reader != null) { + return; + } + self.stream_reader = self.stream.reader(input_output.runtime(), &self.read_buffer); + self.stream_writer = self.stream.writer(input_output.runtime(), &self.write_buffer); + assert(self.stream_reader != null); + assert(self.stream_writer != null); } - fn r(self: *Memcached) *std.Io.Reader { + fn reader(self: *Memcached) *std.Io.Reader { self.ensureStarted(); - return &self.reader.?.interface; + assert(self.stream_reader != null); + assert(self.stream_writer != null); + return &self.stream_reader.?.interface; } - fn w(self: *Memcached) *std.Io.Writer { + fn writer(self: *Memcached) *std.Io.Writer { self.ensureStarted(); - return &self.writer.?.interface; + assert(self.stream_reader != null); + assert(self.stream_writer != null); + return &self.stream_writer.?.interface; } fn flushWrite(self: *Memcached) !void { - try self.w().flush(); + try self.writer().flush(); } fn bufferedWrite(self: *Memcached, data: []const u8) !void { - try self.w().writeAll(data); + assert(data.len > 0); + try self.writer().writeAll(data); } - const chunk_size = 900 * 1024; - const chunk_prefix = "\x00chunked:"; - // Upper bound for any single value we will send or accept. Answers come - // from an unauthenticated localhost server, so never allocate blindly - // from a peer-supplied length. - pub const max_value_bytes = 64 * 1024 * 1024; - - // Large values are split into chunk keys `{key}:{total}:{i}`. Namespacing - // chunks by total length means overwriting with a smaller value cannot - // alias stale chunks from a previous larger value; leftovers expire via TTL. pub fn set(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void { - if (value.len > max_value_bytes) return error.ValueTooLarge; + assertValidKey(key); + if (value.len > max_value_bytes) { + return error.ValueTooLarge; + } if (value.len <= chunk_size) { try self.sendSet(key, value, exptime); + try self.flushWrite(); return; } - const num_chunks = (value.len + chunk_size - 1) / chunk_size; - var meta_buf: [128]u8 = undefined; - const meta = try std.fmt.bufPrint(&meta_buf, chunk_prefix ++ "{d}:{d}", .{ num_chunks, value.len }); + const chunk_count = try std.math.divCeil(usize, value.len, chunk_size); + assert(chunk_count > 1); + var meta_buffer: [128]u8 = undefined; + const meta = try std.fmt.bufPrint(&meta_buffer, chunk_prefix ++ "{d}:{d}", .{ + chunk_count, + value.len, + }); try self.sendSet(key, meta, exptime); - var i: usize = 0; - while (i < num_chunks) : (i += 1) { - const start = i * chunk_size; - const end = @min((i + 1) * chunk_size, value.len); - var chunk_key_buf: [512]u8 = undefined; - const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}:{d}", .{ key, value.len, i }); + var index: usize = 0; + while (index < chunk_count) : (index += 1) { + assert(index < chunk_count); + const start = index * chunk_size; + const end = @min((index + 1) * chunk_size, value.len); + assert(start < value.len); + assert(end <= value.len); + assert(start < end); + var chunk_key_buffer: [512]u8 = undefined; + const chunk_key = try formatChunkKey(&chunk_key_buffer, key, value.len, index); try self.sendSet(chunk_key, value[start..end], exptime); } + try self.flushWrite(); } fn sendSet(self: *Memcached, key: []const u8, value: []const u8, exptime: u32) !void { - var header_buf: [512]u8 = undefined; - const header = try std.fmt.bufPrint(&header_buf, "ms {s} {d} T{d} q\r\n", .{ key, value.len, exptime }); + assertValidKey(key); + var header_buffer: [512]u8 = undefined; + const header = try std.fmt.bufPrint(&header_buffer, "ms {s} {d} T{d} q\r\n", .{ + key, + value.len, + exptime, + }); + assert(header.len > key.len); try self.bufferedWrite(header); if (value.len > 0) { @@ -97,110 +125,141 @@ pub const Memcached = struct { try self.bufferedWrite("\r\n"); } - fn readLine(self: *Memcached, buf: []u8) !?[]const u8 { - // Inclusive so the '\n' is consumed from the stream; exclusive would - // leave it buffered and desync the following `readExact`. - const line = self.r().takeDelimiterInclusive('\n') catch |err| switch (err) { - error.EndOfStream => return null, - error.StreamTooLong => return error.LineTooLong, - else => |e| return e, + fn readLine(self: *Memcached, buffer: []u8) !?[]const u8 { + assert(buffer.len > 0); + const line = self.reader().takeDelimiterInclusive('\n') catch |err| { + if (err == error.EndOfStream) { + return null; + } + if (err == error.StreamTooLong) { + return error.LineTooLong; + } + return err; }; const trimmed = std.mem.trim(u8, line, "\r\n"); - if (buf.len < trimmed.len) return error.BufferTooSmall; - @memcpy(buf[0..trimmed.len], trimmed); - return buf[0..trimmed.len]; + if (buffer.len < trimmed.len) { + return error.BufferTooSmall; + } + @memcpy(buffer[0..trimmed.len], trimmed); + const result = buffer[0..trimmed.len]; + assert(result.ptr == buffer.ptr); + assert(result.len == trimmed.len); + return result; } - fn readExact(self: *Memcached, buf: []u8) !void { - self.r().readSliceAll(buf) catch |err| switch (err) { - error.EndOfStream => return error.IncompleteRead, - else => |e| return e, + fn readExact(self: *Memcached, buffer: []u8) !void { + assert(buffer.len > 0); + self.reader().readSliceAll(buffer) catch |err| { + if (err == error.EndOfStream) { + return error.IncompleteRead; + } + return err; }; } fn consumeCrlf(self: *Memcached) void { - const reader = self.r(); - const first = reader.peekByte() catch return; + const input = self.reader(); + const first = input.peekByte() catch return; if (first == '\r') { - reader.toss(1); - const second = reader.peekByte() catch return; - if (second == '\n') reader.toss(1); - } else if (first == '\n') { - reader.toss(1); + input.toss(1); + const second = input.peekByte() catch return; + if (second == '\n') { + input.toss(1); + } + return; } + if (first == '\n') { + input.toss(1); + } } pub fn get(self: *Memcached, key: []const u8) !?[]u8 { - var cmd_buf: [512]u8 = undefined; - const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{key}); - try self.bufferedWrite(cmd); + assertValidKey(key); + var command_buffer: [512]u8 = undefined; + const command_text = try std.fmt.bufPrint(&command_buffer, "mg {s} v\r\n", .{key}); + assert(command_text.len > "mg v\r\n".len); + try self.bufferedWrite(command_text); try self.flushWrite(); - var line_buf: [512]u8 = undefined; - const line = (try self.readLine(&line_buf)) orelse return null; + var line_buffer: [512]u8 = undefined; + const line = (try self.readLine(&line_buffer)) orelse return null; - if (std.mem.startsWith(u8, line, "VA ")) { - const rest = line[3..]; - const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len; - const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10); - if (size > max_value_bytes) return error.ValueTooLarge; + if (!std.mem.startsWith(u8, line, "VA ")) { + return null; + } + assert(line.len >= "VA ".len); + const rest = line["VA ".len..]; + assert(rest.len < line.len); + const space_index = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len; + assert(space_index <= rest.len); + const size = try std.fmt.parseInt(usize, rest[0..space_index], 10); + if (size > max_value_bytes) { + return error.ValueTooLarge; + } - const data = try self.allocator.alloc(u8, size); - errdefer self.allocator.free(data); + const data = try self.allocator.alloc(u8, size); + assert(data.len == size); + errdefer self.allocator.free(data); - if (size > 0) { - try self.readExact(data); - } + if (size > 0) { + try self.readExact(data); + } - self.consumeCrlf(); + self.consumeCrlf(); - if (std.mem.startsWith(u8, data, chunk_prefix)) { - defer self.allocator.free(data); - return try self.reassembleChunks(key, data); - } - - return data; + if (std.mem.startsWith(u8, data, chunk_prefix)) { + defer self.allocator.free(data); + return try self.reassembleChunks(key, data); } - return null; + return data; } fn reassembleChunks(self: *Memcached, key: []const u8, meta: []const u8) ![]u8 { - var it = std.mem.splitScalar(u8, meta[chunk_prefix.len..], ':'); - const num_chunks_str = it.next() orelse return error.InvalidCacheData; - const total_size_str = it.next() orelse return error.InvalidCacheData; + assertValidKey(key); + const parsed = try parseChunkMeta(meta); - const num_chunks = try std.fmt.parseInt(usize, num_chunks_str, 10); - const total_size = try std.fmt.parseInt(usize, total_size_str, 10); - if (num_chunks == 0) return error.InvalidCacheData; - if (total_size > max_value_bytes) return error.ValueTooLarge; - if (num_chunks != (total_size + chunk_size - 1) / chunk_size) return error.InvalidCacheData; - - const result = try self.allocator.alloc(u8, total_size); + const result = try self.allocator.alloc(u8, parsed.total_bytes); + assert(result.len == parsed.total_bytes); errdefer self.allocator.free(result); - var i: usize = 0; - while (i < num_chunks) : (i += 1) { - var chunk_key_buf: [512]u8 = undefined; - const chunk_key = try std.fmt.bufPrint(&chunk_key_buf, "{s}:{d}:{d}", .{ key, total_size, i }); + var index: usize = 0; + while (index < parsed.chunk_count) : (index += 1) { + assert(index < parsed.chunk_count); + var chunk_key_buffer: [512]u8 = undefined; + const chunk_key = try formatChunkKey(&chunk_key_buffer, key, parsed.total_bytes, index); - var cmd_buf: [512]u8 = undefined; - const cmd = try std.fmt.bufPrint(&cmd_buf, "mg {s} v\r\n", .{chunk_key}); - try self.bufferedWrite(cmd); + var command_buffer: [512]u8 = undefined; + const command_text = try std.fmt.bufPrint( + &command_buffer, + "mg {s} v\r\n", + .{chunk_key}, + ); + assert(command_text.len > "mg v\r\n".len); + try self.bufferedWrite(command_text); try self.flushWrite(); - var line_buf: [512]u8 = undefined; - const line = (try self.readLine(&line_buf)) orelse return error.CacheChunkMissing; + var line_buffer: [512]u8 = undefined; + const line = (try self.readLine(&line_buffer)) orelse return error.CacheChunkMissing; - if (!std.mem.startsWith(u8, line, "VA ")) return error.CacheChunkMissing; + if (!std.mem.startsWith(u8, line, "VA ")) { + return error.CacheChunkMissing; + } + assert(line.len >= "VA ".len); - const rest = line[3..]; - const space_idx = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len; - const size = try std.fmt.parseInt(usize, rest[0..space_idx], 10); + const rest = line["VA ".len..]; + assert(rest.len < line.len); + const space_index = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len; + assert(space_index <= rest.len); + const size = try std.fmt.parseInt(usize, rest[0..space_index], 10); - const chunk_start = i * chunk_size; - const chunk_end = @min((i + 1) * chunk_size, total_size); - if (size != chunk_end - chunk_start) return error.InvalidChunkSize; + const chunk_start = index * chunk_size; + const chunk_end = @min((index + 1) * chunk_size, parsed.total_bytes); + assert(chunk_end <= parsed.total_bytes); + assert(chunk_start < chunk_end); + if (size != chunk_end - chunk_start) { + return error.InvalidChunkSize; + } if (size > 0) { try self.readExact(result[chunk_start..chunk_end]); @@ -212,3 +271,87 @@ pub const Memcached = struct { return result; } }; + +fn assertValidKey(key: []const u8) void { + assert(key.len > 0); + assert(key.len <= 400); + for (key) |byte| { + assert(byte != ' '); + assert(byte != '\r'); + assert(byte != '\n'); + } +} + +const ChunkMeta = struct { + chunk_count: usize, + total_bytes: usize, +}; + +fn parseChunkMeta(meta: []const u8) !ChunkMeta { + assert(meta.len > Memcached.chunk_prefix.len); + assert(std.mem.startsWith(u8, meta, Memcached.chunk_prefix)); + var fields = std.mem.splitScalar(u8, meta[Memcached.chunk_prefix.len..], ':'); + const chunk_count_text = fields.next() orelse return error.InvalidCacheData; + const total_bytes_text = fields.next() orelse return error.InvalidCacheData; + + const chunk_count = std.fmt.parseInt(usize, chunk_count_text, 10) catch { + return error.InvalidCacheData; + }; + const total_bytes = std.fmt.parseInt(usize, total_bytes_text, 10) catch { + return error.InvalidCacheData; + }; + if (chunk_count == 0) { + return error.InvalidCacheData; + } + if (total_bytes > Memcached.max_value_bytes) { + return error.ValueTooLarge; + } + const expected_count = try std.math.divCeil(usize, total_bytes, Memcached.chunk_size); + if (chunk_count != expected_count) { + return error.InvalidCacheData; + } + return .{ .chunk_count = chunk_count, .total_bytes = total_bytes }; +} + +fn formatChunkKey(buffer: *[512]u8, key: []const u8, total_bytes: usize, index: usize) ![]const u8 { + assert(buffer.len == 512); + assertValidKey(key); + assert(total_bytes <= Memcached.max_value_bytes); + assert(total_bytes > 0); + const result = try std.fmt.bufPrint(buffer, "{s}:{d}:{d}", .{ key, total_bytes, index }); + assert(result.len > key.len); + return result; +} + +const testing = std.testing; + +test "parseChunkMeta: accepts a consistent header" { + const meta = try parseChunkMeta("\x00chunked:3:2500000"); + try testing.expectEqual(@as(usize, 3), meta.chunk_count); + try testing.expectEqual(@as(usize, 2500000), meta.total_bytes); +} + +test "parseChunkMeta: rejects zero chunks" { + try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:0:0")); +} + +test "parseChunkMeta: rejects inconsistent counts" { + try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:2:2500000")); + try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:9:2500000")); +} + +test "parseChunkMeta: rejects oversize totals" { + try testing.expectError(error.ValueTooLarge, parseChunkMeta("\x00chunked:73:67108865")); +} + +test "parseChunkMeta: rejects malformed fields" { + try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:3")); + try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:three:2500000")); + try testing.expectError(error.InvalidCacheData, parseChunkMeta("\x00chunked:3:lots")); +} + +test "formatChunkKey: namespaces chunks by total length" { + var buffer: [512]u8 = undefined; + const key = try formatChunkKey(&buffer, "magdalena:look:directory:9:1:3", 2500000, 2); + try testing.expectEqualStrings("magdalena:look:directory:9:1:3:2500000:2", key); +} blob - /dev/null blob + 901d36a23dcc38693c8f459f173eda9db9c54ff5 (mode 644) --- /dev/null +++ src/navigate.zig @@ -0,0 +1,1167 @@ +const std = @import("std"); +const assert = std.debug.assert; +const cache = @import("memcached.zig"); +const config = @import("config.zig"); +const database = @import("database.zig"); +const input_output = @import("input_output.zig"); +const picker = @import("picker.zig"); + +const max_supported_depth: usize = 128; + +comptime { + assert(max_supported_depth > 0); +} + +const Entry = struct { + path: []const u8, + mtime_ns: i128, +}; + +fn gotoFile( + history: ?*database.Database, + file_path: []const u8, + line: ?[]const u8, + app_config: *const config.Config, +) !void { + if (file_path.len == 0) { + return error.EmptyPath; + } + const dot_index = std.mem.lastIndexOfScalar(u8, file_path, '.'); + const extension = if (dot_index) |index| file_path[index + 1 ..] else ""; + const opener = matchOpener(app_config, extension); + assert(opener.action.len > 0); + + if (history) |open_history| { + open_history.logFile(file_path, extension, opener.action) catch |err| { + input_output.warn("failed to log file: {}\n", .{err}); + }; + } + + if (std.Io.Dir.openFileAbsolute(input_output.runtime(), "/dev/tty", .{ + .mode = .read_write, + .allow_directory = false, + .path_only = false, + })) |tty| { + if (std.c.dup2(tty.handle, std.posix.STDIN_FILENO) < 0) { + input_output.warn("failed to redirect stdin to tty\n", .{}); + } + if (std.c.dup2(tty.handle, std.posix.STDOUT_FILENO) < 0) { + input_output.warn("failed to redirect stdout to tty\n", .{}); + } + tty.close(input_output.runtime()); + } else |err| { + input_output.warn("failed to open /dev/tty: {}\n", .{err}); + } + + const parent_directory = std.fs.path.dirname(file_path) orelse "."; + assert(parent_directory.len > 0); + const base_name = std.fs.path.basename(file_path); + try std.process.setCurrentPath(input_output.runtime(), parent_directory); + + if (opener.command) |command| { + return std.process.replace(input_output.runtime(), .{ .argv = &.{ command, base_name } }); + } + + const fallback_editor = input_output.getenv("EDITOR") orelse return error.EditorNotSet; + const editor = app_config.editor orelse fallback_editor; + + if (line) |line_text| { + var line_buf: [32]u8 = undefined; + const line_arg = try std.fmt.bufPrint(&line_buf, "+{s}", .{line_text}); + return std.process.replace(input_output.runtime(), .{ + .argv = &.{ editor, line_arg, base_name }, + }); + } + + return std.process.replace(input_output.runtime(), .{ .argv = &.{ editor, base_name } }); +} + +const OpenerMatch = struct { + action: []const u8, + command: ?[]const u8, +}; + +fn matchOpener(app_config: *const config.Config, extension: []const u8) OpenerMatch { + for (app_config.openers) |opener| { + if (opener.command.len == 0) { + continue; + } + if (opener.action.len == 0) { + continue; + } + for (opener.extensions) |opener_extension| { + if (opener_extension.len == 0) { + continue; + } + if (std.mem.eql(u8, opener_extension, extension)) { + assert(opener.command.len > 0); + assert(opener.action.len > 0); + return .{ .action = opener.action, .command = opener.command }; + } + } + } + return .{ .action = "text_editor", .command = null }; +} + +fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool { + assert(path.len > 0); + var components = std.mem.splitScalar(u8, path, std.fs.path.sep); + while (components.next()) |component| { + for (ignored_patterns) |pattern| { + if (std.mem.eql(u8, component, pattern)) { + return true; + } + } + } + return false; +} + +fn pickerTheme(app_config: *const config.Config) picker.ResolvedTheme { + assert(app_config.max_depth <= max_supported_depth); + return picker.resolveTheme(input_output.runtime(), app_config.*); +} + +fn printPath(path: []const u8) !void { + assert(path.len > 0); + var output_buffer: [4096]u8 = undefined; + var out = input_output.getStdout(&output_buffer); + try out.print("{s}\n", .{path}); + try out.end(); +} + +pub fn recentDirectory( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, +) !void { + const managed_directories = try history.recentDirectories(); + defer managed_directories.deinit(); + const dirs = managed_directories.value; + + if (dirs.len == 0) { + input_output.warn("No recent directories found.\n", .{}); + return; + } + assert(dirs.len > 0); + + var items = std.ArrayListUnmanaged([]const u8).empty; + defer items.deinit(allocator); + + for (dirs) |directory| { + if (shouldSkip(directory.path, app_config.ignored_patterns)) { + continue; + } + try items.append(allocator, directory.path); + } + + const theme = pickerTheme(app_config); + const selected = try picker.pickFromList(allocator, items.items, theme); + defer allocator.free(selected); + assert(selected.len > 0); + + std.Io.Dir.accessAbsolute(input_output.runtime(), selected, .{ + .follow_symlinks = true, + .read = false, + .write = false, + .execute = false, + }) catch |err| { + input_output.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err }); + return error.UserAbort; + }; + + try printPath(selected); +} + +pub fn favorites( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, +) !void { + if (app_config.favorites.len == 0) { + input_output.warn("No favorites found in config.\n", .{}); + return; + } + assert(app_config.favorites.len > 0); + + var items = std.ArrayListUnmanaged([]const u8).empty; + defer { + for (items.items) |item| allocator.free(item); + items.deinit(allocator); + } + + for (app_config.favorites) |entry| { + const expanded = try expandPath(allocator, entry); + try items.append(allocator, expanded); + } + assert(items.items.len == app_config.favorites.len); + + const theme = pickerTheme(app_config); + const selected = try picker.pickFromList(allocator, items.items, theme); + defer allocator.free(selected); + assert(selected.len > 0); + + const is_dir = isDirectory(selected); + + if (is_dir) { + try history.logDirectory(selected); + try printPath(selected); + return; + } + try gotoFile(history, selected, null, app_config); +} + +fn isDirectory(path: []const u8) bool { + assert(path.len > 0); + var directory = std.Io.Dir.openDirAbsolute(input_output.runtime(), path, .{ + .access_sub_paths = true, + .iterate = false, + .follow_symlinks = true, + }) catch return false; + defer directory.close(input_output.runtime()); + return true; +} + +fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 { + assert(path.len > 0); + var result = std.ArrayListUnmanaged(u8).empty; + errdefer result.deinit(allocator); + + const home = input_output.getenv("HOME") orelse return error.HomeNotFound; + + var index: usize = 0; + while (index < path.len) { + assert(index < path.len); + if (path[index] == '~') { + if (isTildeExpansion(path, index)) { + try result.appendSlice(allocator, home); + } else { + try result.append(allocator, '~'); + } + index += 1; + continue; + } + if (std.mem.startsWith(u8, path[index..], "$HOME")) { + try result.appendSlice(allocator, home); + index += "$HOME".len; + continue; + } + try result.append(allocator, path[index]); + index += 1; + } + + return result.toOwnedSlice(allocator); +} + +fn isTildeExpansion(path: []const u8, index: usize) bool { + assert(index < path.len); + assert(path[index] == '~'); + if (index == 0) { + return true; + } + if (path[index - 1] == std.fs.path.sep) { + return true; + } + return false; +} + +pub fn recentFile( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, +) !void { + const managed_files = try history.recentFiles(); + defer managed_files.deinit(); + const files = managed_files.value; + + if (files.len == 0) { + input_output.warn("No recent files found.\n", .{}); + return; + } + assert(files.len > 0); + + var items = std.ArrayListUnmanaged([]const u8).empty; + defer items.deinit(allocator); + + for (files) |file| { + if (shouldSkip(file.path, app_config.ignored_patterns)) { + continue; + } + try items.append(allocator, file.path); + } + + const theme = pickerTheme(app_config); + const selected = try picker.pickFromList(allocator, items.items, theme); + defer allocator.free(selected); + assert(selected.len > 0); + + try std.Io.Dir.accessAbsolute(input_output.runtime(), selected, .{ + .follow_symlinks = true, + .read = false, + .write = false, + .execute = false, + }); + + try gotoFile(history, selected, null, app_config); +} + +pub fn grep( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, +) !void { + assert(history.base_path.len > 0); + const theme = pickerTheme(app_config); + const selected = try picker.pickGrepLine(allocator, app_config.ignored_patterns, theme); + defer allocator.free(selected); + assert(selected.len > 0); + + const target = splitGrepSelection(selected); + try gotoFile(history, target.file, target.line, app_config); +} + +const GrepSelection = struct { + file: []const u8, + line: ?[]const u8, +}; + +fn splitGrepSelection(selected: []const u8) GrepSelection { + assert(selected.len > 0); + var fields = std.mem.splitScalar(u8, selected, ':'); + if (fields.next()) |file| { + const target = GrepSelection{ .file = file, .line = fields.next() }; + assert(target.file.ptr == selected.ptr); + return target; + } + unreachable; +} + +pub fn lookFile( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, + depth: ?usize, +) !void { + try runFzfWithWalker(allocator, history, .file, app_config, depth); +} + +pub fn lookDirectory( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, + depth: ?usize, +) !void { + try runFzfWithWalker(allocator, history, .directory, app_config, depth); +} + +const WalkItem = struct { + path: []const u8, + kind: std.Io.File.Kind, + stat_kind: ?std.Io.File.Kind, + mtime_ns: i128, +}; + +const TreeWalker = struct { + input_output_handle: std.Io, + allocator: std.mem.Allocator, + ignored_patterns: []const []const u8, + max_depth: usize, + stack: std.ArrayListUnmanaged(Frame) = .empty, + + const Frame = struct { + directory: std.Io.Dir, + prefix: ?[]const u8, + depth: usize, + iterator: std.Io.Dir.Iterator, + }; + + fn init( + input_output_handle: std.Io, + allocator: std.mem.Allocator, + root: std.Io.Dir, + ignored_patterns: []const []const u8, + max_depth: usize, + ) !TreeWalker { + assert(max_depth <= max_supported_depth); + var self = TreeWalker{ + .input_output_handle = input_output_handle, + .allocator = allocator, + .ignored_patterns = ignored_patterns, + .max_depth = max_depth, + }; + errdefer self.deinit(); + errdefer root.close(input_output_handle); + try self.stack.append(allocator, .{ + .directory = root, + .prefix = null, + .depth = 0, + .iterator = root.iterate(), + }); + assert(self.stack.items.len == 1); + assert(self.stack.capacity >= 1); + return self; + } + + fn deinit(self: *TreeWalker) void { + while (self.stack.items.len > 0) { + self.popFrame(); + } + assert(self.stack.items.len == 0); + self.stack.deinit(self.allocator); + } + + fn popFrame(self: *TreeWalker) void { + assert(self.stack.items.len > 0); + const top_index = self.stack.items.len - 1; + assert(top_index < self.stack.items.len); + const frame = self.stack.items[top_index]; + self.stack.items = self.stack.items[0..top_index]; + frame.directory.close(self.input_output_handle); + if (frame.prefix) |prefix| { + self.allocator.free(prefix); + } + } + + fn next(self: *TreeWalker) !?WalkItem { + while (self.stack.items.len > 0) { + assert(self.stack.items.len <= self.max_depth + 1); + const top_index = self.stack.items.len - 1; + assert(top_index < self.stack.items.len); + const maybe_entry = try self.stack.items[top_index].iterator.next( + self.input_output_handle, + ); + if (maybe_entry == null) { + self.popFrame(); + continue; + } + const entry = maybe_entry.?; + assert(entry.name.len > 0); + if (try self.descendOrEmit(top_index, entry)) |item| { + return item; + } + } + return null; + } + + fn descendOrEmit(self: *TreeWalker, top_index: usize, entry: std.Io.Dir.Entry) !?WalkItem { + assert(top_index < self.stack.items.len); + assert(entry.name.len > 0); + const parent = &self.stack.items[top_index]; + const entry_path = if (parent.prefix) |prefix| + try std.fs.path.join(self.allocator, &.{ prefix, entry.name }) + else + try self.allocator.dupe(u8, entry.name); + errdefer self.allocator.free(entry_path); + + if (shouldSkip(entry_path, self.ignored_patterns)) { + self.allocator.free(entry_path); + return null; + } + + const stat = parent.directory.statFile( + self.input_output_handle, + entry.name, + .{ .follow_symlinks = true }, + ) catch |err| { + self.allocator.free(entry_path); + if (err == error.FileNotFound) { + return null; + } + if (err == error.AccessDenied) { + return null; + } + input_output.warn("failed to stat {s}: {}\n", .{ entry.name, err }); + return null; + }; + + if (entry.kind == .directory) { + if (parent.depth + 1 < self.max_depth) { + self.pushFrame(top_index, entry.name, entry_path) catch |err| { + input_output.warn("failed to open directory {s}: {}\n", .{ entry.name, err }); + }; + } + } + assert(self.stack.items.len <= self.max_depth + 1); + assert(entry_path.len >= entry.name.len); + return .{ + .path = entry_path, + .kind = entry.kind, + .stat_kind = stat.kind, + .mtime_ns = stat.mtime.nanoseconds, + }; + } + + fn pushFrame( + self: *TreeWalker, + parent_index: usize, + name: []const u8, + prefix_source: []const u8, + ) !void { + assert(parent_index < self.stack.items.len); + assert(name.len > 0); + const parent = &self.stack.items[parent_index]; + assert(parent.depth + 1 < self.max_depth); + var sub_dir = try parent.directory.openDir(self.input_output_handle, name, .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); + errdefer sub_dir.close(self.input_output_handle); + const owned_prefix = try self.allocator.dupe(u8, prefix_source); + errdefer self.allocator.free(owned_prefix); + try self.stack.append(self.allocator, .{ + .directory = sub_dir, + .prefix = owned_prefix, + .depth = parent.depth + 1, + .iterator = sub_dir.iterate(), + }); + assert(self.stack.items.len <= self.max_depth + 1); + const top_frame = &self.stack.items[self.stack.items.len - 1]; + assert(top_frame.prefix != null); + if (top_frame.prefix) |prefix| { + assert(std.mem.eql(u8, prefix, prefix_source)); + } + } +}; + +fn collectWalkEntries( + input_output_handle: std.Io, + allocator: std.mem.Allocator, + root: std.Io.Dir, + kind: std.Io.File.Kind, + ignored_patterns: []const []const u8, + max_depth: usize, + entries: *std.ArrayListUnmanaged(Entry), +) !void { + assert(max_depth <= max_supported_depth); + assert(entries.items.len == 0); + var walker = try TreeWalker.init( + input_output_handle, + allocator, + root, + ignored_patterns, + max_depth, + ); + defer walker.deinit(); + while (try walker.next()) |item| { + defer allocator.free(item.path); + assert(item.path.len > 0); + if (item.stat_kind == null) { + continue; + } + if (item.kind == kind) { + try appendWalkEntry(allocator, entries, item); + continue; + } + if (item.kind == .sym_link) { + if (item.stat_kind == kind) { + try appendWalkEntry(allocator, entries, item); + } + } + } +} + +fn appendWalkEntry( + allocator: std.mem.Allocator, + entries: *std.ArrayListUnmanaged(Entry), + item: WalkItem, +) !void { + assert(item.path.len > 0); + assert(item.stat_kind != null); + const owned_path = try allocator.dupe(u8, item.path); + assert(owned_path.len == item.path.len); + try entries.append(allocator, .{ + .path = owned_path, + .mtime_ns = item.mtime_ns, + }); + assert(entries.items[entries.items.len - 1].path.len == item.path.len); +} + +fn measureMaxDirectoryMtimeNs( + input_output_handle: std.Io, + allocator: std.mem.Allocator, + root: std.Io.Dir, + ignored_patterns: []const []const u8, + max_depth: usize, +) !i128 { + assert(max_depth <= max_supported_depth); + var max_mtime_ns: i128 = 0; + + if (root.stat(input_output_handle)) |root_stat| { + max_mtime_ns = root_stat.mtime.nanoseconds; + } else |err| { + input_output.warn("failed to stat walk root for cache key: {}\n", .{err}); + } + + var walker = try TreeWalker.init( + input_output_handle, + allocator, + root, + ignored_patterns, + max_depth, + ); + defer walker.deinit(); + while (try walker.next()) |item| { + defer allocator.free(item.path); + assert(item.path.len > 0); + if (item.stat_kind == null) { + continue; + } + if (item.stat_kind == .directory) { + if (item.mtime_ns > max_mtime_ns) { + max_mtime_ns = item.mtime_ns; + } + } + } + return max_mtime_ns; +} + +fn isSafeCachedPath(path: []const u8) bool { + if (path.len == 0) { + return false; + } + if (std.mem.indexOfScalar(u8, path, 0) != null) { + return false; + } + if (std.fs.path.isAbsolute(path)) { + return false; + } + var components = std.mem.splitScalar(u8, path, std.fs.path.sep); + while (components.next()) |component| { + if (std.mem.eql(u8, component, "..")) { + return false; + } + } + return true; +} + +const WalkCache = struct { + allocator: std.mem.Allocator, + client: ?cache.Memcached, + key: ?[]const u8 = null, + + fn load( + self: *WalkCache, + app_config: *const config.Config, + searching_directories: bool, + max_depth: usize, + entries: *std.ArrayListUnmanaged(Entry), + ) !bool { + assert(max_depth <= max_supported_depth); + const client = if (self.client) |*cache_client| cache_client else return false; + self.key = try computeWalkCacheKey( + self.allocator, + app_config, + searching_directories, + max_depth, + ); + const key = self.key orelse return false; + assert(key.len > 0); + return try readValidatedWalkEntries(self.allocator, client, key, entries); + } + + fn computeWalkCacheKey( + allocator: std.mem.Allocator, + app_config: *const config.Config, + searching_directories: bool, + max_depth: usize, + ) !?[]const u8 { + assert(max_depth <= max_supported_depth); + const current_directory = std.process.currentPathAlloc( + input_output.runtime(), + allocator, + ) catch |err| { + input_output.warn("failed to get working directory for cache: {}\n", .{err}); + return null; + }; + defer allocator.free(current_directory); + + const hash = std.hash.Crc32.hash(current_directory); + const walk_root = try openWalkRoot(); + const max_directory_mtime_ns = measureMaxDirectoryMtimeNs( + input_output.runtime(), + allocator, + walk_root, + app_config.ignored_patterns, + max_depth, + ) catch |err| fallback: { + input_output.warn("failed to measure directory times for cache key: {}\n", .{err}); + break :fallback 0; + }; + const mtime_seconds = @divTrunc(max_directory_mtime_ns, std.time.ns_per_s); + const kind_name: []const u8 = if (searching_directories) "directory" else "file"; + const key = try std.fmt.allocPrint( + allocator, + "magdalena:look:{s}:{x}:{d}:{d}", + .{ kind_name, hash, mtime_seconds, max_depth }, + ); + assert(key.len > 0); + return key; + } + + fn readValidatedWalkEntries( + allocator: std.mem.Allocator, + client: *cache.Memcached, + key: []const u8, + entries: *std.ArrayListUnmanaged(Entry), + ) !bool { + assert(key.len > 0); + const maybe_data = client.get(key) catch |err| { + input_output.warn("ignoring cache read failure: {}\n", .{err}); + return false; + }; + const cached_data = maybe_data orelse return false; + defer allocator.free(cached_data); + + const maybe_parsed: ?std.json.Parsed([]Entry) = std.json.parseFromSlice( + []Entry, + allocator, + cached_data, + .{ + .allocate = .alloc_always, + .ignore_unknown_fields = false, + .duplicate_field_behavior = .@"error", + .max_value_len = cache.Memcached.max_value_bytes, + }, + ) catch |err| fallback: { + input_output.warn("ignoring corrupt cache entry: {}\n", .{err}); + break :fallback null; + }; + const parsed = maybe_parsed orelse return false; + defer parsed.deinit(); + + var valid_count: usize = 0; + for (parsed.value) |cached_entry| { + if (!isSafeCachedPath(cached_entry.path)) { + continue; + } + try entries.append(allocator, .{ + .path = try allocator.dupe(u8, cached_entry.path), + .mtime_ns = cached_entry.mtime_ns, + }); + valid_count += 1; + } + assert(valid_count <= parsed.value.len); + if (valid_count == 0) { + if (parsed.value.len == 0) { + return true; + } + return false; + } + return true; + } + + const walk_cache_ttl_seconds: u32 = 3600; + + fn store(self: *WalkCache, entries: []const Entry) !void { + if (self.client == null) { + return; + } + const client = &self.client.?; + const key = self.key orelse return; + assert(key.len > 0); + const json_data = try std.json.Stringify.valueAlloc(self.allocator, entries, .{}); + defer self.allocator.free(json_data); + assert(json_data.len > 0); + client.set(key, json_data, walk_cache_ttl_seconds) catch |err| { + input_output.warn("failed to write cache: {}\n", .{err}); + }; + } + + fn deinit(self: *WalkCache) void { + if (self.client) |*client| { + client.deinit(); + } + if (self.key) |key| { + self.allocator.free(key); + } + } +}; + +fn connectCache(allocator: std.mem.Allocator) ?cache.Memcached { + if (cache.Memcached.init(allocator, "127.0.0.1", 11211)) |client| { + return client; + } else |err| { + if (err != error.ConnectionRefused) { + input_output.warn("failed to initialize memcached: {}\n", .{err}); + } + return null; + } +} + +fn openWalkRoot() !std.Io.Dir { + return std.Io.Dir.cwd().openDir(input_output.runtime(), ".", .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); +} + +fn runFzfWithWalker( + allocator: std.mem.Allocator, + history: *database.Database, + kind: std.Io.File.Kind, + app_config: *const config.Config, + depth: ?usize, +) !void { + const searching_directories = kind == .directory; + const max_depth = depth orelse app_config.max_depth; + if (max_depth > max_supported_depth) { + return error.DepthTooLarge; + } + assert(max_depth <= max_supported_depth); + + var entries = std.ArrayListUnmanaged(Entry).empty; + assert(entries.items.len == 0); + defer { + for (entries.items) |entry| allocator.free(entry.path); + entries.deinit(allocator); + } + + var walk_cache = WalkCache{ + .allocator = allocator, + .client = connectCache(allocator), + }; + defer walk_cache.deinit(); + + const cache_hit = try walk_cache.load(app_config, searching_directories, max_depth, &entries); + if (!cache_hit) { + const root = try openWalkRoot(); + try collectWalkEntries( + input_output.runtime(), + allocator, + root, + kind, + app_config.ignored_patterns, + max_depth, + &entries, + ); + try walk_cache.store(entries.items); + } + + walk_cache.deinit(); + walk_cache.client = null; + walk_cache.key = null; + + try sortPickAndOpen(allocator, history, app_config, searching_directories, &entries); +} + +fn compareEntryMtimeDesc(_: void, left: Entry, right: Entry) bool { + assert(left.path.len > 0); + assert(right.path.len > 0); + if (left.mtime_ns != right.mtime_ns) { + return left.mtime_ns > right.mtime_ns; + } + return std.mem.order(u8, left.path, right.path) == .lt; +} + +fn sortPickAndOpen( + allocator: std.mem.Allocator, + history: *database.Database, + app_config: *const config.Config, + searching_directories: bool, + entries: *std.ArrayListUnmanaged(Entry), +) !void { + std.mem.sortUnstable(Entry, entries.items, {}, compareEntryMtimeDesc); + + var items = std.ArrayListUnmanaged([]const u8).empty; + defer items.deinit(allocator); + for (entries.items) |entry| { + assert(entry.path.len > 0); + try items.append(allocator, entry.path); + } + assert(items.items.len == entries.items.len); + + const theme = pickerTheme(app_config); + const selected = try picker.pickFromList(allocator, items.items, theme); + defer allocator.free(selected); + assert(selected.len > 0); + + var clean_path = selected; + if (std.mem.startsWith(u8, selected, "./")) { + clean_path = selected["./".len..]; + } + if (clean_path.len == 0) { + return; + } + std.Io.Dir.cwd().access(input_output.runtime(), clean_path, .{ + .follow_symlinks = true, + .read = false, + .write = false, + .execute = false, + }) catch |err| { + input_output.warn("Selected item no longer exists or is inaccessible:" ++ + " {s} ({})\n", .{ clean_path, err }); + return error.UserAbort; + }; + if (searching_directories) { + try printPath(clean_path); + return; + } + try gotoFile(history, clean_path, null, app_config); +} + +const testing = std.testing; + +test "shouldSkip: matches an exact path component" { + const ignored = [_][]const u8{ "node_modules", ".git" }; + try testing.expect(shouldSkip("src/node_modules/pkg/x.js", &ignored)); + try testing.expect(shouldSkip(".git/config", &ignored)); + try testing.expect(shouldSkip("a/b/.git", &ignored)); +} + +test "shouldSkip: partial component is not a match" { + const ignored = [_][]const u8{"node_modules"}; + try testing.expect(!shouldSkip("src/lib/main.zig", &ignored)); + try testing.expect(!shouldSkip("src/node_modules_old/x", &ignored)); +} + +test "shouldSkip: empty pattern set never skips" { + try testing.expect(!shouldSkip("a/b/c", &.{})); +} + +test "isTildeExpansion: start and post-separator tildes expand" { + try testing.expect(isTildeExpansion("~/x", 0)); + try testing.expect(isTildeExpansion("a/~/x", 2)); +} + +test "isTildeExpansion: mid-component tildes stay literal" { + try testing.expect(!isTildeExpansion("a~b", 1)); + try testing.expect(!isTildeExpansion("a/b~c", 3)); +} + +test "isSafeCachedPath: accepts plain relative paths" { + try testing.expect(isSafeCachedPath("src/main.zig")); + try testing.expect(isSafeCachedPath("./a/b")); +} + +test "isSafeCachedPath: rejects escapes and junk" { + try testing.expect(!isSafeCachedPath("")); + try testing.expect(!isSafeCachedPath("/etc/passwd")); + try testing.expect(!isSafeCachedPath("a/../../etc/passwd")); + try testing.expect(!isSafeCachedPath("..")); + try testing.expect(!isSafeCachedPath("a\x00b")); +} + +test "matchOpener: openers with empty command or action are skipped" { + const openers = [_]config.Config.Opener{ + .{ .extensions = &.{"zig"}, .action = "", .command = "nvim" }, + .{ .extensions = &.{"zig"}, .action = "text_editor", .command = "" }, + .{ .extensions = &.{"zig"}, .action = "text_editor", .command = "nvim" }, + }; + const app_config = config.Config{ .openers = @constCast(&openers) }; + try testing.expectEqualStrings("nvim", matchOpener(&app_config, "zig").command.?); +} + +test "gotoFile: empty path is an error, not a crash" { + const app_config = config.Config{}; + const result = gotoFile(null, "", null, &app_config); + try testing.expectError(error.EmptyPath, result); +} + +test "matchOpener: first matching extension wins" { + const openers = [_]config.Config.Opener{ + .{ .extensions = &.{ "mp4", "mkv" }, .action = "media_player", .command = "mpv" }, + .{ .extensions = &.{"zig"}, .action = "text_editor", .command = "nvim" }, + }; + const app_config = config.Config{ .openers = @constCast(&openers) }; + try testing.expectEqualStrings("mpv", matchOpener(&app_config, "mkv").command.?); + try testing.expectEqualStrings("text_editor", matchOpener(&app_config, "txt").action); + try testing.expect(matchOpener(&app_config, "txt").command == null); + try testing.expectEqualStrings("text_editor", matchOpener(&app_config, "").action); +} + +test "splitGrepSelection: file with line and column" { + const target = splitGrepSelection("src/main.zig:12:4:needle"); + try testing.expectEqualStrings("src/main.zig", target.file); + try testing.expectEqualStrings("12", target.line.?); +} + +test "splitGrepSelection: bare filename has no line" { + const target = splitGrepSelection("README.md"); + try testing.expectEqualStrings("README.md", target.file); + try testing.expect(target.line == null); +} + +test "TreeWalker: collects nested entries without recursion" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + + try temp_directory.dir.createDir(test_input_output, "sub", .fromMode(0o755)); + try temp_directory.dir.createDir(test_input_output, "sub/deep", .fromMode(0o755)); + try writeTestFile(&temp_directory.dir, test_input_output, "top.txt", "top"); + try writeTestFile(&temp_directory.dir, test_input_output, "sub/mid.txt", "mid"); + try writeTestFile(&temp_directory.dir, test_input_output, "sub/deep/bottom.txt", "bottom"); + + const root = try temp_directory.dir.openDir(test_input_output, ".", .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); + var entries = std.ArrayListUnmanaged(Entry).empty; + defer { + for (entries.items) |entry| testing.allocator.free(entry.path); + entries.deinit(testing.allocator); + } + try collectWalkEntries(test_input_output, testing.allocator, root, .file, &.{}, 10, &entries); + try testing.expectEqual(@as(usize, 3), entries.items.len); + + var found_top = false; + var found_mid = false; + var found_bottom = false; + for (entries.items) |entry| { + if (std.mem.eql(u8, entry.path, "top.txt")) found_top = true; + if (std.mem.eql(u8, entry.path, "sub/mid.txt")) found_mid = true; + if (std.mem.eql(u8, entry.path, "sub/deep/bottom.txt")) found_bottom = true; + } + try testing.expect(found_top); + try testing.expect(found_mid); + try testing.expect(found_bottom); +} + +test "TreeWalker: ignored components are pruned, not descended" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + + try temp_directory.dir.createDir(test_input_output, "node_modules", .fromMode(0o755)); + try writeTestFile(&temp_directory.dir, test_input_output, "keep.txt", "keep"); + try writeTestFile(&temp_directory.dir, test_input_output, "node_modules/hidden.txt", "hidden"); + + const ignored = [_][]const u8{"node_modules"}; + const root = try temp_directory.dir.openDir(test_input_output, ".", .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); + var entries = std.ArrayListUnmanaged(Entry).empty; + defer { + for (entries.items) |entry| testing.allocator.free(entry.path); + entries.deinit(testing.allocator); + } + try collectWalkEntries( + test_input_output, + testing.allocator, + root, + .file, + &ignored, + 10, + &entries, + ); + try testing.expectEqual(@as(usize, 1), entries.items.len); + try testing.expectEqualStrings("keep.txt", entries.items[0].path); +} + +test "TreeWalker: max depth bounds descent" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + + try temp_directory.dir.createDir(test_input_output, "sub", .fromMode(0o755)); + try writeTestFile(&temp_directory.dir, test_input_output, "top.txt", "top"); + try writeTestFile(&temp_directory.dir, test_input_output, "sub/mid.txt", "mid"); + + const root = try temp_directory.dir.openDir(test_input_output, ".", .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); + var entries = std.ArrayListUnmanaged(Entry).empty; + defer { + for (entries.items) |entry| testing.allocator.free(entry.path); + entries.deinit(testing.allocator); + } + try collectWalkEntries(test_input_output, testing.allocator, root, .file, &.{}, 1, &entries); + try testing.expectEqual(@as(usize, 1), entries.items.len); + try testing.expectEqualStrings("top.txt", entries.items[0].path); +} + +test "TreeWalker: empty directories yield no entries" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + + const root = try temp_directory.dir.openDir(test_input_output, ".", .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); + var entries = std.ArrayListUnmanaged(Entry).empty; + defer entries.deinit(testing.allocator); + try collectWalkEntries(test_input_output, testing.allocator, root, .file, &.{}, 10, &entries); + try testing.expectEqual(@as(usize, 0), entries.items.len); +} + +test "measureMaxDirectoryMtimeNs: sees newly added files" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + + const root_before = try temp_directory.dir.openDir(test_input_output, ".", .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); + const before = try measureMaxDirectoryMtimeNs( + test_input_output, + testing.allocator, + root_before, + &.{}, + 10, + ); + + try writeTestFile(&temp_directory.dir, test_input_output, "new.txt", "new"); + + const root_after = try temp_directory.dir.openDir(test_input_output, ".", .{ + .access_sub_paths = true, + .iterate = true, + .follow_symlinks = true, + }); + const after = try measureMaxDirectoryMtimeNs( + test_input_output, + testing.allocator, + root_after, + &.{}, + 10, + ); + try testing.expect(after >= before); +} + +test "runFzfWithWalker: rejects depths above the supported maximum" { + var test_history = database.Database{ + .allocator = testing.allocator, + .base_path = "", + .directories_path = "", + .files_path = "", + }; + const app_config = config.Config{}; + const too_deep = max_supported_depth + 1; + try testing.expectError( + error.DepthTooLarge, + runFzfWithWalker(testing.allocator, &test_history, .file, &app_config, too_deep), + ); +} + +fn writeTestFile( + directory: *std.Io.Dir, + input_output_handle: std.Io, + path: []const u8, + content: []const u8, +) !void { + assert(path.len > 0); + assert(content.len > 0); + var file = try directory.createFile(input_output_handle, path, .{ + .read = false, + .truncate = true, + .exclusive = false, + .lock = .none, + .lock_nonblocking = false, + .permissions = .fromMode(0o644), + .resolve_beneath = false, + }); + defer file.close(input_output_handle); + var buffer: [4096]u8 = undefined; + var writer = file.writer(input_output_handle, &buffer); + try writer.interface.writeAll(content); + try writer.interface.flush(); +} blob - /dev/null blob + bc8a351215f71092aea30b412f2fd5fa2f31d6b7 (mode 644) --- /dev/null +++ src/picker.zig @@ -0,0 +1,2395 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const assert = std.debug.assert; +const fuzzig = @import("fuzzig"); +const vaxis = @import("vaxis"); +const vxfw = vaxis.vxfw; +const config = @import("config.zig"); +const input_output = @import("input_output.zig"); + +pub const max_haystack_len: usize = 1024; +pub const max_needle_len: usize = 256; +pub const max_grep_lines: usize = 20000; +pub const max_grep_stdout_bytes: usize = 32 * 1024 * 1024; +pub const max_excerpt_bytes: usize = 64 * 1024; +const min_terminal_columns: u16 = 20; +const min_terminal_rows: u16 = 8; +pub const excerpt_context_before: usize = 5; +pub const excerpt_context_after: usize = 15; + +comptime { + assert(max_haystack_len > 0); + assert(max_needle_len > 0); + assert(max_grep_lines > 0); + assert(max_grep_stdout_bytes > 0); + assert(max_excerpt_bytes > 0); +} + +const FuzzyScorer = struct { + sensitive: fuzzig.Ascii, + insensitive: fuzzig.Ascii, + haystack_max: usize, + + fn initScorer( + allocator: std.mem.Allocator, + haystack_max: usize, + case_sensitive: bool, + ) !fuzzig.Ascii { + assert(haystack_max > 0); + assert(haystack_max <= max_haystack_len); + return try fuzzig.Ascii.init(allocator, haystack_max, max_needle_len, .{ + .case_sensitive = case_sensitive, + }); + } + + fn init(allocator: std.mem.Allocator, longest_haystack: usize) !FuzzyScorer { + assert(longest_haystack > 0); + const haystack_max = @min(longest_haystack, max_haystack_len); + assert(haystack_max > 0); + var sensitive = try initScorer(allocator, haystack_max, true); + errdefer sensitive.deinit(); + var insensitive = try initScorer(allocator, haystack_max, false); + errdefer insensitive.deinit(); + return .{ + .sensitive = sensitive, + .insensitive = insensitive, + .haystack_max = haystack_max, + }; + } + + fn deinit(self: *FuzzyScorer) void { + self.sensitive.deinit(); + self.insensitive.deinit(); + } + + fn clampHaystack(self: *const FuzzyScorer, text: []const u8) struct { + slice: []const u8, + offset: usize, + } { + assert(text.len > 0); + if (text.len <= self.haystack_max) { + return .{ .slice = text, .offset = 0 }; + } + const offset = text.len - self.haystack_max; + assert(offset < text.len); + return .{ .slice = text[offset..], .offset = offset }; + } + + fn clampNeedle(needle: []const u8) []const u8 { + if (needle.len <= max_needle_len) { + return needle; + } + const clamped = needle[0..max_needle_len]; + assert(clamped.len == max_needle_len); + return clamped; + } + + fn slashNeedle(arena: std.mem.Allocator, needle: []const u8) !?[]const u8 { + assert(needle.len > 0); + if (std.mem.indexOfScalar(u8, needle, ' ') == null) { + return null; + } + const copy = try arena.dupe(u8, needle); + assert(copy.len == needle.len); + std.mem.replaceScalar(u8, copy, ' ', '/'); + return copy; + } + + fn scorerFor(self: *FuzzyScorer, query: []const u8) *fuzzig.Ascii { + const scorer = if (hasUppercase(query)) &self.sensitive else &self.insensitive; + if (scorer == &self.sensitive) { + return scorer; + } + assert(scorer == &self.insensitive); + return scorer; + } +}; + +fn hasUppercase(text: []const u8) bool { + for (text) |byte| { + if (std.ascii.isUpper(byte)) { + return true; + } + } + return false; +} + +fn checkTerminalSize(input_output_handle: std.Io) !void { + if (input_output.getenv("TERM")) |term| { + if (std.mem.eql(u8, term, "dumb")) { + input_output.warn("dumb terminal cannot run picker\n", .{}); + return error.NoTerminal; + } + } + if (builtin.os.tag != .linux) { + return; + } + const tty = std.Io.Dir.openFileAbsolute(input_output_handle, "/dev/tty", .{ + .mode = .read_only, + .allow_directory = false, + .path_only = false, + }) catch |err| { + input_output.warn("cannot open terminal for picker: {}\n", .{err}); + return error.NoTerminal; + }; + defer tty.close(input_output_handle); + var size: std.posix.winsize = .{ .row = 0, .col = 0, .xpixel = 0, .ypixel = 0 }; + const ioctl_rc = std.os.linux.ioctl(tty.handle, std.os.linux.T.IOCGWINSZ, @intFromPtr(&size)); + if (ioctl_rc != 0) { + input_output.warn("cannot query terminal size for picker: {d}\n", .{ioctl_rc}); + return error.NoTerminal; + } + if (size.col < min_terminal_columns) { + input_output.warn( + "terminal too narrow for picker: {d} columns, need {d}\n", + .{ size.col, min_terminal_columns }, + ); + return error.TerminalTooSmall; + } + if (size.row < min_terminal_rows) { + input_output.warn( + "terminal too short for picker: {d} rows, need {d}\n", + .{ size.row, min_terminal_rows }, + ); + return error.TerminalTooSmall; + } +} + +pub const ResolvedTheme = struct { + prompt: vaxis.Style, + match: vaxis.Style, + cursor_row: vaxis.Style, + status: vaxis.Style, + preview_current: vaxis.Style, +}; + +fn defaultTheme() ResolvedTheme { + return .{ + .prompt = .{ .fg = .{ .index = 4 } }, + .match = .{ .fg = .{ .index = 4 }, .reverse = true }, + .cursor_row = .{ .bg = .{ .index = 8 } }, + .status = .{ .fg = .{ .index = 8 } }, + .preview_current = .{ .fg = .{ .index = 4 } }, + }; +} + +comptime { + assert(defaultTheme().prompt.fg.index == 4); + assert(defaultTheme().match.reverse); + assert(defaultTheme().cursor_row.bg.index == 8); + assert(defaultTheme().status.fg.index == 8); + assert(defaultTheme().preview_current.fg.index == 4); +} + +pub fn parseThemeColor(text: []const u8) !vaxis.Color { + if (std.mem.eql(u8, text, "default")) { + return .default; + } + if (text.len == 0) { + return error.InvalidColor; + } + if (text[0] == '#') { + return parseHexColor(text); + } + const index = std.fmt.parseInt(u16, text, 10) catch { + return error.InvalidColor; + }; + if (index > 255) { + return error.InvalidColor; + } + assert(index <= 255); + return .{ .index = @intCast(index) }; +} + +fn parseHexColor(text: []const u8) !vaxis.Color { + assert(text.len > 0); + if (text[0] != '#') { + return error.InvalidColor; + } + if (text.len != 7) { + return error.InvalidColor; + } + var rgb: [3]u8 = undefined; + var channel: usize = 0; + while (channel < 3) { + assert(channel < 3); + const pair = text[1 + channel * 2 ..][0..2]; + assert(pair.len == 2); + rgb[channel] = std.fmt.parseUnsigned(u8, pair, 16) catch { + return error.InvalidColor; + }; + channel += 1; + } + assert(channel == 3); + return .{ .rgb = rgb }; +} + +fn noColorSet() bool { + const raw = std.c.getenv("NO_COLOR") orelse return false; + return std.mem.span(raw).len > 0; +} + +fn resolveRoleColor( + input_output_handle: std.Io, + role: []const u8, + maybe_text: ?[]const u8, + fallback: vaxis.Color, +) vaxis.Color { + assert(role.len > 0); + const text = maybe_text orelse return fallback; + const color = parseThemeColor(text) catch { + if (!builtin.is_test) { + warn(input_output_handle, "invalid theme color for '{s}': '{s}', using default\n", .{ + role, + text, + }); + } + return fallback; + }; + return color; +} + +pub fn resolveTheme(input_output_handle: std.Io, app_config: config.Config) ResolvedTheme { + if (noColorSet()) { + return .{ + .prompt = .{}, + .match = .{}, + .cursor_row = .{}, + .status = .{}, + .preview_current = .{}, + }; + } + const defaults = defaultTheme(); + const theme = app_config.theme; + const prompt_fg = resolveRoleColor( + input_output_handle, + "prompt", + theme.prompt, + defaults.prompt.fg, + ); + const match_fg = resolveRoleColor(input_output_handle, "match", theme.match, defaults.match.fg); + const cursor_bg = resolveRoleColor( + input_output_handle, + "cursor_row", + theme.cursor_row, + defaults.cursor_row.bg, + ); + const status_fg = resolveRoleColor( + input_output_handle, + "status", + theme.status, + defaults.status.fg, + ); + const marker_fg = resolveRoleColor( + input_output_handle, + "preview_current", + theme.preview_current, + defaults.preview_current.fg, + ); + return .{ + .prompt = .{ .fg = prompt_fg }, + .match = .{ .fg = match_fg, .reverse = true }, + .cursor_row = .{ .bg = cursor_bg }, + .status = .{ .fg = status_fg }, + .preview_current = .{ .fg = marker_fg }, + }; +} + +pub const RankedItem = struct { + index: usize, + score: i32, + tail_offset: usize, + matches: []const usize, +}; + +pub fn rankItems( + arena: std.mem.Allocator, + scorer: *FuzzyScorer, + items: []const []const u8, + query: []const u8, +) ![]RankedItem { + var ranked = std.ArrayListUnmanaged(RankedItem).empty; + const needle = FuzzyScorer.clampNeedle(query); + const slash_needle = if (needle.len > 0) + try FuzzyScorer.slashNeedle(arena, needle) + else + null; + const searcher = scorer.scorerFor(needle); + for (items, 0..) |item, index| { + assert(index < items.len); + if (item.len == 0) { + continue; + } + if (needle.len == 0) { + try ranked.append(arena, .{ + .index = index, + .score = 0, + .tail_offset = 0, + .matches = &.{}, + }); + continue; + } + const clamped = scorer.clampHaystack(item); + assert(clamped.slice.len <= scorer.haystack_max); + assert(clamped.slice.len > 0); + const direct = searcher.scoreMatches(clamped.slice, needle); + var best = direct; + if (slash_needle) |slash| { + const slashed = searcher.scoreMatches(clamped.slice, slash); + const use_slash = if (slashed.score) |slash_score| blk: { + const direct_score = direct.score orelse break :blk true; + break :blk slash_score >= direct_score; + } else false; + if (use_slash) { + best = slashed; + } + } + const score = best.score orelse continue; + const mapped = try arena.alloc(usize, best.matches.len); + for (best.matches, 0..) |position, match_index| { + assert(match_index < mapped.len); + assert(position < clamped.slice.len); + mapped[match_index] = position + clamped.offset; + } + try ranked.append(arena, .{ + .index = index, + .score = score, + .tail_offset = clamped.offset, + .matches = mapped, + }); + } + std.mem.sortUnstable(RankedItem, ranked.items, {}, compareRankedItem); + return ranked.toOwnedSlice(arena); +} + +fn compareRankedItem(_: void, left: RankedItem, right: RankedItem) bool { + if (left.score != right.score) { + return left.score > right.score; + } + return left.index < right.index; +} + +pub fn appendHighlightSpans( + spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan), + arena: std.mem.Allocator, + text: []const u8, + matches: []const usize, + match_style: vaxis.Style, +) !void { + assert(text.len > 0); + const start_len = spans.items.len; + var cursor: usize = 0; + var match_index: usize = 0; + while (match_index < matches.len) { + assert(match_index < matches.len); + const position = matches[match_index]; + assert(position < text.len); + var run_end = position + 1; + while (match_index + 1 < matches.len) { + if (matches[match_index + 1] != run_end) { + break; + } + match_index += 1; + run_end += 1; + } + assert(run_end <= text.len); + if (position > cursor) { + try appendDisplaySafeText(spans, arena, text[cursor..position], .{}); + } + try appendDisplaySafeText(spans, arena, text[position..run_end], match_style); + cursor = run_end; + match_index += 1; + } + if (cursor < text.len) { + try appendDisplaySafeText(spans, arena, text[cursor..], .{}); + } + assert(spans.items.len >= start_len); +} + +pub fn sanitizeDisplayText(allocator: std.mem.Allocator, text: []const u8) ![]u8 { + var out = std.ArrayListUnmanaged(u8).empty; + errdefer out.deinit(allocator); + const replacement = [_]u8{ 0xEF, 0xBF, 0xBD }; + var i: usize = 0; + var col: usize = 0; + while (i < text.len) { + const b = text[i]; + if (b == '\n') { + try out.append(allocator, '\n'); + i += 1; + col = 0; + continue; + } + if (b == '\t') { + const width = 8 - (col % 8); + assert(width >= 1 and width <= 8); + try out.appendNTimes(allocator, ' ', width); + col += width; + i += 1; + continue; + } + if (b == 0x1B) { + i = skipAnsiEscape(text, i); + continue; + } + if (b < 0x20 or b == 0x7F) { + try out.append(allocator, ' '); + col += 1; + i += 1; + continue; + } + if (b < 0x80) { + try out.append(allocator, b); + col += 1; + i += 1; + continue; + } + const seq_len = std.unicode.utf8ByteSequenceLength(b) catch { + try out.appendSlice(allocator, &replacement); + col += 1; + i += 1; + continue; + }; + if (i + seq_len > text.len) { + try out.appendSlice(allocator, &replacement); + col += 1; + i += 1; + continue; + } + var encoded: [4]u8 = undefined; + @memcpy(encoded[0..seq_len], text[i..][0..seq_len]); + const codepoint: u21 = switch (seq_len) { + 2 => std.unicode.utf8Decode2(encoded[0..2].*) catch { + try out.appendSlice(allocator, &replacement); + col += 1; + i += 1; + continue; + }, + 3 => std.unicode.utf8Decode3(encoded[0..3].*) catch { + try out.appendSlice(allocator, &replacement); + col += 1; + i += 1; + continue; + }, + 4 => std.unicode.utf8Decode4(encoded[0..4].*) catch { + try out.appendSlice(allocator, &replacement); + col += 1; + i += 1; + continue; + }, + else => unreachable, + }; + if (codepoint < 0x20 or (codepoint >= 0x7F and codepoint <= 0x9F)) { + try out.append(allocator, ' '); + } else { + try out.appendSlice(allocator, text[i..][0..seq_len]); + } + col += 1; + i += seq_len; + } + return out.toOwnedSlice(allocator); +} + +fn skipAnsiEscape(text: []const u8, index: usize) usize { + assert(text[index] == 0x1B); + var j = index + 1; + if (j >= text.len) { + return j; + } + const kind = text[j]; + if (kind == '[') { + j += 1; + while (j < text.len) : (j += 1) { + if (text[j] >= 0x40 and text[j] <= 0x7E) { + return j + 1; + } + } + return j; + } + if (kind == ']') { + j += 1; + while (j < text.len) : (j += 1) { + if (text[j] == 0x07) { + return j + 1; + } + if (text[j] == 0x1B and j + 1 < text.len and text[j + 1] == '\\') { + return j + 2; + } + } + return j; + } + if (kind == 'P' or kind == 'X' or kind == '^' or kind == '_') { + j += 1; + while (j < text.len) : (j += 1) { + if (text[j] == 0x1B and j + 1 < text.len and text[j + 1] == '\\') { + return j + 2; + } + } + return j; + } + return j + 1; +} + +pub fn appendDisplaySafeText( + spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan), + arena: std.mem.Allocator, + text: []const u8, + style: vaxis.Style, +) !void { + assert(text.len > 0); + const start_len = spans.items.len; + const clean = try sanitizeDisplayText(arena, text); + if (clean.len == 0) { + try spans.append(arena, .{ .text = " ", .style = style }); + } else { + try spans.append(arena, .{ .text = clean, .style = style }); + } + assert(spans.items.len > start_len); +} + +pub fn appendCursorSpans( + spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan), + arena: std.mem.Allocator, + source: []const vxfw.RichText.TextSpan, + cursor_row: vaxis.Style, +) !void { + assert(source.len > 0); + const start_len = spans.items.len; + for (source) |span| { + assert(span.text.len > 0); + var styled = span; + styled.style.bg = cursor_row.bg; + try spans.append(arena, styled); + } + assert(spans.items.len == start_len + source.len); +} + +pub fn appendPreviewSpans( + spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan), + arena: std.mem.Allocator, + excerpt: []const u8, + column_one_based: usize, + query_len: usize, + theme: ResolvedTheme, +) !void { + assert(excerpt.len > 0); + const start_len = spans.items.len; + var split = std.mem.splitScalar(u8, excerpt, '\n'); + while (split.next()) |line| { + if (line.len == 0) { + continue; + } + if (isTargetLine(line)) { + try appendTargetLineSpans(spans, arena, line, column_one_based, query_len, theme); + continue; + } + try appendDisplaySafeText(spans, arena, line, .{}); + try spans.append(arena, .{ .text = "\n" }); + } + assert(spans.items.len > start_len); +} + +fn isTargetLine(line: []const u8) bool { + assert(line.len > 0); + if (line.len < 2) { + return false; + } + if (line[0] != '>') { + return false; + } + if (line[1] != ' ') { + return false; + } + return true; +} + +const PreviewRegion = struct { + start: usize, + end: usize, +}; + +fn appendTargetLineSpans( + spans: *std.ArrayListUnmanaged(vxfw.RichText.TextSpan), + arena: std.mem.Allocator, + line: []const u8, + column_one_based: usize, + query_len: usize, + theme: ResolvedTheme, +) !void { + assert(line.len >= 2); + try spans.append(arena, .{ .text = line[0..2], .style = theme.preview_current }); + const content = line[2..]; + assert(content.len <= line.len); + const region = previewMatchRegion(content, column_one_based, query_len); + if (region) |match| { + assert(match.start <= content.len); + assert(match.end <= content.len); + if (match.start > 0) { + try appendDisplaySafeText(spans, arena, content[0..match.start], .{}); + } + try appendDisplaySafeText(spans, arena, content[match.start..match.end], theme.match); + if (match.end < content.len) { + try appendDisplaySafeText(spans, arena, content[match.end..], .{}); + } + try spans.append(arena, .{ .text = "\n" }); + return; + } + if (content.len > 0) { + try appendDisplaySafeText(spans, arena, content, .{}); + } + try spans.append(arena, .{ .text = "\n" }); +} + +fn previewMatchRegion( + content: []const u8, + column_one_based: usize, + query_len: usize, +) ?PreviewRegion { + if (column_one_based == 0) { + return null; + } + if (query_len == 0) { + return null; + } + if (content.len == 0) { + return null; + } + const start = column_one_based - 1; + if (start >= content.len) { + return null; + } + assert(start < content.len); + var end = start + query_len; + if (end > content.len) { + end = content.len; + } + assert(end <= content.len); + if (end <= start) { + return null; + } + return .{ .start = start, .end = end }; +} + +fn rowConstraints(ctx: vxfw.DrawContext, width: u16, height: u16) vxfw.DrawContext { + const constrained = ctx.withConstraints(ctx.min, .{ .width = width, .height = height }); + assert(constrained.max.width != null); + assert(constrained.max.height != null); + return constrained; +} + +const ListModel = struct { + allocator: std.mem.Allocator, + items: []const []const u8, + scorer: *FuzzyScorer, + theme: ResolvedTheme, + arena: std.heap.ArenaAllocator, + ranked: []RankedItem = &.{}, + filtered: std.ArrayListUnmanaged(vxfw.RichText) = .empty, + filtered_cursor: std.ArrayListUnmanaged(vxfw.RichText) = .empty, + list_view: vxfw.ListView, + text_field: vxfw.TextField, + selected: ?usize = null, + loaded: bool = false, + + fn init( + allocator: std.mem.Allocator, + items: []const []const u8, + scorer: *FuzzyScorer, + theme: ResolvedTheme, + ) !*ListModel { + assert(items.len > 0); + assert(items.len <= std.math.maxInt(u32)); + const self = try allocator.create(ListModel); + self.* = .{ + .allocator = allocator, + .items = items, + .scorer = scorer, + .theme = theme, + .arena = std.heap.ArenaAllocator.init(allocator), + .list_view = .{ + .children = .{ .builder = undefined }, + }, + .text_field = vxfw.TextField.init(allocator), + }; + self.list_view.children = .{ .builder = .{ + .userdata = self, + .buildFn = ListModel.widgetBuilder, + } }; + self.text_field.userdata = self; + self.text_field.onChange = ListModel.onChange; + self.text_field.onSubmit = ListModel.onSubmit; + assert(self.text_field.userdata != null); + self.list_view.item_count = 0; + return self; + } + + fn deinit(self: *ListModel) void { + const allocator = self.allocator; + self.arena.deinit(); + self.text_field.deinit(); + allocator.destroy(self); + } + + fn widget(self: *ListModel) vxfw.Widget { + const result: vxfw.Widget = .{ + .userdata = self, + .eventHandler = ListModel.typeErasedEventHandler, + .drawFn = ListModel.typeErasedDrawFn, + }; + assert(result.userdata == @as(*anyopaque, @ptrCast(self))); + return result; + } + + fn typeErasedEventHandler( + ptr: *anyopaque, + ctx: *vxfw.EventContext, + event: vxfw.Event, + ) anyerror!void { + const self: *ListModel = @ptrCast(@alignCast(ptr)); + switch (event) { + .init => { + try ctx.tick(0, self.widget()); + return ctx.requestFocus(self.text_field.widget()); + }, + .tick => { + if (!self.loaded) { + self.loaded = true; + const query = try self.text_field.buf.dupe(); + defer self.text_field.buf.allocator.free(query); + try self.refilter(query); + ctx.redraw = true; + } + return; + }, + .key_press => |key| { + if (key.matches(vaxis.Key.escape, .{})) { + ctx.quit = true; + assert(ctx.quit); + return; + } + if (key.matches('c', .{ .ctrl = true })) { + ctx.quit = true; + assert(ctx.quit); + return; + } + if (key.matches('d', .{ .ctrl = true })) { + ctx.quit = true; + assert(ctx.quit); + return; + } + if (key.matches(vaxis.Key.up, .{})) { + self.list_view.prevItem(ctx); + return; + } + if (key.matches('p', .{ .ctrl = true })) { + self.list_view.prevItem(ctx); + return; + } + if (key.matches(vaxis.Key.down, .{})) { + self.list_view.nextItem(ctx); + return; + } + if (key.matches('n', .{ .ctrl = true })) { + self.list_view.nextItem(ctx); + return; + } + return; + }, + .focus_in => { + return ctx.requestFocus(self.text_field.widget()); + }, + else => {}, + } + } + + fn typeErasedDrawFn( + ptr: *anyopaque, + ctx: vxfw.DrawContext, + ) std.mem.Allocator.Error!vxfw.Surface { + const self: *ListModel = @ptrCast(@alignCast(ptr)); + const max_size = ctx.max.size(); + assert(max_size.width > 0); + assert(max_size.height > 0); + + const match_count = self.filtered.items.len; + const total_count = self.items.len; + const count_text = try std.fmt.allocPrint( + ctx.arena, + "{d}/{d}", + .{ match_count, total_count }, + ); + const count: vxfw.Text = .{ .text = count_text, .style = self.theme.status }; + const prompt: vxfw.Text = .{ .text = ">", .style = self.theme.prompt }; + + const list_height = max_size.height -| 2; + const children = try ctx.arena.alloc(vxfw.SubSurface, 4); + children[0] = .{ + .origin = .{ .row = 0, .col = 0 }, + .surface = try prompt.draw(rowConstraints(ctx, 2, 1)), + }; + children[1] = .{ + .origin = .{ .row = 0, .col = 2 }, + .surface = try self.text_field.draw(rowConstraints(ctx, max_size.width -| 2, 1)), + }; + children[2] = .{ + .origin = .{ .row = 1, .col = 2 }, + .surface = try count.draw(rowConstraints(ctx, max_size.width -| 2, 1)), + }; + children[3] = .{ + .origin = .{ .row = 2, .col = 0 }, + .surface = try self.list_view.draw(rowConstraints(ctx, max_size.width, list_height)), + }; + + return .{ + .size = max_size, + .widget = self.widget(), + .buffer = &.{}, + .children = children, + }; + } + + fn widgetBuilder(ptr: *const anyopaque, index: usize, cursor: usize) ?vxfw.Widget { + const self: *const ListModel = @ptrCast(@alignCast(ptr)); + if (index >= self.filtered.items.len) { + return null; + } + assert(index < self.filtered.items.len); + assert(self.filtered_cursor.items.len == self.filtered.items.len); + if (index == cursor) { + return self.filtered_cursor.items[index].widget(); + } + return self.filtered.items[index].widget(); + } + + fn onChange(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, query: []const u8) anyerror!void { + const ptr = maybe_ptr orelse return; + const self: *ListModel = @ptrCast(@alignCast(ptr)); + try self.refilter(query); + } + + fn refilter(self: *ListModel, query: []const u8) !void { + _ = self.arena.reset(.free_all); + const arena = self.arena.allocator(); + const display = try arena.alloc([]const u8, self.items.len); + for (self.items, 0..) |item, index| { + if (item.len == 0) { + display[index] = item; + continue; + } + const clean = try sanitizeDisplayText(arena, item); + display[index] = if (clean.len == 0) " " else clean; + } + self.ranked = try rankItems(arena, self.scorer, display, query); + self.filtered = .empty; + self.filtered_cursor = .empty; + for (self.ranked) |ranked_item| { + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendHighlightSpans( + &spans, + arena, + display[ranked_item.index], + ranked_item.matches, + self.theme.match, + ); + const owned = try spans.toOwnedSlice(arena); + assert(owned.len > 0); + try self.filtered.append(arena, .{ .text = owned }); + var cursor_spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendCursorSpans(&cursor_spans, arena, owned, self.theme.cursor_row); + const owned_cursor = try cursor_spans.toOwnedSlice(arena); + assert(owned_cursor.len == owned.len); + try self.filtered_cursor.append(arena, .{ .text = owned_cursor }); + } + assert(self.filtered.items.len == self.ranked.len); + assert(self.filtered_cursor.items.len == self.ranked.len); + self.list_view.cursor = 0; + assert(self.list_view.cursor == 0); + self.list_view.scroll.top = 0; + self.list_view.scroll.offset = 0; + self.list_view.item_count = @intCast(self.filtered.items.len); + } + + fn onSubmit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void { + const ptr = maybe_ptr orelse return; + const self: *ListModel = @ptrCast(@alignCast(ptr)); + if (self.list_view.cursor < self.ranked.len) { + self.selected = self.ranked[self.list_view.cursor].index; + } + ctx.quit = true; + assert(ctx.quit); + } +}; + +pub fn pickFromList( + allocator: std.mem.Allocator, + items: []const []const u8, + theme: ResolvedTheme, +) ![]u8 { + try checkTerminalSize(input_output.runtime()); + var longest: usize = 0; + var usable_count: usize = 0; + for (items) |item| { + if (item.len == 0) { + continue; + } + usable_count += 1; + if (item.len > longest) { + longest = item.len; + } + } + if (usable_count == 0) { + return error.UserAbort; + } + assert(longest > 0); + var scorer = try FuzzyScorer.init(allocator, longest); + defer scorer.deinit(); + const model = try ListModel.init(allocator, items, &scorer, theme); + defer model.deinit(); + var app_buffer: [4096]u8 = undefined; + var app: vxfw.App = try .init( + input_output.runtime(), + allocator, + input_output.environMap(), + &app_buffer, + ); + defer app.deinit(); + try app.run(model.widget(), .{}); + const selected = model.selected orelse return error.UserAbort; + assert(selected < items.len); + const result = try allocator.dupe(u8, items[selected]); + assert(result.len == items[selected].len); + return result; +} + +pub fn buildUgrepArgv( + allocator: std.mem.Allocator, + ignored_patterns: []const []const u8, + query: []const u8, +) !std.ArrayListUnmanaged([]const u8) { + var argv = std.ArrayListUnmanaged([]const u8).empty; + errdefer argv.deinit(allocator); + try argv.appendSlice(allocator, &.{ + "ugrep", + "--recursive", + "--line-number", + "--column-number", + "--smart-case", + "--ignore-binary", + "--color=never", + }); + for (ignored_patterns) |pattern| { + assert(pattern.len > 0); + try argv.append(allocator, "--exclude-dir"); + try argv.append(allocator, pattern); + try argv.append(allocator, "--exclude"); + try argv.append(allocator, pattern); + } + var words = std.mem.splitScalar(u8, query, ' '); + var first_word = true; + var have_word = false; + while (words.next()) |word| { + if (word.len == 0) { + continue; + } + if (!first_word) { + try argv.append(allocator, "--and"); + } + try argv.append(allocator, "-e"); + try argv.append(allocator, word); + first_word = false; + have_word = true; + } + if (!have_word) { + try argv.append(allocator, "--"); + try argv.append(allocator, query); + } + assert(argv.items.len >= 9); + return argv; +} + +pub fn parseUgrepLines( + allocator: std.mem.Allocator, + lines: *std.ArrayListUnmanaged([]const u8), + output: []const u8, +) !void { + const start_len = lines.items.len; + var split = std.mem.splitScalar(u8, output, '\n'); + while (split.next()) |line| { + if (line.len == 0) { + continue; + } + if (lines.items.len - start_len >= max_grep_lines) { + break; + } + try lines.append(allocator, line); + } + assert(lines.items.len - start_len <= max_grep_lines); + assert(lines.items.len >= start_len); +} + +pub const GrepTarget = struct { + file: []const u8, + line: usize, + column: usize, +}; + +pub fn splitGrepLine(selected: []const u8) GrepTarget { + assert(selected.len > 0); + var fields = std.mem.splitScalar(u8, selected, ':'); + if (fields.next()) |file| { + const line = if (fields.next()) |text| parseGrepNumber(text) else 0; + const column = if (fields.next()) |text| parseGrepNumber(text) else 0; + return .{ .file = file, .line = line, .column = column }; + } + return .{ .file = selected, .line = 0, .column = 0 }; +} + +fn parseGrepNumber(text: []const u8) usize { + if (text.len == 0) { + return 0; + } + const number = std.fmt.parseInt(usize, text, 10) catch return 0; + return number; +} + +pub fn readExcerpt( + input_output_handle: std.Io, + directory: std.Io.Dir, + allocator: std.mem.Allocator, + path: []const u8, + line_one_based: usize, +) ![]u8 { + assert(path.len > 0); + assert(line_one_based > 0); + const content = try directory.readFileAlloc( + input_output_handle, + path, + allocator, + .limited(max_excerpt_bytes), + ); + defer allocator.free(content); + const first = excerptFirstLine(line_one_based); + const last = first + excerpt_context_before + excerpt_context_after; + assert(first >= 1); + assert(last >= first); + var out = std.ArrayListUnmanaged(u8).empty; + errdefer out.deinit(allocator); + var number: usize = 0; + var split = std.mem.splitScalar(u8, content, '\n'); + while (split.next()) |text| { + number += 1; + if (number < first) { + continue; + } + if (number > last) { + break; + } + if (number == line_one_based) { + try out.appendSlice(allocator, "> "); + } else { + try out.appendSlice(allocator, " "); + } + try out.appendSlice(allocator, text); + try out.append(allocator, '\n'); + } + return out.toOwnedSlice(allocator); +} + +fn excerptFirstLine(line_one_based: usize) usize { + assert(line_one_based > 0); + if (line_one_based > excerpt_context_before) { + const first = line_one_based - excerpt_context_before; + assert(first < line_one_based); + assert(first >= 1); + return first; + } + return 1; +} + +const no_preview_text = "[no preview]"; + +const no_preview_spans = [_]vxfw.RichText.TextSpan{ + .{ .text = no_preview_text }, +}; + +fn warn(input_output_handle: std.Io, comptime fmt: []const u8, arguments: anytype) void { + assert(fmt.len > 0); + var buffer: [4096]u8 = undefined; + var writer = std.Io.File.stderr().writerStreaming(input_output_handle, &buffer); + writer.interface.print(fmt, arguments) catch |err| { + std.log.warn("failed to write picker warning: {}", .{err}); + }; + writer.interface.flush() catch |err| { + std.log.warn("failed to flush picker warning: {}", .{err}); + }; +} + +const GrepModel = struct { + input_output_handle: std.Io, + allocator: std.mem.Allocator, + patterns: []const []const u8, + theme: ResolvedTheme, + started_empty: bool = false, + start_error: ?anyerror = null, + output: ?[]u8 = null, + lines: std.ArrayListUnmanaged([]const u8) = .empty, + texts: std.ArrayListUnmanaged(vxfw.RichText) = .empty, + texts_cursor: std.ArrayListUnmanaged(vxfw.RichText) = .empty, + query_len: usize = 0, + preview_text: []u8 = &.{}, + preview_spans: []vxfw.RichText.TextSpan = &.{}, + preview: vxfw.RichText = .{ .text = &no_preview_spans }, + list_view: vxfw.ListView, + text_field: vxfw.TextField, + selected: ?usize = null, + pending_initial: bool = true, + + fn init( + input_output_handle: std.Io, + allocator: std.mem.Allocator, + patterns: []const []const u8, + theme: ResolvedTheme, + ) !*GrepModel { + const self = try allocator.create(GrepModel); + self.* = .{ + .input_output_handle = input_output_handle, + .allocator = allocator, + .patterns = patterns, + .theme = theme, + .list_view = .{ + .children = .{ .builder = undefined }, + }, + .text_field = vxfw.TextField.init(allocator), + }; + self.list_view.children = .{ .builder = .{ + .userdata = self, + .buildFn = GrepModel.widgetBuilder, + } }; + self.text_field.userdata = self; + self.text_field.onChange = GrepModel.onChange; + self.text_field.onSubmit = GrepModel.onSubmit; + assert(self.text_field.userdata != null); + self.list_view.item_count = 0; + return self; + } + + fn initialReload(self: *GrepModel) void { + const truncated = self.reload("") catch |err| { + self.started_empty = true; + self.start_error = err; + if (!builtin.is_test) { + warn( + self.input_output_handle, + "initial grep reload failed, starting empty: {}\n", + .{err}, + ); + } + return; + }; + if (truncated) { + self.started_empty = true; + self.start_error = error.StreamTooLong; + if (!builtin.is_test) { + warn( + self.input_output_handle, + "initial grep found too many matches, starting empty\n", + .{}, + ); + } + } + } + + fn deinit(self: *GrepModel) void { + const allocator = self.allocator; + if (self.output) |output| { + allocator.free(output); + } + if (self.preview_text.len > 0) { + allocator.free(self.preview_text); + } + if (self.preview_spans.len > 0) { + allocator.free(self.preview_spans); + } + self.freeGrepRows(); + self.texts.deinit(allocator); + self.texts_cursor.deinit(allocator); + self.lines.deinit(allocator); + self.text_field.deinit(); + allocator.destroy(self); + } + + fn widget(self: *GrepModel) vxfw.Widget { + const result: vxfw.Widget = .{ + .userdata = self, + .eventHandler = GrepModel.typeErasedEventHandler, + .drawFn = GrepModel.typeErasedDrawFn, + }; + assert(result.userdata == @as(*anyopaque, @ptrCast(self))); + return result; + } + + fn typeErasedEventHandler( + ptr: *anyopaque, + ctx: *vxfw.EventContext, + event: vxfw.Event, + ) anyerror!void { + const self: *GrepModel = @ptrCast(@alignCast(ptr)); + switch (event) { + .init => { + try ctx.tick(0, self.widget()); + return ctx.requestFocus(self.text_field.widget()); + }, + .tick => { + if (self.pending_initial) { + self.pending_initial = false; + self.initialReload(); + ctx.redraw = true; + } + return; + }, + .key_press => |key| { + if (key.matches(vaxis.Key.escape, .{})) { + ctx.quit = true; + assert(ctx.quit); + return; + } + if (key.matches('c', .{ .ctrl = true })) { + ctx.quit = true; + assert(ctx.quit); + return; + } + if (key.matches('d', .{ .ctrl = true })) { + ctx.quit = true; + assert(ctx.quit); + return; + } + if (key.matches(vaxis.Key.up, .{})) { + self.list_view.prevItem(ctx); + self.refreshPreview(); + return; + } + if (key.matches('p', .{ .ctrl = true })) { + self.list_view.prevItem(ctx); + self.refreshPreview(); + return; + } + if (key.matches(vaxis.Key.down, .{})) { + self.list_view.nextItem(ctx); + self.refreshPreview(); + return; + } + if (key.matches('n', .{ .ctrl = true })) { + self.list_view.nextItem(ctx); + self.refreshPreview(); + return; + } + return; + }, + .focus_in => { + return ctx.requestFocus(self.text_field.widget()); + }, + else => {}, + } + } + + fn typeErasedDrawFn( + ptr: *anyopaque, + ctx: vxfw.DrawContext, + ) std.mem.Allocator.Error!vxfw.Surface { + const self: *GrepModel = @ptrCast(@alignCast(ptr)); + const max_size = ctx.max.size(); + assert(max_size.width > 0); + assert(max_size.height > 0); + + const match_count = self.lines.items.len; + const count_text = try std.fmt.allocPrint( + ctx.arena, + "{d} matches", + .{match_count}, + ); + const count: vxfw.Text = .{ .text = count_text, .style = self.theme.status }; + const prompt: vxfw.Text = .{ .text = ">", .style = self.theme.prompt }; + + const total_width: u32 = max_size.width; + const preview_width: u16 = @intCast(total_width * 3 / 5); + const list_width: u16 = @intCast(total_width - total_width * 3 / 5); + assert(@as(u32, list_width) + preview_width == total_width); + const list_height = max_size.height -| 2; + const children = try ctx.arena.alloc(vxfw.SubSurface, 5); + children[0] = .{ + .origin = .{ .row = 0, .col = 0 }, + .surface = try prompt.draw(rowConstraints(ctx, 2, 1)), + }; + children[1] = .{ + .origin = .{ .row = 0, .col = 2 }, + .surface = try self.text_field.draw(rowConstraints(ctx, max_size.width -| 2, 1)), + }; + children[2] = .{ + .origin = .{ .row = 1, .col = 2 }, + .surface = try count.draw(rowConstraints(ctx, max_size.width -| 2, 1)), + }; + children[3] = .{ + .origin = .{ .row = 2, .col = 0 }, + .surface = try self.list_view.draw(rowConstraints(ctx, list_width, list_height)), + }; + children[4] = .{ + .origin = .{ .row = 2, .col = list_width }, + .surface = try self.preview.draw(rowConstraints(ctx, preview_width, list_height)), + }; + + return .{ + .size = max_size, + .widget = self.widget(), + .buffer = &.{}, + .children = children, + }; + } + + fn widgetBuilder(ptr: *const anyopaque, index: usize, cursor: usize) ?vxfw.Widget { + const self: *const GrepModel = @ptrCast(@alignCast(ptr)); + if (index >= self.texts.items.len) { + return null; + } + assert(index < self.texts.items.len); + assert(self.texts_cursor.items.len == self.texts.items.len); + if (index == cursor) { + return self.texts_cursor.items[index].widget(); + } + return self.texts.items[index].widget(); + } + + fn onChange(maybe_ptr: ?*anyopaque, _: *vxfw.EventContext, query: []const u8) anyerror!void { + const ptr = maybe_ptr orelse return; + const self: *GrepModel = @ptrCast(@alignCast(ptr)); + _ = try self.reload(query); + } + + fn reload(self: *GrepModel, query: []const u8) !bool { + self.clearResults(); + self.query_len = query.len; + var argv = try buildUgrepArgv(self.allocator, self.patterns, query); + defer argv.deinit(self.allocator); + const run_result = std.process.run(self.allocator, self.input_output_handle, .{ + .argv = argv.items, + .stdout_limit = .limited(max_grep_stdout_bytes), + .stderr_limit = .limited(65536), + }) catch |err| { + if (err == error.StreamTooLong) { + if (!builtin.is_test) { + warn( + self.input_output_handle, + "too many matches, keep typing to narrow\n", + .{}, + ); + } + self.refreshPreview(); + return true; + } + return err; + }; + defer self.allocator.free(run_result.stderr); + switch (run_result.term) { + .exited => |code| { + if (code != 0) { + self.allocator.free(run_result.stdout); + if (code != 1) { + warn(self.input_output_handle, "ugrep exited with status {d}\n", .{code}); + } + self.refreshPreview(); + return false; + } + }, + else => { + self.allocator.free(run_result.stdout); + warn(self.input_output_handle, "ugrep did not exit normally\n", .{}); + self.refreshPreview(); + return false; + }, + } + self.output = run_result.stdout; + try parseUgrepLines(self.allocator, &self.lines, run_result.stdout); + for (self.lines.items) |line| { + assert(line.len > 0); + try self.appendGrepRow(line); + } + assert(self.texts.items.len == self.lines.items.len); + assert(self.texts_cursor.items.len == self.lines.items.len); + assert(self.lines.items.len <= std.math.maxInt(u32)); + self.list_view.cursor = 0; + assert(self.list_view.cursor == 0); + self.list_view.scroll.top = 0; + self.list_view.scroll.offset = 0; + self.list_view.item_count = @intCast(self.lines.items.len); + self.refreshPreview(); + return false; + } + + fn appendGrepRow(self: *GrepModel, line: []const u8) !void { + assert(line.len > 0); + var normal = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + defer normal.deinit(self.allocator); + try appendDisplaySafeText(&normal, self.allocator, line, .{}); + assert(normal.items.len > 0); + const owned = try self.allocator.dupe(vxfw.RichText.TextSpan, normal.items); + errdefer self.allocator.free(owned); + assert(owned.len > 0); + var cursor_spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + defer cursor_spans.deinit(self.allocator); + try appendCursorSpans(&cursor_spans, self.allocator, owned, self.theme.cursor_row); + const owned_cursor = try cursor_spans.toOwnedSlice(self.allocator); + assert(owned_cursor.len == owned.len); + try self.texts.append(self.allocator, .{ .text = owned }); + try self.texts_cursor.append(self.allocator, .{ .text = owned_cursor }); + assert(self.texts.items.len == self.texts_cursor.items.len); + } + + fn freeGrepRows(self: *GrepModel) void { + assert(self.texts.items.len == self.texts_cursor.items.len); + for (self.texts.items) |row| { + assert(row.text.len > 0); + self.allocator.free(row.text); + } + for (self.texts_cursor.items) |row| { + assert(row.text.len > 0); + self.allocator.free(row.text); + } + } + + fn clearResults(self: *GrepModel) void { + if (self.output) |output| { + self.allocator.free(output); + self.output = null; + } + self.lines.deinit(self.allocator); + self.lines = .empty; + self.freeGrepRows(); + self.texts.deinit(self.allocator); + self.texts = .empty; + self.texts_cursor.deinit(self.allocator); + self.texts_cursor = .empty; + assert(self.output == null); + assert(self.lines.items.len == 0); + assert(self.texts.items.len == 0); + assert(self.texts_cursor.items.len == 0); + } + + fn refreshPreview(self: *GrepModel) void { + if (self.preview_text.len > 0) { + self.allocator.free(self.preview_text); + self.preview_text = &.{}; + } + if (self.preview_spans.len > 0) { + self.allocator.free(self.preview_spans); + self.preview_spans = &.{}; + } + self.preview = .{ .text = &no_preview_spans }; + const cursor = self.list_view.cursor; + if (cursor >= self.lines.items.len) { + return; + } + const target = splitGrepLine(self.lines.items[cursor]); + if (target.file.len == 0) { + return; + } + if (target.line == 0) { + return; + } + self.loadPreview(target) catch return; + } + + fn loadPreview(self: *GrepModel, target: GrepTarget) !void { + assert(target.file.len > 0); + assert(target.line > 0); + const excerpt = try readExcerpt( + self.input_output_handle, + std.Io.Dir.cwd(), + self.allocator, + target.file, + target.line, + ); + errdefer self.allocator.free(excerpt); + if (excerpt.len == 0) { + self.allocator.free(excerpt); + return; + } + assert(excerpt.len > 0); + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + defer spans.deinit(self.allocator); + try appendPreviewSpans( + &spans, + self.allocator, + excerpt, + target.column, + self.query_len, + self.theme, + ); + self.preview_text = excerpt; + self.preview_spans = try spans.toOwnedSlice(self.allocator); + assert(self.preview_spans.len > 0); + self.preview = .{ .text = self.preview_spans }; + assert(self.preview.text.len > 0); + } + + fn onSubmit(maybe_ptr: ?*anyopaque, ctx: *vxfw.EventContext, _: []const u8) anyerror!void { + const ptr = maybe_ptr orelse return; + const self: *GrepModel = @ptrCast(@alignCast(ptr)); + if (self.list_view.cursor < self.lines.items.len) { + self.selected = self.list_view.cursor; + } + ctx.quit = true; + assert(ctx.quit); + } +}; + +pub fn pickGrepLine( + allocator: std.mem.Allocator, + ignored_patterns: []const []const u8, + theme: ResolvedTheme, +) ![]u8 { + try checkTerminalSize(input_output.runtime()); + { + const probe = std.process.run(allocator, input_output.runtime(), .{ + .argv = &.{ "ugrep", "--version" }, + .stdout_limit = .limited(4096), + .stderr_limit = .limited(4096), + }) catch |err| { + input_output.warn("ugrep is required for grep but could not start: {}\n", .{err}); + return error.UgrepNotFound; + }; + defer allocator.free(probe.stdout); + defer allocator.free(probe.stderr); + if (probe.term != .exited) { + return error.UgrepNotFound; + } + if (probe.term.exited != 0) { + return error.UgrepNotFound; + } + } + const model = try GrepModel.init(input_output.runtime(), allocator, ignored_patterns, theme); + defer model.deinit(); + var app_buffer: [4096]u8 = undefined; + var app: vxfw.App = try .init( + input_output.runtime(), + allocator, + input_output.environMap(), + &app_buffer, + ); + defer app.deinit(); + try app.run(model.widget(), .{}); + const selected = model.selected orelse return error.UserAbort; + assert(selected < model.lines.items.len); + const result = try allocator.dupe(u8, model.lines.items[selected]); + assert(result.len == model.lines.items[selected].len); + return result; +} + +const testing = std.testing; + +fn testScorer(items: []const []const u8) !FuzzyScorer { + assert(items.len > 0); + var longest: usize = 1; + for (items) |item| { + if (item.len > longest) { + longest = item.len; + } + } + assert(longest >= 1); + return FuzzyScorer.init(testing.allocator, longest); +} + +test "rankItems: filename match beats non-matches" { + const items = [_][]const u8{ "src/main.zig", "README.md" }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const ranked = try rankItems(arena.allocator(), &scorer, &items, "main"); + try testing.expectEqual(@as(usize, 1), ranked.len); + try testing.expectEqual(@as(usize, 0), ranked[0].index); +} + +test "rankItems: empty query keeps original order" { + const items = [_][]const u8{ "b.txt", "a.txt", "c.txt" }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const ranked = try rankItems(arena.allocator(), &scorer, &items, ""); + try testing.expectEqual(@as(usize, 3), ranked.len); + try testing.expectEqual(@as(usize, 0), ranked[0].index); + try testing.expectEqual(@as(usize, 1), ranked[1].index); + try testing.expectEqual(@as(usize, 2), ranked[2].index); +} + +test "rankItems: no match yields nothing" { + const items = [_][]const u8{"src/main.zig"}; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const ranked = try rankItems(arena.allocator(), &scorer, &items, "zzz"); + try testing.expectEqual(@as(usize, 0), ranked.len); +} + +test "rankItems: uppercase query matches case-sensitively" { + const items = [_][]const u8{ "src/main.zig", "src/MAIN.zig" }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const lower = try rankItems(arena.allocator(), &scorer, &items, "main"); + try testing.expectEqual(@as(usize, 2), lower.len); + const upper = try rankItems(arena.allocator(), &scorer, &items, "MAIN"); + try testing.expectEqual(@as(usize, 1), upper.len); + try testing.expectEqual(@as(usize, 1), upper[0].index); +} + +test "rankItems: results are deterministic across runs" { + const items = [_][]const u8{ "src/main.zig", "src/fzf.zig", "README.md" }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const first = try rankItems(arena.allocator(), &scorer, &items, "z"); + const second = try rankItems(arena.allocator(), &scorer, &items, "z"); + try testing.expectEqual(first.len, second.len); + for (first, second) |left, right| { + try testing.expectEqual(left.index, right.index); + try testing.expectEqual(left.score, right.score); + } +} + +test "rankItems: space matches path separator like slash" { + const items = [_][]const u8{ + "hosts/impish/overlays/config/magdalena", + "hosts/impish/overlays/config/MangoHud", + }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const slashed = try rankItems(arena.allocator(), &scorer, &items, "impish/mag"); + const spaced = try rankItems(arena.allocator(), &scorer, &items, "impish mag"); + try testing.expectEqual(slashed.len, spaced.len); + try testing.expect(spaced.len > 0); + for (slashed, spaced) |left, right| { + try testing.expectEqual(left.index, right.index); + try testing.expectEqual(left.score, right.score); + } +} + +test "rankItems: literal space content still matches" { + const items = [_][]const u8{ "foo bar", "foo/bar" }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const ranked = try rankItems(arena.allocator(), &scorer, &items, "foo bar"); + try testing.expectEqual(@as(usize, 2), ranked.len); + try testing.expectEqual(@as(usize, 0), ranked[0].index); +} + +test "clampHaystack: short text passes through" { + const items = [_][]const u8{"short"}; + var scorer = try testScorer(&items); + defer scorer.deinit(); + const clamped = scorer.clampHaystack("short"); + try testing.expectEqualStrings("short", clamped.slice); + try testing.expectEqual(@as(usize, 0), clamped.offset); +} + +test "clampHaystack: long text keeps the tail" { + const items = [_][]const u8{"0123456789"}; + var scorer = try testScorer(&items); + defer scorer.deinit(); + var small = try FuzzyScorer.init(testing.allocator, 4); + defer small.deinit(); + const clamped = small.clampHaystack("0123456789"); + try testing.expectEqualStrings("6789", clamped.slice); + try testing.expectEqual(@as(usize, 6), clamped.offset); + + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const ranked = try rankItems(arena.allocator(), &small, &[_][]const u8{"0123456789"}, "789"); + try testing.expectEqual(@as(usize, 1), ranked.len); + try testing.expectEqual(@as(usize, 6), ranked[0].tail_offset); +} + +test "appendHighlightSpans: text rebuilds and matches glow" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const text = "src/main.zig"; + const matches = [_]usize{ 4, 5, 6, 7 }; + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendHighlightSpans(&spans, arena.allocator(), text, &matches, defaultTheme().match); + var rebuilt = std.ArrayListUnmanaged(u8).empty; + defer rebuilt.deinit(testing.allocator); + var highlighted: ?[]const u8 = null; + for (spans.items) |span| { + try rebuilt.appendSlice(testing.allocator, span.text); + if (span.style.reverse) { + highlighted = span.text; + } + } + try testing.expectEqualStrings(text, rebuilt.items); + try testing.expectEqualStrings("main", highlighted.?); +} + +test "appendHighlightSpans: no matches leaves plain text" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendHighlightSpans(&spans, arena.allocator(), "abc", &.{}, defaultTheme().match); + try testing.expectEqual(@as(usize, 1), spans.items.len); + try testing.expectEqualStrings("abc", spans.items[0].text); + try testing.expect(!spans.items[0].style.reverse); +} + +test "buildUgrepArgv: exact sequence without shell quoting" { + const patterns = [_][]const u8{ "node_modules", "a'b" }; + var argv = try buildUgrepArgv(testing.allocator, &patterns, "needle"); + defer argv.deinit(testing.allocator); + const expected = [_][]const u8{ + "ugrep", + "--recursive", + "--line-number", + "--column-number", + "--smart-case", + "--ignore-binary", + "--color=never", + "--exclude-dir", + "node_modules", + "--exclude", + "node_modules", + "--exclude-dir", + "a'b", + "--exclude", + "a'b", + "-e", + "needle", + }; + try testing.expectEqual(expected.len, argv.items.len); + for (expected, argv.items) |want, got| { + try testing.expectEqualStrings(want, got); + } +} + +test "buildUgrepArgv: spaces split into AND patterns" { + var argv = try buildUgrepArgv(testing.allocator, &.{}, "impish mag"); + defer argv.deinit(testing.allocator); + const tail = argv.items[argv.items.len - 5 ..]; + try testing.expectEqualStrings("-e", tail[0]); + try testing.expectEqualStrings("impish", tail[1]); + try testing.expectEqualStrings("--and", tail[2]); + try testing.expectEqualStrings("-e", tail[3]); + try testing.expectEqualStrings("mag", tail[4]); +} + +test "buildUgrepArgv: doubled spaces skip empties" { + var argv = try buildUgrepArgv(testing.allocator, &.{}, "a b"); + defer argv.deinit(testing.allocator); + const tail = argv.items[argv.items.len - 5 ..]; + try testing.expectEqualStrings("-e", tail[0]); + try testing.expectEqualStrings("a", tail[1]); + try testing.expectEqualStrings("--and", tail[2]); + try testing.expectEqualStrings("-e", tail[3]); + try testing.expectEqualStrings("b", tail[4]); +} + +test "buildUgrepArgv: all-space query keeps literal form" { + var argv = try buildUgrepArgv(testing.allocator, &.{}, " "); + defer argv.deinit(testing.allocator); + const tail = argv.items[argv.items.len - 2 ..]; + try testing.expectEqualStrings("--", tail[0]); + try testing.expectEqualStrings(" ", tail[1]); +} + +test "grep end to end: space query finds both words like slash" { + const probe = std.process.run(testing.allocator, testing.io, .{ + .argv = &.{ "ugrep", "--version" }, + }) catch |err| { + if (err == error.FileNotFound) return error.SkipZigTest; + return err; + }; + testing.allocator.free(probe.stdout); + testing.allocator.free(probe.stderr); + + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + try writeGrepFixture( + &temp_directory.dir, + test_input_output, + "notes.txt", + "use hosts/impish/overlays/config/magdalena here\n", + ); + try writeGrepFixture( + &temp_directory.dir, + test_input_output, + "path.txt", + "see impish/mag link\n", + ); + try writeGrepFixture( + &temp_directory.dir, + test_input_output, + "other.txt", + "unrelated content here\n", + ); + + const spaced = try runGrepQuery(test_input_output, &temp_directory.dir, "impish mag"); + defer testing.allocator.free(spaced); + try testing.expect(std.mem.indexOf(u8, spaced, "notes.txt") != null); + try testing.expect(std.mem.indexOf(u8, spaced, "path.txt") != null); + try testing.expect(std.mem.indexOf(u8, spaced, "other.txt") == null); + + const slashed = try runGrepQuery(test_input_output, &temp_directory.dir, "impish/mag"); + defer testing.allocator.free(slashed); + try testing.expect(std.mem.indexOf(u8, slashed, "path.txt") != null); + try testing.expect(std.mem.indexOf(u8, slashed, "notes.txt") == null); +} + +fn writeGrepFixture( + directory: *std.Io.Dir, + input_output_handle: std.Io, + name: []const u8, + content: []const u8, +) !void { + assert(name.len > 0); + assert(content.len > 0); + var file = try directory.createFile(input_output_handle, name, .{ + .read = false, + .truncate = true, + .exclusive = false, + .lock = .none, + .lock_nonblocking = false, + .permissions = .fromMode(0o644), + .resolve_beneath = false, + }); + var file_buffer: [4096]u8 = undefined; + var writer = file.writer(input_output_handle, &file_buffer); + try writer.interface.writeAll(content); + try writer.interface.flush(); + file.close(input_output_handle); +} + +fn runGrepQuery( + input_output_handle: std.Io, + directory: *std.Io.Dir, + query: []const u8, +) ![]u8 { + assert(query.len > 0); + var argv = try buildUgrepArgv(testing.allocator, &.{}, query); + defer argv.deinit(testing.allocator); + const run_result = try std.process.run(testing.allocator, input_output_handle, .{ + .argv = argv.items, + .cwd = .{ .dir = directory.* }, + .stdout_limit = .limited(max_grep_stdout_bytes), + .stderr_limit = .limited(65536), + }); + testing.allocator.free(run_result.stderr); + errdefer testing.allocator.free(run_result.stdout); + switch (run_result.term) { + .exited => |code| { + try testing.expectEqual(@as(u8, 0), code); + return run_result.stdout; + }, + else => return error.UgrepDidNotExit, + } +} + +test "parseUgrepLines: skips blanks" { + var lines = std.ArrayListUnmanaged([]const u8).empty; + defer lines.deinit(testing.allocator); + try parseUgrepLines(testing.allocator, &lines, "a:1:1:x\n\nb:2:3:y\n"); + try testing.expectEqual(@as(usize, 2), lines.items.len); + try testing.expectEqualStrings("a:1:1:x", lines.items[0]); +} + +test "parseUgrepLines: caps runaway output" { + var lines = std.ArrayListUnmanaged([]const u8).empty; + defer lines.deinit(testing.allocator); + var big = std.ArrayListUnmanaged(u8).empty; + defer big.deinit(testing.allocator); + var count: usize = 0; + while (count < max_grep_lines + 5) : (count += 1) { + try big.appendSlice(testing.allocator, "f:1:1:x\n"); + } + try parseUgrepLines(testing.allocator, &lines, big.items); + try testing.expectEqual(max_grep_lines, lines.items.len); +} + +test "splitGrepLine: file with line and column" { + const target = splitGrepLine("src/main.zig:12:4:needle"); + try testing.expectEqualStrings("src/main.zig", target.file); + try testing.expectEqual(@as(usize, 12), target.line); +} + +test "splitGrepLine: missing line yields zero" { + const target = splitGrepLine("README.md"); + try testing.expectEqualStrings("README.md", target.file); + try testing.expectEqual(@as(usize, 0), target.line); +} + +test "splitGrepLine: malformed line yields zero" { + const target = splitGrepLine("src/main.zig:deep:4:x"); + try testing.expectEqualStrings("src/main.zig", target.file); + try testing.expectEqual(@as(usize, 0), target.line); +} + +test "readExcerpt: window around the target line" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + var file = try temp_directory.dir.createFile(test_input_output, "sample.txt", .{ + .read = false, + .truncate = true, + .exclusive = false, + .lock = .none, + .lock_nonblocking = false, + .permissions = .fromMode(0o644), + .resolve_beneath = false, + }); + var write_buffer: [4096]u8 = undefined; + var writer = file.writer(test_input_output, &write_buffer); + var number: usize = 1; + while (number <= 30) : (number += 1) { + try writer.interface.print("line{d}\n", .{number}); + } + try writer.interface.flush(); + file.close(test_input_output); + + const excerpt = try readExcerpt( + test_input_output, + temp_directory.dir, + testing.allocator, + "sample.txt", + 10, + ); + defer testing.allocator.free(excerpt); + try testing.expect(std.mem.indexOf(u8, excerpt, "> line10\n") != null); + try testing.expect(std.mem.indexOf(u8, excerpt, " line5\n") != null); + try testing.expect(std.mem.indexOf(u8, excerpt, " line25\n") != null); + try testing.expect(std.mem.indexOf(u8, excerpt, "line26\n") == null); +} + +test "readExcerpt: missing file errors" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const missing = readExcerpt( + testing.io, + temp_directory.dir, + testing.allocator, + "does-not-exist.txt", + 1, + ); + try testing.expectError(error.FileNotFound, missing); +} + +test "readExcerpt: line past the end yields empty" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + var file = try temp_directory.dir.createFile(test_input_output, "tiny.txt", .{ + .read = false, + .truncate = true, + .exclusive = false, + .lock = .none, + .lock_nonblocking = false, + .permissions = .fromMode(0o644), + .resolve_beneath = false, + }); + var tiny_buffer: [4096]u8 = undefined; + var tiny_writer = file.writer(test_input_output, &tiny_buffer); + try tiny_writer.interface.writeAll("only\n"); + try tiny_writer.interface.flush(); + file.close(test_input_output); + const excerpt = try readExcerpt( + test_input_output, + temp_directory.dir, + testing.allocator, + "tiny.txt", + 99, + ); + defer testing.allocator.free(excerpt); + try testing.expectEqual(@as(usize, 0), excerpt.len); +} + +extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; + +test "GrepModel.init survives reload failure without double free" { + var temp_directory = testing.tmpDir(.{ .iterate = true }); + defer temp_directory.cleanup(); + const test_input_output = testing.io; + + var flood_script_buffer: [128]u8 = undefined; + const script = try std.fmt.bufPrint( + &flood_script_buffer, + "#!/bin/sh\n/usr/sbin/yes y | /usr/sbin/head -c {d}\n", + .{max_grep_stdout_bytes + 1024 * 1024}, + ); + var script_file = try temp_directory.dir.createFile(test_input_output, "ugrep", .{ + .read = false, + .truncate = true, + .exclusive = false, + .lock = .none, + .lock_nonblocking = false, + .permissions = .fromMode(0o755), + .resolve_beneath = false, + }); + var script_buffer: [4096]u8 = undefined; + var script_writer = script_file.writer(test_input_output, &script_buffer); + try script_writer.interface.writeAll(script); + try script_writer.interface.flush(); + script_file.close(test_input_output); + + const cwd = try std.process.currentPathAlloc(test_input_output, testing.allocator); + defer testing.allocator.free(cwd); + const fake_bin = try std.fs.path.join(testing.allocator, &.{ + cwd, + ".zig-cache", + "tmp", + temp_directory.sub_path[0..], + }); + defer testing.allocator.free(fake_bin); + + const old_path = if (std.c.getenv("PATH")) |existing| + std.mem.span(existing) + else + "/usr/bin:/bin"; + const new_path = try std.fmt.allocPrint(testing.allocator, "{s}:{s}", .{ fake_bin, old_path }); + defer testing.allocator.free(new_path); + const new_path_z = try testing.allocator.dupeZ(u8, new_path); + defer testing.allocator.free(new_path_z); + assert(setenv("PATH", new_path_z.ptr, 1) == 0); + defer { + if (testing.allocator.dupeZ(u8, old_path)) |restore_z| { + defer testing.allocator.free(restore_z); + _ = setenv("PATH", restore_z.ptr, 1); + } else |_| {} + } + + var model = try GrepModel.init(testing.io, testing.allocator, &.{}, defaultTheme()); + defer model.deinit(); + try testing.expect(model.pending_initial); + model.initialReload(); + try testing.expect(model.started_empty); + try testing.expectEqual(error.StreamTooLong, model.start_error.?); + try testing.expectEqual(@as(usize, 0), model.lines.items.len); +} + +test "parseThemeColor: default keyword" { + const color = try parseThemeColor("default"); + try testing.expect(color == .default); +} + +test "parseThemeColor: palette bounds" { + const zero = try parseThemeColor("0"); + try testing.expectEqual(@as(u8, 0), zero.index); + const top = try parseThemeColor("255"); + try testing.expectEqual(@as(u8, 255), top.index); + try testing.expectError(error.InvalidColor, parseThemeColor("256")); + try testing.expectError(error.InvalidColor, parseThemeColor("-1")); +} + +test "parseThemeColor: hex triple" { + const color = try parseThemeColor("#ff0080"); + try testing.expectEqual([3]u8{ 255, 0, 128 }, color.rgb); + const black = try parseThemeColor("#000000"); + try testing.expectEqual([3]u8{ 0, 0, 0 }, black.rgb); +} + +test "parseThemeColor: malformed input errors" { + try testing.expectError(error.InvalidColor, parseThemeColor("")); + try testing.expectError(error.InvalidColor, parseThemeColor("blue")); + try testing.expectError(error.InvalidColor, parseThemeColor("red")); + try testing.expectError(error.InvalidColor, parseThemeColor("#xyz")); + try testing.expectError(error.InvalidColor, parseThemeColor("#12345")); + try testing.expectError(error.InvalidColor, parseThemeColor("#1234567")); + try testing.expectError(error.InvalidColor, parseThemeColor("#zzzzzz")); +} + +test "resolveTheme: empty config yields defaults" { + const theme = resolveTheme(testing.io, config.Config{}); + try testing.expectEqual(@as(u8, 4), theme.prompt.fg.index); + try testing.expect(!theme.prompt.reverse); + try testing.expectEqual(@as(u8, 4), theme.match.fg.index); + try testing.expect(theme.match.reverse); + try testing.expectEqual(@as(u8, 8), theme.cursor_row.bg.index); + try testing.expectEqual(@as(u8, 8), theme.status.fg.index); + try testing.expectEqual(@as(u8, 4), theme.preview_current.fg.index); +} + +test "resolveTheme: partial theme fills only set roles" { + const app_config = config.Config{ .theme = .{ .prompt = "9" } }; + const theme = resolveTheme(testing.io, app_config); + try testing.expectEqual(@as(u8, 9), theme.prompt.fg.index); + try testing.expectEqual(@as(u8, 4), theme.match.fg.index); + try testing.expectEqual(@as(u8, 8), theme.status.fg.index); +} + +test "resolveTheme: invalid role warns and defaults that role" { + const app_config = config.Config{ .theme = .{ .match = "not-a-color" } }; + const theme = resolveTheme(testing.io, app_config); + try testing.expectEqual(@as(u8, 4), theme.match.fg.index); + try testing.expect(theme.match.reverse); + try testing.expectEqual(@as(u8, 4), theme.prompt.fg.index); +} + +test "resolveTheme: NO_COLOR convention" { + const old_value = if (std.c.getenv("NO_COLOR")) |existing| + try testing.allocator.dupeZ(u8, std.mem.span(existing)) + else + null; + defer { + if (old_value) |saved| { + defer testing.allocator.free(saved); + _ = setenv("NO_COLOR", saved.ptr, 1); + } else { + _ = setenv("NO_COLOR", "", 1); + } + } + assert(setenv("NO_COLOR", "1", 1) == 0); + const app_config = config.Config{ .theme = .{ .prompt = "9" } }; + const blank = resolveTheme(testing.io, app_config); + try testing.expect(blank.prompt.fg == .default); + try testing.expect(blank.match.fg == .default); + try testing.expect(!blank.match.reverse); + try testing.expect(blank.cursor_row.bg == .default); + try testing.expect(blank.status.fg == .default); + try testing.expect(blank.preview_current.fg == .default); + assert(setenv("NO_COLOR", "", 1) == 0); + const kept = resolveTheme(testing.io, config.Config{}); + try testing.expectEqual(@as(u8, 4), kept.prompt.fg.index); + try testing.expect(kept.match.reverse); +} + +test "appendPreviewSpans: marker styled and match highlighted" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const excerpt = " line one\n> needle here\n line three\n"; + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendPreviewSpans(&spans, arena.allocator(), excerpt, 1, 6, defaultTheme()); + var rebuilt = std.ArrayListUnmanaged(u8).empty; + defer rebuilt.deinit(testing.allocator); + var marker_style: ?vaxis.Style = null; + var match_text: ?[]const u8 = null; + for (spans.items) |span| { + try rebuilt.appendSlice(testing.allocator, span.text); + if (std.mem.eql(u8, span.text, "> ")) { + marker_style = span.style; + } + if (span.style.reverse) { + match_text = span.text; + } + } + try testing.expectEqualStrings(excerpt, rebuilt.items); + try testing.expectEqual(@as(u8, 4), marker_style.?.fg.index); + try testing.expectEqualStrings("needle", match_text.?); +} + +test "appendPreviewSpans: other lines stay plain" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const excerpt = " plain one\n> needle here\n plain two\n"; + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendPreviewSpans(&spans, arena.allocator(), excerpt, 1, 6, defaultTheme()); + for (spans.items) |span| { + if (std.mem.eql(u8, span.text, "> ")) { + continue; + } + if (std.mem.eql(u8, span.text, "needle")) { + continue; + } + try testing.expect(span.style.fg == .default); + try testing.expect(!span.style.reverse); + } +} + +test "appendPreviewSpans: past-the-end column leaves plain text" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const excerpt = "> short\n"; + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendPreviewSpans(&spans, arena.allocator(), excerpt, 99, 3, defaultTheme()); + var rebuilt = std.ArrayListUnmanaged(u8).empty; + defer rebuilt.deinit(testing.allocator); + for (spans.items) |span| { + try rebuilt.appendSlice(testing.allocator, span.text); + try testing.expect(!span.style.reverse); + } + try testing.expectEqualStrings(excerpt, rebuilt.items); +} + +test "appendPreviewSpans: zero column yields no highlight" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const excerpt = "> needle here\n"; + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendPreviewSpans(&spans, arena.allocator(), excerpt, 0, 6, defaultTheme()); + for (spans.items) |span| { + try testing.expect(!span.style.reverse); + } +} + +test "appendPreviewSpans: match region clamps to the line" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const excerpt = "> abcdef\n"; + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendPreviewSpans(&spans, arena.allocator(), excerpt, 5, 99, defaultTheme()); + var match_text: ?[]const u8 = null; + for (spans.items) |span| { + if (span.style.reverse) { + match_text = span.text; + } + } + try testing.expectEqualStrings("ef", match_text.?); +} + +test "sanitizeDisplayText: CSI sequences stripped" { + const clean = try sanitizeDisplayText(testing.allocator, "a\x1b[31mred\x1b[0mb"); + defer testing.allocator.free(clean); + try testing.expectEqualStrings("aredb", clean); +} + +test "sanitizeDisplayText: OSC sequences stripped" { + const clean = try sanitizeDisplayText(testing.allocator, "a\x1b]8;;http://x\x07linkb"); + defer testing.allocator.free(clean); + try testing.expectEqualStrings("alinkb", clean); +} + +test "sanitizeDisplayText: controls become spaces, tabs expand" { + const clean = try sanitizeDisplayText(testing.allocator, "a\x00b\x1bc\x7fd\te"); + defer testing.allocator.free(clean); + try testing.expectEqualStrings("a b d e", clean); +} + +test "sanitizeDisplayText: bad UTF-8 repaired, good UTF-8 kept" { + const clean = try sanitizeDisplayText(testing.allocator, "h\xff\xc3\xa9!\xe2\x82\xac"); + defer testing.allocator.free(clean); + try testing.expectEqualStrings("h�é!€", clean); + try testing.expect(std.unicode.utf8ValidateSlice(clean)); +} + +test "sanitizeDisplayText: output never carries ESC" { + const inputs = [_][]const u8{ + "\x1b[31m", + "\x1b]0;title\x07", + "plain", + "a\rb\n", + "\x9b31m", + }; + for (inputs) |input| { + const clean = try sanitizeDisplayText(testing.allocator, input); + defer testing.allocator.free(clean); + try testing.expect(std.mem.indexOfScalar(u8, clean, 0x1B) == null); + try testing.expect(std.unicode.utf8ValidateSlice(clean)); + } +} + +test "sanitizeDisplayText: idempotent" { + const once = try sanitizeDisplayText(testing.allocator, "a\x1b[1mb\tc\xff"); + defer testing.allocator.free(once); + const twice = try sanitizeDisplayText(testing.allocator, once); + defer testing.allocator.free(twice); + try testing.expectEqualStrings(once, twice); +} + +test "ListModel.init paints empty before first tick" { + const items = [_][]const u8{ "src/main.zig", "README.md" }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + const model = try ListModel.init(testing.allocator, &items, &scorer, defaultTheme()); + defer model.deinit(); + try testing.expect(!model.loaded); + try testing.expectEqual(@as(usize, 0), model.filtered.items.len); + try testing.expectEqual(@as(?u32, 0), model.list_view.item_count); + try model.refilter(""); + try testing.expectEqual(@as(usize, 2), model.filtered.items.len); +} + +test "ListModel.refilter scores sanitized text, selects raw" { + const items = [_][]const u8{ "a\x1b[31mmain.zig", "README.md" }; + var scorer = try testScorer(&items); + defer scorer.deinit(); + const model = try ListModel.init(testing.allocator, &items, &scorer, defaultTheme()); + defer model.deinit(); + try model.refilter("main"); + try testing.expectEqual(@as(usize, 1), model.ranked.len); + try testing.expectEqual(@as(usize, 0), model.ranked[0].index); + for (model.filtered.items[0].text) |span| { + try testing.expect(std.mem.indexOfScalar(u8, span.text, 0x1B) == null); + } + try testing.expectEqualStrings("a\x1b[31mmain.zig", model.items[model.ranked[0].index]); +} + +test "appendCursorSpans: differs only by background" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const text = "src/main.zig"; + const matches = [_]usize{ 4, 5, 6, 7 }; + var normal = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendHighlightSpans(&normal, arena.allocator(), text, &matches, defaultTheme().match); + const theme = defaultTheme(); + var cursor = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendCursorSpans(&cursor, arena.allocator(), normal.items, theme.cursor_row); + try testing.expectEqual(normal.items.len, cursor.items.len); + for (normal.items, cursor.items) |plain, highlighted| { + try testing.expectEqualStrings(plain.text, highlighted.text); + try testing.expect(vaxis.Color.eql(plain.style.fg, highlighted.style.fg)); + try testing.expectEqual(plain.style.reverse, highlighted.style.reverse); + try testing.expectEqual(@as(u8, 8), highlighted.style.bg.index); + } + for (normal.items) |span| { + try testing.expect(span.style.bg == .default); + } +} + +test "splitGrepLine: column parsed" { + const target = splitGrepLine("src/main.zig:12:4:needle"); + try testing.expectEqualStrings("src/main.zig", target.file); + try testing.expectEqual(@as(usize, 12), target.line); + try testing.expectEqual(@as(usize, 4), target.column); +} + +test "appendDisplaySafeText: carriage returns become spaces" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendDisplaySafeText(&spans, arena.allocator(), "a\rb\r", .{}); + var rebuilt = std.ArrayListUnmanaged(u8).empty; + defer rebuilt.deinit(testing.allocator); + for (spans.items) |span| { + try testing.expect(std.mem.indexOfScalar(u8, span.text, '\r') == null); + try rebuilt.appendSlice(testing.allocator, span.text); + } + try testing.expectEqualStrings("a b ", rebuilt.items); +} + +test "appendDisplaySafeText: clean text yields one span" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendDisplaySafeText(&spans, arena.allocator(), "abc", defaultTheme().match); + try testing.expectEqual(@as(usize, 1), spans.items.len); + try testing.expectEqualStrings("abc", spans.items[0].text); + try testing.expect(spans.items[0].style.reverse); +} + +test "appendPreviewSpans: carriage returns never reach spans" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const excerpt = "> ab\rcd\n"; + var spans = std.ArrayListUnmanaged(vxfw.RichText.TextSpan).empty; + try appendPreviewSpans(&spans, arena.allocator(), excerpt, 1, 5, defaultTheme()); + for (spans.items) |span| { + try testing.expect(std.mem.indexOfScalar(u8, span.text, '\r') == null); + } +} + +test "splitGrepLine: missing column yields zero" { + const target = splitGrepLine("src/main.zig:12"); + try testing.expectEqualStrings("src/main.zig", target.file); + try testing.expectEqual(@as(usize, 12), target.line); + try testing.expectEqual(@as(usize, 0), target.column); +}