summaryrefslogtreecommitdiff
path: root/tools/lspprobe.zig
blob: 8b953cec98fee1a2461e81da163640316e77a2ba (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
//! One language query against the REAL seam, from the command line.
//!
//!   pardes-lspprobe <kind> <file> <line>:<col> [arg] [--reps N]
//!
//! `kind` is an `lsp.Kind` tag (`definition`, `references`, ...) or its helix
//! key (`gd`, `gr`, `gy`, `gi`, `gD`). Line and column are 1-based, the way
//! the rows print them. Rows go to stdout; the timing line goes to stderr so
//! `lspprobe gd f.rs 10:5 | head` still composes.
//!
//! This is the development probe for the PROTOCOL CLIENT: point it at a file
//! in any workspace (a rust crate, a C tree with compile_commands.json) and it
//! exercises exactly what `gd` in the editor would — same seam, same client,
//! same child server. The server is spawned fresh per invocation and kept for
//! the process, so rep 0 is the true cold cost (spawn + handshake + whatever
//! indexing the deadline allows) and later reps are the warm per-press cost.
//! Generic on purpose: no project names anywhere — the workspace is whatever
//! the file's root markers say.
const std = @import("std");
const lsp = @import("pardes").lsp;

extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;

pub const std_options: std.Options = .{ .log_level = .err };

fn nowUs() u64 {
    var ts: std.c.timespec = undefined;
    _ = std.c.clock_gettime(.MONOTONIC, &ts);
    return @as(u64, @intCast(ts.sec)) *| 1_000_000 +| @as(u64, @intCast(ts.nsec)) / 1000;
}

const aliases = .{
    .{ "gd", lsp.Kind.definition },
    .{ "gD", lsp.Kind.declaration },
    .{ "gy", lsp.Kind.type_definition },
    .{ "gi", lsp.Kind.implementation },
    .{ "gr", lsp.Kind.references },
};

fn usage() noreturn {
    std.debug.print(
        \\usage: pardes-lspprobe <kind> <file> <line>:<col> [arg] [--reps N] [--warm S]
        \\  kind:   gd gD gy gi gr, or any lsp.Kind tag (hover, references,
        \\          document_symbols, workspace_symbols, incoming_calls, ...)
        \\  arg:    the new name for rename, the query for workspace_symbols
        \\  --warm  sleep S seconds after the first (cold) query — long enough
        \\          for an indexing server to reach ready, narrated on stderr
        \\
    , .{});
    std.process.exit(2);
}

/// The editor's message row, here: the client's reader threads narrate server
/// state through the same sink the shells register, printed to stderr so the
/// rows on stdout stay clean.
fn statusSink(ctx: ?*anyopaque, text: []const u8) void {
    _ = ctx;
    std.debug.print("# status: {s}\n", .{text});
}

extern "c" fn usleep(usec: c_uint) c_int;

pub fn main(init: std.process.Init) !void {
    const gpa = std.heap.page_allocator;
    const arena = init.arena.allocator();
    const io = std.Io.Threaded.global_single_threaded.io();

    const args = try init.minimal.args.toSlice(arena);
    if (args.len < 4) usage();

    // comma-separated kinds share ONE server instance and its index — the
    // whole battery against a big workspace pays the cold cost once
    var kinds: [16]lsp.Kind = undefined;
    var nkinds: usize = 0;
    var kit = std.mem.tokenizeScalar(u8, args[1], ',');
    while (kit.next()) |word| {
        if (nkinds == kinds.len) usage();
        kinds[nkinds] = blk: {
            inline for (aliases) |a| if (std.mem.eql(u8, word, a[0])) break :blk a[1];
            break :blk std.meta.stringToEnum(lsp.Kind, word) orelse usage();
        };
        nkinds += 1;
    }
    if (nkinds == 0) usage();

    var pz: [4096:0]u8 = undefined;
    const pz_s = std.fmt.bufPrintSentinel(&pz, "{s}", .{args[2]}, 0) catch usage();
    var realbuf: [4096]u8 = undefined;
    const path = std.mem.span(realpath(pz_s.ptr, &realbuf) orelse {
        std.debug.print("cannot resolve {s}\n", .{args[2]});
        std.process.exit(1);
    });
    const source = std.Io.Dir.cwd().readFileAllocOptions(
        io,
        path,
        arena,
        .limited(64 << 20),
        .of(u8),
        0,
    ) catch {
        std.debug.print("cannot read {s}\n", .{path});
        std.process.exit(1);
    };

    const colon = std.mem.indexOfScalar(u8, args[3], ':') orelse usage();
    const line = std.fmt.parseInt(u32, args[3][0..colon], 10) catch usage();
    const col = std.fmt.parseInt(u32, args[3][colon + 1 ..], 10) catch usage();
    if (line == 0 or col == 0) usage();

    var arg: []const u8 = "";
    var reps: u32 = 1;
    var warm_s: u32 = 0;
    var i: usize = 4;
    while (i < args.len) : (i += 1) {
        if (std.mem.eql(u8, args[i], "--reps")) {
            i += 1;
            if (i >= args.len) usage();
            reps = @max(1, std.fmt.parseInt(u32, args[i], 10) catch usage());
        } else if (std.mem.eql(u8, args[i], "--warm")) {
            i += 1;
            if (i >= args.len) usage();
            warm_s = std.fmt.parseInt(u32, args[i], 10) catch usage();
        } else arg = args[i];
    }

    lsp.setStatusSink(null, statusSink);

    // 1-based line:col -> byte offset
    var off: usize = 0;
    var ln: u32 = 1;
    while (ln < line and off < source.len) : (off += 1) {
        if (source[off] == '\n') ln += 1;
    }
    off = @min(off + col - 1, source.len);

    const root = std.fs.path.dirname(path) orelse "/";
    const timings = try arena.alloc(u64, reps);
    var stdout_buf: [64 * 1024]u8 = undefined;
    var stdout = std.Io.File.stdout().writer(io, &stdout_buf);
    var first_query = true;

    for (kinds[0..nkinds]) |kind| {
        var rows: []const u8 = "";
        for (0..reps) |r| {
            // one warm pause for the whole battery, right after the very
            // first (cold, server-spawning) query — the status lines on
            // stderr narrate what the server does with the time
            var out: std.Io.Writer.Allocating = .init(gpa);
            var scratch: std.heap.ArenaAllocator = .init(gpa);
            defer scratch.deinit();
            const t0 = nowUs();
            lsp.query(gpa, scratch.allocator(), .{
                .kind = kind,
                .path = path,
                .source = source,
                .offset = @intCast(off),
                .arg = arg,
                .root = root,
            }, &out.writer);
            timings[r] = nowUs() -| t0;
            rows = try arena.dupe(u8, out.written());
            out.deinit();
            if (first_query) {
                first_query = false;
                if (warm_s > 0) for (0..warm_s * 10) |_| {
                    _ = usleep(100_000);
                };
            }
        }

        try stdout.interface.print("## {t}\n", .{kind});
        try stdout.interface.writeAll(rows);
        try stdout.interface.flush();

        const n = std.mem.count(u8, rows, "\n");
        const cold = timings[0];
        std.mem.sort(u64, timings[0..reps], {}, std.sort.asc(u64));
        if (reps == 1)
            std.debug.print("# {t}: {d} row(s) in {d}us\n", .{ kind, n, cold })
        else
            std.debug.print("# {t}: {d} row(s); first {d}us, min {d}us, median {d}us ({d} reps)\n", .{
                kind, n, cold, timings[0], timings[reps / 2], reps,
            });
    }
}