commit - 901ed9b95d701f2ea92f2192810727a510191ff0
commit + 27d50783b9f968de96a425fdaa212471e74c8cd3
blob - 37ae75008306305032d1674861c875bc7c8644fd
blob + c61b4bb40e8d5eab8df4f7620c62914300444eed
--- README.md
+++ README.md
## Dependencies
- Zig 0.16.0
-- ripgrep
-- fzf
-- bat
-- fd
+- ugrep
- memcached (optional)
## Building
## Usage
```
-magdalena recent-dirs
+magdalena recent-directories
magdalena recent-files
magdalena favorites
-magdalena goto-dir
+magdalena goto-directory
magdalena goto-file
-magdalena log-dir <path>
+magdalena log-directory <path>
magdalena log-file <path> [type] [action]
magdalena search <query>
magdalena look-file [--depth <n>]
-magdalena look-dir [--depth <n>]
+magdalena look-directory [--depth <n>]
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
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(.{
.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);
.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
.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
"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
-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
+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 <path>
+ \\ magdalena log-file <path> [type] [action]
+ \\ magdalena search <query>
+ \\ magdalena look-file [--depth <n>]
+ \\ magdalena look-directory [--depth <n>]
+ \\ 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
-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 <path>
- \\ magdalena log-file <path> [type] [action]
- \\ magdalena search <query>
- \\ magdalena look-file [--depth <n>]
- \\ magdalena look-dir [--depth <n>]
- \\ 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
+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
-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
+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
-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
+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
-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
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
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,
}
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) {
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]);
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
+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
+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);
+}