summaryrefslogtreecommitdiff
path: root/src/look.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-09 10:41:33 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commit9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a (patch)
tree91fce278abc85e4f7491de8b5f02184c8a2d5a56 /src/look.zig
parent2b1fac6fecbf7d7cc08f9dad24687b05e9968c9a (diff)
downloadpardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.tar.gz
pardes-9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a.zip
replace ArrayLists with bounded storage
Diffstat (limited to 'src/look.zig')
-rw-r--r--src/look.zig191
1 files changed, 135 insertions, 56 deletions
diff --git a/src/look.zig b/src/look.zig
index 273e20c3..7169cf8b 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -340,6 +340,11 @@ const platform_has_fs = switch (pardes.platform) {
const find_max_hits = 512;
const find_max_depth = 16;
const find_max_steps = 100_000;
+/// One search result buffer. A grep can visit one root per pane, each root can
+/// contribute `find_max_hits`, and native paths are capped at 4096 bytes below.
+/// Callers allocate this conservative ceiling once; a full buffer truncates at
+/// the last complete row.
+pub const search_max_output_bytes = pardes.MAX_PANES * find_max_hits * (4096 + 320);
/// Directories a source tree has no answers in, skipped whole. fd reads
/// .gitignore for this; pardes has no ignore parser, and every one of these
@@ -359,50 +364,61 @@ const find_skip = [_][]const u8{
/// resolves against the same directory the walk started in and reads as the
/// short name the searcher was looking for. Only real directories are
/// entered, so a symlink can never close a cycle.
-pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: *std.ArrayList(u8)) void {
- var hits: std.ArrayList([]const u8) = .empty;
+/// Filesystem setup and traversal errors are returned to the UI boundary.
+pub fn find(arena: std.mem.Allocator, dir: []const u8, pat: []const u8, out: []u8) !usize {
+ var hits: [find_max_hits][]const u8 = undefined;
+ var hits_len: usize = 0;
if (platform_has_fs) {
// Zig 0.16 moved the filesystem behind std.Io; the blocking
// single-threaded implementation (the one std.debug itself holds) IS
// the synchronous walk a sans-IO core wants — no pool, no cancelation.
const io = std.Io.Threaded.global_single_threaded.io();
- var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return;
+ var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true });
defer root.close(io);
// walkSelectively, not walk: descending is opt-in, which is the only
// way to express the depth cap and find_skip at all.
- var w = root.walkSelectively(arena) catch return;
+ var w = try root.walkSelectively(arena);
defer w.deinit();
var steps: usize = 0;
- walk: while (steps < find_max_steps and hits.items.len < find_max_hits) {
+ 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 continue) orelse break;
- if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null)
+ const e = (try w.next(io)) orelse break;
+ if (std.ascii.indexOfIgnoreCase(e.basename, pat) != null) {
// e.path points into the walker's own buffer, dead at next()
- hits.append(arena, arena.dupe(u8, e.path) catch break) catch break;
+ hits[hits_len] = try arena.dupe(u8, e.path);
+ hits_len += 1;
+ }
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 {};
+ try w.enter(io, e);
}
} else {
// web: the build-generated source archive IS the filesystem, and it is
// already a flat list of paths — the whole walk is the match.
for (embedded_sources.all) |s| {
- if (hits.items.len >= find_max_hits) break;
- if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null)
- hits.append(arena, s.path) catch break;
+ if (hits_len >= hits.len) break;
+ if (std.ascii.indexOfIgnoreCase(std.fs.path.basename(s.path), pat) != null) {
+ hits[hits_len] = s.path;
+ hits_len += 1;
+ }
}
}
// readdir order is undefined; sort so the same tree gives the same buffer
// twice running and n/N walks it in a sane order.
- std.mem.sort([]const u8, hits.items, {}, struct {
+ std.mem.sort([]const u8, hits[0..hits_len], {}, struct {
fn lt(_: void, a: []const u8, b: []const u8) bool {
return std.mem.lessThan(u8, a, b);
}
}.lt);
- for (hits.items) |h| {
- out.appendSlice(arena, h) catch return;
- out.append(arena, '\n') catch return;
+ var written: usize = 0;
+ for (hits[0..hits_len]) |h| {
+ if (h.len + 1 > out.len - written) break;
+ @memcpy(out[written..][0..h.len], h);
+ written += h.len;
+ out[written] = '\n';
+ written += 1;
}
+ return written;
}
/// how much of one file Grep reads. The core is synchronous, so a tree with a
@@ -417,13 +433,15 @@ const grep_max_files = 20_000;
/// MATCH's span and not just its first cell, so stepping onto one selects the
/// text that matched (config.range_sep). 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;
+const GrepResult = struct { bytes: usize, hits: usize };
+
+fn grepText(path: []const u8, text: []const u8, pat: []const u8, out: []u8, budget: usize) GrepResult {
+ var result: GrepResult = .{ .bytes = 0, .hits = 0 };
var line: usize = 0;
var it = std.mem.splitScalar(u8, text, '\n');
while (it.next()) |raw| {
line += 1;
- if (n >= budget) break;
+ if (result.hits >= 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
@@ -431,13 +449,13 @@ fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: [
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}{c}{d} {s}\n", .{
+ const row = std.fmt.bufPrint(out[result.bytes..], "{s}:{d}:{d}{c}{d} {s}\n", .{
path, line, at + 1, config.range_sep, at + pat.len, ln[0..cut],
}) catch break;
- out.appendSlice(arena, row) catch break;
- n += 1;
+ result.bytes += row.len;
+ result.hits += 1;
}
- return n;
+ return result;
}
/// `grep -R`, in-core: every LINE of every file under `dir` containing `pat`
@@ -449,65 +467,69 @@ fn grepText(arena: std.mem.Allocator, path: []const u8, text: []const u8, pat: [
/// 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 {
+/// Filesystem setup, traversal, and read errors are returned to the UI boundary.
+pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, base: []const u8, pat: []const u8, out: []u8) !usize {
var hits: usize = 0;
+ var written: 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);
+ if (hits >= find_max_hits or written == out.len) break;
+ const result = grepText(s.path, s.contents, pat, out[written..], find_max_hits - hits);
+ hits += result.hits;
+ written += result.bytes;
}
- return;
+ return written;
}
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;
+ // The walk collects into one bounded allocation, then the read scans in
+ // sorted order. e.path dies at the next next(), so these are copies.
+ const files = try arena.alloc([]const u8, grep_max_files);
+ var files_len: usize = 0;
{
const io = std.Io.Threaded.global_single_threaded.io();
- var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return;
+ var root = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true });
defer root.close(io);
- var w = root.walkSelectively(arena) catch return;
+ var w = try root.walkSelectively(arena);
defer w.deinit();
var steps: usize = 0;
- walk: while (steps < find_max_steps and files.items.len < grep_max_files) {
+ walk: while (steps < find_max_steps and files_len < files.len) {
steps += 1;
- const e = (w.next(io) catch continue) orelse break;
+ const e = (try w.next(io)) 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 {};
+ try w.enter(io, e);
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;
+ files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path });
+ files_len += 1;
}
}
- std.mem.sort([]const u8, files.items, {}, struct {
+ std.mem.sort([]const u8, files[0..files_len], {}, 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;
+ // ONE bounded buffer reused for every file: a synchronous search must not
+ // swallow a file it cannot afford to hold.
+ const buf = try gpa.alloc(u8, grep_max_bytes);
defer gpa.free(buf);
- for (files.items) |path| {
- if (hits >= find_max_hits) break;
+ for (files[0..files_len]) |path| {
+ if (hits >= find_max_hits or written == out.len) break;
var pathbuf: [4096]u8 = undefined;
- const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch continue;
+ const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong;
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true });
- if (fd < 0) continue;
+ if (fd < 0) return error.OpenFailed;
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;
+ _ = libc.close(fd);
+ return error.ReadFailed;
}
if (n == 0) break;
len += @intCast(n);
@@ -522,8 +544,11 @@ pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, b
path[home.len + 1 ..]
else
path;
- hits += grepText(arena, shown, text, pat, out, find_max_hits - hits);
+ const result = grepText(shown, text, pat, out[written..], find_max_hits - hits);
+ hits += result.hits;
+ written += result.bytes;
}
+ return written;
}
/// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed)
@@ -536,11 +561,18 @@ fn isDir(path: [*:0]const u8) bool {
/// Read a whole file (gpa-owned) — the look side of opening a file pane. Web
/// reads from the generated source archive; native shells read the real fs.
+const read_file_max_bytes = 256 * 1024 * 1024;
+const read_stream_max_bytes = 4 * 1024 * 1024;
+
+/// Read a whole file with one size-bounded allocation. A file that grows after
+/// fstat is read as the snapshot size; zero-size virtual files get a separate
+/// bounded stream read. Files over either applicable cap are rejected.
pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
if (!platform_has_fs) {
var normalized_buf: [4096]u8 = undefined;
const normalized = normalizeVirtualPath(path, &normalized_buf) orelse return error.OpenFailed;
const source = findEmbeddedSource(normalized, true) orelse return error.OpenFailed;
+ if (source.contents.len > read_file_max_bytes) return error.FileTooLarge;
return gpa.dupe(u8, source.contents);
}
var pathbuf: [4096]u8 = undefined;
@@ -548,19 +580,66 @@ pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
if (fd < 0) return error.OpenFailed;
defer _ = libc.close(fd);
- var buf: std.ArrayList(u8) = .empty;
- errdefer buf.deinit(gpa);
- var chunk: [16384]u8 = undefined;
- while (true) {
- const n = libc.read(fd, &chunk, chunk.len);
+ const end = libc.lseek(fd, 0, libc.SEEK.END);
+ const size: usize = if (end < 0) 0 else @intCast(end);
+ if (end >= 0 and libc.lseek(fd, 0, libc.SEEK.SET) < 0) return error.ReadFailed;
+ if (size == 0) {
+ // procfs and similar virtual files report zero size. Probe once so a
+ // genuinely empty file remains an exact zero-byte allocation, then use
+ // one conservative bounded allocation for a non-empty stream.
+ var first: [16 * 1024]u8 = undefined;
+ var first_len: usize = 0;
+ while (true) {
+ const n = libc.read(fd, &first, first.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ first_len = @intCast(n);
+ break;
+ }
+ if (first_len == 0) return gpa.alloc(u8, 0);
+ var stream = try gpa.alloc(u8, read_stream_max_bytes);
+ errdefer gpa.free(stream);
+ @memcpy(stream[0..first_len], first[0..first_len]);
+ var stream_len = first_len;
+ while (stream_len < stream.len) {
+ const n = libc.read(fd, stream[stream_len..].ptr, stream.len - stream_len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ stream_len += @intCast(n);
+ }
+ if (stream_len == stream.len) {
+ var extra: [1]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &extra, 1);
+ if (n < 0 and libc.errno(n) == .INTR) continue;
+ if (n < 0) return error.ReadFailed;
+ if (n > 0) return error.FileTooLarge;
+ break;
+ }
+ }
+ if (stream_len != stream.len) stream = try gpa.realloc(stream, stream_len);
+ return stream;
+ }
+ if (size > read_file_max_bytes) return error.FileTooLarge;
+ var buf = try gpa.alloc(u8, size);
+ errdefer gpa.free(buf);
+ 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;
return error.ReadFailed;
}
if (n == 0) break;
- try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ len += @intCast(n);
}
- return buf.toOwnedSlice(gpa);
+ if (len != buf.len) buf = try gpa.realloc(buf, len);
+ return buf;
}
// ---- shell cwd: what directory a pane's looks resolve against ----