From 9085cb5bfdd0b78ff3a62c0c71fc231dd7b5052a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sun, 9 Aug 2026 10:41:33 -0300 Subject: replace ArrayLists with bounded storage --- src/look.zig | 191 +++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 135 insertions(+), 56 deletions(-) (limited to 'src/look.zig') 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 ---- -- cgit v1.3