summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-01 09:23:53 -0300
committerGabriel Schneider <[email protected]>2026-09-01 11:24:12 -0300
commitae9325a5cb128d0d952afb8f9feaaca68e5e37a2 (patch)
tree9ae44ac38f7b71edfe2882d0a882dbc61304ec80 /src/pardes.zig
parent848ad99fa597387a85f75e752dc4c9e10f8c24f4 (diff)
downloadpardes-ae9325a5cb128d0d952afb8f9feaaca68e5e37a2.tar.gz
pardes-ae9325a5cb128d0d952afb8f9feaaca68e5e37a2.zip
lsp: a protocol client for every other language, narrated on the message row
The seam grows a second backend: src/lsp/lsp_client.zig speaks JSON-RPC to child language servers — rust-analyzer, clangd, gopls, tsserver, pyright are rows in a spec table — while the in-process ZLS analyser keeps .zig. One reader thread per server owns the socket, routes responses to a mailbox under the conn mutex (monotonic condvar), answers server-to-client requests, feeds the diagnostics store, and narrates $/progress and state changes through a status sink both native shells post to the transient message row: "rust-analyzer: cargo check 88% 955/1083" lands where a save narrates, with the same clock. Chatty progress is throttled and deduplicated; settled states always land, which is also what makes the goldens deterministic. Nothing wedges and nothing healthy dies: waits are deadline-bounded, a timeout cancels and returns no rows, three consecutive timeouts restart the server ONLY while it is idle (an indexing server is narrating its own excuse), spawn and handshake failures back off 10s to 2min, a crash shortly after ready counts as a failure, and only a missing binary disables a spec. PARDES_LSP_{RS,C,GO,TS,PY} override binaries; empty disables; the snapshot harness pins RS to test/lspmock.zig and empties the rest. Mutating answers really mutate now: the @put record beside rename @edit carries per-range text, so = applies the formatter (both backends) and a same-file WorkspaceEdit rename applies atomically, one undo step, narrated ("renamed 2 range(s)"); a multi-file rename previews as rows instead of half-applying. Malformed responses fail closed: coordinates validated not clamped, one bad TextEdit poisons the whole edit set, poison frames kill the connection instead of buffering forever, decoded control bytes reject a uri, hierarchy items too deep to reserialize are skipped. Four kinds helix does not have, on SPC l: c/C incoming/outgoing calls (rows are call sites), t/T super/subtypes. Pull diagnostics (3.17) preferred when advertised. Help gains a language-keys footer for the motions no builtin row could carry; lsp.rel and look.grep now share one path-shortening rule. zig build lspprobe drives the seam from the CLI (comma-separated kinds share one server); measured against a 1083-crate workspace warm: gd 26ms, gr 213 rows 165ms, incoming calls 212 sites 197ms, document symbols 670 rows 347ms. docs/lsp.md tells the whole story; lsp-evaluation.md gets an addendum.
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig132
1 files changed, 107 insertions, 25 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 4fdb6c71..bc9a0939 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -10400,7 +10400,15 @@ pub const Pardes = struct {
if (f.output != null) return;
}
if (arg.len > 128) return; // the effect's arg is a Buf(128)
- if (kind == .rename and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return;
+ // A rename's argument becomes an identifier in someone's source. Zig
+ // buffers get Zig's exact rule; any other language the client speaks
+ // gets the weakest honest one (no whitespace, no quotes — the server
+ // validates the rest and answers nothing when it hates the name).
+ if (kind == .rename) {
+ const zig_buf = if (pane.file) |f| std.mem.endsWith(u8, f.path, ".zig") else true;
+ if (zig_buf and (!std.zig.isValidId(arg) or std.zig.isUnderscore(arg))) return;
+ if (!zig_buf and std.mem.indexOfAny(u8, arg, " \t\r\n\"\\") != null) return;
+ }
const off = if (pane.file) |f| modal.hxOff(f.content, .{
.row = @intCast(@max(0, pane.cur_row)),
.col = @intCast(@max(0, pane.cur_col)),
@@ -10425,72 +10433,125 @@ pub const Pardes = struct {
} });
}
- const LspEdit = struct { start: usize, end: usize };
+ const LspEdit = struct { start: usize, end: usize, text: []const u8 };
- fn parseLspEdits(p: *Pardes, bytes: []const u8) ?[]LspEdit {
+ /// Parse a mutating response into ordered replacements. Two record forms,
+ /// never mixed: `@edit START END` substitutes `fallback` at every range
+ /// (the ZLS rename path — the text is the request's own argument), and
+ /// `@put START END PCT` carries its own percent-encoded replacement (a
+ /// protocol server's rename or format, whose per-range text only the
+ /// server knows). `fallback == null` rejects the @edit form outright — a
+ /// format response has no argument to substitute. Anything malformed
+ /// parses to null and null changes nothing.
+ fn parseLspEdits(p: *Pardes, bytes: []const u8, fallback: ?[]const u8) ?[]LspEdit {
if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null;
- const edits = p.scratch.allocator().alloc(LspEdit, std.mem.count(u8, bytes, "\n")) catch return null;
+ const a = p.scratch.allocator();
+ const edits = a.alloc(LspEdit, std.mem.count(u8, bytes, "\n")) catch return null;
var lines = std.mem.splitScalar(u8, bytes, '\n');
var n: usize = 0;
+ var form: enum { undecided, edit, put } = .undecided;
while (lines.next()) |line| {
if (line.len == 0) {
if (lines.peek() == null) break;
return null;
}
var fields = std.mem.tokenizeScalar(u8, line, ' ');
- if (!std.mem.eql(u8, fields.next() orelse return null, "@edit")) return null;
+ const head = fields.next() orelse return null;
+ const want: @TypeOf(form) = if (std.mem.eql(u8, head, "@put"))
+ .put
+ else if (std.mem.eql(u8, head, "@edit"))
+ .edit
+ else
+ return null;
+ if (form == .undecided) form = want;
+ if (form != want) return null;
const start = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null;
const end = std.fmt.parseInt(usize, fields.next() orelse return null, 10) catch return null;
+ var text: []const u8 = if (want == .edit) (fallback orelse return null) else "";
+ if (want == .put) {
+ if (fields.next()) |encoded| text = decodePct(a, encoded) orelse return null;
+ }
if (fields.next() != null) return null;
- edits[n] = .{ .start = start, .end = end };
+ edits[n] = .{ .start = start, .end = end, .text = text };
n += 1;
}
return if (n == 0) null else edits[0..n];
}
- fn mapLspEditOffset(edits: []const LspEdit, replacement_len: usize, old: usize) usize {
+ /// The `@put` text codec's read half (lsp.put writes it): `%XX` bytes,
+ /// everything else literal. An invalid escape fails the whole record.
+ fn decodePct(a: std.mem.Allocator, encoded: []const u8) ?[]const u8 {
+ const out = a.alloc(u8, encoded.len) catch return null;
+ var n: usize = 0;
+ var i: usize = 0;
+ while (i < encoded.len) {
+ if (encoded[i] == '%') {
+ if (i + 3 > encoded.len) return null;
+ out[n] = std.fmt.parseInt(u8, encoded[i + 1 .. i + 3], 16) catch return null;
+ i += 3;
+ } else {
+ out[n] = encoded[i];
+ i += 1;
+ }
+ n += 1;
+ }
+ return out[0..n];
+ }
+
+ /// Where the cursor lands after `edits` replace their ranges: text before
+ /// the first edit keeps its offset, text between edits shifts by the
+ /// accumulated delta, and a cursor inside a replaced range clamps into
+ /// the replacement.
+ fn mapLspEditOffset(edits: []const LspEdit, old: usize) usize {
var old_at: usize = 0;
var new_at: usize = 0;
for (edits) |e| {
if (old < e.start) return new_at + (old - old_at);
new_at += e.start - old_at;
- if (old < e.end) return new_at + @min(old - e.start, replacement_len - 1);
- new_at += replacement_len;
+ if (old < e.end) return new_at + @min(old - e.start, e.text.len -| 1);
+ new_at += e.text.len;
if (old == e.end) return new_at;
old_at = e.end;
}
return new_at + (old - old_at);
}
- fn applyLspRename(p: *Pardes, pane: *Pane, revision: u32, new_name: []const u8, bytes: []const u8) void {
- const f = if (pane.file) |*file| file else return;
- if (f.revision != revision) return;
- const edits = p.parseLspEdits(bytes) orelse return;
+ /// Apply parsed edit records as ONE undo transaction, or nothing: ranges
+ /// must be ordered, non-overlapping and in bounds, and the file revision
+ /// must still be the one the worker was asked about — the user may have
+ /// typed while the server thought. True when the buffer changed.
+ fn applyLspEdits(p: *Pardes, pane: *Pane, revision: u32, edits: []const LspEdit) bool {
+ const f = if (pane.file) |*file| file else return false;
+ if (f.revision != revision) return false;
var removed: usize = 0;
+ var added: usize = 0;
var previous_end: usize = 0;
+ var same = true;
for (edits) |e| {
- if (e.start < previous_end or e.start >= e.end or e.end > f.content.len) return;
- removed = std.math.add(usize, removed, e.end - e.start) catch return;
+ if (e.start < previous_end or e.start > e.end or e.end > f.content.len) return false;
+ if (e.start == e.end and e.text.len == 0) return false; // an empty insert is a malformed record
+ removed = std.math.add(usize, removed, e.end - e.start) catch return false;
+ added = std.math.add(usize, added, e.text.len) catch return false;
previous_end = e.end;
+ if (same and !std.mem.eql(u8, f.content[e.start..e.end], e.text)) same = false;
}
- if (std.mem.eql(u8, f.content[edits[0].start..edits[0].end], new_name)) return;
- const added = std.math.mul(usize, edits.len, new_name.len) catch return;
- const final_len = std.math.add(usize, f.content.len - removed, added) catch return;
- const replacement = p.gpa.alloc(u8, final_len) catch return;
+ if (same) return false; // a no-op response earns no undo entry
+ const final_len = std.math.add(usize, f.content.len - removed, added) catch return false;
+ const replacement = p.gpa.alloc(u8, final_len) catch return false;
const old_cursor = modal.hxOff(f.content, .{
.row = @intCast(@max(0, pane.cur_row)),
.col = @intCast(@max(0, pane.cur_col)),
});
- const mapped_cursor = mapLspEditOffset(edits, new_name.len, old_cursor);
+ const mapped_cursor = mapLspEditOffset(edits, old_cursor);
var read_at: usize = 0;
var write_at: usize = 0;
for (edits) |e| {
@memcpy(replacement[write_at .. write_at + (e.start - read_at)], f.content[read_at..e.start]);
write_at += e.start - read_at;
- @memcpy(replacement[write_at .. write_at + new_name.len], new_name);
- write_at += new_name.len;
+ @memcpy(replacement[write_at .. write_at + e.text.len], e.text);
+ write_at += e.text.len;
read_at = e.end;
}
@memcpy(replacement[write_at..], f.content[read_at..]);
@@ -10505,10 +10566,16 @@ pub const Pardes = struct {
pane.select = false;
pane.sticky_col = -1;
pane.ensureCursorVisible();
+ return true;
}
- /// A worker answered. Rename's edit records are consumed first and never
- /// rendered. Every other response is the ordinary look/output path:
+ /// A worker answered. The two MUTATING kinds are consumed first: a
+ /// response made of edit records is applied atomically (rename substitutes
+ /// the argument or the server's own text, `=` applies the formatter), and
+ /// only a response that is NOT records renders — a rename that spans other
+ /// files arrives as location rows and opens as a PREVIEW list instead of
+ /// being half-applied, and a format that could not run stays prose.
+ /// Every other response is the ordinary look/output path:
/// one row, a goto -> jump straight there (helix jumps on a single
/// location and shows a picker on several)
/// anything else -> an output buffer, which n/N already steps. That
@@ -10519,7 +10586,22 @@ pub const Pardes = struct {
p.lsp_wait = null;
const pane = p.panes[w.pane] orelse return;
if (pane.serial != w.serial) return;
- if (w.kind == .rename) return p.applyLspRename(pane, w.revision, w.arg.slice(), rows);
+ if (w.kind == .rename or w.kind == .format) {
+ const fallback: ?[]const u8 = if (w.kind == .rename) w.arg.slice() else null;
+ if (p.parseLspEdits(rows, fallback)) |edits| {
+ if (p.applyLspEdits(pane, w.revision, edits)) {
+ var buf: [64]u8 = undefined;
+ const verb: []const u8 = if (w.kind == .rename) "renamed" else "formatted";
+ p.setMessage(w.pane, std.fmt.bufPrint(&buf, "{s} {d} range(s)", .{ verb, edits.len }) catch verb);
+ }
+ return;
+ }
+ if (w.kind == .rename and rows.len > 0) {
+ // fall through: a multi-file rename previews as rows
+ } else if (w.kind == .format and rows.len > 0) {
+ // fall through: "cannot format" and friends are prose
+ } else return;
+ }
if (rows.len == 0) {
// No rows is a legal answer everywhere except here. Tab DIVERTED
// instead of indenting, so an empty answer would eat the keystroke