From 6122159572f1cc950666df484e2074cf32f22eb5 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 16:51:34 -0300 Subject: A 9P look keeps its leading blanks: an indented line, or blanks alone, is looked for as written Every line written to look or exec went through Messages.next, which trimmed blanks from both ends. A look of ` indented` then found the first bare `indented`, and a diff's blank context line ` ` looked at nothing. A look's line is now its text: only its `\r` and newline go. lookAt keeps the blanks for the word search too; it expands `~` only for a look that starts with one, and the path, address and command readings trim for themselves as before. runSearch keeps a pattern's blanks, since a builtin's argument comes trimmed already. An exec line is trimmed as before. fs.md says so. Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 5 ++++- src/look.zig | 25 +++++++++++++++++-------- src/ninep/ctl.zig | 23 +++++++++++++++++++++++ test/monkey9p.py | 9 +++++++-- 4 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index 985e36a1..020fce58 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -196,7 +196,10 @@ an edit land. Modes are 0644/0666, 0444 read-only, 0222 write-only. ## look and exec -A line written to `look` is a right click on it: +A line written to `look` is a right click on it, on the line as written: +its leading blanks are its own (` return x` finds that indented line, and +a diff's blank context line ` ` is a look too); only its newline and `\r` +go. An `exec` line's blanks at either end are trimmed. - a path opens the file (the pane already showing it, if any); `file:12` selects line 12, newline included; `file:12:5` puts the caret at line 12, diff --git a/src/look.zig b/src/look.zig index 76b58fbe..0f99722d 100644 --- a/src/look.zig +++ b/src/look.zig @@ -580,8 +580,10 @@ fn lookFirstHit(p: *Pardes, id: usize) void { /// `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 0; + // Blanks at either end are the pattern's (a look of an indented line); + // a builtin's argument comes trimmed already. + const pat = std.mem.trimEnd(u8, pat_raw, "\r\n"); + if (std.mem.trim(u8, pat, " \t").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. From a @@ -1325,7 +1327,9 @@ fn addressed(tok: []const u8) ?struct { path: []const u8, expr: []const u8 } { /// A look at text that names nothing to open: a `./` name is a missing /// file, anything else a search for the text as written. -fn lookUnresolved(p: *Pardes, id: usize, pane: *Pane, trimmed: []const u8) void { +/// `text` is what a word search looks for: the look as written, its blanks +/// kept; `trimmed` what a name is read from. +fn lookUnresolved(p: *Pardes, id: usize, pane: *Pane, trimmed: []const u8, text: []const u8) void { // A `./` or `../` name opened nothing: said, ENOENT, not searched // for as a word. What is missing is the pane's directory when // that is gone, else the file named. @@ -1340,8 +1344,9 @@ fn lookUnresolved(p: *Pardes, id: usize, pane: *Pane, trimmed: []const u8) void // A raw terminal's clicks are its program's; a look written over // 9P is no keyboard input, and searches whatever the key mode. if (pane.image != null or (pane.body.mode == .tty and !p.fs.serving)) return; - if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, trimmed); - const rows = runSearch(p, id, trimmed, .text, .cursor, false) catch |err| { + if (text.len == 0) return; + if (p.settings.look_word == .search and pane.file != null) return lookWord(p, id, pane, text); + const rows = runSearch(p, id, text, .text, .cursor, false) catch |err| { p.reportError(id, "search", err); return; }; @@ -1380,7 +1385,7 @@ fn lookAddress(p: *Pardes, id: usize, path: []const u8, expr: []const u8) void { // A path that names nothing to open: the whole of what was written // is looked for, as `zzq:2` is, and a miss quotes it as written. if (!by_serial and resolveLookTarget(p, id, path, &realbuf) == .none) - return lookUnresolved(p, id, p.panes[id] orelse return, path.ptr[0 .. path.len + 1 + expr.len]); + return lookUnresolved(p, id, p.panes[id] orelse return, path.ptr[0 .. path.len + 1 + expr.len], path.ptr[0 .. path.len + 1 + expr.len]); const before = p.active; const failures = p.fs.failures; const was_missed = p.look_missed; @@ -1598,8 +1603,12 @@ fn diffFile(base: []const u8, path: []const u8, out: *[4096]u8) ?[]const u8 { pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { const pane = p.panes[id] orelse return; // `~/x`, `~/x:12`, `~/x:/re/`: home, before anything reads the text. + // Anything else as written: a look of ` return x` is of those blanks + // too (the path, address and command readings below trim for + // themselves). var home_buf: [4096]u8 = undefined; - const operand = filesystem.expandHome(std.mem.trim(u8, operand_raw, " \t\r\n"), &home_buf); + const stripped = std.mem.trim(u8, operand_raw, " \t\r\n"); + const operand = if (stripped.len > 0 and stripped[0] == '~') filesystem.expandHome(stripped, &home_buf) else std.mem.trimEnd(u8, operand_raw, "\r\n"); // A +Recent row is a remembered place: reopened and clamped to the file // as it is now, never a miss for a line the file no longer has. if (isRecentList(pane)) { @@ -1665,7 +1674,7 @@ pub fn lookAt(p: *Pardes, id: usize, operand_raw: []const u8) void { pane.sel = @splat(.{}); } switch (found) { - .none => lookUnresolved(p, id, pane, trimmed), + .none => lookUnresolved(p, id, pane, trimmed, std.mem.trimEnd(u8, txt, "\r\n")), .pane => |t| { // `@p` names no open pane: a miss, said. if (t.id >= MAX_PANES or p.panes[t.id] == null) return missed(p, id, "no pane {s}", .{pl.path[0..@min(pl.path.len, 32)]}); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 549aa265..66fa22ab 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -110,6 +110,9 @@ const Messages = struct { p: *Pardes, data: []const u8, at: usize = 0, + /// A look's line is its text, leading blanks and all (` return x`, + /// a diff's context line ` ` alone): only its `\r` goes. + keep_blanks: bool = false, fn init(p: *Pardes, data: []const u8) Messages { return .{ .p = p, .data = data }; @@ -120,6 +123,7 @@ const Messages = struct { const start = m.at; var end = std.mem.indexOfScalarPos(u8, m.data, start, '\n') orelse m.data.len; m.at = end + 1; + if (m.keep_blanks) return std.mem.trimEnd(u8, m.data[start..end], "\r"); const first = std.mem.trim(u8, m.data[start..end], " \t\r"); const edit = "Edit"; if (!std.mem.startsWith(u8, first, edit) or (first.len > edit.len and first[edit.len] != ' ' and first[edit.len] != '\t')) @@ -186,6 +190,7 @@ pub fn command(p: *Pardes, req: Req, serial: ?u32, exec: bool, in_tag: bool) Rep for ([2]bool{ false, true }) |apply| { if (apply) p.fs.results_len = 0; var it = Messages.init(p, req.data); + it.keep_blanks = !exec; while (it.next()) |text| { if (text.len == 0) continue; // Only an Edit block holds newlines (Messages). @@ -2540,6 +2545,24 @@ test "a restored pane whose file is gone from disk comes back dirty, and Del ask try testing.expect(std.mem.indexOf(u8, r.reply.ename, "Modified") != null); } +test "a 9P look keeps its leading blanks, a blank-only look included, and exec still trims" { + const p = try withFile(testing.allocator, "a indented\nb\n indented\n \n"); + defer p.deinit(); + const serial = serialOf(p); + const pane = p.panes[p.paneBySerial(serial).?].?; + pane_files.setDot(pane, .{ .q0 = 0, .q1 = 0 }); + // The indented line, not the `indented` after `a `. + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), " indented\n").reply.status); + try testing.expectEqual(pane_files.State.Range{ .q0 = 13, .q1 = 25 }, pane_files.dotOf(pane)); + // Blanks alone are text to find too. + pane_files.setDot(pane, .{ .q0 = 26, .q1 = 26 }); + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .look), " \n").reply.status); + try testing.expectEqual(pane_files.State.Range{ .q0 = 26, .q1 = 30 }, pane_files.dotOf(pane)); + // exec's line is still trimmed: ` Msg hi` runs Msg. + try testing.expectEqual(Status.ok, wr(p, Node.of(serial, .exec), " Msg blanks trimmed\n").reply.status); + try testing.expect(th.logHas(p, "blanks trimmed")); +} + test "a line starting with # runs as nothing at an exec or a ctl, silently" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); diff --git a/test/monkey9p.py b/test/monkey9p.py index 89751145..64b82832 100644 --- a/test/monkey9p.py +++ b/test/monkey9p.py @@ -698,8 +698,13 @@ def allowances(res, path, data, sizes, room): (acme's xfid.c:842) or run as the Look builtin; an Edit block whose text never ended runs, and fails, at the close.""" base = path.rsplit('/', 1)[-1] - lines = [line for line in served_lines(data, sizes, room) if line.strip()] - if base in ('look', 'event'): + served = served_lines(data, sizes, room) + lines = [line for line in served if line.strip()] + if base == 'look': + # A look's line is its text, blanks and all: one of blanks alone + # is a look too (fs.md); only an empty one (or a lone \r) is none. + n = len([line for line in served if line.rstrip(b'\r')]) + elif base == 'event': n = len(lines) elif base in ('exec', 'ctl', 'tagexec'): n = sum(1 for line in lines if line.split()[:1] == [b'Look']) -- cgit v1.3