//! 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. //! 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 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); } /// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~ pub fn isFileChar(c: u8) bool { return std.ascii.isAlphanumeric(c) or switch (c) { '.', '-', '+', '/', ':', '@', '_', '~' => true, else => false, }; } /// peel a trailing :LINE[:COL] suffix (both 1-based, 0 = absent): /// main.zig:100 -> {main.zig, 100, 0} /// main.zig:100:7 -> {main.zig, 100, 7} /// main.zig:100: -> {main.zig, 100, 0} grep -n's trailing delimiter pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize, col: usize } { var sep: usize = 0; while (sep < tok.len) : (sep += 1) { if (tok[sep] != ':') continue; var j = sep + 1; var line: usize = 0; while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0'); if (j == sep + 1) continue; // no digits after ':' if (j < tok.len and tok[j] != ':') continue; // junk after the number var col: usize = 0; if (j < tok.len) { var k = j + 1; while (k < tok.len and std.ascii.isDigit(tok[k])) : (k += 1) col = col * 10 + (tok[k] - '0'); // digits, and nothing but a delimiter after them, or no column if (k == j + 1 or (k < tok.len and tok[k] != ':')) col = 0; } return .{ .path = tok[0..sep], .line = line, .col = col }; } return .{ .path = tok, .line = 0, .col = 0 }; } pub const Target = union(enum) { none, dir: []const u8, // resolved absolute path, in caller's buf file: struct { path: []const u8, line: usize, col: usize }, 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, line: usize, col: usize }, }; const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" }; pub fn isImagePath(path: []const u8) bool { for (image_exts) |ext| { if (std.ascii.endsWithIgnoreCase(path, ext)) return true; } return false; } /// 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 > 2 and word[0] == '@' and word[1] == 'p') { var id: usize = 0; for (word[2..]) |c| { if (!std.ascii.isDigit(c)) break; id = id * 10 + (c - '0'); } else return .{ .pane = .{ .id = id, .line = pl.line, .col = pl.col } }; } // 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). if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://")) 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 (isImagePath(resolved)) return .{ .image = .{ .path = resolved } }; return .{ .file = .{ .path = resolved, .line = pl.line, .col = pl.col } }; } 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, .line = pl.line, .col = pl.col } }; 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 => 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; /// 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. 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; 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; 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; defer w.deinit(); var steps: usize = 0; walk: while (steps < find_max_steps and hits.items.len < find_max_hits) { 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) // e.path points into the walker's own buffer, dead at next() hits.append(arena, arena.dupe(u8, e.path) catch break) catch break; 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 {}; } } 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; } } // 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 { 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; } } /// 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 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. 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; var line: usize = 0; var it = std.mem.splitScalar(u8, text, '\n'); while (it.next()) |raw| { line += 1; if (n >= 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.allocPrint(arena, "{s}:{d}:{d} {s}\n", .{ path, line, at + 1, ln[0..cut] }) catch break; out.appendSlice(arena, row) catch break; n += 1; } return n; } /// `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. 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 { var hits: 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); } return; } 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; { const io = std.Io.Threaded.global_single_threaded.io(); var root = std.Io.Dir.cwd().openDir(io, dir, .{ .iterate = true }) catch return; defer root.close(io); var w = root.walkSelectively(arena) catch return; defer w.deinit(); var steps: usize = 0; walk: while (steps < find_max_steps and files.items.len < grep_max_files) { steps += 1; const e = (w.next(io) catch 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 {}; 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; } } std.mem.sort([]const u8, files.items, {}, 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; defer gpa.free(buf); for (files.items) |path| { if (hits >= find_max_hits) break; var pathbuf: [4096]u8 = undefined; const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch continue; const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true }); if (fd < 0) continue; 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; } 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; hits += grepText(arena, shown, text, pat, out, find_max_hits - hits); } } /// 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. 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; 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); 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); if (n < 0) { if (libc.errno(n) == .INTR) continue; return error.ReadFailed; } if (n == 0) break; try buf.appendSlice(gpa, chunk[0..@intCast(n)]); } return buf.toOwnedSlice(gpa); } // ---- 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, } }