magdalena

interactive shell navigation and history
Log | Files | Refs | README | LICENSE

fzf.zig (18965B)


      1 const std = @import("std");
      2 
      3 const cache = @import("memcached.zig");
      4 const cfg = @import("cfg.zig");
      5 const hist = @import("db.zig");
      6 const io = @import("io.zig");
      7 
      8 fn gotoFile(allocator: std.mem.Allocator, db: ?*hist.Db, file_path: []const u8, line: ?[]const u8, config: *const cfg.Config) !void {
      9     const ext = if (std.mem.lastIndexOfScalar(u8, file_path, '.')) |idx| file_path[idx + 1 ..] else "";
     10 
     11     var action: []const u8 = "text_editor";
     12     var command: ?[]const u8 = null;
     13 
     14     for (config.openers) |opener| {
     15         for (opener.extensions) |e| {
     16             if (std.mem.eql(u8, e, ext)) {
     17                 action = opener.action;
     18                 command = opener.command;
     19                 break;
     20             }
     21         }
     22         if (command != null) break;
     23     }
     24 
     25     if (db) |d| {
     26         d.logFile(file_path, ext, action) catch |err| io.warn("failed to log file: {}\n", .{err});
     27     }
     28 
     29     if (std.Io.Dir.openFileAbsolute(io.rt(), "/dev/tty", .{ .mode = .read_write })) |tty| {
     30         if (std.c.dup2(tty.handle, std.posix.STDIN_FILENO) < 0) io.warn("failed to redirect stdin to tty\n", .{});
     31         if (std.c.dup2(tty.handle, std.posix.STDOUT_FILENO) < 0) io.warn("failed to redirect stdout to tty\n", .{});
     32         tty.close(io.rt());
     33     } else |err| {
     34         io.warn("failed to open /dev/tty: {}\n", .{err});
     35     }
     36 
     37     if (command) |cmd| {
     38         return std.process.replace(io.rt(), .{ .argv = &.{ cmd, file_path } });
     39     }
     40 
     41     const editor = config.editor orelse (io.getenv("EDITOR") orelse return error.EditorNotSet);
     42 
     43     if (line) |l| {
     44         const line_arg = try std.fmt.allocPrint(allocator, "+{s}", .{l});
     45         return std.process.replace(io.rt(), .{ .argv = &.{ editor, line_arg, file_path } });
     46     }
     47 
     48     return std.process.replace(io.rt(), .{ .argv = &.{ editor, file_path } });
     49 }
     50 
     51 const StdIo = std.process.SpawnOptions.StdIo;
     52 
     53 const Fzf = struct {
     54     argv: [][]const u8,
     55     allocator: std.mem.Allocator,
     56     stdin: StdIo = .pipe,
     57     stdout: StdIo = .pipe,
     58     stderr: StdIo = .inherit,
     59 
     60     pub fn init(allocator: std.mem.Allocator, fzf_opts: []const []const u8, extra_opts: []const []const u8) !Fzf {
     61         var argv = try allocator.alloc([]const u8, 1 + fzf_opts.len + extra_opts.len);
     62         errdefer allocator.free(argv);
     63 
     64         var i: usize = 0;
     65         argv[i] = "fzf";
     66         i += 1;
     67         for (fzf_opts) |opt| {
     68             argv[i] = opt;
     69             i += 1;
     70         }
     71         for (extra_opts) |opt| {
     72             argv[i] = opt;
     73             i += 1;
     74         }
     75 
     76         return .{
     77             .argv = argv,
     78             .allocator = allocator,
     79         };
     80     }
     81 
     82     pub fn spawn(self: *Fzf) !std.process.Child {
     83         return std.process.spawn(io.rt(), .{
     84             .argv = self.argv,
     85             .stdin = self.stdin,
     86             .stdout = self.stdout,
     87             .stderr = self.stderr,
     88         });
     89     }
     90 
     91     pub fn deinit(self: *Fzf) void {
     92         self.allocator.free(self.argv);
     93     }
     94 };
     95 
     96 // ignored_patterns entries are matched by exact path component name, not globs.
     97 fn shouldSkip(path: []const u8, ignored_patterns: []const []const u8) bool {
     98     var it = std.mem.splitScalar(u8, path, std.fs.path.sep);
     99     while (it.next()) |component| {
    100         for (ignored_patterns) |pattern| {
    101             if (std.mem.eql(u8, component, pattern)) {
    102                 return true;
    103             }
    104         }
    105     }
    106     return false;
    107 }
    108 
    109 fn runFzfPicker(allocator: std.mem.Allocator, fzf_opts: []const []const u8, items: []const []const u8) ![]u8 {
    110     var fzf = try Fzf.init(allocator, fzf_opts, &[_][]const u8{});
    111     defer fzf.deinit();
    112     var child = try fzf.spawn();
    113 
    114     if (child.stdin) |stdin| {
    115         for (items) |item| {
    116             stdin.writeStreamingAll(io.rt(), item) catch |err| {
    117                 if (err == error.BrokenPipe) break;
    118                 return err;
    119             };
    120             stdin.writeStreamingAll(io.rt(), "\n") catch |err| {
    121                 if (err == error.BrokenPipe) break;
    122                 return err;
    123             };
    124         }
    125         stdin.close(io.rt());
    126         child.stdin = null;
    127     }
    128 
    129     var stdout_data: ?[]u8 = null;
    130     defer if (stdout_data) |d| allocator.free(d);
    131 
    132     if (child.stdout) |stdout| {
    133         var buffer: [4096]u8 = undefined;
    134         var sr = stdout.reader(io.rt(), &buffer);
    135         stdout_data = try sr.interface.allocRemaining(allocator, .unlimited);
    136     }
    137 
    138     const term = try child.wait(io.rt());
    139     if (term != .exited or term.exited != 0) return error.UserAbort;
    140     const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t");
    141     if (selected.len == 0) return error.UserAbort;
    142 
    143     return allocator.dupe(u8, selected);
    144 }
    145 
    146 pub fn recentDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
    147     const managed_dirs = try db.recentDirs();
    148     defer managed_dirs.deinit();
    149     const dirs = managed_dirs.value;
    150 
    151     if (dirs.len == 0) {
    152         io.warn("No recent directories found.\n", .{});
    153         return;
    154     }
    155 
    156     var items = std.ArrayListUnmanaged([]const u8).empty;
    157     defer items.deinit(allocator);
    158 
    159     for (dirs) |dir| {
    160         if (shouldSkip(dir.path, config.ignored_patterns)) continue;
    161         try items.append(allocator, dir.path);
    162     }
    163 
    164     const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
    165     defer allocator.free(selected);
    166 
    167     std.Io.Dir.accessAbsolute(io.rt(), selected, .{}) catch |err| {
    168         io.warn("Selected directory no longer exists: {s} ({})\n", .{ selected, err });
    169         return error.UserAbort;
    170     };
    171 
    172     var out_buf: [4096]u8 = undefined;
    173     var out = io.getStdout(&out_buf);
    174     try out.print("{s}\n", .{selected});
    175     try out.end();
    176 }
    177 
    178 pub fn favorites(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
    179     if (config.favorites.len == 0) {
    180         io.warn("No favorites found in config.\n", .{});
    181         return;
    182     }
    183 
    184     var items = std.ArrayListUnmanaged([]const u8).empty;
    185     defer {
    186         for (items.items) |item| allocator.free(item);
    187         items.deinit(allocator);
    188     }
    189 
    190     for (config.favorites) |entry| {
    191         const expanded = try expandPath(allocator, entry);
    192         try items.append(allocator, expanded);
    193     }
    194 
    195     const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
    196     defer allocator.free(selected);
    197 
    198     const is_dir = blk: {
    199         var dir = std.Io.Dir.openDirAbsolute(io.rt(), selected, .{}) catch break :blk false;
    200         dir.close(io.rt());
    201         break :blk true;
    202     };
    203 
    204     if (is_dir) {
    205         try db.logDir(selected);
    206         var out_buf: [4096]u8 = undefined;
    207         var out = io.getStdout(&out_buf);
    208         try out.print("{s}\n", .{selected});
    209         try out.end();
    210     } else {
    211         try gotoFile(allocator, db, selected, null, config);
    212     }
    213 }
    214 
    215 fn expandPath(allocator: std.mem.Allocator, path: []const u8) ![]const u8 {
    216     var res = std.ArrayListUnmanaged(u8).empty;
    217     errdefer res.deinit(allocator);
    218 
    219     var i: usize = 0;
    220     while (i < path.len) {
    221         if (std.mem.startsWith(u8, path[i..], "$HOME")) {
    222             const home = io.getenv("HOME") orelse "/home/user";
    223             try res.appendSlice(allocator, home);
    224             i += 5;
    225         } else {
    226             try res.append(allocator, path[i]);
    227             i += 1;
    228         }
    229     }
    230 
    231     return res.toOwnedSlice(allocator);
    232 }
    233 
    234 pub fn recentFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
    235     const managed_files = try db.recentFiles();
    236     defer managed_files.deinit();
    237     const files = managed_files.value;
    238 
    239     if (files.len == 0) {
    240         io.warn("No recent files found.\n", .{});
    241         return;
    242     }
    243 
    244     var items = std.ArrayListUnmanaged([]const u8).empty;
    245     defer items.deinit(allocator);
    246 
    247     for (files) |file| {
    248         if (shouldSkip(file.path, config.ignored_patterns)) continue;
    249         try items.append(allocator, file.path);
    250     }
    251 
    252     const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
    253     defer allocator.free(selected);
    254 
    255     try std.Io.Dir.accessAbsolute(io.rt(), selected, .{});
    256 
    257     try gotoFile(allocator, db, selected, null, config);
    258 }
    259 
    260 pub fn grep(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config) !void {
    261     const initial_query = "";
    262 
    263     var rg_cmd = std.ArrayListUnmanaged(u8).empty;
    264     defer rg_cmd.deinit(allocator);
    265     try rg_cmd.appendSlice(allocator, "change:reload:rg --column --line-number --no-heading --color=always --smart-case");
    266     for (config.ignored_patterns) |pattern| {
    267         try rg_cmd.appendSlice(allocator, " --glob '!");
    268         try rg_cmd.appendSlice(allocator, pattern);
    269         try rg_cmd.appendSlice(allocator, "/*'");
    270         try rg_cmd.appendSlice(allocator, " --glob '!");
    271         try rg_cmd.appendSlice(allocator, pattern);
    272         try rg_cmd.appendSlice(allocator, "'");
    273     }
    274     try rg_cmd.appendSlice(allocator, " {q} || true");
    275 
    276     var fzf = try Fzf.init(allocator, config.fzf_opts, &[_][]const u8{
    277         "--disabled",
    278         "--query",
    279         initial_query,
    280         "--bind",
    281         rg_cmd.items,
    282         "--delimiter",
    283         ":",
    284         "--preview",
    285         "bat --highlight-line {2} {1}",
    286         "--preview-window",
    287         "right,60%,border-left,+{2}+3/3,~3",
    288     });
    289     defer fzf.deinit();
    290 
    291     fzf.stdin = .inherit;
    292     fzf.stdout = .pipe;
    293     fzf.stderr = .inherit;
    294 
    295     var child = try fzf.spawn();
    296 
    297     var stdout_data: ?[]u8 = null;
    298     defer if (stdout_data) |d| allocator.free(d);
    299 
    300     if (child.stdout) |stdout| {
    301         var buffer: [4096]u8 = undefined;
    302         var sr = stdout.reader(io.rt(), &buffer);
    303         stdout_data = try sr.interface.allocRemaining(allocator, .unlimited);
    304     }
    305 
    306     const term = try child.wait(io.rt());
    307     if (term != .exited or term.exited != 0) return error.UserAbort;
    308     const selected = std.mem.trim(u8, stdout_data orelse "", " \n\r\t");
    309     if (selected.len == 0) return error.UserAbort;
    310 
    311     var it = std.mem.splitScalar(u8, selected, ':');
    312     if (it.next()) |file| {
    313         const line = it.next();
    314         try gotoFile(allocator, db, file, line, config);
    315     }
    316 }
    317 
    318 pub fn lookFile(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void {
    319     try runFzfWithWalker(allocator, db, .file, config, depth);
    320 }
    321 
    322 pub fn lookDir(allocator: std.mem.Allocator, db: *hist.Db, config: *const cfg.Config, depth: ?usize) !void {
    323     try runFzfWithWalker(allocator, db, .directory, config, depth);
    324 }
    325 
    326 const Entry = struct {
    327     path: []const u8,
    328     mtime: i128,
    329 };
    330 
    331 fn getMaxDirMtime(ignored_patterns: []const []const u8, max_depth: usize) !i128 {
    332     var max_mtime: i128 = 0;
    333 
    334     if (std.Io.Dir.cwd().statFile(io.rt(), ".", .{})) |stat| {
    335         max_mtime = stat.mtime.nanoseconds;
    336     } else |_| {}
    337 
    338     var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true });
    339     defer root.close(io.rt());
    340     try scanMaxMtime(&root, 0, max_depth, ignored_patterns, &max_mtime);
    341 
    342     return max_mtime;
    343 }
    344 
    345 // Recurses one open directory per call frame, so each handle is closed exactly
    346 // once by its own `defer` as the recursion unwinds.
    347 fn scanMaxMtime(dir: *std.Io.Dir, depth: usize, max_depth: usize, ignored_patterns: []const []const u8, max_mtime: *i128) !void {
    348     var it = dir.iterate();
    349     while (try it.next(io.rt())) |entry| {
    350         var skip = false;
    351         for (ignored_patterns) |pattern| {
    352             if (std.mem.eql(u8, entry.name, pattern)) {
    353                 skip = true;
    354                 break;
    355             }
    356         }
    357         if (skip) continue;
    358 
    359         const is_dir = entry.kind == .directory;
    360         if (is_dir or entry.kind == .sym_link) {
    361             const stat = dir.statFile(io.rt(), entry.name, .{}) catch |err| {
    362                 if (err != error.FileNotFound and err != error.AccessDenied) {
    363                     io.warn("failed to stat {s}: {}\n", .{ entry.name, err });
    364                 }
    365                 continue;
    366             };
    367             if (stat.kind == .directory) {
    368                 if (stat.mtime.nanoseconds > max_mtime.*) max_mtime.* = stat.mtime.nanoseconds;
    369 
    370                 if (is_dir and depth + 1 < max_depth) {
    371                     var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| {
    372                         io.warn("failed to open directory {s}: {}\n", .{ entry.name, err });
    373                         continue;
    374                     };
    375                     defer sub_dir.close(io.rt());
    376                     try scanMaxMtime(&sub_dir, depth + 1, max_depth, ignored_patterns, max_mtime);
    377                 }
    378             }
    379         }
    380     }
    381 }
    382 
    383 // Recurses one open directory per call frame (closed by its own `defer`),
    384 // collecting matching entries with their path relative to the walk root.
    385 fn collectEntries(
    386     allocator: std.mem.Allocator,
    387     dir: *std.Io.Dir,
    388     prefix: ?[]const u8,
    389     depth: usize,
    390     max_depth: usize,
    391     kind: std.Io.File.Kind,
    392     ignored_patterns: []const []const u8,
    393     entries: *std.ArrayListUnmanaged(Entry),
    394 ) !void {
    395     var it = dir.iterate();
    396     while (try it.next(io.rt())) |entry| {
    397         const entry_path = if (prefix) |p|
    398             try std.fs.path.join(allocator, &.{ p, entry.name })
    399         else
    400             try allocator.dupe(u8, entry.name);
    401         defer allocator.free(entry_path);
    402 
    403         if (shouldSkip(entry_path, ignored_patterns)) continue;
    404 
    405         if (entry.kind == kind or entry.kind == .sym_link) {
    406             if (dir.statFile(io.rt(), entry.name, .{})) |stat| {
    407                 if (stat.kind == kind) {
    408                     try entries.append(allocator, .{
    409                         .path = try allocator.dupe(u8, entry_path),
    410                         .mtime = stat.mtime.nanoseconds,
    411                     });
    412                 }
    413             } else |err| {
    414                 if (err != error.FileNotFound and err != error.AccessDenied) {
    415                     io.warn("failed to stat {s}: {}\n", .{ entry.name, err });
    416                 }
    417             }
    418         }
    419 
    420         if (entry.kind == .directory and depth + 1 < max_depth) {
    421             var sub_dir = dir.openDir(io.rt(), entry.name, .{ .iterate = true }) catch |err| {
    422                 io.warn("failed to open directory {s}: {}\n", .{ entry.name, err });
    423                 continue;
    424             };
    425             defer sub_dir.close(io.rt());
    426             try collectEntries(allocator, &sub_dir, entry_path, depth + 1, max_depth, kind, ignored_patterns, entries);
    427         }
    428     }
    429 }
    430 
    431 fn runFzfWithWalker(allocator: std.mem.Allocator, db: *hist.Db, kind: std.Io.File.Kind, config: *const cfg.Config, depth: ?usize) !void {
    432     const is_dir = kind == .directory;
    433     const max_depth = depth orelse config.max_depth;
    434 
    435     var entries = std.ArrayListUnmanaged(Entry).empty;
    436     defer {
    437         for (entries.items) |e| allocator.free(e.path);
    438         entries.deinit(allocator);
    439     }
    440 
    441     var mc = cache.Memcached.init(allocator, "127.0.0.1", 11211) catch |err| blk: {
    442         if (err != error.ConnectionRefused) {
    443             io.warn("failed to initialize memcached: {}\n", .{err});
    444         }
    445         break :blk null;
    446     };
    447     defer if (mc) |*m| m.deinit();
    448 
    449     var cache_key: ?[]const u8 = null;
    450     defer if (cache_key) |k| allocator.free(k);
    451 
    452     var cache_hit = false;
    453     if (mc) |*m| {
    454         const cwd = blk: {
    455             break :blk std.process.currentPathAlloc(io.rt(), allocator) catch |err| {
    456                 io.warn("failed to get cwd for cache: {}\n", .{err});
    457                 break :blk null;
    458             };
    459         };
    460         if (cwd) |c| {
    461             defer allocator.free(c);
    462 
    463             const hash = std.hash.Crc32.hash(c);
    464             const max_dir_mtime = getMaxDirMtime(config.ignored_patterns, max_depth) catch blk: {
    465                 break :blk @as(i128, 0);
    466             };
    467             const mtime = @divTrunc(max_dir_mtime, std.time.ns_per_s);
    468             cache_key = try std.fmt.allocPrint(allocator, "magdalena:look:{s}:{x}:{d}:{d}", .{ if (is_dir) "dir" else "file", hash, mtime, max_depth });
    469             if (cache_key) |key| {
    470                 const cached_data = try m.get(key);
    471                 if (cached_data) |data| {
    472                     defer allocator.free(data);
    473                     const parsed = try std.json.parseFromSlice([]Entry, allocator, data, .{});
    474                     defer parsed.deinit();
    475                     for (parsed.value) |e| {
    476                         try entries.append(allocator, .{
    477                             .path = try allocator.dupe(u8, e.path),
    478                             .mtime = e.mtime,
    479                         });
    480                     }
    481                     cache_hit = true;
    482                 }
    483             }
    484         }
    485     }
    486 
    487     if (!cache_hit) {
    488         var root = try std.Io.Dir.cwd().openDir(io.rt(), ".", .{ .iterate = true });
    489         defer root.close(io.rt());
    490         try collectEntries(allocator, &root, null, 0, max_depth, kind, config.ignored_patterns, &entries);
    491 
    492         if (mc) |*m| {
    493             if (cache_key) |key| {
    494                 const json_data = try std.json.Stringify.valueAlloc(allocator, entries.items, .{});
    495                 defer allocator.free(json_data);
    496                 try m.set(key, json_data, 3600);
    497             }
    498         }
    499     }
    500 
    501     std.mem.sortUnstable(Entry, entries.items, {}, struct {
    502         fn lessThan(_: void, a: Entry, b: Entry) bool {
    503             return a.mtime > b.mtime;
    504         }
    505     }.lessThan);
    506 
    507     var items = std.ArrayListUnmanaged([]const u8).empty;
    508     defer items.deinit(allocator);
    509     for (entries.items) |entry| {
    510         try items.append(allocator, entry.path);
    511     }
    512 
    513     const selected = try runFzfPicker(allocator, config.fzf_opts, items.items);
    514     defer allocator.free(selected);
    515 
    516     const clean_path = if (std.mem.startsWith(u8, selected, "./")) selected[2..] else selected;
    517 
    518     if (clean_path.len > 0) {
    519         std.Io.Dir.cwd().access(io.rt(), clean_path, .{}) catch |err| {
    520             io.warn("Selected item no longer exists or is inaccessible: {s} ({})\n", .{ clean_path, err });
    521             return error.UserAbort;
    522         };
    523         if (is_dir) {
    524             var out_buf: [4096]u8 = undefined;
    525             var out = io.getStdout(&out_buf);
    526             try out.print("{s}\n", .{clean_path});
    527             try out.end();
    528         } else {
    529             try gotoFile(allocator, db, clean_path, null, config);
    530         }
    531     }
    532 }
    533 
    534 const testing = std.testing;
    535 
    536 test "shouldSkip: matches an exact path component" {
    537     const ignored = [_][]const u8{ "node_modules", ".git" };
    538     try testing.expect(shouldSkip("src/node_modules/pkg/x.js", &ignored));
    539     try testing.expect(shouldSkip(".git/config", &ignored));
    540     try testing.expect(shouldSkip("a/b/.git", &ignored));
    541 }
    542 
    543 test "shouldSkip: partial component is not a match" {
    544     const ignored = [_][]const u8{"node_modules"};
    545     try testing.expect(!shouldSkip("src/lib/main.zig", &ignored));
    546     try testing.expect(!shouldSkip("src/node_modules_old/x", &ignored));
    547 }
    548 
    549 test "shouldSkip: empty pattern set never skips" {
    550     try testing.expect(!shouldSkip("a/b/c", &.{}));
    551 }