diff options
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 3 | ||||
| -rw-r--r-- | docs/fs.md | 12 | ||||
| -rw-r--r-- | src/builtins.zig | 12 | ||||
| -rw-r--r-- | src/look.zig | 83 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 34 | ||||
| -rw-r--r-- | test/pdf.zig | 4 |
6 files changed, 127 insertions, 21 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 8332d8fb..58009df5 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -51,7 +51,8 @@ match this document, that session predates the change; restart it. $m/README the served guide, worth reading first $m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name $m/status pid, version, panes -$m/look write a line = a right click on it at the active pane +$m/look write a line = a right click on it at the active pane (file:/re/, file:#n, :/re/ + select by address, as acme's look; a miss changes nothing) $m/exec write a line = a middle click: an editor command word, or a shell line $m/pane/<n>/pty/run write one line, read `exit N` + its output, or `busy` / `error ...`, on the same open: exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 @@ -299,7 +299,17 @@ write with EINVAL; a command that fails inside the editor is reported on the message row, not as a write error. Reading any of these files answers the serials of the panes the last command created, or, when it created none, the pane a look focused or the pane an exec acted on (even one it closed), one -per line. +per line; a look that found text answers the pane the text is selected in, +not the hits buffer it opened. + +A look takes acme's addresses after a colon (editors/acme/look.c:450): a +line written to `look` as `file:/re/`, `file:#n`, `file:$` or any address +opens (or finds) the file and selects what the address names, evaluated +from its dot; `:addr` does the same in the pane itself. `file:12` and +`file:12:5` stay line and column. A bare `/re/` is a path, as in acme, and +failing that a search for its text. A look that finds nothing, or an +address that does not evaluate, says so on the message row and changes +nothing: no buffer opens and the selection stays. `/pane/<n>/name` reads the pane's file name (a terminal's directory) and writing it renames the buffer; a relative name resolves against the pane's diff --git a/src/builtins.zig b/src/builtins.zig index a755969b..97f7817f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -1109,8 +1109,10 @@ pub const Find = struct { pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true }; pub fn run(c: Ctx) void { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); - if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .find, .top) catch |err| - c.p.reportError(c.id, "find", err); + if (pat.len > 0) { + _ = look.runSearch(c.p, c.id, pat, .find, .top, true) catch |err| c.p.reportError(c.id, "find", err); + return; + } c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.find_marker }); } }; @@ -1123,8 +1125,10 @@ pub const Grep = struct { pub const output: OutputTraits = .{ .name = config.search_buffer, .steps = true, .locations = true }; pub fn run(c: Ctx) void { const pat = std.mem.trim(u8, c.arg orelse "", " \t\r\n"); - if (pat.len > 0) return look.runSearch(c.p, c.id, pat, .grep, .top) catch |err| - c.p.reportError(c.id, "grep", err); + if (pat.len > 0) { + _ = look.runSearch(c.p, c.id, pat, .grep, .top, true) catch |err| c.p.reportError(c.id, "grep", err); + return; + } c.p.startPrompt(c.pane, &c.pane.body, .{ .search = config.grep_marker }); } }; diff --git a/src/look.zig b/src/look.zig index 2e27a001..020aacb8 100644 --- a/src/look.zig +++ b/src/look.zig @@ -518,7 +518,7 @@ pub fn submitSearch(p: *Pardes, id: usize) void { .grep else .text; - runSearch(p, id, armed[slash + 1 ..], kind, .top) catch |err| return p.reportError(id, "search", err); + _ = runSearch(p, id, armed[slash + 1 ..], kind, .top, true) catch |err| return p.reportError(id, "search", err); if (kind == .text) lookFirstHit(p, id); } @@ -538,10 +538,13 @@ fn lookFirstHit(p: *Pardes, id: usize) void { exec.runBuiltin(p, config.look_cmd, rid, "", txt); } -pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart) !void { - const pane = p.panes[id] orelse return; +/// Fills the results buffer, and answers how many rows a text or PDF +/// search found (0 for Find and Grep, which do not count). With +/// `open_empty` false a text search that found nothing opens no buffer. +pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start: SearchStart, open_empty: bool) !usize { + const pane = p.panes[id] orelse return 0; const pat = std.mem.trim(u8, pat_raw, " \t\r\n"); - if (pat.len == 0) return; + if (pat.len == 0) return 0; const arena = p.scratch.allocator(); // where the pane lives: a file's directory, a shell's cwd — the walk // root, and the directory the results buffer is named in. @@ -617,6 +620,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start nrows += 1; } } + if (!open_empty and kind == .text and nrows == 0) return 0; const content = try p.gpa.dupe(u8, out[0..out_len]); // the buffer records WHICH search filled it, pattern and all: Find and // Grep are builtins (words you can execute), the bare `/` is a key @@ -626,6 +630,7 @@ pub fn runSearch(p: *Pardes, id: usize, pat_raw: []const u8, kind: Search, start .grep => .{ .cmd = .Grep }, }; try panes.Output.fillResults(p, id, dir, from, pat, content, anchor); + return nrows; } pub fn searchStep(p: *Pardes, id: usize, delta: i32) bool { @@ -1137,6 +1142,53 @@ pub fn followPdfLink(p: *Pardes, id: usize, probe: *const panes.Pdf.WordProbe) v } } +/// `file:addr` or `:addr`, acme's look.c:450-543: a colon followed by an +/// address character (addr.c:28, isaddrc) that is not a line spot this +/// parser already reads (`file:12`, `file:12:5`). Not a URL. +fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } { + if (std.mem.indexOf(u8, tok, "://") != null) return null; + if (std.mem.indexOfAny(u8, tok, " \t") != null) return null; + const colon = std.mem.indexOfScalar(u8, tok, ':') orelse return null; + const expr = tok[colon + 1 ..]; + if (expr.len == 0 or std.mem.indexOfScalar(u8, "0123456789+-/$.#,;", expr[0]) == null) return null; + if (std.ascii.isDigit(expr[0])) { + const pl = parsePathLine(tok); + if (pl.at.line != 0 and pl.end == tok.len) return null; + } + return .{ .path = tok[0..colon], .expr = expr }; +} + +/// Opens (or finds) `path` as Look would, `id`'s own pane when it is empty, +/// and selects what `expr` addresses there from its dot. An address that +/// does not evaluate moves nothing and says why (acme: no jump). +fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void { + const ninep_pane = @import("ninep/pane.zig"); + const Addr = @import("ninep/addr.zig").Addr; + var target = id; + if (path.len > 0) { + const before = p.active; + lookAt(p, id, path); + target = p.active; + // The path opened nothing (a search, or no such file): Look said so. + if (target == before and target != id) return; + } + const pane = p.panes[target] orelse return; + const f = pane.file orelse return; + if (path.len > 0 and (f.output != null or !std.mem.endsWith(u8, f.path, std.fs.path.basename(path)))) return; + var a: Addr = .{ .text = f.content, .lim = null, .expr = expr }; + const r = a.address(ninep_pane.dotOf(pane)) orelse { + var said: [128]u8 = undefined; + return p.setMessage(id, std.fmt.bufPrint(&said, "Look: {s}", .{a.err}) catch "Look: bad address"); + }; + if (a.i < expr.len or r.q0 > r.q1) { + var said: [128]u8 = undefined; + return p.setMessage(id, std.fmt.bufPrint(&said, "Look: {s}", .{if (r.q0 > r.q1) "addresses out of order" else "bad address syntax"}) catch "Look: bad address"); + } + clearNavigationSelection(p, pane); + ninep_pane.setDot(pane, r); + p.active = target; +} + pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { const pane = p.panes[id] orelse return; p.noteHaptic(.look); @@ -1149,31 +1201,36 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { // The operand may borrow selected terminal/PDF text released below. const txt = p.scratch.allocator().dupe(u8, operand) catch return; const trimmed = std.mem.trim(u8, txt, " \t\r\n"); - clearNavigationSelection(p, pane); + // acme's `file:addr` and `:addr` (look.c:450-543): an address after + // the colon, evaluated in that file from its dot (openfile, look.c:675). + if (addressed(trimmed)) |ad| return lookAddress(p, id, ad.path, ad.expr); const pl = parsePathLine(trimmed); + if (comptime pdf_enabled) if (pane.hasPdf()) clearNavigationSelection(p, pane); if (comptime pdf_enabled) if (panes.Pdf.lookSection(p, id, pl.path, pl.at)) return; var realbuf: [4096]u8 = undefined; const found = resolveLookTarget(p, id, txt, &realbuf); + // A miss changes nothing, the selection included; anything else starts + // from a clean one. + if (found != .none) clearNavigationSelection(p, pane); switch (found) { .none => { if (pane.image != null or pane.body.mode == .tty) return; - runSearch(p, id, trimmed, .text, .cursor) catch |err| { + const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| { p.reportError(id, "search", err); return; }; + if (rows == 0) { + var said: [96]u8 = undefined; + return p.setMessage(id, std.fmt.bufPrint(&said, "Look: no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)}) catch "Look: no match"); + } + clearNavigationSelection(p, pane); const at = pane.search_row; _ = searchStep(p, id, 1); if (at != null and pane.search_row == at) { pane.search_row = null; _ = searchStep(p, id, 1); } - // A path that is not there searches for its text, and a search - // that found nothing says so rather than leave an empty buffer. - const results = if (pane.search_pane) |rid| p.panes[rid] else null; - if (results) |r| if (r.file) |f| if (f.content.len == 0) { - var said: [96]u8 = undefined; - p.setMessage(id, std.fmt.bufPrint(&said, "Look: no match for \"{s}\"", .{@import("Messages.zig").clip(trimmed, 48)}) catch "Look: no match"); - }; + }, .pane => |t| { if (t.id >= MAX_PANES) return; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index e4e983a8..8b73e8f7 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -76,6 +76,9 @@ fn run(p: *Pardes, id: usize, exec: bool, text: []const u8) void { const after = Serials.snapshot(p); var created = false; for (after.list[0..after.len]) |serial| if (std.mem.indexOfScalar(u32, before.list[0..before.len], serial) == null) { + // A look's hits buffer is how it found the text, not where: it + // answers the pane the hit is selected in. + if (!exec) if (p.paneBySerial(serial)) |made| if (p.panes[made].?.file) |f| if (f.output) |o| if (o.from == .search) continue; noteResult(p, serial); created = true; }; @@ -739,6 +742,37 @@ test "a second lock is refused until the holder unlocks or closes, and binds nob for (p.fs.opens) |o| try testing.expect(o.node == 0); } +test "a look takes acme's :addr and file:addr, and a miss changes nothing" { + const gpa = testing.allocator; + const p = try withFile(gpa, "foo x foo y\nbar\n"); + defer p.deinit(); + const serial = serialOf(p); + const look_node = Node.of(serial, .look); + const dot = Node.of(serial, .dot); + // :addr is an address in this pane, evaluated from its dot. + try testing.expectEqual(Status.ok, wr(p, look_node, ":/y/\n").reply.status); + try testing.expectEqualStrings(" 10 11 ", rd(p, dot, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, look_node, ":#4\n").reply.status); + try testing.expectEqualStrings(" 4 4 ", rd(p, dot, 0, 64).bytes); + try testing.expectEqual(Status.ok, wr(p, look_node, ":/foo/\n").reply.status); // from #4: the second foo + try testing.expectEqualStrings(" 6 9 ", rd(p, dot, 0, 64).bytes); + // file:addr names the pane's own file here. + var line: [256]u8 = undefined; + const path = p.panes[0].?.file.?.path; + try testing.expectEqual(Status.ok, wr(p, look_node, try std.fmt.bufPrint(&line, "{s}:/bar/\n", .{path})).reply.status); + try testing.expectEqualStrings(" 12 15 ", rd(p, dot, 0, 64).bytes); + // A miss: no buffer, the selection kept, and it says so. + const panes_before = p.panes.len - std.mem.count(?*pardes.Pane, &p.panes, &.{null}); + try testing.expectEqual(Status.ok, wr(p, look_node, "zzqq\n").reply.status); + try testing.expectEqual(panes_before, p.panes.len - std.mem.count(?*pardes.Pane, &p.panes, &.{null})); + try testing.expectEqualStrings(" 12 15 ", rd(p, dot, 0, 64).bytes); + try testing.expectStringStartsWith(p.panes[0].?.msg[0..p.panes[0].?.msg_len], "Look: no match"); + // A hit answers the pane the text is selected in, not its hits buffer. + try testing.expectEqual(Status.ok, wr(p, look_node, "foo\n").reply.status); + var own: [16]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&own, "{d}\n", .{serial}), rd(p, look_node, 0, 64).bytes); +} + test "exec runs a builtin at the pane and records the pane it acted on" { const gpa = testing.allocator; const p = try withFile(gpa, "Msg fs-ran\n"); diff --git a/test/pdf.zig b/test/pdf.zig index 47f8a9e8..2699fe01 100644 --- a/test/pdf.zig +++ b/test/pdf.zig @@ -883,7 +883,7 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" { try std.testing.expect(pane.pdf.?.document_scroll_y > before_height_scroll); const revision_before_search = active_raster.revision; - try pardes.look.runSearch(p, 0, "Pardes", .text, .top); + _ = try pardes.look.runSearch(p, 0, "Pardes", .text, .top, true); try std.testing.expectEqualStrings("Pardes", pane.pdf.?.search_query); try std.testing.expectEqual(revision_before_search, active_raster.revision); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; @@ -1579,7 +1579,7 @@ test "PDF n/N addresses and reveals distinct MuPDF hits on one page" { p.locations_config.context = 2; p.locations_config.tscontext = true; - try pardes.look.runSearch(p, 0, "Pardes", .text, .top); + _ = try pardes.look.runSearch(p, 0, "Pardes", .text, .top, true); const results_id = pane.search_pane orelse return error.MissingPdfSearchResults; const results = p.panes[results_id].?.file.?; var found: [3]bool = @splat(false); |
