diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-01 09:23:53 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-01 11:24:12 -0300 |
| commit | ae9325a5cb128d0d952afb8f9feaaca68e5e37a2 (patch) | |
| tree | 9ae44ac38f7b71edfe2882d0a882dbc61304ec80 /src/lsp/lsp.zig | |
| parent | 848ad99fa597387a85f75e752dc4c9e10f8c24f4 (diff) | |
| download | pardes-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.zig | 106 |
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"; |
