diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 23:35:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 19f7322100062b7c1adcde3376063ce6c1d8c72d (patch) | |
| tree | b76347dffe0347c29a38b4cad6031678683dba74 /src/lsp/lsp.zig | |
| parent | 753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff) | |
| download | pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip | |
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a
file being in no directory at all is what says it is core. Pane-kind bodies
leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving
it the layout, the event/effect machine and the generic render loop.
Builtins are one struct each in builtins.zig, and the enum is folded out of
the file's own declaration list at comptime — a zig file IS a struct, so the
list of builtins and the builtins themselves are the same text. Adding one is
writing a struct. Key paths deliberately stay one table for the config pass.
Pure refactor: no golden moved.
Diffstat (limited to 'src/lsp/lsp.zig')
| -rw-r--r-- | src/lsp/lsp.zig | 162 |
1 files changed, 162 insertions, 0 deletions
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig new file mode 100644 index 00000000..536047a3 --- /dev/null +++ b/src/lsp/lsp.zig @@ -0,0 +1,162 @@ +//! 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, + + // 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 + // rather than about the code. + /// SPC l i — configuration, capabilities and the recent-query log + status, + /// SPC l w — why the query at the cursor answers what it does. Narrates + /// the REAL resolution path rather than re-deriving it, so it cannot drift + /// away from what `gd` actually did. + explain, + + /// 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", + // prose about the backend, never a list of locations + .status, .explain => "+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( + out: *std.Io.Writer, + path: []const u8, + line: usize, + col: usize, + text: []const u8, +) void { + out.print("{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`, writing rows to `out`. Runs on a worker thread with no +/// access to the core: everything it may read is in `req`. +/// +/// `out` is a plain `std.Io.Writer` — the shell owns the buffer behind it (an +/// `Io.Writer.Allocating`), so a backend never allocates the result, never +/// frees it, and cannot get the allocator wrong. Write failures are the +/// writer's problem; a backend may ignore them. +/// +/// `arena` is freed wholesale when the query returns; `gpa` is for a backend's +/// own longer-lived scratch. Errors are not reported — a backend that cannot +/// 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); +} + +/// ZLS, linked in as a module and called directly — no subprocess, no +/// JSON-RPC. See `lsp_zls.zig`. The web shell has no threads, never emits the +/// effect, and cannot build ZLS anyway, so there it is the empty backend the +/// base tree shipped with. +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 const supports: std.EnumSet(Kind) = .initEmpty(); +}; + +/// 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; + +/// Name shown by the harness and in `SPC ?`. Each implementation renames it. +pub const backend_name = "zls-inproc"; |
