Add LSP diagnostics panel
This commit is contained in:
+154
@@ -11,6 +11,8 @@ pub const Error = error{
|
||||
InvalidArgv,
|
||||
InvalidCwd,
|
||||
InvalidDocument,
|
||||
InvalidDiagnostics,
|
||||
InvalidDiagnosticRow,
|
||||
};
|
||||
|
||||
pub const Document = struct {
|
||||
@@ -19,6 +21,82 @@ pub const Document = struct {
|
||||
text: []const u8,
|
||||
};
|
||||
|
||||
pub const DiagnosticLocation = struct {
|
||||
uri: []const u8,
|
||||
line: usize,
|
||||
character: usize,
|
||||
};
|
||||
|
||||
pub fn diagnosticRowsAlloc(allocator: std.mem.Allocator, payload: []const u8) ![][]const u8 {
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidDiagnostics;
|
||||
defer parsed.deinit();
|
||||
const root = parsed.value;
|
||||
if (root != .object) return Error.InvalidDiagnostics;
|
||||
const params = objectGet(root, "params") orelse return Error.InvalidDiagnostics;
|
||||
const uri = stringGet(params, "uri") orelse return Error.InvalidDiagnostics;
|
||||
const diagnostics_value = objectGet(params, "diagnostics") orelse return Error.InvalidDiagnostics;
|
||||
if (diagnostics_value != .array) return Error.InvalidDiagnostics;
|
||||
|
||||
var rows = std.ArrayList([]const u8).empty;
|
||||
errdefer {
|
||||
for (rows.items) |row| allocator.free(row);
|
||||
rows.deinit(allocator);
|
||||
}
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "lsp:diagnostics:count_{d}", .{diagnostics_value.array.items.len}));
|
||||
const uri_preview = try previewAlloc(allocator, uri);
|
||||
defer allocator.free(uri_preview);
|
||||
for (diagnostics_value.array.items) |diagnostic| {
|
||||
const range = objectGet(diagnostic, "range") orelse return Error.InvalidDiagnostics;
|
||||
const start = objectGet(range, "start") orelse return Error.InvalidDiagnostics;
|
||||
const line = unsignedGet(start, "line") orelse return Error.InvalidDiagnostics;
|
||||
const character = unsignedGet(start, "character") orelse return Error.InvalidDiagnostics;
|
||||
const severity = unsignedGet(diagnostic, "severity") orelse 1;
|
||||
const message = stringGet(diagnostic, "message") orelse return Error.InvalidDiagnostics;
|
||||
const preview = try previewAlloc(allocator, message);
|
||||
defer allocator.free(preview);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(
|
||||
allocator,
|
||||
"lsp:diag:{s}:{d}:{d}:{s}:{s}",
|
||||
.{ severityText(severity), line + 1, character + 1, uri_preview, preview },
|
||||
));
|
||||
}
|
||||
return rows.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
pub fn diagnosticLocationFromRow(row: []const u8) !DiagnosticLocation {
|
||||
if (!std.mem.startsWith(u8, row, "lsp:diag:")) return Error.InvalidDiagnosticRow;
|
||||
var parts = std.mem.splitScalar(u8, row, ':');
|
||||
_ = parts.next() orelse return Error.InvalidDiagnosticRow; // lsp
|
||||
_ = parts.next() orelse return Error.InvalidDiagnosticRow; // diag
|
||||
_ = parts.next() orelse return Error.InvalidDiagnosticRow; // severity
|
||||
const line_token = parts.next() orelse return Error.InvalidDiagnosticRow;
|
||||
const character_token = parts.next() orelse return Error.InvalidDiagnosticRow;
|
||||
const uri = parts.next() orelse return Error.InvalidDiagnosticRow;
|
||||
const line = std.fmt.parseUnsigned(usize, line_token, 10) catch return Error.InvalidDiagnosticRow;
|
||||
const character = std.fmt.parseUnsigned(usize, character_token, 10) catch return Error.InvalidDiagnosticRow;
|
||||
if (line == 0 or character == 0 or uri.len == 0) return Error.InvalidDiagnosticRow;
|
||||
return .{ .uri = uri, .line = line, .character = character };
|
||||
}
|
||||
|
||||
pub fn byteOffsetForLineColumn(content: []const u8, line: usize, column: usize) !usize {
|
||||
if (line == 0 or column == 0) return Error.InvalidDiagnosticRow;
|
||||
var current_line: usize = 1;
|
||||
var line_start: usize = 0;
|
||||
var i: usize = 0;
|
||||
while (i <= content.len) : (i += 1) {
|
||||
if (i == content.len or content[i] == '\n') {
|
||||
if (current_line == line) {
|
||||
const line_bytes = content[line_start..i];
|
||||
if (column - 1 > line_bytes.len) return Error.InvalidDiagnosticRow;
|
||||
return line_start + column - 1;
|
||||
}
|
||||
current_line += 1;
|
||||
line_start = i + 1;
|
||||
}
|
||||
}
|
||||
return Error.InvalidDiagnosticRow;
|
||||
}
|
||||
|
||||
pub fn runDocumentSyncRowsAlloc(
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
@@ -109,6 +187,56 @@ pub fn transcriptAlloc(allocator: std.mem.Allocator, document: Document) ![]u8 {
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn objectGet(value: std.json.Value, key: []const u8) ?std.json.Value {
|
||||
if (value != .object) return null;
|
||||
return value.object.get(key);
|
||||
}
|
||||
|
||||
fn stringGet(value: std.json.Value, key: []const u8) ?[]const u8 {
|
||||
const child = objectGet(value, key) orelse return null;
|
||||
return switch (child) {
|
||||
.string => |text| text,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn unsignedGet(value: std.json.Value, key: []const u8) ?usize {
|
||||
const child = objectGet(value, key) orelse return null;
|
||||
return switch (child) {
|
||||
.integer => |number| if (number >= 0) @intCast(number) else null,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn severityText(severity: usize) []const u8 {
|
||||
return switch (severity) {
|
||||
1 => "error",
|
||||
2 => "warning",
|
||||
3 => "info",
|
||||
4 => "hint",
|
||||
else => "unknown",
|
||||
};
|
||||
}
|
||||
|
||||
fn previewAlloc(allocator: std.mem.Allocator, text: []const u8) ![]u8 {
|
||||
var out = std.ArrayList(u8).empty;
|
||||
errdefer out.deinit(allocator);
|
||||
var i: usize = 0;
|
||||
while (i < text.len and out.items.len < 80) {
|
||||
const len = std.unicode.utf8ByteSequenceLength(text[i]) catch 1;
|
||||
const end = @min(text.len, i + len);
|
||||
const slice = text[i..end];
|
||||
if (slice.len == 1 and (slice[0] <= 0x20 or slice[0] == ':' or slice[0] == '|')) {
|
||||
try out.append(allocator, '_');
|
||||
} else {
|
||||
try out.appendSlice(allocator, slice);
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
if (out.items.len == 0) try out.append(allocator, '_');
|
||||
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});
|
||||
@@ -221,3 +349,29 @@ test "adversarial: lsp validation and spawn failures are explicit" {
|
||||
defer freeRows(std.testing.allocator, rows);
|
||||
try std.testing.expect(std.mem.startsWith(u8, rows[1], "lsp:status:spawn_error_"));
|
||||
}
|
||||
|
||||
test "regular: publish diagnostics payload becomes panel rows and location" {
|
||||
const payload =
|
||||
\\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///repo/src/main.zig","diagnostics":[{"range":{"start":{"line":1,"character":4},"end":{"line":1,"character":5}},"severity":1,"message":"expected semicolon"},{"range":{"start":{"line":2,"character":0},"end":{"line":2,"character":3}},"severity":2,"message":"unused var"}]}}
|
||||
;
|
||||
const rows = try diagnosticRowsAlloc(std.testing.allocator, payload);
|
||||
defer freeRows(std.testing.allocator, rows);
|
||||
try std.testing.expectEqualStrings("lsp:diagnostics:count_2", rows[0]);
|
||||
try std.testing.expectEqualStrings("lsp:diag:error:2:5:file_///repo/src/main.zig:expected_semicolon", rows[1]);
|
||||
try std.testing.expectEqualStrings("lsp:diag:warning:3:1:file_///repo/src/main.zig:unused_var", rows[2]);
|
||||
const location = try diagnosticLocationFromRow(rows[1]);
|
||||
try std.testing.expectEqualStrings("file_///repo/src/main.zig", location.uri);
|
||||
try std.testing.expectEqual(@as(usize, 2), location.line);
|
||||
try std.testing.expectEqual(@as(usize, 5), location.character);
|
||||
}
|
||||
|
||||
test "regular: diagnostic location maps to buffer offset" {
|
||||
try std.testing.expectEqual(@as(usize, 8), try byteOffsetForLineColumn("one\nabcdTARGET\n", 2, 5));
|
||||
}
|
||||
|
||||
test "adversarial: bad diagnostics and rows are rejected" {
|
||||
try std.testing.expectError(Error.InvalidDiagnostics, diagnosticRowsAlloc(std.testing.allocator, "not json"));
|
||||
try std.testing.expectError(Error.InvalidDiagnostics, diagnosticRowsAlloc(std.testing.allocator, "{}"));
|
||||
try std.testing.expectError(Error.InvalidDiagnosticRow, diagnosticLocationFromRow("lsp:diagnostics:count_1"));
|
||||
try std.testing.expectError(Error.InvalidDiagnosticRow, byteOffsetForLineColumn("short", 9, 1));
|
||||
}
|
||||
|
||||
+63
@@ -150,6 +150,8 @@ pub const Client = struct {
|
||||
if (std.mem.eql(u8, line, "terminal_exit")) return self.closeTerminalPanel();
|
||||
if (std.mem.startsWith(u8, line, "syntax_spans ")) return self.openSyntaxSpans(line[13..]);
|
||||
if (std.mem.startsWith(u8, line, "lsp_sync ")) return self.openLspSync(line[9..]);
|
||||
if (std.mem.startsWith(u8, line, "lsp_diagnostics ")) return self.openLspDiagnostics(line[16..]);
|
||||
if (std.mem.eql(u8, line, "lsp_diagnostics_open_selected")) return self.openSelectedLspDiagnostic();
|
||||
if (std.mem.startsWith(u8, line, "panel_open ")) return self.applyProtocolCommand(line);
|
||||
if (std.mem.startsWith(u8, line, "list_open ")) return self.applyProtocolCommand(line);
|
||||
if (std.mem.startsWith(u8, line, "list_filter ")) return self.applyProtocolCommand(line);
|
||||
@@ -439,6 +441,25 @@ pub const Client = struct {
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn openLspDiagnostics(self: *Client, payload: []const u8) !void {
|
||||
const rows = lsp_mod.diagnosticRowsAlloc(self.allocator, payload) catch return Error.ProtocolRejected;
|
||||
defer freeOwnedRows(self.allocator, rows);
|
||||
try self.session.openListPanel("lsp-diagnostics", rows);
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn openSelectedLspDiagnostic(self: *Client) !void {
|
||||
const selected = self.session.selectListPanel() catch return Error.ProtocolRejected;
|
||||
const location = lsp_mod.diagnosticLocationFromRow(selected) catch return Error.ProtocolRejected;
|
||||
const snap = try self.session.snapshot();
|
||||
const bytes = try self.allocator.dupe(u8, snap.bytes);
|
||||
defer self.allocator.free(bytes);
|
||||
const offset = lsp_mod.byteOffsetForLineColumn(bytes, location.line, location.character) catch return Error.ProtocolRejected;
|
||||
try self.session.openFixtureAt(bytes, offset);
|
||||
try self.session.closePanel();
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn openStaticJobRow(self: *Client, title: []const u8, row: []const u8) !void {
|
||||
try self.session.openListPanel(title, &.{row});
|
||||
self.message = null;
|
||||
@@ -1590,3 +1611,45 @@ test "adversarial: lsp sync rejection does not corrupt current buffer" {
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings("safe", snap.bytes);
|
||||
}
|
||||
|
||||
test "regular: lsp diagnostics panel renders rows and jumps to diagnostic" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 96, .height = 7 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open one\\nabcdTARGET");
|
||||
const payload =
|
||||
\\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///tmp/main.zig","diagnostics":[{"range":{"start":{"line":0,"character":4},"end":{"line":1,"character":5}},"severity":1,"message":"expected semicolon"}]}}
|
||||
;
|
||||
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_diagnostics {s}", .{payload});
|
||||
defer std.testing.allocator.free(command);
|
||||
try client.handleTraceLine(command);
|
||||
{
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp-diagnostics") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:diagnostics:count_1") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:diag:error:1:5:file_///tmp/main.zig:expected_semicolon") != null);
|
||||
}
|
||||
|
||||
try client.handleTraceLine("list_down");
|
||||
try client.handleTraceLine("lsp_diagnostics_open_selected");
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
||||
}
|
||||
|
||||
test "adversarial: malformed diagnostics and unselected summary row do not corrupt buffer" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 5 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open safe");
|
||||
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_diagnostics not-json"));
|
||||
const payload =
|
||||
\\{"jsonrpc":"2.0","method":"textDocument/publishDiagnostics","params":{"uri":"file:///tmp/main.zig","diagnostics":[{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"severity":2,"message":"warn"}]}}
|
||||
;
|
||||
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_diagnostics {s}", .{payload});
|
||||
defer std.testing.allocator.free(command);
|
||||
try client.handleTraceLine(command);
|
||||
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_diagnostics_open_selected"));
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings("safe", snap.bytes);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user