From 1b64db8a4cc077054890f8b7f70c969ca80c2171 Mon Sep 17 00:00:00 2001 From: Techatrix <19954306+Techatrix@users.noreply.github.com> Date: Sun, 25 Sep 2022 01:04:29 +0200 Subject: [PATCH] implement textDocument/codeAction --- src/Server.zig | 37 +++- src/code_actions.zig | 485 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 520 insertions(+), 2 deletions(-) create mode 100644 src/code_actions.zig diff --git a/src/Server.zig b/src/Server.zig index 05fe650..ad5f0e2 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -12,6 +12,7 @@ const references = @import("references.zig"); const offsets = @import("offsets.zig"); const semantic_tokens = @import("semantic_tokens.zig"); const inlay_hints = @import("inlay_hints.zig"); +const code_actions = @import("code_actions.zig"); const shared = @import("shared.zig"); const Ast = std.zig.Ast; const tracy = @import("tracy.zig"); @@ -1569,7 +1570,7 @@ fn initializeHandler(server: *Server, writer: anytype, id: types.RequestId, req: .completionProvider = .{ .resolveProvider = false, .triggerCharacters = &[_][]const u8{ ".", ":", "@", "]" }, .completionItem = .{ .labelDetailsSupport = true } }, .documentHighlightProvider = true, .hoverProvider = true, - .codeActionProvider = false, + .codeActionProvider = true, .declarationProvider = true, .definitionProvider = true, .typeDefinitionProvider = true, @@ -2257,6 +2258,38 @@ fn inlayHintHandler(server: *Server, writer: anytype, id: types.RequestId, req: return try respondGeneric(writer, id, null_result_response); } +fn codeActionHandler(server: *Server, writer: anytype, id: types.RequestId, req: requests.CodeAction) !void { + const handle = server.document_store.getHandle(req.params.textDocument.uri) orelse { + log.warn("Trying to get code actions of non existent document {s}", .{req.params.textDocument.uri}); + return try respondGeneric(writer, id, null_result_response); + }; + + const allocator = server.arena.allocator(); + + var builder = code_actions.Builder{ + .arena = &server.arena, + .document_store = &server.document_store, + .handle = handle, + .offset_encoding = server.offset_encoding, + }; + + var actions = std.ArrayListUnmanaged(types.CodeAction){}; + + for (req.params.context.diagnostics) |diagnostic| { + try builder.generateCodeAction(diagnostic, &actions); + } + + for (actions.items) |*action| { + // TODO query whether SourceFixAll is supported by the server + if (action.kind == .SourceFixAll) action.kind = .QuickFix; + } + + return try send(writer, allocator, types.Response{ + .id = id, + .result = .{ .CodeAction = actions.items }, + }); +} + // Needed for the hack seen below. fn extractErr(val: anytype) anyerror { val catch |e| return e; @@ -2368,6 +2401,7 @@ pub fn processJsonRpc(server: *Server, writer: anytype, json: []const u8) !void .{ "textDocument/rename", requests.Rename, renameHandler }, .{ "textDocument/references", requests.References, referencesHandler }, .{ "textDocument/documentHighlight", requests.DocumentHighlight, documentHighlightHandler }, + .{ "textDocument/codeAction", requests.CodeAction, codeActionHandler }, .{ "workspace/didChangeConfiguration", std.json.Value, didChangeConfigurationHandler }, }; @@ -2408,7 +2442,6 @@ pub fn processJsonRpc(server: *Server, writer: anytype, json: []const u8) !void // needs a response) or false if the method is a notification (in which // case it should be silently ignored) const unimplemented_map = std.ComptimeStringMap(bool, .{ - .{ "textDocument/codeAction", true }, .{ "textDocument/codeLens", true }, .{ "textDocument/documentLink", true }, .{ "textDocument/rangeFormatting", true }, diff --git a/src/code_actions.zig b/src/code_actions.zig new file mode 100644 index 0000000..bfa1c41 --- /dev/null +++ b/src/code_actions.zig @@ -0,0 +1,485 @@ +const std = @import("std"); +const Ast = std.zig.Ast; + +const DocumentStore = @import("DocumentStore.zig"); +const analysis = @import("analysis.zig"); +const ast = @import("ast.zig"); + +const types = @import("types.zig"); +const requests = @import("requests.zig"); +const offsets = @import("offsets.zig"); + +pub const Builder = struct { + arena: *std.heap.ArenaAllocator, + document_store: *DocumentStore, + handle: *DocumentStore.Handle, + offset_encoding: offsets.Encoding, + + pub fn generateCodeAction( + builder: *Builder, + diagnostic: types.Diagnostic, + actions: *std.ArrayListUnmanaged(types.CodeAction), + ) error{OutOfMemory}!void { + const kind = DiagnosticKind.parse(diagnostic.message) orelse return; + + const loc = offsets.rangeToLoc(builder.text(), diagnostic.range, builder.offset_encoding); + + switch (kind) { + .unused => |id| switch (id) { + .@"function parameter" => try handleUnusedFunctionParameter(builder, actions, loc), + .@"local constant" => try handleUnusedVariableOrConstant(builder, actions, loc), + .@"local variable" => try handleUnusedVariableOrConstant(builder, actions, loc), + .@"loop index capture" => try handleUnusedIndexCapture(builder, actions, loc), + .@"capture" => try handleUnusedCapture(builder, actions, loc), + }, + .pointless_discard => try handlePointlessDiscard(builder, actions, loc), + .omit_discard => |id| switch (id) { + .@"index capture" => try handleUnusedIndexCapture(builder, actions, loc), + .@"error capture" => try handleUnusedCapture(builder, actions, loc), + }, + .unreachable_code => { + // TODO + // autofix: comment out code + // fix: remove code + }, + } + } + + pub fn createTextEditLoc(self: *Builder, loc: offsets.Loc, new_text: []const u8) types.TextEdit { + const range = offsets.locToRange(self.text(), loc, self.offset_encoding); + return types.TextEdit{ .range = range, .newText = new_text }; + } + + pub fn createTextEditPos(self: *Builder, index: usize, new_text: []const u8) types.TextEdit { + const position = offsets.indexToPosition(self.text(), index, self.offset_encoding); + return types.TextEdit{ .range = .{ .start = position, .end = position }, .newText = new_text }; + } + + pub fn createWorkspaceEdit(self: *Builder, edits: []const types.TextEdit) error{OutOfMemory}!types.WorkspaceEdit { + var text_edits = std.ArrayListUnmanaged(types.TextEdit){}; + try text_edits.appendSlice(self.arena.allocator(), edits); + + var workspace_edit = types.WorkspaceEdit{ .changes = .{} }; + try workspace_edit.changes.putNoClobber(self.arena.allocator(), self.handle.uri(), text_edits); + + return workspace_edit; + } + + fn text(self: *Builder) []const u8 { + return self.handle.document.text; + } +}; + +fn handleUnusedFunctionParameter(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void { + const identifier_name = offsets.locToSlice(builder.text(), loc); + + const tree = builder.handle.tree; + const node_tags = tree.nodes.items(.tag); + const node_datas = tree.nodes.items(.data); + const node_tokens = tree.nodes.items(.main_token); + + const token_starts = tree.tokens.items(.start); + + const decl = (try analysis.lookupSymbolGlobal( + builder.document_store, + builder.arena, + builder.handle, + identifier_name, + loc.start, + )) orelse return; + + const payload = switch (decl.decl.*) { + .param_payload => |pay| pay, + else => return, + }; + + std.debug.assert(node_tags[payload.func] == .fn_decl); + + const block = node_datas[payload.func].rhs; + + const indent = offsets.lineSliceUntilIndex(builder.text(), token_starts[node_tokens[payload.func]]).len; + const new_text = try createDiscardText(builder.arena.allocator(), identifier_name, indent + 4); + + const index = token_starts[node_tokens[block]] + 1; + + const action1 = types.CodeAction{ + .title = "discard function parameter", + .kind = .SourceFixAll, + .isPreferred = true, + .edit = try builder.createWorkspaceEdit(&.{builder.createTextEditPos(index, new_text)}), + }; + + const param_loc = .{ + .start = offsets.tokenToIndex(tree, ast.paramFirstToken(tree, payload.param)), + .end = offsets.tokenToLoc(tree, ast.paramLastToken(tree, payload.param)).end, + }; + + // TODO fix formatting + // TODO remove trailing comma on last parameter + const action2 = types.CodeAction{ + .title = "remove function parameter", + .kind = .QuickFix, + .isPreferred = false, + .edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(param_loc, "")}), + }; + + try actions.appendSlice(builder.arena.allocator(), &.{ action1, action2 }); +} + +fn handleUnusedVariableOrConstant(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void { + const identifier_name = offsets.locToSlice(builder.text(), loc); + + const tree = builder.handle.tree; + const token_tags = tree.tokens.items(.tag); + const token_starts = tree.tokens.items(.start); + + const decl = (try analysis.lookupSymbolGlobal( + builder.document_store, + builder.arena, + builder.handle, + identifier_name, + loc.start, + )) orelse return; + + const node = switch (decl.decl.*) { + .ast_node => |node| node, + else => return, + }; + + const first_token = tree.firstToken(node); + const last_token = ast.lastToken(tree, node) + 1; + + const indent = offsets.lineSliceUntilIndex(builder.text(), token_starts[first_token]).len; + + if (token_tags[last_token] != .semicolon) return; + + const new_text = try createDiscardText(builder.arena.allocator(), identifier_name, indent); + + const index = token_starts[last_token] + 1; + + try actions.append(builder.arena.allocator(), .{ + .title = "discard value", + .kind = .SourceFixAll, + .isPreferred = true, + .edit = try builder.createWorkspaceEdit(&.{builder.createTextEditPos(index, new_text)}), + }); +} + +fn handleUnusedIndexCapture(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void { + const capture_locs = getCaptureLoc(builder.text(), loc, true) orelse return; + + // TODO support discarding without modifying the capture + // by adding a discard in the block scope + const is_value_discarded = std.mem.eql(u8, offsets.locToSlice(builder.text(), capture_locs.value), "_"); + if (is_value_discarded) { + // |_, i| -> + // TODO fix formatting + try actions.append(builder.arena.allocator(), .{ + .title = "remove capture", + .kind = .QuickFix, + .isPreferred = true, + .edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(capture_locs.loc, "")}), + }); + } else { + // |v, i| -> |v| + // |v, _| -> |v| + try actions.append(builder.arena.allocator(), .{ + .title = "remove index capture", + .kind = .QuickFix, + .isPreferred = true, + .edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc( + .{ .start = capture_locs.value.end, .end = capture_locs.loc.end - 1 }, + "", + )}), + }); + } +} + +fn handleUnusedCapture(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void { + const capture_locs = getCaptureLoc(builder.text(), loc, false) orelse return; + + // TODO support discarding without modifying the capture + // by adding a discard in the block scope + if (capture_locs.index != null) { + // |v, i| -> |_, i| + try actions.append(builder.arena.allocator(), .{ + .title = "discard capture", + .kind = .QuickFix, + .isPreferred = true, + .edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(capture_locs.value, "_")}), + }); + } else { + // |v| -> + // TODO fix formatting + try actions.append(builder.arena.allocator(), .{ + .title = "remove capture", + .kind = .QuickFix, + .isPreferred = true, + .edit = try builder.createWorkspaceEdit(&.{builder.createTextEditLoc(capture_locs.loc, "")}), + }); + } +} + +fn handlePointlessDiscard(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void { + const edit_loc = getDiscardLoc(builder.text(), loc) orelse return; + + try actions.append(builder.arena.allocator(), .{ + .title = "remove pointless discard", + .kind = .SourceFixAll, + .isPreferred = true, + .edit = try builder.createWorkspaceEdit(&.{ + builder.createTextEditLoc(edit_loc, ""), + }), + }); +} + +// returns a discard string `\n{indent}_ = identifier_name;` +fn createDiscardText(allocator: std.mem.Allocator, identifier_name: []const u8, indent: usize) ![]const u8 { + const new_text_len = 1 + indent + "_ = ;".len + identifier_name.len; + var new_text = try std.ArrayListUnmanaged(u8).initCapacity(allocator, new_text_len); + errdefer new_text.deinit(allocator); + + new_text.appendAssumeCapacity('\n'); + new_text.appendNTimesAssumeCapacity(' ', indent); + new_text.appendSliceAssumeCapacity("_ = "); + new_text.appendSliceAssumeCapacity(identifier_name); + new_text.appendAssumeCapacity(';'); + + return new_text.toOwnedSlice(allocator); +} + +const DiagnosticKind = union(enum) { + unused: IdCat, + pointless_discard: IdCat, + omit_discard: DiscardCat, + unreachable_code, + + const IdCat = enum { + @"function parameter", + @"local constant", + @"local variable", + @"loop index capture", + @"capture", + }; + + const DiscardCat = enum { + // "discard of index capture; omit it instead" + @"index capture", + // "discard of error capture; omit it instead" + @"error capture", + }; + + pub fn parse(diagnostic_message: []const u8) ?DiagnosticKind { + const msg = diagnostic_message; + + if (std.mem.startsWith(u8, msg, "unused ")) { + return DiagnosticKind{ + .unused = parseEnum(IdCat, msg["unused ".len..]) orelse return null, + }; + } else if (std.mem.startsWith(u8, msg, "pointless discard of ")) { + return DiagnosticKind{ + .pointless_discard = parseEnum(IdCat, msg["pointless discard of ".len..]) orelse return null, + }; + } else if (std.mem.startsWith(u8, msg, "discard of ")) { + return DiagnosticKind{ + .omit_discard = parseEnum(DiscardCat, msg["discard of ".len..]) orelse return null, + }; + } + return null; + } + + fn parseEnum(comptime T: type, message: []const u8) ?T { + inline for (std.meta.fields(T)) |field| { + if (std.mem.startsWith(u8, message, field.name)) { + // is there a better way to achieve this? + return @intToEnum(T, field.value); + } + } + + return null; + } +}; + +/// takes the location of an identifier which is part of a discard `_ = location_here;` +/// and returns the location from '_' until ';' or null on failure +fn getDiscardLoc(text: []const u8, loc: offsets.Loc) ?offsets.Loc { + // check of the loc points to a valid identifier + for (offsets.locToSlice(text, loc)) |c| { + if (!isSymbolChar(c)) return null; + } + + // check if the identifier is followed by a colon + const colon_position = found: { + var i = loc.end; + while (i < text.len) : (i += 1) { + switch (text[i]) { + ' ' => continue, + ';' => break :found i + 1, + else => return null, + } + } + return null; + }; + + // check if the identifier is preceed by a equal sign and then an underscore + var i: usize = loc.start - 1; + var found_equal_sign = false; + const underscore_position = found: { + while (true) : (i -= 1) { + if (i == 0) return null; + switch (text[i]) { + ' ' => {}, + '=' => { + if (found_equal_sign) return null; + found_equal_sign = true; + }, + '_' => if (found_equal_sign) break :found i else return null, + else => return null, + } + } + }; + + // move backwards until we find a newline + i = underscore_position - 1; + const start_position = found: { + while (true) : (i -= 1) { + if (i == 0) break :found underscore_position; + switch (text[i]) { + ' ' => {}, + '\n' => break :found i, + else => break :found underscore_position, + } + } + }; + + return offsets.Loc{ + .start = start_position, + .end = colon_position, + }; +} + +const CaptureLocs = struct { + loc: offsets.Loc, + value: offsets.Loc, + index: ?offsets.Loc, +}; + +/// takes the location of an identifier which is part of a payload `|value, index|` +/// and returns the location from '|' until '|' or null on failure +/// use `is_index_payload` to indicate whether `loc` points to `value` or `index` +fn getCaptureLoc(text: []const u8, loc: offsets.Loc, is_index_payload: bool) ?CaptureLocs { + const value_end = if (!is_index_payload) loc.end else found: { + // move back until we find a comma + const comma_position = found_comma: { + var i = loc.start - 1; + while (i != 0) : (i -= 1) { + switch (text[i]) { + ' ' => continue, + ',' => break :found_comma i, + else => return null, + } + } else return null; + }; + + // trim space + var i = comma_position - 1; + while (i != 0) : (i -= 1) { + switch (text[i]) { + ' ' => continue, + else => { + if (!isSymbolChar(text[i])) return null; + break :found i + 1; + }, + } + } else return null; + }; + + const value_start = if (!is_index_payload) loc.start else found: { + // move back until we find a non identifier character + var i = value_end - 1; + while (i != 0) : (i -= 1) { + if (isSymbolChar(text[i])) continue; + switch (text[i]) { + ' ', '|', '*' => break :found i + 1, + else => return null, + } + } else return null; + }; + + var index: ?offsets.Loc = null; + + if (is_index_payload) { + index = loc; + } else blk: { + // move forward until we find a comma + const comma_position = found_comma: { + var i = value_end; + while (i < text.len) : (i += 1) { + switch (text[i]) { + ' ' => continue, + ',' => break :found_comma i, + else => break :blk, + } + } + break :blk; + }; + + // trim space + const index_start = found_start: { + var i = comma_position + 1; + while (i < text.len) : (i += 1) { + switch (text[i]) { + ' ' => continue, + else => { + if (!isSymbolChar(text[i])) break :blk; + break :found_start i; + }, + } + } + break :blk; + }; + + // move forward until we find a non identifier character + var i = index_start + 1; + while (i < text.len) : (i += 1) { + if (isSymbolChar(text[i])) continue; + index = offsets.Loc{ + .start = index_start, + .end = i, + }; + break; + } + } + + const start_pipe_position = found: { + var i = value_start - 1; + while (i != 0) : (i -= 1) { + switch (text[i]) { + ' ' => continue, + '|' => break :found i, + else => return null, + } + } else return null; + }; + + const end_pipe_position = found: { + var i: usize = if (index) |index_loc| index_loc.end else value_end; + while (i < text.len) : (i += 1) { + switch (text[i]) { + ' ' => continue, + '|' => break :found i + 1, + else => return null, + } + } else return null; + }; + + return CaptureLocs{ + .loc = .{ .start = start_pipe_position, .end = end_pipe_position }, + .value = .{ .start = value_start, .end = value_end }, + .index = index, + }; +} + +fn isSymbolChar(char: u8) bool { + return std.ascii.isAlNum(char) or char == '_'; +}