//! What a click on text MEANS. The acme "look" (right click / Enter): expand //! the click to a file-ish word, then resolve it against the pane's directory. //! How a word is SPELLED — the isfilec set, the `:LINE:COL` suffix, `@pN`, the //! URL schemes, the image extensions — is config.zig; this file is only what //! the spelling RESOLVES to. //! //! This is the one deliberately platform-divergent file — the divergence is a //! comptime switch on pardes.platform, used the way the stdlib switches on //! os.tag, so every platform's behavior sits in the same screenful: //! tty/gui — the word resolves through the real filesystem (realpath, //! open(O_DIRECTORY)); dirs open shells, files open file panes. //! web — tracked Pardes .zig sources form a build-generated read-only //! filesystem; URLs still open in a new tab. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const pardes = @import("pardes.zig"); const config = @import("config.zig"); const pdf_enabled = @import("pardes_config").mupdf; const embedded_sources = if (pardes.platform == .web) @import("embedded_sources") else struct { pub const Source = struct { path: []const u8, contents: []const u8 }; pub const all = [_]Source{}; }; extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8; extern "c" fn fork() c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; // absolute opener path per OS: execv must not search PATH (no allocation // between fork and exec), same rule as the shell spawn. // ponytail: hardcoded path; a distro that puts xdg-open elsewhere (nix) needs // a PATH search in the child, which is not fork-safe here. const opener_path: ?[*:0]const u8 = switch (builtin.os.tag) { .linux => "/usr/bin/xdg-open", .macos => "/usr/bin/open", else => null, }; /// Hand a URL to the desktop — the native half of the web backend's /// window.open. Double fork: the opener is reparented to init, so the one /// child we DO wait for exits immediately and nothing is left to reap. pub fn openLink(url: []const u8) void { const opener = opener_path orelse return; var buf: [1024]u8 = undefined; const url_z = std.fmt.bufPrintSentinel(&buf, "{s}", .{url}, 0) catch return; const pid = fork(); if (pid < 0) return; if (pid == 0) { if (fork() == 0) { const argv: [3:null]?[*:0]const u8 = .{ opener, url_z.ptr, null }; _ = execv(opener, &argv); } _exit(0); } _ = libc.waitpid(pid, null, 0); } /// WHERE in a pane a look word points. A spot (`:LINE:COL`) — or a SPAN, when /// the word carries a range (config.range_sep), which a look SELECTS instead /// of merely parking on. Everything is 1-based and 0 means absent, so a bare /// path is the all-zero Spot and `end_line == 0` is the question "is this a /// range". pub const Spot = struct { line: usize = 0, col: usize = 0, end_line: usize = 0, /// 0 with a live `end_line` is the whole-lines form: through the END of /// end_line, newline included, which is what helix's `x` selects. end_col: usize = 0, }; /// digits at `i` and where they end; `end == i` means there were none. Four /// numbers now come out of the same token, and spelling the scan four times /// is how one of them ends up subtly different from the others. fn num(tok: []const u8, i: usize) struct { v: usize, end: usize } { var v: usize = 0; var j = i; while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) v = v * 10 + (tok[j] - '0'); return .{ .v = v, .end = j }; } /// peel a trailing :LINE[:COL] spot, or one of the three range spellings, off /// a look word (config.line_col_sep / config.range_sep own both characters): /// main.zig:100 -> line 100 /// main.zig:100:7 -> line 100, col 7 /// main.zig:100: -> line 100 grep -n's trailing delimiter /// main.zig:100-104 -> lines 100..104 whole /// main.zig:100:7-21 -> line 100, cols 7..21 /// main.zig:100:7-104:3 -> line 100 col 7 .. line 104 col 3 /// /// A tail that does not parse leaves the token a plain PATH, which is the rule /// that keeps the dash safe: `a-b`, `build-2:3` and `x:1-y` are all paths (the /// last one goes back to hunting for a later ':' and finds none), because a /// range needs a number on both sides of its dash. pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { if (tok[sep] != config.line_col_sep) continue; const l = num(tok, sep + 1); if (l.end == sep + 1) continue; // no digits after ':' const path = tok[0..sep]; var i = l.end; // `:LINE-ENDLINE`: whole lines, no column anywhere in the form if (i < tok.len and tok[i] == config.range_sep) { const e = num(tok, i + 1); if (e.end == i + 1) continue; // a dash with no number is not a range if (e.end < tok.len and tok[e.end] != config.line_col_sep) continue; // junk after it return .{ .path = path, .at = .{ .line = l.v, .end_line = e.v } }; } if (i < tok.len and tok[i] != config.line_col_sep) continue; // junk after the number var at: Spot = .{ .line = l.v }; if (i == tok.len) return .{ .path = path, .at = at }; // `:COL`. A column that does not parse is dropped and the LINE still // stands, which is how this has always read a half-mangled suffix. const c = num(tok, i + 1); if (c.end == i + 1) return .{ .path = path, .at = at }; if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep) return .{ .path = path, .at = at }; at.col = c.v; i = c.end; if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at }; // `-ENDCOL` on this same line, unless a `:ENDCOL` follows — then that // first number was the end LINE all along. One lookahead, and it is // what lets the two-number and four-number forms share a spelling. const e = num(tok, i + 1); if (e.end == i + 1) return .{ .path = path, .at = at }; at.end_line = at.line; at.end_col = e.v; if (e.end < tok.len and tok[e.end] == config.line_col_sep) { const e2 = num(tok, e.end + 1); if (e2.end > e.end + 1) { at.end_line = at.end_col; at.end_col = e2.v; } } return .{ .path = path, .at = at }; } return .{ .path = tok, .at = .{} }; } test "parsePathLine: spots, ranges, and the paths that merely look like them" { const cases = [_]struct { tok: []const u8, path: []const u8, at: Spot }{ .{ .tok = "main.zig", .path = "main.zig", .at = .{} }, .{ .tok = "main.zig:100", .path = "main.zig", .at = .{ .line = 100 } }, .{ .tok = "main.zig:100:", .path = "main.zig", .at = .{ .line = 100 } }, .{ .tok = "main.zig:100:7", .path = "main.zig", .at = .{ .line = 100, .col = 7 } }, .{ .tok = "main.zig:100-104", .path = "main.zig", .at = .{ .line = 100, .end_line = 104 } }, .{ .tok = "main.zig:100:7-21", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 100, .end_col = 21 } }, .{ .tok = "main.zig:100:7-104:3", .path = "main.zig", .at = .{ .line = 100, .col = 7, .end_line = 104, .end_col = 3 } }, // the dash cases that must stay ORDINARY PATHS .{ .tok = "my-file.zig", .path = "my-file.zig", .at = .{} }, .{ .tok = "my-file:10", .path = "my-file", .at = .{ .line = 10 } }, .{ .tok = "x:1-y", .path = "x:1-y", .at = .{} }, .{ .tok = "a-b-c", .path = "a-b-c", .at = .{} }, .{ .tok = "2026-07-30", .path = "2026-07-30", .at = .{} }, // a mangled tail still yields what parsed (unchanged behaviour) .{ .tok = "main.zig:100x", .path = "main.zig:100x", .at = .{} }, .{ .tok = "main.zig:100:7x", .path = "main.zig", .at = .{ .line = 100 } }, }; for (cases) |c| { const got = parsePathLine(c.tok); try std.testing.expectEqualStrings(c.path, got.path); try std.testing.expectEqual(c.at, got.at); } } /// A file-like Look target has a rendering kind only in MuPDF builds. The /// feature-off enum has no `pdf` tag at all, so `.pdf` is indistinguishable /// from any other ordinary file before it reaches the core. pub const FileKind = if (pdf_enabled) enum { text, pdf } else enum { text }; pub const FileTarget = struct { path: []const u8, at: Spot, kind: FileKind = .text, }; pub const Target = union(enum) { none, dir: []const u8, // resolved absolute path, in caller's buf file: FileTarget, image: struct { path: []const u8 }, url: []const u8, /// `@p7:10:5` — pane 7, line 10, column 5 (0 = unspecified). The one /// target that names a live pane instead of a path, because terminals and /// output buffers have no file for a location to point at. pane: struct { id: usize, at: Spot }, }; pub fn isImagePath(path: []const u8) bool { for (config.image_exts) |ext| { if (std.ascii.endsWithIgnoreCase(path, ext)) return true; } return false; } pub fn isPdfPath(path: []const u8) bool { if (comptime !pdf_enabled) return false; return std.ascii.endsWithIgnoreCase(path, ".pdf"); } test "PDF file kinds exist only in MuPDF-enabled builds" { try std.testing.expectEqual(pdf_enabled, isPdfPath("manual.PDF")); try std.testing.expect(!isPdfPath("manual.pdf.txt")); try std.testing.expectEqual( pdf_enabled, std.meta.stringToEnum(FileKind, "pdf") != null, ); try std.testing.expect(std.meta.stringToEnum(std.meta.Tag(Target), "pdf") == null); } test ".pdf Look paths are ordinary files when MuPDF is disabled" { if (!platform_has_fs) return; var realbuf: [4096]u8 = undefined; const target = resolve("docs/design.pdf", ".", &realbuf); switch (target) { .file => |file| { if (comptime pdf_enabled) try std.testing.expectEqual(FileKind.pdf, file.kind) else try std.testing.expectEqual(FileKind.text, file.kind); }, else => return error.PdfDidNotResolveAsFile, } } /// Resolve a looked-at word against the pane's directory. `realbuf` must /// outlive the returned Target (native paths point into it; web paths are /// process-lifetime slices in the embedded source archive). pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target { const trimmed = std.mem.trim(u8, word_raw, " \t\r\n"); const pl = parsePathLine(trimmed); const word = pl.path; if (word.len == 0) return .none; // `@pN` addresses a pane, not a path: every platform, before the fs. if (word.len > config.pane_addr.len and std.mem.startsWith(u8, word, config.pane_addr)) { var id: usize = 0; for (word[config.pane_addr.len..]) |c| { if (!std.ascii.isDigit(c)) break; id = id * 10 + (c - '0'); } else return .{ .pane = .{ .id = id, .at = pl.at } }; } // a URL is a URL everywhere: no filesystem can answer it, so it leaves the // app (browser tab on web, xdg-open/open on the desktop). for (config.url_schemes) |scheme| { if (std.mem.startsWith(u8, trimmed, scheme)) return .{ .url = trimmed }; } if (platform_has_fs) { var joinbuf: [2048]u8 = undefined; const joined: ?[:0]u8 = if (word[0] == '/') (std.fmt.bufPrintSentinel(&joinbuf, "{s}", .{word}, 0) catch null) else (std.fmt.bufPrintSentinel(&joinbuf, "{s}/{s}", .{ cwd, word }, 0) catch null); const jz = joined orelse return .none; const rp = realpath(jz.ptr, realbuf) orelse return .none; const resolved = std.mem.span(rp); if (isDir(rp)) return .{ .dir = resolved }; if (comptime pdf_enabled) if (isPdfPath(resolved)) return .{ .file = .{ .path = resolved, .at = pl.at, .kind = .pdf, } }; if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } }; return .{ .file = .{ .path = resolved, .at = pl.at } }; } else { // web: tracked Zig sources resolve inside the build-generated, // read-only source filesystem. if (resolveEmbedded(word, cwd, realbuf)) |source| return .{ .file = .{ .path = source.path, .at = pl.at } }; return .none; } } /// Resolve a source path without teaching the core about a browser filesystem. /// Cwd-relative and absolute dump paths are normalized, with printed archive /// paths also accepted root-relative. The suffix match lets a dump made in /// `/host/repo` address names that deliberately remain relative to the root. fn resolveEmbedded(word: []const u8, cwd: []const u8, scratch: *[4096]u8) ?embedded_sources.Source { var wordbuf: [4096]u8 = undefined; const normalized_word = normalizeVirtualPath(word, &wordbuf) orelse return null; if (word.len > 0 and word[0] == '/') return findEmbeddedSource(normalized_word, true); var joined: [4096]u8 = undefined; if (std.fmt.bufPrint(&joined, "{s}/{s}", .{ cwd, word }) catch null) |candidate| if (normalizeVirtualPath(candidate, scratch)) |normalized| if (findEmbeddedSource(normalized, true)) |source| return source; // A printed archive path is root-relative even when its surrounding dump // pane came from some unrelated cwd. return findEmbeddedSource(normalized_word, false); } fn normalizeVirtualPath(path: []const u8, out: *[4096]u8) ?[]const u8 { var len: usize = 0; var parts = std.mem.tokenizeAny(u8, path, "/\\"); while (parts.next()) |part| { if (std.mem.eql(u8, part, ".")) continue; if (std.mem.eql(u8, part, "..")) { while (len > 0 and out[len - 1] != '/') len -= 1; if (len > 0) len -= 1; continue; } const extra = part.len + @intFromBool(len != 0); if (len + extra > out.len) return null; if (len != 0) { out[len] = '/'; len += 1; } @memcpy(out[len..][0..part.len], part); len += part.len; } if (len == 0) return null; return out[0..len]; } fn findEmbeddedSource(path: []const u8, allow_root_suffix: bool) ?embedded_sources.Source { for (embedded_sources.all) |source| if (std.mem.eql(u8, source.path, path)) return source; if (!allow_root_suffix) return null; for (embedded_sources.all) |source| { if (path.len <= source.path.len or path[path.len - source.path.len - 1] != '/') continue; if (std.mem.endsWith(u8, path, source.path)) return source; } return null; } const platform_has_fs = switch (pardes.platform) { .tty, .gui, .macos => true, .web => false, }; // Find's safety rails. The core is SYNCHRONOUS — a Find at `/` runs inside the // keystroke that asked for it — so the walk must end whatever it is pointed at. // Three caps, because each alone leaks: hits bound the results buffer, depth // bounds a deep tree, and steps bound a wide shallow one (a pattern that never // matches would otherwise walk the whole disk without ever filling `hits`). 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 /// costs a real search: agave's `target/` alone is 456_000 of its 460_000 /// entries and holds 1_200 of the 1_242 paths matching "bank", so a Find for /// `bank` burned the whole 512-hit budget on build artifacts and never /// reached `runtime/src/bank.rs`. That looked like a broken matcher. const find_skip = [_][]const u8{ ".git", ".jj", "target", "node_modules", ".venv", "__pycache__", ".zig-cache", "zig-out", }; /// `fd`, in-core: every path under `dir` whose NAME contains `pat` (plain /// case-insensitive substring — fd's default is a regex and pardes has no /// regex engine to spend on one), one path per line into `out`, RELATIVE to /// `dir` — the results buffer is itself named `dir/+Search`, so every row /// 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. /// 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 = 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 = try root.walkSelectively(arena); defer w.deinit(); var steps: usize = 0; 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 = (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[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; 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_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[0..hits_len], {}, struct { fn lt(_: void, a: []const u8, b: []const u8) bool { return std.mem.lessThan(u8, a, b); } }.lt); 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 /// 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-ENDCOL 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. The row names the /// 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`. 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 (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 // 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.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; result.bytes += row.len; result.hits += 1; } return result; } /// `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. /// 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 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 written; } const root_path = std.mem.trimEnd(u8, dir, "/"); const home = std.mem.trimEnd(u8, base, "/"); // 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 = try std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }); defer root.close(io); var w = try root.walkSelectively(arena); defer w.deinit(); var steps: usize = 0; walk: while (steps < find_max_steps and files_len < files.len) { steps += 1; 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; try w.enter(io, e); continue; } if (e.kind != .file) continue; files[files_len] = try std.fmt.allocPrint(arena, "{s}/{s}", .{ root_path, e.path }); files_len += 1; } } 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 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[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 return error.PathTooLong; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }); 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; _ = libc.close(fd); return error.ReadFailed; } 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; 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) fn isDir(path: [*:0]const u8) bool { const fd = libc.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }); if (fd < 0) return false; _ = libc.close(fd); return true; } /// 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; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY }); if (fd < 0) return error.OpenFailed; defer _ = libc.close(fd); 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; len += @intCast(n); } if (len != buf.len) buf = try gpa.realloc(buf, len); return buf; } // ---- shell cwd: what directory a pane's looks resolve against ---- // macOS has no /proc; libproc's proc_pidinfo(PROC_PIDVNODEPATHINFO) yields the // cwd vnode path. Not in std.c — layout from xnu's sys/proc_info.h. const vnode_info_path = extern struct { vi: [152]u8 align(8), // struct vnode_info: vinfo_stat + type + pad + fsid path: [1024]u8, // MAXPATHLEN }; const proc_vnodepathinfo = extern struct { cdir: vnode_info_path, rdir: vnode_info_path, }; const PROC_PIDVNODEPATHINFO: c_int = 9; extern "c" fn proc_pidinfo(pid: c_int, flavor: c_int, arg: u64, buffer: *anyopaque, buffersize: c_int) c_int; /// Live cwd of a shell process (pane tags, look resolution). linux reads /// /proc//cwd, darwin asks libproc; other POSIX systems have no cheap /// answer — return null and panes keep their spawn-time cwd (callers already /// tolerate failure: dead shells have no cwd either). pub fn shellCwd(pid: libc.pid_t, buf: *[1024]u8) ?[]const u8 { switch (builtin.os.tag) { .linux => { var pbuf: [64]u8 = undefined; const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pid}, 0) catch return null; const n = libc.readlink(path, buf, buf.len); if (n <= 0) return null; return buf[0..@intCast(n)]; }, .macos, .ios, .tvos, .watchos, .visionos => { var info: proc_vnodepathinfo = undefined; const n = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, @sizeOf(proc_vnodepathinfo)); if (n < @as(c_int, @sizeOf(proc_vnodepathinfo))) return null; const path = std.mem.sliceTo(&info.cdir.path, 0); if (path.len == 0) return null; @memcpy(buf[0..path.len], path); return buf[0..path.len]; }, else => return null, } }