summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/9p_io.zig3
-rw-r--r--src/Mini.zig6
-rw-r--r--src/builtins.zig37
-rw-r--r--src/detached/server.zig1
-rw-r--r--src/dump.zig39
-rw-r--r--src/edit.zig72
-rw-r--r--src/esp32p4_9p.zig1
-rw-r--r--src/exec.zig18
-rw-r--r--src/file_watch.zig13
-rw-r--r--src/fonts.zig1
-rw-r--r--src/fs.zig17
-rw-r--r--src/gui/gui.zig21
-rw-r--r--src/host_io.zig53
-rw-r--r--src/layout.zig13
-rw-r--r--src/linux/v9fs.zig10
-rw-r--r--src/look.zig21
-rw-r--r--src/lsp/lsp_client.zig16
-rw-r--r--src/lsp/lsp_zls.zig26
-rw-r--r--src/ninep/ctl.zig83
-rw-r--r--src/ninep/pane.zig29
-rw-r--r--src/ninep/pty.zig21
-rw-r--r--src/ninep/tree.zig15
-rw-r--r--src/pardes.zig87
-rw-r--r--src/pdf_view.zig2
-rw-r--r--src/regexp.zig65
-rw-r--r--src/selection_pipe.zig8
-rw-r--r--src/tagline.zig2
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;
}
diff --git a/src/fs.zig b/src/fs.zig
index 5fd1fd4e..753a4446 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -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";