diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/builtins.zig | 28 | ||||
| -rw-r--r-- | src/config.zig | 8 | ||||
| -rw-r--r-- | src/gui/gui.zig | 27 | ||||
| -rw-r--r-- | src/look.zig | 11 | ||||
| -rw-r--r-- | src/lsp/lsp.zig | 106 | ||||
| -rw-r--r-- | src/lsp/lsp_client.zig | 2127 | ||||
| -rw-r--r-- | src/lsp/lsp_zls.zig | 36 | ||||
| -rw-r--r-- | src/output_pane.zig | 57 | ||||
| -rw-r--r-- | src/pardes.zig | 132 | ||||
| -rw-r--r-- | src/tty/tty.zig | 50 |
10 files changed, 2495 insertions, 87 deletions
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), |
