diff options
Diffstat (limited to 'test/lspmock.zig')
| -rw-r--r-- | test/lspmock.zig | 271 |
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", .{}); + } +} |
