From ae9325a5cb128d0d952afb8f9feaaca68e5e37a2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 09:23:53 -0300 Subject: lsp: a protocol client for every other language, narrated on the message row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are rows in a spec table — while the in-process ZLS analyser keeps .zig. One reader thread per server owns the socket, routes responses to a mailbox under the conn mutex (monotonic condvar), answers server-to-client requests, feeds the diagnostics store, and narrates $/progress and state changes through a status sink both native shells post to the transient message row: "rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with the same clock. Chatty progress is throttled and deduplicated; settled states always land, which is also what makes the goldens deterministic. Nothing wedges and nothing healthy dies: waits are deadline-bounded, a timeout cancels and returns no rows, three consecutive timeouts restart the server ONLY while it is idle (an indexing server is narrating its own excuse), spawn and handshake failures back off 10s to 2min, a crash shortly after ready counts as a failure, and only a missing binary disables a spec. PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot harness pins RS to test/lspmock.zig and empties the rest. Mutating answers really mutate now: the @put record beside rename @edit carries per-range text, so = applies the formatter (both backends) and a same-file WorkspaceEdit rename applies atomically, one undo step, narrated ("renamed 2 range(s)"); a multi-file rename previews as rows instead of half-applying. Malformed responses fail closed: coordinates validated not clamped, one bad TextEdit poisons the whole edit set, poison frames kill the connection instead of buffering forever, decoded control bytes reject a uri, hierarchy items too deep to reserialize are skipped. Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when advertised. Help gains a language-keys footer for the motions no builtin row could carry; lsp.rel and look.grep now share one path-shortening rule. zig build lspprobe drives the seam from the CLI (comma-separated kinds share one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213 rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms. docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum. --- src/builtins.zig | 28 + src/config.zig | 8 + src/gui/gui.zig | 27 + src/look.zig | 11 +- src/lsp/lsp.zig | 106 ++- src/lsp/lsp_client.zig | 2127 ++++++++++++++++++++++++++++++++++++++++++++++++ src/lsp/lsp_zls.zig | 36 +- src/output_pane.zig | 57 +- src/pardes.zig | 132 ++- src/tty/tty.zig | 50 +- 10 files changed, 2495 insertions(+), 87 deletions(-) create mode 100644 src/lsp/lsp_client.zig (limited to 'src') 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), -- cgit v1.3