//! 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; /// 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 } }; } 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: URLs leave the app; tracked Zig sources resolve inside the // build-generated, read-only source filesystem. if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://")) return .{ .url = trimmed }; 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; /// `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 ABSOLUTE path per line into `out`. /// Absolute because each line is an ordinary look target that outlives the /// walk: a terminal's cwd can move out from under its own results buffer. /// Only real directories are entered, so a symlink can never close a cycle, /// and `.git` is skipped whole (fd's default too). 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 the .git skip at all. var w = root.walkSelectively(arena) catch return; defer w.deinit(); var steps: usize = 0; 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) hits.append(arena, std.fmt.allocPrint(arena, "{s}/{s}", .{ std.mem.trimEnd(u8, dir, "/"), e.path, }) catch break) catch break; if (e.kind == .directory and e.depth() < find_max_depth and !std.mem.eql(u8, e.basename, ".git")) 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; } } /// 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, } }