diff --git a/src/diagnostics.zig b/src/diagnostics.zig index 7924e3d..136a629 100644 --- a/src/diagnostics.zig +++ b/src/diagnostics.zig @@ -13,6 +13,99 @@ pub const SourceLabel = struct { row: []const u8, }; +pub const Severity = enum { + err, + warning, + info, + hint, + + pub fn parse(text: []const u8) !Severity { + if (std.mem.eql(u8, text, "error")) return .err; + if (std.mem.eql(u8, text, "err")) return .err; + if (std.mem.eql(u8, text, "warning")) return .warning; + if (std.mem.eql(u8, text, "info")) return .info; + if (std.mem.eql(u8, text, "hint")) return .hint; + return error.InvalidDiagnosticRow; + } + + pub fn label(self: Severity) []const u8 { + return switch (self) { + .err => "error", + .warning => "warning", + .info => "info", + .hint => "hint", + }; + } +}; + +pub const Diagnostic = struct { + provider: []const u8, + version: u64, + file: []const u8, + start: usize, + end: usize, + severity: Severity, + message: []const u8, + + pub fn stale(self: Diagnostic, document_version: u64) bool { + return self.version < document_version; + } +}; + +pub fn rowAlloc( + allocator: std.mem.Allocator, + provider: []const u8, + version: u64, + file: []const u8, + start: usize, + end: usize, + severity: Severity, + message: []const u8, +) ![]u8 { + if (provider.len == 0 or file.len == 0 or start > end) return error.InvalidDiagnosticRow; + return std.fmt.allocPrint( + allocator, + "diag:{s}:{d}:{s}:{d}:{d}:{s}:{s}", + .{ sanitizeToken(provider), version, severity.label(), start, end, sanitizeToken(file), sanitizeToken(message) }, + ); +} + +pub fn parseRow(row: []const u8) !Diagnostic { + if (!std.mem.startsWith(u8, row, "diag:")) return error.InvalidDiagnosticRow; + var parts = std.mem.splitScalar(u8, row, ':'); + _ = parts.next() orelse return error.InvalidDiagnosticRow; + const provider = parts.next() orelse return error.InvalidDiagnosticRow; + const version_text = parts.next() orelse return error.InvalidDiagnosticRow; + const severity_text = parts.next() orelse return error.InvalidDiagnosticRow; + const start_text = parts.next() orelse return error.InvalidDiagnosticRow; + const end_text = parts.next() orelse return error.InvalidDiagnosticRow; + const file = parts.next() orelse return error.InvalidDiagnosticRow; + const message = parts.rest(); + const version = std.fmt.parseUnsigned(u64, version_text, 10) catch return error.InvalidDiagnosticRow; + const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return error.InvalidDiagnosticRow; + const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return error.InvalidDiagnosticRow; + if (provider.len == 0 or file.len == 0 or start > end) return error.InvalidDiagnosticRow; + return .{ .provider = provider, .version = version, .file = file, .start = start, .end = end, .severity = try Severity.parse(severity_text), .message = message }; +} + +pub fn panelRowAlloc(allocator: std.mem.Allocator, row: []const u8, document_version: u64) ![]u8 { + const diagnostic = try parseRow(row); + const freshness = if (diagnostic.stale(document_version)) "stale" else "fresh"; + return std.fmt.allocPrint( + allocator, + "diag:{s}:{s}:{s}:{d}-{d}:{s}:{s}", + .{ freshness, diagnostic.provider, diagnostic.severity.label(), diagnostic.start, diagnostic.end, diagnostic.file, diagnostic.message }, + ); +} + +pub fn sanitizeToken(text: []const u8) []const u8 { + if (text.len == 0) return "unknown"; + for (text) |byte| { + if (byte <= 0x20 or byte == ':' or byte == '|') return "invalid"; + } + return text; +} + pub fn sourceLabel(row: []const u8) SourceLabel { if (std.mem.startsWith(u8, row, "provider:")) { return .{ diff --git a/src/leader.zig b/src/leader.zig index 2bbb6cb..ebbe172 100644 --- a/src/leader.zig +++ b/src/leader.zig @@ -14,6 +14,7 @@ pub const Feature = enum { search, panel_close, lsp, + diagnostics, }; pub const Action = union(enum) { @@ -28,6 +29,10 @@ pub const Action = union(enum) { hover, signature, expand_hover, + diagnostics_open, + diagnostics_next, + diagnostics_previous, + diagnostics_filter, repeat_rail, restore_last, not_built: Feature, @@ -46,6 +51,7 @@ const Mode = enum { symbol_rail, search_rail, language_rail, + diagnostic_rail, open_prompt, }; @@ -71,6 +77,7 @@ pub const Leader = struct { .symbol_rail => return self.handleSymbolRail(event), .search_rail => return self.handleSearchRail(event), .language_rail => return self.handleLanguageRail(event), + .diagnostic_rail => return self.handleDiagnosticRail(event), .open_prompt => return self.handleOpenPrompt(event), } } @@ -83,6 +90,7 @@ pub const Leader = struct { .symbol_rail => symbol_mod.rail_status, .search_rail => "search: f current file p project s symbols", .language_rail => "language: h hover s signature o open hover", + .diagnostic_rail => "diagnostics: d/open n next p previous f filter-source", .open_prompt => "open: type path, Enter opens, Esc cancels", }; } @@ -131,6 +139,10 @@ pub const Leader = struct { self.mode = .language_rail; return .none; } + if (std.mem.eql(u8, text, "d")) { + self.mode = .diagnostic_rail; + return .diagnostics_open; + } if (std.mem.eql(u8, text, "q")) { self.mode = .idle; return .quit; @@ -216,6 +228,50 @@ pub const Leader = struct { } } + fn handleDiagnosticRail(self: *Leader, event: input.Event) Action { + self.message = null; + switch (event) { + .text => |text| { + if (std.mem.eql(u8, text, "d")) { + self.mode = .idle; + return .diagnostics_open; + } + if (std.mem.eql(u8, text, "n")) { + self.mode = .idle; + return .diagnostics_next; + } + if (std.mem.eql(u8, text, "p")) { + self.mode = .idle; + return .diagnostics_previous; + } + if (std.mem.eql(u8, text, "f")) { + self.mode = .idle; + return .diagnostics_filter; + } + self.mode = .idle; + self.message = "unknown diagnostics key"; + return .none; + }, + .key => |key| switch (key) { + .escape, .backspace => { + self.mode = .idle; + self.message = "diagnostics cancelled"; + return .none; + }, + else => { + self.mode = .idle; + self.message = "unknown diagnostics key"; + return .none; + }, + }, + .unknown => { + self.mode = .idle; + self.message = "unknown diagnostics key"; + return .none; + }, + } + } + fn handleSearchRail(self: *Leader, event: input.Event) Action { self.message = null; switch (event) { diff --git a/src/tui.zig b/src/tui.zig index f597715..c580bc0 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -152,6 +152,11 @@ pub const Client = struct { panel_context: PanelContext = .none, hover_rows: std.ArrayList([]u8) = .empty, signature_rows: std.ArrayList([]u8) = .empty, + diagnostic_rows: std.ArrayList([]u8) = .empty, + diagnostic_filter: ?[]u8 = null, + diagnostic_index: usize = 0, + diagnostic_selected: bool = false, + document_version: u64 = 1, undo_stack: std.ArrayList(EditSnapshot) = .empty, redo_stack: std.ArrayList(EditSnapshot) = .empty, yank_bytes: ?[]u8 = null, @@ -182,6 +187,8 @@ pub const Client = struct { self.search_matches.deinit(self.allocator); self.freeOwnedClientRows(&self.hover_rows); self.freeOwnedClientRows(&self.signature_rows); + self.freeOwnedClientRows(&self.diagnostic_rows); + if (self.diagnostic_filter) |filter| self.allocator.free(filter); self.freeSnapshotStack(&self.undo_stack); self.freeSnapshotStack(&self.redo_stack); self.repo.deinit(); @@ -203,6 +210,8 @@ pub const Client = struct { if (std.mem.startsWith(u8, line, "repo_file ")) return self.addRepoFile(line[10..]); if (std.mem.startsWith(u8, line, "lsp_hover_fixture ")) return self.addHoverFixture(line[18..]); if (std.mem.startsWith(u8, line, "lsp_signature_fixture ")) return self.addSignatureFixture(line[22..]); + if (std.mem.startsWith(u8, line, "diagnostic_fixture ")) return self.addDiagnosticFixture(line[19..]); + if (std.mem.startsWith(u8, line, "diagnostic_filter ")) return self.setDiagnosticFilter(line[18..]); if (std.mem.eql(u8, line, "file_picker")) return self.openRepoList("files", false, false); if (std.mem.eql(u8, line, "file_picker all")) return self.openRepoList("files", false, true); if (std.mem.eql(u8, line, "file_tree")) return self.openRepoList("tree", true, false); @@ -514,6 +523,31 @@ pub const Client = struct { try self.signature_rows.append(self.allocator, row); } + fn addDiagnosticFixture(self: *Client, payload: []const u8) !void { + var parts = std.mem.splitScalar(u8, payload, '|'); + const provider = parts.next() orelse return Error.ProtocolRejected; + const version_text = parts.next() orelse return Error.ProtocolRejected; + const file = parts.next() orelse return Error.ProtocolRejected; + const start_text = parts.next() orelse return Error.ProtocolRejected; + const end_text = parts.next() orelse return Error.ProtocolRejected; + const severity_text = parts.next() orelse return Error.ProtocolRejected; + const message = parts.rest(); + const version = std.fmt.parseUnsigned(u64, version_text, 10) catch return Error.ProtocolRejected; + const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return Error.ProtocolRejected; + const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return Error.ProtocolRejected; + const severity = diagnostics_mod.Severity.parse(severity_text) catch return Error.ProtocolRejected; + const row = diagnostics_mod.rowAlloc(self.allocator, provider, version, file, start, end, severity, message) catch return Error.ProtocolRejected; + try self.diagnostic_rows.append(self.allocator, row); + } + + fn setDiagnosticFilter(self: *Client, provider: []const u8) !void { + if (self.diagnostic_filter) |old| self.allocator.free(old); + self.diagnostic_filter = if (std.mem.eql(u8, provider, "all")) null else try self.allocator.dupe(u8, diagnostics_mod.sanitizeToken(provider)); + self.diagnostic_index = 0; + self.diagnostic_selected = false; + try self.openDiagnosticsPanel(); + } + fn addRepoFile(self: *Client, payload: []const u8) !void { const separator = std.mem.indexOfScalar(u8, payload, '=') orelse { self.message = "diagnostic:unsupported_file:invalid_repo_file_payload"; @@ -1176,6 +1210,138 @@ pub const Client = struct { try self.openListRows("hover", rows, .none); } + const DiagnosticDirection = enum { next, previous }; + + fn diagnosticMatchesFilter(self: *const Client, row: []const u8) bool { + const filter = self.diagnostic_filter orelse return true; + const diagnostic = diagnostics_mod.parseRow(row) catch return false; + return std.mem.eql(u8, diagnostic.provider, filter); + } + + fn diagnosticPanelRowsAlloc(self: *const Client) ![][]const u8 { + var rows = std.ArrayList([]const u8).empty; + errdefer freeOwnedRows(self.allocator, rows.items); + for (self.diagnostic_rows.items) |row| { + if (!self.diagnosticMatchesFilter(row)) continue; + const panel_row = diagnostics_mod.panelRowAlloc(self.allocator, row, self.document_version) catch try self.allocator.dupe(u8, "diag:invalid"); + try rows.append(self.allocator, panel_row); + } + if (rows.items.len == 0) try rows.append(self.allocator, try self.allocator.dupe(u8, "diag:empty")); + return rows.toOwnedSlice(self.allocator); + } + + fn openDiagnosticsPanel(self: *Client) !void { + const snap = try self.session.snapshot(); + if (snap.active_panel_title) |title| { + if (std.mem.eql(u8, title, "diagnostics")) self.session.closePanel() catch {}; + } + const rows = try self.diagnosticPanelRowsAlloc(); + defer freeOwnedRows(self.allocator, rows); + try self.session.openListPanel("diagnostics", rows); + self.message = null; + } + + fn filteredDiagnosticCount(self: *const Client) usize { + var count: usize = 0; + for (self.diagnostic_rows.items) |row| { + if (self.diagnosticMatchesFilter(row)) count += 1; + } + return count; + } + + fn filteredDiagnosticRow(self: *const Client, index: usize) ?[]const u8 { + var seen: usize = 0; + for (self.diagnostic_rows.items) |row| { + if (!self.diagnosticMatchesFilter(row)) continue; + if (seen == index) return row; + seen += 1; + } + return null; + } + + fn gotoDiagnostic(self: *Client, direction: DiagnosticDirection) !void { + const count = self.filteredDiagnosticCount(); + if (count == 0) { + self.message = "diag:empty"; + return; + } + self.diagnostic_index = if (self.diagnostic_selected) switch (direction) { + .next => (self.diagnostic_index + 1) % count, + .previous => if (self.diagnostic_index == 0) count - 1 else self.diagnostic_index - 1, + } else switch (direction) { + .next => 0, + .previous => count - 1, + }; + self.diagnostic_selected = true; + const row = self.filteredDiagnosticRow(self.diagnostic_index) orelse return; + const range = self.diagnosticRangeFromRow(row) catch |err| { + self.message = @errorName(err); + return; + }; + try self.session.moveToByte(range.start); + try self.session.selectRange(range.start, range.end); + self.session.clearSelection(); + try self.session.moveToByte(range.start); + self.session.closePanel() catch {}; + self.message = "diag:jumped"; + } + + fn filterDiagnosticsFromPanel(self: *Client) !void { + const selected = self.session.activeListItem() catch null; + if (selected) |row| { + if (diagnosticProviderFromPanelRow(row)) |provider| { + try self.setDiagnosticFilter(provider); + return; + } + } + if (self.diagnostic_filter) |old| { + self.allocator.free(old); + self.diagnostic_filter = null; + self.message = "diag:filter_all"; + } else self.message = "diag:filter_select_source"; + try self.openDiagnosticsPanel(); + } + + fn diagnosticProviderFromPanelRow(row: []const u8) ?[]const u8 { + if (!std.mem.startsWith(u8, row, "diag:")) return null; + var parts = std.mem.splitScalar(u8, row, ':'); + _ = parts.next() orelse return null; + const freshness_or_empty = parts.next() orelse return null; + if (std.mem.eql(u8, freshness_or_empty, "empty") or std.mem.eql(u8, freshness_or_empty, "invalid")) return null; + return parts.next(); + } + + fn diagnosticRangeFromRow(self: *Client, row: []const u8) !ObjectRange { + const diagnostic = try diagnostics_mod.parseRow(row); + const snap = try self.session.snapshot(); + if (diagnostic.stale(self.document_version)) return error.StaleDiagnostic; + if (diagnostic.end > snap.bytes.len) return error.InvalidDiagnosticRange; + return .{ .start = diagnostic.start, .end = diagnostic.end }; + } + + fn currentDiagnosticRange(self: *Client) !ObjectRange { + const count = self.filteredDiagnosticCount(); + if (count == 0) return error.NoDiagnostics; + if (self.diagnostic_index >= count) self.diagnostic_index = 0; + const row = self.filteredDiagnosticRow(self.diagnostic_index) orelse return error.NoDiagnostics; + return self.diagnosticRangeFromRow(row); + } + + fn selectCurrentDiagnosticRange(self: *Client) !void { + const range = self.currentDiagnosticRange() catch |err| { + self.message = @errorName(err); + return; + }; + try self.session.selectRange(range.start, range.end); + self.message = "selected diagnostic"; + } + + fn noteDocumentChanged(self: *Client) void { + self.document_version += 1; + self.diagnostic_index = 0; + self.diagnostic_selected = false; + } + fn openSearchPrompt(self: *Client) void { self.search_prompt_active = true; self.search_prompt.clearRetainingCapacity(); @@ -1257,6 +1423,7 @@ pub const Client = struct { const text = eventText(event) orelse return self.unknownPrefixOrInput("normal"); if (std.mem.eql(u8, text, "a")) return self.moveParameter(.next); if (std.mem.eql(u8, text, "A")) return self.moveParameter(.previous); + if (std.mem.eql(u8, text, "e")) return self.gotoDiagnostic(.next); self.applyKnownRailOrMessage(event, "go rail ready"); } @@ -1414,6 +1581,7 @@ pub const Client = struct { self.dropLastUndoSnapshot(); return err; }; + self.noteDocumentChanged(); } fn changeRange(self: *Client, range: ObjectRange) !void { @@ -1424,6 +1592,7 @@ pub const Client = struct { self.dropLastUndoSnapshot(); return err; }; + self.noteDocumentChanged(); self.mode = .insert; self.message = "insert"; } @@ -1447,6 +1616,7 @@ pub const Client = struct { self.dropLastUndoSnapshot(); return err; }; + self.noteDocumentChanged(); } fn yankCurrentLine(self: *Client, include_newline: bool) !void { @@ -1497,7 +1667,7 @@ pub const Client = struct { 'i' => try self.indentRange(), 'p' => try self.parameterRange(), 'f' => try self.enclosingFormRange(), - 'd' => try self.lineRange(false), + 'd' => self.currentDiagnosticRange() catch try self.lineRange(false), else => error.UnsupportedObject, }; } @@ -1599,6 +1769,7 @@ pub const Client = struct { self.dropLastUndoSnapshot(); return err; }; + self.noteDocumentChanged(); } fn applyMutatingProtocol(self: *Client, line: []const u8) !void { @@ -1608,6 +1779,7 @@ pub const Client = struct { self.dropLastUndoSnapshot(); return err; }; + self.noteDocumentChanged(); } fn repeatProtocol(self: *Client, line: []const u8, repeat: usize) !void { @@ -1675,6 +1847,10 @@ pub const Client = struct { .hover => try self.showCompactHover(), .signature => try self.showCompactSignature(), .expand_hover => try self.openExpandedHover(), + .diagnostics_open => try self.openDiagnosticsPanel(), + .diagnostics_next => try self.gotoDiagnostic(.next), + .diagnostics_previous => try self.gotoDiagnostic(.previous), + .diagnostics_filter => try self.filterDiagnosticsFromPanel(), .repeat_rail => self.openPrefix(.repeat), .restore_last => self.restoreLastRail(), .not_built => {}, @@ -3720,3 +3896,109 @@ test "regular: go rail parameter movement uses keymap paths" { snap = try client.session.snapshot(); try std.testing.expectEqual(@as(usize, 5), snap.cursor_byte); } + +test "regular: diagnostics panel preserves providers filters and navigates" { + var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 }); + defer client.deinit(); + try client.handleTraceLine("open abcdef\nsecond"); + try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|1|3|error|bad_token"); + try client.handleTraceLine("diagnostic_fixture lint|1|main.zig|1|3|warning|style_duplicate"); + try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|8|13|info|second_line"); + + try client.handleInput(" "); + try client.handleInput("d"); + const panel_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(panel_frame); + try std.testing.expect(std.mem.indexOf(u8, panel_frame, "diag:fresh:zls:error:1-3:main.zig:bad_token") != null); + try std.testing.expect(std.mem.indexOf(u8, panel_frame, "diag:fresh:lint:warning:1-3:main.zig:style_duplicate") != null); + + try client.handleInput("f"); + const filtered_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(filtered_frame); + try std.testing.expect(std.mem.indexOf(u8, filtered_frame, "diag:fresh:zls:error:1-3") != null); + try std.testing.expect(std.mem.indexOf(u8, filtered_frame, "lint:warning") == null); + + try client.handleInput(" "); + try client.handleInput("d"); + try client.handleInput("n"); + var snap = try client.session.snapshot(); + try std.testing.expectEqual(@as(usize, 1), snap.cursor_byte); + + try client.handleInput(" "); + try client.handleInput("d"); + try client.handleInput("n"); + snap = try client.session.snapshot(); + try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte); +} + +test "regular: go rail jumps to first diagnostic" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); + defer client.deinit(); + try client.handleTraceLine("open abcdef"); + try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|2|5|error|range"); + + try client.handleInput("g"); + try client.handleInput("e"); + const snap = try client.session.snapshot(); + try std.testing.expectEqual(@as(usize, 2), snap.cursor_byte); +} + +test "regular: select diagnostic object range with s d" { + var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 6 }); + defer client.deinit(); + try client.handleTraceLine("open abcdef"); + try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|2|5|error|range"); + + try client.handleInput("s"); + try client.handleInput("d"); + const snap = try client.session.snapshot(); + try std.testing.expect(snap.selection != null); + try std.testing.expectEqual(@as(usize, 2), snap.selection.?.anchor); + try std.testing.expectEqual(@as(usize, 5), snap.selection.?.cursor); +} + +test "adversarial: diagnostics stale empty duplicate and invalid ranges are safe" { + var empty_client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 }); + defer empty_client.deinit(); + try empty_client.handleTraceLine("open abcdef"); + try empty_client.handleInput(" "); + try empty_client.handleInput("d"); + const empty_frame = try empty_client.render(std.testing.allocator); + defer std.testing.allocator.free(empty_frame); + try std.testing.expect(std.mem.indexOf(u8, empty_frame, "diag:empty") != null); + + var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 8 }); + defer client.deinit(); + try client.handleTraceLine("open abcdef"); + try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|1|3|error|first"); + try client.handleTraceLine("diagnostic_fixture lint|1|main.zig|1|3|warning|same_range_other_provider"); + try client.handleTraceLine("diagnostic_fixture zls|1|main.zig|20|25|hint|invalid_range"); + try client.handleInput("i"); + try client.handleInput("X"); + try client.handleTraceLine("key escape"); + + try client.handleInput(" "); + try client.handleInput("d"); + const stale_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(stale_frame); + try std.testing.expect(std.mem.indexOf(u8, stale_frame, "diag:stale:zls:error:1-3") != null); + try std.testing.expect(std.mem.indexOf(u8, stale_frame, "diag:stale:lint:warning:1-3") != null); + + try client.handleTraceLine("diagnostic_filter all"); + try client.handleInput("n"); + const invalid_nav_frame = try client.render(std.testing.allocator); + defer std.testing.allocator.free(invalid_nav_frame); + try std.testing.expect(std.mem.indexOf(u8, invalid_nav_frame, "StaleDiagnostic") != null or std.mem.indexOf(u8, invalid_nav_frame, "InvalidDiagnosticRange") != null); +} + +test "regular: diagnostic row model sanitizes and parses" { + const row = try diagnostics_mod.rowAlloc(std.testing.allocator, "zls", 3, "main.zig", 1, 4, .err, "bad: token"); + defer std.testing.allocator.free(row); + try std.testing.expect(std.mem.indexOf(u8, row, "invalid") != null); + const parsed = try diagnostics_mod.parseRow(row); + try std.testing.expectEqual(@as(u64, 3), parsed.version); + try std.testing.expectEqual(diagnostics_mod.Severity.err, parsed.severity); + const panel = try diagnostics_mod.panelRowAlloc(std.testing.allocator, row, 4); + defer std.testing.allocator.free(panel); + try std.testing.expect(std.mem.indexOf(u8, panel, "diag:stale:zls:error:1-4") != null); +}