summaryrefslogtreecommitdiff
path: root/src/look.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-06 18:11:36 -0300
committerGabriel Schneider <[email protected]>2026-09-07 13:59:12 -0300
commit60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch)
tree310fc734173cf771881f4691c71909135fadde97 /src/look.zig
parentfa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff)
downloadpardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz
pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples. Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/look.zig')
-rw-r--r--src/look.zig1472
1 files changed, 46 insertions, 1426 deletions
diff --git a/src/look.zig b/src/look.zig
index 236b6950..daea5221 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -1,45 +1,22 @@
-//! 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 lsp = @import("lsp/lsp.zig");
const config = @import("config.zig");
const pdf_enabled = @import("pardes_config").mupdf;
-/// The virtual filesystem, on every platform: the browser has only this, and
-/// `run-isolated` chooses it (see `isolated` below).
-const embedded_sources = @import("source_manifest.zig");
+const fs = @import("fs.zig");
+const platform_has_fs = fs.platform_has_fs;
-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;
@@ -56,59 +33,20 @@ pub fn openLink(url: []const u8) void {
_ = 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;
- // SATURATING, and this is not defensive programming — it is the fix for a
- // crash on an ordinary keystroke. The digits come off whatever word is
- // under the pointer, so `*` and `+` here run on text the user never wrote
- // and cannot control: a right-click, an Enter, or an `n` on anything shaped
- // `foo:99999999999999999999` — a hash in a log, a column of a CSV, the
- // output of any program — overflowed a `usize` and took the editor down
- // with "integer overflow". A number too big to be a line is not a line, and
- // `maxInt` is refused by every consumer for free: `file_pane.open` asks
- // `line <= total` and `focusPaneLine` asks `id < MAX_PANES`. Same shape
- // acmefs.zig's address parser already uses.
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.
-///
-/// `end` is how far into `tok` the form actually REACHED. The read is lenient
-/// by design — `main.zig:100:7x` is the file at line 100 and the mangled `:7x`
-/// is simply dropped — so `end == tok.len` is the separate question "is the
-/// whole token this target and nothing else", which is what a row-grained step
-/// must ask before it selects a run of a line (lookableLineSpan).
pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end: usize } {
var sep: usize = 0;
while (sep < tok.len) : (sep += 1) {
@@ -117,7 +55,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end:
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
@@ -127,10 +64,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end:
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, .end = i };
- // `: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 — and
- // `end` stops at the last character that DID read, so the caller that
- // cares can tell the two apart.
const c = num(tok, i + 1);
if (c.end == i + 1) return .{ .path = path, .at = at, .end = i };
if (c.end < tok.len and tok[c.end] != config.line_col_sep and tok[c.end] != config.range_sep)
@@ -138,9 +71,6 @@ pub fn parsePathLine(tok: []const u8) struct { path: []const u8, at: Spot, end:
at.col = c.v;
i = c.end;
if (i == tok.len or tok[i] != config.range_sep) return .{ .path = path, .at = at, .end = i };
- // `-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, .end = i };
at.end_line = at.line;
@@ -168,13 +98,11 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" {
.{ .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 } },
};
@@ -186,11 +114,6 @@ test "parsePathLine: spots, ranges, and the paths that merely look like them" {
}
test "a number too big to be a line saturates instead of taking the editor down" {
- // These are keystrokes, not arguments. `parsePathLine` runs on whatever
- // word is under the pointer on a right-click, an Enter or an `n` — so the
- // digits come out of a hash in a log, a CSV column, or any program's
- // output, and an unchecked `v * 10` there is a panic on ordinary use. Every
- // number in the token comes through the same scan, so all four are tried.
const huge = "99999999999999999999999999";
const cases = [_][]const u8{
"f.zig:" ++ huge,
@@ -201,31 +124,21 @@ test "a number too big to be a line saturates instead of taking the editor down"
for (cases) |tok| {
const got = parsePathLine(tok);
try std.testing.expectEqualStrings("f.zig", got.path);
- // Saturated rather than wrapped: a wrap would address a REAL line, and
- // silently jumping somewhere is worse than not jumping.
try std.testing.expect(got.at.line >= 1);
}
- // ...and the pane address, which has its own scan. `focusPaneLine` refuses
- // anything past MAX_PANES, so this resolves to a pane that cannot exist.
var realbuf: [4096]u8 = undefined;
- const target = resolve("@p" ++ huge, "/tmp", &realbuf);
+ const target = resolve(null, "@p" ++ huge, "/tmp", &realbuf);
try std.testing.expect(target == .pane);
try std.testing.expect(target.pane.id >= 16);
}
test "parsePathLine: `end` separates a whole-token target from a lenient read" {
- // the whole token IS the target: every spelling the doc above lists
for ([_][]const u8{
"main.zig", "main.zig:100", "main.zig:100:7",
"main.zig:100-104", "main.zig:100:7-21", "main.zig:100:7-104:3",
"@p3:10:5", "x:1-y",
}) |tok| try std.testing.expectEqual(tok.len, parsePathLine(tok).end);
- // ...and the reads that DROP a tail: a result row with its matched text
- // still attached, which is exactly what a row-grained step must not select
- // whole (lookableLineSpan). Note where each one STOPS — a spot is only
- // taken once its whole form has read, so the `:7` of a `:100:7 text` row
- // is dropped along with the text and `end` says so.
const partial = [_]struct { tok: []const u8, end: usize }{
.{ .tok = "main.zig:100:", .end = "main.zig:100".len }, // trailing ':' is peeled, not parsed
.{ .tok = "main.zig:100:7x", .end = "main.zig:100".len },
@@ -234,17 +147,11 @@ test "parsePathLine: `end` separates a whole-token target from a lenient read" {
.{ .tok = "@p3:10:5 /home/goblin", .end = "@p3:10".len },
};
for (partial) |c| try std.testing.expectEqual(c.end, parsePathLine(c.tok).end);
- // A form that breaks off mid-range is not a lenient read at all: the scan
- // goes back for a later ':', finds none, and the token is a plain PATH
- // whole — which resolves or does not on its own merits.
const whole = "main.zig:100-104 whole lines";
try std.testing.expectEqual(whole.len, parsePathLine(whole).end);
try std.testing.expectEqualStrings(whole, parsePathLine(whole).path);
}
-/// 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 {
@@ -259,9 +166,6 @@ pub const Target = union(enum) {
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 },
};
@@ -290,7 +194,7 @@ test "PDF file kinds exist only in MuPDF-enabled builds" {
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);
+ const target = resolve(null, "docs/design.pdf", ".", &realbuf);
switch (target) {
.file => |file| {
if (comptime pdf_enabled)
@@ -302,110 +206,48 @@ test ".pdf Look paths are ordinary files when MuPDF is disabled" {
}
}
-/// Where a look-able word actually SITS inside a run of non-whitespace.
pub const Span = struct { start: usize, end: usize };
-/// The punctuation a path wears in prose and never owns. Two sets, because
-/// the two ends are not alike: a directory may legally END in `/`, and the
-/// `:` that closes `grep -n`'s `main.zig:100:` is junk on the right and
-/// meaningful nowhere on the left.
const lead_trim = "([{<\"'`*";
const trail_trim = ")]}>\"'`*,;:.!?";
-/// The largest look-able span inside one whitespace-delimited `word`, or null
-/// when nothing in it resolves. This is the WORD grain of n/N — split a row on
-/// whitespace and take the biggest piece of each run Look can act on — which
-/// is what a terminal, a file and a PDF step, because their lines are free
-/// text and a line may hold several places (an `ls` row hops file to file).
-/// A results buffer steps ROWS instead: lookableLineSpan.
-///
-/// TWO resolve attempts at most, which is what keeps a motion across a
-/// screenful of prose from being a hundred realpaths: the run with every
-/// wrapper character peeled off BOTH ends at once, then — only if that found
-/// nothing — the run exactly as written.
-///
-/// PEELED FIRST, which is the ordering that matters. `resolve` is lenient
-/// about a tail it cannot parse (`main.zig:12:3,` yields the FILE and drops
-/// the position, by design), so asking it about the raw run first would
-/// happily answer yes and swallow the comma along with the `:3`. Peeling
-/// first hands it `main.zig:12:3` and the look lands on the column. The raw
-/// run stays as the fallback for the file genuinely named `foo,` or `..`,
-/// where the peel eats something real.
-///
-/// Deliberately NOT a search for the longest resolving substring: that costs
-/// a syscall per prefix to find a path hiding inside a word nobody typed as
-/// one. A run needing a cleverer peel is still one Enter away with the cursor
-/// parked on it.
-///
-/// Direction-free on purpose: n and N ask this the same question about the
-/// same run and get the same span back, which is what lets the two motions be
-/// exact inverses of each other.
-pub fn lookableSpan(word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span {
+pub fn wordSpan(p: ?*pardes.Pardes, word: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span {
if (word.len == 0) return null;
var lo: usize = 0;
var hi: usize = word.len;
while (lo < hi and std.mem.indexOfScalar(u8, lead_trim, word[lo]) != null) lo += 1;
while (hi > lo and std.mem.indexOfScalar(u8, trail_trim, word[hi - 1]) != null) hi -= 1;
- if (lo < hi and resolve(word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi };
- // nothing came off, so the peeled attempt WAS the raw one
+ if (lo < hi and resolve(p, word[lo..hi], cwd, realbuf) != .none) return .{ .start = lo, .end = hi };
if (lo == 0 and hi == word.len) return null;
- if (resolve(word, cwd, realbuf) == .none) return null;
+ if (resolve(p, word, cwd, realbuf) == .none) return null;
return .{ .start = 0, .end = word.len };
}
test "lookableSpan peels prose punctuation off a path, largest first" {
if (!platform_has_fs) return;
var realbuf: [4096]u8 = undefined;
- // the bare run resolves whole, wrappers and all left alone
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 0, .end = "src/look.zig".len }),
- lookableSpan("src/look.zig", ".", &realbuf),
+ wordSpan(null, "src/look.zig", ".", &realbuf),
);
- // ...and a wrapped one gives back the span INSIDE the wrappers
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 1, .end = 1 + "src/look.zig".len }),
- lookableSpan("(src/look.zig),", ".", &realbuf),
+ wordSpan(null, "(src/look.zig),", ".", &realbuf),
);
- // the `:LINE:COL` tail is part of the span: it is what a look READS
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 0, .end = "src/look.zig:12:3".len }),
- lookableSpan("src/look.zig:12:3,", ".", &realbuf),
+ wordSpan(null, "src/look.zig:12:3,", ".", &realbuf),
);
- // grep -n's trailing delimiter comes off, the line number stays
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 0, .end = "src/look.zig:12".len }),
- lookableSpan("src/look.zig:12:", ".", &realbuf),
+ wordSpan(null, "src/look.zig:12:", ".", &realbuf),
);
- try std.testing.expectEqual(@as(?Span, null), lookableSpan("nothing-here", ".", &realbuf));
- try std.testing.expectEqual(@as(?Span, null), lookableSpan("", ".", &realbuf));
- try std.testing.expectEqual(@as(?Span, null), lookableSpan("((()))", ".", &realbuf));
+ try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "nothing-here", ".", &realbuf));
+ try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "", ".", &realbuf));
+ try std.testing.expectEqual(@as(?Span, null), wordSpan(null, "((()))", ".", &realbuf));
}
-/// The largest look-able span ANCHORED at the start of `line`'s text, or null
-/// when the row names no place at all. This is the ROW grain of n/N, and what
-/// a results buffer steps: a row there IS one location — `path:LINE:COL text`
-/// — and the words after the location are the MATCH, not a second place to
-/// step to. One stop per row, always its head.
-///
-/// LARGEST, so the candidates are the run from the first non-blank cell out to
-/// each whitespace boundary, tried LONGEST first: a path with a blank in it
-/// (`old notes/plan.txt`) beats the word hiding inside it, which is the case
-/// the word grain cannot express at all.
-///
-/// A candidate only counts when it is the target EXACTLY — parsePathLine
-/// consuming every byte of it, after the same wrapper peel lookableSpan does.
-/// That gate is what keeps longest-first from swallowing the whole row:
-/// `resolve` is lenient by design and answers `src/x.zig:12:5 const y` with
-/// the FILE, so without it every result row would select out to its right
-/// margin and throw the `:5` away along with the text. A url is lenient the
-/// same way in the other direction — it is recognised by its PREFIX, so a
-/// longer run is not a longer link — and only the filesystem can vouch for a
-/// span with a blank inside it, so only the filesystem is allowed to.
-///
-/// Cost is the word grain's: the exactness gate is pure parsing, so a row
-/// spends at most one resolve per whitespace boundary and the ordinary result
-/// row — whose head is its whole location — spends two.
-pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span {
+pub fn lineSpan(p: ?*pardes.Pardes, line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?Span {
var lo: usize = 0;
while (lo < line.len and (line[lo] == ' ' or line[lo] == '\t')) lo += 1;
var hi = std.mem.trimEnd(u8, line, " \t\r").len;
@@ -415,13 +257,12 @@ pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?
while (a < b and std.mem.indexOfScalar(u8, lead_trim, line[a]) != null) a += 1;
while (b > a and std.mem.indexOfScalar(u8, trail_trim, line[b - 1]) != null) b -= 1;
const cand = line[a..b];
- if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(cand, cwd, realbuf)) {
+ if (cand.len > 0 and parsePathLine(cand).end == cand.len) switch (resolve(p, cand, cwd, realbuf)) {
.dir, .file, .image => return .{ .start = a, .end = b },
.url, .pane => if (std.mem.indexOfAny(u8, cand, " \t") == null)
return .{ .start = a, .end = b },
.none => {},
};
- // ...else the same run one word shorter
while (hi > lo and line[hi - 1] != ' ' and line[hi - 1] != '\t') hi -= 1;
while (hi > lo and (line[hi - 1] == ' ' or line[hi - 1] == '\t')) hi -= 1;
}
@@ -431,1302 +272,81 @@ pub fn lookableLineSpan(line: []const u8, cwd: []const u8, realbuf: *[4096]u8) ?
test "lookableLineSpan takes the row's location and stops before its text" {
if (!platform_has_fs) return;
var realbuf: [4096]u8 = undefined;
- // a grep row: the location, and NOT the matched code after it — which
- // `resolve` would happily answer for, minus the column
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 0, .end = "src/look.zig:12:5-9".len }),
- lookableLineSpan("src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf),
+ lineSpan(null, "src/look.zig:12:5-9 const std = @import(\"std\");", ".", &realbuf),
);
- // an lsp/jumplist row, whose column is followed by a blank rather than a
- // ':' — the form a lenient read drops on the floor
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 0, .end = "src/look.zig:12:5".len }),
- lookableLineSpan("src/look.zig:12:5 pub fn resolve", ".", &realbuf),
+ lineSpan(null, "src/look.zig:12:5 pub fn resolve", ".", &realbuf),
);
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 0, .end = "@p3:10:5".len }),
- lookableLineSpan("@p3:10:5 /home/goblin", ".", &realbuf),
+ lineSpan(null, "@p3:10:5 /home/goblin", ".", &realbuf),
);
- // a bare path row, wrappers peeled and blank indent skipped like anywhere
- // else — the anchor is the row's first non-blank cell, not column zero
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 3, .end = 3 + "src/look.zig".len }),
- lookableLineSpan(" (src/look.zig)", ".", &realbuf),
+ lineSpan(null, " (src/look.zig)", ".", &realbuf),
);
- // a link row keeps its link and leaves the title alone: a longer run is
- // not a longer url
try std.testing.expectEqualDeep(
@as(?Span, .{ .start = 0, .end = "https://pardes.dev/a".len }),
- lookableLineSpan("https://pardes.dev/a Chapter One", ".", &realbuf),
+ lineSpan(null, "https://pardes.dev/a Chapter One", ".", &realbuf),
);
- // ANCHORED: a place mentioned mid-row is not a stop, and a row with no
- // place at its head is no stop at all
try std.testing.expectEqual(
@as(?Span, null),
- lookableLineSpan("see also src/look.zig", ".", &realbuf),
+ lineSpan(null, "see also src/look.zig", ".", &realbuf),
);
- try std.testing.expectEqual(@as(?Span, null), lookableLineSpan(" ", ".", &realbuf));
- try std.testing.expectEqual(@as(?Span, null), lookableLineSpan("", ".", &realbuf));
+ try std.testing.expectEqual(@as(?Span, null), lineSpan(null, " ", ".", &realbuf));
+ try std.testing.expectEqual(@as(?Span, null), lineSpan(null, "", ".", &realbuf));
}
test "lookableLineSpan prefers the longest run, so a blank inside a path is one span" {
if (!platform_has_fs) return;
var realbuf: [4096]u8 = undefined;
- // A real path with a blank in it, under a directory whose own name is the
- // first word of the row: the word grain can only ever see `tmp`, and the
- // row grain sees the file, because it asks about the longest run first.
- const io = std.Io.Threaded.global_single_threaded.io();
- var tmp = try std.Io.Dir.cwd().openDir(io, "/tmp", .{});
- defer tmp.close(io);
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
const name = "pardes look span.txt";
- try tmp.writeFile(io, .{ .sub_path = name, .data = "" });
- defer tmp.deleteFile(io, name) catch {};
+ try tmp.dir.writeFile(io, .{ .sub_path = name, .data = "" });
+ try tmp.dir.createDir(io, "subdir", .default_dir);
+ var path: [4096]u8 = undefined;
+ const len = try tmp.dir.realPathFile(io, name, &path);
+ const cwd = std.fs.path.dirname(path[0..len]).?;
try std.testing.expectEqualDeep(
- @as(?Span, .{ .start = 0, .end = ("tmp/" ++ name).len }),
- lookableLineSpan("tmp/" ++ name, "/", &realbuf),
+ @as(?Span, .{ .start = 0, .end = name.len }),
+ lineSpan(null, name, cwd, &realbuf),
);
- // ...and the shrink still finds the shorter run when the long one is
- // prose. Candidates END at a blank, so the runs tried are whole words:
- // there is no hunt for a path hiding inside one (lookableSpan's rule).
try std.testing.expectEqualDeep(
- @as(?Span, .{ .start = 0, .end = "tmp".len }),
- lookableLineSpan("tmp holds pardes look span.txt", "/", &realbuf),
+ @as(?Span, .{ .start = 0, .end = "subdir".len }),
+ lineSpan(null, "subdir holds pardes look span.txt", cwd, &realbuf),
);
}
-/// 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 {
+pub fn resolve(p: ?*pardes.Pardes, 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;
- // Saturating for the same reason `num` above is: this scan also
- // runs on a word somebody merely clicked. `focusPaneLine` refuses
- // anything past `MAX_PANES`, so a saturated id addresses nothing.
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;
-}
-
-/// Whether there is a real filesystem to reach at all. An ISOLATED build has
-/// none by construction — the option is comptime, so every libc path below is
-/// dead code the compiler removes rather than a branch that could be taken by
-/// accident. The browser has never had one either, and both then read the same
-/// embedded source.
-const platform_has_fs = !pardes.isolated and switch (pardes.platform) {
- .tty, .gui, .macos => true,
- // The browser's filesystem is the embedded source archive; the P4
- // firmware's is whatever the serial host answers for, through the Host
- // vtable — never a path this process opens.
- .web, .esp32p4 => 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 the core uses — 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;
- // A FILE THIS WALK CANNOT READ IS A FILE THIS WALK SKIPS. It used to
- // abort the whole grep and report `OpenFailed`, so ONE root-owned 0600
- // file — or one deleted between the walk and the read, which is routine
- // in a build tree — turned a search of ten thousand files into zero
- // results and a word that explains nothing. A grep is a question about
- // the files you can read; the ones you cannot are not an answer to it.
- // NONBLOCK for the reason `readFile` has it: a FIFO in the tree would
- // otherwise stop the search until somebody wrote to it.
- const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .CLOEXEC = true, .NONBLOCK = true });
- if (fd < 0) continue;
- var len: usize = 0;
- var readable = true;
- while (len < buf.len) {
- const n = libc.read(fd, buf[len..].ptr, buf.len - len);
- if (n < 0) {
- if (libc.errno(n) == .INTR) continue;
- // Skipped, not fatal, for the same reason: whatever this is, it
- // is not text this search can answer with.
- readable = false;
- break;
- }
- if (n == 0) break;
- len += @intCast(n);
- }
- _ = libc.close(fd);
- if (!readable) continue;
- 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. The rule is
- // lsp.rel's — under `base` means relative, anywhere else stays
- // absolute — and it is THE one spelling now; this used to be an
- // inline twin that the seam's own comment complained about.
- const shown = lsp.rel(home, path);
- const result = grepText(shown, text, pat, out[written..], find_max_hits - hits);
- hits += result.hits;
- written += result.bytes;
- }
- return written;
-}
-
-test "grep skips a file it cannot read instead of abandoning the search" {
- if (!platform_has_fs) return;
- const gpa = std.testing.allocator;
- var tmp = std.testing.tmpDir(.{});
- defer tmp.cleanup();
- var base_buf: [std.fs.max_path_bytes]u8 = undefined;
- const dir = base_buf[0..try tmp.dir.realPath(std.testing.io, &base_buf)];
-
- // Two files, and the unreadable one sorts FIRST — the walk reads in sorted
- // order, so `a-` is the one that used to abort the search before `b-` was
- // ever opened.
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "a-locked.txt", .data = "needle here\n" });
- try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "b-open.txt", .data = "needle here\n" });
- var locked_buf: [std.fs.max_path_bytes]u8 = undefined;
- const locked = try std.fmt.bufPrintSentinel(&locked_buf, "{s}/a-locked.txt", .{dir}, 0);
- if (libc.chmod(locked, 0) != 0) return;
- // Running as root reads it anyway, and then this test is testing nothing:
- // say so by not pretending to have run.
- const probe = libc.open(locked, .{ .ACCMODE = .RDONLY });
- if (probe >= 0) {
- _ = libc.close(probe);
- _ = libc.chmod(locked, 0o644);
- return error.SkipZigTest;
- }
-
- var arena: std.heap.ArenaAllocator = .init(gpa);
- defer arena.deinit();
- const out = try gpa.alloc(u8, 64 * 1024);
- defer gpa.free(out);
- const n = try grep(arena.allocator(), gpa, dir, dir, "needle", out);
- _ = libc.chmod(locked, 0o644); // so `tmp.cleanup` can remove it
-
- // The readable file's hit came back. Before this, the whole call returned
- // `error.OpenFailed` and the +Grep buffer was empty.
- try std.testing.expect(std.mem.indexOf(u8, out[0..n], "b-open.txt") != null);
- try std.testing.expect(std.mem.indexOf(u8, out[0..n], "a-locked.txt") == null);
-}
-
-
-/// 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;
- // NONBLOCK, and it is the difference between an error and a dead editor.
- // A plain blocking `open` of a FIFO waits for a writer that may never come,
- // and this call runs INSIDE the keystroke that asked for it — no frame, no
- // message row, and in the tty shell no Ctrl-C either, because the terminal
- // is in raw mode. `Look` on a named pipe (or on a device that blocks until
- // carrier) froze the whole program with nothing on screen to say why. The
- // flag is cleared again below for the file kinds that are worth reading;
- // the ones that are not are refused by name.
- const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .NONBLOCK = true });
- // WHY it would not open, not just that it would not. Every one of these is
- // an ordinary thing to do by accident — `pardes /root`, a file left at mode
- // 000, a name that was deleted between resolving and reading — and a caller
- // that can only say "OpenFailed" has to show the human a word that means
- // nothing to them. `errno` is libc's here, which is the only reason it can
- // be read off a `-1`: see the raw-syscall note in `termCwd`.
- if (fd < 0) return switch (libc.errno(fd)) {
- .ACCES, .PERM => error.PermissionDenied,
- .NOENT => error.FileNotFound,
- .ISDIR => error.IsDirectory,
- .NAMETOOLONG => error.PathTooLong,
- else => error.OpenFailed,
- };
- defer _ = libc.close(fd);
-
- // The flag STAYS SET, and the read loops below answer `EAGAIN` with
- // `NotAFile`. A regular file ignores `O_NONBLOCK` entirely — the kernel
- // never short-reads one for it — so this costs ordinary opens nothing and
- // turns the one case that would have hung into an error with a name.
- const end = libc.lseek(fd, 0, libc.SEEK.END);
- // NOT SEEKABLE IS NOT A DOCUMENT. `lseek` answers `ESPIPE` for a pipe, a
- // socket and a terminal, and those are exactly the things whose "contents"
- // are a future rather than a file — with `O_NONBLOCK` above they no longer
- // hang the editor, but an unwritten FIFO then reads as EOF and opened as a
- // silent empty pane, which says even less than the hang did. The zero-size
- // files that ARE worth streaming — procfs and its kin — seek fine and
- // report 0, so they take the branch below untouched.
- if (end < 0 and libc.errno(end) == .SPIPE) return error.NotAFile;
- 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;
- // Nothing to read AND nothing that will end: an empty pipe with
- // no writer. This is the hang, reported instead of waited on.
- if (libc.errno(n) == .AGAIN) return error.NotAFile;
- 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;
- // A stream that has paused is a stream that has ended, as far
- // as one keystroke is concerned: keep what came.
- if (libc.errno(n) == .AGAIN) break;
- 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 and libc.errno(n) == .AGAIN) break;
- 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/<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,
- }
-}
-
-// ---- tty occupancy: is a pane's terminal still the prompt pardes forked? ----
-
-// Linux answers TIOCGPGRP asked of the pty MASTER with the SLAVE side's
-// foreground process group — the number the kernel would deliver ^C to. Not in
-// std.c, and the master is the only end pardes holds.
-extern "c" fn tcgetpgrp(fd: c_int) libc.pid_t;
-
-/// How far the descendant walk goes before it stops trusting itself. A shell
-/// sitting at its prompt has no descendants at all and a foreground job is one
-/// hop, so these are not a budget, they are a fuse: the walk is driven by
-/// numbers read out of the kernel and must not be able to spin on a surprising
-/// one (the same reason nested.outer() caps its hops). Hitting either bound
-/// answers OCCUPIED — a tree we did not finish reading may hide the foreground
-/// job, and typing a command line into vim is worse than declining to type it
-/// into a shell that really was idle under 32 background jobs.
-const occ_max_depth: u8 = 8;
-const occ_max_visited: usize = 32;
-
-/// Scratch for one `ttyTaken` answer: the walk's helpers share it rather than
-/// each declaring its own copy of a path buffer. Lives in the probe's own
-/// frame — there is no polling loop to hoist it out of any more, because the
-/// core asks this question only where it is about to type a command line.
-const TtyProbe = struct {
- /// the forked shell's own executable, read once per probe
- self_exe: [std.fs.max_path_bytes]u8 = undefined,
- /// ...and one descendant's, to compare against it
- exe: [std.fs.max_path_bytes]u8 = undefined,
- /// one small /proc text at a time: a children list, a stat line, a status
- /// blob. Each is consumed (parsed to numbers) before the next read.
- blob: [4096]u8 = undefined,
- /// the DFS worklist, bounded by the same fuse as the visit count
- pending: [occ_max_visited]Node = undefined,
-
- const Node = struct { pid: libc.pid_t, depth: u8 };
-};
-
-/// Is something OTHER than the shell prompt pardes forked sitting on this
-/// pane's tty — vim, less, an agent, a build? An Exec must never type a command
-/// line into such a program (it would land as vim keystrokes), so a taken
-/// terminal is treated exactly like no terminal at all: the core routes the
-/// command to another shell.
-///
-/// The predicate, and the false answer each clause exists to prevent:
-///
-/// fg = tcgetpgrp(master) the tty's foreground pgrp, from the kernel
-/// fg < 0 -> free no answer at all (not a tty, a host that
-/// does not allow the ioctl): behave as before
-/// self = exe(shell_pid) the binary of the terminal we spawned,
-/// straight out of /proc, so no spawn path has
-/// to be plumbed through three frontends' Pty
-/// structs and kept in step with shell_bin
-/// self == null -> free the shell is gone; the pane's EOF is about
-/// to remove it anyway
-/// walk descendants of shell_pid:
-/// exe unreadable -> occupied, unless the child is a zombie (or has
-/// already vanished), which is provably not on
-/// the tty. Unreadable-but-alive is a setuid
-/// program — `sudo` waiting for a password is
-/// the case that must NOT be typed into.
-/// exe != self -> occupied iff its pgrp is fg. The pgrp filter is
-/// what keeps `sleep 30 &` from looking
-/// occupied: a background job is a child of an
-/// idle prompt, and its pgrp is not the tty's.
-/// exe == self -> recurse. A nested shell prompt is still a usable
-/// prompt, so `bash` inside `bash` stays
-/// Exec-able; and the leaf is the answer, which
-/// is what catches `bash -c 'sleep 30'` — there
-/// the foreground pgrp LEADER's exe is our own
-/// shell binary while the tty really belongs to
-/// `sleep`.
-/// no visited process in pgrp fg, and fg != shell_pid
-/// -> occupied the tty belongs to a group we could not
-/// attribute to anything we forked (a
-/// foreground leader that died or re-parented);
-/// never type into it.
-/// otherwise -> free
-pub fn ttyTaken(shell_pid: libc.pid_t, master_fd: c_int) bool {
- switch (builtin.os.tag) {
- .linux => {
- var probe: TtyProbe = undefined;
- const fg = tcgetpgrp(master_fd);
- if (fg < 0) return false;
- const self_exe = procExe(shell_pid, &probe.self_exe) orelse return false;
-
- // The shell's own pgrp is normally the tty's when it is at its
- // prompt (forkpty made it the session and group leader), so the
- // idle answer is reached without reading its stat at all — the
- // whole fast path is tcgetpgrp, one readlink, and an empty
- // children file.
- var saw_fg = fg == shell_pid;
- var pending: usize = 0;
- var visited: usize = 0;
- switch (pushChildren(&probe, &pending, shell_pid, 1)) {
- .pushed => {},
- // No children file: a kernel without CONFIG_PROC_CHILDREN
- // cannot answer this question at all, so answer free and leave
- // behaviour exactly as it was before this probe existed.
- .unreadable => return false,
- .full => return true,
- }
-
- while (pending > 0) {
- pending -= 1;
- const node = probe.pending[pending];
- visited += 1;
- if (visited > occ_max_visited) return true;
-
- // One stat read carries the group; note it before anything can
- // return, because the final clause is about every process we
- // looked at, not only the ones that decided the answer.
- const pgrp = procPgrp(node.pid, &probe.blob);
- if (pgrp) |g| {
- if (g == fg) saw_fg = true;
- }
-
- const exe = procExe(node.pid, &probe.exe) orelse {
- if (offTty(node.pid, &probe.blob)) continue;
- return true;
- };
- if (!std.mem.eql(u8, exe, self_exe)) {
- if (pgrp) |g| if (g == fg) return true;
- continue;
- }
- if (node.depth >= occ_max_depth) return true;
- switch (pushChildren(&probe, &pending, node.pid, node.depth + 1)) {
- .pushed => {},
- // This one exited while we walked (or the kernel stopped
- // answering for it); its own pgrp was already counted and
- // there is nothing below it to learn.
- .unreadable => {},
- .full => return true,
- }
- }
- return !saw_fg;
- },
- // A darwin implementation is tcgetpgrp (which xnu also allows on the
- // master) plus a descendant walk built from proc_listchildpids, with
- // proc_pidpath for the exe and proc_bsdinfo's pbi_pgid for the group —
- // there is no /proc to read. Until then macOS behaves as it did before
- // this probe existed: every terminal is a prompt.
- else => return false,
- }
-}
-
-/// DELIVER A SIGNAL TO WHATEVER IS ON THIS PANE'S TTY — the host half of
-/// `pty/ctl`'s `sig` verb, shared by every frontend that owns pane shells so
-/// that the target is decided once instead of three times.
-///
-/// THE TARGET IS THE FOREGROUND PROCESS GROUP, not the shell's pid, and the
-/// difference is the whole usefulness of the verb. `tcgetpgrp` on the master
-/// answers with the number the kernel would deliver a ^C to (see the comment
-/// on the declaration above), which is the running build, the pager, the
-/// agent — the thing a script means when it says `sig INT`. Aimed at the pid
-/// instead, `sig INT` would reach an interactive shell, which ignores SIGINT
-/// while it waits for a job: the verb would appear to work and do nothing on
-/// the one case anybody wants it for. At an idle prompt the two are the same
-/// number, because forkpty made the shell its own group leader.
-///
-/// The pid is the FALLBACK, for an OS or a host whose master end will not
-/// answer the ioctl. There `sig KILL` still ends the shell, which is the case
-/// where being ignored is not an acceptable outcome.
-pub fn signalTty(shell_pid: libc.pid_t, master_fd: c_int, which: pardes.PtySignal) void {
- // Whatever `std.c.SIG` spells these as on this platform, unconverted: the
- // one call below wants exactly that type (see the `kill` beside `harvest`).
- const sig = switch (which) {
- .int => libc.SIG.INT,
- .term => libc.SIG.TERM,
- .hup => libc.SIG.HUP,
- .quit => libc.SIG.QUIT,
- .kill => libc.SIG.KILL,
- };
- const fg = tcgetpgrp(master_fd);
- // A process GROUP is addressed as its negated leader; `fg` is already a
- // group id, so this is `kill(-fg)` and not `kill(-leader_of(fg))`.
- if (fg > 0) {
- _ = libc.kill(-fg, sig);
- return;
- }
- if (shell_pid > 0) _ = libc.kill(shell_pid, sig);
-}
-
-/// Read a small /proc text in one go. These files are generated on read and
-/// answer completely in a single call at these sizes; a short read would only
-/// truncate a field, which every parser below treats as "no answer".
-fn readProc(path: [*:0]const u8, buf: []u8) ?[]const u8 {
- const fd = libc.open(path, .{ .ACCMODE = .RDONLY });
- if (fd < 0) return null;
- defer _ = libc.close(fd);
- const got = libc.read(fd, buf.ptr, buf.len);
- if (got <= 0) return null;
- return buf[0..@intCast(got)];
-}
-
-/// The binary behind a pid, as the kernel spells it. Fails for a zombie (no mm
-/// to point at) and for a process we may not inspect — the two cases `ttyTaken`
-/// has to tell apart.
-fn procExe(pid: libc.pid_t, buf: *[std.fs.max_path_bytes]u8) ?[]const u8 {
- var name: [64:0]u8 = undefined;
- const link = std.fmt.bufPrintSentinel(&name, "/proc/{d}/exe", .{@as(u32, @intCast(pid))}, 0) catch return null;
- const n = libc.readlink(link, buf, buf.len);
- if (n <= 0) return null;
- return buf[0..@intCast(n)];
-}
-
-/// A pid's process group.
-fn procPgrp(pid: libc.pid_t, buf: *[4096]u8) ?libc.pid_t {
- var name: [64:0]u8 = undefined;
- const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/stat", .{@as(u32, @intCast(pid))}, 0) catch return null;
- return parsePgrp(readProc(path, buf) orelse return null);
-}
-
-/// Field 5 of /proc/<pid>/stat, found by scanning back from the LAST ')'
-/// rather than counting fields from the start: field 2 is `comm` in
-/// parentheses, and a comm may contain spaces AND parentheses, so a process
-/// named `sh (a b)` shifts everything after it and a positional parse silently
-/// reads some other number as the group. Same trap nested.parsePPid documents;
-/// the kernel puts comm's closing paren last precisely so this scan works.
-fn parsePgrp(stat: []const u8) ?libc.pid_t {
- const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null;
- var fields = std.mem.tokenizeAny(u8, stat[close + 1 ..], " \t\n");
- _ = fields.next() orelse return null; // 3: state
- _ = fields.next() orelse return null; // 4: ppid
- const pgrp = fields.next() orelse return null; // 5: pgrp
- return std.fmt.parseInt(libc.pid_t, pgrp, 10) catch null;
-}
-
-/// Is this pid provably NOT holding the tty even though its exe is unreadable:
-/// a zombie (dead, waiting to be reaped) or already gone. Everything else that
-/// hides its exe — a setuid program — is alive and on the terminal.
-fn offTty(pid: libc.pid_t, buf: *[4096]u8) bool {
- var name: [64:0]u8 = undefined;
- const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/status", .{@as(u32, @intCast(pid))}, 0) catch return false;
- // No status at all: the pid died between the children read and here. A
- // process that no longer exists cannot be typed into.
- const status = readProc(path, buf) orelse return true;
- return parseZombie(status);
-}
-
-/// The `State:` field of a /proc/<pid>/status blob, and only Z. Line-anchored,
-/// so a comm that spells `State: Z` inside the `Name:` line cannot answer.
-fn parseZombie(status: []const u8) bool {
- var lines = std.mem.splitScalar(u8, status, '\n');
- while (lines.next()) |line| {
- if (!std.mem.startsWith(u8, line, "State:")) continue;
- const state = std.mem.trim(u8, line["State:".len..], " \t\r");
- return state.len > 0 and state[0] == 'Z';
- }
- return false;
-}
-
-const Pushed = enum { pushed, unreadable, full };
-
-/// Put a pid's direct children on the worklist. The children file is the whole
-/// reason this walk is cheap: an idle shell's is empty, so the fast path reads
-/// one empty file instead of scanning /proc.
-///
-/// Spelled out rather than routed through `readProc` precisely because of that
-/// empty file: readProc treats a zero-byte answer as no answer, which is right
-/// for a stat line and exactly wrong here — "this process has no children" is
-/// the most informative reply the walk ever gets, and calling it unreadable
-/// would make the whole probe give up on every idle shell.
-fn pushChildren(probe: *TtyProbe, pending: *usize, pid: libc.pid_t, depth: u8) Pushed {
- var name: [96:0]u8 = undefined;
- const path = std.fmt.bufPrintSentinel(&name, "/proc/{d}/task/{d}/children", .{
- @as(u32, @intCast(pid)), @as(u32, @intCast(pid)),
- }, 0) catch return .unreadable;
- const fd = libc.open(path, .{ .ACCMODE = .RDONLY });
- if (fd < 0) return .unreadable;
- defer _ = libc.close(fd);
- const got = libc.read(fd, &probe.blob, probe.blob.len);
- if (got < 0) return .unreadable;
-
- var kids: [occ_max_visited]libc.pid_t = undefined;
- const total = parseChildren(probe.blob[0..@intCast(got)], &kids);
- if (total > kids.len or pending.* + total > probe.pending.len) return .full;
- for (kids[0..total]) |kid| {
- probe.pending[pending.*] = .{ .pid = kid, .depth = depth };
- pending.* += 1;
- }
- return .pushed;
-}
-
-/// The pids in a /proc/<pid>/task/<tid>/children blob: space separated, with a
-/// trailing space, and empty for the overwhelmingly common idle shell. Returns
-/// how many valid pids the blob HAS, having written the first `out.len` of them
-/// — a total past `out.len` is the caller's overflow signal. A token that is
-/// not strictly digits is skipped rather than answered wrong: this drives who
-/// gets walked, and parseInt alone would take `-1` and `+7`.
-fn parseChildren(text: []const u8, out: []libc.pid_t) usize {
- var total: usize = 0;
- var it = std.mem.tokenizeAny(u8, text, " \t\n\r");
- while (it.next()) |tok| {
- if (std.mem.indexOfNone(u8, tok, "0123456789") != null) continue;
- const kid = std.fmt.parseInt(libc.pid_t, tok, 10) catch continue;
- if (total < out.len) out[total] = kid;
- total += 1;
- }
- return total;
-}
-
-test "the children blob parses to pids, and a garbage token never becomes one" {
- var out: [8]libc.pid_t = undefined;
- // the idle shell, which is the case the whole fast path is shaped around
- try std.testing.expectEqual(@as(usize, 0), parseChildren("", &out));
- try std.testing.expectEqual(@as(usize, 0), parseChildren(" ", &out));
- // one child — the kernel writes a TRAILING space and no newline
- try std.testing.expectEqual(@as(usize, 1), parseChildren("991 ", &out));
- try std.testing.expectEqual(@as(libc.pid_t, 991), out[0]);
- // several, with and without the trailing separator
- try std.testing.expectEqual(@as(usize, 3), parseChildren("7 8 9 ", &out));
- try std.testing.expectEqualSlices(libc.pid_t, &.{ 7, 8, 9 }, out[0..3]);
- try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12", &out));
- try std.testing.expectEqual(@as(usize, 2), parseChildren("11 12\n", &out));
- // garbage: this list decides whose /proc entries get read, and parseInt
- // alone would take every one of these. The pids AROUND the junk still
- // answer — dropping the tree because one token was odd would silently turn
- // a busy terminal into a free one.
- try std.testing.expectEqual(@as(usize, 2), parseChildren("5 -1 +7 0x3 abc 6 ", &out));
- try std.testing.expectEqualSlices(libc.pid_t, &.{ 5, 6 }, out[0..2]);
- // overflow is REPORTED, not silently truncated: the total is what the blob
- // HAS, so the caller can answer "occupied" instead of walking a tree it
- // only partly read
- var two: [2]libc.pid_t = undefined;
- try std.testing.expectEqual(@as(usize, 4), parseChildren("1 2 3 4 ", &two));
- try std.testing.expectEqualSlices(libc.pid_t, &.{ 1, 2 }, two[0..2]);
-}
-
-test "the process group comes off the last ')', not a comm-shifted stat field" {
- // the comm here contains a space AND parentheses — the exact shape that
- // breaks `field 5 of /proc/<pid>/stat` (see nested.parsePPid). Counting
- // from the left answers `b))` for the state and `S` for the group.
- const shifted = "1234 (sh (a b)) S 991 992 993 34816 992 4194560 " ++
- "1729 0 0 0 1 0 0 0 20 0 1 0 8244630 9887744 1131";
- try std.testing.expectEqual(@as(libc.pid_t, 992), parsePgrp(shifted).?);
- // ...and the ordinary shape still reads the same field
- try std.testing.expectEqual(@as(libc.pid_t, 7), parsePgrp("42 (bash) S 1 7 7 34816 7 4194304").?);
- // a group of its own, which is what a background job has
- try std.testing.expectEqual(@as(libc.pid_t, 42), parsePgrp("42 (sleep) S 7 42 7 0 -1").?);
- // a truncated read must not answer from a half line, and a blob that is
- // not a stat line at all must not answer at all
- try std.testing.expect(parsePgrp("") == null);
- try std.testing.expect(parsePgrp("1234 (bash) S 991") == null);
- try std.testing.expect(parsePgrp("1234 (bash) S 991 notanumber") == null);
- try std.testing.expect(parsePgrp("no parens here at all") == null);
-}
-
-test "the zombie state comes off its own status line" {
- // a reaped-but-not-yet-collected child: no exe to read, and provably not
- // holding the tty, so the walk must skip it instead of answering occupied
- try std.testing.expect(parseZombie("Name:\tsh (a b)\nUmask:\t0022\nState:\tZ (zombie)\nTgid:\t1234\n"));
- try std.testing.expect(parseZombie("State:\tZ (zombie)\n"));
- // every other state is a live process, and an unreadable exe then means
- // setuid (sudo asking for a password) — the one thing never to type into
- try std.testing.expect(!parseZombie("Name:\tsh\nState:\tS (sleeping)\n"));
- try std.testing.expect(!parseZombie("Name:\tvim\nState:\tR (running)\n"));
- try std.testing.expect(!parseZombie("Name:\tvim\nState:\tT (stopped)\n"));
- // a comm that spells the field cannot answer for it: the scan is anchored
- // to the start of a line, and `Name:` is where a comm lives
- try std.testing.expect(!parseZombie("Name:\tsh (State: Z)\nState:\tS (sleeping)\n"));
- // a truncated read is not a zombie (and so stays conservative)
- try std.testing.expect(!parseZombie("Name:\tsh\nSta"));
- try std.testing.expect(!parseZombie("State:\t"));
-}
-
-// ---- tests: the predicate against real processes on a real pty ----
-//
-// The parsers above cannot see any of what follows: whether Linux answers
-// TIOCGPGRP on the MASTER at all, whether bash really puts a background job in
-// its own group, and whether `bash -c` leaves our own binary as the foreground
-// leader are all facts about the system, and every one of them decides an
-// answer. So these fork a real bash on a real pty — the way
-// test/e2e_harness.zig forks the whole app — and drive it.
-extern "c" fn forkpty(
- amaster: *c_int,
- name: ?[*:0]u8,
- termp: ?*const anyopaque,
- winp: ?*const std.posix.winsize,
-) c_int;
-
-const test_shell = "/bin/bash";
-const test_prompt = "PZX> ";
-
-/// A real interactive bash on a pty of our own, plus the polling the cases need.
-/// Nothing here sleeps for a fixed time waiting for the shell: every step polls
-/// to a deadline, and every poll DRAINS the master — a shell whose output is
-/// never read blocks on a full pty buffer and then nothing else happens either.
-const TestShell = struct {
- master: c_int,
- pid: libc.pid_t,
- /// a rolling window of what the shell has written, so a case can wait for
- /// the prompt (or a job-control notice) instead of guessing a duration
- tail: [8192]u8 = undefined,
- tail_len: usize = 0,
-
- fn start() ?TestShell {
- if (!haveFile(test_shell)) return null;
- var master: c_int = undefined;
- const ws = std.posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
- const pid = forkpty(&master, null, null, &ws);
- if (pid < 0) return null;
- if (pid == 0) {
- // --norc: the developer's own bashrc must not decide what these
- // tests see. -i: job control, which is what puts a background job
- // in a group of its own and is half of what is under test.
- const argv: [3:null]?[*:0]const u8 = .{ test_shell, "--norc", "-i" };
- _ = execv(test_shell, &argv);
- _exit(127);
- }
- var sh: TestShell = .{ .master = master, .pid = pid };
- // A prompt of our own — EXPORTED, so a nested bash shows the same one —
- // spelled with a '' seam, so the echo of the command that sets it
- // cannot be mistaken for the prompt it produces.
- sh.send("export PS1='PZ''X> '\n");
- if (!sh.waitText(test_prompt, 10_000)) {
- sh.stop();
- return null;
- }
- sh.forget();
- return sh;
- }
-
- fn send(sh: *TestShell, bytes: []const u8) void {
- _ = libc.write(sh.master, bytes.ptr, bytes.len);
- }
-
- fn forget(sh: *TestShell) void {
- sh.tail_len = 0;
- }
-
- /// Read everything the shell has produced so far, without blocking.
- fn drain(sh: *TestShell) void {
- while (true) {
- var fds = [1]libc.pollfd{.{ .fd = sh.master, .events = libc.POLL.IN, .revents = 0 }};
- if (libc.poll(&fds, 1, 0) <= 0) return;
- if (fds[0].revents & libc.POLL.IN == 0) return;
- var chunk: [4096]u8 = undefined;
- const n = libc.read(sh.master, &chunk, chunk.len);
- if (n <= 0) return;
- sh.append(chunk[0..@intCast(n)]);
- }
- }
-
- fn append(sh: *TestShell, bytes: []const u8) void {
- if (bytes.len >= sh.tail.len) {
- @memcpy(&sh.tail, bytes[bytes.len - sh.tail.len ..]);
- sh.tail_len = sh.tail.len;
- return;
- }
- const room = sh.tail.len - sh.tail_len;
- if (bytes.len > room) {
- const drop = bytes.len - room;
- std.mem.copyForwards(u8, sh.tail[0 .. sh.tail_len - drop], sh.tail[drop..sh.tail_len]);
- sh.tail_len -= drop;
- }
- @memcpy(sh.tail[sh.tail_len..][0..bytes.len], bytes);
- sh.tail_len += bytes.len;
- }
-
- fn waitText(sh: *TestShell, needle: []const u8, ms: i64) bool {
- const deadline = nowMs() + ms;
- while (true) {
- sh.drain();
- if (std.mem.indexOf(u8, sh.tail[0..sh.tail_len], needle) != null) return true;
- if (nowMs() >= deadline) return false;
- sleepMs(5);
- }
- }
-
- fn taken(sh: *TestShell) bool {
- sh.drain();
- return ttyTaken(sh.pid, sh.master);
- }
-
- /// Poll until the verdict is `want` — the answer changes when the SHELL
- /// gets around to forking or reaping, not when we sent the line.
- fn waitTaken(sh: *TestShell, want: bool, ms: i64) bool {
- const deadline = nowMs() + ms;
- while (true) {
- if (sh.taken() == want) return true;
- if (nowMs() >= deadline) return false;
- sleepMs(5);
- }
- }
-
- /// ...and the other direction: the verdict STAYS `want` for a window. What
- /// a false positive looks like is a probe that flickers to occupied while
- /// the shell sits at its prompt with a background job, and a single sample
- /// can miss it.
- fn holdsTaken(sh: *TestShell, want: bool, ms: i64) bool {
- const deadline = nowMs() + ms;
- while (nowMs() < deadline) {
- if (sh.taken() != want) return false;
- sleepMs(5);
- }
- return true;
- }
-
- /// Kill the shell AND everything under it, then reap and close. The tree
- /// has to be collected BEFORE the shell dies: a foreground job lives in its
- /// own process group, so killing bash alone leaves `sleep 30` running,
- /// re-parented to init — a stray that outlives the test binary.
- fn stop(sh: *TestShell) void {
- var probe: TtyProbe = undefined;
- var pending: usize = 0;
- var doomed: [occ_max_visited]libc.pid_t = undefined;
- var n: usize = 0;
- _ = pushChildren(&probe, &pending, sh.pid, 1);
- while (pending > 0) {
- pending -= 1;
- const node = probe.pending[pending];
- if (n == doomed.len) break;
- doomed[n] = node.pid;
- n += 1;
- if (node.depth < occ_max_depth) _ = pushChildren(&probe, &pending, node.pid, node.depth + 1);
- }
- _ = libc.kill(sh.pid, libc.SIG.KILL);
- for (doomed[0..n]) |kid| {
- _ = libc.kill(kid, libc.SIG.KILL);
- // ...and its group, for a program that forked helpers of its own
- _ = libc.kill(-kid, libc.SIG.KILL);
- }
- _ = libc.waitpid(sh.pid, null, 0);
- _ = libc.close(sh.master);
- }
-};
-
-fn haveFile(path: [*:0]const u8) bool {
- const fd = libc.open(path, .{ .ACCMODE = .RDONLY });
- if (fd < 0) return false;
- _ = libc.close(fd);
- return true;
-}
-
-fn nowMs() i64 {
- var ts: libc.timespec = undefined;
- _ = libc.clock_gettime(.MONOTONIC, &ts);
- return @as(i64, @intCast(ts.sec)) * 1000 + @divFloor(@as(i64, @intCast(ts.nsec)), 1_000_000);
-}
-
-fn sleepMs(ms: i64) void {
- const ts = libc.timespec{
- .sec = @intCast(@divFloor(ms, 1000)),
- .nsec = @intCast(@mod(ms, 1000) * 1_000_000),
- };
- _ = libc.nanosleep(&ts, null);
-}
-
-test "an idle prompt is free, a foreground job takes the tty, and Ctrl-C hands it back" {
- if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
- var sh = TestShell.start() orelse return error.SkipZigTest;
- defer sh.stop();
-
- // The whole point of the default: a shell sitting at its prompt is usable,
- // and stays usable across samples.
- try std.testing.expect(sh.holdsTaken(false, 200));
-
- // A foreground job IS the terminal now — this is the answer an Exec needs,
- // and typing a command line here would be typing it at `sleep`.
- sh.send("sleep 30\n");
- try std.testing.expect(sh.waitTaken(true, 10_000));
-
- // ^C, and the tty is the prompt's again. Nothing is cached: the next poll
- // simply finds no children, which is why recovery needs no event.
- sh.forget();
- sh.send("\x03");
- try std.testing.expect(sh.waitTaken(false, 10_000));
- try std.testing.expect(sh.waitText(test_prompt, 10_000));
-}
-
-test "a background job is not the tty's owner" {
- if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
- var sh = TestShell.start() orelse return error.SkipZigTest;
- defer sh.stop();
-
- // The false positive the pgrp filter exists for. Waiting for the job
- // notice first matters: the verdict has to be taken while the child is
- // genuinely alive, or this test would pass with no probe at all.
- sh.send("sleep 30 &\n");
- try std.testing.expect(sh.waitText("[1]", 10_000));
- try std.testing.expect(sh.holdsTaken(false, 300));
-
- // ...and it is still free once the job is gone, which also means the
- // zombie between `kill` and bash's reap is not read as an occupant.
- sh.send("kill %1\n");
- try std.testing.expect(sh.holdsTaken(false, 300));
-}
-
-test "a nested interactive shell is still a prompt" {
- if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
- var sh = TestShell.start() orelse return error.SkipZigTest;
- defer sh.stop();
-
- // `bash` inside `bash`: the leaf matches the binary we spawned, so it is a
- // prompt like any other and Exec must keep working. This is the case the
- // recursion is FOR, and the reason "any child at all" would be wrong.
- sh.forget();
- sh.send("bash --norc -i\n");
- try std.testing.expect(sh.waitText(test_prompt, 10_000));
- try std.testing.expect(sh.holdsTaken(false, 300));
-
- // ...and one level deeper still
- sh.forget();
- sh.send("bash --norc -i\n");
- try std.testing.expect(sh.waitText(test_prompt, 10_000));
- try std.testing.expect(sh.holdsTaken(false, 300));
-
- // a job inside the INNER shell is still the tty's owner
- sh.send("sleep 30\n");
- try std.testing.expect(sh.waitTaken(true, 10_000));
- sh.send("\x03");
- try std.testing.expect(sh.waitTaken(false, 10_000));
-}
-
-test "the walk reaches the leaf: bash -c 'sleep 30' takes the tty" {
- if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
- var sh = TestShell.start() orelse return error.SkipZigTest;
- defer sh.stop();
-
- sh.send("bash --norc -c 'sleep 30'\n");
- try std.testing.expect(sh.waitTaken(true, 10_000));
- sh.send("\x03");
- try std.testing.expect(sh.waitTaken(false, 10_000));
-
- // The same shape where bash provably CANNOT exec the command in place (two
- // commands, so the wrapper has to stay around and fork): the foreground
- // group's leader is then our own shell binary while the tty really belongs
- // to `sleep`. A predicate that stopped at the leader would call this free.
- sh.send("bash --norc -c 'sleep 30; :'\n");
- try std.testing.expect(sh.waitTaken(true, 10_000));
-
- // ...and that is the shape asserted, not assumed: the shell's only child
- // runs the same binary the shell does.
- var probe: TtyProbe = undefined;
- var pending: usize = 0;
- try std.testing.expectEqual(Pushed.pushed, pushChildren(&probe, &pending, sh.pid, 1));
- try std.testing.expectEqual(@as(usize, 1), pending);
- var wrapper_buf: [std.fs.max_path_bytes]u8 = undefined;
- var shell_buf: [std.fs.max_path_bytes]u8 = undefined;
- try std.testing.expectEqualStrings(
- procExe(sh.pid, &shell_buf).?,
- procExe(probe.pending[0].pid, &wrapper_buf).?,
- );
-
- sh.send("\x03");
- try std.testing.expect(sh.waitTaken(false, 10_000));
-}
-
-test "a full-screen program takes the tty until it quits" {
- if (comptime builtin.os.tag != .linux) return error.SkipZigTest;
- // The two shapes a human actually loses a terminal to: an editor that takes
- // the alternate screen, and a pager that does not. Both are skipped rather
- // than failed where they are not installed.
- const cases = [_]struct { bin: [*:0]const u8, run: []const u8, quit: []const u8 }{
- // -u NONE -i NONE: no vimrc, no viminfo — this must not touch the
- // developer's own files, and an rc that starts a plugin would change
- // the process tree under test.
- .{ .bin = "/usr/bin/vim", .run = "vim -u NONE -i NONE\n", .quit = "\x1b:q!\r" },
- // LESS= so a developer's own -F (quit if one screen) cannot make the
- // pager exit before it is asked to
- .{ .bin = "/usr/bin/less", .run = "env LESS= less /etc/hosts\n", .quit = "q" },
- };
- var ran: usize = 0;
- for (cases) |c| {
- if (!haveFile(c.bin)) continue;
- var sh = TestShell.start() orelse return error.SkipZigTest;
- defer sh.stop();
- sh.send(c.run);
- try std.testing.expect(sh.waitTaken(true, 10_000));
- sh.forget();
- sh.send(c.quit);
- try std.testing.expect(sh.waitTaken(false, 10_000));
- try std.testing.expect(sh.waitText(test_prompt, 10_000));
- ran += 1;
- }
- if (ran == 0) return error.SkipZigTest;
+ const found = fs.resolve(p, word, cwd, realbuf) orelse return .none;
+ if (found.dir) return .{ .dir = found.path };
+ if (comptime pdf_enabled) if (isPdfPath(found.path)) return .{ .file = .{
+ .path = found.path,
+ .at = pl.at,
+ .kind = .pdf,
+ } };
+ if (isImagePath(found.path)) return .{ .image = .{ .path = found.path } };
+ return .{ .file = .{ .path = found.path, .at = pl.at } };
}