summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 12:19:39 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit4f57043e39396bc958a103f87466870cf8dafe00 (patch)
tree89ec869d2e3b3eb231f14f9298bb56afa9e0f6ae
parentab220b89afcfda848cf84c18cc135a2e5c392b79 (diff)
downloadpardes-4f57043e39396bc958a103f87466870cf8dafe00.tar.gz
pardes-4f57043e39396bc958a103f87466870cf8dafe00.zip
A language server question that finds nothing, cannot be answered or has no room fails, and so does a write to an image's or PDF's body
Callers or Callees with nothing found, Hover or Lspwhy with no room for its pane, and a question asked of no file's pane said nothing, the 9P write succeeding; a body write to an image or PDF pane went nowhere. A write that asks a language server now waits for its answer (turn.lsp_answers), and what fails there -- nothing found, no answer, a backend's @none why, a placement with no room (ENOSPC) -- fails the write and logs its err. No diagnostics is said, not failed. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--src/9p_io.zig8
-rw-r--r--src/fs.zig3
-rw-r--r--src/ninep/ctl.zig36
-rw-r--r--src/ninep/pane.zig2
-rw-r--r--src/pardes.zig91
-rw-r--r--test/snapshots/lsp.golden1
6 files changed, 132 insertions, 9 deletions
diff --git a/src/9p_io.zig b/src/9p_io.zig
index 72897979..96f91bc1 100644
--- a/src/9p_io.zig
+++ b/src/9p_io.zig
@@ -312,13 +312,19 @@ pub const Listener = struct {
defer if (restoring) l.restore_writer.store(false, .release);
core.fs.late_failure_len = 0;
if (core.effects_len != 0) pardes.turn.awaitSettled(epoch);
+ if (core.fs.lsp_answer_at) |n| {
+ core.fs.lsp_answer_at = null;
+ pardes.turn.awaitLsp(n);
+ }
// ponytail: one slot, so a failure of another client's effects that
// settle in the same wait is told to this write too.
if (l.core == core and core.fs.late_failure_len != 0) {
const late = core.fs.late_failure[0..core.fs.late_failure_len];
// What is not there (`no such directory`) is ENOENT, as a
// builtin's failure saying so is (ctl.failureErrno).
- const errno = if (std.mem.indexOf(u8, late, "no such") != null or std.mem.indexOf(u8, late, "not found") != null) pardes.ctlfs.E.NOENT else pardes.ctlfs.E.IO;
+ const errno = if (std.mem.indexOf(u8, late, "no such") != null or std.mem.indexOf(u8, late, "not found") != null)
+ pardes.ctlfs.E.NOENT
+ else if (std.mem.indexOf(u8, late, "no space") != null) pardes.ctlfs.E.NOSPC else pardes.ctlfs.E.IO;
const failed = pardes.ctlfs.failText(req.tag, errno, late);
// Its err record says it (the path in it); the msg the failure
// was also said as goes, as a builtin's failing a write does.
diff --git a/src/fs.zig b/src/fs.zig
index 5d7d68e8..5fd1fd4e 100644
--- a/src/fs.zig
+++ b/src/fs.zig
@@ -1308,6 +1308,9 @@ pub const Namespace = struct {
header_held: bool = false,
late_failure: [256]u8 = undefined,
late_failure_len: u16 = 0,
+ /// A 9P write asked a language server something: the answer count it
+ /// waits to change (`turn.lsp_answers`), so its failure is the write's.
+ lsp_answer_at: ?u64 = null,
/// The serial of the pane the root's exec or look last ran a line at
/// (the keyboard's), 0 before one: its err record names that pane.
ran_at: u32 = 0,
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index c2a78fb8..2c6d2010 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -2079,6 +2079,42 @@ test "a script whose interpreter is not there: Tty refuses it up front, only an
try testing.expect(!p.panes[id].?.shell_failed);
}
+test "a language server question that finds nothing, cannot be answered, or is asked of no file fails, and a write to an image's body is refused" {
+ const p = try withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ const id = p.active;
+ // Asked of a pane that is no file's: at once.
+ _ = wr(p, root_exec, "Recent\n");
+ const listed = p.paneBySerial(try std.fmt.parseInt(u32, std.mem.trimEnd(u8, rd(p, root_exec, 0, 64).bytes, "\n"), 10)).?;
+ p.lspRequest(listed, .incoming_calls, "");
+ try testing.expect(std.mem.indexOf(u8, p.panes[listed].?.msg[0..p.panes[listed].?.msg_len], "Callers: only a file's pane asks") != null);
+ // Nothing found: Callers says so, and the waiting write hears it.
+ p.fs.late_failure_len = 0;
+ 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]);
+ // A backend that cannot answer says why.
+ p.fs.late_failure_len = 0;
+ p.lspRequest(id, .outgoing_calls, "");
+ p.lspResponse(p.lsp_wait.?.id, "@none zls does not answer call hierarchy\n");
+ try testing.expectEqualStrings("Callees: zls does not answer call hierarchy", p.fs.late_failure[0..p.fs.late_failure_len]);
+ // No answer at all.
+ p.fs.late_failure_len = 0;
+ p.lspRequest(id, .hover, "");
+ p.lspResponse(p.lsp_wait.?.id, null);
+ try testing.expectEqualStrings("Hover: the language server did not answer", p.fs.late_failure[0..p.fs.late_failure_len]);
+ // No diagnostics is no failure.
+ p.fs.late_failure_len = 0;
+ p.lspRequest(id, .diagnostics, "");
+ p.lspResponse(p.lsp_wait.?.id, "");
+ try testing.expectEqual(@as(u16, 0), p.fs.late_failure_len);
+ // An image's body takes no write.
+ const slot = p.freeSlot().?;
+ const img = try pardes.panes.Image.create(p, slot, "/tmp/pardes-no-such.png", try p.image_gpa.dupe(u8, "not a png"));
+ try testing.expectEqual(E.INVAL, wr(p, Node.of(img.serial, .body), "text\n").errno());
+}
+
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();
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index 5ed3f6f4..fe3312b4 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -449,6 +449,8 @@ pub fn write(p: *Pardes, req: Req, id: usize, pane: *Pane, file: PaneFile) Reply
fn writeBody(p: *Pardes, req: Req, id: usize, pane: *Pane) Reply {
if (req.data.len == 0) return .{ .tag = req.tag, .written = 0 };
if (pane.file == null) {
+ // An image's or a PDF's body is no text a write goes into.
+ if (!pane.isTerminal()) return tree.failText(req.tag, E.INVAL, e_no_text);
const take = wholeUtf8(req.data);
p.emitWrite(id, req.data[0..take]);
return .{ .tag = req.tag, .written = @intCast(take) };
diff --git a/src/pardes.zig b/src/pardes.zig
index ba9a1f90..135823e4 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -117,7 +117,10 @@ pub const Turn = struct {
/// (`takeRestore`), which lives after `pump` because it swaps the core
/// out from under it; a request that asked for one waits for this.
restores: u64 = 0,
- /// Both of the above, and `out` reaching zero.
+ /// Bumped each time a language server's answer (or its lack) is taken
+ /// in (`lspResponse`): a write that asked one waits for this.
+ lsp_answers: u64 = 0,
+ /// All of the above, and `out` reaching zero.
settled: std.Io.Condition = .init,
/// The editor's thread takes the turn for good; it has it until `stop`.
@@ -204,6 +207,16 @@ pub const Turn = struct {
t.settled.broadcast(io);
}
+ pub fn lspAnswered(t: *Turn) void {
+ const io = t.io orelse return;
+ t.lsp_answers +%= 1;
+ t.settled.broadcast(io);
+ }
+
+ pub fn awaitLsp(t: *Turn, answers: u64) void {
+ while (t.lsp_answers == answers) t.settled.wait(t.io.?, &t.mutex) catch return;
+ }
+
pub fn awaitRestored(t: *Turn, restores: u64) void {
while (t.restores == restores) t.settled.wait(t.io.?, &t.mutex) catch return;
}
@@ -5634,6 +5647,7 @@ pub const Pardes = struct {
p.raw_hover_intent = false;
look.cancelLookHover(p);
p.fs.serving = true;
+ p.fs.lsp_answer_at = null;
const reply = ctlfs.handle(p, req);
p.fs.serving = false;
if (reply.status == .err and (req.op == .write or req.op == .setattr or (req.op == .open and reply.errno == ctlfs.E.NOSPC) or (req.op == .release and req.remove))) ctlfs.events.noteError(p, req, reply);
@@ -6310,23 +6324,30 @@ pub const Pardes = struct {
pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void {
if (!normal.multiOnce(p)) return; // one question per keystroke, from the primary
- if (!lsp.supports.contains(kind)) return;
+ var said: [128]u8 = undefined;
+ if (!lsp.supports.contains(kind))
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: no language server here answers it", .{lspWord(kind)}) catch "no language server answers it");
const pane = p.panes[id] orelse return;
if (kind != .status) {
- const f = pane.file orelse return;
- if (f.output != null) return;
+ const f = pane.file orelse
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: only a file's pane asks a language server", .{lspWord(kind)}) catch "only a file's pane asks");
+ if (f.output != null)
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: only a file's pane asks a language server", .{lspWord(kind)}) catch "only a file's pane asks");
}
- if (arg.len > 128) return; // the effect's arg is a Buf(128)
+ if (arg.len > 128) return p.reportFailure(id, std.fmt.bufPrint(&said, "{s}: its argument is at most 128 bytes", .{lspWord(kind)}) catch "argument too long");
if (kind == .rename) {
const zig_buf = if (pane.file) |f| std.mem.endsWith(u8, f.path, ".zig") else true;
- if (zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return;
- if (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null) return;
+ if ((zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) or
+ (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null))
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "Rename: {s} is no name", .{arg}) catch "Rename: no name");
}
const off = if (pane.file) |f| modal.offsetAt(f.content, .{
.row = @intCast(@max(0, pane.body.cur_row)),
.col = @intCast(@max(0, pane.body.cur_col)),
}) else 0;
p.lsp_seq +%= 1;
+ // A 9P write that asked it is answered once the answer is in.
+ if (p.fs.serving) p.fs.lsp_answer_at = turn.lsp_answers;
p.lsp_wait = .{
.id = p.lsp_seq,
.kind = kind,
@@ -6466,13 +6487,56 @@ pub const Pardes = struct {
return true;
}
+ /// The builtin word that asks `kind`, for what it says.
+ fn lspWord(kind: lsp.Kind) []const u8 {
+ return switch (kind) {
+ .hover => "Hover",
+ .explain => "Lspwhy",
+ .incoming_calls => "Callers",
+ .outgoing_calls => "Callees",
+ .document_symbols => "Symbols",
+ .workspace_symbols => "WsSymbols",
+ .diagnostics => "Diagnostics",
+ .workspace_diagnostics => "WsDiagnostics",
+ .code_action => "CodeAction",
+ .select_refs => "SelectRefs",
+ .supertypes => "Supertypes",
+ .subtypes => "Subtypes",
+ .rename => "Rename",
+ .format => "Format",
+ else => @tagName(kind),
+ };
+ }
+
+ /// What was said of a failure just now on pane `id` is also the waiting
+ /// 9P write's (9p_io, `fs.late_failure`).
+ pub fn noteLateFailure(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const said = pane.msg[0..pane.msg_len];
+ const kept = @import("Messages.zig").clip(said, p.fs.late_failure.len);
+ @memcpy(p.fs.late_failure[0..kept.len], kept);
+ p.fs.late_failure_len = @intCast(kept.len);
+ }
+
pub fn lspResponse(p: *Pardes, id: u32, response: ?[]const u8) void {
+ defer turn.lspAnswered();
const w = p.lsp_wait orelse return;
if (w.id != id) return; // superseded by a newer press, or the pane died
p.lsp_wait = null;
- const rows = response orelse return;
const pane = p.panes[w.pane] orelse return;
if (pane.serial != w.serial) return;
+ // Whatever fails from here -- no answer, nothing found, no room for
+ // its pane -- fails a 9P write that asked it too.
+ const failures = p.fs.failures;
+ defer if (p.fs.failures != failures) p.noteLateFailure(w.pane);
+ var said: [256]u8 = undefined;
+ const rows = response orelse
+ return p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: the language server did not answer", .{lspWord(w.kind)}) catch "no answer");
+ // A backend that cannot answer it says why (`@none <why>`).
+ if (std.mem.startsWith(u8, rows, "@none ")) {
+ const why = std.mem.trimEnd(u8, rows["@none ".len..], "\n");
+ return p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: {s}", .{ lspWord(w.kind), why[0..@min(why.len, 200)] }) catch "no answer");
+ }
if (w.kind == .rename or w.kind == .format) {
const fallback: ?[]const u8 = if (w.kind == .rename) w.arg.slice() else null;
if (p.parseLspEdits(rows, fallback)) |edits| {
@@ -6492,6 +6556,17 @@ pub const Pardes = struct {
if (rows.len == 0) {
if (w.kind == .completion and pane.body.mode == .insert and pane.body.nsel == 0 and
pane.body.cur_row == w.row and pane.body.cur_col == w.col) edit.insertTab(p, &pane.body);
+ // Nothing found fails, but for a file with no diagnostics,
+ // which is no failure, and a completion's Tab.
+ 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",
+ };
+ if (none) |text| p.reportFailure(w.pane, std.fmt.bufPrint(&said, "{s}: {s}", .{ lspWord(w.kind), text }) catch "nothing found");
return;
}
diff --git a/test/snapshots/lsp.golden b/test/snapshots/lsp.golden
index 984a7abf..2d3880f1 100644
--- a/test/snapshots/lsp.golden
+++ b/test/snapshots/lsp.golden
@@ -33,3 +33,4 @@
== snap step grid=120x41 cursor=15,3
|3: 1 const std = @import("std");
== snap nothing grid=120x41 cursor=7,5
+|3: 1 const std = @import("std"); definition: nothing found, not found