summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 08:15:56 -0300
committerGabriel Schneider <[email protected]>2026-10-01 08:53:11 -0300
commita274bd2c706fdfd3effa0ad88c5b4083c0dc0f33 (patch)
tree1242a9341c5692e58890706a65bc89f6ad2e3633
parent577108ec6a003db9504b8376f3d88c21ccaf0765 (diff)
downloadpardes-a274bd2c706fdfd3effa0ad88c5b4083c0dc0f33.tar.gz
pardes-a274bd2c706fdfd3effa0ad88c5b4083c0dc0f33.zip
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 <[email protected]>
-rw-r--r--src/fs.zig118
-rw-r--r--src/look.zig28
-rw-r--r--src/ninep/ctl.zig5
3 files changed, 118 insertions, 33 deletions
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, &notes);
_ = 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, &notes);
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, &notes);
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, &notes);
+ 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, &notes);
+ 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 {
diff --git a/src/look.zig b/src/look.zig
index c77119c5..11c77760 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -593,7 +593,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
var out_len: usize = 0;
var nrows: usize = 0;
var anchor: ?usize = null;
- var skipped: usize = 0;
+ var notes: filesystem.WalkNotes = .{};
if (kind == .grep) {
var roots: [MAX_PANES][]const u8 = undefined;
var nroots: usize = 0;
@@ -626,13 +626,13 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
// not made yet, a peer's) has nothing to grep: passed over, not the
// search's failure.
for (roots[0..nroots]) |r|
- out_len += filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..], &skipped) catch |err| switch (err) {
+ out_len += filesystem.grep(arena, p.gpa, r, dir, pat, out[out_len..], &notes) catch |err| switch (err) {
error.FileNotFound, error.NotDir, error.AccessDenied => continue,
else => return err,
};
nrows = std.mem.count(u8, out[0..out_len], "\n");
} else if (kind == .find) {
- out_len = try filesystem.find(arena, dir, pat, out, &skipped);
+ out_len = try filesystem.find(arena, dir, pat, out, &notes);
nrows = std.mem.count(u8, out[0..out_len], "\n");
} else if (pane.hasPdf()) {
if (comptime pdf_enabled) {
@@ -673,17 +673,17 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start
nrows += 1;
}
}
- // What the walk could not open is said in one line under the hits, so
- // a locked directory reads as passed over, not as nothing there.
- if (skipped > 0) {
- const note = std.fmt.bufPrint(out[out_len..], "{d} director{s} skipped: permission denied\n", .{ skipped, if (skipped == 1) "y" else "ies" }) catch "";
- out_len += note.len;
- nrows += @intFromBool(note.len > 0);
- }
- // A Find or Grep that finds nothing empties its directory's +Search, if
- // there is one, rather than leave the last search's rows looking like
- // this one's.
- if (!open_empty and nrows == 0 and (kind == .text or !panes.Output.hasFileSearch(p, dir))) return 0;
+ // What the walk passed over -- a directory it might not open, a file
+ // read in part, a cap it stopped at -- is said in lines under the hits,
+ // so a search that found nothing where it did not look says so, never
+ // just "not found".
+ const noted = notes.write(out[out_len..]);
+ out_len += noted;
+ nrows += std.mem.count(u8, out[out_len - noted .. out_len], "\n");
+ // A Find or Grep that finds nothing leaves its directory's +Search as
+ // it was: the failure says why, and the last search's rows are still
+ // that search's (its tag names it).
+ if (!open_empty and nrows == 0) return 0;
const content = try p.gpa.dupe(u8, out[0..out_len]);
// the buffer records WHICH search filled it, pattern and all: Find and
// Grep are builtins (words you can execute), the bare `/` is a key
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index c564da6e..d4f36bea 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -2563,8 +2563,9 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir
var after: usize = 0;
for (p.panes) |slot| after += @intFromBool(slot != null);
try testing.expectEqual(count, after);
- // The +Search the miss would have filled is emptied, not left stale.
- try testing.expectEqualStrings("", found.file.?.content);
+ // The +Search a miss would have filled keeps the last search's rows:
+ // the miss is said, and its tag names what they are of.
+ try testing.expect(std.mem.indexOf(u8, found.file.?.content, "bravo here") != null);
}
test "Unmount of a name not mounted says no such mount, which a mount reads as ENOENT" {