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` (` /// `) 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 ` `; 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 "); }