summaryrefslogtreecommitdiff
path: root/test/lspmock.zig
diff options
context:
space:
mode:
Diffstat (limited to 'test/lspmock.zig')
-rw-r--r--test/lspmock.zig271
1 files changed, 271 insertions, 0 deletions
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", .{});
+ }
+}