summaryrefslogtreecommitdiff
path: root/src/lsp.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-28 23:30:29 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitf43c1e11b44e2464f0bb0b635e0abcaf3c717e23 (patch)
treeddc5e5528ebb59f72d30bef49522f814578e4fce /src/lsp.zig
parent65b207c3392c75eac3f2b18a266a6482d8345df1 (diff)
downloadpardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.tar.gz
pardes-f43c1e11b44e2464f0bb0b635e0abcaf3c717e23.zip
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.
Diffstat (limited to 'src/lsp.zig')
-rw-r--r--src/lsp.zig141
1 files changed, 141 insertions, 0 deletions
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";