summaryrefslogtreecommitdiff
path: root/src/lsp/lsp.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 23:35:45 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit19f7322100062b7c1adcde3376063ce6c1d8c72d (patch)
treeb76347dffe0347c29a38b4cad6031678683dba74 /src/lsp/lsp.zig
parent753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff)
downloadpardes-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.zig162
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";