From f43c1e11b44e2464f0bb0b635e0abcaf3c717e23 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 28 Jul 2026 23:30:29 -0300 Subject: LSP seam: async execution model, helix keymap, evaluation harness The base every language backend plugs into. Three parts: ASYNC. The core had no request/response shape - every effect was fire-and-forget or instantaneous. A language query is the first thing that answers later, so: Effect .lsp -> shell worker -> Event .lsp_resp. tty.zig uses io.concurrent + the vaxis queue, gui.zig a detached thread + the mutex queue it already had for ptys; web no-ops it. The worker never touches the core (path/source/arg are snapshotted into an LspJob), one query in flight identified by a monotonic id so a second press makes the first answer stale, and no rows is a legal answer. KEYMAP. Helix's, verified against its default.rs rather than recalled. gd/gD/gy/gi/gr and ]d/[d had no conflicts. The SPC letters did, so pardes's own builtins moved instead of helix's: Kill k->q, Del d->wc (closing a pane is a window op, and c is helix's own close), Dump/Restore s?->f?, Tutor ht->T. A three-exception muscle-memory map is not a map. RESULTS ARE +SEARCH ROWS. path:LINE:COL text, absolute. That is what look.zig resolves and n/N step, so one row from a goto jumps and several open a buffer - helix's multi-result picker needed no picker code. Backends supply exactly one function (lsp.query) plus a supports set and a name; the base has none on purpose. zig build lspbench scores them on the same corpus: feature matrix (trusting results, not the supports flag - a claimed-but-empty kind is reported as a false claim), cold and warm latency, peak RSS. Two snapshot scripts moved. leader.snap encoded the old key paths. chordcut.snap's last two steps clicked column 5, which lands on a FILE pane, so 'key c-b' toggled nothing and the typed text was being read as normal-mode keys - the golden recorded no TTY pane and no cat -v output anywhere. Pointing them at an actual shell makes both steps assert what their comments claim, and the tty paste chord is now covered for the first time. --- src/lsp.zig | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 src/lsp.zig (limited to 'src/lsp.zig') diff --git a/src/lsp.zig b/src/lsp.zig new file mode 100644 index 00000000..62b5e1bb --- /dev/null +++ b/src/lsp.zig @@ -0,0 +1,141 @@ +//! The language-intelligence seam. +//! +//! The core never speaks a protocol and never blocks. It emits an `lsp` Effect +//! naming a Kind, a file and a byte offset; a shell runs `query` on a worker +//! and posts the answer back as an `lsp_resp` Event. That is the whole async +//! execution model — the same shape the pty readers already use, because a +//! language query is just another thing that answers later. +//! +//! Every backend renders into ONE format: `+Search` rows. A location is +//! `path:LINE:COL text`, which is what look.zig already resolves and what n/N +//! already steps, so a multi-result answer IS helix's picker and a single +//! result IS a jump — with no picker UI written for it. Free text (hover, +//! a rename's diff) rides the same buffer as plain lines. +//! +//! `query` is the ONLY thing an implementation supplies. Swapping backends is +//! swapping this one function, which is also how the three competing +//! implementations are measured against each other: same core, same harness, +//! same rows, different `query`. +const std = @import("std"); + +/// What the caller wants to know. The helix command each one backs is named +/// alongside, because the keymap is helix's and these are its verbs. +pub const Kind = enum { + /// gd + definition, + /// gD + declaration, + /// gy + type_definition, + /// gi + implementation, + /// gr + references, + /// SPC k + hover, + /// SPC s + document_symbols, + /// SPC S (arg = the query) + workspace_symbols, + /// SPC d, and the list that ]d / [d step + diagnostics, + /// SPC D + workspace_diagnostics, + /// SPC r (arg = the new name) + rename, + /// SPC a + code_action, + /// = + format, + /// SPC h + select_refs, + + /// Whether an answer of exactly one row should JUMP rather than open a + /// results buffer. Helix: the five gotos jump on a single location and + /// show a picker on several; a symbol list is always a picker. + pub fn jumpsWhenSingle(k: Kind) bool { + return switch (k) { + .definition, .declaration, .type_definition, .implementation, .references => true, + else => false, + }; + } + + /// The buffer an answer opens. Kept distinct from `+Search` only where the + /// content is not a list of locations — n/N over prose is nonsense. + pub fn bufferName(k: Kind) []const u8 { + return switch (k) { + .hover => "+Hover", + .code_action, .format, .rename => "+Lsp", + else => "+Search", + }; + } +}; + +/// One question. `source` is a snapshot of the buffer taken by the shell +/// before the worker starts — the core keeps editing while this is in flight, +/// so a backend must never reach back into core memory. +pub const Req = struct { + kind: Kind, + /// absolute path of the file the offset is in + path: []const u8, + /// the buffer's bytes, NUL-terminated (std.zig.Ast and zls both want a + /// sentinel, and every backend has to parse this same text) + source: [:0]const u8, + /// cursor position, a byte offset into `source` + offset: u32, + /// kind-specific argument: the new name for a rename, the query for + /// workspace symbols. Empty otherwise. + arg: []const u8 = "", + /// where the project starts — the directory of the pane that asked. A + /// backend that indexes more than one file walks from here. + root: []const u8 = "", +}; + +/// Emit one `path:LINE:COL text` row. Line and column are 1-based, the way +/// every other row in a `+Search` buffer is (and the way look.zig parses one). +pub fn row( + gpa: std.mem.Allocator, + out: *std.ArrayList(u8), + path: []const u8, + line: usize, + col: usize, + text: []const u8, +) void { + out.print(gpa, "{s}:{d}:{d} {s}\n", .{ + path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\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 } { + const upto = source[0..@min(offset, source.len)]; + const line = std.mem.count(u8, upto, "\n"); + const bol = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |i| i + 1 else 0; + return .{ .line = line, .col = upto.len - bol }; +} + +/// Answer `req`, appending rows to `out`. Runs on a worker thread with no +/// access to the core: everything it may read is in `req`. +/// +/// `arena` is freed wholesale when the query returns; `gpa` owns only what +/// goes into `out`. Errors are not reported — a backend that cannot answer +/// appends nothing, and the core treats "no rows" as "no result", which is +/// also what a language server that is still starting up looks like. +pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.ArrayList(u8)) void { + // ponytail: the base tree has no backend on purpose — this is the seam the + // competing implementations fill in, and an empty answer is a legal one. + _ = gpa; + _ = arena; + _ = req; + _ = out; +} + +/// 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) = .initEmpty(); + +/// Name shown by the harness and in `SPC ?`. Each implementation renames it. +pub const backend_name = "none"; -- cgit v1.3