From ae9325a5cb128d0d952afb8f9feaaca68e5e37a2 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 1 Sep 2026 09:23:53 -0300 Subject: lsp: a protocol client for every other language, narrated on the message row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- test/lspfixture/client/Cargo.toml | 11 ++ test/lspfixture/client/lib.rs | 4 + test/lspfixture/client/main.rs | 7 + test/lspmock.zig | 271 ++++++++++++++++++++++++++++++++++ test/snapshot.zig | 17 +++ test/snapshots/builtins.golden | 66 ++++----- test/snapshots/builtins.snap | 11 +- test/snapshots/leader.golden | 30 ++-- test/snapshots/lsp-client-edit.golden | 44 ++++++ test/snapshots/lsp-client-edit.snap | 35 +++++ test/snapshots/lsp-client.golden | 27 ++++ test/snapshots/lsp-client.snap | 34 +++++ test/snapshots/lsp-rename.golden | 8 + test/snapshots/lspdebug.golden | 2 +- 14 files changed, 518 insertions(+), 49 deletions(-) create mode 100644 test/lspfixture/client/Cargo.toml create mode 100644 test/lspfixture/client/lib.rs create mode 100644 test/lspfixture/client/main.rs create mode 100644 test/lspmock.zig create mode 100644 test/snapshots/lsp-client-edit.golden create mode 100644 test/snapshots/lsp-client-edit.snap create mode 100644 test/snapshots/lsp-client.golden create mode 100644 test/snapshots/lsp-client.snap (limited to 'test') 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=`; 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 [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 -- cgit v1.3