summaryrefslogtreecommitdiff
path: root/test/lspmock.zig
blob: 7b379b49d95f6cbbf297b15a92b92560a46b69d7 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
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", .{});
    }
}