From 38e9919a9ea9055538409b388d580c4e4c838434 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 10 Aug 2026 09:14:05 -0300 Subject: fixed lsp rename --- src/pardes.zig | 117 +++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 106 insertions(+), 11 deletions(-) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 4b9e8b1d..4ce4d101 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4321,16 +4321,24 @@ pub const Pardes = struct { /// this one" — the id bump makes the older answer stale and lspResponse /// drops it. A queue would only buy the right to render an answer nobody /// is waiting for any more. - /// `arg` rides along only so the buffer the answer opens can record what - /// was asked (a rename's new name, a symbol query) — the query itself has - /// it in the effect already. - /// `row`/`col` are where the cursor was when the question was asked. Only - /// `completion` reads them, and only to undo itself: Tab diverted instead - /// of indenting, so an empty answer has to put the indent back — but only - /// if the cursor has not moved since, or four spaces appear under someone - /// who kept typing. + /// `arg` holds the replacement name for rename and the query for workspace + /// symbols. `serial` rejects a response after its pane slot was reused; + /// `revision` makes a mutating rename conditional on the source snapshot + /// the worker actually analysed. `row`/`col` are where the cursor was when + /// the question was asked. Only `completion` reads them, and only to undo + /// itself: Tab diverted instead of indenting, so an empty answer has to put + /// the indent back — but only if the cursor has not moved since. lsp_seq: u32 = 0, - lsp_wait: ?struct { id: u32, kind: lsp.Kind, pane: usize, arg: Effect.Buf(128), row: i32 = 0, col: i32 = 0 } = null, + lsp_wait: ?struct { + id: u32, + kind: lsp.Kind, + pane: usize, + serial: u32, + revision: u32, + arg: Effect.Buf(128), + row: i32 = 0, + col: i32 = 0, + } = null, /// One current shell-filter request. A newer submit frees and supersedes /// it; old worker answers then fail the id check. The request itself owns @@ -7642,6 +7650,7 @@ 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; const off = if (pane.file) |f| modal.hxOff(f.content, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)), @@ -7651,6 +7660,8 @@ pub const Pardes = struct { .id = p.lsp_seq, .kind = kind, .pane = id, + .serial = pane.serial, + .revision = if (pane.file) |f| f.revision else 0, .arg = .from(arg), .row = pane.cur_row, .col = pane.cur_col, @@ -7664,8 +7675,90 @@ pub const Pardes = struct { } }); } - /// A worker answered. Rows are `+Search` format with ABSOLUTE paths, so - /// both dispositions below are the ordinary look path: + const LspEdit = struct { start: usize, end: usize }; + + fn parseLspEdits(p: *Pardes, bytes: []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; + var lines = std.mem.splitScalar(u8, bytes, '\n'); + var n: usize = 0; + 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 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; + if (fields.next() != null) return null; + edits[n] = .{ .start = start, .end = end }; + n += 1; + } + return if (n == 0) null else edits[0..n]; + } + + fn mapLspEditOffset(edits: []const LspEdit, replacement_len: usize, 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; + 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; + + var removed: usize = 0; + var previous_end: usize = 0; + 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; + previous_end = e.end; + } + 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; + + 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); + 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; + read_at = e.end; + } + @memcpy(replacement[write_at..], f.content[read_at..]); + + p.pushUndo(pane); + file_pane.setContent(p, f, replacement); + const cursor = modal.hxPos(f.content, mapped_cursor); + pane.cur_row = @intCast(cursor.row); + pane.cur_col = @intCast(cursor.col); + pane.vsel.active = false; + pane.msel.active = false; + pane.select = false; + pane.sticky_col = -1; + pane.ensureCursorVisible(); + } + + /// A worker answered. Rename's edit records are consumed first and never + /// rendered. 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 @@ -7675,6 +7768,8 @@ pub const Pardes = struct { if (w.id != id) return; // superseded by a newer press, or the pane died 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 (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 -- cgit v1.3