1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
|
//! 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/<pid>/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,
}
}
|