summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/lspfixture/client/Cargo.toml11
-rw-r--r--test/lspfixture/client/lib.rs4
-rw-r--r--test/lspfixture/client/main.rs7
-rw-r--r--test/lspmock.zig271
-rw-r--r--test/snapshot.zig17
-rw-r--r--test/snapshots/builtins.golden66
-rw-r--r--test/snapshots/builtins.snap11
-rw-r--r--test/snapshots/leader.golden30
-rw-r--r--test/snapshots/lsp-client-edit.golden44
-rw-r--r--test/snapshots/lsp-client-edit.snap35
-rw-r--r--test/snapshots/lsp-client.golden27
-rw-r--r--test/snapshots/lsp-client.snap34
-rw-r--r--test/snapshots/lsp-rename.golden8
-rw-r--r--test/snapshots/lspdebug.golden2
14 files changed, 518 insertions, 49 deletions
diff --git a/test/lspfixture/client/Cargo.toml b/test/lspfixture/client/Cargo.toml
new file mode 100644
index 00000000..01a28899
--- /dev/null
+++ b/test/lspfixture/client/Cargo.toml
@@ -0,0 +1,11 @@
+# Root marker for the client fixture: rootOf stops here, and a real
+# rust-analyzer pointed at this directory (instead of the mock) finds a
+# well-formed, dependency-free crate.
+[package]
+name = "lspfixture-client"
+version = "0.0.0"
+edition = "2021"
+
+[[bin]]
+name = "lspfixture-client"
+path = "main.rs"
diff --git a/test/lspfixture/client/lib.rs b/test/lspfixture/client/lib.rs
new file mode 100644
index 00000000..2887517e
--- /dev/null
+++ b/test/lspfixture/client/lib.rs
@@ -0,0 +1,4 @@
+// mock fixture
+pub fn add(a: i32, b: i32) -> i32 {
+ a + b
+}
diff --git a/test/lspfixture/client/main.rs b/test/lspfixture/client/main.rs
new file mode 100644
index 00000000..ab29f8bc
--- /dev/null
+++ b/test/lspfixture/client/main.rs
@@ -0,0 +1,7 @@
+mod lib;
+
+fn main() {
+ let x = add(1, 2);
+ let y = add(3, 4);
+ let _ = x + y;
+}
diff --git a/test/lspmock.zig b/test/lspmock.zig
new file mode 100644
index 00000000..7b379b49
--- /dev/null
+++ b/test/lspmock.zig
@@ -0,0 +1,271 @@
+//! A deterministic language server for the protocol client's tests.
+//!
+//! Speaks just enough LSP over stdin/stdout to pin the client end to end
+//! WITHOUT a real analyser in the loop: every answer is a fixed shape over
+//! the request's own uri, so a golden can quote it byte for byte. The
+//! fixture it narrates is test/lspfixture/client/ — the ranges below are
+//! POSITIONS IN THOSE FILES, and moving either without the other breaks the
+//! snapshot, which is the point.
+//!
+//! The snapshot harness points the client at this binary with
+//! `PARDES_LSP_RS=<abs path>`; nothing outside a test should ever run it.
+//!
+//! `LSPMOCK_MODE` selects a failure personality for the degradation tests:
+//! (unset) answer everything
+//! mute handshake, then never answer another request (timeout path)
+//! die exit right after the handshake (dead-server path)
+//! utf16 refuse the utf-8 negotiation (column-conversion path)
+const std = @import("std");
+const libc = std.c;
+
+extern "c" fn getenv(name: [*:0]const u8) ?[*:0]const u8;
+
+var mode: enum { normal, mute, die, utf16 } = .normal;
+
+/// stdout, written with plain libc write(2): the transport is a socketpair
+/// and the frames are small, so partial writes are retried and nothing else
+/// is needed.
+fn send(body: []const u8) void {
+ var hdr_buf: [64]u8 = undefined;
+ const hdr = std.fmt.bufPrint(&hdr_buf, "Content-Length: {d}\r\n\r\n", .{body.len}) catch return;
+ writeAll(hdr);
+ writeAll(body);
+}
+
+fn writeAll(bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = libc.write(1, bytes.ptr + off, bytes.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ std.process.exit(0); // client hung up; nothing left to say
+ }
+ off += @intCast(n);
+ }
+}
+
+fn reply(a: std.mem.Allocator, id: i64, comptime result_fmt: []const u8, args: anytype) void {
+ const body = std.fmt.allocPrint(a, "{{\"jsonrpc\":\"2.0\",\"id\":{d},\"result\":" ++ result_fmt ++ "}}", .{id} ++ args) catch return;
+ send(body);
+}
+
+fn notify(a: std.mem.Allocator, comptime method: []const u8, comptime params_fmt: []const u8, args: anytype) void {
+ const body = std.fmt.allocPrint(a, "{{\"jsonrpc\":\"2.0\",\"method\":\"" ++ method ++ "\",\"params\":" ++ params_fmt ++ "}}", args) catch return;
+ send(body);
+}
+
+// std.json.Value navigation, the client's own idiom.
+fn get(v: ?std.json.Value, key: []const u8) ?std.json.Value {
+ const o = v orelse return null;
+ if (o != .object) return null;
+ return o.object.get(key);
+}
+fn str(v: ?std.json.Value) ?[]const u8 {
+ const o = v orelse return null;
+ return if (o == .string) o.string else null;
+}
+fn num(v: ?std.json.Value) ?i64 {
+ const o = v orelse return null;
+ return if (o == .integer) o.integer else null;
+}
+
+/// The request's document uri — under `textDocument` for most methods, under
+/// `item` for the hierarchy follow-ups. Every cross-file answer derives from
+/// it, so the mock stays inside the caller's own fixture tree.
+fn reqUri(params: ?std.json.Value) []const u8 {
+ return str(get(get(params, "textDocument"), "uri")) orelse
+ str(get(get(params, "item"), "uri")) orelse "file:///dev/null";
+}
+
+/// The same uri with its basename replaced.
+fn sibling(a: std.mem.Allocator, uri: []const u8, name: []const u8) []const u8 {
+ const cut = std.mem.lastIndexOfScalar(u8, uri, '/') orelse return uri;
+ return std.fmt.allocPrint(a, "{s}/{s}", .{ uri[0..cut], name }) catch uri;
+}
+
+/// `{"start":{"line":L,"character":A},"end":{"line":L,"character":B}}`
+fn range(a: std.mem.Allocator, line: u32, from: u32, to: u32) []const u8 {
+ return std.fmt.allocPrint(
+ a,
+ "{{\"start\":{{\"line\":{d},\"character\":{d}}},\"end\":{{\"line\":{d},\"character\":{d}}}}}",
+ .{ line, from, line, to },
+ ) catch "{}";
+}
+
+/// rootUri captured at initialize — workspace/symbol has no textDocument, so
+/// its answers derive from here instead of the /dev/null fallback.
+var root_uri: [512]u8 = undefined;
+var root_uri_len: usize = 0;
+
+pub fn main(init: std.process.Init) !void {
+ _ = init;
+ if (getenv("LSPMOCK_MODE")) |m| {
+ const s = std.mem.span(m);
+ if (std.mem.eql(u8, s, "mute")) mode = .mute;
+ if (std.mem.eql(u8, s, "die")) mode = .die;
+ if (std.mem.eql(u8, s, "utf16")) mode = .utf16;
+ }
+
+ const gpa = std.heap.page_allocator;
+ var buf = try gpa.alloc(u8, 1 << 20);
+ var len: usize = 0;
+
+ while (true) {
+ // one Content-Length frame at the head of buf
+ const msg: ?[]const u8 = blk: {
+ const sep = std.mem.indexOf(u8, buf[0..len], "\r\n\r\n") orelse break :blk null;
+ var clen: ?usize = null;
+ var it = std.mem.splitSequence(u8, buf[0..sep], "\r\n");
+ while (it.next()) |ln| {
+ const colon = std.mem.indexOfScalar(u8, ln, ':') orelse continue;
+ if (std.ascii.eqlIgnoreCase(std.mem.trim(u8, ln[0..colon], " "), "content-length"))
+ clen = std.fmt.parseInt(usize, std.mem.trim(u8, ln[colon + 1 ..], " "), 10) catch null;
+ }
+ const n = clen orelse std.process.exit(1);
+ if (len < sep + 4 + n) break :blk null;
+ break :blk buf[sep + 4 .. sep + 4 + n];
+ };
+ const raw = msg orelse {
+ if (len == buf.len) buf = try gpa.realloc(buf, buf.len * 2);
+ const got = libc.read(0, buf.ptr + len, buf.len - len);
+ if (got < 0) {
+ if (libc.errno(got) == .INTR) continue;
+ return;
+ }
+ if (got == 0) return; // editor hung up
+ len += @intCast(got);
+ continue;
+ };
+
+ var arena_state: std.heap.ArenaAllocator = .init(gpa);
+ defer arena_state.deinit();
+ const a = arena_state.allocator();
+
+ handle(a, raw);
+
+ // compact the consumed frame away
+ const consumed = (@intFromPtr(raw.ptr) - @intFromPtr(buf.ptr)) + raw.len;
+ std.mem.copyForwards(u8, buf[0 .. len - consumed], buf[consumed..len]);
+ len -= consumed;
+ }
+}
+
+fn handle(a: std.mem.Allocator, raw: []const u8) void {
+ const v = std.json.parseFromSliceLeaky(std.json.Value, a, raw, .{}) catch return;
+ const method = str(get(v, "method")) orelse "";
+ const id = num(get(v, "id"));
+ const params = get(v, "params");
+
+ if (std.mem.eql(u8, method, "initialize")) {
+ if (str(get(params, "rootUri"))) |r| {
+ root_uri_len = @min(r.len, root_uri.len);
+ @memcpy(root_uri[0..root_uri_len], r[0..root_uri_len]);
+ }
+ const enc: []const u8 = if (mode == .utf16) "utf-16" else "utf-8";
+ reply(a, id orelse 0,
+ \\{{"capabilities":{{"positionEncoding":"{s}",
+ \\"textDocumentSync":1,
+ \\"definitionProvider":true,"declarationProvider":true,
+ \\"typeDefinitionProvider":true,"implementationProvider":true,
+ \\"referencesProvider":true,"documentHighlightProvider":true,
+ \\"hoverProvider":true,"documentSymbolProvider":true,
+ \\"workspaceSymbolProvider":true,"renameProvider":true,
+ \\"documentFormattingProvider":true,"codeActionProvider":true,
+ \\"completionProvider":{{}},
+ \\"callHierarchyProvider":true,"typeHierarchyProvider":true,
+ \\"diagnosticProvider":{{"interFileDependencies":false,"workspaceDiagnostics":false}},
+ \\"workspace":{{"workspaceFolders":{{"supported":true}}}}}},
+ \\"serverInfo":{{"name":"lspmock"}}}}
+ , .{enc});
+ return;
+ }
+ if (std.mem.eql(u8, method, "initialized")) {
+ if (mode == .die) std.process.exit(0);
+ // the progress trio, so the status pipeline has something to narrate
+ notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"begin\",\"title\":\"mocking\"}}}}", .{});
+ notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"report\",\"percentage\":50,\"message\":\"halfway\"}}}}", .{});
+ notify(a, "$/progress", "{{\"token\":\"t1\",\"value\":{{\"kind\":\"end\"}}}}", .{});
+ return;
+ }
+ if (id == null) return; // didOpen/didChange/$/cancelRequest: nothing to say
+
+ if (mode == .mute) return; // swallow every request after the handshake
+
+ const rid = id.?;
+ const uri = reqUri(params);
+
+ // ---- fixture geometry (0-based lines/chars, over lspfixture/client/) --
+ // main.rs L3/L4: ` let x = add(1, 2);` — "add" at 12..15
+ // lib.rs L1: `pub fn add(a: i32, b: i32) -> i32 {` — "add" at 7..10
+ // main.rs L2: `fn main() {` — "main" at 3..7
+ const lib = sibling(a, uri, "lib.rs");
+ const mainrs = sibling(a, uri, "main.rs");
+
+ if (std.mem.eql(u8, method, "textDocument/definition") or
+ std.mem.eql(u8, method, "textDocument/declaration") or
+ std.mem.eql(u8, method, "textDocument/typeDefinition") or
+ std.mem.eql(u8, method, "textDocument/implementation"))
+ {
+ reply(a, rid, "[{{\"uri\":\"{s}\",\"range\":{s}}}]", .{ lib, range(a, 1, 7, 10) });
+ } else if (std.mem.eql(u8, method, "textDocument/references")) {
+ reply(a, rid, "[{{\"uri\":\"{s}\",\"range\":{s}}},{{\"uri\":\"{s}\",\"range\":{s}}},{{\"uri\":\"{s}\",\"range\":{s}}}]", .{
+ mainrs, range(a, 3, 12, 15),
+ mainrs, range(a, 4, 12, 15),
+ lib, range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/documentHighlight")) {
+ reply(a, rid, "[{{\"range\":{s}}},{{\"range\":{s}}}]", .{ range(a, 3, 12, 15), range(a, 4, 12, 15) });
+ } else if (std.mem.eql(u8, method, "textDocument/hover")) {
+ reply(a, rid, "{{\"contents\":{{\"kind\":\"markdown\",\"value\":\"mock hover: pub fn add\"}}}}", .{});
+ } else if (std.mem.eql(u8, method, "textDocument/documentSymbol")) {
+ reply(a, rid, "[{{\"name\":\"main\",\"kind\":12,\"range\":{s},\"selectionRange\":{s}}}]", .{
+ range(a, 2, 0, 11), range(a, 2, 3, 7),
+ });
+ } else if (std.mem.eql(u8, method, "workspace/symbol")) {
+ const q = str(get(params, "query")) orelse "";
+ reply(a, rid, "[{{\"name\":\"{s}_sym\",\"kind\":12,\"location\":{{\"uri\":\"{s}/lib.rs\",\"range\":{s}}}}}]", .{
+ q, root_uri[0..root_uri_len], range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/rename")) {
+ const nn = str(get(params, "newName")) orelse "renamed";
+ reply(a, rid, "{{\"changes\":{{\"{s}\":[{{\"range\":{s},\"newText\":\"{s}\"}},{{\"range\":{s},\"newText\":\"{s}\"}}]}}}}", .{
+ uri, range(a, 3, 12, 15), nn, range(a, 4, 12, 15), nn,
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/formatting")) {
+ reply(a, rid, "[{{\"range\":{s},\"newText\":\"// formatted by mock\\n\"}}]", .{range(a, 0, 0, 0)});
+ } else if (std.mem.eql(u8, method, "textDocument/codeAction")) {
+ reply(a, rid, "[{{\"title\":\"mock action: extract\"}}]", .{});
+ } else if (std.mem.eql(u8, method, "textDocument/completion")) {
+ reply(a, rid, "{{\"items\":[{{\"label\":\"alpha\",\"detail\":\"fn alpha()\"}},{{\"label\":\"beta\",\"detail\":\"fn beta()\"}}]}}", .{});
+ } else if (std.mem.eql(u8, method, "textDocument/diagnostic")) {
+ reply(a, rid, "{{\"kind\":\"full\",\"items\":[{{\"range\":{s},\"severity\":2,\"message\":\"mock diagnostic\"}}]}}", .{range(a, 0, 0, 7)});
+ } else if (std.mem.eql(u8, method, "textDocument/prepareCallHierarchy")) {
+ reply(a, rid, "[{{\"name\":\"add\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ lib, range(a, 1, 0, 35), range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "callHierarchy/incomingCalls")) {
+ reply(a, rid, "[{{\"from\":{{\"name\":\"main\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}},\"fromRanges\":[{s},{s}]}}]", .{
+ mainrs, range(a, 2, 0, 11), range(a, 2, 3, 7), range(a, 3, 12, 15), range(a, 4, 12, 15),
+ });
+ } else if (std.mem.eql(u8, method, "callHierarchy/outgoingCalls")) {
+ reply(a, rid, "[{{\"to\":{{\"name\":\"callee\",\"kind\":12,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}},\"fromRanges\":[]}}]", .{
+ lib, range(a, 1, 0, 35), range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "textDocument/prepareTypeHierarchy")) {
+ reply(a, rid, "[{{\"name\":\"MockType\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ uri, range(a, 2, 3, 7), range(a, 2, 3, 7),
+ });
+ } else if (std.mem.eql(u8, method, "typeHierarchy/supertypes")) {
+ reply(a, rid, "[{{\"name\":\"SuperMock\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ lib, range(a, 1, 7, 10), range(a, 1, 7, 10),
+ });
+ } else if (std.mem.eql(u8, method, "typeHierarchy/subtypes")) {
+ reply(a, rid, "[{{\"name\":\"SubMock\",\"kind\":5,\"uri\":\"{s}\",\"range\":{s},\"selectionRange\":{s}}}]", .{
+ mainrs, range(a, 2, 3, 7), range(a, 2, 3, 7),
+ });
+ } else if (std.mem.eql(u8, method, "shutdown")) {
+ reply(a, rid, "null", .{});
+ } else {
+ reply(a, rid, "null", .{});
+ }
+}
diff --git a/test/snapshot.zig b/test/snapshot.zig
index 7f8c4f21..5dc2e6e9 100644
--- a/test/snapshot.zig
+++ b/test/snapshot.zig
@@ -79,6 +79,9 @@ const SNAP_BASE = "/tmp/pardes-snap";
var trace_stable = false;
/// set for the serial re-run pass; children widen their probes (see beCareful)
var careful = false;
+/// abs path of the deterministic mock language server the build handed over
+/// (--lspmock=); null leaves the protocol client fully disabled for the run
+var lspmock_bin: ?[:0]const u8 = null;
const gpa = std.heap.page_allocator;
pub fn main(init: std.process.Init) !void {
@@ -102,11 +105,14 @@ pub fn main(init: std.process.Init) !void {
const cpus = std.Thread.getCpuCount() catch 4;
var jobs: usize = cpus + cpus / 4;
var script_count: usize = 0;
+ var lspmock: ?[]const u8 = null;
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update")) {
update = true;
} else if (std.mem.eql(u8, a, "--trace-stable")) {
trace_stable = true;
+ } else if (std.mem.startsWith(u8, a, "--lspmock=")) {
+ lspmock = try absPath(arena, orig_cwd, a["--lspmock=".len..]);
} else if (std.mem.startsWith(u8, a, "--jobs=")) {
jobs = @max(1, try std.fmt.parseInt(usize, a["--jobs=".len..], 10));
} else if (exe == null) {
@@ -129,6 +135,7 @@ pub fn main(init: std.process.Init) !void {
for (args[1..]) |a| {
if (std.mem.eql(u8, a, "--update") or
std.mem.eql(u8, a, "--trace-stable") or
+ std.mem.startsWith(u8, a, "--lspmock=") or
std.mem.startsWith(u8, a, "--jobs=")) continue;
if (!saw_exe) {
saw_exe = true;
@@ -139,6 +146,7 @@ pub fn main(init: std.process.Init) !void {
}
if (exe == null) fatal("usage: pardes-snap <exe> [scripts...] [--update] [--jobs=N]", .{});
const exe_z = try arena.dupeZ(u8, exe.?);
+ lspmock_bin = if (lspmock) |p| try arena.dupeZ(u8, p) else null;
if (scripts.len == 0) {
scripts = try listSnaps(arena, try absPath(arena, orig_cwd, "test/snapshots"));
@@ -389,6 +397,15 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const
// views' durations (lsp_zls.zig hideTime) and the transient message row's
// time of day (message.zig stamp)
_ = setenv("PARDES_NOTIME", "1", 1);
+ // The protocol client must be HERMETIC under the harness: `.rs` files
+ // speak to the deterministic mock when the build handed one over, and
+ // every other spec is disabled outright — a script that opened a stray
+ // `.c` or `.ts` file must never spawn whatever clangd this machine has.
+ _ = setenv("PARDES_LSP_RS", lspmock_bin orelse "", 1);
+ _ = setenv("PARDES_LSP_C", "", 1);
+ _ = setenv("PARDES_LSP_GO", "", 1);
+ _ = setenv("PARDES_LSP_TS", "", 1);
+ _ = setenv("PARDES_LSP_PY", "", 1);
// Pin the dump path: live runs use a timestamped file under the user data
// dir (dump.outPath), which would make the dump/load/restore goldens
// nondeterministic. Per-SCRIPT, not one shared /tmp/pardes-dump.zon: four
diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index 5019c855..555a6fc4 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -42,39 +42,39 @@
|64: 30 SPC h t Tutor topbar
|65: 31 SPC j i Forward C-i
== snap index-tail grid=120x66 cursor=7,64
-|35: 49 SPC s a Attach
-|36: 50 SPC s d Dump topbar
-|37: 51 SPC s r Restore
-|38: 52 SPC t a Ascii
-|39: 53 SPC t b Tagbottom
-|40: 54 SPC t c Colors
-|41: 55 SPC t d Debug topbar
-|42: 56 SPC t i PdfTint
-|43: 57 SPC t l Palette
-|44: 58 SPC t n NextColor topbar
-|45: 59 SPC t p Petscii
-|46: 60 SPC t s PdfSections
-|47: 61 SPC t t ThemeSel
-|48: 62 SPC t w Wrap
-|49: 63 SPC t z PdfFit
-|50: 64 SPC w h Left C-w h, C-w left
-|51: 65 SPC w j Down C-w j, C-w down
-|52: 66 SPC w k Up C-w k, C-w up
-|53: 67 SPC w l Right C-w l, C-w right
-|54: 68 SPC y ClipYank
-|55: 69 Look enter, right-click
-|56: 70 Exec tab, middle-click
-|57: 71 Msg
-|58: 72 ThemeFile
-|59: 73 DumpThemes
-|60: 74 Filter
-|61: 75 EffectCode
-|62: 76 9p
-|63: 77 Theme
-|64: 78 Shell
-|65: 79
-== snap not-a-picker grid=120x66 cursor=7,64
-== snap window-group grid=120x66 cursor=7,35
+|35: 66 SPC t w Wrap
+|36: 67 SPC t z PdfFit
+|37: 68 SPC w h Left C-w h, C-w left
+|38: 69 SPC w j Down C-w j, C-w down
+|39: 70 SPC w k Up C-w k, C-w up
+|40: 71 SPC w l Right C-w l, C-w right
+|41: 72 SPC y ClipYank
+|42: 73 Look enter, right-click
+|43: 74 Exec tab, middle-click
+|44: 75 Msg
+|45: 76 ThemeFile
+|46: 77 DumpThemes
+|47: 78 Filter
+|48: 79 EffectCode
+|49: 80 9p
+|50: 81 Theme
+|51: 82 Shell
+|52: 83
+|53: 84 language keys (motions, not words):
+|54: 85 gd gD gy gi gr goto: definition,
+|55: 86 declaration, type-def,
+|56: 87 implementation, refs
+|57: 88 ]d [d ]D [D diagnostics: next,
+|58: 89 prev, last, first
+|59: 90 = format (applies, one
+|60: 91 undo step)
+|61: 92 Tab after a . completion, in insert
+|62: 93 C-left-click definition, by mouse
+|63: 94 SPC l ... hover, rename, symbols,
+|64: 95 calls, types: above
+|65: 96
+== snap not-a-picker grid=120x66 cursor=19,61
+== snap window-group grid=120x66 cursor=19,35
|35: 1 pardes builtins under SPC w
|36: 2
|37: 3 SPC w h Left C-w h, C-w left
diff --git a/test/snapshots/builtins.snap b/test/snapshots/builtins.snap
index ad8e27f3..4eaa3543 100644
--- a/test/snapshots/builtins.snap
+++ b/test/snapshots/builtins.snap
@@ -55,12 +55,15 @@ snap window-group
# which is the whole reason n/N were never allowed to be the picker.
#
# The one place in this suite a builtin cannot be added for free: the click is
-# a SCREEN coordinate, so every row inserted above `SPC h t` in the listing
-# moves Tutor down one and this number with it. The five clipboard builtins
-# put three there — SPC P, SPC R and SPC Y, uppercase sorting ahead of the
-# lowercase `SPC h`; the eleven panel effects now follow them as well.
+# a SCREEN COORDINATE. The rows above `SPC h t` in the listing move Tutor and
+# this number with them — the five clipboard builtins put three there. And
+# since the language-keys footer made the page longer than the pane, the
+# re-opened view RETAINS the scroll the `ge` above left behind, which is why
+# `gg` now pins the top before the click: an exec aimed one row into the
+# footer would run "declaration," in a shell instead of opening the tutor.
key space ?
wait 10000 SPC c n
+key g g
stable 700 15000
press middle 20 65
release middle 20 65
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 38b4f9dc..5d3adba2 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -160,17 +160,21 @@
|36:
== snap help-language grid=100x40 cursor=7,22
|22: 1 pardes builtins under SPC l
-|24: 3 SPC l D WsDiagnostics
-|25: 4 SPC l S WsSymbols
-|26: 5 SPC l a CodeAction
-|27: 6 SPC l d Diagnostics
-|28: 7 SPC l h SelectRefs
-|29: 8 SPC l i Lspinfo
-|30: 9 SPC l k Hover
-|31: 10 SPC l r Rename
-|32: 11 SPC l s Symbols
-|33: 12 SPC l w Lspwhy
-|34: 13
+|24: 3 SPC l C Callees
+|25: 4 SPC l D WsDiagnostics
+|26: 5 SPC l S WsSymbols
+|27: 6 SPC l T Subtypes
+|28: 7 SPC l a CodeAction
+|29: 8 SPC l c Callers
+|30: 9 SPC l d Diagnostics
+|31: 10 SPC l h SelectRefs
+|32: 11 SPC l i Lspinfo
+|33: 12 SPC l k Hover
+|34: 13 SPC l r Rename
+|35: 14 SPC l s Symbols
+|36: 15 SPC l t Supertypes
+|37: 16 SPC l w Lspwhy
+|38: 17
== snap del-output grid=100x40 cursor=7,2
|21:
|22:
@@ -186,3 +190,7 @@
|32:
|33:
|34:
+|35:
+|36:
+|37:
+|38:
diff --git a/test/snapshots/lsp-client-edit.golden b/test/snapshots/lsp-client-edit.golden
new file mode 100644
index 00000000..15a477b2
--- /dev/null
+++ b/test/snapshots/lsp-client-edit.golden
@@ -0,0 +1,44 @@
+== snap formatted grid=100x24 cursor=7,3
+|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/lsp-client-edit/cwd/main.rs * Save New Newtty Del
+| 1 // formatted by mock
+| 2 mod lib;
+| 3
+| 4 fn main() {
+| 5 let x = add(1, 2);
+| 6 let y = add(3, 4);
+| 7 let _ = x + y;
+| 8 }
+| 9
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+|
+| formatted 1 range(s)
+== snap unformatted grid=100x24 cursor=7,2
+|2: 1 mod lib;
+|3: 2
+|4: 3 fn main() {
+|5: 4 let x = add(1, 2);
+|6: 5 let y = add(3, 4);
+|7: 6 let _ = x + y;
+|8: 7 }
+|9: 8
+|10:
+|23:
+== snap renamed grid=100x24 cursor=12,4
+|5: 4 let x = sum(1, 2);
+|6: 5 let y = sum(3, 4);
+|23: renamed 2 range(s)
+== snap unrenamed grid=100x24 cursor=12,4
+|5: 4 let x = add(1, 2);
+|6: 5 let y = add(3, 4);
+|23:
diff --git a/test/snapshots/lsp-client-edit.snap b/test/snapshots/lsp-client-edit.snap
new file mode 100644
index 00000000..a9e66e51
--- /dev/null
+++ b/test/snapshots/lsp-client-edit.snap
@@ -0,0 +1,35 @@
+# The protocol client's MUTATING answers, through the @put record path: a
+# server TextEdit response becomes one atomic buffer edit with one undo step.
+# = applies the mock's formatting edit (a header line) and narrates
+# "formatted 1 range(s)" on the message row
+# SPC l r renames both `add` calls via the server's WorkspaceEdit
+# u reverses each as a single transaction
+file main.rs mod lib;\n\nfn main() {\n let x = add(1, 2);\n let y = add(3, 4);\n let _ = x + y;\n}\n
+file lib.rs // mock fixture\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n
+start 24 100 main.rs
+wait 8000 add(1, 2)
+stable 700 20000
+key =
+wait 10000 formatted by mock
+stable 700 15000
+snap formatted
+key u
+wait 8000 mod lib;
+stable 700 15000
+snap unformatted
+# rename through the leader prompt: both call sites change in one step
+press left 13 5
+release left 13 5
+key space
+key l
+key r
+stable 400 5000
+text sum
+key enter
+wait 10000 sum(3, 4)
+stable 700 15000
+snap renamed
+key u
+wait 8000 add(3, 4)
+stable 700 15000
+snap unrenamed
diff --git a/test/snapshots/lsp-client.golden b/test/snapshots/lsp-client.golden
new file mode 100644
index 00000000..a203cc7c
--- /dev/null
+++ b/test/snapshots/lsp-client.golden
@@ -0,0 +1,27 @@
+== snap gd-jumped grid=100x24 cursor=16,15
+|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
+| /tmp/pardes-snap/lsp-client/cwd/main.rs Save New Newtty Del
+| 1 mod lib;
+| 2
+| 3 fn main() {
+| 4 let x = add(1, 2);
+| 5 let y = add(3, 4);
+| 6 let _ = x + y;
+| 7 }
+| 8
+|
+|
+| --:--:-- lsp rust-analyzer: ready
+| /tmp/pardes-snap/lsp-client/cwd/lib.rs Save New Newtty Del
+| 1 // mock fixture
+| 2 pub fn add(a: i32, b: i32) -> i32 {
+| 3 a + b
+| 4 }
+| 5
+== snap gr-list grid=100x24 cursor=16,15
+|12:
+|20: /tmp/pardes-snap/lsp-client/cwd/+Search Save New Newtty Del
+|21: 1 main.rs:4:13-15 let x = add(1, 2);
+|22: 2 main.rs:5:13-15 let y = add(3, 4);
+|23: 3 lib.rs:2:8-10 pub fn add(a: i32, b: i32) -> i32 {
+== snap gr-opened grid=100x24 cursor=21,6
diff --git a/test/snapshots/lsp-client.snap b/test/snapshots/lsp-client.snap
new file mode 100644
index 00000000..bc5267cc
--- /dev/null
+++ b/test/snapshots/lsp-client.snap
@@ -0,0 +1,34 @@
+# The protocol client end to end, against the deterministic mock server the
+# harness pins via PARDES_LSP_RS (test/lspmock.zig). What this exercises is
+# everything BETWEEN the keymap and the analyser: spawn, handshake, the reader
+# thread, $/progress narration onto the transient message row, and the
+# location shapes coming back as +Search rows.
+# gd on a cross-file Location -> a look: lib.rs opens with `add` selected,
+# and the message row still carries the server's settled state
+# ("lsp lspmock: ready") because nothing has cleared it yet.
+# gr -> three rows with column SPANS, n arms the walk, Enter opens one.
+file main.rs mod lib;\n\nfn main() {\n let x = add(1, 2);\n let y = add(3, 4);\n let _ = x + y;\n}\n
+file lib.rs // mock fixture\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n
+start 24 100 main.rs
+wait 8000 add(1, 2)
+stable 700 20000
+# cursor into the first `add` call, then helix's goto-definition
+press left 13 5
+release left 13 5
+key g
+key d
+wait 10000 pub fn add
+stable 700 15000
+snap gd-jumped
+# references from the definition the look landed on (cursor sits on `add`)
+key g
+key r
+wait 10000 add(3, 4)
+stable 700 15000
+snap gr-list
+# step the list and open the second hit: main.rs with the span selected
+key n
+key n
+key enter
+stable 700 15000
+snap gr-opened
diff --git a/test/snapshots/lsp-rename.golden b/test/snapshots/lsp-rename.golden
index e21228e4..a241a93c 100644
--- a/test/snapshots/lsp-rename.golden
+++ b/test/snapshots/lsp-rename.golden
@@ -16,9 +16,17 @@
| 13 _ = helper;
| 14 }
| 15
+|
+|
+|
+|
+|
+|
+| renamed 2 range(s)
== snap undone grid=110x24 cursor=17,9
|4: 3 fn helper(x: u32) u32 {
|9: 8 _ = helper(41);
+|23:
== snap redone grid=110x24 cursor=17,9
|4: 3 fn renamed_helper(x: u32) u32 {
|9: 8 _ = renamed_helper(41);
diff --git a/test/snapshots/lspdebug.golden b/test/snapshots/lspdebug.golden
index 91ab400a..cd9ac0c7 100644
--- a/test/snapshots/lspdebug.golden
+++ b/test/snapshots/lspdebug.golden
@@ -27,7 +27,7 @@
|43: 9 0 row(s)
== snap info grid=120x44 cursor=7,4
|18: /tmp/pardes-snap/lspdebug/cwd/+Lsp Save New Newtty Del
-|19: 1 backend: zls-inproc
+|19: 1 backend: zls-inproc+lsp-client
|20: 2 zls: 0.16.1-dev+3e0d0820 (compiled in — no server process, no JSON-RPC)
|21: 3 zig lib dir: /usr/lib/zig [OK]
|22: 4 offsets: utf-8 walk caps: 512 files, 2000 rows