Add provider-aware diagnostics rail
This commit is contained in:
+283
-1
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user