summaryrefslogtreecommitdiff
path: root/src/lsp/lsp.zig
blob: 8dc4da4f827643b671e298bf61c1c72a3a0c0e0c (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
const std = @import("std");

pub const Kind = enum {
    definition,
    declaration,
    type_definition,
    implementation,
    references,
    hover,
    document_symbols,
    workspace_symbols,
    diagnostics,
    workspace_diagnostics,
    rename,
    code_action,
    format,
    select_refs,
    completion,
    incoming_calls,
    outgoing_calls,
    supertypes,
    subtypes,
    status,
    explain,
};

pub const Req = struct {
    kind: Kind,
    path: []const u8,
    source: [:0]const u8,
    offset: u32,
    arg: []const u8 = "",
    root: []const u8 = "",
    /// The other files open in the editor, for a query whose answer can
    /// reach them (a rename, references): the server is told of each first,
    /// or it answers from what it has seen and misses the rest.
    others: []const Doc = &.{},
    /// For `explain`: the query it narrates, read from `arg` (`<kind>
    /// <arg>`) by `explaining`; a definition when none was asked before.
    explains: Kind = .definition,
};

pub const Doc = struct { path: []const u8, source: [:0]const u8 };

/// Whether a query's answer can name places in other files, so every open
/// file of the server's language is synced before it is asked.
pub fn reachesOtherFiles(kind: Kind) bool {
    return switch (kind) {
        .rename, .references, .implementation, .workspace_symbols, .incoming_calls, .outgoing_calls, .supertypes, .subtypes => true,
        else => false,
    };
}

/// An `explain` request made the one it narrates: `explains` and `arg` set
/// from its `<kind> <arg>`; any other request as it is.
pub fn explaining(req: Req) Req {
    if (req.kind != .explain) return req;
    var r = req;
    const sp = std.mem.indexOfScalar(u8, req.arg, ' ') orelse req.arg.len;
    const named = std.meta.stringToEnum(Kind, req.arg[0..sp]) orelse .definition;
    r.explains = if (named == .explain or named == .status) .definition else named;
    r.arg = if (sp < req.arg.len) req.arg[sp + 1 ..] else "";
    return r;
}

test "an explain request narrates the query named in its arg, a definition by default" {
    const base: Req = .{ .kind = .explain, .path = "/x.zig", .source = "", .offset = 0 };
    var r = base;
    r.arg = "hover ";
    try std.testing.expectEqual(Kind.hover, explaining(r).explains);
    r.arg = "rename newName";
    try std.testing.expectEqual(Kind.rename, explaining(r).explains);
    try std.testing.expectEqualStrings("newName", explaining(r).arg);
    try std.testing.expectEqual(Kind.definition, explaining(base).explains);
}

/// Every language, Zig's (zls) included, through the protocol client, which
/// runs its server as a child process. None on the web or the board.
const backends = if (@import("pardes_config").platform == .web or @import("pardes_config").platform == .esp32p4)
    .{}
else
    .{@import("lsp_client.zig")};

pub const backend_name = "lsp-client";

pub const supports: std.EnumSet(Kind) = blk: {
    var s: std.EnumSet(Kind) = .initEmpty();
    for (0..backends.len) |i| s.setUnion(backends[i].supports);
    break :blk s;
};

pub fn speaks(path: []const u8) bool {
    inline for (backends) |b| if (b.speaks(path)) return true;
    return false;
}

pub fn query(gpa: std.mem.Allocator, arena: std.mem.Allocator, req: Req, out: *std.Io.Writer) !void {
    if (req.kind == .status) {
        try out.print("backend: {s}\n", .{backend_name});
        inline for (backends) |b| try b.query(gpa, arena, req, out);
        return;
    }
    inline for (backends) |b| {
        if (b.speaks(req.path) and b.supports.contains(req.kind))
            return b.query(gpa, arena, req, out);
    }
    if (req.kind == .explain and backends.len > 0)
        return backends[0].query(gpa, arena, req, out);
    // Nothing asked: said why, where an empty answer would read as nothing
    // found.
    inline for (backends) |b| if (b.speaks(req.path))
        return none(out, "the language server for this file does not answer it", .{});
    try none(out, "no language server for this file", .{});
}

/// The one line a backend answers with when it cannot answer at all, and
/// why: the core says it rather than "nothing found".
pub fn none(out: *std.Io.Writer, comptime fmt: []const u8, args: anytype) std.Io.Writer.Error!void {
    try out.print("@none " ++ fmt ++ "\n", args);
}

// Called on server reader threads; the sink must copy text before returning.
pub fn setStatusSink(ctx: ?*anyopaque, cb: ?*const fn (ctx: ?*anyopaque, text: []const u8) void) void {
    inline for (backends) |b| b.setStatusSink(ctx, cb);
}

pub fn rel(base: []const u8, path: []const u8) []const u8 {
    if (base.len == 0) return path;
    const prefix = std.mem.trimEnd(u8, base, "/");
    if (path.len > prefix.len and std.mem.startsWith(u8, path, prefix) and path[prefix.len] == '/')
        return path[prefix.len + 1 ..];
    return path;
}

pub fn row(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, text: []const u8) std.Io.Writer.Error!void {
    try out.print("{s}:{d}:{d} {s}\n", .{
        path, line + 1, col + 1, std.mem.trim(u8, text, " \t\r\n"),
    });
}

// Input positions are zero-based and end-exclusive; displayed spans are one-based and inclusive.
pub fn spanRow(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, end_line: usize, end_col: usize, text: []const u8) std.Io.Writer.Error!void {
    return sourceSpanRow(out, path, line, col, end_line, end_col, std.mem.trim(u8, text, " \t\r\n"));
}

/// Source bytes keep their indentation and trailing spaces so byte columns
/// still identify the same token in the displayed snippet.
pub fn sourceSpanRow(out: *std.Io.Writer, path: []const u8, line: usize, col: usize, end_line: usize, end_col: usize, text: []const u8) std.Io.Writer.Error!void {
    try out.print("{s}:{d}:{d}", .{ path, line + 1, col + 1 });
    if (end_line == line and end_col > col) try out.print("-{d}", .{end_col});
    try out.print(" {s}\n", .{std.mem.trimEnd(u8, text, "\r\n")});
}

pub fn edit(out: *std.Io.Writer, start: usize, end: usize) std.Io.Writer.Error!void {
    try out.print("@edit {d} {d}\n", .{ start, end });
}

pub fn put(out: *std.Io.Writer, start: usize, end: usize, text: []const u8) std.Io.Writer.Error!void {
    try out.print("@put {d} {d} ", .{ start, end });
    for (text) |c| {
        if (c == '%' or c < 0x21)
            try out.print("%{X:0>2}", .{c})
        else
            try out.writeByte(c);
    }
    try out.writeByte('\n');
}

pub fn lineCol(source: []const u8, offset: usize) struct { line: usize, col: usize } {
    const upto = source[0..@min(offset, source.len)];
    const line = std.mem.count(u8, upto, "\n");
    const bol = if (std.mem.lastIndexOfScalar(u8, upto, '\n')) |i| i + 1 else 0;
    return .{ .line = line, .col = upto.len - bol };
}

test "LSP encoders report every insufficient output capacity" {
    const cases = [_]struct { kind: enum { row, span, source_span, edit, put }, expected: []const u8 }{
        .{ .kind = .row, .expected = "file:1:3 hi\n" },
        .{ .kind = .span, .expected = "file:1:3-5 hi\n" },
        .{ .kind = .source_span, .expected = "file:1:3-5 \t hi \t\n" },
        .{ .kind = .edit, .expected = "@edit 1 3\n" },
        .{ .kind = .put, .expected = "@put 1 3 hé%20%25%0A\n" },
    };
    for (cases) |case| {
        var buf: [128]u8 = undefined;
        for (0..case.expected.len + 1) |capacity| {
            var out: std.Io.Writer = .fixed(buf[0..capacity]);
            const result = switch (case.kind) {
                .row => row(&out, "file", 0, 2, " hi \n"),
                .span => spanRow(&out, "file", 0, 2, 0, 5, " hi \n"),
                .source_span => sourceSpanRow(&out, "file", 0, 2, 0, 5, "\t hi \t\r\n"),
                .edit => edit(&out, 1, 3),
                .put => put(&out, 1, 3, "hé %\n"),
            };
            if (capacity < case.expected.len) {
                try std.testing.expectError(error.WriteFailed, result);
            } else {
                try result;
                try std.testing.expectEqualStrings(case.expected, out.buffered());
            }
        }
    }
}

test "a question no backend answers for a file says why rather than find nothing" {
    if (backends.len == 0) return;
    var arena: std.heap.ArenaAllocator = .init(std.testing.allocator);
    defer arena.deinit();
    var buf: [256]u8 = undefined;
    var out: std.Io.Writer = .fixed(&buf);
    // A file no server speaks for is answered, not left empty.
    try query(std.testing.allocator, arena.allocator(), .{ .kind = .incoming_calls, .path = "/x/notes.txt", .source = "", .offset = 0 }, &out);
    try std.testing.expectStringStartsWith(out.buffered(), "@none ");
}