diff options
29 files changed, 3378 insertions, 149 deletions
@@ -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, + }); + } +} |
