diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-06 18:11:36 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-07 13:59:12 -0300 |
| commit | 60367d8fe23f6af98ec28e3cf6c2094dfe332df0 (patch) | |
| tree | 310fc734173cf771881f4691c71909135fadde97 /src/lsp/lsp_client.zig | |
| parent | fa82cac885cb4738fe36d1e49b4749b5a3e31a4a (diff) | |
| download | pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.tar.gz pardes-60367d8fe23f6af98ec28e3cf6c2094dfe332df0.zip | |
Refactor panes and filesystem; replace FUSE with 9P
Consolidate pane, layout, memory and host code. Serve 9P by default over Unix sockets, with runtime mounts and optional TCP/QUIC transports. Remove FUSE and obsolete proof-of-concept examples.
Fix highlighting and terminal-history performance, expand differential and stress-test infrastructure, sort navigation results while preserving the next occurrence, add syntax-colored Braille minimaps, remove SPC-k, and document 9P interaction as a repository skill.
Diffstat (limited to 'src/lsp/lsp_client.zig')
| -rw-r--r-- | src/lsp/lsp_client.zig | 156 |
1 files changed, 81 insertions, 75 deletions
diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig index b1be590b..8c6a0da9 100644 --- a/src/lsp/lsp_client.zig +++ b/src/lsp/lsp_client.zig @@ -189,7 +189,7 @@ const wedged_strikes = 3; // consecutive timeouts before a restart const max_rows = 2000; const max_doc_bytes = 8 << 20; -const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer }; +const Err = error{ Dead, Timeout, Protocol, OutOfMemory, NoServer, WriteFailed }; /// Long-lived state outlives every query arena and cannot borrow the caller's /// gpa (a different one shows up in the harness than in the shell), so @@ -399,11 +399,9 @@ const Trace = struct { // ------------------------------------------------------------------- query -/// The seam entry point. Never fails, never panics; no rows is the only error -/// rendering there is (`SPC l i` shows what was swallowed). -pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) void { +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void { _ = gpa; - if (req.kind == .status) return status(req, out) catch {}; + if (req.kind == .status) return status(req, out); var tr: Trace = .{ .on = req.kind == .explain }; const si = specFor(req.path) orelse { @@ -414,33 +412,36 @@ pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: lsp.Req, out }; tr.note("file {s} -> {s} (languageId {s})", .{ std.fs.path.basename(req.path), specs[si].name, specs[si].lang }); - const scratch_buf = arena.alloc(u8, max_rows * 512) catch return; + const scratch_buf = try arena.alloc(u8, max_rows * 512); var scratch: std.Io.Writer = .fixed(scratch_buf); const t0 = nowUs(); var err_name: []const u8 = ""; + var failure: ?anyerror = null; answer(arena, si, req, &scratch, &tr) catch |e| { + failure = e; err_name = @errorName(e); - tr.note("ERROR: {s} — the editor shows this as 'no result'", .{err_name}); + tr.note("ERROR: {s}", .{err_name}); }; const us = nowUs() -| t0; const rows = std.mem.count(u8, scratch.buffered(), "\n"); record(si, req, us, rows, err_name); if (req.kind == .explain) return traceOut(&tr, req, out, rows, us); - out.writeAll(scratch.buffered()) catch {}; + if (failure) |err| return err; + try out.writeAll(scratch.buffered()); } -fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) void { +fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) std.Io.Writer.Error!void { if (req.kind != .explain) return; - out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ + try out.print("lsp explain — the definition query at byte {d} of {s}\n\n", .{ req.offset, if (req.path.len == 0) "(no file)" else std.fs.path.basename(req.path), - }) catch {}; - out.writeAll(tr.buf[0..tr.len]) catch {}; + }); + try out.writeAll(tr.buf[0..tr.len]); if (hideTime()) - out.print("\n{d} row(s)\n", .{rows}) catch {} + try out.print("\n{d} row(s)\n", .{rows}) else - out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {}; + try out.print("\n{d} row(s) in {d}us\n", .{ rows, us }); } fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void { @@ -470,7 +471,7 @@ fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer } } }, - error.OutOfMemory, error.NoServer => {}, + error.OutOfMemory, error.NoServer, error.WriteFailed => {}, } return e; }; @@ -519,7 +520,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io .select_refs => { const b = try atPos(arena, uri.items, pos); const result = (try call(c, arena, "textDocument/documentHighlight", b.items, deadline)) orelse return; - for (items(result)) |h| emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); + for (items(result)) |h| try emitRange(&cx, c.caps.enc, uri.items, get(h, "range"), ""); }, .hover => { const b = try atPos(arena, uri.items, pos); @@ -530,8 +531,8 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io break :blk ""; }; if (text.len == 0) return; - out.writeAll(std.mem.trim(u8, text, " \t\r\n")) catch {}; - out.writeByte('\n') catch {}; + try out.writeAll(std.mem.trim(u8, text, " \t\r\n")); + try out.writeByte('\n'); }, .document_symbols => { var b: std.ArrayList(u8) = .empty; @@ -539,7 +540,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io try jstr(&b, arena, uri.items); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/documentSymbol", b.items, deadline)) orelse return; - walkSymbols(&cx, c.caps.enc, uri.items, result, 0); + try walkSymbols(&cx, c.caps.enc, uri.items, result, 0); }, .workspace_symbols => { var b: std.ArrayList(u8) = .empty; @@ -548,7 +549,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "workspace/symbol", b.items, deadline)) orelse return; for (items(result)) |sym| { const loc = get(sym, "location") orelse continue; - emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); + try emitRange(&cx, c.caps.enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), str(get(sym, "name")) orelse ""); } }, .diagnostics => try diagnostics(c, arena, &cx, uri.items, deadline), @@ -576,7 +577,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io const result = (try call(c, arena, "textDocument/codeAction", b.items, deadline)) orelse return; for (items(result)) |ca| { const title = str(get(ca, "title")) orelse continue; - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), pos.line, 0, flat(arena, title)); cx.rows += 1; } }, @@ -601,7 +602,7 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io // no location of its own (unlike the ZLS backend, which points // at declarations), so the honest place is where it would be // inserted. n/N still step the list; Enter goes nowhere new. - lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); + try lsp.row(cx.out, here, pos.line, byteCol(req.source, pos.line, pos.ch, c.caps.enc), text.items); n += 1; } }, @@ -618,30 +619,30 @@ fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io /// Goto/references result shapes: bare Location, Location[], LocationLink[]. fn locations(cx: *Cx, enc: Enc, result: std.json.Value) Err!void { if (result == .object) { - emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); + try emitRange(cx, enc, str(get(result, "uri")) orelse return, get(result, "range"), ""); return; } for (items(result)) |loc| { if (get(loc, "targetUri")) |tu| { const r = get(loc, "targetSelectionRange") orelse get(loc, "targetRange"); - emitRange(cx, enc, str(tu) orelse continue, r, ""); + try emitRange(cx, enc, str(tu) orelse continue, r, ""); } else { - emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); + try emitRange(cx, enc, str(get(loc, "uri")) orelse continue, get(loc, "range"), ""); } } } /// DocumentSymbol[] nests (`children`), SymbolInformation[] is flat. -fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) void { +fn walkSymbols(cx: *Cx, enc: Enc, uri: []const u8, node: std.json.Value, depth: u8) Err!void { if (depth > 8) return; for (items(node)) |sym| { const name = str(get(sym, "name")) orelse continue; if (get(get(sym, "location"), "range")) |r| { - emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); + try emitRange(cx, enc, str(get(get(sym, "location"), "uri")) orelse uri, r, name); } else { - emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); + try emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name); } - if (get(sym, "children")) |kids| walkSymbols(cx, enc, uri, kids, depth + 1); + if (get(sym, "children")) |kids| try walkSymbols(cx, enc, uri, kids, depth + 1); } } @@ -655,7 +656,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea try jstr(&b, arena, uri); try app(&b, arena, "}"); const result = (try call(c, arena, "textDocument/diagnostic", b.items, deadline)) orelse return; - for (items(get(result, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(result, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); return; } var stale = true; @@ -664,7 +665,7 @@ fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, dea break; }; if (stale) waitFresh(c, uri, nowMs() + diag_ms); - renderStore(c, arena, cx, uri); + try renderStore(c, arena, cx, uri); } fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i64) Err!void { @@ -672,15 +673,15 @@ fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i const result = (try call(c, arena, "workspace/diagnostic", "\"previousResultIds\":[]", deadline)) orelse return; for (items(get(result, "items"))) |per| { const uri = str(get(per, "uri")) orelse continue; - for (items(get(per, "items"))) |dg| emitDiag(cx, c.caps.enc, uri, dg, arena); + for (items(get(per, "items"))) |dg| try emitDiag(cx, c.caps.enc, uri, dg, arena); } return; } - renderStore(c, arena, cx, null); + try renderStore(c, arena, cx, null); } /// One diagnostic row: `severity: message`, at the diagnostic's own range. -fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) void { +fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.mem.Allocator) Err!void { const sev = num(get(dg, "severity")) orelse 1; const label: []const u8 = switch (sev) { 1 => "error", @@ -688,15 +689,15 @@ fn emitDiag(cx: *Cx, enc: Enc, uri: []const u8, dg: std.json.Value, arena: std.m 3 => "info", else => "hint", }; - const msg = std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }) catch return; - emitRange(cx, enc, uri, get(dg, "range"), msg); + const msg = try std.fmt.allocPrint(arena, "{s}: {s}", .{ label, flat(arena, str(get(dg, "message")) orelse "") }); + try emitRange(cx, enc, uri, get(dg, "range"), msg); } -fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) void { +fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) Err!void { for (c.diags.items) |d| { if (only_uri) |u| if (!std.mem.eql(u8, d.uri, u)) continue; const v = std.json.parseFromSliceLeaky(std.json.Value, arena, d.body, .{}) catch continue; - for (items(get(get(v, "params"), "diagnostics"))) |dg| emitDiag(cx, c.caps.enc, d.uri, dg, arena); + for (items(get(get(v, "params"), "diagnostics"))) |dg| try emitDiag(cx, c.caps.enc, d.uri, dg, arena); } } @@ -740,7 +741,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, u, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } } else if (get(result, "changes")) |ch| if (ch == .object) { @@ -753,7 +754,7 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, const span = byteSpan(src, get(ed, "range"), enc) orelse return; const text = str(get(ed, "newText")) orelse return; edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; - } else emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); + } else try emitRange(cx, enc, e.key_ptr.*, get(ed, "range"), flat(cx.arena, str(get(ed, "newText")) orelse "")); } } }; @@ -762,13 +763,13 @@ fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, // the preview needs the self-file rows too — the point is the full map for (edits.items) |ed| { const lc = lsp.lineCol(src, ed.start); - lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); + try lsp.row(cx.out, lsp.rel(cx.base, cx.cur_path), lc.line, lc.col, flat(cx.arena, ed.text)); cx.rows += 1; } return; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// TextEdit[] from formatting is by definition about the current document: @@ -783,7 +784,7 @@ fn formatEdits(cx: *Cx, enc: Enc, result: std.json.Value, src: []const u8) Err!v edits.append(cx.arena, .{ .start = span.start, .end = span.end, .text = text }) catch return error.OutOfMemory; } sortEdits(edits.items); - for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text); + for (edits.items) |ed| try lsp.put(cx.out, ed.start, ed.end, ed.text); } /// One would-be buffer mutation, on its way to an `@put` record. @@ -842,14 +843,14 @@ fn hierarchy(c: *Conn, si: usize, arena: std.mem.Allocator, cx: *Cx, uri: []cons const name = str(get(from, "name")) orelse ""; const ranges = items(get(entry, "fromRanges")); if (ranges.len == 0) { - emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); - } else for (ranges) |r| emitRange(cx, c.caps.enc, fu, r, name); + try emitRange(cx, c.caps.enc, fu, get(from, "selectionRange"), name); + } else for (ranges) |r| try emitRange(cx, c.caps.enc, fu, r, name); }, .outgoing => { const to = get(entry, "to") orelse continue; - emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); + try emitRange(cx, c.caps.enc, str(get(to, "uri")) orelse continue, get(to, "selectionRange") orelse get(to, "range"), hierText(cx.arena, to)); }, - .supers, .subs => emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), + .supers, .subs => try emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)), }; } } @@ -924,7 +925,7 @@ const Cx = struct { /// decoded, `rel`'d against the asking window), utf-16 columns become byte /// columns, and a single-line range becomes the `path:LINE:COL-ENDCOL` form a /// look SELECTS. `note` overrides the source line as the row's text. -fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) void { +fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: []const u8) Err!void { if (cx.rows >= max_rows) return; const path = pathOf(cx.arena, uri) orelse return; if (path.len == 0 or path[0] != '/') return; // rows promise absolute-or-rel-from-base @@ -953,9 +954,9 @@ fn emitRange(cx: *Cx, enc: Enc, uri: []const u8, range: ?std.json.Value, note: [ // byte column; converting the exclusive utf-16 end unit yields the // exclusive byte column, which is the same number. const end_col = colBytes(lntext, r.ec, enc); - lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); + try lsp.spanRow(cx.out, shown, r.sl, col, r.el, end_col, rowtext); } else { - lsp.row(cx.out, shown, r.sl, col, rowtext); + try lsp.row(cx.out, shown, r.sl, col, rowtext); } cx.rows += 1; } @@ -1050,36 +1051,12 @@ fn flat(arena: std.mem.Allocator, s: []const u8) []const u8 { return std.mem.trim(u8, buf.items, " "); } -/// Close-on-exec by fcntl, the darwin route. Same three lines as fuse.zig's -/// and nested.zig's, and here for the same reason they have their own: this -/// file imports neither. fn setCloexec(fd: c_int) void { const FD_CLOEXEC: c_int = 1; _ = libc.fcntl(fd, libc.F.SETFD, FD_CLOEXEC); } -/// The client's transport: an AF_UNIX stream pair with both ends close-on-exec -/// and, on darwin, the parent end opted out of SIGPIPE. False if the host -/// refused, which is a dead server and not a dead editor. -/// -/// A named function rather than nine lines inside `ensure` because the one -/// thing it encodes is a PLATFORM LIE, and a test has to be able to call -/// exactly what the spawn calls. SOCK_CLOEXEC is a LINUX flag; zig spells -/// `SOCK.CLOEXEC` for darwin too — as 0x10000000, with "does not exist on -/// darwin but is used in std.net" in the comment beside it — and darwin's -/// socketpair(2) validates `type` strictly, so asking for it there returns -/// EPROTONOSUPPORT. Every server spawn on macOS failed on that line, before -/// the fork: no binary probe, no handshake, no message row, just `NoServer` in -/// 100µs from a client that had never once run on the platform it was written -/// on. The end-to-end suite that would have caught it (test/snapshots/ -/// lsp-client.snap) only ever runs against the linux target, where the flag is -/// real. fuse.zig and nested.zig already took the plain-socket-plus-fcntl -/// route; this was the one caller that did not. -/// -/// THE WINDOW THIS LEAVES, the same one host_io.zig states for the pty master: -/// fcntl after socketpair is not atomic, so another thread that forks and -/// execs in between inherits both ends. Linux closes it with the flag; darwin -/// has no socketpair that takes one. +// Darwin needs fcntl after socketpair; another concurrent fork can inherit the pair in that window. fn transportPair(sv: *[2]libc.fd_t) bool { const sock_type = if (comptime builtin.os.tag.isDarwin()) libc.SOCK.STREAM @@ -2065,6 +2042,35 @@ fn coord(v: ?std.json.Value) ?u32 { // ----------------------------------------------------------------- tests +test "LSP client rename and format propagate incomplete edit encoding" { + const gpa = std.testing.allocator; + const uri = "file:///file.c"; + const parsed = try std.json.parseFromSlice(std.json.Value, gpa, + \\{"changes":{"file:///file.c":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":3}},"newText":"A%\n"},{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"B"}]}} + , .{}); + defer parsed.deinit(); + const expected = "@put 0 3 A%25%0A\n@put 4 7 B\n"; + for ([_]bool{ true, false }) |rename| { + var buffer: [128]u8 = undefined; + for (0..expected.len + 1) |capacity| { + var arena: std.heap.ArenaAllocator = .init(gpa); + defer arena.deinit(); + var out: std.Io.Writer = .fixed(buffer[0..capacity]); + var cx: Cx = .{ .arena = arena.allocator(), .base = "/", .cur_path = "/file.c", .cur_src = "abc xyz", .out = &out }; + const result = if (rename) + renameEdits(&cx, .utf8, uri, parsed.value, cx.cur_src) + else + formatEdits(&cx, .utf8, get(get(parsed.value, "changes"), uri).?, cx.cur_src); + if (capacity < expected.len) { + try std.testing.expectError(error.WriteFailed, result); + } else { + try result; + try std.testing.expectEqualStrings(expected, out.buffered()); + } + } + } +} + test "frameNext distinguishes incomplete, valid and poison frames" { try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n")); try std.testing.expectEqual(FrameStep.incomplete, frameNext("Content-Length: 5\r\n\r\nhel")); |
