summaryrefslogtreecommitdiff
path: root/src/lsp/lsp.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/lsp/lsp.zig')
-rw-r--r--src/lsp/lsp.zig314
1 files changed, 78 insertions, 236 deletions
diff --git a/src/lsp/lsp.zig b/src/lsp/lsp.zig
index 0b4f2ba1..ca19009a 100644
--- a/src/lsp/lsp.zig
+++ b/src/lsp/lsp.zig
@@ -1,216 +1,111 @@
-//! 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.
-//!
-//! Location answers render as `+Search` rows. A location is
-//! `path:LINE:COL text` — or `path:LINE:COL-ENDCOL text` where the protocol
-//! answered with a real range, which a look then SELECTS — and that 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, formatting) rides the same buffer. Rename
-//! is the one mutating answer: it emits byte ranges through `edit`, and the core
-//! applies them atomically only while the source revision is still current.
-//!
-//! `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 — helix's
-/// bare `<space>X` spelled `SPC l X` here, because `d`, `k`, `s` and `h` were
-/// already pardes's own most-pressed leader keys and the rest follow them into
-/// the group rather than splitting the menu (see config.leader_path).
pub const Kind = enum {
- /// gd
definition,
- /// gD
declaration,
- /// gy
type_definition,
- /// gi
implementation,
- /// gr
references,
- /// SPC l k
hover,
- /// SPC l s
document_symbols,
- /// SPC l S (arg = the query)
workspace_symbols,
- /// SPC l d, and the list that ]d / [d step
diagnostics,
- /// SPC l D
workspace_diagnostics,
- /// SPC l r (arg = the new name)
rename,
- /// SPC l a
code_action,
- /// =
format,
- /// SPC l h
select_refs,
- /// Tab in insert mode, with a `.` immediately before the cursor. NOT an
- /// autocomplete popup — the seam returns locations, so this answers "what
- /// could go here, and where is each of those DEFINED": one row per
- /// candidate, pointing at its declaration, in the same `+Search` buffer
- /// `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
- // 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,
-
- // What an ANSWER becomes — which buffer it opens, whether a single row
- // jumps instead, whether n/N walk it — is not here: it is one row per Kind
- // in output_pane.traits, beside the same questions asked of `/`, Find,
- // Grep and Help. A Kind added above will not compile until it has one.
};
-/// 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 = "",
};
-/// How a row SPELLS a path: relative to `base` if it lives UNDER it, its full
-/// absolute self otherwise.
-///
-/// `base` is `Req.root` — the directory of the file the query was asked about
-/// — which is also the directory the results buffer is opened in, so a row
-/// shortened here reads as the name that window would have typed and still
-/// resolves when looked. `gr` over one file was otherwise the same
-/// forty-character absolute prefix repeated down the whole pane, with the part
-/// you came to read pushed off the right edge.
-///
-/// UNDER, not "shorter": a path outside that tree is left absolute rather than
-/// walked up to with `../`. An absolute path resolves from anywhere and says
-/// where it is; `../../..` says neither, and the moment the row is read
-/// somewhere other than beside its own buffer it is wrong.
-///
-/// This is also `look.grep`'s `shown` rule — it calls this function, so the
-/// two spellings the docs used to complain about are one.
+const backends = if (@import("pardes_config").zls_backend)
+ .{ @import("lsp_zls.zig"), @import("lsp_client.zig") }
+else
+ .{};
+
+pub const backend_name = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc";
+
+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;
+};
+
+pub fn speaks(path: []const u8) bool {
+ inline for (backends) |b| if (b.speaks(path)) return true;
+ return false;
+}
+
+pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void {
+ if (req.kind == .status) {
+ inline for (backends) |b| try 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);
+ }
+ if (req.kind == .explain and backends.len > 0)
+ try backends[0].query(gpa, arena, req, out);
+}
+
+// Called on server reader threads; the sink must copy text before returning.
+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);
+}
+
pub fn rel(base: []const u8, path: []const u8) []const u8 {
if (base.len == 0) return path;
- const home = std.mem.trimEnd(u8, base, "/");
- if (path.len > home.len and std.mem.startsWith(u8, path, home) and path[home.len] == '/')
- return path[home.len + 1 ..];
+ const prefix = std.mem.trimEnd(u8, base, "/");
+ if (path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/')
+ return path[prefix.len + 1 ..];
return path;
}
-/// 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).
-/// `path` has already been through `rel`: the caller holds the base.
-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", .{
+pub fn row(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, text: []const u8) std.Io.Writer.Error!void {
+ try out.print("{s}:{d}:{d} {s}\n", .{
path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"),
- }) catch {};
+ });
}
-/// The same row for a protocol RANGE: `path:LINE:COL-ENDCOL`, which a look
-/// SELECTS rather than parking on its first cell — so `gd` lands on the whole
-/// name and a references list steps symbol by symbol with each one highlighted
-/// (config.range_sep spells the dash; `-` is written out here for the same
-/// reason `:` is).
-///
-/// `end_col` is the protocol's own EXCLUSIVE end character, which is already
-/// the 1-based inclusive column pardes wants, so the conversion is the absence
-/// of one. A span that is empty or crosses lines falls back to the point row:
-/// the only multi-line ranges here are whole declarations, and a goto onto one
-/// wants the cursor at its name, not its body painted.
-pub fn spanRow(
- out: *std.Io.Writer,
- path: []const u8,
- line: usize,
- col: usize,
- end_line: usize,
- end_col: usize,
- text: []const u8,
-) void {
+// Input positions are zero-based and end-exclusive; displayed spans are one-based and inclusive.
+pub fn spanRow(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, end_line: usize, end_col: usize, text: []const u8) std.Io.Writer.Error!void {
if (end_line != line or end_col <= col) return row(out, path, line, col, text);
- out.print("{s}:{d}:{d}-{d} {s}\n", .{
+ try out.print("{s}:{d}:{d}-{d} {s}\n", .{
path, line + 1, col + 1, end_col, std.mem.trim(u8, text, " \t\r\n"),
- }) catch {};
+ });
}
-/// Emit one half-open byte range for a mutating response. Rename is the only
-/// current user: every other answer remains human-readable rows. Byte offsets
-/// avoid converting the displayed 1-based locations back into source offsets
-/// in the core, and the prefix makes malformed or mixed responses fail closed.
-pub fn edit(out: *std.Io.Writer, start: usize, end: usize) void {
- out.print("@edit {d} {d}\n", .{ start, end }) catch {};
+pub fn edit(out: *std.Io.Writer, start: usize, end: usize) std.Io.Writer.Error!void {
+ try out.print("@edit {d} {d}\n", .{ start, end });
}
-/// 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 {};
+pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) std.Io.Writer.Error!void {
+ try out.print("@put {d} {d} ", .{ start, end });
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 {}
+ if (c == '%' or c < 0x21)
+ try out.print("%{X:0>2}", .{c})
else
- out.writeByte(c) catch {};
+ try out.writeByte(c);
}
- out.writeByte('\n') catch {};
+ try out.writeByte('\n');
}
-/// 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");
@@ -218,82 +113,29 @@ pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usi
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 {
- // `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;
+test "LSP encoders report every insufficient output capacity" {
+ const cases = [_]struct { kind: enum { row, span, edit, put }, expected: []const u8 }{
+ .{ .kind = .row, .expected = "file:1:3 hi\n" },
+ .{ .kind = .span, .expected = "file:1:3-5 hi\n" },
+ .{ .kind = .edit, .expected = "@edit 1 3\n" },
+ .{ .kind = .put, .expected = "@put 1 3 hé%20%25%0A\n" },
+ };
+ for (cases) |case| {
+ var buf: [128]u8 = undefined;
+ for (0..case.expected.len + 1) |capacity| {
+ var out: std.Io.Writer = .fixed(buf[0..capacity]);
+ const result = switch (case.kind) {
+ .row => row(&out, "file", 0, 2, " hi \n"),
+ .span => spanRow(&out, "file", 0, 2, 0, 5, " hi \n"),
+ .edit => edit(&out, 1, 3),
+ .put => put(&out, 1, 3, "hé %\n"),
+ };
+ if (capacity < case.expected.len) {
+ try std.testing.expectError(error.WriteFailed, result);
+ } else {
+ try result;
+ try std.testing.expectEqualStrings(case.expected, out.buffered());
+ }
+ }
}
- 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);
}
-
-/// 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) = 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
-/// one key that must not be eaten when the answer is no: insert-mode Tab
-/// 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 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 = if (backends.len > 1) "zls-inproc+lsp-client" else "zls-inproc";