diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/look.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-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.zig | 1472 |
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 } }; } |
