From a274bd2c706fdfd3effa0ad88c5b4083c0dc0f33 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 08:15:56 -0300 Subject: Grep and Find say when a cap hid what they might have found: `cut at 512 hits`, `N files read only in part (first 256 KiB)`, `walk cut at N entries`; one that finds nothing leaves the +Search as it was Grep reads 256 KiB of a file and stops at 512 hits, and its walk at 20000 files or 100000 entries; Find stops at 512 names. A cap hit was silent, so a match past one read as `pattern not found`. The walk now notes each cap it hit (fs.WalkNotes) and the +Search says it in a line under the hits, as it says directories skipped; a search that found nothing but hit a cap opens the +Search with those lines, not a miss. A Find or Grep that finds nothing and passed nothing over leaves the directory's +Search with the last search's rows, its failure saying so, where it used to empty it. Co-Authored-By: Claude Opus 5.5 --- src/fs.zig | 118 ++++++++++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 101 insertions(+), 17 deletions(-) (limited to 'src/fs.zig') diff --git a/src/fs.zig b/src/fs.zig index 7d6b0d71..9eb3fd0f 100644 --- a/src/fs.zig +++ b/src/fs.zig @@ -1070,6 +1070,43 @@ pub const platform_has_fs = !pardes.isolated and switch (pardes.platform) { }; const find_max_hits = 512; + +/// What a Find or Grep walk passed over, said under its hits: directories +/// it might not open, files read only in part, and caps it stopped at. +pub const WalkNotes = struct { + skipped: usize = 0, + /// Files longer than grep_max_bytes, searched in their first part only. + partial: usize = 0, + hits_cut: bool = false, + /// The cap on entries walked it stopped at, 0 if none. + walk_cut: usize = 0, + + pub const max_hits = find_max_hits; + pub const max_file_bytes = grep_max_bytes; + pub const max_files = grep_max_files; + pub const max_depth = find_max_depth; + + /// The lines that say it, each ending in a newline, into `out`. + pub fn write(n: WalkNotes, out: []u8) usize { + var w = std.Io.Writer.fixed(out); + if (n.hits_cut) w.print("cut at {d} hits\n", .{max_hits}) catch {}; + if (n.partial > 0) w.print("{d} file{s} read only in part (first {d} KiB)\n", .{ n.partial, if (n.partial == 1) "" else "s", max_file_bytes / 1024 }) catch {}; + if (n.walk_cut > 0) w.print("walk cut at {d} entries\n", .{n.walk_cut}) catch {}; + if (n.skipped > 0) w.print("{d} director{s} skipped: permission denied\n", .{ n.skipped, if (n.skipped == 1) "y" else "ies" }) catch {}; + return w.buffered().len; + } + + /// Whether `line` is one of those, not a hit. + pub fn isNote(line: []const u8) bool { + if (std.mem.startsWith(u8, line, "cut at ") or std.mem.startsWith(u8, line, "walk cut at ")) return true; + const digits = for (line, 0..) |c, i| { + if (!std.ascii.isDigit(c)) break i; + } else line.len; + if (digits == 0) return false; + const rest = line[digits..]; + return std.mem.startsWith(u8, rest, " director") or std.mem.startsWith(u8, rest, " file"); + } +}; const find_max_depth = 16; const find_max_steps = 100_000; pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320); @@ -1080,9 +1117,9 @@ const find_skip = [_][]const u8{ }; /// A directory the walk may not open or read is passed over and counted in -/// `skipped`, its siblings' hits kept: one locked directory is not every -/// search's failure. -pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8, skipped: *usize) !usize { +/// `notes.skipped`, its siblings' hits kept: one locked directory is not +/// every search's failure. A cap the walk hit is noted too. +pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8, notes: *WalkNotes) !usize { var hits: [find_max_hits][]const u8 = undefined; var hits_len: usize = 0; if (platform_has_fs) { @@ -1097,7 +1134,7 @@ pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u walk: while (steps < find_max_steps and hits_len < hits.len) { steps += 1; // an unreadable dir burns a step too, so it cannot spin const e = (w.next(io) catch { - skipped.* += 1; + notes.skipped += 1; continue; }) orelse break; if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) { @@ -1107,9 +1144,11 @@ pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u if (e.kind != .directory or 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 { - skipped.* += 1; + notes.skipped += 1; }; } + if (hits_len >= hits.len) notes.hits_cut = true; + if (steps >= find_max_steps) notes.walk_cut = find_max_steps; } else { for (sources) |s| { if (hits_len >= hits.len) break; @@ -1160,7 +1199,7 @@ fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budg return result; } -pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8, skipped: *usize) !usize { +pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8, notes: *WalkNotes) !usize { var hits: usize = 0; var written: usize = 0; if (!platform_has_fs) { @@ -1189,14 +1228,14 @@ pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, b walk: while (steps < find_max_steps and files_len < files.len) { steps += 1; const e = (w.next(io) catch { - skipped.* += 1; + notes.skipped += 1; 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 { - skipped.* += 1; + notes.skipped += 1; }; continue; } @@ -1204,6 +1243,7 @@ pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, b files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); files_len += 1; } + if (files_len >= files.len) notes.walk_cut = grep_max_files else if (steps >= find_max_steps) notes.walk_cut = find_max_steps; } std.mem.sort([]const u8, files[0..files_len], {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { @@ -1213,7 +1253,10 @@ pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, b const buf = try gpa.alloc(u8, grep_max_bytes); defer gpa.free(buf); for (files[0..files_len]) |path| { - if (hits >= find_max_hits or written == out.len) break; + if (hits >= find_max_hits or written == out.len) { + notes.hits_cut = true; + break; + } var pathbuf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true }); @@ -1230,14 +1273,19 @@ pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, b if (n == 0) break; len += @intCast(n); } + // Read to its cap with more behind it: searched in part, and said. + var more: [1]u8 = undefined; + const longer = readable and len == buf.len and libc.read(fd, &more, 1) > 0; _ = libc.close(fd); if (!readable) continue; const text = buf[0..len]; if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; + if (longer) notes.partial += 1; const shown = lsp.rel(home, path); const result = grepText(shown, text, pat, out[written..], find_max_hits - hits); hits += result.hits; written += result.bytes; + if (hits >= find_max_hits) notes.hits_cut = true; } return written; } @@ -1266,8 +1314,8 @@ test "grep skips a file it cannot read instead of abandoning the search" { defer arena.deinit(); const out = try gpa.alloc(u8, 64 * 1024); defer gpa.free(out); - var skipped: usize = 0; - const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out, &skipped); + var notes: WalkNotes = .{}; + const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out, ¬es); _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null); @@ -1293,14 +1341,50 @@ test "a directory the walk may not open is skipped and counted: Grep and Find ke defer arena.deinit(); const out = try gpa.alloc(u8, 64 * 1024); defer gpa.free(out); - var skipped: usize = 0; - var n = try grep(arena.allocator(), gpa, dir, dir, "needle", out, &skipped); + var notes: WalkNotes = .{}; + var n = try grep(arena.allocator(), gpa, dir, dir, "needle", out, ¬es); try std.testing.expect(std.mem.indexOf(u8, out[0..n], "needle-out.txt:1:") != null); - try std.testing.expectEqual(@as(usize, 1), skipped); - skipped = 0; - n = try find(arena.allocator(), dir, "needle", out, &skipped); + try std.testing.expectEqual(@as(usize, 1), notes.skipped); + notes = .{}; + n = try find(arena.allocator(), dir, "needle", out, ¬es); try std.testing.expect(std.mem.indexOf(u8, out[0..n], "needle-out.txt") != null); - try std.testing.expectEqual(@as(usize, 1), skipped); + try std.testing.expectEqual(@as(usize, 1), notes.skipped); +} + +test "Grep says when a cap hid what it might have found: a file read in part, the hits cut" { + if (!platform_has_fs) return; + const gpa = std.testing.allocator; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var base_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)]; + // The match past the first 256 KiB of a long file. + const long = try gpa.alloc(u8, grep_max_bytes + 64); + defer gpa.free(long); + @memset(long, 'x'); + for (0..long.len / 64) |i| long[i * 64] = '\n'; + @memcpy(long[long.len - 8 ..][0..7], "needle\n"); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "long.txt", .data = long }); + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + const out = try gpa.alloc(u8, 256 * 1024); + defer gpa.free(out); + var notes: WalkNotes = .{}; + var n = try grep(arena.allocator(), gpa, dir, dir, "needle", out, ¬es); + try std.testing.expectEqual(@as(usize, 0), n); + try std.testing.expectEqual(@as(usize, 1), notes.partial); + // More hits than the cap. + var many: std.ArrayList(u8) = .empty; + defer many.deinit(gpa); + for (0..find_max_hits + 10) |_| try many.appendSlice(gpa, "needle\n"); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "many.txt", .data = many.items }); + notes = .{}; + n = try grep(arena.allocator(), gpa, dir, dir, "needle", out, ¬es); + try std.testing.expect(notes.hits_cut); + var said: [256]u8 = undefined; + const lines = said[0..notes.write(&said)]; + try std.testing.expectEqualStrings("cut at 512 hits\n1 file read only in part (first 256 KiB)\n", lines); + try std.testing.expect(WalkNotes.isNote("cut at 512 hits") and WalkNotes.isNote("2 directories skipped: permission denied") and !WalkNotes.isNote("src/2 directories.txt")); } fn isDir(path: [*:0]const u8) bool { -- cgit v1.3