Add LSP hover and signature panels
This commit is contained in:
+238
@@ -17,6 +17,7 @@ pub const Error = error{
|
||||
InvalidNavigationRow,
|
||||
InvalidEdit,
|
||||
InvalidEditRow,
|
||||
InvalidHelp,
|
||||
};
|
||||
|
||||
pub const Document = struct {
|
||||
@@ -31,6 +32,8 @@ pub const DiagnosticLocation = struct {
|
||||
character: usize,
|
||||
};
|
||||
|
||||
pub const ParameterDirection = enum { next, previous };
|
||||
|
||||
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();
|
||||
@@ -220,6 +223,66 @@ pub fn applyEditRowAlloc(allocator: std.mem.Allocator, content: []const u8, row:
|
||||
return .{ .bytes = try out.toOwnedSlice(allocator), .cursor = start + parsed.new_text.len };
|
||||
}
|
||||
|
||||
pub fn hoverRowsAlloc(allocator: std.mem.Allocator, payload: []const u8, width: usize) ![][]const u8 {
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidHelp;
|
||||
defer parsed.deinit();
|
||||
const root = parsed.value;
|
||||
if (root != .object) return Error.InvalidHelp;
|
||||
const result = objectGet(root, "result") orelse return Error.InvalidHelp;
|
||||
if (result == .null) return singleHelpRow(allocator, "lsp:hover:none");
|
||||
const text = hoverText(result) orelse return Error.InvalidHelp;
|
||||
return wrappedRowsAlloc(allocator, "lsp:hover:", text, width);
|
||||
}
|
||||
|
||||
pub fn signatureRowsAlloc(allocator: std.mem.Allocator, payload: []const u8, width: usize) ![][]const u8 {
|
||||
var parsed = std.json.parseFromSlice(std.json.Value, allocator, payload, .{}) catch return Error.InvalidHelp;
|
||||
defer parsed.deinit();
|
||||
const root = parsed.value;
|
||||
if (root != .object) return Error.InvalidHelp;
|
||||
const result = objectGet(root, "result") orelse return Error.InvalidHelp;
|
||||
if (result == .null) return singleHelpRow(allocator, "lsp:signature:none");
|
||||
const signatures = objectGet(result, "signatures") orelse return Error.InvalidHelp;
|
||||
if (signatures != .array or signatures.array.items.len == 0) return singleHelpRow(allocator, "lsp:signature:none");
|
||||
const active_signature = unsignedGet(result, "activeSignature") orelse 0;
|
||||
const active_parameter = unsignedGet(result, "activeParameter") orelse 0;
|
||||
const signature = signatures.array.items[@min(active_signature, signatures.array.items.len - 1)];
|
||||
const label = stringGetValue(signature, "label") orelse return Error.InvalidHelp;
|
||||
|
||||
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:signature:active_{d}", .{active_parameter + 1}));
|
||||
try appendWrappedRows(allocator, &rows, "lsp:signature:label:", label, width);
|
||||
if (objectGet(signature, "parameters")) |parameters| {
|
||||
if (parameters != .array) return Error.InvalidHelp;
|
||||
for (parameters.array.items, 0..) |parameter, index| {
|
||||
const parameter_label = parameterLabel(parameter) orelse continue;
|
||||
const state = if (index == active_parameter) "active" else "inactive";
|
||||
const prefix = try std.fmt.allocPrint(allocator, "lsp:signature:param_{d}:{s}:", .{ index + 1, state });
|
||||
defer allocator.free(prefix);
|
||||
try appendWrappedRows(allocator, &rows, prefix, parameter_label, width);
|
||||
}
|
||||
}
|
||||
return rows.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
pub fn parameterMoveRowsAlloc(allocator: std.mem.Allocator, content: []const u8, cursor: usize, direction: ParameterDirection) !struct { rows: [][]const u8, cursor: usize } {
|
||||
const call = findCall(content, cursor) orelse return .{ .rows = try singleHelpRow(allocator, "lsp:param:outside_call"), .cursor = cursor };
|
||||
if (call.comma_count == 0) return .{ .rows = try singleHelpRow(allocator, "lsp:param:single_parameter"), .cursor = cursor };
|
||||
const current_index = parameterIndex(call, cursor);
|
||||
const target_index = switch (direction) {
|
||||
.next => @min(current_index + 1, call.comma_count),
|
||||
.previous => if (current_index == 0) 0 else current_index - 1,
|
||||
};
|
||||
const target_cursor = parameterStart(call, target_index);
|
||||
const rows = try allocator.alloc([]const u8, 1);
|
||||
errdefer allocator.free(rows);
|
||||
rows[0] = try std.fmt.allocPrint(allocator, "lsp:param:active_{d}:{d}", .{ target_index + 1, target_cursor });
|
||||
return .{ .rows = rows, .cursor = target_cursor };
|
||||
}
|
||||
|
||||
pub fn runDocumentSyncRowsAlloc(
|
||||
allocator: std.mem.Allocator,
|
||||
io: std.Io,
|
||||
@@ -310,6 +373,135 @@ pub fn transcriptAlloc(allocator: std.mem.Allocator, document: Document) ![]u8 {
|
||||
return out.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn hoverText(value: std.json.Value) ?[]const u8 {
|
||||
if (value == .string) return value.string;
|
||||
const contents = objectGet(value, "contents") orelse value;
|
||||
if (contents == .string) return contents.string;
|
||||
if (contents == .object) return stringGetValue(contents, "value") orelse stringGetValue(contents, "language");
|
||||
if (contents == .array and contents.array.items.len > 0) return hoverText(contents.array.items[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
fn parameterLabel(value: std.json.Value) ?[]const u8 {
|
||||
const label = objectGet(value, "label") orelse return null;
|
||||
return switch (label) {
|
||||
.string => |text| text,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn singleHelpRow(allocator: std.mem.Allocator, row: []const u8) ![][]const u8 {
|
||||
const rows = try allocator.alloc([]const u8, 1);
|
||||
errdefer allocator.free(rows);
|
||||
rows[0] = try allocator.dupe(u8, row);
|
||||
return rows;
|
||||
}
|
||||
|
||||
fn wrappedRowsAlloc(allocator: std.mem.Allocator, prefix: []const u8, text: []const u8, width: usize) ![][]const u8 {
|
||||
var rows = std.ArrayList([]const u8).empty;
|
||||
errdefer {
|
||||
for (rows.items) |row| allocator.free(row);
|
||||
rows.deinit(allocator);
|
||||
}
|
||||
try appendWrappedRows(allocator, &rows, prefix, text, width);
|
||||
if (rows.items.len == 0) try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}_", .{prefix}));
|
||||
return rows.toOwnedSlice(allocator);
|
||||
}
|
||||
|
||||
fn appendWrappedRows(allocator: std.mem.Allocator, rows: *std.ArrayList([]const u8), prefix: []const u8, text: []const u8, width: usize) !void {
|
||||
const preview = try previewAlloc(allocator, text);
|
||||
defer allocator.free(preview);
|
||||
const budget = if (width > prefix.len + 4) width - prefix.len else 8;
|
||||
var start: usize = 0;
|
||||
while (start < preview.len) {
|
||||
const end = @min(preview.len, start + budget);
|
||||
try rows.append(allocator, try std.fmt.allocPrint(allocator, "{s}{s}", .{ prefix, preview[start..end] }));
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
|
||||
const CallInfo = struct {
|
||||
content: []const u8,
|
||||
open: usize,
|
||||
close: usize,
|
||||
commas: [16]usize,
|
||||
comma_count: usize,
|
||||
};
|
||||
|
||||
fn findCall(content: []const u8, cursor: usize) ?CallInfo {
|
||||
const limit = @min(cursor, content.len);
|
||||
var open: ?usize = null;
|
||||
var depth: usize = 0;
|
||||
var i = limit;
|
||||
while (i > 0) {
|
||||
i -= 1;
|
||||
switch (content[i]) {
|
||||
')' => depth += 1,
|
||||
'(' => {
|
||||
if (depth == 0) {
|
||||
open = i;
|
||||
break;
|
||||
}
|
||||
depth -= 1;
|
||||
},
|
||||
'\n' => break,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
const open_index = open orelse return null;
|
||||
var close_index = content.len;
|
||||
var forward = open_index + 1;
|
||||
var forward_depth: usize = 0;
|
||||
while (forward < content.len) : (forward += 1) {
|
||||
switch (content[forward]) {
|
||||
'(' => forward_depth += 1,
|
||||
')' => {
|
||||
if (forward_depth == 0) {
|
||||
close_index = forward;
|
||||
break;
|
||||
}
|
||||
forward_depth -= 1;
|
||||
},
|
||||
'\n' => break,
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
if (limit > close_index) return null;
|
||||
var comma_buf: [16]usize = undefined;
|
||||
var comma_count: usize = 0;
|
||||
var j = open_index + 1;
|
||||
var comma_depth: usize = 0;
|
||||
while (j < close_index and comma_count < comma_buf.len) : (j += 1) {
|
||||
switch (content[j]) {
|
||||
'(' => comma_depth += 1,
|
||||
')' => {
|
||||
if (comma_depth > 0) comma_depth -= 1;
|
||||
},
|
||||
',' => if (comma_depth == 0) {
|
||||
comma_buf[comma_count] = j;
|
||||
comma_count += 1;
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
return .{ .content = content, .open = open_index, .close = close_index, .commas = comma_buf, .comma_count = comma_count };
|
||||
}
|
||||
|
||||
fn parameterIndex(call: CallInfo, cursor: usize) usize {
|
||||
for (call.commas[0..call.comma_count], 0..) |comma, index| if (cursor <= comma) return index;
|
||||
return call.comma_count;
|
||||
}
|
||||
|
||||
fn parameterStart(call: CallInfo, index: usize) usize {
|
||||
var start = if (index == 0) call.open + 1 else call.commas[index - 1] + 1;
|
||||
while (start < call.close and isSpaceByte(call.content[start])) : (start += 1) {}
|
||||
return start;
|
||||
}
|
||||
|
||||
fn isSpaceByte(byte: u8) bool {
|
||||
return byte == ' ' or byte == '\t';
|
||||
}
|
||||
|
||||
fn validateEditKind(kind: []const u8) !void {
|
||||
if (std.mem.eql(u8, kind, "rename") or std.mem.eql(u8, kind, "edit")) return;
|
||||
return Error.InvalidEdit;
|
||||
@@ -742,3 +934,49 @@ test "adversarial: invalid edits and non-edit rows are rejected" {
|
||||
try std.testing.expectError(Error.InvalidEditRow, applyEditRowAlloc(std.testing.allocator, "safe", "lsp:action:none:title"));
|
||||
try std.testing.expectError(Error.InvalidEditRow, applyEditRowAlloc(std.testing.allocator, "safe", "lsp:edit:rename:1:1:9:1:78:file:title"));
|
||||
}
|
||||
|
||||
test "regular: hover rows are viewport bounded and sanitized" {
|
||||
const payload =
|
||||
\\{"jsonrpc":"2.0","id":8,"result":{"contents":{"kind":"markdown","value":"pub fn add(lhs: i32, rhs: i32) i32"}}}
|
||||
;
|
||||
const rows = try hoverRowsAlloc(std.testing.allocator, payload, 32);
|
||||
defer freeRows(std.testing.allocator, rows);
|
||||
try std.testing.expect(rows.len > 1);
|
||||
for (rows) |row| try std.testing.expect(row.len <= 32);
|
||||
try std.testing.expect(std.mem.startsWith(u8, rows[0], "lsp:hover:"));
|
||||
}
|
||||
|
||||
test "regular: signature rows mark active parameter" {
|
||||
const payload =
|
||||
\\{"jsonrpc":"2.0","id":9,"result":{"activeSignature":0,"activeParameter":1,"signatures":[{"label":"add(lhs: i32, rhs: i32)","parameters":[{"label":"lhs: i32"},{"label":"rhs: i32"}]}]}}
|
||||
;
|
||||
const rows = try signatureRowsAlloc(std.testing.allocator, payload, 48);
|
||||
defer freeRows(std.testing.allocator, rows);
|
||||
try std.testing.expectEqualStrings("lsp:signature:active_2", rows[0]);
|
||||
try std.testing.expect(std.mem.indexOf(u8, rows[1], "add(lhs") != null);
|
||||
try std.testing.expectEqualStrings("lsp:signature:param_2:active:rhs__i32", rows[3]);
|
||||
}
|
||||
|
||||
test "regular: parameter movement finds siblings in a call" {
|
||||
{
|
||||
const moved = try parameterMoveRowsAlloc(std.testing.allocator, "add(alpha, beta, gamma)", 5, .next);
|
||||
defer freeRows(std.testing.allocator, moved.rows);
|
||||
try std.testing.expectEqual(@as(usize, 11), moved.cursor);
|
||||
try std.testing.expectEqualStrings("lsp:param:active_2:11", moved.rows[0]);
|
||||
}
|
||||
|
||||
{
|
||||
const moved = try parameterMoveRowsAlloc(std.testing.allocator, "add(alpha, beta, gamma)", 11, .previous);
|
||||
defer freeRows(std.testing.allocator, moved.rows);
|
||||
try std.testing.expectEqual(@as(usize, 4), moved.cursor);
|
||||
}
|
||||
}
|
||||
|
||||
test "adversarial: help payloads and non-call parameter moves fail safely" {
|
||||
try std.testing.expectError(Error.InvalidHelp, hoverRowsAlloc(std.testing.allocator, "{}", 40));
|
||||
try std.testing.expectError(Error.InvalidHelp, signatureRowsAlloc(std.testing.allocator, "not-json", 40));
|
||||
const moved = try parameterMoveRowsAlloc(std.testing.allocator, "no call here", 3, .next);
|
||||
defer freeRows(std.testing.allocator, moved.rows);
|
||||
try std.testing.expectEqualStrings("lsp:param:outside_call", moved.rows[0]);
|
||||
try std.testing.expectEqual(@as(usize, 3), moved.cursor);
|
||||
}
|
||||
|
||||
+96
@@ -160,6 +160,10 @@ pub const Client = struct {
|
||||
if (std.mem.startsWith(u8, line, "lsp_rename ")) return self.openLspWorkspaceEdit("rename", line[11..]);
|
||||
if (std.mem.startsWith(u8, line, "lsp_code_actions ")) return self.openLspCodeActions(line[17..]);
|
||||
if (std.mem.eql(u8, line, "lsp_edit_apply_selected")) return self.applySelectedLspEdit();
|
||||
if (std.mem.startsWith(u8, line, "lsp_hover ")) return self.openLspHover(line[10..]);
|
||||
if (std.mem.startsWith(u8, line, "lsp_signature ")) return self.openLspSignature(line[14..]);
|
||||
if (std.mem.eql(u8, line, "lsp_param_next")) return self.moveLspParameter(.next);
|
||||
if (std.mem.eql(u8, line, "lsp_param_previous")) return self.moveLspParameter(.previous);
|
||||
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);
|
||||
@@ -508,6 +512,31 @@ pub const Client = struct {
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn openLspHover(self: *Client, payload: []const u8) !void {
|
||||
const rows = lsp_mod.hoverRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected;
|
||||
defer freeOwnedRows(self.allocator, rows);
|
||||
try self.session.openListPanel("lsp-hover", rows);
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn openLspSignature(self: *Client, payload: []const u8) !void {
|
||||
const rows = lsp_mod.signatureRowsAlloc(self.allocator, payload, self.viewport.width) catch return Error.ProtocolRejected;
|
||||
defer freeOwnedRows(self.allocator, rows);
|
||||
try self.session.openListPanel("lsp-signature", rows);
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn moveLspParameter(self: *Client, direction: lsp_mod.ParameterDirection) !void {
|
||||
const snap = try self.session.snapshot();
|
||||
const moved = lsp_mod.parameterMoveRowsAlloc(self.allocator, snap.bytes, snap.cursor_byte, direction) catch return Error.ProtocolRejected;
|
||||
defer freeOwnedRows(self.allocator, moved.rows);
|
||||
const bytes = try self.allocator.dupe(u8, snap.bytes);
|
||||
defer self.allocator.free(bytes);
|
||||
try self.session.openFixtureAt(bytes, moved.cursor);
|
||||
try self.session.openListPanel("lsp-parameter", moved.rows);
|
||||
self.message = null;
|
||||
}
|
||||
|
||||
fn jumpCurrentBufferToLspLocation(self: *Client, location: lsp_mod.DiagnosticLocation) !void {
|
||||
const snap = try self.session.snapshot();
|
||||
const bytes = try self.allocator.dupe(u8, snap.bytes);
|
||||
@@ -1858,3 +1887,70 @@ test "adversarial: lsp edit failures do not corrupt current buffer" {
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings("safe", snap.bytes);
|
||||
}
|
||||
|
||||
test "regular: lsp hover wraps inside narrow viewport" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 32, .height = 7 });
|
||||
defer client.deinit();
|
||||
|
||||
const payload =
|
||||
\\{"jsonrpc":"2.0","id":8,"result":{"contents":{"kind":"markdown","value":"pub fn add(lhs: i32, rhs: i32) i32"}}}
|
||||
;
|
||||
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_hover {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-hover") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:hover:") != null);
|
||||
try assertLinesFit(frame, 32);
|
||||
}
|
||||
|
||||
test "regular: lsp signature shows active parameter in shared panel" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 7 });
|
||||
defer client.deinit();
|
||||
|
||||
const payload =
|
||||
\\{"jsonrpc":"2.0","id":9,"result":{"activeSignature":0,"activeParameter":1,"signatures":[{"label":"add(lhs: i32, rhs: i32)","parameters":[{"label":"lhs: i32"},{"label":"rhs: i32"}]}]}}
|
||||
;
|
||||
const command = try std.fmt.allocPrint(std.testing.allocator, "lsp_signature {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-signature") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:active_2") != null);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:signature:param_2:active") != null);
|
||||
}
|
||||
|
||||
test "regular: lsp parameter movement updates cursor and reports active parameter" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 72, .height = 6 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.session.openFixtureAt("add(alpha, beta, gamma)", 5);
|
||||
try client.handleTraceLine("lsp_param_next");
|
||||
var snap = try client.session.snapshot();
|
||||
try std.testing.expectEqual(@as(usize, 11), snap.cursor_byte);
|
||||
{
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:active_2:11") != null);
|
||||
}
|
||||
try client.handleTraceLine("lsp_param_previous");
|
||||
snap = try client.session.snapshot();
|
||||
try std.testing.expectEqual(@as(usize, 4), snap.cursor_byte);
|
||||
}
|
||||
|
||||
test "adversarial: malformed hover signature and non-call parameter movement are safe" {
|
||||
var client = try Client.init(std.testing.allocator, .{ .width = 64, .height = 5 });
|
||||
defer client.deinit();
|
||||
|
||||
try client.handleTraceLine("open safe");
|
||||
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_hover not-json"));
|
||||
try std.testing.expectError(Error.ProtocolRejected, client.handleTraceLine("lsp_signature not-json"));
|
||||
try client.handleTraceLine("lsp_param_next");
|
||||
const snap = try client.session.snapshot();
|
||||
try std.testing.expectEqualStrings("safe", snap.bytes);
|
||||
const frame = try client.render(std.testing.allocator);
|
||||
defer std.testing.allocator.free(frame);
|
||||
try std.testing.expect(std.mem.indexOf(u8, frame, "lsp:param:outside_call") != null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user