Add provider-aware diagnostics rail

This commit is contained in:
slhx agent
2026-06-21 13:56:46 +02:00
parent 264dedb47c
commit 5224fe4c6a
3 changed files with 432 additions and 1 deletions
+93
View File
@@ -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 .{
+56
View File
@@ -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) {
+283 -1
View File
@@ -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);
}