summaryrefslogtreecommitdiff
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
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.
-rw-r--r--build.zig21
-rw-r--r--build/snap.zig17
-rw-r--r--docs/lsp-evaluation.md11
-rw-r--r--docs/lsp.md145
-rw-r--r--src/builtins.zig28
-rw-r--r--src/config.zig8
-rw-r--r--src/gui/gui.zig27
-rw-r--r--src/look.zig11
-rw-r--r--src/lsp/lsp.zig106
-rw-r--r--src/lsp/lsp_client.zig2127
-rw-r--r--src/lsp/lsp_zls.zig36
-rw-r--r--src/output_pane.zig57
-rw-r--r--src/pardes.zig132
-rw-r--r--src/tty/tty.zig50
-rw-r--r--test/lspfixture/client/Cargo.toml11
-rw-r--r--test/lspfixture/client/lib.rs4
-rw-r--r--test/lspfixture/client/main.rs7
-rw-r--r--test/lspmock.zig271
-rw-r--r--test/snapshot.zig17
-rw-r--r--test/snapshots/builtins.golden66
-rw-r--r--test/snapshots/builtins.snap11
-rw-r--r--test/snapshots/leader.golden30
-rw-r--r--test/snapshots/lsp-client-edit.golden44
-rw-r--r--test/snapshots/lsp-client-edit.snap35
-rw-r--r--test/snapshots/lsp-client.golden27
-rw-r--r--test/snapshots/lsp-client.snap34
-rw-r--r--test/snapshots/lsp-rename.golden8
-rw-r--r--test/snapshots/lspdebug.golden2
-rw-r--r--tools/lspprobe.zig184
29 files changed, 3378 insertions, 149 deletions
diff --git a/build.zig b/build.zig
index f27e47aa..dd2d64bf 100644
--- a/build.zig
+++ b/build.zig
@@ -1503,6 +1503,27 @@ pub fn build(b: *std.Build) void {
lspbench_step.dependOn(install_lspbench);
lspbench_step.dependOn(&run_lspbench.step);
+ // The client's one-shot probe: the same seam, one query, any
+ // workspace — how the protocol client is exercised against real
+ // rust/C/go trees during development without driving the editor.
+ // Debug is fine: the latency measured is the child server's.
+ const lspprobe = b.addExecutable(.{
+ .name = "pardes-lspprobe",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tools/lspprobe.zig"),
+ .link_libc = true,
+ }),
+ });
+ lspprobe.root_module.addImport("pardes", hx_core_mod);
+ const install_lspprobe = &b.addInstallArtifact(lspprobe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
+ const run_lspprobe = b.addRunArtifact(lspprobe);
+ if (b.args) |args| run_lspprobe.addArgs(args);
+ const lspprobe_step = b.step("lspprobe", "one language query against the real seam (-- <kind> <file> <line>:<col> [arg] [--reps N])");
+ lspprobe_step.dependOn(install_lspprobe);
+ lspprobe_step.dependOn(&run_lspprobe.step);
+
// the editing scoreboard: one gesture, one file size, one number.
// ReleaseFast for the same reason lspbench is — a Debug build measures
// safety checks, and the question here is what the algorithm costs.
diff --git a/build/snap.zig b/build/snap.zig
index 841f4fcd..766be2b4 100644
--- a/build/snap.zig
+++ b/build/snap.zig
@@ -30,6 +30,21 @@ pub fn addTty(b: *std.Build, opts: struct {
.freebsd, .netbsd, .openbsd => snap.root_module.linkSystemLibrary("util", .{}),
else => {},
}
+ // The deterministic mock language server (test/lspmock.zig): the runner
+ // points PARDES_LSP_RS at it, so the lsp-client scripts drive the REAL
+ // protocol client — spawn, handshake, reader thread, progress narration —
+ // against answers a golden can quote byte for byte. No pardes import; it
+ // is a foreign binary on purpose.
+ const lspmock = b.addExecutable(.{
+ .name = "pardes-lspmock",
+ .root_module = b.createModule(.{
+ .target = opts.target,
+ .optimize = opts.optimize,
+ .root_source_file = b.path("test/lspmock.zig"),
+ .link_libc = true,
+ }),
+ });
+ const install_lspmock = &b.addInstallArtifact(lspmock, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
// Installed by `snap` and not by the default install step, and into
// `<prefix>/dev` rather than `<prefix>/bin`: a bare `zig build` installs
// the two shells into the user's own bin directory, and this harness is
@@ -37,11 +52,13 @@ pub fn addTty(b: *std.Build, opts: struct {
const install_snap = &b.addInstallArtifact(snap, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_snap = b.addRunArtifact(snap);
run_snap.addArtifactArg(opts.exe);
+ run_snap.addPrefixedFileArg("--lspmock=", lspmock.getEmittedBin());
if (b.args) |args| run_snap.addArgs(args);
// scripts fork children and drive real ptys: never cached, never "up to date"
run_snap.has_side_effects = true;
const snap_step = b.step("snap", "run the snapshot parity suite (-- --update regenerates goldens)");
snap_step.dependOn(install_snap);
+ snap_step.dependOn(install_lspmock);
snap_step.dependOn(&run_snap.step);
}
diff --git a/docs/lsp-evaluation.md b/docs/lsp-evaluation.md
index ab502422..184223e8 100644
--- a/docs/lsp-evaluation.md
+++ b/docs/lsp-evaluation.md
@@ -116,6 +116,17 @@ A and B were not deleted, only un-worktree'd. They remain whole commits:
Both are one `jj new <change>` away if the multi-language argument below wins
later, or if the ZLS coupling ever needs backing out.
+> **Addendum, 2026-09-01.** The multi-language argument won. The tree now has
+> a REAL protocol client beside the in-process analyser —
+> `src/lsp/lsp_client.zig`, routed per file by the seam — but it is a new
+> implementation, not `stuurqqt` resurrected: B pumped the socket only while
+> a query waited, which cannot carry an indexing server's `$/progress` or
+> unsolicited diagnostics, so the client has a reader thread per server and a
+> status sink narrating server state onto the transient message row. B's
+> transport bones (socketpair, deadline-bounded writes, MSG_NOSIGNAL,
+> believe-the-server encoding) survive in it. See "The protocol client" in
+> docs/lsp.md.
+
## Recommendation (as written before the decision)
**C (in-process), with B as the answer to a question pardes has not asked yet.**
diff --git a/docs/lsp.md b/docs/lsp.md
index b35c8eeb..4b2e5ba2 100644
--- a/docs/lsp.md
+++ b/docs/lsp.md
@@ -149,11 +149,10 @@ to end — the enum one level up (absolute row) and one level down (`inner/tint.
a stripped path that still has a separator in it), each with the `n` step that
selects it and the Enter that opens it, plus a right click.
-This is the rule `look.grep` already follows for its own rows, and it is
-spelled TWICE: `lsp.rel` here, and an inline `if` over the asking pane's
-directory in `look.zig`'s `grep` (the `shown` computation) there. Same rule,
-two implementations — they want to become one function, and `lsp.zig`'s own
-comment on `rel` says so.
+This is the rule `look.grep` follows for its own rows, and since the client
+landed it is spelled ONCE: look.zig's `grep` calls `lsp.rel` for its `shown`
+paths rather than keeping the inline twin this paragraph used to complain
+about.
`completion` is the kind this shape changes the most. Every other editor answers
a dot with a popup of NAMES to insert; a seam that returns locations cannot
@@ -250,6 +249,118 @@ Known limitations, in the order you will meet them:
- **`error.`** is not handled — the position context is `.error_access`, which
no branch claims.
+
+## The protocol client: every other language
+
+`src/lsp/lsp_client.zig` is the second backend behind the same seam: a real
+LSP client — JSON-RPC 2.0, `Content-Length` frames — speaking to child
+processes. Nothing in it knows any single language; `specs` is a table of
+(binary, languageId, extensions, root markers), and rust-analyzer, clangd,
+gopls, typescript-language-server and pyright are rows in it. The seam asks
+each backend `speaks(path) and supports(kind)` in order, so `.zig` stays with
+the in-process analyser (cold is warm, no process) and everything else routes
+here. `SPC l i` prints both sections; `backend_name` is `zls-inproc+lsp-client`.
+
+**One server per spec, one reader thread per server, and the reader is not
+optional.** A real server TALKS: rust-analyzer streams `$/progress` for the
+whole minutes-long index of a big workspace, publishes diagnostics nobody
+asked for, and asks its own `workspace/configuration` questions mid-flight.
+The reader owns the read side of the socketpair, routes responses to the one
+waiting query (a mailbox under the connection's mutex), answers
+server-to-client requests so the server never blocks on us, feeds the
+diagnostics store, and narrates state changes through the STATUS SINK — a
+callback both native shells register at startup and post to their event
+queue, so "rust-analyzer: cargo check 88% 955/1083" lands on the same
+transient message row a save narrates into (`message.stamp`, verb `lsp`, on
+the ACTIVE pane — server state is session news, not a fact about the pane
+that asked). Chatty progress is throttled to one post per 150ms per server
+and deduplicated; state CHANGES (starting, ready, exited, errors) always
+land, and repeating the row already shown never does — which is also what
+makes the settled state deterministic for the snapshot goldens.
+
+Nothing may wedge the editor, and nothing healthy may be killed for being
+busy:
+
+- every write and every mailbox wait is deadline-bounded (8s handshake, 4s
+ request); a query the server does not answer in time returns no rows and
+ sends `$/cancelRequest`;
+- three CONSECUTIVE timeouts mean wedged and force a restart — but only
+ while the server is idle. One with active `$/progress` (rust-analyzer
+ mid-`cargo check` over a thousand crates) is demonstrably alive, already
+ narrating its own excuse on the message row, and killing it would throw
+ the index away right before it pays off. This rule exists because the
+ first run against a thousand-crate workspace did exactly that;
+- a failed spawn or handshake is NOT a session disable: it backs off
+ exponentially (10s doubling to 2min, reset by the next success), because
+ the failure that taught this was a rustup shim deciding to download the
+ project's whole pinned toolchain before launching the real server. Only a
+ missing binary disables a spec, once, with a message saying which env var
+ overrides it;
+- a server that dies is reaped by whoever saw it die (the reader on EOF,
+ `shutdownIf` on a transport error), the fd is closed by the READER ALONE —
+ `shutdown(2)` first, so a polled fd number is never recycled under a
+ thread still watching it — and the next query respawns, generation-checked
+ so a stale worker can neither adopt nor kill its successor's server.
+
+`PARDES_LSP_RS` / `_C` / `_GO` / `_TS` / `_PY` override each spec's binary
+(a path or a PATH name); the empty string disables the spec. The snapshot
+harness pins `_RS` to `test/lspmock.zig`'s deterministic mock and empties
+the rest, so `test/snapshots/lsp-client.snap` (gd across files, gr spans,
+n/Enter) and `lsp-client-edit.snap` (format apply, rename apply, one-step
+undo for each) drive the REAL client — spawn, handshake, reader, narration —
+against answers a golden can quote. `zig build lspprobe -- gd <file> <l>:<c>`
+is the same seam from the command line, for pointing at any real workspace;
+comma-separated kinds share one server so a big index is paid for once.
+
+The root is helix's `find_root` rule: walking up from the file, the TOP-MOST
+directory holding one of the spec's markers wins (a cargo workspace's root
+`Cargo.toml` beats the member crate's), the closest `.git` is the fallback,
+the asking directory the last resort. A second project in the same session
+becomes a workspace FOLDER when the server advertises support. Position
+encoding is negotiated to utf-8 and the server's ANSWER is believed; the
+utf-16 conversion is implemented in both directions for servers that refuse.
+Diagnostics PULL (`textDocument/diagnostic`, LSP 3.17) is preferred when the
+server advertises it — rust-analyzer does — and the push store fed by the
+reader answers otherwise, `]d` stepping either for free.
+
+### Mutating answers really mutate now
+
+The seam grew a second record form beside rename's `@edit`: `@put START END
+TEXT` carries a per-range replacement, percent-encoded onto the one line a
+record is allowed to be (`lsp.put`). The core decodes, validates (ordered,
+non-overlapping, in bounds, revision unchanged) and applies ALL records as
+one undo transaction, then says so on the message row ("formatted 1
+range(s)", "renamed 2 range(s)"). So:
+
+- `=` FORMATS, like helix — through the client it applies the server's
+ TextEdits; through ZLS it applies one span covering everything `zig fmt`
+ would change. The two non-edit answers stay prose in `+Lsp`: a file that
+ does not parse, and (client-side) a server with no formatter.
+- `SPC l r` through the client applies a WorkspaceEdit that stays inside the
+ asked-about file. One that spans OTHER files (a real workspace rename)
+ arrives as location rows instead and opens as a PREVIEW list in the same
+ buffer `gr` fills — applying a fraction of a workspace rename silently
+ would be worse than either. The ZLS backend still resolves and renames
+ current-file references via `@edit`, exactly as before.
+
+### Four kinds helix does not have
+
+The hierarchy kinds are two-step in the protocol (prepare at the cursor,
+then follow the item), are gated on the server capability so an old server
+costs zero round trips, and their answers are LOCATIONS — the one thing this
+seam renders for free. helix has no binding for any of the four (checked
+against helix-term/src/keymap/default.rs).
+
+| keys | kind | what the rows are |
+|---|---|---|
+| `SPC l c` | incoming_calls | one row per CALL SITE, under the caller's name |
+| `SPC l C` | outgoing_calls | the callees' declarations |
+| `SPC l t` | supertypes | the types this one extends/implements |
+| `SPC l T` | subtypes | the types that extend/implement this one |
+
+All four behave like `gr`: a list to walk with `n`/`N`, and a lone answer is
+a jump.
+
## Which ZLS, and which stdlib
Both are decided at build time, and `SPC l i` prints both.
@@ -302,16 +413,18 @@ Verified against `helix-term/src/keymap/default.rs`, not from memory.
| `gi` | implementation | |
| `gr` | references | |
| `SPC l k` | hover | opens `+Hover` |
-| `SPC l r` | rename | tag input; applies current-file references in one undo step |
+| `SPC l r` | rename | tag input; applies same-file edits in one undo step, PREVIEWS a multi-file WorkspaceEdit as rows |
| `SPC l a` | code action | |
| `SPC l h` | select references | |
| `SPC l s` / `SPC l S` | document / workspace symbols | `S` takes a query |
| `SPC l d` / `SPC l D` | document / workspace diagnostics | |
| `]d` / `[d` | next / prev diagnostic | steps the list, asks for one if absent |
| `]D` / `[D` | last / first diagnostic | |
-| `=` | format | |
+| `=` | format | applies the formatter's edits in one undo step |
| `Ctrl`+left-click | definition | the mouse spelling of `gd` |
| `Tab` in INSERT mode, right after a `.` | completion | what could go here, and where each of those is defined |
+| `SPC l c` / `SPC l C` | incoming / outgoing calls | beyond helix — see the client section |
+| `SPC l t` / `SPC l T` | supertypes / subtypes | beyond helix — see the client section |
Tab is the one key here that is not helix's and not a goto. helix's `Tab`
completes; pardes's shows you the CANDIDATES' DECLARATIONS in a `+Search`
@@ -340,12 +453,14 @@ afford one keystroke more. So every one of them keeps helix's own letter and
gains the prefix — `<space>k` becomes `SPC l k`, `<space>d` becomes `SPC l d`
— and nothing pardes had moved at all.
-Two more live in the same group because they belong to it, not to helix:
+Two more live in the same group because they belong to it, not to helix (the
+four hierarchy kinds above are also pardes's own — helix has no spelling for
+them):
| keys | builtin | what it shows |
|---|---|---|
-| `SPC l i` | `Lspinfo` | which ZLS, which stdlib (and whether it opens), what the backend answers and refuses, plus the last 24 queries with timings, row counts and **the errors `query` swallowed** |
-| `SPC l w` | `Lspwhy` | why the definition query at the cursor answers what it does |
+| `SPC l i` | `Lspinfo` | BOTH backends: which ZLS and which stdlib (and whether it opens); every protocol server's state, root, encoding and capabilities; and each side's last 24 queries with timings, row counts and **the errors `query` swallowed** |
+| `SPC l w` | `Lspwhy` | why the definition query at the cursor answers what it does — narrated by whichever backend the file routes to |
These exist because of the seam's own contract: a backend never fails loudly,
which is right for an editor — a thrown analyser must not take the process with
@@ -362,9 +477,13 @@ document — which matters precisely when the pane you are in is the problem.
## Writing a backend
-`src/lsp/lsp.zig` is the seam. An implementation supplies three things and touches
-nothing else (`backend_name` below is the SEAM's, not yours — it is a literal in
-`lsp.zig` naming whichever backend was compiled in):
+`src/lsp/lsp.zig` is the seam, and since the protocol client landed it holds a
+LIST of backends, asked in order: the first one that `speaks` the file's
+language and claims the kind in `supports` answers. An implementation supplies
+three things and touches nothing else (`backend_name` is the SEAM's, not
+yours — one literal in `lsp.zig` naming the compiled-in combination; a backend
+with unsolicited news to deliver may additionally accept the status sink, as
+`setStatusSink` shows):
```zig
pub fn query(gpa, arena, req: Req, out: *std.Io.Writer) void
diff --git a/src/builtins.zig b/src/builtins.zig
index 4e096c97..40a5083c 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -945,6 +945,34 @@ pub const WsDiagnostics = struct {
}
};
+// The four hierarchy words, protocol-only (LSP 3.16/3.17): the in-process
+// Zig backend has no analyser for them, so in a `.zig` pane they answer
+// nothing. helix has no binding for any of the four.
+
+pub const Callers = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .incoming_calls, "");
+ }
+};
+
+pub const Callees = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .outgoing_calls, "");
+ }
+};
+
+pub const Supertypes = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .supertypes, "");
+ }
+};
+
+pub const Subtypes = struct {
+ pub fn run(c: Ctx) void {
+ c.p.lspRequest(c.id, .subtypes, "");
+ }
+};
+
// The two that need a word from the user, handled exactly the way Find and
// Grep are: an argument means run it now (a selection chorded onto the name),
// no argument arms the tag input and Enter submits (submitSearch).
diff --git a/src/config.zig b/src/config.zig
index 1a3de86c..81ecc514 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -107,6 +107,14 @@ pub const leader_path = paths: {
.WsDiagnostics = "lD",
.Lspinfo = "li",
.Lspwhy = "lw",
+ // The hierarchy group, protocol servers only. `c`/`t` were free under
+ // `l`; helix has no spelling for these at all (they postdate its
+ // keymap), so the letters are pardes's own: who Calls me / whom I
+ // Call, and the Type lattice up / down.
+ .Callers = "lc",
+ .Callees = "lC",
+ .Supertypes = "lt",
+ .Subtypes = "lT",
.Del = "d",
// A terminal-pane tag owns this presentation switch. It deliberately
// has no global leader path: executing the word beside that terminal
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 91380e46..11e9f53b 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -899,6 +899,9 @@ const Msg = union(enum) {
eof: struct { pane: u8, gen: u32, fd: c_int },
/// a language query finished on its own thread (see lspThread)
lsp: struct { id: u32, rows: []u8 },
+ /// a language SERVER changed state; narrated by the client's reader
+ /// threads through the status sink, lsp-allocator-owned
+ lsp_status: []u8,
/// a selection-filter worker finished; every stdout is gpa-owned
pipe: selection_pipe.Response,
/// something happened in a watched directory (see watchThread)
@@ -917,6 +920,7 @@ const Msg = union(enum) {
switch (m) {
.output => |o| gpa.free(o.bytes),
.lsp => |l| lsp_allocator.free(l.rows),
+ .lsp_status => |t| lsp_allocator.free(t),
.pipe => |response_value| {
var response = response_value;
response.deinit(gpa);
@@ -1120,6 +1124,15 @@ const Queue = struct {
}
};
+/// The registered `lsp.setStatusSink` target, called from the protocol
+/// client's reader threads: dupe with the concurrent lsp allocator, push to
+/// the mutex queue. A push after close is disposed by the queue itself.
+fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void {
+ const q: *Queue = @ptrCast(@alignCast(ctx orelse return));
+ const copy = q.lsp_allocator.dupe(u8, text) catch return;
+ q.push(.{ .lsp_status = copy });
+}
+
fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void {
var buf: [0x10000]u8 = undefined;
while (true) {
@@ -2158,6 +2171,10 @@ fn localSession(
if (test_mode) setStdinRaw() catch {};
shell.threads_ok = true;
+ // Server state narration: reader threads → queue → drainQueue → the
+ // message row. Unset before the queue closes (see the defer above it).
+ pardes.lsp.setStatusSink(&queue, lspStatusSink);
+ defer pardes.lsp.setStatusSink(null, null);
// The core owns the loop. This owns the two things a pump cannot do from
// inside itself, because both replace the whole session and are only safe
@@ -2580,6 +2597,8 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
while (core.nextEffect()) |e| core.perform(e);
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue);
shell.threads_ok = true;
+ pardes.lsp.setStatusSink(&queue, lspStatusSink);
+ defer pardes.lsp.setStatusSink(null, null);
setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
@@ -3696,6 +3715,14 @@ const Shell = struct {
s.lsp_allocator.free(l.rows);
s.saw_event = true;
},
+ // Server state on the transient message row of the ACTIVE pane —
+ // session news, same row and same stamp a completed save uses.
+ .lsp_status => |text| {
+ var mbuf: [256]u8 = undefined;
+ s.core.setMessage(s.core.active, message.stamp(&mbuf, "lsp", text));
+ s.lsp_allocator.free(text);
+ s.saw_event = true;
+ },
.pipe => |response_value| {
var response = response_value;
s.core.update(.{ .pipe_resp = .{
diff --git a/src/look.zig b/src/look.zig
index c98e7988..1730a456 100644
--- a/src/look.zig
+++ b/src/look.zig
@@ -15,6 +15,7 @@ const std = @import("std");
const builtin = @import("builtin");
const libc = std.c;
const pardes = @import("pardes.zig");
+const lsp = @import("lsp/lsp.zig");
const config = @import("config.zig");
const pdf_enabled = @import("pardes_config").mupdf;
/// The virtual filesystem, on every platform: the browser has only this, and
@@ -776,11 +777,11 @@ pub fn grep(arena: std.mem.Allocator, gpa: std.mem.Allocator, dir: []const u8, b
if (std.mem.indexOfScalar(u8, text[0..@min(len, 1024)], 0) != null) continue; // binary
// per PATH, not per root: one root can straddle the asking pane's
// directory (a shell at `/a` searching for a file pane at `/a/b`), and
- // the rows inside it are the ones worth shortening
- const shown = if (path.len > home.len and std.mem.startsWith(u8, path, home) and path[home.len] == '/')
- path[home.len + 1 ..]
- else
- path;
+ // the rows inside it are the ones worth shortening. The rule is
+ // lsp.rel's — under `base` means relative, anywhere else stays
+ // absolute — and it is THE one spelling now; this used to be an
+ // inline twin that the seam's own comment complained about.
+ const shown = lsp.rel(home, path);
const result = grepText(shown, text, pat, out[written..], find_max_hits - hits);
hits += result.hits;
written += result.bytes;
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";
diff --git a/src/lsp/lsp_client.zig b/src/lsp/lsp_client.zig
new file mode 100644
index 00000000..eca2691d
--- /dev/null
+++ b/src/lsp/lsp_client.zig
@@ -0,0 +1,2127 @@
+//! A real Language Server Protocol client: child processes spoken to over
+//! JSON-RPC 2.0 with `Content-Length` framing. Nothing here knows any single
+//! language — `specs` is a table of (binary, languageId, extensions, root
+//! markers), and rust-analyzer, clangd and gopls are rows in it. The in-process
+//! ZLS backend keeps `.zig`; this file is every language pardes highlights but
+//! could not answer questions about.
+//!
+//! THE PROCESS LIFECYCLE IS THE DESIGN. `lsp.query` is a synchronous call on a
+//! worker thread, and a language server costs tens of milliseconds to start
+//! and MINUTES to index a large workspace. So a server is spawned lazily on
+//! the first query that needs it, kept for the life of the editor, and each
+//! spec gets at most one — `conns[i]` guards itself with a pthread mutex
+//! because the gui shell detaches its workers and a superseded query can still
+//! be inside `run` when the next arrives.
+//!
+//! ONE READER THREAD PER SERVER, and it is not optional. The first design
+//! pumped the socket only while a query waited, which works for a server that
+//! only ever answers. A real server TALKS: rust-analyzer streams `$/progress`
+//! for the whole minutes-long index of a big workspace, publishes diagnostics
+//! it was never asked for, and asks its own `workspace/configuration`
+//! questions mid-flight. The reader owns the read side of the socket, routes
+//! responses to the one waiting query (a mailbox under the conn's mutex),
+//! answers server-to-client requests so the server never blocks on us, feeds
+//! the diagnostics store, and narrates state changes through `status sink` —
+//! the shell posts them to `Pardes.setMessage`, so "rust-analyzer indexing 45%"
+//! lands on the same transient message row a save narrates into. The reader is
+//! also the ONLY closer of its socket fd: teardown calls `shutdown(2)` and the
+//! reader closes on the EOF it then reads, so the fd number cannot be recycled
+//! under a thread still polling it.
+//!
+//! Nothing here may wedge the editor:
+//! - every write and every mailbox wait is bounded by a deadline,
+//! - a query the server does not answer in time returns no rows and sends
+//! `$/cancelRequest`; the server is NOT killed for being busy (an indexing
+//! server is busy for minutes and the status row says so) — but three
+//! consecutive timeouts mean wedged, and wedged is killed and respawned,
+//! - the transport is an AF_UNIX socketpair, so a dead server answers EPIPE
+//! from `send(MSG_NOSIGNAL)` instead of killing pardes with SIGPIPE
+//! (ignoring SIGPIPE process-wide would be inherited by every pty shell we
+//! fork and would change what `yes | head` does in a pane),
+//! - a binary missing from PATH costs one cached probe, disables the spec
+//! for the session, and says so ONCE on the message row.
+//!
+//! Position encoding is negotiated to utf-8 and the SERVER'S ANSWER is
+//! believed, not our request; the utf-16 conversion is implemented in both
+//! directions for servers that refuse (`Req.offset` is a byte offset, and a
+//! misconverted column is wrong on every line with non-ASCII in it).
+const std = @import("std");
+const builtin = @import("builtin");
+const libc = std.c;
+const lsp = @import("lsp.zig");
+
+extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+extern "c" fn setsid() libc.pid_t;
+extern "c" fn access(path: [*:0]const u8, mode: c_int) c_int;
+/// std.posix.getenv is gone in 0.16 and std.process.Environ wants an Io
+extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8;
+extern "c" fn usleep(usec: c_uint) c_int;
+extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
+// test-only libc (the seam's own tests build a real directory tree)
+extern "c" fn mkdtemp(template: [*:0]u8) ?[*:0]u8;
+extern "c" fn mkdir(path: [*:0]const u8, mode: libc.mode_t) c_int;
+extern "c" fn system(cmd: [*:0]const u8) c_int;
+// pthread condattr surface, absent from std.c: what makes the mailbox waits
+// tick on CLOCK_MONOTONIC (linux) or a relative timeout (darwin).
+const pthread_condattr_t = extern struct { data: [8]u8 align(@alignOf(usize)) = @splat(0) };
+extern "c" fn pthread_condattr_init(attr: *pthread_condattr_t) c_int;
+extern "c" fn pthread_condattr_setclock(attr: *pthread_condattr_t, clock: c_int) c_int;
+extern "c" fn pthread_condattr_destroy(attr: *pthread_condattr_t) c_int;
+extern "c" fn pthread_cond_init(cond: *libc.pthread_cond_t, attr: ?*const pthread_condattr_t) c_int;
+extern "c" fn pthread_cond_timedwait_relative_np(cond: *libc.pthread_cond_t, mutex: *libc.pthread_mutex_t, reltime: *const libc.timespec) c_int;
+const CLOCK_MONOTONIC: c_int = 1; // linux ABI; the setclock call is linux-only
+
+const X_OK: c_int = 1;
+const POLLIN: i16 = 0x001;
+const POLLOUT: i16 = 0x004;
+/// linux MSG_NOSIGNAL; darwin has no such flag and gets SO_NOSIGPIPE on the
+/// socket instead (0x1022), set right after socketpair. Both numbers are ABI.
+const msg_nosignal: c_int = if (builtin.os.tag.isDarwin()) 0 else 0x4000;
+const so_nosigpipe: c_int = 0x1022;
+
+/// One language server this client knows how to run. Adding a language is
+/// adding a row; nothing below the table branches on a language.
+const Spec = struct {
+ /// what the message row and `SPC l i` call it
+ name: []const u8,
+ /// argv[0], searched on PATH unless it contains a slash
+ bin: []const u8,
+ args: []const []const u8 = &.{},
+ /// the protocol's `languageId` for didOpen
+ lang: []const u8,
+ /// extensions that route a file here (the seam asks `speaks`)
+ exts: []const []const u8,
+ /// project markers, walked UP from the file: the TOP-MOST directory
+ /// holding one is the root (helix's find_root rule), `.git` the fallback
+ markers: []const []const u8,
+ /// environment override: a binary path/name to use instead of `bin`, or
+ /// empty ("") to disable the spec entirely. How the snapshot harness pins
+ /// a deterministic mock server, and how a user points at a custom build.
+ env: [:0]const u8,
+};
+
+pub const specs = [_]Spec{
+ .{
+ .name = "rust-analyzer",
+ .bin = "rust-analyzer",
+ .lang = "rust",
+ .exts = &.{".rs"},
+ .markers = &.{ "Cargo.toml", "rust-project.json" },
+ .env = "PARDES_LSP_RS",
+ },
+ .{
+ .name = "clangd",
+ .bin = "clangd",
+ .lang = "c",
+ .exts = &.{ ".c", ".h", ".cc", ".cpp", ".hpp", ".cxx", ".hxx" },
+ .markers = &.{ "compile_commands.json", "compile_flags.txt", ".clangd" },
+ .env = "PARDES_LSP_C",
+ },
+ .{
+ .name = "gopls",
+ .bin = "gopls",
+ .lang = "go",
+ .exts = &.{".go"},
+ .markers = &.{ "go.mod", "go.work" },
+ .env = "PARDES_LSP_GO",
+ },
+ .{
+ .name = "typescript-language-server",
+ .bin = "typescript-language-server",
+ .args = &.{"--stdio"},
+ .lang = "typescript",
+ .exts = &.{ ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs" },
+ .markers = &.{ "tsconfig.json", "jsconfig.json", "package.json" },
+ .env = "PARDES_LSP_TS",
+ },
+ .{
+ .name = "pyright",
+ .bin = "pyright-langserver",
+ .args = &.{"--stdio"},
+ .lang = "python",
+ .exts = &.{".py"},
+ .markers = &.{ "pyproject.toml", "setup.py", "requirements.txt" },
+ .env = "PARDES_LSP_PY",
+ },
+};
+
+/// Everything, including the four hierarchy kinds the in-process backend has
+/// no analyser for. Whether one SERVER can answer is a capability question
+/// answered per connection; a kind its server never advertised simply returns
+/// no rows, which the harness reports as CLAIMED-EMPTY per language — honest,
+/// since the claim here is about the protocol, not about every server.
+pub const supports: std.EnumSet(lsp.Kind) = .initFull();
+
+/// Routing: an extension in the table whose spec is not disabled by its env
+/// var. Deliberately does NOT probe for the binary — this runs on the Tab
+/// keystroke. A missing binary is discovered at spawn, disables the spec, and
+/// says so once on the message row; until then Tab in a `.rs` file diverts,
+/// gets "no rows" instantly (the disabled flag short-circuits), and indents
+/// late exactly like any other unanswerable completion.
+pub fn speaks(path: []const u8) bool {
+ return specFor(path) != null;
+}
+
+fn specFor(path: []const u8) ?usize {
+ for (&specs, 0..) |*s, i| {
+ for (s.exts) |e| {
+ if (!std.mem.endsWith(u8, path, e)) continue;
+ if (getenv(s.env)) |v| if (v[0] == 0) return null; // "" disables
+ return i;
+ }
+ }
+ return null;
+}
+
+// Deadlines. Requests are bounded because tty.zig cancels-and-joins the
+// previous worker on a new keypress, so the worst UI stall a wedged wait can
+// cause is one req_ms. Timeouts do NOT kill the server — an indexing
+// rust-analyzer legitimately sits on a `gd` for longer than anyone will wait,
+// and the message row is already narrating why.
+const init_ms = 8_000; // spawn + initialize handshake
+const req_ms = 4_000; // one request/response round trip
+const diag_ms = 1_200; // wait for a publishDiagnostics push after didChange
+const reply_ms = 2_000; // our answers to server-to-client requests
+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 };
+
+/// 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
+/// connections own their memory from the page allocator.
+const sa = std.heap.page_allocator;
+
+/// Which units `character` counts in — see the header note on believing the
+/// server.
+const Enc = enum { utf8, utf16 };
+
+const Doc = struct {
+ uri: []u8,
+ version: u32,
+ /// content hash: a query whose buffer has not moved since the last one
+ /// sends no didChange at all, which is most of what makes warm queries fast
+ hash: u64,
+ /// a didChange the server has not answered with diagnostics yet
+ stale: bool = true,
+};
+
+/// The last publishDiagnostics per file, kept as raw params and re-parsed
+/// against a query's arena. The accumulation IS workspace diagnostics for a
+/// server with no pull support.
+const DiagSet = struct { uri: []u8, body: []u8 };
+
+/// The slice of server capabilities this client changes behaviour on. Silent
+/// kinds (a server with no renameProvider) need no flag — the request errors
+/// and errors render as no rows. These four either pick between two code
+/// paths or gate a second round trip.
+const Caps = struct {
+ enc: Enc = .utf16,
+ /// textDocument/diagnostic (LSP 3.17 pull) — preferred over the push store
+ pull: bool = false,
+ /// workspace/diagnostic
+ pull_workspace: bool = false,
+ call_hier: bool = false,
+ type_hier: bool = false,
+ /// workspace/didChangeWorkspaceFolders is worth sending
+ folders: bool = false,
+};
+
+const State = enum(u8) {
+ /// never spawned — the row every spec starts on
+ off,
+ /// spawned, initialize in flight
+ starting,
+ ready,
+ /// transport broke or the server was declared wedged; next query respawns
+ dead,
+ /// binary missing or two failed handshakes; stays down for the session
+ disabled,
+};
+
+const Conn = struct {
+ mu: libc.pthread_mutex_t = .{},
+ cond: libc.pthread_cond_t = .{},
+ state: State = .off,
+ /// bumped per spawn. A reader thread that sees a different gen than its
+ /// own is reading a corpse and exits; a waiter that sees one stops waiting.
+ gen: u32 = 0,
+ handshake_fails: u8 = 0,
+ /// monotonic ms before which a dead/failed server is not respawned —
+ /// exponential backoff against forking a doomed child per keystroke
+ retry_after_ms: i64 = 0,
+ /// monotonic ms when the last handshake completed — a crash shortly
+ /// after "ready" counts as a handshake failure for backoff purposes
+ ready_at_ms: i64 = 0,
+ /// linux: the zeroed condvar defaults to REALTIME; re-initialized with a
+ /// monotonic condattr before its first wait (under the mutex)
+ cond_monotonic: bool = false,
+ timeouts: u8 = 0,
+ pid: libc.pid_t = -1,
+ sock: c_int = -1,
+ next_id: u32 = 1,
+ caps: Caps = .{},
+ /// the workspace root sent in initialize (sa-owned)
+ root: []u8 = &.{},
+ /// roots added since, via didChangeWorkspaceFolders (sa-owned entries)
+ extra_roots: std.ArrayList([]u8) = .empty,
+ docs: std.ArrayList(Doc) = .empty,
+ diags: std.ArrayList(DiagSet) = .empty,
+ /// the response mailbox: one request outstanding per connection
+ want_id: u32 = 0,
+ resp: ?[]u8 = null,
+ /// active $/progress begins, and the last begin's title for report rows
+ progress: i32 = 0,
+ title: [48]u8 = @splat(0),
+ title_len: u8 = 0,
+
+ fn lock(c: *Conn) void {
+ _ = libc.pthread_mutex_lock(&c.mu);
+ }
+ fn unlock(c: *Conn) void {
+ _ = libc.pthread_mutex_unlock(&c.mu);
+ }
+ fn alive(c: *const Conn) bool {
+ return c.state == .starting or c.state == .ready;
+ }
+};
+
+var conns: [specs.len]Conn = @splat(.{});
+
+// ------------------------------------------------------------- status sink
+
+/// One registered listener for unsolicited state changes; both native shells
+/// register at startup and DEREGISTER before tearing their loop down — the
+/// registry lock is held across the callback, so a null-ing shell cannot race
+/// a reader thread mid-post. The callback must copy `text` before returning.
+var sink_mu: std.atomic.Mutex = .unlocked;
+var sink_ctx: ?*anyopaque = null;
+var sink_cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void = null;
+/// throttle for chatty progress reports, per spec
+var sink_last_ms: [specs.len]i64 = @splat(0);
+var sink_last_text: [specs.len][96]u8 = @splat(@splat(0));
+var sink_last_len: [specs.len]u8 = @splat(0);
+
+pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void {
+ while (!sink_mu.tryLock()) std.atomic.spinLoopHint();
+ defer sink_mu.unlock();
+ sink_ctx = ctx;
+ sink_cb = cb;
+}
+
+const Chat = enum {
+ /// a progress report: at most one per 150ms per server, dropped when it
+ /// repeats the previous text — rust-analyzer emits thousands over a big
+ /// index and the message row repaints per post
+ chatty,
+ /// a state change: starting, ready, exited, errors. Always posted.
+ always,
+};
+
+fn post(si: usize, chat: Chat, comptime fmt: []const u8, args: anytype) void {
+ var buf: [192]u8 = undefined;
+ const text = std.fmt.bufPrint(&buf, fmt, args) catch return;
+ while (!sink_mu.tryLock()) std.atomic.spinLoopHint();
+ defer sink_mu.unlock();
+ const cb = sink_cb orelse return;
+ // Repeating the row that is already showing is never news, whatever the
+ // class — and it is what makes the FINAL state deterministic for the
+ // snapshot harness: however many intermediate reports the throttle let
+ // through, an `.always` end state lands exactly once.
+ const cut = @min(text.len, sink_last_text[si].len);
+ if (std.mem.eql(u8, text[0..cut], sink_last_text[si][0..sink_last_len[si]])) return;
+ if (chat == .chatty) {
+ const now = nowMs();
+ if (now - sink_last_ms[si] < 150) return;
+ sink_last_ms[si] = now;
+ }
+ sink_last_len[si] = @intCast(cut);
+ @memcpy(sink_last_text[si][0..cut], text[0..cut]);
+ cb(sink_ctx, text);
+}
+
+// ------------------------------------------------------------- introspection
+
+const LogEntry = struct {
+ used: bool = false,
+ kind: lsp.Kind = .definition,
+ spec: u8 = 0,
+ us: u64 = 0,
+ rows: usize = 0,
+ err: [24]u8 = @splat(0),
+ err_len: u8 = 0,
+ file: [64]u8 = @splat(0),
+ file_len: u8 = 0,
+};
+const log_cap = 24;
+var log_buf: [log_cap]LogEntry = @splat(.{});
+var log_next: usize = 0;
+var log_total: u64 = 0;
+var log_mu: std.atomic.Mutex = .unlocked;
+
+fn record(si: usize, req: lsp.Req, us: u64, rows: usize, err: []const u8) void {
+ while (!log_mu.tryLock()) std.atomic.spinLoopHint();
+ defer log_mu.unlock();
+ const e = &log_buf[log_next];
+ e.* = .{ .used = true, .kind = req.kind, .spec = @intCast(si), .us = us, .rows = rows };
+ const base = std.fs.path.basename(req.path);
+ e.file_len = @intCast(@min(base.len, e.file.len));
+ @memcpy(e.file[0..e.file_len], base[0..e.file_len]);
+ e.err_len = @intCast(@min(err.len, e.err.len));
+ @memcpy(e.err[0..e.err_len], err[0..e.err_len]);
+ log_next = (log_next + 1) % log_cap;
+ log_total += 1;
+}
+
+fn hideTime() bool {
+ const v = libc.getenv("PARDES_NOTIME") orelse return false;
+ return std.mem.span(v).len != 0;
+}
+
+/// `SPC l w` narration, same shape as the ZLS backend's: threaded through the
+/// real path, so it cannot disagree with what `gd` actually did.
+const Trace = struct {
+ on: bool = false,
+ buf: [16 * 1024]u8 = undefined,
+ len: usize = 0,
+
+ fn note(t: *Trace, comptime fmt: []const u8, args: anytype) void {
+ if (!t.on or t.len == t.buf.len) return;
+ var w: std.Io.Writer = .fixed(t.buf[t.len..]);
+ w.print(fmt ++ "\n", args) catch {};
+ t.len += w.buffered().len;
+ }
+};
+
+// ------------------------------------------------------------------- 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 {
+ _ = gpa;
+ if (req.kind == .status) return status(req, out) catch {};
+
+ var tr: Trace = .{ .on = req.kind == .explain };
+ const si = specFor(req.path) orelse {
+ tr.note("STOP: no language server spec matches {s}", .{
+ if (req.path.len == 0) "a pane with no file" else req.path,
+ });
+ return traceOut(&tr, req, out, 0, 0);
+ };
+ 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;
+ var scratch: std.Io.Writer = .fixed(scratch_buf);
+
+ const t0 = nowUs();
+ var err_name: []const u8 = "";
+ answer(arena, si, req, &scratch, &tr) catch |e| {
+ err_name = @errorName(e);
+ tr.note("ERROR: {s} — the editor shows this as 'no result'", .{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 {};
+}
+
+fn traceOut(tr: *const Trace, req: lsp.Req, out: *std.Io.Writer, rows: usize, us: u64) void {
+ if (req.kind != .explain) return;
+ 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 {};
+ if (hideTime())
+ out.print("\n{d} row(s)\n", .{rows}) catch {}
+ else
+ out.print("\n{d} row(s) in {d}us\n", .{ rows, us }) catch {};
+}
+
+fn answer(arena: std.mem.Allocator, si: usize, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void {
+ const c = &conns[si];
+ c.lock();
+ defer c.unlock();
+ const g = try ensure(c, si, arena, req, tr);
+ run(c, si, arena, req, out, tr) catch |e| {
+ switch (e) {
+ // transport-level: this server is gone; forget it, and the next
+ // query respawns. Gen-checked so a respawn that happened while we
+ // waited is not the one we kill.
+ error.Dead, error.Protocol => shutdownIf(c, g),
+ error.Timeout => {
+ // Slow is only wedged when the server is IDLE. One with
+ // active `$/progress` work (rust-analyzer running cargo
+ // check over a thousand crates) is demonstrably alive, is
+ // narrating its own excuse on the message row, and killing
+ // it would throw the index away right before it pays off.
+ if (c.progress > 0) {
+ c.timeouts = 0;
+ } else {
+ c.timeouts +|= 1;
+ if (c.timeouts >= wedged_strikes and c.gen == g) {
+ post(si, .always, "{s} not answering — restarting", .{specs[si].name});
+ shutdownIf(c, g);
+ }
+ }
+ },
+ error.OutOfMemory, error.NoServer => {},
+ }
+ return e;
+ };
+ c.timeouts = 0;
+}
+
+fn run(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer, tr: *Trace) Err!void {
+ // `explain` narrates the definition query — dispatch below on the
+ // effective kind so the trace follows the code `gd` really runs.
+ const kind: lsp.Kind = if (req.kind == .explain) .definition else req.kind;
+
+ // Both arg-taking kinds are useless without one, and an empty
+ // workspace/symbol query means "every symbol in the project".
+ if ((kind == .rename or kind == .workspace_symbols) and req.arg.len == 0) return;
+
+ var uri: std.ArrayList(u8) = .empty;
+ try uriOf(&uri, arena, req.path);
+ try syncDoc(c, si, arena, uri.items, req.source);
+
+ const pos = posOf(req.source, req.offset, c.caps.enc);
+ var cx: Cx = .{ .arena = arena, .base = req.root, .cur_path = req.path, .cur_src = req.source, .out = out };
+ tr.note("server {s} (pid {d}), root {s}, {s} columns", .{
+ @tagName(c.state), c.pid, c.root, @tagName(c.caps.enc),
+ });
+ const deadline = nowMs() + req_ms;
+
+ switch (kind) {
+ .definition, .declaration, .type_definition, .implementation, .references => {
+ const method = switch (kind) {
+ .definition => "textDocument/definition",
+ .declaration => "textDocument/declaration",
+ .type_definition => "textDocument/typeDefinition",
+ .implementation => "textDocument/implementation",
+ else => "textDocument/references",
+ };
+ var b = try atPos(arena, uri.items, pos);
+ if (kind == .references) try app(&b, arena, ",\"context\":{\"includeDeclaration\":true}");
+ tr.note("-> {s} @ {d}:{d}", .{ method, pos.line + 1, pos.ch + 1 });
+ const result = (try call(c, arena, method, b.items, deadline)) orelse {
+ tr.note("<- empty (no result, an error response, or null)", .{});
+ return;
+ };
+ try locations(&cx, c.caps.enc, result);
+ tr.note("<- {d} row(s)", .{cx.rows});
+ },
+ .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"), "");
+ },
+ .hover => {
+ const b = try atPos(arena, uri.items, pos);
+ const result = (try call(c, arena, "textDocument/hover", b.items, deadline)) orelse return;
+ const contents = get(result, "contents") orelse return;
+ const text = str(contents) orelse str(get(contents, "value")) orelse blk: {
+ for (items(contents)) |m| if (str(m) orelse str(get(m, "value"))) |t| break :blk t;
+ break :blk "";
+ };
+ if (text.len == 0) return;
+ out.writeAll(std.mem.trim(u8, text, " \t\r\n")) catch {};
+ out.writeByte('\n') catch {};
+ },
+ .document_symbols => {
+ var b: std.ArrayList(u8) = .empty;
+ try app(&b, arena, "\"textDocument\":{\"uri\":");
+ 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);
+ },
+ .workspace_symbols => {
+ var b: std.ArrayList(u8) = .empty;
+ try app(&b, arena, "\"query\":");
+ try jstr(&b, arena, req.arg);
+ 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 "");
+ }
+ },
+ .diagnostics => try diagnostics(c, arena, &cx, uri.items, deadline),
+ .workspace_diagnostics => try workspaceDiagnostics(c, arena, &cx, deadline),
+ .rename => {
+ var b = try atPos(arena, uri.items, pos);
+ try app(&b, arena, ",\"newName\":");
+ try jstr(&b, arena, req.arg);
+ const result = (try call(c, arena, "textDocument/rename", b.items, deadline)) orelse return;
+ try renameEdits(&cx, c.caps.enc, uri.items, result, req.source);
+ },
+ .format => {
+ var b: std.ArrayList(u8) = .empty;
+ try app(&b, arena, "\"textDocument\":{\"uri\":");
+ try jstr(&b, arena, uri.items);
+ try app(&b, arena, "},\"options\":{\"tabSize\":4,\"insertSpaces\":true}");
+ const result = (try call(c, arena, "textDocument/formatting", b.items, deadline)) orelse return;
+ try formatEdits(&cx, c.caps.enc, result, req.source);
+ },
+ .code_action => {
+ var b: std.ArrayList(u8) = .empty;
+ try app(&b, arena, "\"textDocument\":{\"uri\":");
+ try jstr(&b, arena, uri.items);
+ try b.print(arena, "}},\"range\":{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}},\"context\":{{\"diagnostics\":[]}}", .{ pos.line, pos.ch, pos.line, pos.ch });
+ 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));
+ cx.rows += 1;
+ }
+ },
+ .completion => {
+ var b = try atPos(arena, uri.items, pos);
+ try app(&b, arena, ",\"context\":{\"triggerKind\":1}");
+ const result = (try call(c, arena, "textDocument/completion", b.items, deadline)) orelse return;
+ const list = if (get(result, "items")) |it| items(it) else items(result);
+ const here = lsp.rel(cx.base, cx.cur_path);
+ var n: usize = 0;
+ for (list) |item| {
+ if (n >= 100) break;
+ const label = str(get(item, "label")) orelse continue;
+ const detail = str(get(item, "detail")) orelse "";
+ var text: std.ArrayList(u8) = .empty;
+ try app(&text, arena, label);
+ if (detail.len > 0) {
+ try app(&text, arena, " ");
+ try app(&text, arena, flat(arena, detail));
+ }
+ // Every row carries the ASKING position: a completion item has
+ // 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);
+ n += 1;
+ }
+ },
+ .incoming_calls => try hierarchy(c, si, arena, &cx, uri.items, pos, deadline, .incoming, tr),
+ .outgoing_calls => try hierarchy(c, si, arena, &cx, uri.items, pos, deadline, .outgoing, tr),
+ .supertypes => try hierarchy(c, si, arena, &cx, uri.items, pos, deadline, .supers, tr),
+ .subtypes => try hierarchy(c, si, arena, &cx, uri.items, pos, deadline, .subs, tr),
+ .status, .explain => unreachable,
+ }
+}
+
+// -------------------------------------------------------- kind sub-handlers
+
+/// 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"), "");
+ 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, "");
+ } else {
+ 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 {
+ 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);
+ } else {
+ emitRange(cx, enc, uri, get(sym, "selectionRange") orelse get(sym, "range"), name);
+ }
+ if (get(sym, "children")) |kids| walkSymbols(cx, enc, uri, kids, depth + 1);
+ }
+}
+
+/// Pull when the server does (LSP 3.17), the push store otherwise. The store
+/// path waits briefly for a publish that postdates the didChange we just
+/// sent, so `]d` right after an edit sees the new truth, not the old one.
+fn diagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, deadline: i64) Err!void {
+ if (c.caps.pull) {
+ var b: std.ArrayList(u8) = .empty;
+ try app(&b, arena, "\"textDocument\":{\"uri\":");
+ 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);
+ return;
+ }
+ var stale = true;
+ for (c.docs.items) |d| if (std.mem.eql(u8, d.uri, uri)) {
+ stale = d.stale;
+ break;
+ };
+ if (stale) waitFresh(c, uri, nowMs() + diag_ms);
+ renderStore(c, arena, cx, uri);
+}
+
+fn workspaceDiagnostics(c: *Conn, arena: std.mem.Allocator, cx: *Cx, deadline: i64) Err!void {
+ if (c.caps.pull_workspace) {
+ 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);
+ }
+ return;
+ }
+ 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 {
+ const sev = num(get(dg, "severity")) orelse 1;
+ const label: []const u8 = switch (sev) {
+ 1 => "error",
+ 2 => "warning",
+ 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);
+}
+
+fn renderStore(c: *Conn, arena: std.mem.Allocator, cx: *Cx, only_uri: ?[]const u8) 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);
+ }
+}
+
+/// Block (mutex released) until the reader marks `uri` fresh or the deadline
+/// passes. The reader broadcasts on every publishDiagnostics.
+fn waitFresh(c: *Conn, uri: []const u8, deadline: i64) void {
+ const g = c.gen;
+ while (c.gen == g and c.alive()) {
+ var fresh = false;
+ for (c.docs.items) |d| if (std.mem.eql(u8, d.uri, uri)) {
+ fresh = !d.stale;
+ break;
+ };
+ if (fresh) return;
+ if (!timedWait(c, deadline)) return;
+ }
+}
+
+/// A WorkspaceEdit that stays inside the asked-about file becomes `@put`
+/// records the core applies as one undo transaction; anything wider (a real
+/// multi-file rename, file creates/renames) becomes a PREVIEW — one location
+/// row per would-be edit, in the same buffer `gr` fills, because silently
+/// applying a fraction of a workspace rename would be worse than either.
+fn renameEdits(cx: *Cx, enc: Enc, self_uri: []const u8, result: std.json.Value, src: []const u8) Err!void {
+ var edits: std.ArrayList(PutEdit) = .empty;
+ var foreign = false;
+
+ if (get(result, "documentChanges")) |dcs| {
+ for (items(dcs)) |dc| {
+ if (get(dc, "kind") != null) {
+ foreign = true; // create/rename/delete file operations
+ continue;
+ }
+ const u = str(get(get(dc, "textDocument"), "uri")) orelse continue;
+ const in_self = std.mem.eql(u8, u, self_uri);
+ if (!in_self) foreign = true;
+ for (items(get(dc, "edits"))) |ed| {
+ if (in_self) {
+ // one malformed edit poisons the WHOLE mutating response:
+ // applying the valid remainder would be a partial edit set
+ 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 if (get(result, "changes")) |ch| if (ch == .object) {
+ var it = ch.object.iterator();
+ while (it.next()) |e| {
+ const in_self = std.mem.eql(u8, e.key_ptr.*, self_uri);
+ if (!in_self) foreign = true;
+ for (items(e.value_ptr.*)) |ed| {
+ if (in_self) {
+ 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 ""));
+ }
+ }
+ };
+
+ if (foreign) {
+ // 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));
+ cx.rows += 1;
+ }
+ return;
+ }
+ sortEdits(edits.items);
+ for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text);
+}
+
+/// TextEdit[] from formatting is by definition about the current document:
+/// straight to sorted `@put` records.
+fn formatEdits(cx: *Cx, enc: Enc, result: std.json.Value, src: []const u8) Err!void {
+ var edits: std.ArrayList(PutEdit) = .empty;
+ for (items(result)) |ed| {
+ // fail the whole response on the first malformed TextEdit — a subset
+ // of a format is not a format
+ 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;
+ }
+ sortEdits(edits.items);
+ for (edits.items) |ed| lsp.put(cx.out, ed.start, ed.end, ed.text);
+}
+
+/// One would-be buffer mutation, on its way to an `@put` record.
+const PutEdit = struct { start: usize, end: usize, text: []const u8 };
+
+fn sortEdits(edits: []PutEdit) void {
+ std.mem.sort(PutEdit, edits, {}, struct {
+ fn lt(_: void, a: PutEdit, b: PutEdit) bool {
+ return a.start < b.start;
+ }
+ }.lt);
+}
+
+const Hier = enum { incoming, outgoing, supers, subs };
+
+/// The two-step hierarchy kinds: prepare at the cursor, then follow every
+/// item the server returned (usually one). Gated on the server capability so
+/// an old server costs zero round trips.
+fn hierarchy(c: *Conn, si: usize, arena: std.mem.Allocator, cx: *Cx, uri: []const u8, pos: Pos, deadline: i64, h: Hier, tr: *Trace) Err!void {
+ const call_side = h == .incoming or h == .outgoing;
+ if (call_side and !c.caps.call_hier) {
+ tr.note("STOP: {s} does not advertise callHierarchyProvider", .{specs[si].name});
+ return;
+ }
+ if (!call_side and !c.caps.type_hier) {
+ tr.note("STOP: {s} does not advertise typeHierarchyProvider", .{specs[si].name});
+ return;
+ }
+ const prepare: []const u8 = if (call_side) "textDocument/prepareCallHierarchy" else "textDocument/prepareTypeHierarchy";
+ const follow: []const u8 = switch (h) {
+ .incoming => "callHierarchy/incomingCalls",
+ .outgoing => "callHierarchy/outgoingCalls",
+ .supers => "typeHierarchy/supertypes",
+ .subs => "typeHierarchy/subtypes",
+ };
+ const b = try atPos(arena, uri, pos);
+ const prepared = (try call(c, arena, prepare, b.items, deadline)) orelse return;
+ for (items(prepared)) |item| {
+ // the item goes back VERBATIM — servers hide resolution state in
+ // `data` and a re-serialized subset would come back unresolvable.
+ // But only if it can round-trip: Stringify panics past its nesting
+ // limit, so a server that nests a bomb in `data` gets no rows for
+ // that item, not a dead editor.
+ if (jsonDepth(item, 0) > 96) continue;
+ var body: std.ArrayList(u8) = .empty;
+ try app(&body, arena, "\"item\":");
+ const item_json = std.json.Stringify.valueAlloc(arena, item, .{}) catch return error.OutOfMemory;
+ try app(&body, arena, item_json);
+ const result = (try call(c, arena, follow, body.items, deadline)) orelse return;
+ for (items(result)) |entry| switch (h) {
+ .incoming => {
+ // one row per CALL SITE, under the caller's name — that is
+ // what n/N want to walk
+ const from = get(entry, "from") orelse continue;
+ const fu = str(get(from, "uri")) orelse continue;
+ 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);
+ },
+ .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));
+ },
+ .supers, .subs => emitRange(cx, c.caps.enc, str(get(entry, "uri")) orelse continue, get(entry, "selectionRange") orelse get(entry, "range"), hierText(cx.arena, entry)),
+ };
+ }
+}
+
+/// Depth of a parsed json value, saturating just past `at`'s caller's cap —
+/// the guard that keeps server-controlled nesting away from Stringify's
+/// fixed-depth assertion.
+fn jsonDepth(v: std.json.Value, at: u32) u32 {
+ if (at > 96) return at;
+ return switch (v) {
+ .object => |o| blk: {
+ var deepest = at;
+ var it = o.iterator();
+ while (it.next()) |e| deepest = @max(deepest, jsonDepth(e.value_ptr.*, at + 1));
+ break :blk deepest;
+ },
+ .array => |a| blk: {
+ var deepest = at;
+ for (a.items) |e| deepest = @max(deepest, jsonDepth(e, at + 1));
+ break :blk deepest;
+ },
+ else => at,
+ };
+}
+
+fn hierText(arena: std.mem.Allocator, item: std.json.Value) []const u8 {
+ const name = str(get(item, "name")) orelse "";
+ const detail = str(get(item, "detail")) orelse return name;
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ name, flat(arena, detail) }) catch name;
+}
+
+// ------------------------------------------------------------ row rendering
+
+/// Files read while answering ONE query, so a references list over a handful
+/// of files reads each once, not once per row. The current buffer never needs
+/// reading: `req.source` IS its text, unsaved edits included.
+const Cx = struct {
+ arena: std.mem.Allocator,
+ base: []const u8,
+ cur_path: []const u8,
+ cur_src: []const u8,
+ out: *std.Io.Writer,
+ rows: usize = 0,
+ files: std.ArrayList(struct { path: []const u8, text: []const u8 }) = .empty,
+
+ fn text(cx: *Cx, path: []const u8) ?[]const u8 {
+ if (std.mem.eql(u8, path, cx.cur_path)) return cx.cur_src;
+ for (cx.files.items) |f| if (std.mem.eql(u8, f.path, path)) return f.text;
+ if (cx.files.items.len >= 32) return null;
+ var pathbuf: [4096]u8 = undefined;
+ const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return null;
+ const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return null;
+ defer _ = libc.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ var chunk: [64 * 1024]u8 = undefined;
+ while (buf.items.len < max_doc_bytes) {
+ const n = libc.read(fd, &chunk, chunk.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return null;
+ }
+ if (n == 0) break;
+ buf.appendSlice(cx.arena, chunk[0..@intCast(n)]) catch return null;
+ }
+ cx.files.append(cx.arena, .{ .path = path, .text = buf.items }) catch return null;
+ return buf.items;
+ }
+};
+
+/// One row from an LSP (uri, Range). The uri becomes a real path (percent-
+/// 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 {
+ 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
+ const r = rangeOf(range) orelse return;
+ const src = cx.text(path);
+
+ var bol: usize = 0;
+ var eol: usize = 0;
+ if (src) |t| {
+ var n: u32 = 0;
+ while (n < r.sl) : (n += 1) {
+ bol = (std.mem.indexOfScalarPos(u8, t, bol, '\n') orelse {
+ bol = t.len;
+ break;
+ }) + 1;
+ }
+ eol = std.mem.indexOfScalarPos(u8, t, @min(bol, t.len), '\n') orelse t.len;
+ }
+ const lntext: []const u8 = if (src) |t| t[@min(bol, t.len)..@min(eol, t.len)] else "";
+
+ const col = colBytes(lntext, r.sc, enc);
+ const shown = lsp.rel(cx.base, path);
+ const rowtext = if (note.len > 0) note else lntext;
+ if (r.el == r.sl and r.ec > r.sc) {
+ // spanRow wants the protocol's EXCLUSIVE end as a 1-based inclusive
+ // 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);
+ } else {
+ lsp.row(cx.out, shown, r.sl, col, rowtext);
+ }
+ cx.rows += 1;
+}
+
+/// utf-16 code units -> byte column within one line; identity for utf-8.
+fn colBytes(lntext: []const u8, ch: u32, enc: Enc) usize {
+ if (enc == .utf8 or lntext.len == 0) return ch;
+ var units: u32 = 0;
+ var i: usize = 0;
+ while (i < lntext.len and units < ch) {
+ const l = std.unicode.utf8ByteSequenceLength(lntext[i]) catch 1;
+ units += if (l == 4) 2 else 1;
+ i += l;
+ }
+ return i;
+}
+
+/// The current-buffer byte column for a (line, ch) position — used only for
+/// rows that point at the asking position itself.
+fn byteCol(src: []const u8, line: u32, ch: u32, enc: Enc) usize {
+ var bol: usize = 0;
+ var n: u32 = 0;
+ while (n < line) : (n += 1)
+ bol = (std.mem.indexOfScalarPos(u8, src, bol, '\n') orelse return ch) + 1;
+ const eol = std.mem.indexOfScalarPos(u8, src, bol, '\n') orelse src.len;
+ return colBytes(src[bol..eol], ch, enc);
+}
+
+/// Whole-document Range -> half-open byte span for a MUTATING record.
+/// STRICT: null unless both endpoints denote positions that exist in the
+/// document — for edits, a clamped range is a wrong edit, and wrong edits
+/// fail closed (the render paths keep their forgiving conversions; a row a
+/// column off is an inconvenience, a splice a column off is corruption).
+fn byteSpan(src: []const u8, range: ?std.json.Value, enc: Enc) ?struct { start: usize, end: usize } {
+ const r = rangeOf(range) orelse return null;
+ const start = strictOffset(src, r.sl, r.sc, enc) orelse return null;
+ const end = strictOffset(src, r.el, r.ec, enc) orelse return null;
+ if (end < start) return null;
+ return .{ .start = start, .end = end };
+}
+
+/// (line, character) -> byte offset, or null when the line does not exist or
+/// the character runs past its end. End-of-line (character == line length) is
+/// a real position — that is where an insert-at-EOL lands.
+fn strictOffset(src: []const u8, line: u32, ch: u32, enc: Enc) ?usize {
+ var bol: usize = 0;
+ var n: u32 = 0;
+ while (n < line) : (n += 1)
+ bol = (std.mem.indexOfScalarPos(u8, src, bol, '\n') orelse return null) + 1;
+ const eol = std.mem.indexOfScalarPos(u8, src, bol, '\n') orelse src.len;
+ if (enc == .utf8) {
+ if (ch > eol - bol) return null;
+ return bol + ch;
+ }
+ var units: u32 = 0;
+ var i: usize = bol;
+ while (i < eol and units < ch) {
+ const l = std.unicode.utf8ByteSequenceLength(src[i]) catch 1;
+ units += if (l == 4) 2 else 1;
+ i += l;
+ }
+ if (units < ch) return null;
+ return i;
+}
+
+/// The RENDER-path sibling of strictOffset: clamps instead of failing,
+/// because a row is presentation, not mutation.
+fn offsetAt(src: []const u8, line: u32, ch: u32, enc: Enc) usize {
+ var bol: usize = 0;
+ var n: u32 = 0;
+ while (n < line) : (n += 1)
+ bol = (std.mem.indexOfScalarPos(u8, src, bol, '\n') orelse return src.len) + 1;
+ const eol = std.mem.indexOfScalarPos(u8, src, bol, '\n') orelse src.len;
+ return bol + @min(colBytes(src[bol..eol], ch, enc), eol - bol);
+}
+
+/// Prose squashed onto the one line a row is: newlines and tabs to spaces,
+/// cut to a width a pane can show.
+fn flat(arena: std.mem.Allocator, s: []const u8) []const u8 {
+ var buf: std.ArrayList(u8) = .empty;
+ var sp = false;
+ for (s) |ch| {
+ if (ch == '\n' or ch == '\r' or ch == '\t' or ch == 0) {
+ if (!sp and buf.items.len > 0) buf.append(arena, ' ') catch break;
+ sp = true;
+ } else {
+ buf.append(arena, ch) catch break;
+ sp = false;
+ }
+ if (buf.items.len >= 200) break;
+ }
+ return std.mem.trim(u8, buf.items, " ");
+}
+
+// ----------------------------------------------------------- the connection
+
+/// Spawn-or-return, and tell an existing server about a new project root.
+/// Returns the generation the caller's whole query is bound to.
+fn ensure(c: *Conn, si: usize, arena: std.mem.Allocator, req: lsp.Req, tr: *Trace) Err!u32 {
+ if (c.state == .disabled) {
+ tr.note("STOP: {s} is disabled for this session (no binary; set {s})", .{ specs[si].name, specs[si].env });
+ return error.NoServer;
+ }
+ if (c.alive()) {
+ const root = rootOf(req.root, specs[si].markers);
+ if (!std.mem.eql(u8, root, c.root)) {
+ var known = false;
+ for (c.extra_roots.items) |r| if (std.mem.eql(u8, r, root)) {
+ known = true;
+ break;
+ };
+ if (!known and c.caps.folders) {
+ var b: std.ArrayList(u8) = .empty;
+ try app(&b, arena, "{\"jsonrpc\":\"2.0\",\"method\":\"workspace/didChangeWorkspaceFolders\",\"params\":{\"event\":{\"added\":[{\"name\":\"root\",\"uri\":\"");
+ try uriOf(&b, arena, root);
+ try app(&b, arena, "\"}],\"removed\":[]}}}");
+ try frame(c.sock, b.items, nowMs() + reply_ms);
+ const owned = sa.dupe(u8, root) catch return error.OutOfMemory;
+ c.extra_roots.append(sa, owned) catch sa.free(owned);
+ }
+ }
+ return c.gen;
+ }
+
+ // A recent failed spawn/handshake holds the fork back; the message row
+ // already said when the next try is due.
+ if (nowMs() < c.retry_after_ms) {
+ tr.note("STOP: {s} failed recently; retry due in {d}ms", .{ specs[si].name, c.retry_after_ms - nowMs() });
+ return error.NoServer;
+ }
+
+ // Look before forking; a missing binary must cost one probe and one
+ // message, not a doomed fork per keystroke.
+ var exe_buf: [4096:0]u8 = undefined;
+ const exe = binOf(&specs[si], &exe_buf) orelse {
+ c.state = .disabled;
+ post(si, .always, "{s} not found — install it or set {s}", .{ specs[si].name, specs[si].env });
+ tr.note("STOP: no {s} binary on PATH (override: {s})", .{ specs[si].bin, specs[si].env });
+ return error.NoServer;
+ };
+
+ const root = rootOf(req.root, specs[si].markers);
+ var root_buf: [4096:0]u8 = undefined;
+ const root_z = std.fmt.bufPrintSentinel(&root_buf, "{s}", .{root}, 0) catch return error.NoServer;
+ // Owned BEFORE anything spawns: the reader thread is the socket's only
+ // closer once it exists, so nothing fallible may sit between fork and
+ // reader-spawn — an abort there would strand the fd.
+ const owned_root = sa.dupe(u8, root) catch return error.OutOfMemory;
+
+ var sv: [2]libc.fd_t = undefined;
+ if (libc.socketpair(libc.AF.UNIX, libc.SOCK.STREAM | libc.SOCK.CLOEXEC, 0, &sv) != 0) {
+ sa.free(owned_root);
+ return error.NoServer;
+ }
+ if (comptime builtin.os.tag.isDarwin()) {
+ const one: c_int = 1;
+ _ = libc.setsockopt(sv[0], libc.SOL.SOCKET, so_nosigpipe, @ptrCast(&one), @sizeOf(c_int));
+ }
+ const pid = libc.fork();
+ if (pid < 0) {
+ _ = libc.close(sv[0]);
+ _ = libc.close(sv[1]);
+ sa.free(owned_root);
+ return error.NoServer;
+ }
+ if (pid == 0) {
+ // Between fork and exec, in a process with threads: only async-
+ // 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
+ _ = libc.dup2(sv[1], 0);
+ _ = libc.dup2(sv[1], 1);
+ const devnull = libc.open("/dev/null", .{ .ACCMODE = .WRONLY });
+ if (devnull >= 0) _ = libc.dup2(devnull, 2);
+ var fd: c_int = 3;
+ while (fd < 1024) : (fd += 1) _ = libc.close(fd);
+ _ = chdir(root_z.ptr);
+ var argv: [8:null]?[*:0]const u8 = @splat(null);
+ argv[0] = exe.ptr;
+ // args are comptime literals; the buffers live until execv
+ var argbufs: [6][64:0]u8 = undefined;
+ for (specs[si].args, 0..) |a, i| {
+ if (i >= argbufs.len) break;
+ const z = std.fmt.bufPrintSentinel(&argbufs[i], "{s}", .{a}, 0) catch break;
+ argv[1 + i] = z.ptr;
+ }
+ _ = execv(exe.ptr, &argv);
+ _exit(127);
+ }
+ _ = libc.close(sv[1]);
+
+ // the slate the old generation may have left (a self-died server skips
+ // shutdownIf) must not leak into the new one: a stale doc entry would
+ // suppress the didOpen the new server never got
+ c.gen +%= 1;
+ const g = c.gen;
+ c.pid = pid;
+ c.sock = sv[0];
+ c.state = .starting;
+ c.caps = .{};
+ c.next_id = 1;
+ c.want_id = 0;
+ if (c.resp) |r| sa.free(r);
+ c.resp = null;
+ c.progress = 0;
+ c.timeouts = 0;
+ forgetDocs(c);
+ if (c.root.len > 0) sa.free(c.root);
+ c.root = owned_root;
+
+ const th = std.Thread.spawn(.{}, reader, .{ si, g, sv[0] }) catch {
+ // no reader means nobody would ever close the fd our way; do it here,
+ // before anything else can see the conn
+ _ = libc.close(sv[0]);
+ _ = libc.kill(pid, .KILL);
+ _ = libc.waitpid(pid, null, 0);
+ c.state = .dead;
+ c.sock = -1;
+ return error.NoServer;
+ };
+ th.detach();
+
+ post(si, .always, "{s} starting — {s}", .{ specs[si].name, root });
+ tr.note("spawned {s} (pid {d}) at {s}", .{ specs[si].name, pid, root });
+
+ handshake(c, arena, root) catch |e| {
+ shutdownIf(c, g);
+ // NOT a session disable. A handshake that misses the deadline is
+ // routinely environmental — a rustup shim deciding to download the
+ // project's pinned toolchain before launching the real server was
+ // the case that taught this — and it heals by itself. What must not
+ // happen is a fork per keystroke while it heals, so failures back
+ // off on backoffMs's schedule, reset by the next success.
+ c.handshake_fails +|= 1;
+ const wait = backoffMs(c.handshake_fails);
+ c.retry_after_ms = nowMs() + wait;
+ post(si, .always, "{s} not answering — retrying in {d}s", .{
+ specs[si].name, @divTrunc(wait, 1000),
+ });
+ return switch (e) {
+ error.OutOfMemory => error.OutOfMemory,
+ else => error.NoServer,
+ };
+ };
+ c.handshake_fails = 0;
+ c.retry_after_ms = 0;
+ c.ready_at_ms = nowMs();
+ c.state = .ready;
+ post(si, .always, "{s} ready — {s}", .{ specs[si].name, root });
+ return g;
+}
+
+fn handshake(c: *Conn, arena: std.mem.Allocator, root: []const u8) Err!void {
+ var b: std.ArrayList(u8) = .empty;
+ try b.print(arena, "\"processId\":{d},\"clientInfo\":{{\"name\":\"pardes\"}},\"rootUri\":\"", .{@as(u32, @bitCast(libc.getpid()))});
+ try uriOf(&b, arena, root);
+ try app(&b, arena, "\",\"workspaceFolders\":[{\"name\":\"root\",\"uri\":\"");
+ try uriOf(&b, arena, root);
+ try app(&b, arena, "\"");
+ try app(&b, arena,
+ \\}],"capabilities":{"general":{"positionEncodings":["utf-8","utf-16"]},
+ \\"window":{"workDoneProgress":true},
+ \\"workspace":{"configuration":true,"workspaceFolders":true,"symbol":{},
+ \\"diagnostics":{"refreshSupport":false}},
+ \\"textDocument":{"synchronization":{"dynamicRegistration":false,"willSave":false,"didSave":false},
+ \\"publishDiagnostics":{"relatedInformation":false},
+ \\"diagnostic":{"dynamicRegistration":false,"relatedDocumentSupport":false},
+ \\"hover":{"contentFormat":["plaintext","markdown"]},
+ \\"definition":{"linkSupport":true},"declaration":{"linkSupport":true},
+ \\"typeDefinition":{"linkSupport":true},"implementation":{"linkSupport":true},
+ \\"references":{},"documentHighlight":{},
+ \\"documentSymbol":{"hierarchicalDocumentSymbolSupport":true},
+ \\"formatting":{},"rename":{"prepareSupport":false},
+ \\"completion":{"completionItem":{"snippetSupport":false,"documentationFormat":["plaintext"]}},
+ \\"callHierarchy":{},"typeHierarchy":{},
+ \\"codeAction":{"codeActionLiteralSupport":{"codeActionKind":{"valueSet":[]}}}},
+ \\"experimental":{"serverStatusNotification":true}}
+ );
+ // strip the literal's newlines: legal JSON either way, but the frame
+ // length must match what is sent
+ var body: std.ArrayList(u8) = .empty;
+ for (b.items) |ch| if (ch != '\n') try body.append(arena, ch);
+
+ const deadline = nowMs() + init_ms;
+ const reply = (try call(c, arena, "initialize", body.items, deadline)) orelse return error.Protocol;
+
+ const caps = get(reply, "capabilities");
+ c.caps.enc = if (std.mem.eql(u8, str(get(caps, "positionEncoding")) orelse "utf-16", "utf-8")) .utf8 else .utf16;
+ if (get(caps, "diagnosticProvider")) |dp| {
+ c.caps.pull = provider(dp);
+ c.caps.pull_workspace = if (get(dp, "workspaceDiagnostics")) |w| w == .bool and w.bool else false;
+ }
+ c.caps.call_hier = provider(get(caps, "callHierarchyProvider"));
+ c.caps.type_hier = provider(get(caps, "typeHierarchyProvider"));
+ c.caps.folders = if (get(get(get(caps, "workspace"), "workspaceFolders"), "supported")) |s| s == .bool and s.bool else false;
+
+ try frame(c.sock, "{\"jsonrpc\":\"2.0\",\"method\":\"initialized\",\"params\":{}}", deadline);
+}
+
+/// A server capability that may be `true`, an options object, or absent.
+fn provider(v: ?std.json.Value) bool {
+ const o = v orelse return false;
+ return switch (o) {
+ .bool => |b| b,
+ .object => true,
+ else => false,
+ };
+}
+
+/// didOpen the first time a file is seen, didChange when its bytes moved,
+/// nothing when they did not — the common case between two presses of gd.
+fn syncDoc(c: *Conn, si: usize, arena: std.mem.Allocator, uri: []const u8, src: []const u8) Err!void {
+ const hash = std.hash.Wyhash.hash(0, src);
+ var doc: ?*Doc = null;
+ for (c.docs.items) |*d| if (std.mem.eql(u8, d.uri, uri)) {
+ doc = d;
+ break;
+ };
+ if (doc) |d| if (d.hash == hash) return;
+
+ var b: std.ArrayList(u8) = .empty;
+ if (doc) |d| {
+ d.version += 1;
+ d.hash = hash;
+ d.stale = true;
+ try b.print(arena, "{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{{\"textDocument\":{{\"uri\":", .{});
+ try jstr(&b, arena, uri);
+ try b.print(arena, ",\"version\":{d}}},\"contentChanges\":[{{\"text\":", .{d.version});
+ try jstr(&b, arena, src);
+ try app(&b, arena, "}]}}");
+ } else {
+ if (c.docs.items.len >= 256) return; // a session does not open this many
+ const owned = sa.dupe(u8, uri) catch return error.OutOfMemory;
+ c.docs.append(sa, .{ .uri = owned, .version = 1, .hash = hash }) catch {
+ sa.free(owned);
+ return error.OutOfMemory;
+ };
+ try app(&b, arena, "{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{\"textDocument\":{\"uri\":");
+ try jstr(&b, arena, uri);
+ try b.print(arena, ",\"languageId\":\"{s}\",\"version\":1,\"text\":", .{specs[si].lang});
+ try jstr(&b, arena, src);
+ try app(&b, arena, "}}}");
+ }
+ try frame(c.sock, b.items, nowMs() + req_ms);
+}
+
+/// Send a request and wait on the mailbox. Returns the parsed `result`, or
+/// null for an error response / null result — both are legal "no answer".
+/// Runs with the conn mutex held; the mutex is released inside the wait.
+fn call(c: *Conn, arena: std.mem.Allocator, method: []const u8, params: []const u8, deadline: i64) Err!?std.json.Value {
+ const g = c.gen;
+ const id = c.next_id;
+ c.next_id +%= 1;
+
+ var b: std.ArrayList(u8) = .empty;
+ try b.print(arena, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"method\":\"{s}\",\"params\":{{", .{ id, method });
+ try app(&b, arena, params);
+ try app(&b, arena, "}}");
+
+ if (c.resp) |r| sa.free(r);
+ c.resp = null;
+ c.want_id = id;
+ defer c.want_id = 0;
+
+ try frame(c.sock, b.items, deadline);
+
+ while (c.resp == null and c.gen == g and c.alive()) {
+ if (!timedWait(c, deadline)) break;
+ }
+ if (c.gen != g or !c.alive()) return error.Dead;
+ const raw = c.resp orelse {
+ // give the server leave to abandon the work we stopped waiting for
+ var cb: std.ArrayList(u8) = .empty;
+ cb.print(arena, "{{\"jsonrpc\":\"2.0\",\"method\":\"$/cancelRequest\",\"params\":{{\"id\":{d}}}}}", .{id}) catch return error.Timeout;
+ frame(c.sock, cb.items, nowMs() + reply_ms) catch {};
+ return error.Timeout;
+ };
+ c.resp = null;
+ defer sa.free(raw);
+ const v = std.json.parseFromSliceLeaky(std.json.Value, arena, raw, .{}) catch return error.Protocol;
+ const result = get(v, "result") orelse return null;
+ if (result == .null) return null;
+ return result;
+}
+/// One bounded cond wait. False when the deadline has passed.
+///
+/// The deadline arithmetic everywhere here is CLOCK_MONOTONIC, so the wait
+/// must be too: a REALTIME abstime plus an NTP step backwards would park a
+/// worker far past `req_ms`, violating the seam's bounded-wait promise. On
+/// linux the condvar is lazily initialized with a monotonic condattr; darwin
+/// has no setclock but has a RELATIVE wait, which never consults the wall
+/// clock at all.
+fn timedWait(c: *Conn, deadline: i64) bool {
+ const left = deadline - nowMs();
+ if (left <= 0) return false;
+ const wait_ms = @min(left, 500); // tick so gen/state changes are noticed
+ if (comptime builtin.os.tag.isDarwin()) {
+ const rel: libc.timespec = .{
+ .sec = @divTrunc(wait_ms, 1000),
+ .nsec = @rem(wait_ms, 1000) * 1_000_000,
+ };
+ _ = pthread_cond_timedwait_relative_np(&c.cond, &c.mu, &rel);
+ return true;
+ }
+ if (!c.cond_monotonic) {
+ // lazily re-initialize the zeroed (REALTIME) condvar with a
+ // monotonic clock; under the mutex, and only before the first wait,
+ // so no thread can be parked on the old one
+ var attr: pthread_condattr_t = undefined;
+ if (pthread_condattr_init(&attr) == 0) {
+ _ = pthread_condattr_setclock(&attr, CLOCK_MONOTONIC);
+ _ = pthread_cond_init(&c.cond, &attr);
+ _ = pthread_condattr_destroy(&attr);
+ }
+ c.cond_monotonic = true;
+ }
+ var now: libc.timespec = undefined;
+ _ = libc.clock_gettime(.MONOTONIC, &now);
+ var abs: libc.timespec = .{
+ .sec = now.sec + @divTrunc(wait_ms, 1000),
+ .nsec = now.nsec + @rem(wait_ms, 1000) * 1_000_000,
+ };
+ if (abs.nsec >= 1_000_000_000) {
+ abs.sec += 1;
+ abs.nsec -= 1_000_000_000;
+ }
+ _ = libc.pthread_cond_timedwait(&c.cond, &c.mu, &abs);
+ return true;
+}
+
+/// Forget a server, but only the generation the caller actually used — a
+/// respawn that happened during the caller's wait must not be killed for its
+/// predecessor's crimes. Mutex held. The reader closes the fd; this only
+/// shuts the transport down and reaps the child.
+fn shutdownIf(c: *Conn, g: u32) void {
+ if (c.gen != g or c.sock < 0) return;
+ _ = libc.shutdown(c.sock, 2); // SHUT_RDWR: the reader sees EOF and closes
+ reap(c.pid);
+ c.sock = -1;
+ c.pid = -1;
+ if (c.state != .disabled) c.state = .dead;
+ forgetDocs(c);
+ if (c.resp) |r| sa.free(r);
+ c.resp = null;
+ _ = libc.pthread_cond_broadcast(&c.cond);
+}
+
+/// TERM, a bounded grace, then KILL. Never blocks unboundedly: a reaped or
+/// foreign pid answers -1 immediately.
+fn reap(pid: libc.pid_t) void {
+ if (pid <= 0) return;
+ _ = libc.kill(pid, .TERM);
+ var tries: u8 = 0;
+ while (tries < 20) : (tries += 1) {
+ const w = libc.waitpid(pid, null, libc.W.NOHANG);
+ if (w == pid or w < 0) return;
+ _ = usleep(10_000);
+ }
+ _ = libc.kill(pid, .KILL);
+ _ = libc.waitpid(pid, null, 0);
+}
+
+/// Free every per-document accumulation. Mutex held.
+fn forgetDocs(c: *Conn) void {
+ for (c.extra_roots.items) |r| sa.free(r);
+ c.extra_roots.clearRetainingCapacity();
+ for (c.docs.items) |d| sa.free(d.uri);
+ c.docs.clearRetainingCapacity();
+ for (c.diags.items) |d| {
+ sa.free(d.uri);
+ sa.free(d.body);
+ }
+ c.diags.clearRetainingCapacity();
+}
+
+// ------------------------------------------------------------ reader thread
+
+/// Owns the read side of one socket for one generation, and is the only
+/// closer of that fd. Routes responses to the mailbox, answers server
+/// requests, feeds the diagnostics store, narrates progress.
+fn reader(si: usize, g: u32, sock: c_int) void {
+ const c = &conns[si];
+ var buf = sa.alloc(u8, 256 * 1024) catch {
+ readerExit(c, si, g, sock);
+ return;
+ };
+ defer sa.free(buf);
+ var len: usize = 0;
+
+ outer: while (true) {
+ // drain every complete frame already buffered
+ drain: while (true) switch (frameNext(buf[0..len])) {
+ .frame => |m| {
+ dispatch(c, si, g, sock, buf[m.body_start..m.body_end]);
+ std.mem.copyForwards(u8, buf[0 .. len - m.consumed], buf[m.consumed..len]);
+ len -= m.consumed;
+ },
+ .incomplete => break :drain,
+ // a complete header that is not a frame we can speak: nothing
+ // after it can ever re-synchronize, so the connection is over —
+ // this must NOT read as "incomplete", which would buffer the
+ // poison forever while every request quietly times out
+ .poison => break :outer,
+ };
+ // grow when a frame is bigger than the space that is left
+ if (len == buf.len) {
+ if (buf.len >= 64 << 20) break :outer;
+ const bigger = sa.realloc(buf, buf.len * 2) catch break :outer;
+ buf = bigger;
+ }
+ var pfd = [_]libc.pollfd{.{ .fd = sock, .events = POLLIN, .revents = 0 }};
+ const pr = libc.poll(&pfd, 1, 1000);
+ if (pr < 0) {
+ if (libc.errno(pr) == .INTR) continue;
+ break :outer;
+ }
+ if (pr == 0) {
+ // tick: a respawn while the server was silent leaves this thread
+ // reading a corpse; notice and go
+ c.lock();
+ const stale = c.gen != g;
+ c.unlock();
+ if (stale) break :outer;
+ continue;
+ }
+ const got = libc.recv(sock, buf.ptr + len, buf.len - len, 0);
+ if (got < 0) {
+ const e = libc.errno(got);
+ if (e == .INTR or e == .AGAIN) continue;
+ break :outer;
+ }
+ if (got == 0) break :outer; // the server exited or shutdownIf spoke
+ len += @intCast(got);
+ }
+ readerExit(c, si, g, sock);
+}
+
+fn readerExit(c: *Conn, si: usize, g: u32, sock: c_int) void {
+ c.lock();
+ if (c.gen == g and c.alive()) {
+ // the server died on its own — shutdownIf never saw it, so the corpse
+ // is ours to reap and the conn's transport fields are ours to clear
+ c.state = .dead;
+ reap(c.pid);
+ c.pid = -1;
+ c.sock = -1;
+ // A server that keeps crashing right after its handshake would
+ // otherwise respawn on every keystroke — the handshake SUCCEEDING
+ // resets the backoff, so the crash has to count as the failure it
+ // is. A long-lived server that dies gets one free respawn.
+ if (nowMs() - c.ready_at_ms < 30_000) {
+ c.handshake_fails +|= 1;
+ const wait = backoffMs(c.handshake_fails);
+ c.retry_after_ms = nowMs() + wait;
+ post(si, .always, "{s} crashed — retrying in {d}s", .{ specs[si].name, @divTrunc(wait, 1000) });
+ } else {
+ post(si, .always, "{s} exited — restarts on the next query", .{specs[si].name});
+ }
+ _ = libc.pthread_cond_broadcast(&c.cond);
+ }
+ c.unlock();
+ _ = libc.close(sock);
+}
+
+/// 10s doubling to 2min: the fork-per-keystroke guard's schedule.
+fn backoffMs(fails: u8) i64 {
+ const shift: u5 = @min(fails -| 1, 4);
+ return @as(i64, 10_000) << shift;
+}
+
+const Framed = struct { body_start: usize, body_end: usize, consumed: usize };
+
+const FrameStep = union(enum) {
+ /// keep reading; the head of the buffer may still become a frame
+ incomplete,
+ /// a complete, valid frame
+ frame: Framed,
+ /// a complete header that cannot be a frame (no/invalid/oversized
+ /// Content-Length): the stream can never re-synchronize
+ poison,
+};
+
+/// One Content-Length-framed message at the head of `data`. Pure — this is
+/// the whole wire format, and it is testable without a socket.
+fn frameNext(data: []const u8) FrameStep {
+ const sep = std.mem.indexOf(u8, data, "\r\n\r\n") orelse return .incomplete;
+ var clen: ?usize = null;
+ var it = std.mem.splitSequence(u8, data[0..sep], "\r\n");
+ while (it.next()) |line| {
+ const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue;
+ if (!std.ascii.eqlIgnoreCase(std.mem.trim(u8, line[0..colon], " "), "content-length")) continue;
+ clen = std.fmt.parseInt(usize, std.mem.trim(u8, line[colon + 1 ..], " "), 10) catch null;
+ }
+ const n = clen orelse return .poison;
+ const start = sep + 4;
+ if (n > 64 << 20) return .poison;
+ if (data.len < start + n) return .incomplete;
+ return .{ .frame = .{ .body_start = start, .body_end = start + n, .consumed = start + n } };
+}
+
+/// One message from the server. Parses into a throwaway arena; touches conn
+/// state only under the mutex; replies never hold it (bounded writes must not
+/// stall the mailbox).
+fn dispatch(c: *Conn, si: usize, g: u32, sock: c_int, raw: []const u8) void {
+ var arena_state: std.heap.ArenaAllocator = .init(sa);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const v = std.json.parseFromSliceLeaky(std.json.Value, arena, raw, .{}) catch return;
+ if (v != .object) return;
+
+ if (str(get(v, "method"))) |method| {
+ if (get(v, "id")) |id| return serverRequest(arena, sock, method, id, get(v, "params"));
+ return notification(c, si, g, raw, method, get(v, "params"));
+ }
+
+ // a response: deliver to the one waiting query, if it is still waiting
+ const id = num(get(v, "id")) orelse return;
+ c.lock();
+ defer c.unlock();
+ if (c.gen == g and c.want_id != 0 and id == c.want_id and c.resp == null) {
+ c.resp = sa.dupe(u8, raw) catch null;
+ _ = libc.pthread_cond_broadcast(&c.cond);
+ }
+}
+
+/// The server asked US something. Everything optional was declined in the
+/// handshake, so a null-ish answer is always legal — but it must ARRIVE, or
+/// the server blocks forever on its own question.
+fn serverRequest(arena: std.mem.Allocator, sock: c_int, method: []const u8, id: std.json.Value, params: ?std.json.Value) void {
+ var b: std.ArrayList(u8) = .empty;
+ app(&b, arena, "{\"jsonrpc\":\"2.0\",\"id\":") catch return;
+ switch (id) {
+ .integer => |n| b.print(arena, "{d}", .{n}) catch return,
+ .string => |t| jstr(&b, arena, t) catch return,
+ else => app(&b, arena, "null") catch return,
+ }
+ if (std.mem.eql(u8, method, "workspace/configuration")) {
+ // one null per asked item — a bare null here is a protocol violation
+ // some servers punish with a parse loop
+ const n = items(get(params, "items")).len;
+ app(&b, arena, ",\"result\":[") catch return;
+ for (0..n) |i| app(&b, arena, if (i == 0) "null" else ",null") catch return;
+ app(&b, arena, "]}") catch return;
+ } else if (std.mem.eql(u8, method, "workspace/applyEdit")) {
+ app(&b, arena, ",\"result\":{\"applied\":false}}") catch return;
+ } else {
+ app(&b, arena, ",\"result\":null}") catch return;
+ }
+ frame(sock, b.items, nowMs() + reply_ms) catch {};
+}
+
+fn notification(c: *Conn, si: usize, g: u32, raw: []const u8, method: []const u8, params: ?std.json.Value) void {
+ if (std.mem.eql(u8, method, "textDocument/publishDiagnostics")) {
+ const uri = str(get(params, "uri")) orelse return;
+ // keep the raw MESSAGE (params included); re-parsing it per query
+ // beats owning a parsed tree past this arena's life
+ const body = sa.dupe(u8, raw) catch return;
+ c.lock();
+ defer c.unlock();
+ if (c.gen != g) {
+ sa.free(body);
+ return;
+ }
+ for (c.docs.items) |*d| if (std.mem.eql(u8, d.uri, uri)) {
+ d.stale = false;
+ break;
+ };
+ var slot: ?*DiagSet = null;
+ for (c.diags.items) |*d| if (std.mem.eql(u8, d.uri, uri)) {
+ slot = d;
+ break;
+ };
+ if (slot) |d| {
+ sa.free(d.body);
+ d.body = body;
+ } else if (c.diags.items.len < 256) {
+ const owned = sa.dupe(u8, uri) catch {
+ sa.free(body);
+ return;
+ };
+ c.diags.append(sa, .{ .uri = owned, .body = body }) catch {
+ sa.free(owned);
+ sa.free(body);
+ return;
+ };
+ } else sa.free(body);
+ _ = libc.pthread_cond_broadcast(&c.cond); // waitFresh watches this
+ return;
+ }
+ if (std.mem.eql(u8, method, "$/progress")) {
+ const value = get(params, "value") orelse return;
+ const pkind = str(get(value, "kind")) orelse return;
+ const msg = str(get(value, "message")) orelse "";
+ c.lock();
+ if (c.gen != g) {
+ c.unlock();
+ return;
+ }
+ if (std.mem.eql(u8, pkind, "begin")) {
+ c.progress += 1;
+ const title = str(get(value, "title")) orelse "";
+ c.title_len = @intCast(@min(title.len, c.title.len));
+ @memcpy(c.title[0..c.title_len], title[0..c.title_len]);
+ var tbuf: [48]u8 = undefined;
+ const t = tbuf[0..c.title_len];
+ @memcpy(t, c.title[0..c.title_len]);
+ c.unlock();
+ post(si, .chatty, "{s}: {s}\u{2026} {s}", .{ specs[si].name, t, msg });
+ return;
+ }
+ if (std.mem.eql(u8, pkind, "report")) {
+ var tbuf: [48]u8 = undefined;
+ const t = tbuf[0..c.title_len];
+ @memcpy(t, c.title[0..c.title_len]);
+ const pct = num(get(value, "percentage"));
+ c.unlock();
+ if (pct) |p|
+ post(si, .chatty, "{s}: {s} {d}% {s}", .{ specs[si].name, t, p, msg })
+ else
+ post(si, .chatty, "{s}: {s} {s}", .{ specs[si].name, t, msg });
+ return;
+ }
+ // end
+ c.progress -= 1;
+ const idle = c.progress <= 0;
+ if (idle) c.progress = 0;
+ c.unlock();
+ // the setpoint every burst of progress ends on; `.always` + the
+ // dedupe above means it lands exactly once however the reports raced
+ if (idle) post(si, .always, "{s}: ready", .{specs[si].name});
+ return;
+ }
+ if (std.mem.eql(u8, method, "window/showMessage")) {
+ const t = num(get(params, "type")) orelse 3;
+ if (t > 2) return; // info/log are the server's diary, not the user's
+ post(si, .always, "{s}: {s}", .{ specs[si].name, str(get(params, "message")) orelse "" });
+ return;
+ }
+ // rust-analyzer's precise quiescence signal, opted into via
+ // experimental.serverStatusNotification
+ if (std.mem.eql(u8, method, "experimental/serverStatus")) {
+ const health = str(get(params, "health")) orelse "ok";
+ const quiescent = if (get(params, "quiescent")) |q| q == .bool and q.bool else false;
+ if (!std.mem.eql(u8, health, "ok"))
+ post(si, .always, "{s}: {s} — {s}", .{ specs[si].name, health, str(get(params, "message")) orelse "" })
+ else if (quiescent)
+ post(si, .always, "{s}: ready", .{specs[si].name});
+ return;
+ }
+}
+
+// ------------------------------------------------------------- introspection
+
+fn status(req: lsp.Req, out: *std.Io.Writer) !void {
+ _ = req;
+ try out.print("\nprotocol servers (lsp-client):\n", .{});
+ for (&specs, 0..) |*s, i| {
+ const c = &conns[i];
+ c.lock();
+ const state = c.state;
+ var rootbuf: [256]u8 = undefined;
+ const rootlen = @min(c.root.len, rootbuf.len);
+ @memcpy(rootbuf[0..rootlen], c.root[0..rootlen]);
+ const root = rootbuf[0..rootlen];
+ const enc = c.caps.enc;
+ const ndocs = c.docs.items.len;
+ const ndiag = c.diags.items.len;
+ const pull = c.caps.pull;
+ const chier = c.caps.call_hier;
+ const thier = c.caps.type_hier;
+ c.unlock();
+ const env_v = if (getenv(s.env)) |v| std.mem.span(v) else null;
+ try out.print(" {s:<28} {s}", .{ s.name, @tagName(state) });
+ if (state == .ready or state == .starting) {
+ try out.print(" root {s} {s}", .{ root, @tagName(enc) });
+ if (pull) try out.print(" pull-diags", .{});
+ if (chier) try out.print(" call-hier", .{});
+ if (thier) try out.print(" type-hier", .{});
+ try out.print(" docs {d} diag-files {d}", .{ ndocs, ndiag });
+ }
+ if (env_v) |v| try out.print(" [{s}={s}]", .{ s.env, if (v.len == 0) "(disabled)" else v });
+ try out.print("\n ", .{});
+ for (s.exts) |e| try out.print("{s} ", .{e});
+ try out.print("\n", .{});
+ }
+
+ while (!log_mu.tryLock()) std.atomic.spinLoopHint();
+ defer log_mu.unlock();
+ try out.print("\nclient queries ({d} total, keeping {d}):\n", .{ log_total, log_cap });
+ var shown: usize = 0;
+ for (0..log_cap) |i| {
+ const e = &log_buf[(log_next + i) % log_cap];
+ if (!e.used) continue;
+ shown += 1;
+ if (hideTime()) {
+ try out.print(" {s:<18} {s:<14} {s:<20} {d:>4} row(s){s}{s}\n", .{
+ @tagName(e.kind), specs[e.spec].name, e.file[0..e.file_len], e.rows,
+ if (e.err_len > 0) " ERROR " else "", e.err[0..e.err_len],
+ });
+ } else {
+ try out.print(" {s:<18} {s:<14} {s:<20} {d:>7}us {d:>4} row(s){s}{s}\n", .{
+ @tagName(e.kind), specs[e.spec].name, e.file[0..e.file_len], e.us, e.rows,
+ if (e.err_len > 0) " ERROR " else "", e.err[0..e.err_len],
+ });
+ }
+ }
+ if (shown == 0) try out.print(" (none yet — press gd in a file with a server, then ask again)\n", .{});
+}
+
+// ------------------------------------------------------------------ plumbing
+
+fn app(b: *std.ArrayList(u8), arena: std.mem.Allocator, s: []const u8) Err!void {
+ b.appendSlice(arena, s) catch return error.OutOfMemory;
+}
+
+/// `"textDocument":{"uri":U},"position":{...}` — the params shared by every
+/// position request.
+fn atPos(arena: std.mem.Allocator, uri: []const u8, pos: Pos) Err!std.ArrayList(u8) {
+ var b: std.ArrayList(u8) = .empty;
+ try app(&b, arena, "\"textDocument\":{\"uri\":");
+ try jstr(&b, arena, uri);
+ b.print(arena, "}},\"position\":{{\"line\":{d},\"character\":{d}}}", .{ pos.line, pos.ch }) catch return error.OutOfMemory;
+ // the extra `}` above closed textDocument; nothing to fix up
+ return b;
+}
+
+fn frame(sock: c_int, body: []const u8, deadline: i64) Err!void {
+ if (sock < 0) return error.Dead;
+ var hdr: [64]u8 = undefined;
+ const h = std.fmt.bufPrint(&hdr, "Content-Length: {d}\r\n\r\n", .{body.len}) catch return error.Protocol;
+ try sockWrite(sock, h, deadline);
+ try sockWrite(sock, body, deadline);
+}
+
+fn sockWrite(sock: c_int, bytes: []const u8, deadline: i64) Err!void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const left = deadline - nowMs();
+ if (left <= 0) return error.Timeout;
+ var pfd = [_]libc.pollfd{.{ .fd = sock, .events = POLLOUT, .revents = 0 }};
+ const pr = libc.poll(&pfd, 1, @intCast(@min(left, 1000)));
+ if (pr < 0) {
+ if (libc.errno(pr) == .INTR) continue;
+ return error.Dead;
+ }
+ if (pr == 0) continue;
+ const n = libc.send(sock, bytes.ptr + off, bytes.len - off, msg_nosignal);
+ if (n < 0) {
+ const e = libc.errno(n);
+ if (e == .INTR or e == .AGAIN) continue;
+ return error.Dead;
+ }
+ if (n == 0) return error.Dead;
+ off += @intCast(n);
+ }
+}
+
+fn nowMs() i64 {
+ var ts: libc.timespec = undefined;
+ _ = libc.clock_gettime(.MONOTONIC, &ts);
+ return @as(i64, @intCast(ts.sec)) * 1000 + @divTrunc(@as(i64, @intCast(ts.nsec)), 1_000_000);
+}
+
+fn nowUs() u64 {
+ var ts: libc.timespec = undefined;
+ _ = libc.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000;
+}
+
+/// The spec's binary: the env override when set, else `bin`, PATH-searched
+/// unless it names a path.
+fn binOf(spec: *const Spec, buf: *[4096:0]u8) ?[:0]const u8 {
+ const name = if (getenv(spec.env)) |v| std.mem.span(v) else spec.bin;
+ if (name.len == 0) return null;
+ if (std.mem.indexOfScalar(u8, name, '/') != null) {
+ const z = std.fmt.bufPrintSentinel(buf, "{s}", .{name}, 0) catch return null;
+ return if (access(z.ptr, X_OK) == 0) z else null;
+ }
+ const path = if (getenv("PATH")) |p| std.mem.span(p) else "/usr/local/bin:/usr/bin:/bin";
+ var it = std.mem.tokenizeScalar(u8, path, ':');
+ while (it.next()) |dir| {
+ const cand = std.fmt.bufPrintSentinel(buf, "{s}/{s}", .{ dir, name }, 0) catch continue;
+ if (access(cand.ptr, X_OK) == 0) return cand;
+ }
+ return null;
+}
+
+/// Where the project starts: helix's find_root rule. Walk UP from `dir`; the
+/// TOP-MOST directory holding a language marker wins (a cargo workspace's
+/// root Cargo.toml beats the member crate's), the CLOSEST `.git` is the
+/// fallback, the asking directory the last resort.
+fn rootOf(dir: []const u8, markers: []const []const u8) []const u8 {
+ if (dir.len == 0 or dir[0] != '/') return "/";
+ var top_marker: ?[]const u8 = null;
+ var git: ?[]const u8 = null;
+ var d = dir;
+ var buf: [4096:0]u8 = undefined;
+ while (true) {
+ for (markers) |marker| {
+ const p = std.fmt.bufPrintSentinel(&buf, "{s}/{s}", .{ d, marker }, 0) catch continue;
+ if (access(p.ptr, 0) == 0) {
+ top_marker = d;
+ break;
+ }
+ }
+ if (git == null) {
+ if (std.fmt.bufPrintSentinel(&buf, "{s}/.git", .{d}, 0)) |p| {
+ if (access(p.ptr, 0) == 0) git = d;
+ } else |_| {}
+ }
+ d = std.fs.path.dirname(d) orelse break;
+ if (d.len <= 1) break;
+ }
+ return top_marker orelse git orelse dir;
+}
+
+/// A path as a `file://` URI, raw (no JSON quotes). Everything outside the
+/// unreserved set is percent-encoded; `/` stays a separator.
+fn uriOf(b: *std.ArrayList(u8), arena: std.mem.Allocator, path: []const u8) Err!void {
+ b.appendSlice(arena, "file://") catch return error.OutOfMemory;
+ for (path) |ch| {
+ if (std.ascii.isAlphanumeric(ch) or ch == '/' or ch == '-' or ch == '_' or ch == '.' or ch == '~') {
+ b.append(arena, ch) catch return error.OutOfMemory;
+ } else {
+ b.print(arena, "%{X:0>2}", .{ch}) catch return error.OutOfMemory;
+ }
+ }
+}
+
+/// `file:///a/b%20c` -> `/a/b c`. Rows carry real paths; look.zig opens them
+/// and `%20` is not a filename. A decoded CONTROL byte rejects the whole uri:
+/// a `%0A` in a path would split one location into two physical rows — a
+/// server-forged extra row in the results buffer — and no path worth opening
+/// has a newline, tab or NUL in it.
+fn pathOf(arena: std.mem.Allocator, uri: []const u8) ?[]const u8 {
+ var rest = uri;
+ if (std.mem.startsWith(u8, rest, "file://")) {
+ rest = rest["file://".len..];
+ if (rest.len > 0 and rest[0] != '/') return null; // an authority we cannot open
+ } else if (std.mem.indexOf(u8, rest, "://") != null) return null;
+ var out: std.ArrayList(u8) = .empty;
+ var i: usize = 0;
+ while (i < rest.len) {
+ var byte: u8 = rest[i];
+ if (rest[i] == '%' and i + 2 < rest.len) {
+ if (std.fmt.parseInt(u8, rest[i + 1 .. i + 3], 16)) |b| {
+ byte = b;
+ i += 3;
+ } else |_| i += 1;
+ } else i += 1;
+ if (byte < 0x20) return null;
+ out.append(arena, byte) catch return null;
+ }
+ return out.items;
+}
+
+/// A JSON string. Invalid UTF-8 becomes `?` one byte at a time rather than
+/// U+FFFD: a buffer being typed into may be invalid mid-keystroke, and a
+/// replacement that changed byte lengths would move every offset after it.
+fn jstr(b: *std.ArrayList(u8), arena: std.mem.Allocator, s: []const u8) Err!void {
+ b.append(arena, '"') catch return error.OutOfMemory;
+ var i: usize = 0;
+ while (i < s.len) {
+ const ch = s[i];
+ if (ch < 0x80) {
+ switch (ch) {
+ '"' => b.appendSlice(arena, "\\\"") catch return error.OutOfMemory,
+ '\\' => b.appendSlice(arena, "\\\\") catch return error.OutOfMemory,
+ '\n' => b.appendSlice(arena, "\\n") catch return error.OutOfMemory,
+ '\r' => b.appendSlice(arena, "\\r") catch return error.OutOfMemory,
+ '\t' => b.appendSlice(arena, "\\t") catch return error.OutOfMemory,
+ else => if (ch < 0x20)
+ b.print(arena, "\\u{x:0>4}", .{ch}) catch return error.OutOfMemory
+ else
+ b.append(arena, ch) catch return error.OutOfMemory,
+ }
+ i += 1;
+ continue;
+ }
+ const l = std.unicode.utf8ByteSequenceLength(ch) catch {
+ b.append(arena, '?') catch return error.OutOfMemory;
+ i += 1;
+ continue;
+ };
+ if (i + l > s.len or !std.unicode.utf8ValidateSlice(s[i .. i + l])) {
+ b.append(arena, '?') catch return error.OutOfMemory;
+ i += 1;
+ continue;
+ }
+ b.appendSlice(arena, s[i .. i + l]) catch return error.OutOfMemory;
+ i += l;
+ }
+ b.append(arena, '"') catch return error.OutOfMemory;
+}
+
+const Pos = struct { line: u32, ch: u32 };
+
+/// Byte offset -> LSP position. `lsp.lineCol` gives the line and the BYTE
+/// column; only the column needs re-counting, and only when the server
+/// refused utf-8.
+fn posOf(src: []const u8, off: u32, enc: Enc) Pos {
+ const lc = lsp.lineCol(src, off);
+ if (enc == .utf8) return .{ .line = @intCast(lc.line), .ch = @intCast(lc.col) };
+ const bol = @min(off, src.len) - lc.col;
+ var units: u32 = 0;
+ var i: usize = bol;
+ while (i < bol + lc.col) {
+ const l = std.unicode.utf8ByteSequenceLength(src[i]) catch 1;
+ units += if (l == 4) 2 else 1;
+ i += l;
+ }
+ return .{ .line = @intCast(lc.line), .ch = units };
+}
+
+// std.json.Value navigation: optional-in, optional-out, so a missing field
+// and a wrongly-typed one read the same and neither can panic on a server
+// that sends the unexpected.
+
+fn get(v: ?std.json.Value, key: []const u8) ?std.json.Value {
+ const o = v orelse return null;
+ if (o != .object) return null;
+ return o.object.get(key);
+}
+
+fn str(v: ?std.json.Value) ?[]const u8 {
+ const o = v orelse return null;
+ return if (o == .string) o.string else null;
+}
+
+fn num(v: ?std.json.Value) ?i64 {
+ const o = v orelse return null;
+ return if (o == .integer) o.integer else null;
+}
+
+fn items(v: ?std.json.Value) []std.json.Value {
+ const o = v orelse return &.{};
+ return if (o == .array) o.array.items else &.{};
+}
+
+const Range = struct { sl: u32, sc: u32, el: u32, ec: u32 };
+
+fn rangeOf(range: ?std.json.Value) ?Range {
+ const st = get(range, "start") orelse return null;
+ const en = get(range, "end") orelse return null;
+ return .{
+ .sl = coord(get(st, "line")) orelse return null,
+ .sc = coord(get(st, "character")) orelse return null,
+ .el = coord(get(en, "line")) orelse return null,
+ .ec = coord(get(en, "character")) orelse return null,
+ };
+}
+
+/// One protocol coordinate, VALIDATED rather than clamped: a negative line is
+/// not "line zero", it is a malformed response, and a value past u32 would
+/// panic the @intCast that follows. Server bytes are not trusted bytes.
+fn coord(v: ?std.json.Value) ?u32 {
+ const n = num(v) orelse return null;
+ if (n < 0 or n > std.math.maxInt(u32)) return null;
+ return @intCast(n);
+}
+
+// ----------------------------------------------------------------- tests
+
+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"));
+ const one = frameNext("Content-Length: 5\r\n\r\nhello").frame;
+ try std.testing.expectEqualStrings("hello", "Content-Length: 5\r\n\r\nhello"[one.body_start..one.body_end]);
+ const two = "content-length: 2\r\nX-Other: y\r\n\r\nab" ++ "Content-Length: 1\r\n\r\nz";
+ const first = frameNext(two).frame;
+ try std.testing.expectEqualStrings("ab", two[first.body_start..first.body_end]);
+ const second = frameNext(two[first.consumed..]).frame;
+ try std.testing.expectEqualStrings("z", two[first.consumed..][second.body_start..second.body_end]);
+ // complete-but-unusable headers can never re-synchronize: poison, not
+ // "keep buffering" — the wedge a review found and this line pins
+ try std.testing.expectEqual(FrameStep.poison, frameNext("Content-Length: nope\r\n\r\n"));
+ try std.testing.expectEqual(FrameStep.poison, frameNext("X-Only: y\r\n\r\n"));
+ try std.testing.expectEqual(FrameStep.poison, frameNext("Content-Length: 67108865\r\n\r\n"));
+}
+
+test "server coordinates are validated, not clamped" {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ const bad = [_][]const u8{
+ \\{"start":{"line":-1,"character":0},"end":{"line":0,"character":1}}
+ ,
+ \\{"start":{"line":0,"character":0},"end":{"line":0,"character":4294967296}}
+ ,
+ \\{"start":{"line":0,"character":0},"end":{"line":0}}
+ };
+ for (bad) |json| {
+ const v = try std.json.parseFromSliceLeaky(std.json.Value, arena, json, .{});
+ try std.testing.expect(rangeOf(v) == null);
+ }
+ // strict offsets: past-EOL and past-EOF are refusals, EOL itself is real
+ const src = "ab\ncd";
+ try std.testing.expectEqual(@as(?usize, 2), strictOffset(src, 0, 2, .utf8));
+ try std.testing.expect(strictOffset(src, 0, 3, .utf8) == null);
+ try std.testing.expect(strictOffset(src, 2, 0, .utf8) == null);
+ try std.testing.expectEqual(@as(?usize, 5), strictOffset(src, 1, 2, .utf8));
+}
+
+test "uri round-trips spaces and utf-8" {
+ var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator);
+ defer arena_state.deinit();
+ const arena = arena_state.allocator();
+ var b: std.ArrayList(u8) = .empty;
+ try uriOf(&b, arena, "/a dir/naïve.rs");
+ try std.testing.expectEqualStrings("file:///a%20dir/na%C3%AFve.rs", b.items);
+ try std.testing.expectEqualStrings("/a dir/naïve.rs", pathOf(arena, b.items).?);
+ try std.testing.expect(pathOf(arena, "file://host/x") == null);
+ // a %0A would split one location into two rows: rejected wholesale
+ try std.testing.expect(pathOf(arena, "file:///tmp/a%0A/tmp/b:1:1") == null);
+ try std.testing.expect(pathOf(arena, "file:///tmp/a%00b") == null);
+ try std.testing.expect(pathOf(arena, "https://x/y") == null);
+}
+
+test "posOf and offsetAt invert each other under both encodings" {
+ const src = "aé𝕏b\ncd\n"; // é: 2 bytes/1 unit, 𝕏: 4 bytes/2 units
+ inline for (.{ Enc.utf8, Enc.utf16 }) |enc| {
+ const off: u32 = 7; // the 'b'
+ const p = posOf(src, off, enc);
+ try std.testing.expectEqual(@as(u32, 0), p.line);
+ try std.testing.expectEqual(off, @as(u32, @intCast(offsetAt(src, p.line, p.ch, enc))));
+ }
+ const p2 = posOf("aé𝕏b\ncd\n", 10, .utf16); // the 'd'
+ try std.testing.expectEqual(@as(u32, 1), p2.line);
+ try std.testing.expectEqual(@as(u32, 1), p2.ch);
+}
+
+test "rootOf takes the top-most marker and falls back to git then dir" {
+ // libc mkdtemp under /tmp rather than std.testing.tmpDir: rootOf probes
+ // with access(2) on ABSOLUTE paths, and /tmp has no ancestor markers to
+ // muddy the fallback assertions the way the repo's own .zig-cache does.
+ var tpl: [64:0]u8 = undefined;
+ _ = std.fmt.bufPrintSentinel(&tpl, "/tmp/pardes-rootof-XXXXXX", .{}, 0) catch unreachable;
+ const base_z = mkdtemp(&tpl) orelse return error.TestUnexpectedResult;
+ const base = std.mem.span(base_z);
+ defer {
+ var cmd: [128:0]u8 = undefined;
+ if (std.fmt.bufPrintSentinel(&cmd, "rm -rf {s}", .{base}, 0)) |z| _ = system(z.ptr) else |_| {}
+ }
+ var pb: [128:0]u8 = undefined;
+ for ([_][]const u8{ "/ws", "/ws/member", "/ws/member/src" }) |d| {
+ const z = try std.fmt.bufPrintSentinel(&pb, "{s}{s}", .{ base, d }, 0);
+ try std.testing.expect(mkdir(z.ptr, 0o700) == 0);
+ }
+ for ([_][]const u8{ "/ws/Cargo.toml", "/ws/member/Cargo.toml" }) |f| {
+ const z = try std.fmt.bufPrintSentinel(&pb, "{s}{s}", .{ base, f }, 0);
+ const fd = libc.open(z.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true }, @as(libc.mode_t, 0o600));
+ try std.testing.expect(fd >= 0);
+ _ = libc.close(fd);
+ }
+ const deep = try std.fmt.bufPrintSentinel(&pb, "{s}/ws/member/src", .{base}, 0);
+ const markers = [_][]const u8{"Cargo.toml"};
+ const root = rootOf(deep, &markers);
+ try std.testing.expect(std.mem.endsWith(u8, root, "/ws"));
+ // no language marker and no .git anywhere under /tmp: the asking dir wins
+ const nothing = rootOf(deep, &.{"no-such-marker"});
+ try std.testing.expect(std.mem.startsWith(u8, deep, nothing));
+}
+
+test "specFor routes extensions and honours the disable env" {
+ try std.testing.expect(specFor("/x/main.rs") != null);
+ try std.testing.expect(specFor("/x/main.zig") == null);
+ try std.testing.expect(specFor("/x/README.md") == null);
+ try std.testing.expectEqualStrings("rust-analyzer", specs[specFor("/x/main.rs").?].name);
+}
diff --git a/src/lsp/lsp_zls.zig b/src/lsp/lsp_zls.zig
index bf220c47..fb9804e6 100644
--- a/src/lsp/lsp_zls.zig
+++ b/src/lsp/lsp_zls.zig
@@ -1352,11 +1352,11 @@ fn codeActions(
// ---------------------------------------------------------------- format
-/// `=`. The seam returns ROWS, not edits — there is no channel through which a
-/// backend can rewrite the buffer — so format reports rather than formats: one
-/// row per line `zig fmt` would change, in the `+Lsp` buffer. That is the
-/// honest shape of "format" behind a read-only seam, and it is still the
-/// answer to the question the keypress asks.
+/// `=`. The response is `@put` edit records (see lsp.put): one span covering
+/// everything `zig fmt` would change, which the core substitutes as a single
+/// undo step — so `=` FORMATS, exactly like helix. The two answers that are
+/// not edits stay prose rows in `+Lsp`: a file that does not parse cannot be
+/// formatted, and a file already formatted has nothing to apply.
fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !void {
const path = lsp.rel(req.root, req.path);
var tree: Ast = try .parse(arena, req.source, .zig);
@@ -1377,22 +1377,18 @@ fn formatQuery(arena: std.mem.Allocator, req: lsp.Req, out: *std.Io.Writer) !voi
lsp.row(out, path, 0, 0, "already formatted");
return;
}
- var a = std.mem.splitScalar(u8, req.source, '\n');
- var b = std.mem.splitScalar(u8, formatted, '\n');
- var ln: usize = 0;
- var n: usize = 0;
- while (n < max_rows) : (ln += 1) {
- const x = a.next();
- const y = b.next();
- if (x == null and y == null) break;
- const xs = x orelse "";
- const ys = y orelse "";
- if (std.mem.eql(u8, xs, ys)) continue;
- n += 1;
- lsp.row(out, path, ln, 0, try std.fmt.allocPrint(arena, "- {s} + {s}", .{
- std.mem.trim(u8, xs, " \t"), std.mem.trim(u8, ys, " \t"),
- }));
+ // one record, spanning only what changed: the common prefix and suffix
+ // stay untouched, which is also what keeps the cursor mapping tight
+ var lo: usize = 0;
+ const min_len = @min(req.source.len, formatted.len);
+ while (lo < min_len and req.source[lo] == formatted[lo]) lo += 1;
+ var src_hi = req.source.len;
+ var fmt_hi = formatted.len;
+ while (src_hi > lo and fmt_hi > lo and req.source[src_hi - 1] == formatted[fmt_hi - 1]) {
+ src_hi -= 1;
+ fmt_hi -= 1;
}
+ lsp.put(out, lo, src_hi, formatted[lo..fmt_hi]);
}
// ------------------------------------------------------------------ files
diff --git a/src/output_pane.zig b/src/output_pane.zig
index 9021e479..1850fa92 100644
--- a/src/output_pane.zig
+++ b/src/output_pane.zig
@@ -106,14 +106,22 @@ pub fn traits(o: Origin) Traits {
.hover => .{ .name = config.hover_buffer },
// prose: an action list, a diff, a report about the backend
.code_action, .format, .status, .explain => .{ .name = config.lsp_buffer },
- // Rename responses are edits consumed before an output can open;
- // the exhaustive table still records the otherwise-unused trait.
- .rename => .{ .name = config.lsp_buffer },
+ // Rename usually resolves to edit records the core consumes before
+ // any buffer opens; what RENDERS is the multi-file PREVIEW — one
+ // location row per would-be edit — which n/N step like any list.
+ .rename => .{ .name = config.search_buffer, .steps = true },
.definition, .declaration, .type_definition, .implementation, .references => .{
.name = config.search_buffer,
.steps = true,
.jumps = true,
},
+ // The hierarchy kinds behave like references: a list of places,
+ // and a lone answer (one caller, one subtype) is a jump.
+ .incoming_calls, .outgoing_calls, .supertypes, .subtypes => .{
+ .name = config.search_buffer,
+ .steps = true,
+ .jumps = true,
+ },
// completion lists WHAT COULD GO HERE, one row per candidate's
// definition. It does not jump on a single row where the gotos do:
// a goto answers a question whose answer is a place, so landing
@@ -397,8 +405,25 @@ fn openStepped(p: *Pardes, id: usize, from: Origin, content: []u8) !void {
fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 {
const full_header = "pardes builtins, and how to run each:\nSPC and its keys, a chord, a button, the\ntopbar - or the name, executed anywhere.\n\n";
const group_header = "pardes builtins under SPC";
+ // The LANGUAGE KEYS are the one part of the keymap Help would otherwise
+ // never show: they are motions and modes, not words, so no builtin row
+ // carries them — yet they are the keys a reader comes looking for. Full
+ // listing only; a mid-chord `SPC l` view stays a pure filter.
+ const language_footer =
+ "\nlanguage keys (motions, not words):\n" ++
+ "gd gD gy gi gr goto: definition,\n" ++
+ " declaration, type-def,\n" ++
+ " implementation, refs\n" ++
+ "]d [d ]D [D diagnostics: next,\n" ++
+ " prev, last, first\n" ++
+ "= format (applies, one\n" ++
+ " undo step)\n" ++
+ "Tab after a . completion, in insert\n" ++
+ "C-left-click definition, by mouse\n" ++
+ "SPC l ... hover, rename, symbols,\n" ++
+ " calls, types: above\n";
var len: usize = if (prefix.len == 0)
- full_header.len
+ full_header.len + language_footer.len
else
group_header.len + prefix.len * 2 + 2;
for (pardes.builtin_rows) |row| {
@@ -431,6 +456,10 @@ fn helpContent(gpa: std.mem.Allocator, prefix: []const u8) ![]u8 {
content[at] = '\n';
at += 1;
}
+ if (prefix.len == 0) {
+ @memcpy(content[at..][0..language_footer.len], language_footer);
+ at += language_footer.len;
+ }
std.debug.assert(at == content.len);
return content;
}
@@ -442,22 +471,28 @@ pub fn openHelp(p: *Pardes, id: usize, prefix: []const u8) !void {
return openRead(p, id, .{ .cmd = .Help }, prefix, content);
}
-test "full Help renders every enabled builtin row" {
+test "full Help renders every builtin row, then the language keys" {
const content = try helpContent(std.testing.allocator, "");
defer std.testing.allocator.free(content);
- const body = content[(std.mem.lastIndexOf(u8, content, "\n\n") orelse
+ // FIRST blank line: the end of the header (the footer opens with one too)
+ const body = content[(std.mem.indexOf(u8, content, "\n\n") orelse
return error.MissingHelpHeader) + 2 ..];
var lines = std.mem.splitScalar(u8, body, '\n');
for (pardes.builtin_rows) |row|
try std.testing.expectEqualStrings(row.line, lines.next() orelse
return error.MissingBuiltinHelpRow);
- // The renderer terminates every row with a newline, so only split's empty
- // trailing field may remain. Any extra non-empty field is an unregistered
- // Help row and any missing row already failed in the loop above.
+ // ...and after the last row, the language-keys section: the one part of
+ // the keymap no builtin row can carry, closing the page.
try std.testing.expectEqualStrings("", lines.next() orelse
- return error.MissingHelpTerminator);
- try std.testing.expect(lines.next() == null);
+ return error.MissingLanguageKeys);
+ try std.testing.expectEqualStrings("language keys (motions, not words):", lines.next() orelse
+ return error.MissingLanguageKeys);
+ try std.testing.expect(std.mem.indexOf(u8, body, "\ngd gD gy gi gr goto: definition,\n") != null);
+ // the group view stays a pure filter: no footer under a prefix
+ const group = try helpContent(std.testing.allocator, "l");
+ defer std.testing.allocator.free(group);
+ try std.testing.expect(std.mem.indexOf(u8, group, "language keys") == null);
}
/// The complete live Config report: the generated settings and the host facts
diff --git a/src/pardes.zig b/src/pardes.zig
index 4fdb6c71..bc9a0939 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -10400,7 +10400,15 @@ pub const Pardes = struct {
if (f.output != null) return;
}
if (arg.len > 128) return; // the effect's arg is a Buf(128)
- if (kind == .rename and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return;
+ // A rename's argument becomes an identifier in someone's source. Zig
+ // buffers get Zig's exact rule; any other language the client speaks
+ // gets the weakest honest one (no whitespace, no quotes — the server
+ // validates the rest and answers nothing when it hates the name).
+ 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;
+ }
const off = if (pane.file) |f| modal.hxOff(f.content, .{
.row = @intCast(@max(0, pane.cur_row)),
.col = @intCast(@max(0, pane.cur_col)),
@@ -10425,72 +10433,125 @@ pub const Pardes = struct {
} });
}
- const LspEdit = struct { start: usize, end: usize };
+ const LspEdit = struct { start: usize, end: usize, text: []const u8 };
- fn parseLspEdits(p: *Pardes, bytes: []const u8) ?[]LspEdit {
+ /// Parse a mutating response into ordered replacements. Two record forms,
+ /// never mixed: `@edit START END` substitutes `fallback` at every range
+ /// (the ZLS rename path — the text is the request's own argument), and
+ /// `@put START END PCT` carries its own percent-encoded replacement (a
+ /// protocol server's rename or format, whose per-range text only the
+ /// server knows). `fallback == null` rejects the @edit form outright — a
+ /// format response has no argument to substitute. Anything malformed
+ /// parses to null and null changes nothing.
+ fn parseLspEdits(p: *Pardes, bytes: []const u8, fallback: ?[]const u8) ?[]LspEdit {
if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null;
- const edits = p.scratch.allocator().alloc(LspEdit, std.mem.count(u8, bytes, "\n")) catch return null;
+ const a = p.scratch.allocator();
+ const edits = a.alloc(LspEdit, std.mem.count(u8, bytes, "\n")) catch return null;
var lines = std.mem.splitScalar(u8, bytes, '\n');
var n: usize = 0;
+ var form: enum { undecided, edit, put } = .undecided;
while (lines.next()) |line| {
if (line.len == 0) {
if (lines.peek() == null) break;
return null;
}
var fields = std.mem.tokenizeScalar(u8, line, ' ');
- if (!std.mem.eql(u8, fields.next() orelse return null, "@edit")) return null;
+ const head = fields.next() orelse return null;
+ const want: @TypeOf(form) = if (std.mem.eql(u8, head, "@put"))
+ .put
+ else if (std.mem.eql(u8, head, "@edit"))
+ .edit
+ else
+ return null;
+ if (form == .undecided) form = want;
+ if (form != want) return null;
const start = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null;
const end = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null;
+ var text: []const u8 = if (want == .edit) (fallback orelse return null) else "";
+ if (want == .put) {
+ if (fields.next()) |encoded| text = decodePct(a, encoded) orelse return null;
+ }
if (fields.next() != null) return null;
- edits[n] = .{ .start = start, .end = end };
+ edits[n] = .{ .start = start, .end = end, .text = text };
n += 1;
}
return if (n == 0) null else edits[0..n];
}
- fn mapLspEditOffset(edits: []const LspEdit, replacement_len: usize, old: usize) usize {
+ /// The `@put` text codec's read half (lsp.put writes it): `%XX` bytes,
+ /// everything else literal. An invalid escape fails the whole record.
+ fn decodePct(a: std.mem.Allocator, encoded: []const u8) ?[]const u8 {
+ const out = a.alloc(u8, encoded.len) catch return null;
+ var n: usize = 0;
+ var i: usize = 0;
+ while (i < encoded.len) {
+ if (encoded[i] == '%') {
+ if (i + 3 > encoded.len) return null;
+ out[n] = std.fmt.parseInt(u8, encoded[i + 1 .. i + 3], 16) catch return null;
+ i += 3;
+ } else {
+ out[n] = encoded[i];
+ i += 1;
+ }
+ n += 1;
+ }
+ return out[0..n];
+ }
+
+ /// Where the cursor lands after `edits` replace their ranges: text before
+ /// the first edit keeps its offset, text between edits shifts by the
+ /// accumulated delta, and a cursor inside a replaced range clamps into
+ /// the replacement.
+ fn mapLspEditOffset(edits: []const LspEdit, old: usize) usize {
var old_at: usize = 0;
var new_at: usize = 0;
for (edits) |e| {
if (old < e.start) return new_at + (old - old_at);
new_at += e.start - old_at;
- if (old < e.end) return new_at + @min(old - e.start, replacement_len - 1);
- new_at += replacement_len;
+ if (old < e.end) return new_at + @min(old - e.start, e.text.len -| 1);
+ new_at += e.text.len;
if (old == e.end) return new_at;
old_at = e.end;
}
return new_at + (old - old_at);
}
- fn applyLspRename(p: *Pardes, pane: *Pane, revision: u32, new_name: []const u8, bytes: []const u8) void {
- const f = if (pane.file) |*file| file else return;
- if (f.revision != revision) return;
- const edits = p.parseLspEdits(bytes) orelse return;
+ /// Apply parsed edit records as ONE undo transaction, or nothing: ranges
+ /// must be ordered, non-overlapping and in bounds, and the file revision
+ /// must still be the one the worker was asked about — the user may have
+ /// typed while the server thought. True when the buffer changed.
+ fn applyLspEdits(p: *Pardes, pane: *Pane, revision: u32, edits: []const LspEdit) bool {
+ const f = if (pane.file) |*file| file else return false;
+ if (f.revision != revision) return false;
var removed: usize = 0;
+ var added: usize = 0;
var previous_end: usize = 0;
+ var same = true;
for (edits) |e| {
- if (e.start < previous_end or e.start >= e.end or e.end > f.content.len) return;
- removed = std.math.add(usize, removed, e.end - e.start) catch return;
+ if (e.start < previous_end or e.start > e.end or e.end > f.content.len) return false;
+ if (e.start == e.end and e.text.len == 0) return false; // an empty insert is a malformed record
+ removed = std.math.add(usize, removed, e.end - e.start) catch return false;
+ added = std.math.add(usize, added, e.text.len) catch return false;
previous_end = e.end;
+ if (same and !std.mem.eql(u8, f.content[e.start..e.end], e.text)) same = false;
}
- if (std.mem.eql(u8, f.content[edits[0].start..edits[0].end], new_name)) return;
- const added = std.math.mul(usize, edits.len, new_name.len) catch return;
- const final_len = std.math.add(usize, f.content.len - removed, added) catch return;
- const replacement = p.gpa.alloc(u8, final_len) catch return;
+ if (same) return false; // a no-op response earns no undo entry
+ const final_len = std.math.add(usize, f.content.len - removed, added) catch return false;
+ const replacement = p.gpa.alloc(u8, final_len) catch return false;
const old_cursor = modal.hxOff(f.content, .{
.row = @intCast(@max(0, pane.cur_row)),
.col = @intCast(@max(0, pane.cur_col)),
});
- const mapped_cursor = mapLspEditOffset(edits, new_name.len, old_cursor);
+ const mapped_cursor = mapLspEditOffset(edits, old_cursor);
var read_at: usize = 0;
var write_at: usize = 0;
for (edits) |e| {
@memcpy(replacement[write_at .. write_at + (e.start - read_at)], f.content[read_at..e.start]);
write_at += e.start - read_at;
- @memcpy(replacement[write_at .. write_at + new_name.len], new_name);
- write_at += new_name.len;
+ @memcpy(replacement[write_at .. write_at + e.text.len], e.text);
+ write_at += e.text.len;
read_at = e.end;
}
@memcpy(replacement[write_at..], f.content[read_at..]);
@@ -10505,10 +10566,16 @@ pub const Pardes = struct {
pane.select = false;
pane.sticky_col = -1;
pane.ensureCursorVisible();
+ return true;
}
- /// A worker answered. Rename's edit records are consumed first and never
- /// rendered. Every other response is the ordinary look/output path:
+ /// A worker answered. The two MUTATING kinds are consumed first: a
+ /// response made of edit records is applied atomically (rename substitutes
+ /// the argument or the server's own text, `=` applies the formatter), and
+ /// only a response that is NOT records renders — a rename that spans other
+ /// files arrives as location rows and opens as a PREVIEW list instead of
+ /// being half-applied, and a format that could not run stays prose.
+ /// Every other response is the ordinary look/output path:
/// one row, a goto -> jump straight there (helix jumps on a single
/// location and shows a picker on several)
/// anything else -> an output buffer, which n/N already steps. That
@@ -10519,7 +10586,22 @@ pub const Pardes = struct {
p.lsp_wait = null;
const pane = p.panes[w.pane] orelse return;
if (pane.serial != w.serial) return;
- if (w.kind == .rename) return p.applyLspRename(pane, w.revision, w.arg.slice(), rows);
+ 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| {
+ if (p.applyLspEdits(pane, w.revision, edits)) {
+ var buf: [64]u8 = undefined;
+ const verb: []const u8 = if (w.kind == .rename) "renamed" else "formatted";
+ p.setMessage(w.pane, std.fmt.bufPrint(&buf, "{s} {d} range(s)", .{ verb, edits.len }) catch verb);
+ }
+ return;
+ }
+ if (w.kind == .rename and rows.len > 0) {
+ // fall through: a multi-file rename previews as rows
+ } else if (w.kind == .format and rows.len > 0) {
+ // fall through: "cannot format" and friends are prose
+ } else return;
+ }
if (rows.len == 0) {
// No rows is a legal answer everywhere except here. Tab DIVERTED
// instead of indenting, so an empty answer would eat the keystroke
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index c85908d6..f8f7150b 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -64,6 +64,10 @@ pub const Command = struct {
paste_end,
/// a language query finished on a worker; rows are lsp-domain-owned
lsp_done: struct { id: u32, rows: []u8 },
+ /// a language SERVER changed state (spawned, indexing, exited) — the
+ /// client's reader thread narrates and this lands it on the message
+ /// row; text is lsp-domain-owned
+ lsp_status: []u8,
/// a selection-filter worker finished; every stdout is gpa-owned
pipe_done: selection_pipe.Response,
/// something happened in a watched directory (see watchFiles)
@@ -702,7 +706,14 @@ fn localSession(
.inotify_fd = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1,
.fs = fs,
};
+ // The protocol client's reader threads narrate server state through this
+ // sink from the moment it is set; posting is safe because the loop queue
+ // outlives them all — and it is UNSET first thing in the defer below,
+ // under the sink's own lock, so no reader can be mid-post when the queue
+ // starts draining for teardown.
+ pardes.lsp.setStatusSink(&sh, lspStatusSink);
defer {
+ pardes.lsp.setStatusSink(null, null);
// reap the reader tasks (cancel interrupts a blocked read) before
// closing the masters — the runtime joins those threads on exit and a
// reader stuck in read(2) would hang the process — then drain the
@@ -750,6 +761,7 @@ fn localSession(
.command => |line| gpa.free(line),
.paste => |b| gpa.free(@constCast(b)),
.lsp_done => |d| allocs.lsp.free(d.rows),
+ .lsp_status => |text| allocs.lsp.free(text),
.pipe_done => |response_value| {
var response = response_value;
response.deinit(gpa);
@@ -1185,6 +1197,15 @@ const Shell = struct {
s.lsp_task = null;
}
},
+ // Server state on the transient message row — the same row, the
+ // same `message.stamp` clock, and the same shell-side ownership a
+ // completed save uses. The ACTIVE pane, because the state of a
+ // server is session news, not a fact about the pane that asked.
+ .lsp_status => |text| {
+ var mbuf: [256]u8 = undefined;
+ core.setMessage(core.active, message.stamp(&mbuf, "lsp", text));
+ s.lsp_gpa.free(text);
+ },
.pipe_done => |response_value| {
var response = response_value;
core.update(.{ .pipe_resp = .{
@@ -1620,6 +1641,20 @@ fn lspWorker(allocator: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!v
return;
}
+/// The registered `lsp.setStatusSink` target, called from the protocol
+/// client's READER threads. Only thread-safe, NON-BLOCKING things happen
+/// here: a dupe with the concurrent lsp allocator and a TRY-post onto the
+/// loop's queue. Never the blocking post — the sink lock is held around this
+/// call, and a full queue plus a teardown spinning on that lock would be a
+/// deadlock; server state is periodic news, so a dropped line is repriced
+/// by the next one.
+fn lspStatusSink(ctx: ?*anyopaque, text: []const u8) void {
+ const s: *Shell = @ptrCast(@alignCast(ctx orelse return));
+ const copy = s.lsp_gpa.dupe(u8, text) catch return;
+ const posted = s.loop.tryPostEvent(.{ .lsp_status = copy }) catch false;
+ if (!posted) s.lsp_gpa.free(copy);
+}
+
fn pipeWorker(
io: std.Io,
gpa: std.mem.Allocator,
@@ -2110,14 +2145,17 @@ const Attach = struct {
// (server.zig `nap`) and the frames simply arrive. `fs_ready`
// belongs to `--fs`, which lives with the core. `lsp_done` and
// `pipe_done` answer work the core dispatches, and it dispatches it
- // there. `pty_read`, `pty_eof` and `files_changed` are the ones
- // that MOVED: the daemon forks the pane shells and holds the
- // inotify instance now, so the only descriptors this process reads
- // are its terminal and one socket. An in-place switch (`Attach` in
- // a local session) cancels its readers and its watcher and drains
+ // there; `lsp_status` narrates servers whose sink the local loop
+ // UNSET in its teardown before this loop started, and the queue
+ // was drained after that, so none is in flight. `pty_read`,
+ // `pty_eof` and `files_changed` are the ones that MOVED: the
+ // daemon forks the pane shells and holds the inotify instance
+ // now, so the only descriptors this process reads are its
+ // terminal and one socket. An in-place switch (`Attach` in a
+ // local session) cancels its readers and its watcher and drains
// this queue before the attached loop starts, so not even a late
// post from the session it just left arrives here.
- .nop, .tick, .fs_ready, .lsp_done, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
+ .nop, .tick, .fs_ready, .lsp_done, .lsp_status, .pipe_done, .pty_read, .pty_eof, .files_changed => {},
.quit => return .none,
.focus_in => {},
.focus_out => return a.send(.pointer_leave),
diff --git a/test/lspfixture/client/Cargo.toml b/test/lspfixture/client/Cargo.toml
new file mode 100644
index 00000000..01a28899
--- /dev/null
+++ b/test/lspfixture/client/Cargo.toml
@@ -0,0 +1,11 @@
+# Root marker for the client fixture: rootOf stops here, and a real
+# rust-analyzer pointed at this directory (instead of the mock) finds a
+# well-formed, dependency-free crate.
+[package]
+name = "lspfixture-client"
+version = "0.0.0"
+edition = "2021"
+
+[[bin]]
+name = "lspfixture-client"
+path = "main.rs"
diff --git a/test/lspfixture/client/lib.rs b/test/lspfixture/client/lib.rs
new file mode 100644
index 00000000..2887517e
--- /dev/null
+++ b/test/lspfixture/client/lib.rs
@@ -0,0 +1,4 @@
+// mock fixture
+pub fn add(a: i32, b: i32) -> i32 {
+ a + b
+}
diff --git a/test/lspfixture/client/main.rs b/test/lspfixture/client/main.rs
new file mode 100644
index 00000000..ab29f8bc
--- /dev/null
+++ b/test/lspfixture/client/main.rs
@@ -0,0 +1,7 @@
+mod lib;
+
+fn main() {
+ let x = add(1, 2);
+ let y = add(3, 4);
+ let _ = x + y;
+}
diff --git a/test/lspmock.zig b/test/lspmock.zig
new file mode 100644
index 00000000..7b379b49
--- /dev/null
+++ b/test/lspmock.zig
@@ -0,0 +1,271 @@
+//! A deterministic language server for the protocol client's tests.
+//!
+//! Speaks just enough LSP over stdin/stdout to pin the client end to end
+//! WITHOUT a real analyser in the loop: every answer is a fixed shape over
+//! the request's own uri, so a golden can quote it byte for byte. The
+//! fixture it narrates is test/lspfixture/client/ — the ranges below are
+//! POSITIONS IN THOSE FILES, and moving either without the other breaks the
+//! snapshot, which is the point.
+//!
+//! The snapshot harness points the client at this binary with
+//! `PARDES_LSP_RS=<abs path>`; nothing outside a test should ever run it.
+//!
+//! `LSPMOCK_MODE` selects a failure personality for the degradation tests:
+//! (unset) answer everything
+//! mute handshake, then never answer another request (timeout path)
+//! die exit right after the handshake (dead-server path)
+//! utf16 refuse the utf-8 negotiation (column-conversion path)
+const std = @import("std");
+const libc = std.c;
+
+extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8;
+
+var mode: enum { normal, mute, die, utf16 } = .normal;
+
+/// stdout, written with plain libc write(2): the transport is a socketpair
+/// and the frames are small, so partial writes are retried and nothing else
+/// is needed.
+fn send(body: []const u8) void {
+ var hdr_buf: [64]u8 = undefined;
+ const hdr = std.fmt.bufPrint(&hdr_buf, "Content-Length: {d}\r\n\r\n", .{body.len}) catch return;
+ writeAll(hdr);
+ writeAll(body);
+}
+
+fn writeAll(bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = libc.write(1, bytes.ptr + off, bytes.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ std.process.exit(0); // client hung up; nothing left to say
+ }
+ off += @intCast(n);
+ }
+}
+
+fn reply(a: std.mem.Allocator, id: i64, comptime result_fmt: []const u8, args: anytype) void {
+ const body = std.fmt.allocPrint(a, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"result\":" ++ result_fmt ++ "}}", .{id} ++ args) catch return;
+ send(body);
+}
+
+fn notify(a: std.mem.Allocator, comptime method: []const u8, comptime params_fmt: []const u8, args: anytype) void {
+ const body = std.fmt.allocPrint(a, "{{\"jsonrpc\":\"2.0\",\"method\":\"" ++ method ++ "\",\"params\":" ++ params_fmt ++ "}}", args) catch return;
+ send(body);
+}
+
+// std.json.Value navigation, the client's own idiom.
+fn get(v: ?std.json.Value, key: []const u8) ?std.json.Value {
+ const o = v orelse return null;
+ if (o != .object) return null;
+ return o.object.get(key);
+}
+fn str(v: ?std.json.Value) ?[]const u8 {
+ const o = v orelse return null;
+ return if (o == .string) o.string else null;
+}
+fn num(v: ?std.json.Value) ?i64 {
+ const o = v orelse return null;
+ return if (o == .integer) o.integer else null;
+}
+
+/// The request's document uri — under `textDocument` for most methods, under
+/// `item` for the hierarchy follow-ups. Every cross-file answer derives from
+/// it, so the mock stays inside the caller's own fixture tree.
+fn reqUri(params: ?std.json.Value) []const u8 {
+ return str(get(get(params, "textDocument"), "uri")) orelse
+ str(get(get(params, "item"), "uri")) orelse "file:///dev/null";
+}
+
+/// The same uri with its basename replaced.
+fn sibling(a: std.mem.Allocator, uri: []const u8, name: []const u8) []const u8 {
+ const cut = std.mem.lastIndexOfScalar(u8, uri, '/') orelse return uri;
+ return std.fmt.allocPrint(a, "{s}/{s}", .{ uri[0..cut], name }) catch uri;
+}
+
+/// `{"start":{"line":L,"character":A},"end":{"line":L,"character":B}}`
+fn range(a: std.mem.Allocator, line: u32, from: u32, to: u32) []const u8 {
+ return std.fmt.allocPrint(
+ a,
+ "{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}}",
+ .{ line, from, line, to },
+ ) catch "{}";
+}
+
+/// rootUri captured at initialize — workspace/symbol has no textDocument, so
+/// its answers derive from here instead of the /dev/null fallback.
+var root_uri: [512]u8 = undefined;
+var root_uri_len: usize = 0;
+
+pub fn main(init: std.process.Init) !void {
+ _ = init;
+ if (getenv("LSPMOCK_MODE")) |m| {
+ const s = std.mem.span(m);
+ if (std.mem.eql(u8, s, "mute")) mode = .mute;
+ if (std.mem.eql(u8, s, "die")) mode = .die;
+ if (std.mem.eql(u8, s, "utf16")) mode = .utf16;
+ }
+
+ const gpa = std.heap.page_allocator;
+ var buf = try gpa.alloc(u8, 1 << 20);
+ var len: usize = 0;
+
+ while (true) {
+ // one Content-Length frame at the head of buf
+ const msg: ?[]const u8 = blk: {
+ const sep = std.mem.indexOf(u8, buf[0..len], "\r\n\r\n") orelse break :blk null;
+ var clen: ?usize = null;
+ var it = std.mem.splitSequence(u8, buf[0..sep], "\r\n");
+ while (it.next()) |ln| {
+ const colon = std.mem.indexOfScalar(u8, ln, ':') orelse continue;
+ if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, ln[0..colon], " "), "content-length"))
+ clen = std.fmt.parseInt(usize, std.mem.trim(u8, ln[colon + 1 ..], " "), 10) catch null;
+ }
+ const n = clen orelse std.process.exit(1);
+ if (len < sep + 4 + n) break :blk null;
+ break :blk buf[sep + 4 .. sep + 4 + n];
+ };
+ const raw = msg orelse {
+ if (len == buf.len) buf = try gpa.realloc(buf, buf.len * 2);
+ const got = libc.read(0, buf.ptr + len, buf.len - len);
+ if (got < 0) {
+ if (libc.errno(got) == .INTR) continue;
+ return;
+ }
+ if (got == 0) return; // editor hung up
+ len += @intCast(got);
+ continue;
+ };
+
+ var arena_state: std.heap.ArenaAllocator = .init(gpa);
+ defer arena_state.deinit();
+ const a = arena_state.allocator();
+
+ handle(a, raw);
+
+ // compact the consumed frame away
+ const consumed = (@intFromPtr(raw.ptr) - @intFromPtr(buf.ptr)) + raw.len;
+ std.mem.copyForwards(u8, buf[0 .. len - consumed], buf[consumed..len]);
+ len -= consumed;
+ }
+}
+
+fn handle(a: std.mem.Allocator, raw: []const u8) void {
+ const v = std.json.parseFromSliceLeaky(std.json.Value, a, raw, .{}) catch return;
+ const method = str(get(v, "method")) orelse "";
+ const id = num(get(v, "id"));
+ const params = get(v, "params");
+
+ if (std.mem.eql(u8, method, "initialize")) {
+ if (str(get(params, "rootUri"))) |r| {
+ root_uri_len = @min(r.len, root_uri.len);
+ @memcpy(root_uri[0..root_uri_len], r[0..root_uri_len]);
+ }
+ const enc: []const u8 = if (mode == .utf16) "utf-16" else "utf-8";
+ reply(a, id orelse 0,
+ \\{{"capabilities":{{"positionEncoding":"{s}",
+ \\"textDocumentSync":1,
+ \\"definitionProvider":true,"declarationProvider":true,
+ \\"typeDefinitionProvider":true,"implementationProvider":true,
+ \\"referencesProvider":true,"documentHighlightProvider":true,
+ \\"hoverProvider":true,"documentSymbolProvider":true,
+ \\"workspaceSymbolProvider":true,"renameProvider":true,
+ \\"documentFormattingProvider":true,"codeActionProvider":true,
+ \\"completionProvider":{{}},
+ \\"callHierarchyProvider":true,"typeHierarchyProvider":true,
+ \\"diagnosticProvider":{{"interFileDependencies":false,"workspaceDiagnostics":false}},
+ \\"workspace":{{"workspaceFolders":{{"supported":true}}}}}},
+ \\"serverInfo":{{"name":"lspmock"}}}}
+ , .{enc});
+ return;
+ }
+ if (std.mem.eql(u8, method, "initialized")) {
+ if (mode == .die) std.process.exit(0);
+ // the progress trio, so the status pipeline has something to narrate
+ notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"begin\",\"title\":\"mocking\"}}}}", .{});
+ notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"report\",\"percentage\":50,\"message\":\"halfway\"}}}}", .{});
+ notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"end\"}}}}", .{});
+ return;
+ }
+ if (id == null) return; // didOpen/didChange/$/cancelRequest: nothing to say
+
+ if (mode == .mute) return; // swallow every request after the handshake
+
+ const rid = id.?;
+ const uri = reqUri(params);
+
+ // ---- fixture geometry (0-based lines/chars, over lspfixture/client/) --
+ // main.rs L3/L4: ` let x = add(1, 2);` — "add" at 12..15
+ // lib.rs L1: `pub fn add(a: i32, b: i32) -> i32 {` — "add" at 7..10
+ // main.rs L2: `fn main() {` — "main" at 3..7
+ const lib = sibling(a, uri, "lib.rs");
+ const mainrs = sibling(a, uri, "main.rs");
+
+ if (std.mem.eql(u8, method, "textDocument/definition") or
+ std.mem.eql(u8, method, "textDocument/declaration") or
+ std.mem.eql(u8, method, "textDocument/typeDefinition") or
+ std.mem.eql(u8, method, "textDocument/implementation"))
+ {
+ reply(a, rid, "[{{\"uri\":\"{s}\",\"range\":{s}}}]", .{ lib, range(a, 1, 7, 10) });
+ } else if (std.mem.eql(u8, method, "textDocument/references")) {
+ reply(a, rid, "[{{\"uri\":\"{s}\",\"range\":{s}}},{{\"uri\":\"{s}\",\"range\":{s}}},{{\"uri\":\"{s}\",\"range\":{s}}}]", .{
+ mainrs, range(a, 3, 12, 15),
+ mainrs, range(a, 4, 12, 15),
+ lib, range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/documentHighlight")) {
+ reply(a, rid, "[{{\"range\":{s}}},{{\"range\":{s}}}]", .{ range(a, 3, 12, 15), range(a, 4, 12, 15) });
+ } else if (std.mem.eql(u8, method, "textDocument/hover")) {
+ reply(a, rid, "{{\"contents\":{{\"kind\":\"markdown\",\"value\":\"mock hover: pub fn add\"}}}}", .{});
+ } else if (std.mem.eql(u8, method, "textDocument/documentSymbol")) {
+ reply(a, rid, "[{{\"name\":\"main\",\"kind\":12,\"range\":{s},\"selectionRange\":{s}}}]", .{
+ range(a, 2, 0, 11), range(a, 2, 3, 7),
+ });
+ } else if (std.mem.eql(u8, method, "workspace/symbol")) {
+ const q = str(get(params, "query")) orelse "";
+ reply(a, rid, "[{{\"name\":\"{s}_sym\",\"kind\":12,\"location\":{{\"uri\":\"{s}/lib.rs\",\"range\":{s}}}}}]", .{
+ q, root_uri[0..root_uri_len], range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/rename")) {
+ const nn = str(get(params, "newName")) orelse "renamed";
+ reply(a, rid, "{{\"changes\":{{\"{s}\":[{{\"range\":{s},\"newText\":\"{s}\"}},{{\"range\":{s},\"newText\":\"{s}\"}}]}}}}", .{
+ uri, range(a, 3, 12, 15), nn, range(a, 4, 12, 15), nn,
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/formatting")) {
+ reply(a, rid, "[{{\"range\":{s},\"newText\":\"// formatted by mock\\n\"}}]", .{range(a, 0, 0, 0)});
+ } else if (std.mem.eql(u8, method, "textDocument/codeAction")) {
+ reply(a, rid, "[{{\"title\":\"mock action: extract\"}}]", .{});
+ } else if (std.mem.eql(u8, method, "textDocument/completion")) {
+ reply(a, rid, "{{\"items\":[{{\"label\":\"alpha\",\"detail\":\"fn alpha()\"}},{{\"label\":\"beta\",\"detail\":\"fn beta()\"}}]}}", .{});
+ } else if (std.mem.eql(u8, method, "textDocument/diagnostic")) {
+ reply(a, rid, "{{\"kind\":\"full\",\"items\":[{{\"range\":{s},\"severity\":2,\"message\":\"mock diagnostic\"}}]}}", .{range(a, 0, 0, 7)});
+ } else if (std.mem.eql(u8, method, "textDocument/prepareCallHierarchy")) {
+ reply(a, rid, "[{{\"name\":\"add\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ lib, range(a, 1, 0, 35), range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "callHierarchy/incomingCalls")) {
+ reply(a, rid, "[{{\"from\":{{\"name\":\"main\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}},\"fromRanges\":[{s},{s}]}}]", .{
+ mainrs, range(a, 2, 0, 11), range(a, 2, 3, 7), range(a, 3, 12, 15), range(a, 4, 12, 15),
+ });
+ } else if (std.mem.eql(u8, method, "callHierarchy/outgoingCalls")) {
+ reply(a, rid, "[{{\"to\":{{\"name\":\"callee\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}},\"fromRanges\":[]}}]", .{
+ lib, range(a, 1, 0, 35), range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/prepareTypeHierarchy")) {
+ reply(a, rid, "[{{\"name\":\"MockType\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ uri, range(a, 2, 3, 7), range(a, 2, 3, 7),
+ });
+ } else if (std.mem.eql(u8, method, "typeHierarchy/supertypes")) {
+ reply(a, rid, "[{{\"name\":\"SuperMock\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ lib, range(a, 1, 7, 10), range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "typeHierarchy/subtypes")) {
+ reply(a, rid, "[{{\"name\":\"SubMock\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ mainrs, range(a, 2, 3, 7), range(a, 2, 3, 7),
+ });
+ } else if (std.mem.eql(u8, method, "shutdown")) {
+ reply(a, rid, "null", .{});
+ } else {
+ reply(a, rid, "null", .{});
+ }
+}
diff --git a/test/snapshot.zig b/test/snapshot.zig
index 7f8c4f21..5dc2e6e9 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -79,6 +79,9 @@ const SNAP_BASE = "/tmp/pardes-snap";
var trace_stable = false;
/// set for the serial re-run pass; children widen their probes (see beCareful)
var careful = false;
+/// abs path of the deterministic mock language server the build handed over
+/// (--lspmock=); null leaves the protocol client fully disabled for the run
+var lspmock_bin: ?[:0]const u8 = null;
const gpa = std.heap.page_allocator;
pub fn main(init: std.process.Init) !void {
@@ -102,11 +105,14 @@ pub fn main(init: std.process.Init) !void {
const cpus = std.Thread.getCpuCount() catch 4;
var jobs: usize = cpus + cpus / 4;
var script_count: usize = 0;
+ var lspmock: ?[]const u8 = null;
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update")) {
update = true;
} else if (std.mem.eql(u8, a, "--trace-stable")) {
trace_stable = true;
+ } else if (std.mem.startsWith(u8, a, "--lspmock=")) {
+ lspmock = try absPath(arena, orig_cwd, a["--lspmock=".len..]);
} else if (std.mem.startsWith(u8, a, "--jobs=")) {
jobs = @max(1, try std.fmt.parseInt(usize, a["--jobs=".len..], 10));
} else if (exe == null) {
@@ -129,6 +135,7 @@ pub fn main(init: std.process.Init) !void {
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update") or
std.mem.eql(u8, a, "--trace-stable") or
+ std.mem.startsWith(u8, a, "--lspmock=") or
std.mem.startsWith(u8, a, "--jobs=")) continue;
if (!saw_exe) {
saw_exe = true;
@@ -139,6 +146,7 @@ pub fn main(init: std.process.Init) !void {
}
if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update] [--jobs=N]", .{});
const exe_z = try arena.dupeZ(u8, exe.?);
+ lspmock_bin = if (lspmock) |p| try arena.dupeZ(u8, p) else null;
if (scripts.len == 0) {
scripts = try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"));
@@ -389,6 +397,15 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
// views' durations (lsp_zls.zig hideTime) and the transient message row's
// time of day (message.zig stamp)
_ = setenv("PARDES_NOTIME", "1", 1);
+ // The protocol client must be HERMETIC under the harness: `.rs` files
+ // speak to the deterministic mock when the build handed one over, and
+ // every other spec is disabled outright — a script that opened a stray
+ // `.c` or `.ts` file must never spawn whatever clangd this machine has.
+ _ = setenv("PARDES_LSP_RS", lspmock_bin orelse "", 1);
+ _ = setenv("PARDES_LSP_C", "", 1);
+ _ = setenv("PARDES_LSP_GO", "", 1);
+ _ = setenv("PARDES_LSP_TS", "", 1);
+ _ = setenv("PARDES_LSP_PY", "", 1);
// Pin the dump path: live runs use a timestamped file under the user data
// dir (dump.outPath), which would make the dump/load/restore goldens
// nondeterministic. Per-SCRIPT, not one shared /tmp/pardes-dump.zon: four
diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index 5019c855..555a6fc4 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -42,39 +42,39 @@
|64: 30 SPC h t Tutor topbar
|65: 31 SPC j i Forward C-i
== snap index-tail grid=120x66 cursor=7,64
-|35: 49 SPC s a Attach
-|36: 50 SPC s d Dump topbar
-|37: 51 SPC s r Restore
-|38: 52 SPC t a Ascii
-|39: 53 SPC t b Tagbottom
-|40: 54 SPC t c Colors
-|41: 55 SPC t d Debug topbar
-|42: 56 SPC t i PdfTint
-|43: 57 SPC t l Palette
-|44: 58 SPC t n NextColor topbar
-|45: 59 SPC t p Petscii
-|46: 60 SPC t s PdfSections
-|47: 61 SPC t t ThemeSel
-|48: 62 SPC t w Wrap
-|49: 63 SPC t z PdfFit
-|50: 64 SPC w h Left C-w h, C-w left
-|51: 65 SPC w j Down C-w j, C-w down
-|52: 66 SPC w k Up C-w k, C-w up
-|53: 67 SPC w l Right C-w l, C-w right
-|54: 68 SPC y ClipYank
-|55: 69 Look enter, right-click
-|56: 70 Exec tab, middle-click
-|57: 71 Msg
-|58: 72 ThemeFile
-|59: 73 DumpThemes
-|60: 74 Filter
-|61: 75 EffectCode
-|62: 76 9p
-|63: 77 Theme
-|64: 78 Shell
-|65: 79
-== snap not-a-picker grid=120x66 cursor=7,64
-== snap window-group grid=120x66 cursor=7,35
+|35: 66 SPC t w Wrap
+|36: 67 SPC t z PdfFit
+|37: 68 SPC w h Left C-w h, C-w left
+|38: 69 SPC w j Down C-w j, C-w down
+|39: 70 SPC w k Up C-w k, C-w up
+|40: 71 SPC w l Right C-w l, C-w right
+|41: 72 SPC y ClipYank
+|42: 73 Look enter, right-click
+|43: 74 Exec tab, middle-click
+|44: 75 Msg
+|45: 76 ThemeFile
+|46: 77 DumpThemes
+|47: 78 Filter
+|48: 79 EffectCode
+|49: 80 9p
+|50: 81 Theme
+|51: 82 Shell
+|52: 83
+|53: 84 language keys (motions, not words):
+|54: 85 gd gD gy gi gr goto: definition,
+|55: 86 declaration, type-def,
+|56: 87 implementation, refs
+|57: 88 ]d [d ]D [D diagnostics: next,
+|58: 89 prev, last, first
+|59: 90 = format (applies, one
+|60: 91 undo step)
+|61: 92 Tab after a . completion, in insert
+|62: 93 C-left-click definition, by mouse
+|63: 94 SPC l ... hover, rename, symbols,
+|64: 95 calls, types: above
+|65: 96
+== snap not-a-picker grid=120x66 cursor=19,61
+== snap window-group grid=120x66 cursor=19,35
|35: 1 pardes builtins under SPC w
|36: 2
|37: 3 SPC w h Left C-w h, C-w left
diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap
index ad8e27f3..4eaa3543 100644
--- a/test/snapshots/builtins.snap
+++ b/test/snapshots/builtins.snap
@@ -55,12 +55,15 @@ snap window-group
# which is the whole reason n/N were never allowed to be the picker.
#
# The one place in this suite a builtin cannot be added for free: the click is
-# a SCREEN coordinate, so every row inserted above `SPC h t` in the listing
-# moves Tutor down one and this number with it. The five clipboard builtins
-# put three there — SPC P, SPC R and SPC Y, uppercase sorting ahead of the
-# lowercase `SPC h`; the eleven panel effects now follow them as well.
+# a SCREEN COORDINATE. The rows above `SPC h t` in the listing move Tutor and
+# this number with them — the five clipboard builtins put three there. And
+# since the language-keys footer made the page longer than the pane, the
+# re-opened view RETAINS the scroll the `ge` above left behind, which is why
+# `gg` now pins the top before the click: an exec aimed one row into the
+# footer would run "declaration," in a shell instead of opening the tutor.
key space ?
wait 10000 SPC c n
+key g g
stable 700 15000
press middle 20 65
release middle 20 65
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 38b4f9dc..5d3adba2 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -160,17 +160,21 @@
|36:
== snap help-language grid=100x40 cursor=7,22
|22: 1 pardes builtins under SPC l
-|24: 3 SPC l D WsDiagnostics
-|25: 4 SPC l S WsSymbols
-|26: 5 SPC l a CodeAction
-|27: 6 SPC l d Diagnostics
-|28: 7 SPC l h SelectRefs
-|29: 8 SPC l i Lspinfo
-|30: 9 SPC l k Hover
-|31: 10 SPC l r Rename
-|32: 11 SPC l s Symbols
-|33: 12 SPC l w Lspwhy
-|34: 13
+|24: 3 SPC l C Callees
+|25: 4 SPC l D WsDiagnostics
+|26: 5 SPC l S WsSymbols
+|27: 6 SPC l T Subtypes
+|28: 7 SPC l a CodeAction
+|29: 8 SPC l c Callers
+|30: 9 SPC l d Diagnostics
+|31: 10 SPC l h SelectRefs
+|32: 11 SPC l i Lspinfo
+|33: 12 SPC l k Hover
+|34: 13 SPC l r Rename
+|35: 14 SPC l s Symbols
+|36: 15 SPC l t Supertypes
+|37: 16 SPC l w Lspwhy
+|38: 17
== snap del-output grid=100x40 cursor=7,2
|21:
|22:
@@ -186,3 +190,7 @@
|32:
|33:
|34:
+|35:
+|36:
+|37:
+|38:
diff --git a/test/snapshots/lsp-client-edit.golden b/test/snapshots/lsp-client-edit.golden
new file mode 100644
index 00000000..15a477b2
--- /dev/null
+++ b/test/snapshots/lsp-client-edit.golden
@@ -0,0 +1,44 @@
+== snap formatted grid=100x24 cursor=7,3
+|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/lsp-client-edit/cwd/main.rs * Save New Newtty Del
+| 1 // formatted by mock
+| 2 mod lib;
+| 3
+| 4 fn main() {
+| 5 let x = add(1, 2);
+| 6 let y = add(3, 4);
+| 7 let _ = x + y;
+| 8 }
+| 9
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| formatted 1 range(s)
+== snap unformatted grid=100x24 cursor=7,2
+|2: 1 mod lib;
+|3: 2
+|4: 3 fn main() {
+|5: 4 let x = add(1, 2);
+|6: 5 let y = add(3, 4);
+|7: 6 let _ = x + y;
+|8: 7 }
+|9: 8
+|10:
+|23:
+== snap renamed grid=100x24 cursor=12,4
+|5: 4 let x = sum(1, 2);
+|6: 5 let y = sum(3, 4);
+|23: renamed 2 range(s)
+== snap unrenamed grid=100x24 cursor=12,4
+|5: 4 let x = add(1, 2);
+|6: 5 let y = add(3, 4);
+|23:
diff --git a/test/snapshots/lsp-client-edit.snap b/test/snapshots/lsp-client-edit.snap
new file mode 100644
index 00000000..a9e66e51
--- /dev/null
+++ b/test/snapshots/lsp-client-edit.snap
@@ -0,0 +1,35 @@
+# The protocol client's MUTATING answers, through the @put record path: a
+# server TextEdit response becomes one atomic buffer edit with one undo step.
+# = applies the mock's formatting edit (a header line) and narrates
+# "formatted 1 range(s)" on the message row
+# SPC l r renames both `add` calls via the server's WorkspaceEdit
+# u reverses each as a single transaction
+file main.rs mod lib;\n\nfn main() {\n let x = add(1, 2);\n let y = add(3, 4);\n let _ = x + y;\n}\n
+file lib.rs // mock fixture\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n
+start 24 100 main.rs
+wait 8000 add(1, 2)
+stable 700 20000
+key =
+wait 10000 formatted by mock
+stable 700 15000
+snap formatted
+key u
+wait 8000 mod lib;
+stable 700 15000
+snap unformatted
+# rename through the leader prompt: both call sites change in one step
+press left 13 5
+release left 13 5
+key space
+key l
+key r
+stable 400 5000
+text sum
+key enter
+wait 10000 sum(3, 4)
+stable 700 15000
+snap renamed
+key u
+wait 8000 add(3, 4)
+stable 700 15000
+snap unrenamed
diff --git a/test/snapshots/lsp-client.golden b/test/snapshots/lsp-client.golden
new file mode 100644
index 00000000..a203cc7c
--- /dev/null
+++ b/test/snapshots/lsp-client.golden
@@ -0,0 +1,27 @@
+== snap gd-jumped grid=100x24 cursor=16,15
+|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/lsp-client/cwd/main.rs Save New Newtty Del
+| 1 mod lib;
+| 2
+| 3 fn main() {
+| 4 let x = add(1, 2);
+| 5 let y = add(3, 4);
+| 6 let _ = x + y;
+| 7 }
+| 8
+|
+|
+| --:--:-- lsp rust-analyzer: ready
+| /tmp/pardes-snap/lsp-client/cwd/lib.rs Save New Newtty Del
+| 1 // mock fixture
+| 2 pub fn add(a: i32, b: i32) -> i32 {
+| 3 a + b
+| 4 }
+| 5
+== snap gr-list grid=100x24 cursor=16,15
+|12:
+|20: /tmp/pardes-snap/lsp-client/cwd/+Search Save New Newtty Del
+|21: 1 main.rs:4:13-15 let x = add(1, 2);
+|22: 2 main.rs:5:13-15 let y = add(3, 4);
+|23: 3 lib.rs:2:8-10 pub fn add(a: i32, b: i32) -> i32 {
+== snap gr-opened grid=100x24 cursor=21,6
diff --git a/test/snapshots/lsp-client.snap b/test/snapshots/lsp-client.snap
new file mode 100644
index 00000000..bc5267cc
--- /dev/null
+++ b/test/snapshots/lsp-client.snap
@@ -0,0 +1,34 @@
+# The protocol client end to end, against the deterministic mock server the
+# harness pins via PARDES_LSP_RS (test/lspmock.zig). What this exercises is
+# everything BETWEEN the keymap and the analyser: spawn, handshake, the reader
+# thread, $/progress narration onto the transient message row, and the
+# location shapes coming back as +Search rows.
+# gd on a cross-file Location -> a look: lib.rs opens with `add` selected,
+# and the message row still carries the server's settled state
+# ("lsp lspmock: ready") because nothing has cleared it yet.
+# gr -> three rows with column SPANS, n arms the walk, Enter opens one.
+file main.rs mod lib;\n\nfn main() {\n let x = add(1, 2);\n let y = add(3, 4);\n let _ = x + y;\n}\n
+file lib.rs // mock fixture\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n
+start 24 100 main.rs
+wait 8000 add(1, 2)
+stable 700 20000
+# cursor into the first `add` call, then helix's goto-definition
+press left 13 5
+release left 13 5
+key g
+key d
+wait 10000 pub fn add
+stable 700 15000
+snap gd-jumped
+# references from the definition the look landed on (cursor sits on `add`)
+key g
+key r
+wait 10000 add(3, 4)
+stable 700 15000
+snap gr-list
+# step the list and open the second hit: main.rs with the span selected
+key n
+key n
+key enter
+stable 700 15000
+snap gr-opened
diff --git a/test/snapshots/lsp-rename.golden b/test/snapshots/lsp-rename.golden
index e21228e4..a241a93c 100644
--- a/test/snapshots/lsp-rename.golden
+++ b/test/snapshots/lsp-rename.golden
@@ -16,9 +16,17 @@
| 13 _ = helper;
| 14 }
| 15
+|
+|
+|
+|
+|
+|
+| renamed 2 range(s)
== snap undone grid=110x24 cursor=17,9
|4: 3 fn helper(x: u32) u32 {
|9: 8 _ = helper(41);
+|23:
== snap redone grid=110x24 cursor=17,9
|4: 3 fn renamed_helper(x: u32) u32 {
|9: 8 _ = renamed_helper(41);
diff --git a/test/snapshots/lspdebug.golden b/test/snapshots/lspdebug.golden
index 91ab400a..cd9ac0c7 100644
--- a/test/snapshots/lspdebug.golden
+++ b/test/snapshots/lspdebug.golden
@@ -27,7 +27,7 @@
|43: 9 0 row(s)
== snap info grid=120x44 cursor=7,4
|18: /tmp/pardes-snap/lspdebug/cwd/+Lsp Save New Newtty Del
-|19: 1 backend: zls-inproc
+|19: 1 backend: zls-inproc+lsp-client
|20: 2 zls: 0.16.1-dev+3e0d0820 (compiled in — no server process, no JSON-RPC)
|21: 3 zig lib dir: /usr/lib/zig [OK]
|22: 4 offsets: utf-8 walk caps: 512 files, 2000 rows
diff --git a/tools/lspprobe.zig b/tools/lspprobe.zig
new file mode 100644
index 00000000..8b953cec
--- /dev/null
+++ b/tools/lspprobe.zig
@@ -0,0 +1,184 @@
+//! One language query against the REAL seam, from the command line.
+//!
+//! pardes-lspprobe <kind> <file> <line>:<col> [arg] [--reps N]
+//!
+//! `kind` is an `lsp.Kind` tag (`definition`, `references`, ...) or its helix
+//! key (`gd`, `gr`, `gy`, `gi`, `gD`). Line and column are 1-based, the way
+//! the rows print them. Rows go to stdout; the timing line goes to stderr so
+//! `lspprobe gd f.rs 10:5 | head` still composes.
+//!
+//! This is the development probe for the PROTOCOL CLIENT: point it at a file
+//! in any workspace (a rust crate, a C tree with compile_commands.json) and it
+//! exercises exactly what `gd` in the editor would — same seam, same client,
+//! same child server. The server is spawned fresh per invocation and kept for
+//! the process, so rep 0 is the true cold cost (spawn + handshake + whatever
+//! indexing the deadline allows) and later reps are the warm per-press cost.
+//! Generic on purpose: no project names anywhere — the workspace is whatever
+//! the file's root markers say.
+const std = @import("std");
+const lsp = @import("pardes").lsp;
+
+extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
+
+pub const std_options: std.Options = .{ .log_level = .err };
+
+fn nowUs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000;
+}
+
+const aliases = .{
+ .{ "gd", lsp.Kind.definition },
+ .{ "gD", lsp.Kind.declaration },
+ .{ "gy", lsp.Kind.type_definition },
+ .{ "gi", lsp.Kind.implementation },
+ .{ "gr", lsp.Kind.references },
+};
+
+fn usage() noreturn {
+ std.debug.print(
+ \\usage: pardes-lspprobe <kind> <file> <line>:<col> [arg] [--reps N] [--warm S]
+ \\ kind: gd gD gy gi gr, or any lsp.Kind tag (hover, references,
+ \\ document_symbols, workspace_symbols, incoming_calls, ...)
+ \\ arg: the new name for rename, the query for workspace_symbols
+ \\ --warm sleep S seconds after the first (cold) query — long enough
+ \\ for an indexing server to reach ready, narrated on stderr
+ \\
+ , .{});
+ std.process.exit(2);
+}
+
+/// The editor's message row, here: the client's reader threads narrate server
+/// state through the same sink the shells register, printed to stderr so the
+/// rows on stdout stay clean.
+fn statusSink(ctx: ?*anyopaque, text: []const u8) void {
+ _ = ctx;
+ std.debug.print("# status: {s}\n", .{text});
+}
+
+extern "c" fn usleep(usec: c_uint) c_int;
+
+pub fn main(init: std.process.Init) !void {
+ const gpa = std.heap.page_allocator;
+ const arena = init.arena.allocator();
+ const io = std.Io.Threaded.global_single_threaded.io();
+
+ const args = try init.minimal.args.toSlice(arena);
+ if (args.len < 4) usage();
+
+ // comma-separated kinds share ONE server instance and its index — the
+ // whole battery against a big workspace pays the cold cost once
+ var kinds: [16]lsp.Kind = undefined;
+ var nkinds: usize = 0;
+ var kit = std.mem.tokenizeScalar(u8, args[1], ',');
+ while (kit.next()) |word| {
+ if (nkinds == kinds.len) usage();
+ kinds[nkinds] = blk: {
+ inline for (aliases) |a| if (std.mem.eql(u8, word, a[0])) break :blk a[1];
+ break :blk std.meta.stringToEnum(lsp.Kind, word) orelse usage();
+ };
+ nkinds += 1;
+ }
+ if (nkinds == 0) usage();
+
+ var pz: [4096:0]u8 = undefined;
+ const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{args[2]}, 0) catch usage();
+ var realbuf: [4096]u8 = undefined;
+ const path = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse {
+ std.debug.print("cannot resolve {s}\n", .{args[2]});
+ std.process.exit(1);
+ });
+ const source = std.Io.Dir.cwd().readFileAllocOptions(
+ io,
+ path,
+ arena,
+ .limited(64 << 20),
+ .of(u8),
+ 0,
+ ) catch {
+ std.debug.print("cannot read {s}\n", .{path});
+ std.process.exit(1);
+ };
+
+ const colon = std.mem.indexOfScalar(u8, args[3], ':') orelse usage();
+ const line = std.fmt.parseInt(u32, args[3][0..colon], 10) catch usage();
+ const col = std.fmt.parseInt(u32, args[3][colon + 1 ..], 10) catch usage();
+ if (line == 0 or col == 0) usage();
+
+ var arg: []const u8 = "";
+ var reps: u32 = 1;
+ var warm_s: u32 = 0;
+ var i: usize = 4;
+ while (i < args.len) : (i += 1) {
+ if (std.mem.eql(u8, args[i], "--reps")) {
+ i += 1;
+ if (i >= args.len) usage();
+ reps = @max(1, std.fmt.parseInt(u32, args[i], 10) catch usage());
+ } else if (std.mem.eql(u8, args[i], "--warm")) {
+ i += 1;
+ if (i >= args.len) usage();
+ warm_s = std.fmt.parseInt(u32, args[i], 10) catch usage();
+ } else arg = args[i];
+ }
+
+ lsp.setStatusSink(null, statusSink);
+
+ // 1-based line:col -> byte offset
+ var off: usize = 0;
+ var ln: u32 = 1;
+ while (ln < line and off < source.len) : (off += 1) {
+ if (source[off] == '\n') ln += 1;
+ }
+ off = @min(off + col - 1, source.len);
+
+ const root = std.fs.path.dirname(path) orelse "/";
+ const timings = try arena.alloc(u64, reps);
+ var stdout_buf: [64 * 1024]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(io, &stdout_buf);
+ var first_query = true;
+
+ for (kinds[0..nkinds]) |kind| {
+ var rows: []const u8 = "";
+ for (0..reps) |r| {
+ // one warm pause for the whole battery, right after the very
+ // first (cold, server-spawning) query — the status lines on
+ // stderr narrate what the server does with the time
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ var scratch: std.heap.ArenaAllocator = .init(gpa);
+ defer scratch.deinit();
+ const t0 = nowUs();
+ lsp.query(gpa, scratch.allocator(), .{
+ .kind = kind,
+ .path = path,
+ .source = source,
+ .offset = @intCast(off),
+ .arg = arg,
+ .root = root,
+ }, &out.writer);
+ timings[r] = nowUs() -| t0;
+ rows = try arena.dupe(u8, out.written());
+ out.deinit();
+ if (first_query) {
+ first_query = false;
+ if (warm_s > 0) for (0..warm_s * 10) |_| {
+ _ = usleep(100_000);
+ };
+ }
+ }
+
+ try stdout.interface.print("## {t}\n", .{kind});
+ try stdout.interface.writeAll(rows);
+ try stdout.interface.flush();
+
+ const n = std.mem.count(u8, rows, "\n");
+ const cold = timings[0];
+ std.mem.sort(u64, timings[0..reps], {}, std.sort.asc(u64));
+ if (reps == 1)
+ std.debug.print("# {t}: {d} row(s) in {d}us\n", .{ kind, n, cold })
+ else
+ std.debug.print("# {t}: {d} row(s); first {d}us, min {d}us, median {d}us ({d} reps)\n", .{
+ kind, n, cold, timings[0], timings[reps / 2], reps,
+ });
+ }
+}