From 65b207c3392c75eac3f2b18a266a6482d8345df1 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 16:49:51 -0300 Subject: Builtins (Find, Grep) qol improvements and ui fixes --- src/look.zig | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) (limited to 'src/look.zig') diff --git a/src/look.zig b/src/look.zig index 2b788bde..a3d6ab30 100644 --- a/src/look.zig +++ b/src/look.zig @@ -243,6 +243,123 @@ pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: *st } } +/// how much of one file Grep reads. The core is synchronous, so a tree with a +/// core dump in it must not stall the keystroke: past this the tail of the file +/// is simply not searched (`grep -R` would read it all). +const grep_max_bytes = 256 * 1024; +const grep_max_files = 20_000; + +/// every line of `text` holding `pat`, as `path:LINE:COL text` rows — the +/// shared half of grep(), and the shape every result row in pardes has: the +/// leading word is a look target, so n/N walk the hits. Returns the rows +/// written, at most `budget`. +fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: []const u8, out: *std.ArrayList(u8), budget: usize) usize { + var n: usize = 0; + var line: usize = 0; + var it = std.mem.splitScalar(u8, text, '\n'); + while (it.next()) |raw| { + line += 1; + if (n >= budget) break; + const at = std.ascii.indexOfIgnoreCase(raw, pat) orelse continue; + // one minified line can be the whole file: cut it, but never mid + // codepoint — a partial UTF-8 sequence reaches the renderer as a hit + // row and there is nothing sane for it to draw. + const ln = std.mem.trimEnd(u8, raw, " \t\r"); + var cut = @min(ln.len, 200); + while (cut > 0 and cut < ln.len and ln[cut] & 0xc0 == 0x80) cut -= 1; + const row = std.fmt.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ path, line, at + 1, ln[0..cut] }) catch break; + out.appendSlice(arena, row) catch break; + n += 1; + } + return n; +} + +/// `grep -R`, in-core: every LINE of every file under `dir` containing `pat` +/// (plain case-insensitive substring, like every other search here), one row +/// per hit into `out`. A row's path is RELATIVE to `base` — the directory of +/// the pane that asked, which is also the one its results buffer is named in, +/// so a row reads as the short name that pane would have typed and still looks +/// up. A hit `base` does not contain (another pane's tree) keeps its absolute +/// path, which resolves from anywhere. Same walk, same skip list and same three +/// caps as find(), plus grep_max_bytes and a NUL sniff so a binary never lands +/// in the results. +pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: *std.ArrayList(u8)) void { + var hits: usize = 0; + if (!platform_has_fs) { + // web: the build-generated source archive IS the filesystem + for (embedded_sources.all) |s| { + if (hits >= find_max_hits) break; + hits += grepText(arena, s.path, s.contents, pat, out, find_max_hits - hits); + } + return; + } + const root_path = std.mem.trimEnd(u8, dir, "/"); + const home = std.mem.trimEnd(u8, base, "/"); + // the walk COLLECTS, then the read scans in sorted order: readdir order is + // undefined, and rows the same tree hands back in a different order twice + // running are rows n/N cannot be trusted to walk (find() sorts for the + // same reason). e.path dies at the next next(), so these are copies. + var files: std.ArrayList([]const u8) = .empty; + { + const io = std.Io.Threaded.global_single_threaded.io(); + var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return; + defer root.close(io); + var w = root.walkSelectively(arena) catch return; + defer w.deinit(); + var steps: usize = 0; + walk: while (steps < find_max_steps and files.items.len < grep_max_files) { + steps += 1; + const e = (w.next(io) catch continue) orelse break; + if (e.kind == .directory) { + if (e.depth() >= find_max_depth) continue; + for (find_skip) |s| if (std.mem.eql(u8, e.basename, s)) continue :walk; + w.enter(io, e) catch {}; + continue; + } + if (e.kind != .file) continue; + const path = std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }) catch break; + files.append(arena, path) catch break; + } + } + std.mem.sort([]const u8, files.items, {}, struct { + fn lt(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lt); + // ONE buffer for every file: readFile is unbounded, and a synchronous + // search must not swallow a file it cannot afford to hold + const buf = gpa.alloc(u8, grep_max_bytes) catch return; + defer gpa.free(buf); + for (files.items) |path| { + if (hits >= find_max_hits) break; + var pathbuf: [4096]u8 = undefined; + const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch continue; + const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }); + if (fd < 0) continue; + var len: usize = 0; + while (len < buf.len) { + const n = libc.read(fd, buf[len..].ptr, buf.len - len); + if (n < 0) { + if (libc.errno(n) == .INTR) continue; + break; + } + if (n == 0) break; + len += @intCast(n); + } + _ = libc.close(fd); + const text = buf[0..len]; + if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; // binary + // per PATH, not per root: one root can straddle the asking pane's + // directory (a shell at `/a` searching for a file pane at `/a/b`), and + // the rows inside it are the ones worth shortening + const shown = if (path.len > home.len and std.mem.startsWith(u8, path, home) and path[home.len] == '/') + path[home.len + 1 ..] + else + path; + hits += grepText(arena, shown, text, pat, out, find_max_hits - hits); + } +} + /// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed) fn isDir(path: [*:0]const u8) bool { const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); -- cgit v1.3