summaryrefslogtreecommitdiff
path: root/src/lsp/lsp.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-01 09:23:53 -0300
committerGabriel Schneider <[email protected]>2026-09-01 11:24:12 -0300
commitae9325a5cb128d0d952afb8f9feaaca68e5e37a2 (patch)
tree9ae44ac38f7b71edfe2882d0a882dbc61304ec80 /src/lsp/lsp.zig
parent848ad99fa597387a85f75e752dc4c9e10f8c24f4 (diff)
downloadpardes-ae9325a5cb128d0d952afb8f9feaaca68e5e37a2.tar.gz
pardes-ae9325a5cb128d0d952afb8f9feaaca68e5e37a2.zip
lsp: a protocol client for every other language, narrated on the message row
The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are rows in a spec table — while the in-process ZLS analyser keeps .zig. One reader thread per server owns the socket, routes responses to a mailbox under the conn mutex (monotonic condvar), answers server-to-client requests, feeds the diagnostics store, and narrates $/progress and state changes through a status sink both native shells post to the transient message row: "rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with the same clock. Chatty progress is throttled and deduplicated; settled states always land, which is also what makes the goldens deterministic. Nothing wedges and nothing healthy dies: waits are deadline-bounded, a timeout cancels and returns no rows, three consecutive timeouts restart the server ONLY while it is idle (an indexing server is narrating its own excuse), spawn and handshake failures back off 10s to 2min, a crash shortly after ready counts as a failure, and only a missing binary disables a spec. PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot harness pins RS to test/lspmock.zig and empties the rest. Mutating answers really mutate now: the @put record beside rename @edit carries per-range text, so = applies the formatter (both backends) and a same-file WorkspaceEdit rename applies atomically, one undo step, narrated ("renamed 2 range(s)"); a multi-file rename previews as rows instead of half-applying. Malformed responses fail closed: coordinates validated not clamped, one bad TextEdit poisons the whole edit set, poison frames kill the connection instead of buffering forever, decoded control bytes reject a uri, hierarchy items too deep to reserialize are skipped. Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when advertised. Help gains a language-keys footer for the motions no builtin row could carry; lsp.rel and look.grep now share one path-shortening rule. zig build lspprobe drives the seam from the CLI (comma-separated kinds share one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213 rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms. docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum.
Diffstat (limited to 'src/lsp/lsp.zig')
-rw-r--r--src/lsp/lsp.zig106
1 files changed, 86 insertions, 20 deletions
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 255a8200..0b4f2ba1 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -62,6 +62,21 @@ pub const Kind = enum {
/// `gr` fills. Nothing is inserted.
completion,
+ // The two-step hierarchy kinds, LSP 3.16/3.17: prepare at the cursor,
+ // then walk the item the server handed back. helix has none of these
+ // four (checked against helix-term/src/keymap/default.rs, which stops at
+ // the gotos), so they are pardes exceeding parity rather than matching
+ // it — possible here because the answers are LOCATIONS, and locations
+ // are the one thing this seam renders for free.
+ /// SPC l c — who calls the function under the cursor
+ incoming_calls,
+ /// SPC l C — everything the function under the cursor calls
+ outgoing_calls,
+ /// SPC l t — the types this one extends/implements
+ supertypes,
+ /// SPC l T — the types that extend/implement this one
+ subtypes,
+
// The two introspection kinds. A backend that answers nothing is
// indistinguishable from a backend that is broken, so these exist to tell
// those apart — they are the only Kinds whose answer is ABOUT the backend
@@ -114,8 +129,8 @@ pub const Req = struct {
/// where it is; `../../..` says neither, and the moment the row is read
/// somewhere other than beside its own buffer it is wrong.
///
-/// This is `look.grep`'s `shown` rule, spelled a second time — see the note
-/// there; the two want to become one function.
+/// This is also `look.grep`'s `shown` rule — it calls this function, so the
+/// two spellings the docs used to complain about are one.
pub fn rel(base: []const u8, path: []const u8) []const u8 {
if (base.len == 0) return path;
const home = std.mem.trimEnd(u8, base, "/");
@@ -173,6 +188,27 @@ pub fn edit(out: *std.Io.Writer, start: usize, end: usize) void {
out.print("@edit {d} {d}\n", .{ start, end }) catch {};
}
+/// The general mutating record: a half-open byte range REPLACED BY `text`,
+/// which `@edit` cannot say (its replacement is the request's own arg, the
+/// same for every range). Rename through a protocol server and `=` both need
+/// per-range text, so this carries it — percent-encoded onto the one line a
+/// record is allowed to be, because a TextEdit's newText is full of newlines
+/// and the record stream is parsed line by line. The core decodes with
+/// `parseLspEdits` and applies all records in one undo transaction; malformed,
+/// overlapping or out-of-bounds records change nothing, exactly as for @edit.
+pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) void {
+ out.print("@put {d} {d} ", .{ start, end }) catch {};
+ for (text) |c| {
+ // '%' so the encoding round-trips; control bytes so the record stays
+ // one line; ' ' so the text is one token. Everything else is itself.
+ if (c == '%' or c == ' ' or c < 0x21)
+ out.print("%{X:0>2}", .{c}) catch {}
+ else
+ out.writeByte(c) catch {};
+ }
+ out.writeByte('\n') catch {};
+}
+
/// Byte offset -> (line, column), both 0-based. Every backend needs it to turn
/// an AST token into a row, so it lives here rather than three times over.
pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } {
@@ -195,29 +231,45 @@ pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usi
/// answer writes nothing, and the core treats "no rows" as "no result", which
/// is also what a language server still starting up looks like.
pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) void {
- backend.query(gpa, arena, req, out);
+ // `status` is about the BACKENDS, plural: every one reports, in seam
+ // order, so `SPC l i` shows the analyser and the protocol client side by
+ // side and a machine with neither prints nothing at all.
+ if (req.kind == .status) {
+ inline for (backends) |b| b.query(gpa, arena, req, out);
+ return;
+ }
+ inline for (backends) |b| {
+ if (b.speaks(req.path) and b.supports.contains(req.kind))
+ return b.query(gpa, arena, req, out);
+ }
+ // Nobody spoke the file. `explain` exists precisely to narrate a refusal,
+ // so it still goes to the first backend, whose trace says WHY it stopped
+ // ("not a .zig file", "no server for .md") instead of silently no-rowing.
+ if (req.kind == .explain and backends.len > 0)
+ backends[0].query(gpa, arena, req, out);
}
-/// ZLS, linked in as a module and called directly — no subprocess, no
-/// JSON-RPC. See `lsp_zls.zig`. The web shell cannot build ZLS (no threads,
-/// freestanding wasm), so there it is the empty backend the base tree shipped
-/// with — and empty means SILENT rather than failing: `supports` below is then
-/// an empty set, `lspRequest` returns on it before emitting, and the effect
-/// never leaves the core. `web.zig`'s prong for it is waiting for a host that
-/// links a backend, not dead weight for one that never will.
-const backend = if (@import("pardes_config").zls_backend) @import("lsp_zls.zig") else struct {
- pub fn query(_: std.mem.Allocator, _: std.mem.Allocator, _: Req, _: *std.Io.Writer) void {}
- pub fn speaks(_: []const u8) bool {
- return false;
- }
- pub const supports: std.EnumSet(Kind) = .initEmpty();
-};
+/// The compiled-in backends, asked in order; the first one that speaks the
+/// file's language AND claims the kind answers. Two on a native build — ZLS
+/// linked as a module for Zig (no process, cold is warm), and a real LSP
+/// client (lsp_client.zig) speaking JSON-RPC to child servers for everything
+/// else: rust-analyzer, clangd, gopls, whatever the spec table names. A
+/// FREESTANDING core (web, esp32) compiles in neither: `supports` is then
+/// empty, `lspRequest` returns before it emits, and the effect never exists.
+const backends = if (@import("pardes_config").zls_backend)
+ .{ @import("lsp_zls.zig"), @import("lsp_client.zig") }
+else
+ .{};
/// What this backend can actually answer, for the evaluation harness and for
/// the core (a Kind that is not supported never leaves the keymap). An
/// implementation narrows this to what it really does — claiming a feature it
/// does not have shows up immediately in the harness's matrix.
-pub const supports: std.EnumSet(Kind) = backend.supports;
+pub const supports: std.EnumSet(Kind) = blk: {
+ var s: std.EnumSet(Kind) = .initEmpty();
+ for (0..backends.len) |i| s.setUnion(backends[i].supports);
+ break :blk s;
+};
/// Does the backend read this file's LANGUAGE at all? `supports` answers what
/// a backend can do; this answers what it can do it TO, and it exists for the
@@ -225,9 +277,23 @@ pub const supports: std.EnumSet(Kind) = backend.supports;
/// diverts to `completion` after a `.`, so in a README — or in any pane the
/// backend would refuse — it has to indent instead. The core asks rather than
/// knowing, so the list of extensions stays the backend's business.
-pub const speaks = backend.speaks;
+pub fn speaks(path: []const u8) bool {
+ inline for (backends) |b| if (b.speaks(path)) return true;
+ return false;
+}
+
+/// Where a shell registers the one function unsolicited SERVER STATE goes
+/// through: "rust-analyzer indexing 3/120", "gopls exited". Called from the
+/// client's reader threads, so a sink must be thread-safe and must copy
+/// `text` before returning; both native shells post it to their event queue
+/// and let the loop hand it to `Pardes.setMessage` — the same transient row a
+/// save narrates into, because a server starting up is exactly that kind of
+/// news. A build with no client accepts and ignores the registration.
+pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void {
+ if (@import("pardes_config").zls_backend) backends[1].setStatusSink(ctx, cb);
+}
/// Name shown by the harness and in `SPC ?`. This is the SEAM's, not the
/// backend's: a backend does not declare it, so renaming a backend means
/// editing this line.
-pub const backend_name = "zls-inproc";
+pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc";