summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
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