diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 132 |
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 |
