summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-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,
+ });
+ }
+}