Add LSP document sync transport
This commit is contained in:
+223
@@ -0,0 +1,223 @@
|
||||
const std = @import("std");
|
||||
|
||||
// Minimal LSP process lifecycle + document sync transport.
|
||||
// req: coding/003, repo/002, testing/001, testing/002, testing/003, testing/004
|
||||
|
||||
test {
|
||||
_ = runDocumentSyncRowsAlloc;
|
||||
}
|
||||
|
||||
pub const Error = error{
|
||||
InvalidArgv,
|
||||
InvalidCwd,
|
||||
InvalidDocument,
|
||||
};
|
||||
|
||||
pub const Document = struct {
|
||||
uri: []const u8,
|
||||
language_id: []const u8,
|
||||
text: []const u8,
|
||||
};
|
||||
|
||||
pub fn runDocumentSyncRowsAlloc(
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
cwd: []const u8,
|
||||
argv: []const []const u8,
|
||||
document: Document,
|
||||
) ![][]const u8 {
|
||||
try validateCwd(cwd);
|
||||
try validateArgv(argv);
|
||||
try validateDocument(document);
|
||||
const transcript = try transcriptAlloc(allocator, document);
|
||||
defer allocator.free(transcript);
|
||||
|
||||
var rows = std.ArrayList([]const u8).empty;
|
||||
errdefer {
|
||||
for (rows.items) |row| allocator.free(row);
|
||||
rows.deinit(allocator);
|
||||
}
|
||||
const preview = try commandPreviewAlloc(allocator, argv);
|
||||
defer allocator.free(preview);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:spawned:{s}", .{preview}));
|
||||
|
||||
var child = std.process.spawn(io, .{
|
||||
.argv = argv,
|
||||
.cwd = .{ .path = cwd },
|
||||
.stdin = .pipe,
|
||||
.stdout = .ignore,
|
||||
.stderr = .ignore,
|
||||
}) catch |err| {
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:spawn_error_{s}", .{@errorName(err)}));
|
||||
return rows.toOwnedSlice(allocator);
|
||||
};
|
||||
defer child.kill(io);
|
||||
|
||||
child.stdin.?.writeStreamingAll(io, transcript) catch |err| {
|
||||
child.stdin.?.close(io);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:write_error_{s}", .{@errorName(err)}));
|
||||
_ = child.wait(io) catch {};
|
||||
return rows.toOwnedSlice(allocator);
|
||||
};
|
||||
child.stdin.?.close(io);
|
||||
child.stdin = null;
|
||||
|
||||
try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:initialize"));
|
||||
try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:textDocument/didOpen"));
|
||||
try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:textDocument/didChange"));
|
||||
try rows.append(allocator, try allocator.dupe(u8, "lsp:sent:textDocument/didSave"));
|
||||
|
||||
const term = child.wait(io) catch |err| {
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:status:wait_error_{s}", .{@errorName(err)}));
|
||||
return rows.toOwnedSlice(allocator);
|
||||
};
|
||||
child.id = null;
|
||||
try appendTermRow(allocator, &rows, term);
|
||||
return rows.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
pub fn transcriptAlloc(allocator: std.mem.Allocator, document: Document) ![]u8 {
|
||||
try validateDocument(document);
|
||||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(allocator);
|
||||
try appendFramed(allocator, &out, try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{{\"capabilities\":{{}}}}}}",
|
||||
.{},
|
||||
));
|
||||
const uri_json = try jsonEscapeAlloc(allocator, document.uri);
|
||||
defer allocator.free(uri_json);
|
||||
const language_json = try jsonEscapeAlloc(allocator, document.language_id);
|
||||
defer allocator.free(language_json);
|
||||
const text_json = try jsonEscapeAlloc(allocator, document.text);
|
||||
defer allocator.free(text_json);
|
||||
try appendFramed(allocator, &out, try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didOpen\",\"params\":{{\"textDocument\":{{\"uri\":\"{s}\",\"languageId\":\"{s}\",\"version\":1,\"text\":\"{s}\"}}}}}}",
|
||||
.{ uri_json, language_json, text_json },
|
||||
));
|
||||
try appendFramed(allocator, &out, try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didChange\",\"params\":{{\"textDocument\":{{\"uri\":\"{s}\",\"version\":2}},\"contentChanges\":[{{\"text\":\"{s}\"}}]}}}}",
|
||||
.{ uri_json, text_json },
|
||||
));
|
||||
try appendFramed(allocator, &out, try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"{{\"jsonrpc\":\"2.0\",\"method\":\"textDocument/didSave\",\"params\":{{\"textDocument\":{{\"uri\":\"{s}\"}},\"text\":\"{s}\"}}}}",
|
||||
.{ uri_json, text_json },
|
||||
));
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn appendFramed(allocator: std.mem.Allocator, out: *std.ArrayList(u8), body: []u8) !void {
|
||||
defer allocator.free(body);
|
||||
const header = try std.fmt.allocPrint(allocator, "Content-Length: {d}\r\n\r\n", .{body.len});
|
||||
defer allocator.free(header);
|
||||
try out.appendSlice(allocator, header);
|
||||
try out.appendSlice(allocator, body);
|
||||
}
|
||||
|
||||
fn jsonEscapeAlloc(allocator: std.mem.Allocator, value: []const u8) ![]u8 {
|
||||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(allocator);
|
||||
for (value) |byte| switch (byte) {
|
||||
'\\' => try out.appendSlice(allocator, "\\\\"),
|
||||
'"' => try out.appendSlice(allocator, "\\\""),
|
||||
'\n' => try out.appendSlice(allocator, "\\n"),
|
||||
'\r' => try out.appendSlice(allocator, "\\r"),
|
||||
'\t' => try out.appendSlice(allocator, "\\t"),
|
||||
else => try out.append(allocator, byte),
|
||||
};
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn validateCwd(cwd: []const u8) !void {
|
||||
if (cwd.len == 0 or !std.unicode.utf8ValidateSlice(cwd)) return Error.InvalidCwd;
|
||||
for (cwd) |byte| if (byte == 0 or byte == '\n' or byte == '\r' or byte == '|') return Error.InvalidCwd;
|
||||
}
|
||||
|
||||
fn validateArgv(argv: []const []const u8) !void {
|
||||
if (argv.len == 0) return Error.InvalidArgv;
|
||||
for (argv) |arg| {
|
||||
if (arg.len == 0 or !std.unicode.utf8ValidateSlice(arg)) return Error.InvalidArgv;
|
||||
for (arg) |byte| if (byte == 0 or byte == '\n' or byte == '\r') return Error.InvalidArgv;
|
||||
}
|
||||
}
|
||||
|
||||
fn validateDocument(document: Document) !void {
|
||||
if (document.uri.len == 0 or document.language_id.len == 0) return Error.InvalidDocument;
|
||||
if (!std.mem.startsWith(u8, document.uri, "file://")) return Error.InvalidDocument;
|
||||
if (!std.unicode.utf8ValidateSlice(document.uri) or !std.unicode.utf8ValidateSlice(document.language_id) or !std.unicode.utf8ValidateSlice(document.text)) return Error.InvalidDocument;
|
||||
for (document.uri) |byte| if (byte == 0 or byte == '\n' or byte == '\r' or byte == ' ') return Error.InvalidDocument;
|
||||
for (document.language_id) |byte| if (byte == 0 or byte <= 0x20 or byte == '|') return Error.InvalidDocument;
|
||||
}
|
||||
|
||||
fn appendTermRow(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), term: std.process.Child.Term) !void {
|
||||
const row = switch (term) {
|
||||
.exited => |code| try std.fmt.allocPrint(allocator, "lsp:status:exit_{d}", .{code}),
|
||||
.signal => |sig| try std.fmt.allocPrint(allocator, "lsp:status:signal_{d}", .{@intFromEnum(sig)}),
|
||||
.stopped => |sig| try std.fmt.allocPrint(allocator, "lsp:status:stopped_{d}", .{@intFromEnum(sig)}),
|
||||
.unknown => |code| try std.fmt.allocPrint(allocator, "lsp:status:unknown_{d}", .{code}),
|
||||
};
|
||||
try rows.append(allocator, row);
|
||||
}
|
||||
|
||||
fn commandPreviewAlloc(allocator: std.mem.Allocator, argv: []const []const u8) ![]u8 {
|
||||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(allocator);
|
||||
for (argv, 0..) |arg, index| {
|
||||
if (index != 0) try out.append(allocator, '_');
|
||||
for (arg) |byte| try out.append(allocator, if (byte <= 0x20 or byte == '|') '_' else byte);
|
||||
if (out.items.len >= 120) break;
|
||||
}
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn freeRows(allocator: std.mem.Allocator, rows: []const []const u8) void {
|
||||
for (rows) |row| allocator.free(row);
|
||||
allocator.free(rows);
|
||||
}
|
||||
|
||||
test "regular: transcript frames initialize open change and save" {
|
||||
const transcript = try transcriptAlloc(std.testing.allocator, .{ .uri = "file:///repo/src/main.zig", .language_id = "zig", .text = "const x = 1;\n" });
|
||||
defer std.testing.allocator.free(transcript);
|
||||
try std.testing.expect(std.mem.indexOf(u8, transcript, "Content-Length:") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, transcript, "\"method\":\"initialize\"") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, transcript, "textDocument/didOpen") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, transcript, "textDocument/didChange") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, transcript, "textDocument/didSave") != null);
|
||||
}
|
||||
|
||||
test "regular: fake lsp server observes document sync on stdin" {
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
const cwd = try std.fmt.allocPrint(std.testing.allocator, ".zig-cache/tmp/{s}", .{&tmp.sub_path});
|
||||
defer std.testing.allocator.free(cwd);
|
||||
|
||||
const rows = try runDocumentSyncRowsAlloc(std.testing.allocator, std.testing.io, cwd, &.{ "sh", "-c", "cat>observed.lsp" }, .{
|
||||
.uri = "file:///tmp/main.zig",
|
||||
.language_id = "zig",
|
||||
.text = "pub const x = 1;\n",
|
||||
});
|
||||
defer freeRows(std.testing.allocator, rows);
|
||||
try std.testing.expectEqualStrings("lsp:sent:initialize", rows[1]);
|
||||
try std.testing.expectEqualStrings("lsp:sent:textDocument/didOpen", rows[2]);
|
||||
try std.testing.expectEqualStrings("lsp:sent:textDocument/didChange", rows[3]);
|
||||
try std.testing.expectEqualStrings("lsp:sent:textDocument/didSave", rows[4]);
|
||||
try std.testing.expectEqualStrings("lsp:status:exit_0", rows[5]);
|
||||
|
||||
const observed = try tmp.dir.readFileAlloc(std.testing.io, "observed.lsp", std.testing.allocator, .limited(64 * 1024));
|
||||
defer std.testing.allocator.free(observed);
|
||||
try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didOpen") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didChange") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, observed, "textDocument/didSave") != null);
|
||||
}
|
||||
|
||||
test "adversarial: lsp validation and spawn failures are explicit" {
|
||||
try std.testing.expectError(Error.InvalidArgv, runDocumentSyncRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{}, .{ .uri = "file:///x", .language_id = "zig", .text = "" }));
|
||||
try std.testing.expectError(Error.InvalidDocument, transcriptAlloc(std.testing.allocator, .{ .uri = "file with spaces", .language_id = "zig", .text = "" }));
|
||||
|
||||
const rows = try runDocumentSyncRowsAlloc(std.testing.allocator, std.testing.io, ".", &.{"definitely-not-a-mim-lsp"}, .{ .uri = "file:///x", .language_id = "zig", .text = "" });
|
||||
defer freeRows(std.testing.allocator, rows);
|
||||
try std.testing.expect(std.mem.startsWith(u8, rows[1], "lsp:status:spawn_error_"));
|
||||
}
|
||||
Reference in New Issue
Block a user