diff options
Diffstat (limited to 'src/ninep')
| -rw-r--r-- | src/ninep/ctl.zig | 83 | ||||
| -rw-r--r-- | src/ninep/pane.zig | 29 | ||||
| -rw-r--r-- | src/ninep/pty.zig | 21 | ||||
| -rw-r--r-- | src/ninep/tree.zig | 15 |
4 files changed, 135 insertions, 13 deletions
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index b93d83c3..397f3339 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -52,7 +52,7 @@ fn noteResult(p: *Pardes, serial: u32) void { /// created; failing that, a look records the pane it focused and an exec /// the pane it acted on (even one it closed). pub fn run(p: *Pardes, id: usize, exec: bool, text: []const u8, in_tag: bool) void { - const context = p.panes[id].?.serial; + const context = if (p.panes[id]) |pane| pane.serial else 0; // A pane an event reader holds hears its clicks instead of acting on // them (editors/acme/look.c:53 and exec.c:173 send them to winevent), // and a line written to a look or exec is a click: on that pane, or for @@ -260,6 +260,7 @@ pub fn resultsLen(p: *Pardes) u64 { var n: u64 = 0; for (p.fs.results[0..p.fs.results_len]) |serial| { var digits: [16]u8 = undefined; + // unreachable: a u32 serial and a newline fit 16 n += (std.fmt.bufPrint(&digits, "{d}\n", .{serial}) catch unreachable).len; } return n; @@ -446,7 +447,7 @@ pub fn runBuiltin(p: *Pardes, req: Req, id: usize, line: []const u8) ?Reply { defer p.fs.no_prompt = false; // What a ctl write touched reads back from /look and /exec as a click's // does (`run`), so a script that made a pane with `Tty` finds it there. - const context = p.panes[id].?.serial; + const context = if (p.panes[id]) |pane| pane.serial else 0; const before = Serials.snapshot(p); _ = exec_line.executeBuiltinLine(p, id, line); const after = Serials.snapshot(p); @@ -580,7 +581,8 @@ pub fn writeRoot(p: *Pardes, req: Req) Reply { if (checkBuiltin(p, req, line, .session)) |refusal| return refusal; continue; } - if (p.panes[p.active] == null) return Reply.fail(req.tag, E.NOENT); + // An empty window (no pane at all) still takes its session words. + if (p.panes[p.active] == null and p.anyPane() != null) return Reply.fail(req.tag, E.NOENT); p.fs.session_write = true; defer p.fs.session_write = false; if (runBuiltin(p, req, p.active, line)) |refusal| return refusal; @@ -1731,8 +1733,8 @@ test "Shell refuses a path that is no executable, and bare it goes back to the d const root_ctl = @intFromEnum(tree.TopFile.ctl); const refused = wr(p, root_ctl, "Shell /nonexistent/zzsh\n"); try testing.expectEqual(Status.err, refused.reply.status); - try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: no shell \"/nonexistent/zzsh\"") != null); - try testing.expect(th.logHas(p, "no shell \"/nonexistent/zzsh\"")); + try testing.expect(std.mem.indexOf(u8, refused.reply.ename, "Shell: shell \"/nonexistent/zzsh\" not found") != null); + try testing.expect(th.logHas(p, "shell \"/nonexistent/zzsh\" not found")); try testing.expectEqual(E.IO, wr(p, root_ctl, "Shell /etc\n").errno()); try testing.expect(th.logHas(p, "Shell: not a shell: /etc is a directory")); try testing.expectEqualStrings("", p.settings.shell.requested.get()); @@ -1922,10 +1924,10 @@ test "Grep with no hit says grep: no match and opens nothing, a missing pane dir for (p.panes) |slot| count += @intFromBool(slot != null); const missed = wr(p, Node.of(serialOf(p), .exec), "Grep zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, missed.errno()); - try testing.expectEqualStrings("grep: no match, not found", missed.reply.ename); + try testing.expectEqualStrings("grep: pattern not found", missed.reply.ename); const unfound = wr(p, Node.of(serialOf(p), .exec), "Find zzqqnomatchzz\n"); try testing.expectEqual(E.NOENT, unfound.errno()); - try testing.expectEqualStrings("find: no match, not found", unfound.reply.ename); + try testing.expectEqualStrings("find: name not found", unfound.reply.ename); var after: usize = 0; for (p.panes) |slot| after += @intFromBool(slot != null); try testing.expectEqual(count, after); @@ -2096,7 +2098,7 @@ test "a language server question that finds nothing, cannot be answered, or is a p.lspRequest(id, .incoming_calls, ""); try testing.expect(p.lsp_wait != null); p.lspResponse(p.lsp_wait.?.id, ""); - try testing.expectEqualStrings("Callers: no callers, not found", p.fs.late_failure[0..p.fs.late_failure_len]); + try testing.expectEqualStrings("Callers: callers not found", p.fs.late_failure[0..p.fs.late_failure_len]); // A backend that cannot answer says why. p.fs.late_failure_len = 0; p.lspRequest(id, .outgoing_calls, ""); @@ -2140,6 +2142,23 @@ test "a corrupt image fails its look with an err, and no pane is made" { try testing.expectEqual(count, after); } +test "a truncated PDF fails its look with its path and why" { + if (comptime !pardes.pdf_enabled) return; + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const bytes = try @import("mupdf").makeLinksTestPdf(testing.allocator); + defer testing.allocator.free(bytes); + try tmp.dir.writeFile(testing.io, .{ .sub_path = "cut.pdf", .data = bytes[0..40] }); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + var line: [4200]u8 = undefined; + const r = wr(p, root_look, try std.fmt.bufPrint(&line, "{s}/cut.pdf\n", .{dir})); + try testing.expectEqual(Status.err, r.reply.status); + try testing.expect(std.mem.indexOf(u8, r.reply.ename, "cut.pdf: not a PDF pardes can read: ") != null); +} + test "a PDF's body reads its page's text layer, and takes no write" { if (comptime !pardes.pdf_enabled) return; const p = try withFile(testing.allocator, "x\n"); @@ -2201,6 +2220,20 @@ test "+Unsaved drops a pane once it has closed" { try testing.expect(std.mem.indexOf(u8, list.file.?.content, "/tmp/pardes-unsaved-a.txt: Modified") == null); } +test "a buffer whose file was deleted on disk is dirty, and Del asks first" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + try testing.expectEqualStrings("0", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + pardes.panes.File.deleted(p, @intCast(p.paneBySerial(serial).?)); + try testing.expectEqualStrings("1", std.mem.trimEnd(u8, rd(p, Node.of(serial, .dirty), 0, 8).bytes, "\n")); + try testing.expectEqual(Status.err, wr(p, Node.of(serial, .ctl), "Del\n").reply.status); + try testing.expect(p.paneBySerial(serial) != null); + _ = wr(p, Node.of(serial, .ctl), "Del\n"); + p.sync(); + try testing.expect(p.paneBySerial(serial) == null); +} + test "every EINVAL a write gets says why, in its err record too; DEL is a control character in a line" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2512,6 +2545,33 @@ test "Recent keeps only files on disk: not a name never saved, nor the served tr try testing.expect(p.recent.get(names[2]) != null); } +test "a new file saved shows in /recent, open, while it is" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [4096]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)]; + // A host that writes files, as the real ones do. + const Writing = struct { + fn write(_: ?*anyopaque, _: u8, path: []const u8, bytes: []const u8) void { + std.Io.Dir.cwd().writeFile(testing.io, .{ .sub_path = path, .data = bytes }) catch {}; + } + }; + p.host = .{ .vtable = &.{ .write_file = Writing.write } }; + defer p.host = .{}; + const made = try th.newPane(p); + var line: [4200]u8 = undefined; + const path = try std.fmt.bufPrint(&line, "{s}/new.txt", .{dir}); + var name: [4200]u8 = undefined; + _ = wr(p, Node.of(made, .name), try std.fmt.bufPrint(&name, "{s}\n", .{path})); + _ = wr(p, Node.of(made, .body), "fresh\n"); + try testing.expectEqual(Status.ok, wr(p, Node.of(made, .ctl), "Save\n").reply.status); + p.perform(.{ .save_file = .{ .pane = @intCast(p.paneBySerial(made).?) } }); + var want: [4300]u8 = undefined; + try testing.expect(std.mem.indexOf(u8, rd(p, @intFromEnum(tree.TopFile.recent), 0, 8192).bytes, try std.fmt.bufPrint(&want, "open {s}\n", .{path})) != null); +} + test "/recent shows a name as /index does, a newline in it escaped" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -2584,6 +2644,13 @@ test "a remembered place is clamped to the file as it is now, its selection's tw try testing.expect(back.body.vsel.active); // Its ends clamped to the last character, `two`'s `o`, not its newline. try testing.expect(back.body.cur_col <= 2 and back.body.vsel.col <= 2); + try testing.expect(std.mem.indexOf(u8, back.msg[0..back.msg_len], "c.txt has 2 lines now: at its last") != null); + // The same row with the file open: the same place, said the same way. + _ = wr(p, Node.of(rows.serial, .look), loc); + p.sync(); + try testing.expectEqual(back.serial, p.panes[p.active].?.serial); + try testing.expect(back.body.cur_row <= 1); + try testing.expect(std.mem.indexOf(u8, back.msg[0..back.msg_len], "at its last") != null); } test "size is monotonic: growing is never refused, and a size once taken is taken again" { diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index f90a83d8..9f85a75f 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -125,7 +125,8 @@ pub fn tagOf(p: *Pardes, pane: *Pane) []const u8 { pub fn dirtyOf(pane: *const Pane) bool { const f = if (pane.file) |*x| x else return false; if (!panes.Output.fileTraits(f.output).saves) return false; - return f.revision != f.saved_revision; + // Its file deleted on disk: the text is only here now, as acme counts it. + return f.revision != f.saved_revision or f.disk_gone; } /// The pane's name: its file, its document, or a terminal's directory. @@ -280,6 +281,7 @@ pub fn fileSize(p: *Pardes, id: usize, f: PaneFile) u64 { .pty_status => pty.status_len, .pty_ctl => pty_ctl: { var buf: [32]u8 = undefined; + // unreachable: two u16s and 9 bytes fit 32 break :pty_ctl (std.fmt.bufPrint(&buf, "winsize {d} {d}\n", .{ pane.cols, pane.rows }) catch unreachable).len; }, .pty_data => if (pf.pty_out.peek()) |chunk| chunk.len else 0, @@ -303,9 +305,11 @@ pub fn indexLen(p: *Pardes) u64 { last = serial; const pane = p.panes[p.paneBySerial(serial).?].?; var digits: [16]u8 = undefined; + // unreachable: a u32 serial is at most 10 digits n += (std.fmt.bufPrint(&digits, "{d}", .{serial}) catch unreachable).len; var name_buf: [4 * 4096]u8 = undefined; n += 1 + kindOf(pane).len + 3 + events.shown(nameOf(pane), &name_buf).len + 1; + // unreachable: a column index is under 16 digits n += 1 + (std.fmt.bufPrint(&digits, "{d}", .{columnOf(p, p.paneBySerial(serial).?)}) catch unreachable).len; } return n; @@ -665,7 +669,8 @@ fn nameFault(name: []const u8) ?[]const u8 { while (std.mem.indexOfScalarPos(u8, name, i, 0xC2)) |at| : (i = at + 1) if (at + 1 < name.len and name[at + 1] <= 0x9F) return e_name_char ++ ": a control character"; } - if (name[0] == ' ' or name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end"; + if (name[0] == ' ') return e_name_char ++ ": a blank at its start"; + if (name[name.len - 1] == ' ') return e_name_char ++ ": a blank at its end"; return null; } @@ -1123,6 +1128,24 @@ test "after a failed address, one that goes from it says there is none" { try testing.expectEqual(Status.ok, wr(p, addr, ".").reply.status); } +test "a write of two lines to name is refused EINVAL, on a held open or not" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const node = Node.of(serial, .name); + try testing.expectEqual(E.INVAL, wr(p, node, "/tmp/a\n/tmp/b\n").errno()); + const h = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h, .data = "/tmp/a\n/tmp/b\n" }).errno()); + _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h, .opened = true }); + try testing.expectEqualStrings("/test.txt", nameOf(p.panes[p.paneBySerial(serial).?].?)); + // Line by line, as bash writes it: the second line is refused. + const h2 = call(p, .{ .tag = 1, .op = .open, .node = node, .omode = 1 }).reply.handle; + try testing.expectEqual(Status.ok, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-a\n" }).reply.status); + try testing.expectEqual(E.INVAL, call(p, .{ .tag = 2, .op = .write, .node = node, .handle = h2, .data = "/tmp/pardes-b\n" }).errno()); + _ = call(p, .{ .tag = 3, .op = .release, .node = node, .handle = h2, .opened = true }); + try testing.expectEqualStrings("/tmp/pardes-a", nameOf(p.panes[p.paneBySerial(serial).?].?)); +} + test "a name cut across writes is one name, applied once at its newline or its close" { const p = try withFile(testing.allocator, "x\n"); defer p.deinit(); @@ -1252,7 +1275,7 @@ test "name reads the file name and writing it promotes a scratch without touchin // off), a second line, a control byte, DEL, a C1 control, not UTF-8. for ([_][2][]const u8{ .{ "trailing.zig \n", "a blank at its end" }, - .{ " leading.zig\n", "a blank at its end" }, + .{ " leading.zig\n", "a blank at its start" }, .{ "tab\t.zig\n", "a control character" }, .{ "two\nlines\n", "a newline" }, .{ "del\x7f.zig\n", "a control character" }, diff --git a/src/ninep/pty.zig b/src/ninep/pty.zig index bf5f432c..d32c35fd 100644 --- a/src/ninep/pty.zig +++ b/src/ninep/pty.zig @@ -183,14 +183,20 @@ pub const Run = struct { /// Plan 9's kprint read the same way). read: usize = 0, /// The first line: how it ended. - answer: [48]u8 = undefined, + answer: [96]u8 = undefined, len: u8 = 0, /// Then what the command printed, gpa-owned. output: []u8 = &.{}, }; fn answer(p: *Pardes, slot: *Run, comptime fmt: []const u8, args: anytype) void { - slot.len = @intCast((std.fmt.bufPrint(&slot.answer, fmt ++ "\n", args) catch unreachable).len); + // A program's name comes from the host, whatever its length: an answer + // longer than the room is cut, its newline kept, never a panic. + var w: std.Io.Writer = .fixed(&slot.answer); + w.print(fmt ++ "\n", args) catch { + slot.answer[slot.answer.len - 1] = '\n'; + }; + slot.len = @intCast(w.end); slot.phase = .done; p.fs.news = true; // the read held on it can be answered } @@ -362,6 +368,7 @@ fn finish(p: *Pardes, slot: *Run, pane: *Pane, status_code: ?i32) void { } // A D that carries no status says nothing of how the command went. var code: [16]u8 = undefined; + // unreachable: an exit status fits 16 const status = if (status_code) |s| std.fmt.bufPrint(&code, "{d}", .{s}) catch unreachable else "?"; // The header is the whole first line, so a count there can never be // mistaken for output; `cut` with no count: its start is not there to @@ -387,6 +394,7 @@ pub fn shellExited(p: *Pardes, pane: *Pane, status: ?u8) void { finish(p, slot, pane, code); }; var buf: [4]u8 = undefined; + // unreachable: a u8 exit code is at most 3 digits pardes.exec.noteRun(p, pane, "exit", std.fmt.bufPrint(&buf, "{d}", .{code}) catch unreachable); } @@ -1070,3 +1078,12 @@ test "the pty queue drops the oldest at its cap" { } try testing.expect(seen > 0 and seen <= events.queue_cap); } + +test "a run's answer longer than its room is cut, its newline kept" { + var slot: Run = .{}; + const p = try th.withTerm(testing.allocator); + defer p.deinit(); + answer(p, &slot, "busy: {s} is running", .{"x" ** 200}); + try testing.expectEqual(slot.answer.len, slot.len); + try testing.expectEqual(@as(u8, '\n'), slot.answer[slot.len - 1]); +} diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig index 60778b9c..d6f4211b 100644 --- a/src/ninep/tree.zig +++ b/src/ninep/tree.zig @@ -162,6 +162,9 @@ pub const Open = struct { /// answered from it again: bash's `read` takes a chunk, keeps a line and /// lseeks back to just past it, and a stream has already moved on. replay: ?Replay = null, + /// A `name` open has named its pane: one name an open, so a second line + /// on it (bash writes `printf 'a\nb\n'` a line at a time) is refused. + named: bool = false, pub const Replay = struct { off: u64, bytes: []u8 }; @@ -512,6 +515,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { .col => |c| { if (layout.columnBySerial(p, c.serial) == null) return null; return .{ + // unreachable: a u32 serial fits node_name (16) .name = if (c.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{c.serial}) catch unreachable) else c.file.fileName(), .node = Node.ofCol(c.serial, c.file), .dir = c.file == .dir, @@ -534,6 +538,7 @@ fn attrOf(p: *Pardes, target: Target) ?Reply.Attr { const pn = p.panes[id].?; if (t.file.inPty() and !pn.isTerminal()) return null; return .{ + // unreachable: a u32 serial fits node_name (16) .name = if (t.file == .dir) (std.fmt.bufPrint(&p.fs.node_name, "{d}", .{t.serial}) catch unreachable) else t.file.fileName(), .node = Node.of(t.serial, t.file), .dir = t.file.isDir(), @@ -579,6 +584,7 @@ fn topSize(p: *Pardes, f: TopFile) u64 { if (p.header_focus) break :focus 0; const pn = p.panes[p.active] orelse break :focus 0; var digits: [16]u8 = undefined; + // unreachable: a u32 serial and a newline fit 16 break :focus (std.fmt.bufPrint(&digits, "{d}\n", .{pn.serial}) catch unreachable).len; }, }; @@ -688,6 +694,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { continue; } var buf: [16]u8 = undefined; + // unreachable: a u32 serial fits 16 const name = std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable; stageDirent(out, p.gpa, Node.of(serial, .dir), true, name); } @@ -699,6 +706,7 @@ fn readdir(p: *Pardes, req: Req, target: Target) Reply { } var buf: [16]u8 = undefined; const serial = layout.columnSerial(p, c); + // unreachable: a u32 serial fits 16 stageDirent(out, p.gpa, Node.ofCol(serial, .dir), true, std.fmt.bufPrint(&buf, "{d}", .{serial}) catch unreachable); }, else => return Reply.fail(req.tag, E.NOTDIR), @@ -1071,6 +1079,13 @@ fn write(p: *Pardes, req: Req, target: Target) Reply { o.pending.clearRetainingCapacity(); return failText(req.tag, E.INVAL, "a line or Edit block over 1 MiB"); } + if (target == .pane and target.pane.file == .name) { + if (o.named or std.mem.count(u8, o.pending.items[0..end], "\n") > 1) { + o.pending.clearRetainingCapacity(); + return failText(req.tag, E.INVAL, "invalid file name: one name a write, on one line"); + } + o.named = true; + } const whole = p.scratch.allocator().dupe(u8, o.pending.items[0..end]) catch return Reply.fail(req.tag, E.NOMEM); const rest = o.pending.items.len - end; std.mem.copyForwards(u8, o.pending.items[0..rest], o.pending.items[end..]); |
