From 56975c3cf39b59f384edab32c31a2c161250fcd6 Mon Sep 17 00:00:00 2001 From: slhx agent Date: Sun, 21 Jun 2026 04:34:55 +0200 Subject: [PATCH] Add LSP diagnostics panel --- src/lsp.zig | 154 ++++++++++++++++++++++++++++++++++++++++++++++++++++ src/tui.zig | 63 +++++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/src/lsp.zig b/src/lsp.zig index b18abaf..e71f53b 100644 --- a/src/lsp.zig +++ b/src/lsp.zig @@ -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)); +} diff --git a/src/tui.zig b/src/tui.zig index 6d6ed12..42d8318 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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); +}