diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/9p_io.zig | 3 | ||||
| -rw-r--r-- | src/Mini.zig | 6 | ||||
| -rw-r--r-- | src/builtins.zig | 37 | ||||
| -rw-r--r-- | src/detached/server.zig | 1 | ||||
| -rw-r--r-- | src/dump.zig | 39 | ||||
| -rw-r--r-- | src/edit.zig | 72 | ||||
| -rw-r--r-- | src/esp32p4_9p.zig | 1 | ||||
| -rw-r--r-- | src/exec.zig | 18 | ||||
| -rw-r--r-- | src/file_watch.zig | 13 | ||||
| -rw-r--r-- | src/fonts.zig | 1 | ||||
| -rw-r--r-- | src/fs.zig | 17 | ||||
| -rw-r--r-- | src/gui/gui.zig | 21 | ||||
| -rw-r--r-- | src/host_io.zig | 53 | ||||
| -rw-r--r-- | src/layout.zig | 13 | ||||
| -rw-r--r-- | src/linux/v9fs.zig | 10 | ||||
| -rw-r--r-- | src/look.zig | 21 | ||||
| -rw-r--r-- | src/lsp/lsp_client.zig | 16 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 26 | ||||
| -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 | ||||
| -rw-r--r-- | src/pardes.zig | 87 | ||||
| -rw-r--r-- | src/pdf_view.zig | 2 | ||||
| -rw-r--r-- | src/regexp.zig | 65 | ||||
| -rw-r--r-- | src/selection_pipe.zig | 8 | ||||
| -rw-r--r-- | src/tagline.zig | 2 |
27 files changed, 591 insertions, 89 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig index 96f91bc1..91240b57 100644 --- a/src/9p_io.zig +++ b/src/9p_io.zig @@ -865,7 +865,7 @@ pub fn listen(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes, named: [ return null; }; _ = l.runner.listen(.{ .unix = p }, max_conns) catch |err| retry: { - const existing = std.Io.Dir.cwd().statFile(io, p, .{ .follow_symlinks = false }) catch null; + const existing = @import("fs.zig").statPath(io, p, .{ .follow_symlinks = false }) catch null; if (err != error.AddressInUse or existing == null or existing.?.kind != .unix_domain_socket or alive(p)) { log.warn("something is already listening on {s}", .{p}); l.deinit(gpa); @@ -1638,6 +1638,7 @@ extern "c" fn unsetenv(name: [*:0]const u8) c_int; pub fn start(io: std.Io, gpa: std.mem.Allocator, core: *pardes.Pardes) ?*Listener { var name: [16]u8 = undefined; + // unreachable: a u32 is at most 10 digits const fallback = std.fmt.bufPrint(&name, "{d}", .{@as(u32, @intCast(libc.getpid()))}) catch unreachable; return listen(io, gpa, core, core.opts.ninep_name, fallback, core.opts.ninep_tcp, core.opts.ninep_quic) orelse { core.reportError(0, "9p listener", error.ListenFailed); diff --git a/src/Mini.zig b/src/Mini.zig index ac9a8e85..a6aef15a 100644 --- a/src/Mini.zig +++ b/src/Mini.zig @@ -49,6 +49,7 @@ const Row = struct { const end = modal.nextGrapheme(row.text, row.at); const grapheme = row.text[row.at..end]; row.left = File.graphemeDisplayWidth(grapheme); + // unreachable below: `generate` refuses a source that is no UTF-8 const n = std.unicode.utf8ByteSequenceLength(grapheme[0]) catch unreachable; const cp = std.unicode.utf8Decode(grapheme[0..n]) catch unreachable; const blank = switch (cp) { @@ -108,6 +109,7 @@ fn render(output: ?Result, source: []const u8, styles: []const u8) !usize { if (output) |out| { @memset(out.content[offset..][0..spaces], ' '); @memset(out.colors[offset..][0..spaces], 0); + // unreachable: U+2800 plus a u8 mask is a braille codepoint, always three bytes _ = std.unicode.utf8Encode(@as(u21, 0x2800) + mask, out.content[offset + spaces ..][0..3]) catch unreachable; @memset(out.colors[offset + spaces ..][0..3], color); } @@ -313,3 +315,7 @@ test "Mini publishes only complete snapshots and content replacement drops metad const path = try std.fmt.bufPrint(&path_buf, "{s}/mini.txt", .{dir}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{path}); } + +test "Mini refuses a file that is no UTF-8, before its decoding could panic" { + try std.testing.expectError(error.InvalidUtf8, generate(std.testing.allocator, "ok \xff\xfe bad\n\xc3", "")); +} diff --git a/src/builtins.zig b/src/builtins.zig index b0499dc0..8788e65f 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -536,13 +536,18 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { // under 100 bytes it is not asked about, as acme's winclean does not // whine about a small unnamed window, nor about a command's output. const scratch = if (f.output) |o| o.from == .cmd and o.from.cmd == .New else false; - if (f.revision == f.saved_revision or !(panes.Output.fileTraits(f.output).saves or scratch)) continue; - if (f.output != null and f.content.len < 100) continue; - // Dirty by a rename alone is no text to lose, as get says (ctl.zig). - // Nor is a buffer never edited at all: its first edit is what keeps - // the text it had (File.zig), so with none there is nothing to lose. - const saved = f.saved_hash orelse continue; - if (std.hash.Wyhash.hash(0, f.content) == saved) continue; + // A file deleted on disk is asked about, edited or not: its text is + // nowhere else now (acme's winclean asks the same). + const gone = f.disk_gone and f.output == null; + if (!gone) { + if (f.revision == f.saved_revision or !(panes.Output.fileTraits(f.output).saves or scratch)) continue; + if (f.output != null and f.content.len < 100) continue; + // Dirty by a rename alone is no text to lose, as get says (ctl.zig). + // Nor is a buffer never edited at all: its first edit is what keeps + // the text it had (File.zig), so with none there is nothing to lose. + const saved = f.saved_hash orelse continue; + if (std.hash.Wyhash.hash(0, f.content) == saved) continue; + } if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == asking) continue; pane.discard_warned = .{ .revision = f.revision, .by = asking }; // Scratches share a name: each is named with its pane's serial too, @@ -803,10 +808,14 @@ pub const ThemeFile = struct { pub const requires_arg = true; pub const enabled = pardes.hosted; pub fn run(c: Ctx) void { - if (comptime enabled) - colors.requestThemeFile(c.p, c.id, c.arg orelse return) - else - unreachable; + if (comptime !enabled) unreachable; + const arg = std.mem.trim(u8, c.arg orelse return, " \t\r\n"); + // Said with why, not "invalid theme path". + if (!std.mem.endsWith(u8, arg, ".zon")) { + var said: [320]u8 = undefined; + return c.p.reportFailure(c.id, std.fmt.bufPrint(&said, "ThemeFile: {s}: invalid theme path: a theme file is a .zon (DumpThemes writes some to start from)", .{arg[0..@min(arg.len, 200)]}) catch "ThemeFile: invalid theme path: a theme file is a .zon"); + } + colors.requestThemeFile(c.p, c.id, arg); } }; @@ -1375,8 +1384,8 @@ pub const Mini = struct { /// A Find or Grep that finds nothing fails as one, in words 9ns reads as /// ENOENT (`not found`), the same for both. -const e_search_miss_find = "find: no match, not found"; -const e_search_miss_grep = "grep: no match, not found"; +const e_search_miss_find = "find: name not found"; +const e_search_miss_grep = "grep: pattern not found"; /// List the files below this pane's directory whose names hold its argument, in a +Search. pub const Find = struct { @@ -1818,6 +1827,7 @@ const Board = struct { writeWord(addr, value); const back = readWord(addr); var buf: [96]u8 = undefined; + // unreachable below: three 8-digit hex words and 18 bytes fit 96 p.setMessage(id, std.fmt.bufPrint( &buf, "{x:0>8}: wrote {x:0>8}, reads {x:0>8}", @@ -1840,6 +1850,7 @@ const Board = struct { if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin; var buf: [48]u8 = undefined; + // unreachable: three small numbers and 10 bytes fit 48 p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable); } diff --git a/src/detached/server.zig b/src/detached/server.zig index 676b5b95..b536e4a7 100644 --- a/src/detached/server.zig +++ b/src/detached/server.zig @@ -1214,6 +1214,7 @@ pub const Session = struct { fn refuseFd(_: *Session, fd: c_int, why: wire.Refusal) void { var buf: [wire.header_len + 1]u8 = undefined; + // unreachable: a refusal is a header and one byte, which buf is sized to const bytes = wire.encodeServer(&buf, .{ .refuse = why }) catch unreachable; var off: usize = 0; while (off < bytes.len) { diff --git a/src/dump.zig b/src/dump.zig index 45a5e0eb..fca88a2d 100644 --- a/src/dump.zig +++ b/src/dump.zig @@ -47,11 +47,40 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { const yd = es.getEpochDay().calculateYearDay(); const md = yd.calculateMonthDay(); const ds = es.getDaySeconds(); - return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}.zon", .{ - dir, yd.year, md.month.numeric(), - @as(u8, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), - ds.getSecondsIntoMinute(), - }, 0) catch null; + var stamp: [32]u8 = undefined; + const base = std.fmt.bufPrint(&stamp, "pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{ + yd.year, md.month.numeric(), @as(u8, md.day_index) + 1, + ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(), + }) catch return null; + return uniqueIn(buf, dir, base); +} + +/// `<dir>/<base>.zon`, or `<base>-2.zon` and on when two dumps land in one +/// second: a dump never overwrites another. +fn uniqueIn(buf: *[1024:0]u8, dir: []const u8, base: []const u8) ?[:0]const u8 { + var n: usize = 1; + while (n < 1000) : (n += 1) { + const path = (if (n == 1) + std.fmt.bufPrintSentinel(buf, "{s}/{s}.zon", .{ dir, base }, 0) + else + std.fmt.bufPrintSentinel(buf, "{s}/{s}-{d}.zon", .{ dir, base, n }, 0)) catch return null; + if (comptime !pardes.hosted) return path; + if (std.c.access(path.ptr, std.c.F_OK) != 0) return path; + } + return null; +} + +test "two dumps in one second get names of their own" { + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var dir_buf: [900]u8 = undefined; + const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; + var buf: [1024:0]u8 = undefined; + const first = try std.testing.allocator.dupe(u8, uniqueIn(&buf, dir, "pardes-x").?); + defer std.testing.allocator.free(first); + try std.testing.expect(std.mem.endsWith(u8, first, "/pardes-x.zon")); + try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes-x.zon", .data = "" }); + try std.testing.expect(std.mem.endsWith(u8, uniqueIn(&buf, dir, "pardes-x").?, "/pardes-x-2.zon")); } /// The host's environment; a freestanding core (web, the board) has none /// and no libc to ask, so only a DumpDir with no `~/` names a directory there. diff --git a/src/edit.zig b/src/edit.zig index a194ef48..183d6325 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -487,6 +487,19 @@ fn editTextEol(p: *Pardes, t: *Text, b: Bounds) ?panes.EditText { /// install a rewritten editable text (frees the old one) pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { + setEditTextEnd(p, t, new, null); +} + +/// Where text `s` put in at `c` ends: `g.`'s place after typing it. +fn insertedEnd(c: modal.Cursor, s: []const u8) modal.Cursor { + const nl = std.mem.lastIndexOfScalar(u8, s, '\n') orelse return .{ .row = c.row, .col = c.col + s.len }; + return .{ .row = c.row + std.mem.count(u8, s, "\n"), .col = s.len - nl - 1 }; +} + +/// `setEditText` with where the edit ended in `new` (in the edit text's +/// rows) when the caller knows it, as typing does: then nothing scans the +/// whole text to find it. Null, it is found by comparing the texts. +pub fn setEditTextEnd(p: *Pardes, t: *Text, new: []u8, known_end: ?modal.Cursor) void { if (t.what == .tag) { // A tag is edited as it is shown, but only what follows its // prefix is its own: an edit that changed the prefix is refused. @@ -512,10 +525,14 @@ pub fn setEditText(p: *Pardes, t: *Text, new: []u8) void { const pane = t.pane() orelse return retireEditText(p, new); // what changed ended where the texts agree again from their ends const old: []const u8, const row0: i32 = if (pane.file) |f| .{ f.content, 0 } else if (pane.ovl) |o| .{ o.text, o.row } else .{ "", 0 }; - const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); - var back: usize = 0; - while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; - const end = modal.positionAt(new, new.len - back); + const end = known_end orelse found: { + // ponytail: O(file), for the edits that do not say where they ended + // (paste, a pipe, s): typing passes its end and never comes here. + const front = std.mem.indexOfDiff(u8, old, new) orelse @min(old.len, new.len); + var back: usize = 0; + while (back < @min(old.len, new.len) - front and old[old.len - 1 - back] == new[new.len - 1 - back]) back += 1; + break :found modal.positionAt(new, new.len - back); + }; t.last_edit = .{ .row = @as(i32, @intCast(end.row)) + row0, .col = @intCast(end.col) }; if (pane.file) |*f| return panes.File.setContent(p, f, new); panes.Terminal.setEditText(p, pane, new); @@ -813,7 +830,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row - eb.row0)), .col = @intCast(@max(0, t.cur_col)) }; if (key.text.len > 0 and !key.ctrl and !key.alt) { const new = modal.insertAt(p.gpa, text, c, key.text) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, insertedEnd(c, key.text)); for (key.text) |ch| { if (ch == '\n') { t.cur_row += 1; @@ -837,7 +854,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const from = @min(wr.anchor, wr.head); const fc = modal.positionAt(e2.text, from); const new = modal.deleteSpan(p.gpa, e2.text, fc, c2) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, fc); t.cur_row = @as(i32, @intCast(fc.row)) + e2.row0; t.cur_col = @intCast(fc.col); t.cur_pinned = true; @@ -855,13 +872,13 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const to = @max(wr.anchor, wr.head); if (to <= g) return; const new = modal.deleteSpan(p.gpa, e2.text, c2, modal.positionAt(e2.text, to)) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, c2); t.cur_pinned = true; return; } if (hit(key, config.kill_to_line_start)) { const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = 0 }, c) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row, .col = 0 }); t.cur_col = 0; t.cur_pinned = true; t.show(); @@ -870,7 +887,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { if (hit(key, config.kill_to_line_end)) { const llen = modal.lineSlice(text, c.row).len; const new = modal.deleteSpan(p.gpa, text, c, .{ .row = c.row, .col = llen }) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, c); t.cur_pinned = true; return; } @@ -883,7 +900,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { // cursor go and the new line takes the indent if (std.mem.indexOfNone(u8, before, " \t") == null) { const new = modal.insertAt(p.gpa, text, .{ .row = c.row, .col = 0 }, "\n") catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row + 1, .col = 0 }); t.cur_row += 1; t.cur_pinned = true; t.show(); @@ -900,7 +917,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const cut = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = kept }, c) catch return; defer p.gpa.free(cut); const new = modal.insertAt(p.gpa, cut, .{ .row = c.row, .col = kept }, block) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row + 1, .col = indent }); t.cur_row += 1; t.cur_col = @intCast(indent); t.cur_pinned = true; @@ -922,7 +939,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { while (drop > 0 and prev > 0 and line[prev - 1] == ' ') : (drop -= 1) prev -= 1; } const new = modal.deleteSpan(p.gpa, text, .{ .row = c.row, .col = prev }, c) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row, .col = prev }); t.cur_col = @intCast(prev); t.cur_pinned = true; t.show(); @@ -936,7 +953,7 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { if (row == 0) return; const prevlen = modal.lineSlice(e2.text, row - 1).len; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row - 1, .col = prevlen }, .{ .row = row, .col = 0 }) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = row - 1, .col = prevlen }); t.cur_row -= 1; t.cur_col = @intCast(prevlen); t.cur_pinned = true; @@ -948,13 +965,13 @@ fn insertEdit(p: *Pardes, t: *Text, key: Key) void { const line = modal.lineSlice(text, c.row); if (c.col < line.len) { const new = modal.deleteChar(p.gpa, text, c) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, c); } else { const e2 = editText(p, t, t.cur_row, t.cur_row + 1, t.cur_col) orelse return; const row: usize = @intCast(@max(0, t.cur_row - e2.row0)); if (row + 1 >= modal.lineCount(e2.text)) return; const new = modal.deleteSpan(p.gpa, e2.text, .{ .row = row, .col = c.col }, .{ .row = row + 1, .col = 0 }) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = row, .col = c.col }); } t.cur_pinned = true; }, @@ -993,7 +1010,7 @@ pub fn insertTab(p: *Pardes, t: *Text) void { }; const pad = modal.INDENT_W - (c.col % modal.INDENT_W); const new = modal.insertAt(p.gpa, eb.text, c, " "[0..pad]) catch return; - setEditText(p, t, new); + setEditTextEnd(p, t, new, .{ .row = c.row, .col = c.col + pad }); t.cur_col += @intCast(pad); t.cur_pinned = true; t.show(); @@ -2365,3 +2382,26 @@ test "double-click selects as acme's does" { const e = doubleClickRange(lines, 7); try std.testing.expectEqualStrings("two\n", lines[e[0]..e[1]]); } + +test "a typed edit's known end is where the texts' comparison would find it" { + const p = try Pardes.init(std.testing.allocator, .{ .cols = 80, .rows = 20, .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("one\ntwo three\nfour\n"); + const t = &pane.body; + const cases = [_]struct { at: modal.Cursor, s: []const u8 }{ + .{ .at = .{ .row = 1, .col = 3 }, .s = "xy" }, + .{ .at = .{ .row = 0, .col = 0 }, .s = "a\nbc" }, + .{ .at = .{ .row = 2, .col = 4 }, .s = "\n" }, + }; + for (cases) |c| { + const base = try p.gpa.dupe(u8, pane.file.?.content); + defer p.gpa.free(base); + const scanned = try modal.insertAt(p.gpa, base, c.at, c.s); + setEditTextEnd(p, t, scanned, null); + const by_scan = t.last_edit.?; + const known = try modal.insertAt(p.gpa, base, c.at, c.s); + setEditTextEnd(p, t, known, insertedEnd(c.at, c.s)); + try std.testing.expectEqual(by_scan.row, t.last_edit.?.row); + try std.testing.expectEqual(by_scan.col, t.last_edit.?.col); + } +} diff --git a/src/esp32p4_9p.zig b/src/esp32p4_9p.zig index e23235a0..cd59b9df 100644 --- a/src/esp32p4_9p.zig +++ b/src/esp32p4_9p.zig @@ -103,6 +103,7 @@ fn die(msg: []const u8) noreturn { .{ .rerror = .{ .ename = msg[0..@min(msg.len, ninep.errmax)] } }, ninep.notag, &buf, + // unreachable: the message is cut to errmax and buf is sized to it ) catch unreachable; uart.write(bytes); while (true) {} diff --git a/src/exec.zig b/src/exec.zig index 88c0705d..06e5d375 100644 --- a/src/exec.zig +++ b/src/exec.zig @@ -391,7 +391,7 @@ pub fn isDirectory(path: []const u8) bool { const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return false; pardes.turn.yield(); defer pardes.turn.back(); - const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return false; + const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return false; return stat.kind == .directory; } @@ -407,7 +407,7 @@ pub fn kindOf(path: []const u8) ?std.Io.File.Kind { const z = std.fmt.bufPrintSentinel(&buf, "{s}", .{path}, 0) catch return null; pardes.turn.yield(); defer pardes.turn.back(); - const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return null; + const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), z, .{}) catch return null; return stat.kind; } @@ -643,7 +643,12 @@ pub fn execute(p: *Pardes, id: usize, txt: []const u8) ?usize { /// such text may be a bound REPL's. Run by name (`Exec ...`, whose line /// comes back through `execute`) or written as a command word, never. pub fn executeFrom(p: *Pardes, id: usize, txt: []const u8, from_body: bool) ?usize { - const pane = p.panes[id] orelse return null; + // An empty window runs its session words, and nothing else: no pane to + // run a command for. + const pane = p.panes[id] orelse { + _ = executeBuiltinLine(p, id, commandText(txt)); + return null; + }; const gesture = from_body and config.commandWord(std.mem.trim(u8, txt, " \t\r\n")) == null; const cmd = commandText(txt); if (cmd.len == 0) return null; @@ -891,7 +896,9 @@ pub fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]co /// `runBuiltin` saying whether a gesture on the pane's body made it. pub fn runBuiltinFrom(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8, from_body: bool) void { if (!normal.multiOnce(p)) return; // a builtin is per-keystroke, never per-cursor - const pane = p.panes[id] orelse return; + // No pane at all (the window Delcol emptied): a word runs all the same, + // with a stand-in that is no pane (New, Newcol, Exit are what make sense). + const pane = p.panes[id] orelse (if (builtins.registry.scope(b) == .session or p.anyPane() == null) p.standIn() orelse return else return); // A builtin says its own name before it runs, so a tagline click is // legible even when what it did is invisible. Whatever the builtin // then says for itself replaces the announcement. It is the message @@ -1000,6 +1007,9 @@ pub const NewPane = enum { scratch, fs, shell, doc, command, errors }; /// from: null when a column tag, the workspace tag or 9P asked (acme's /// t->w == nil). pub fn placeNew(p: *Pardes, from: usize, origin: ?usize, id: usize, kind: NewPane) void { + // No column left (the last one Delcol'd): a pane made from the workspace + // tag opens one to go in. + _ = layout.firstColumn(p); const asked = if (p.exec_column != null) null else origin; layout.compute(p); const before = p.rects; diff --git a/src/file_watch.zig b/src/file_watch.zig index 8bd9de8b..148bc836 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -397,7 +397,7 @@ fn watchPath( // stat and a lookup out there: the turn goes out with them. pardes.turn.yield(); defer pardes.turn.back(); - const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null; + const stat = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null; const dir = if (stat != null and stat.?.kind == .directory) watched_path else std.fs.path.dirname(watched_path) orelse "."; var dir_buf: [4096:0]u8 = undefined; if (dir.len >= dir_buf.len) return; @@ -576,12 +576,19 @@ pub fn applyThemeEffect( watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 }); if (!on) return false; const request = colors.themeFileRequest(core, generation) orelse return false; + // What fails here is the ThemeFile write's failure too, which waits on + // this (the one-failure rule): late, as a Save's. + const pane = request.pane; const bytes = filesystem.readFile(gpa, request.path) catch |err| { colors.failThemeFile(core, generation, err); + core.noteLateFailure(pane); return false; }; defer gpa.free(bytes); - if (!colors.loadThemeFile(core, generation, bytes, animate)) return false; + if (!colors.loadThemeFile(core, generation, bytes, animate)) { + core.noteLateFailure(pane); + return false; + } const hash = std.hash.Wyhash.hash(0, bytes); watchPath(fd, watches, theme_slot, request.path, generation, .{ .text = hash }); if (watches[theme_slot] == null) return false; @@ -631,7 +638,7 @@ pub fn identify(io: std.Io, path: []const u8) !Identity { const native = filesystem.localPath(path) orelse return error.NonLocalPath; pardes.turn.yield(); defer pardes.turn.back(); - const stat = try std.Io.Dir.cwd().statFile(io, native, .{}); + const stat = try @import("fs.zig").statPath(io, native, .{}); if (stat.kind != .file) return error.NotFile; return .{ .inode = stat.inode, diff --git a/src/fonts.zig b/src/fonts.zig index 4cfba44c..098bf36f 100644 --- a/src/fonts.zig +++ b/src/fonts.zig @@ -324,6 +324,7 @@ fn sfntBase(head: []const u8) ?usize { fn scratchFont(buf: *[64:0]u8, ext: []const u8) [:0]const u8 { return std.fmt.bufPrintSentinel(buf, "/tmp/pardes-fonts-test-{d}{s}", .{ @as(u32, @intCast(libc.getpid())), ext, + // unreachable: 23 bytes, a u32 pid (10) and a short extension fit 64 }, 0) catch unreachable; } @@ -159,7 +159,7 @@ pub fn osHandle(p: *pardes.Pardes, req: Req) Reply { const stat = stat: { pardes.turn.yield(); defer pardes.turn.back(); - break :stat std.Io.Dir.cwd().statFile(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); + break :stat statPath(io, path, .{}) catch return Reply.fail(req.tag, E.NOENT); }; const attr: Reply.Attr = .{ .name = if (req.node == os_root) "os" else std.fs.path.basename(path), .node = req.node, .dir = stat.kind == .directory, .size = stat.size, .mode = if (stat.kind == .directory) 0o755 else 0o644, .mtime = std.math.cast(u32, stat.mtime.toSeconds()) orelse 0 }; switch (req.op) { @@ -622,6 +622,21 @@ pub fn isVirtual(path: []const u8) bool { std.mem.eql(u8, path, "/n") or std.mem.startsWith(u8, path, "/n/"); } +/// std's `statFile`, but a name with a part over 255 bytes is NameTooLong: +/// std's own takes the kernel's ENAMETOOLONG for that for a bug and panics +/// (Io.Threaded dirStatFileLinux), and such a name comes from anyone who +/// writes one to `name`, `look` or DumpDir. +pub fn statPath(io: std.Io, path: []const u8, options: std.Io.Dir.StatFileOptions) !std.Io.File.Stat { + var parts = std.mem.tokenizeScalar(u8, path, '/'); + while (parts.next()) |part| if (part.len > 255) return error.NameTooLong; + return std.Io.Dir.cwd().statFile(io, path, options); +} + +test "a path with a part over 255 bytes is NameTooLong, never a panic" { + const long = "/tmp/" ++ "x" ** 300 ++ "/f"; + try std.testing.expectError(error.NameTooLong, statPath(std.testing.io, long, .{})); +} + pub fn localPath(path: []const u8) ?[]const u8 { if (std.mem.eql(u8, path, "/n/os")) return "/"; if (std.mem.startsWith(u8, path, "/n/os/")) return path[5..]; diff --git a/src/gui/gui.zig b/src/gui/gui.zig index d935b5fd..b34b314d 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -937,6 +937,8 @@ const Queue = struct { completion_len: usize = 0, fn lock(q: *Queue) void { + // unreachable failures: pthread calls on this queue's own initialised, non-recursive + // mutex and condition fail only on misuse (EINVAL, EDEADLK), never on input. std.debug.assert(libc.pthread_mutex_lock(&q.mutex) == .SUCCESS); } @@ -1061,9 +1063,19 @@ const Queue = struct { } fn take(q: *Queue) MessageBatch { + var batch: MessageBatch = .{}; + q.takeInto(&batch); + return batch; + } + + /// `take` into a batch the caller keeps: an idle wakeup copies nothing + /// (the batch is some 20 KB, and was built and returned by value each + /// time). + fn takeInto(q: *Queue, batch: *MessageBatch) void { + batch.len = 0; q.lock(); defer q.unlock(); - var batch: MessageBatch = .{}; + if (q.len == 0 and !q.files_changed and q.completion_len == 0) return; while (q.len > 0) { batch.items[batch.len] = q.items[q.head]; batch.len += 1; @@ -1080,7 +1092,6 @@ const Queue = struct { batch.len += q.completion_len; q.completion_len = 0; std.debug.assert(libc.pthread_cond_broadcast(&q.space) == .SUCCESS); - return batch; } fn discardCompletions(q: *Queue) void { @@ -3999,6 +4010,8 @@ const Shell = struct { ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, queue: *Queue, + /// What drainQueue takes each wakeup, kept rather than rebuilt. + batch: MessageBatch = .{}, pipe_tasks: *PipeTasks, inotify_fd: c_int, watches: *file_watch.Table, @@ -4153,7 +4166,8 @@ const Shell = struct { s.reconcilePtys(); // After this batch's output, which an exit is told behind. defer s.takeExits(); - var msgs = s.queue.take(); + s.queue.takeInto(&s.batch); + const msgs = &s.batch; var check_files = false; for (msgs.slice()) |m| switch (m) { .output => |o| { @@ -8322,6 +8336,7 @@ fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, t const pixels: [*]const u8 = @ptrCast(mapped); try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, "latest"); var name: [24]u8 = undefined; + // unreachable below: "transition-" or "frame-" and a u32 fit 24 if (transition) |frame| try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "transition-{d}", .{frame}) catch unreachable); if (g.capture_series) try writeCapturePpm(g, gpa, pixels[0..size], sw, sh, std.fmt.bufPrint(&name, "frame-{d:0>5}", .{g.captured}) catch unreachable); g.captured +%= 1; diff --git a/src/host_io.zig b/src/host_io.zig index 8bbb0985..9e3fbf79 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -933,7 +933,7 @@ pub const Shell = struct { if (libc.access(@ptrCast(&buf), 0) == 0) return std.fmt.bufPrint(said, "not a shell: {s} is not executable", .{bin}) catch "not a shell: not executable"; } // "not found": the words a mount reads as ENOENT. - return std.fmt.bufPrint(said, "no shell \"{s}\", not found (a name on the usual paths, or a path to one)", .{bin}) catch "no such shell"; + return std.fmt.bufPrint(said, "shell \"{s}\" not found (a name on the usual paths, or a path to one)", .{bin}) catch "no such shell"; } /// The program a script's `#!` line names, when it is not there to @@ -970,7 +970,7 @@ pub const Shell = struct { var said: [320]u8 = undefined; try std.testing.expectEqualStrings("interpreter /no/such/interp not found", refusal(try std.fmt.bufPrint(&path, "{s}/bad", .{dir}), &said).?); try std.testing.expect(refusal(try std.fmt.bufPrint(&path, "{s}/good", .{dir}), &said) == null); - try std.testing.expect(std.mem.startsWith(u8, refusal(try std.fmt.bufPrint(&path, "{s}/none", .{dir}), &said).?, "no shell ")); + try std.testing.expect(std.mem.startsWith(u8, refusal(try std.fmt.bufPrint(&path, "{s}/none", .{dir}), &said).?, "shell \"")); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "plain", .data = "echo\n" }); const plain = try std.fmt.bufPrint(&path, "{s}/plain", .{dir}); try std.testing.expect(std.mem.endsWith(u8, refusal(plain, &said).?, "plain is not executable")); @@ -1048,7 +1048,7 @@ pub const Shell = struct { var buf: [2048]u8 = undefined; try std.testing.expectEqualStrings(bash_rc, readSmall(a_bash, &buf) orelse return error.ReadFailed); try std.testing.expectEqualStrings(fish_rc, readSmall(a.fishPath().?, &buf) orelse return error.ReadFailed); - const stat = try std.Io.Dir.cwd().statFile(std.testing.io, a_bash, .{}); + const stat = try @import("fs.zig").statPath(std.testing.io, a_bash, .{}); try std.testing.expectEqual(0, stat.permissions.toMode() & 0o077); var kept: [rc_path_capacity:0]u8 = @splat(0); @memcpy(kept[0..a_bash.len], a_bash); @@ -1075,6 +1075,45 @@ pub const Shell = struct { } }; +/// In a child between fork and exec: every signal back to its default and +/// none blocked, whatever this process ignores or blocks (the tty's SIGWINCH, +/// a SIGHUP nohup ignored). A handler resets at exec by itself; an ignored +/// signal and the mask do not. Async-signal-safe: sigaction and sigprocmask. +pub fn resetChildSignals() void { + const default: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.DFL }, .mask = posix.sigemptyset(), .flags = 0 }; + var sig: u8 = 1; + while (sig < 65) : (sig += 1) { + if (sig == @intFromEnum(posix.SIG.KILL) or sig == @intFromEnum(posix.SIG.STOP)) continue; + _ = std.c.sigaction(@enumFromInt(sig), &default, null); + } + const none = posix.sigemptyset(); + posix.sigprocmask(posix.SIG.SETMASK, &none, null); +} + +test "a child starts with every signal at its default and none blocked" { + var blocked = posix.sigemptyset(); + posix.sigaddset(&blocked, posix.SIG.WINCH); + posix.sigprocmask(posix.SIG.BLOCK, &blocked, null); + defer posix.sigprocmask(posix.SIG.UNBLOCK, &blocked, null); + const ignore: posix.Sigaction = .{ .handler = .{ .handler = posix.SIG.IGN }, .mask = posix.sigemptyset(), .flags = 0 }; + var was: posix.Sigaction = undefined; + posix.sigaction(posix.SIG.HUP, &ignore, &was); + defer posix.sigaction(posix.SIG.HUP, &was, null); + const pid = libc.fork(); + if (pid == 0) { + resetChildSignals(); + var now: posix.Sigaction = undefined; + posix.sigaction(posix.SIG.HUP, null, &now); + var mask = posix.sigemptyset(); + posix.sigprocmask(posix.SIG.BLOCK, null, &mask); + const ok = now.handler.handler == posix.SIG.DFL and !posix.sigismember(&mask, posix.SIG.WINCH); + libc._exit(if (ok) 0 else 1); + } + var status: c_int = 0; + _ = libc.waitpid(pid, &status, 0); + try std.testing.expectEqual(@as(c_int, 0), status); +} + extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn execve(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8, envp: [*:null]const ?[*:0]const u8) c_int; @@ -1202,7 +1241,7 @@ pub fn forkShell( // A shell's directory may be inside a mount this editor serves. pardes.turn.yield(); defer pardes.turn.back(); - const stat = try std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), path, .{}); + const stat = try @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), path, .{}); if (stat.kind != .directory) return error.NotDir; break :dir path; }; @@ -1278,9 +1317,7 @@ pub fn forkShell( return error.ForkFailed; } if (pid == 0) { - var set = posix.sigemptyset(); - posix.sigaddset(&set, posix.SIG.WINCH); - posix.sigprocmask(posix.SIG.UNBLOCK, &set, null); + resetChildSignals(); if (cwd_z) |path| if (chdir(path.ptr) != 0) { const why = [_]u8{ 'c', @truncate(@intFromEnum(libc.errno(@as(c_int, -1)))) }; _ = libc.write(told[1], &why, why.len); @@ -1440,7 +1477,7 @@ pub fn shellCwd(pid: libc.pid_t, buf: []u8) ?[]const u8 { // no directory to be named by, so the name it had stays, and a // restart works there once it is back. if (std.mem.endsWith(u8, link, " (deleted)")) { - _ = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), link, .{}) catch return null; + _ = @import("fs.zig").statPath(std.Io.Threaded.global_single_threaded.io(), link, .{}) catch return null; } return link; }, diff --git a/src/layout.zig b/src/layout.zig index 16af1ec5..28f567bc 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -635,6 +635,19 @@ fn canSplitCol(p: *Pardes, col: usize) bool { /// An empty column beside column `col`, with half its width: acme's Newcol /// (rowadd, rows.c:51). The new column's index, or null with no room. +/// A window with no column left (Delcol took the last, as acme lets it): +/// its first one, empty; null when it has columns. +pub fn firstColumn(p: *Pardes) ?usize { + if (p.ncol != 0) return null; + p.col_weight[0] = 2; + p.col_tags[0] = .{ .what = .column, .gpa = p.gpa }; + p.col_serial[0] = 0; + p.col_n[0] = 0; + p.ncol = 1; + noteColumn(p, "newcol", columnSerial(p, 0)); + return 0; +} + pub fn insertColumn(p: *Pardes, col: usize, before: bool) ?usize { if (!canSplitCol(p, col)) return null; var old_weight = p.col_weight[col]; diff --git a/src/linux/v9fs.zig b/src/linux/v9fs.zig index 4e18660d..5e031bfe 100644 --- a/src/linux/v9fs.zig +++ b/src/linux/v9fs.zig @@ -118,6 +118,16 @@ fn launch(arena: std.mem.Allocator, args: []const [:0]const u8) !u8 { const pid = fork(); if (pid < 0) return error.ForkFailed; if (pid == 0) { + // host_io's resetChildSignals, here in its own module: every signal + // back to its default and none blocked before the exec. + const default: std.posix.Sigaction = .{ .handler = .{ .handler = std.posix.SIG.DFL }, .mask = std.posix.sigemptyset(), .flags = 0 }; + var sig: u8 = 1; + while (sig < 65) : (sig += 1) { + if (sig == @intFromEnum(std.posix.SIG.KILL) or sig == @intFromEnum(std.posix.SIG.STOP)) continue; + _ = std.c.sigaction(@enumFromInt(sig), &default, null); + } + const none = std.posix.sigemptyset(); + std.posix.sigprocmask(std.posix.SIG.SETMASK, &none, null); _ = execvp("sudo", argv.ptr); _exit(127); } diff --git a/src/look.zig b/src/look.zig index 5cf26240..79cbd50d 100644 --- a/src/look.zig +++ b/src/look.zig @@ -49,6 +49,7 @@ pub fn openLink(url: []const u8) void { if (pid < 0) return; if (pid == 0) { if (fork() == 0) { + @import("host_io.zig").resetChildSignals(); const argv: [3:null]?[*:0]const u8 = .{ opener, url_z.ptr, null }; _ = execv(opener, &argv); } @@ -1414,13 +1415,21 @@ pub fn reopenAt(p: *Pardes, from: usize, path: []const u8, at: Spot) void { } clearNavigationSelection(p, pane); focusPaneLine(p, id, to, .center); + // Said when the file no longer has the line, closed or open: the place + // is its last line now. + if (at.line > n or at.end_line > n) { + var said: [miss_path_cap + 64]u8 = undefined; + var cut: [miss_path_cap + "…".len]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint(&said, "{s} has {d} lines now: at its last", .{ missText(path, &cut), n }) catch "at its last line"); + } } -/// Whether `pane` is the +Recent list, whose rows are remembered places. +/// Whether `pane` is the +Recent or the Jumplist list, whose rows are +/// remembered places: a look at one clamps to the file as it is now. fn isRecentList(pane: *const Pane) bool { const f = pane.file orelse return false; const o = f.output orelse return false; - return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }); + return std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Recent }) or std.meta.eql(o.from, panes.Output.Origin{ .cmd = .Jumplist }); } pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { @@ -1539,8 +1548,12 @@ pub fn lookAt(p: *Pardes, id: usize, operand: []const u8) void { if (comptime pdf_enabled) if (target.kind == .pdf) { if (focusPaneByPath(p, target.path, target.at)) return; const free = p.freeSlot() orelse return p.reportError(id, "look", error.NoPaneSlots); - _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| - return p.reportError(id, "look", err); + // Said with its path and why, as an image that cannot be read is. + _ = panes.Pdf.openPane(p, free, target.path, target.at.line) catch |err| { + var said: [miss_path_cap + 64]u8 = undefined; + var cut: [miss_path_cap + "…".len]u8 = undefined; + return p.reportError(id, std.fmt.bufPrint(&said, "look: {s}: not a PDF pardes can read", .{missText(target.path, &cut)}) catch "look: not a PDF pardes can read", err); + }; exec.placeNew(p, id, id, free, .doc); return; }; diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index 55df1f91..7c1980f6 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -429,6 +429,8 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out record(si, req, us, rows, err_name); if (req.kind == .explain) return traceOut(&tr, req, out, rows, us); + // No server to start for the language: said, not "did not answer". + if (failure) |err| if (err == error.NoServer) return lsp.none(out, "language server for {s} not found", .{std.fs.path.extension(req.path)}); if (failure) |err| return err; try out.writeAll(scratch.buffered()); } @@ -1148,6 +1150,7 @@ fn ensure(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, tr: *Trac // signal-safe calls, no allocation, no locks. (Same rule as tty.zig's // forkShell.) _ = setsid(); // Ctrl-C in pardes's terminal is not the server's business + @import("../host_io.zig").resetChildSignals(); _ = libc.dup2(sv[1], 0); _ = libc.dup2(sv[1], 1); const devnull = libc.open("/dev/null", .{ .ACCMODE = .WRONLY }); @@ -2230,3 +2233,16 @@ test "LSP source snippets preserve indentation with UTF16 byte ranges and prose } } } + +test "a language with no server to start says so, not that it did not answer" { + const si = specFor("/x/a.py") orelse return; + const was = conns[si].state; + conns[si].state = .disabled; + defer conns[si].state = was; + var arena: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena.deinit(); + var buf: [256]u8 = undefined; + var out: std.Io.Writer = .fixed(&buf); + try query(std.testing.allocator, arena.allocator(), .{ .kind = .hover, .path = "/x/a.py", .source = "", .offset = 0 }, &out); + try std.testing.expectEqualStrings("@none language server for .py not found\n", out.buffered()); +} diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig index 09493c26..2311ec47 100644 --- a/src/lsp/lsp_zls.zig +++ b/src/lsp/lsp_zls.zig @@ -354,10 +354,14 @@ fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { // The dependency module map, which is the answer to "why does gd work on // std and not on @import(\"vaxis\")" — if a name is not on this list, the // build did not import it and nothing can resolve it. - try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len}); - for (cfg.module_names, cfg.module_roots) |n, r| { - try out.print(" {s:<14} {s}\n", .{ n, r }); - } + // pardes's own build's imports: listed for a file in its tree, none for + // any other project's file, which does not have them. + if (inProject(req.path)) { + try out.print("\ndependency imports gd can follow ({d}):\n", .{cfg.module_names.len}); + for (cfg.module_names, cfg.module_roots) |n, r| { + try out.print(" {s:<14} {s}\n", .{ n, r }); + } + } else try out.print("\ndependency imports gd can follow: none (this file is not in the tree pardes was built from)\n", .{}); try out.print("asked from: {s}\n", .{if (req.path.len == 0) "a pane with no file" else req.path}); try out.print("\nanswers:", .{}); @@ -426,6 +430,20 @@ fn status(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { /// running ITS build.zig. /// 3. A module whose root source is a GENERATED file is absent from the /// table: it has no path until make() runs. +/// Whether `path` is in the tree pardes was built from, whose build's +/// imports `cfg.module_names` are. +fn inProject(path: []const u8) bool { + const project = std.mem.trimEnd(u8, cfg.project_root, "/"); + return project.len > 0 and path.len > project.len and std.mem.startsWith(u8, path, project) and path[project.len] == '/'; +} + +test "only a file in pardes's own tree is told pardes's dependency imports" { + var buf: [4096]u8 = undefined; + const inside = try std.fmt.bufPrint(&buf, "{s}/src/main.zig", .{std.mem.trimEnd(u8, cfg.project_root, "/")}); + try std.testing.expect(inProject(inside)); + try std.testing.expect(!inProject("/tmp/elsewhere/main.zig")); +} + fn moduleRoot(name: []const u8) ?[]const u8 { comptime std.debug.assert(cfg.module_names.len == cfg.module_roots.len); for (cfg.module_names, cfg.module_roots) |n, r| { 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..]); diff --git a/src/pardes.zig b/src/pardes.zig index 4fb11362..dc479909 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -1826,6 +1826,16 @@ test "a restored terminal's tag names neither the REPL it was nor the shell it h try std.testing.expect(std.mem.indexOf(u8, shown, "Tty+fish") == null); } +test "a Tty started by a shell's path keeps the path in its Tty+ word" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + try std.testing.expect(p.executeBuiltinLine(0, "Tty /bin/sh")); + try std.testing.expect(p.active != 0); + p.acknowledgeShell(p.active, "/bin/sh", false); + try std.testing.expect(std.mem.startsWith(u8, tagline.curTail(p.panes[p.active].?), " Tty+/bin/sh ")); +} + test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); defer p.deinit(); @@ -1843,7 +1853,7 @@ test "Tty+fish, one word a tag can hold, opens a terminal on that shell" { const caller = p.panes[0].?; try std.testing.expect(p.executeBuiltinLine(0, "Tty /nonexistent")); try std.testing.expect(p.active == before and caller.shell == null); - try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "no shell \"/nonexistent\"") != null); + try std.testing.expect(std.mem.indexOf(u8, caller.msg[0..caller.msg_len], "shell \"/nonexistent\" not found") != null); try std.testing.expect(p.executeBuiltinLine(0, "Tty fsh-not-a-shell")); try std.testing.expect(caller.shell == null); // A directory passes access(X_OK); it is no shell either. @@ -4553,6 +4563,10 @@ pub const Pardes = struct { /// where the shell wrote the last dump (shell reports back after /// write_dump); shown in the topbar as `Restore <path>` last_dump: ?[]const u8 = null, + /// What a session word (Newcol, New, Exit) runs with when no pane is + /// there to run it: the window Delcol emptied. Never installed, made the + /// first time it is wanted. + stand_in: ?*Pane = null, /// a Restore builtin wants this dump loaded into the live session; the /// shell consumes it via takeRestore each frame (restore contents stay host-fed) restore_req: ?[]const u8 = null, @@ -4778,6 +4792,7 @@ pub const Pardes = struct { for (p.panes) |slot| if (slot) |pane| p.noteClosed(pane); p.saveRecent(); p.recent.deinit(p.recentGpa()); + if (p.stand_in) |pane| p.teardownPane(pane); look.cancelLookHover(p); if (p.repl_ask) |ask| p.gpa.free(ask.text); for (0..MAX_PANES) |id| p.dropPendingWrite(id); @@ -5022,7 +5037,8 @@ pub const Pardes = struct { } fn newScratch(p: *Pardes, from_id: usize, free: usize, kind: exec.NewPane) !*Pane { - const src = p.panes[from_id] orelse return error.MissingPane; + // None asking (an empty window): the session's, as pane/new's is. + const src_or = p.panes[from_id]; const content = try p.gpa.dupe(u8, ""); errdefer p.gpa.free(content); // A terminal that has not said where it is yet (the boot layout's) @@ -5030,10 +5046,10 @@ pub const Pardes = struct { // 9P's pane/new, and a New from a column's or the workspace's tag: // no pane asked for it, whichever last had the keyboard (acme's new // window has acme's directory). - const own = kind != .fs and !p.exec_header; - const dir = if (own) paneDir(src) else ""; + const src = if (kind != .fs and !p.exec_header) src_or else null; + const dir = if (src) |s| paneDir(s) else ""; const np = try panes.Output.open(p, free, if (dir.len == 0) p.sessionDir() else dir, .{ .cmd = .New }, "", content); - if (own) np.cwd = .{ .inherited = src }; + if (src) |s| np.cwd = .{ .inherited = s }; np.body.cur_pinned = true; return np; } @@ -5054,6 +5070,8 @@ pub const Pardes = struct { /// Newcol: an empty column right of the calling pane's, the keyboard /// on its tag, for the next pane made to fill. pub fn newColumn(p: *Pardes, from_id: usize) void { + // An empty window (Delcol took the last column): its first column. + if (layout.firstColumn(p)) |c| return tagline.enterHeader(p, c); const col = p.keyboardColumn(from_id) orelse return; // Said, not silently nothing: "no space" is 9ns's word for ENOSPC. const c = layout.insertColumn(p, col, false) orelse { @@ -5187,7 +5205,9 @@ pub const Pardes = struct { // The tag's Tty word names the shell the host ran, and a click // on it (`Tty+fish`) opens another of the same. if (pane.command == null) { - const base = std.fs.path.basename(executable); + // A shell given as a path keeps it: its bare name would find + // nothing, or another program, on the usual paths. + const base = if (pane.shell) |given| (if (std.mem.indexOfScalar(u8, given, '/') != null) given else std.fs.path.basename(executable)) else std.fs.path.basename(executable); const rest = tagline.terminal_pane_tail[" Tty".len..]; if (std.fmt.bufPrint(&pane.shell_tail, " Tty+{s}{s}", .{ base, rest })) |tail| { pane.shell_tail_len = @intCast(tail.len); @@ -5327,6 +5347,12 @@ pub const Pardes = struct { } } + /// The pane a session word runs with in an empty window. + pub fn standIn(p: *Pardes) ?*Pane { + if (p.stand_in == null) p.stand_in = panes.Terminal.createDoc(p.gpa, p.screen_w, p.screen_h) catch null; + return p.stand_in; + } + /// Drop a pane's parked bytes: its pty is gone, and the slot it occupied /// may be handed to a different pane next frame. fn dropPendingWrite(p: *Pardes, id: usize) void { @@ -5566,6 +5592,8 @@ pub const Pardes = struct { if (saved.saved_revision != saved.revision) return; saved.disk_gone = false; ctlfs.events.noteLog(p, .save, saved_pane); + // On disk now, a new file too: Recent has it, open. + if (saved.output == null and recentKeeps(saved.path)) p.recent.opened(p.recentGpa(), saved.path); if (!saved.watch_after_save) return; saved.watch_after_save = false; if (filesystem.localPath(saved.path) != null) @@ -6606,10 +6634,10 @@ pub const Pardes = struct { const none: ?[]const u8 = switch (w.kind) { .completion, .format, .rename => null, .diagnostics, .workspace_diagnostics => return p.setMessage(w.pane, std.fmt.bufPrint(&said, "{s}: none", .{lspWord(w.kind)}) catch "none"), - .incoming_calls => "no callers, not found", - .outgoing_calls => "no callees, not found", - .hover => "nothing to show, not found", - else => "nothing found, not found", + .incoming_calls => "callers not found", + .outgoing_calls => "callees not found", + .hover => "nothing to show: not found", + else => "not found", }; if (none) |text| p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: {s}", .{ lspWord(w.kind), text }) catch "nothing found"); return; @@ -6750,7 +6778,9 @@ pub const Pardes = struct { p.panes[closed] = null; } layout.dropColumn(p, col); - if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return p.quitEmpty(); + // The last column gone, the window stays, empty but for the + // workspace tag, as acme's does; Newcol starts again from there. + if (p.panes[p.active] == null) p.active = beside orelse p.anyPane() orelse return tagline.enterHeader(p, null); } /// No pane is left: the session is over. @@ -6792,7 +6822,7 @@ pub const Pardes = struct { } /// Any live pane: the last resort when nothing took the closed one's place. - fn anyPane(p: *const Pardes) ?usize { + pub fn anyPane(p: *const Pardes) ?usize { for (p.panes, 0..) |slot, k| if (slot != null) return k; return null; } @@ -6863,7 +6893,9 @@ pub const Pardes = struct { pub fn jumpBy(p: *Pardes, delta: i32) void { const next = @as(i64, @intCast(p.jcur)) + delta; - if (p.njumps == 0 or next < 0 or next >= p.njumps) return; + // At an end of the list: said, not a step that silently does nothing. + if (p.njumps == 0 or next < 0 or next >= p.njumps) + return p.setMessage(p.active, if (delta < 0) "Back: nothing further back" else "Forward: nothing further on"); const j = p.jumps[@intCast(next)]; if (j.pane >= MAX_PANES) return; // A closed file's entry: opened again at its dot, placed as a look @@ -8398,6 +8430,7 @@ test "jump history clears selections and lands at the recorded cursor" { const selected = pane.body.primaryRange(pane.file.?.content, 0); p.jumpBy(1); // No forward entry: selection stays untouched. try std.testing.expectEqualDeep(selected, pane.body.primaryRange(pane.file.?.content, 0)); + try std.testing.expect(std.mem.indexOf(u8, p.panes[p.active].?.msg[0..p.panes[p.active].?.msg_len], "nothing further on") != null); for ([_]Loc{ .{ .pane = 15, .serial = pane.serial, .line = 1, .col = 1 }, .{ .pane = 0, .serial = pane.serial + 1, .line = 1, .col = 1 }, @@ -8508,3 +8541,31 @@ test "a Save the host could not do logs no save record" { p.perform(.{ .save_file = .{ .pane = 0 } }); try std.testing.expect(th.logHas(p, "\nsave ")); } + +test "Delcol on the last column leaves the window, drawn and typed into, and Newcol starts it again" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); + defer p.deinit(); + while (p.nextEffect()) |_| {} + while (p.ncol > 0) { + p.exec_column = 0; + _ = p.executeBuiltinLine(p.active, "Delcol"); + p.exec_column = null; + p.sync(); + } + try std.testing.expect(!p.quit); + try std.testing.expect(p.anyPane() == null); + // Drawn, and keys go to the workspace tag. + _ = try p.render(p.frame_arena.allocator()); + p.update(.{ .key = .{ .cp = 'x', .text = "x" } }); + p.update(.{ .key = .{ .cp = Key.enter } }); + p.sync(); + _ = try p.render(p.frame_arena.allocator()); + try std.testing.expect(!p.quit); + _ = p.executeBuiltinLine(p.active, "Newcol"); + p.sync(); + try std.testing.expectEqual(@as(usize, 1), p.ncol); + _ = p.executeBuiltinLine(p.active, "New"); + p.sync(); + try std.testing.expect(p.anyPane() != null); + _ = try p.render(p.frame_arena.allocator()); +} diff --git a/src/pdf_view.zig b/src/pdf_view.zig index 58dd2c3c..8f842cde 100644 --- a/src/pdf_view.zig +++ b/src/pdf_view.zig @@ -2763,6 +2763,7 @@ pub const SectionRows = if (enabled) struct { for (entries, 0..) |entry, ordinal| { const resolved = ordinals[ordinal]; if (resolved == std.math.maxInt(usize)) continue; + // unreachable: resolveOrdinals resolves only to usable destinations const destination = usableDestination(entries[resolved].destination) orelse unreachable; total += switch (destination) { .internal => |internal| std.fmt.count("{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), @@ -2777,6 +2778,7 @@ pub const SectionRows = if (enabled) struct { for (entries, 0..) |entry, ordinal| { const resolved = ordinals[ordinal]; if (resolved == std.math.maxInt(usize)) continue; + // unreachable: resolveOrdinals resolves only to usable destinations const destination = usableDestination(entries[resolved].destination) orelse unreachable; const prefix = switch (destination) { .internal => |internal| try std.fmt.bufPrint(out[at..], "{s}:{d}:{d} ", .{ target, internal.page + 1, ordinal + 1 }), diff --git a/src/regexp.zig b/src/regexp.zig index 27b3c0ba..ca396ff1 100644 --- a/src/regexp.zig +++ b/src/regexp.zig @@ -48,6 +48,11 @@ pub const Regex = struct { /// ends of the whole text and so never match where sam would. pub fn compile(pat: []const u8) error{ Bad, Anchor }!Regex { if (pat.len == 0) return error.Bad; + // mvzr takes `^` only at its pattern's start, so `^def|^ ` (a `^` + // after a `|`) is written `^(def| )` for it: the same lines. A mix, + // `^a|b`, has no such spelling and is refused rather than wrong. + var joined: [258]u8 = undefined; + if (try anchoredAlternation(pat, &joined)) |whole| return compile(whole); var buf: [256]u8 = undefined; var len: usize = 0; var spans = false; @@ -97,6 +102,56 @@ pub const Regex = struct { }; } + /// `^a|^b` as `^(a|b)` in `out`, when the pattern is an alternation at + /// its top level and every branch starts with `^`; null when it is not + /// one, or no branch does. + fn anchoredAlternation(pat: []const u8, out: *[258]u8) error{Bad}!?[]const u8 { + var bars: [16]usize = undefined; + var n: usize = 0; + var depth: usize = 0; + var in_class = false; + var i: usize = 0; + while (i < pat.len) : (i += 1) { + const c = pat[i]; + if (c == '\\') { + i += 1; + } else if (in_class) { + in_class = c != ']'; + } else if (c == '[') { + in_class = true; + } else if (c == '(') { + depth += 1; + } else if (c == ')') { + depth -|= 1; + } else if (c == '|' and depth == 0) { + if (n == bars.len) return error.Bad; + bars[n] = i; + n += 1; + } + } + if (n == 0) return null; + var anchored: usize = 0; + var from: usize = 0; + for (0..n + 1) |k| { + const to = if (k < n) bars[k] else pat.len; + anchored += @intFromBool(to > from and pat[from] == '^'); + from = to + 1; + } + if (anchored == 0) return null; + if (anchored != n + 1) return error.Bad; + var w = std.Io.Writer.fixed(out); + w.writeAll("^(") catch return error.Bad; + from = 0; + for (0..n + 1) |k| { + const to = if (k < n) bars[k] else pat.len; + if (k > 0) w.writeByte('|') catch return error.Bad; + w.writeAll(pat[from + 1 .. to]) catch return error.Bad; + from = to + 1; + } + w.writeByte(')') catch return error.Bad; + return w.buffered(); + } + pub const Match = struct { start: usize, end: usize }; /// The first match that starts in `from..=last` and ends by `hi`, as @@ -185,6 +240,16 @@ test "lines are haystacks: ^ and $ at each line, . never a newline, \\n spans li try std.testing.expectError(error.Bad, Regex.compile("a\\")); } +test "a ^ after | anchors that branch: ^def|^ finds a line that starts either way, and a mix is refused" { + const text = "x def\n a\ndef b\n"; + var both = try Regex.compile("^def|^ "); + try std.testing.expectEqual(@as(usize, 6), (try both.find(text, 0, text.len, text.len)).?.start); + try std.testing.expectEqual(@as(usize, 9), (try both.find(text, 7, text.len, text.len)).?.start); + try std.testing.expectError(error.Bad, Regex.compile("^def|x")); + var plain = try Regex.compile("a|b"); + try std.testing.expectEqual(@as(usize, 7), (try plain.find(text, 0, text.len, text.len)).?.start); +} + test "a quoted backslash before n is no newline, and a class ends where mvzr ends it" { // `\\n` is a backslash then an n: the pattern stays on one line, so its // `.` is not made [^\n] and does match within the line. diff --git a/src/selection_pipe.zig b/src/selection_pipe.zig index e1a42962..44cc1709 100644 --- a/src/selection_pipe.zig +++ b/src/selection_pipe.zig @@ -202,6 +202,14 @@ pub fn runOne( if (std.mem.indexOfScalar(u8, command, 0) != null or std.mem.indexOfScalar(u8, cwd, 0) != null) return fail.k(.spawn); + // std's spawn runs no code in the child: this thread's mask, which the + // child inherits, is cleared across the fork (only the tty's SIGWINCH is + // ever blocked, and its default is to be ignored). A handler resets at + // exec by itself, and nothing here is ignored. + const none = std.posix.sigemptyset(); + var kept: std.posix.sigset_t = undefined; + std.posix.sigprocmask(std.posix.SIG.SETMASK, &none, &kept); + defer std.posix.sigprocmask(std.posix.SIG.SETMASK, &kept, null); var child = std.process.spawn(io, .{ .argv = &.{ "/bin/sh", "-c", command }, .cwd = if (cwd.len == 0) .inherit else .{ .path = cwd }, diff --git a/src/tagline.zig b/src/tagline.zig index 5f95b2a0..df093925 100644 --- a/src/tagline.zig +++ b/src/tagline.zig @@ -30,7 +30,7 @@ const dirty_marker = " *"; /// A file with changes it has not saved. pub fn dirty(pane: *const panes.Pane) bool { const f = pane.file orelse return false; - return panes.Output.fileTraits(f.output).saves and f.revision != f.saved_revision; + return panes.Output.fileTraits(f.output).saves and (f.revision != f.saved_revision or f.disk_gone); } pub const legacy_pane_tail = " Del"; pub const legacy_file_pane_tail = " Save Del"; |
