1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
|
//! 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.
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 ..];
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", .{
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 {
if (end_line != line or end_col <= col) return row(out, path, line, col, text);
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 {};
}
/// 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 {};
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 {}
else
out.writeByte(c) catch {};
}
out.writeByte('\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 {
// `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;
}
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";
|