diff --git a/src/ast.zig b/src/ast.zig index 5659c29f0..02a6805ed 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -400,6 +400,24 @@ fn findMatchingRBrace(tree: *const Ast, start: Ast.TokenIndex) ?Ast.TokenIndex { return if (std.mem.findScalarPos(std.zig.Token.Tag, tree.tokens.items(.tag), start, .r_brace)) |index| @intCast(index) else null; } +pub fn isKeyword(tag: std.zig.Token.Tag) bool { + const tags = std.zig.Token.keywords.values(); + var first_keyword: std.meta.Tag(std.zig.Token.Tag) = @intFromEnum(tags[0]); + var last_keyword: std.meta.Tag(std.zig.Token.Tag) = @intFromEnum(tags[tags.len - 1]); + for (std.zig.Token.keywords.values()) |token_tag| { + first_keyword = @min(@intFromEnum(token_tag), first_keyword); + last_keyword = @max(@intFromEnum(token_tag), last_keyword); + } + return first_keyword <= @intFromEnum(tag) and @intFromEnum(tag) <= last_keyword; +} + +test isKeyword { + try std.testing.expect(!isKeyword(.invalid)); + try std.testing.expect(!isKeyword(.container_doc_comment)); + try std.testing.expect(isKeyword(.keyword_addrspace)); + try std.testing.expect(isKeyword(.keyword_while)); +} + /// Similar to `std.zig.Ast.lastToken` but also handles ASTs with syntax errors. pub fn lastToken(tree: *const Ast, node: Node.Index) Ast.TokenIndex { var n = node; diff --git a/src/features/completions.zig b/src/features/completions.zig index 2eadc383b..d3ab90b51 100644 --- a/src/features/completions.zig +++ b/src/features/completions.zig @@ -412,10 +412,7 @@ fn functionTypeCompletion( .kind = kind, .detail = details, .insertTextFormat = insert_text_format, - .textEdit = if (builder.server.client_capabilities.supports_completion_insert_replace_support) - .{ .insert_replace_edit = .{ .newText = new_text, .insert = insert_range, .replace = replace_range } } - else - .{ .text_edit = .{ .newText = new_text, .range = insert_range } }, + .textEdit = createTextEdit(builder, .{ .newText = new_text, .insert = insert_range, .replace = replace_range }), }; } @@ -473,12 +470,27 @@ fn populateSnippedCompletions(builder: *Builder, kind: enum { generic, top_level } } +fn createTextEdit(builder: *Builder, edit: types.completion.Item.InsertReplaceEdit) types.completion.Item.TextEdit { + std.debug.assert(edit.insert.start.line == edit.insert.end.line); // text edit range must be a single line + std.debug.assert(edit.replace.start.line == edit.replace.end.line); // text edit range must be a single line + std.debug.assert(offsets.orderPosition(edit.insert.start, edit.replace.start) == .eq); // insert and replace text edits must start at the same position + std.debug.assert(edit.insert.end.character <= edit.replace.end.character); // insert text edit must be a prefix of the replace text edit + if (builder.server.client_capabilities.supports_completion_insert_replace_support) { + return .{ .insert_replace_edit = edit }; + } else { + return .{ .text_edit = .{ .newText = edit.newText, .range = edit.insert } }; + } +} + fn prepareCompletionLoc(tree: *const Ast, source_index: usize) offsets.Loc { const fallback_loc: offsets.Loc = .{ .start = source_index, .end = source_index }; const token = switch (offsets.sourceIndexToTokenIndex(tree, source_index)) { .none => return fallback_loc, .one => |token| token, - .between => |data| data.left, + .between => |data| switch (tree.tokenTag(data.left)) { + .identifier, .builtin, .invalid => data.left, + else => |tag| if (ast.isKeyword(tag)) data.left else data.right, + }, }; switch (tree.tokenTag(token)) { .identifier, .builtin => |tag| { @@ -487,21 +499,20 @@ fn prepareCompletionLoc(tree: *const Ast, source_index: usize) offsets.Loc { std.debug.assert(token_loc.start <= source_index and source_index <= token_loc.end); return offsets.identifierIndexToLoc(tree.source, token_loc.start, if (tag == .builtin) .name else .full); }, - .colon => return fallback_loc, - else => { - const token_start = tree.tokenStart(token); + else => |token_tag| { + if (token_tag != .invalid and !ast.isKeyword(token_tag)) + return fallback_loc; - var start: usize, var end: usize = start: { + const start: usize, var end: usize = start: { + const token_start = tree.tokenStart(token); if (std.mem.startsWith(u8, tree.source[token_start..], "@\"")) { break :start .{ token_start, token_start + 2 }; } else if (std.mem.startsWith(u8, tree.source[token_start..], "@") or std.mem.startsWith(u8, tree.source[token_start..], ".")) { - if (token_start + 1 < source_index) return fallback_loc; break :start .{ token_start + 1, token_start + 1 }; } else { break :start .{ token_start, token_start }; } }; - start = @min(start, source_index); end = @max(end, source_index); while (end < tree.source.len and offsets.isSymbolChar(tree.source[end])) { @@ -597,10 +608,7 @@ fn completeBuiltin(builder: *Builder) error{OutOfMemory}!void { .filterText = name[1..], .detail = detail, .insertTextFormat = insert_text_format, - .textEdit = if (builder.server.client_capabilities.supports_completion_insert_replace_support) - .{ .insert_replace_edit = .{ .newText = new_text[1..], .insert = insert_range, .replace = replace_range } } - else - .{ .text_edit = .{ .newText = new_text[1..], .range = insert_range } }, + .textEdit = createTextEdit(builder, .{ .newText = new_text[1..], .insert = insert_range, .replace = replace_range }), .documentation = .{ .markup_content = .{ .kind = if (builder.server.client_capabilities.completion_doc_supports_md) .markdown else .plaintext, @@ -834,15 +842,12 @@ fn completeFileSystemStringLiteral(builder: *Builder, pos_context: Analyser.Posi }, } - const string_content_range = offsets.locToRange(source, string_content_loc, builder.server.offset_encoding); + const string_content_range = offsets.locToRange(source, .{ .start = insert_loc.start, .end = string_content_loc.end }, builder.server.offset_encoding); // completions on module replace the entire string literal for (builder.completions.items) |*item| { if (item.kind == .Module and item.textEdit == null) { - item.textEdit = if (builder.server.client_capabilities.supports_completion_insert_replace_support) - .{ .insert_replace_edit = .{ .newText = item.label, .insert = insert_range, .replace = string_content_range } } - else - .{ .text_edit = .{ .newText = item.label, .range = insert_range } }; + item.textEdit = createTextEdit(builder, .{ .newText = item.label, .insert = insert_range, .replace = string_content_range }); } } } @@ -898,7 +903,10 @@ fn completeFileSystemStringLiteral(builder: *Builder, pos_context: Analyser.Posi const label = try builder.arena.dupe(u8, entry.name); const insert_text = if (entry.kind == .directory) - try std.fmt.allocPrint(builder.arena, "{s}/", .{entry.name}) + if (next_separator_index == null) + try std.fmt.allocPrint(builder.arena, "{s}/", .{entry.name}) + else + label else label; @@ -906,10 +914,7 @@ fn completeFileSystemStringLiteral(builder: *Builder, pos_context: Analyser.Posi .label = label, .kind = if (entry.kind == .file) .File else .Folder, .detail = if (pos_context == .cinclude_string_literal) path else null, - .textEdit = if (builder.server.client_capabilities.supports_completion_insert_replace_support) - .{ .insert_replace_edit = .{ .newText = insert_text, .insert = insert_range, .replace = replace_range } } - else - .{ .text_edit = .{ .newText = insert_text, .range = insert_range } }, + .textEdit = createTextEdit(builder, .{ .newText = insert_text, .insert = insert_range, .replace = replace_range }), .sortText = if (entry.kind == .file) "6" else "5", }); } else |err| switch (err) { @@ -975,10 +980,7 @@ pub fn completionAtIndex( for (completions) |*item| { if (item.textEdit == null) { - item.textEdit = if (server.client_capabilities.supports_completion_insert_replace_support) - .{ .insert_replace_edit = .{ .newText = item.insertText orelse item.label, .insert = insert_range, .replace = replace_range } } - else - .{ .text_edit = .{ .newText = item.insertText orelse item.label, .range = insert_range } }; + item.textEdit = createTextEdit(&builder, .{ .newText = item.insertText orelse item.label, .insert = insert_range, .replace = replace_range }); } item.insertText = null; // https://github.com/microsoft/language-server-protocol/issues/898#issuecomment-593968008 diff --git a/tests/lsp_features/completion.zig b/tests/lsp_features/completion.zig index c1cdea388..352a88be1 100644 --- a/tests/lsp_features/completion.zig +++ b/tests/lsp_features/completion.zig @@ -4295,6 +4295,54 @@ test "insert replace behaviour - file system completions" { // zig fmt: on } +test "insert replace behaviour - expression inside parens/braces/brackets" { + try testCompletionTextEdit(.{ + .source = + \\const foo = 5; + \\const bar = foo(); + , + .label = "foo", + .expected_insert_line = "const bar = foo(foo);", + .expected_replace_line = "const bar = foo(foo);", + }); + try testCompletionTextEdit(.{ + .source = + \\const foo = 5; + \\const bar = foo(foo); + , + .label = "foo", + .expected_insert_line = "const bar = foo(foooo);", + .expected_replace_line = "const bar = foo(foo);", + }); + try testCompletionTextEdit(.{ + .source = + \\const foo = 5; + \\const bar = foo(foo); + , + .label = "foo", + .expected_insert_line = "const bar = foo(foofoo);", + .expected_replace_line = "const bar = foo(foo);", + }); + try testCompletionTextEdit(.{ + .source = + \\const foo = 5; + \\const bar = foo{}; + , + .label = "foo", + .expected_insert_line = "const bar = foo{foo};", + .expected_replace_line = "const bar = foo{foo};", + }); + try testCompletionTextEdit(.{ + .source = + \\const foo = 5; + \\const bar = foo[]; + , + .label = "foo", + .expected_insert_line = "const bar = foo[foo];", + .expected_replace_line = "const bar = foo[foo];", + }); +} + test "generic function with @This() as self param" { try testCompletion( \\const Foo = struct { @@ -4615,6 +4663,7 @@ fn testCompletionTextEdit( defer ctx.deinit(); ctx.server.client_capabilities.supports_snippets = true; + ctx.server.client_capabilities.supports_completion_insert_replace_support = true; ctx.server.config_manager.config.enable_argument_placeholders = options.enable_argument_placeholders; ctx.server.config_manager.config.enable_snippets = options.enable_snippets; @@ -4627,60 +4676,38 @@ fn testCompletionTextEdit( .position = cursor_position, }; - for ([_]bool{ false, true }) |supports_insert_replace| { - ctx.server.client_capabilities.supports_completion_insert_replace_support = supports_insert_replace; - - @setEvalBranchQuota(5000); - const response = try ctx.server.sendRequestSync(ctx.arena.allocator(), "textDocument/completion", params) orelse { - std.debug.print("Server returned `null` as the result\n", .{}); - return error.InvalidResponse; - }; - const completion_item = try searchCompletionItemWithLabel(response.completion_list, options.label); - - std.debug.assert(completion_item.additionalTextEdits == null); // unsupported - std.debug.assert(!(!options.enable_snippets and completion_item.insertTextFormat == .Snippet)); - std.debug.assert(!(completion_item.kind == .Snippet and completion_item.insertTextFormat != .Snippet)); - - const TextEditOrInsertReplace = std.meta.Child(@TypeOf(completion_item.textEdit)); - - const text_edit_or_insert_replace: TextEditOrInsertReplace = completion_item.textEdit.?; - - switch (text_edit_or_insert_replace) { - .text_edit => |text_edit| { - try std.testing.expect(text_edit.range.start.line == text_edit.range.end.line); // text edit range must be a single line - try std.testing.expect(offsets.positionInsideRange(cursor_position, text_edit.range)); // text edit range must contain the cursor position - - const actual_text = try zls.diff.applyTextEdits(allocator, text, &.{text_edit}, ctx.server.offset_encoding); - defer allocator.free(actual_text); + @setEvalBranchQuota(5000); + const response = try ctx.server.sendRequestSync(ctx.arena.allocator(), "textDocument/completion", params) orelse { + std.debug.print("Server returned `null` as the result\n", .{}); + return error.InvalidResponse; + }; + const completion_item = try searchCompletionItemWithLabel(response.completion_list, options.label); - try std.testing.expectEqualStrings(expected_insert_text, actual_text); + std.debug.assert(completion_item.additionalTextEdits == null); // unsupported + std.debug.assert(!(!options.enable_snippets and completion_item.insertTextFormat == .Snippet)); + std.debug.assert(!(completion_item.kind == .Snippet and completion_item.insertTextFormat != .Snippet)); - if (supports_insert_replace) { - try std.testing.expectEqualStrings(expected_replace_text, actual_text); - } - }, - .insert_replace_edit => |insert_replace_edit| { - std.debug.assert(supports_insert_replace); + // Assumes that a `insert_replace_edit` response is sent. This doesn't have to be the case if the text edits for insert and replace are the same. + const edit: types.completion.Item.InsertReplaceEdit = completion_item.textEdit.?.insert_replace_edit; - try std.testing.expect(insert_replace_edit.insert.start.line == insert_replace_edit.insert.end.line); // text edit range must be a single line - try std.testing.expect(insert_replace_edit.replace.start.line == insert_replace_edit.replace.end.line); // text edit range must be a single line - try std.testing.expect(offsets.positionInsideRange(cursor_position, insert_replace_edit.insert)); // text edit range must contain the cursor position - try std.testing.expect(offsets.positionInsideRange(cursor_position, insert_replace_edit.replace)); // text edit range must contain the cursor position + try std.testing.expect(edit.insert.start.line == edit.insert.end.line); // text edit range must be a single line + try std.testing.expect(edit.replace.start.line == edit.replace.end.line); // text edit range must be a single line + try std.testing.expect(offsets.positionInsideRange(cursor_position, edit.insert)); // text edit range must contain the cursor position + try std.testing.expect(offsets.positionInsideRange(cursor_position, edit.replace)); // text edit range must contain the cursor position + try std.testing.expect(offsets.orderPosition(edit.insert.start, edit.replace.start) == .eq); // insert and replace text edits must start at the same position + try std.testing.expect(edit.insert.end.character <= edit.replace.end.character); // insert text edit must be a prefix of the replace text edit - const insert_text_edit: types.TextEdit = .{ .newText = insert_replace_edit.newText, .range = insert_replace_edit.insert }; - const replace_text_edit: types.TextEdit = .{ .newText = insert_replace_edit.newText, .range = insert_replace_edit.replace }; + const insert_text_edit: types.TextEdit = .{ .newText = edit.newText, .range = edit.insert }; + const replace_text_edit: types.TextEdit = .{ .newText = edit.newText, .range = edit.replace }; - const actual_insert_text = try zls.diff.applyTextEdits(allocator, text, &.{insert_text_edit}, ctx.server.offset_encoding); - defer allocator.free(actual_insert_text); + const actual_insert_text = try zls.diff.applyTextEdits(allocator, text, &.{insert_text_edit}, ctx.server.offset_encoding); + defer allocator.free(actual_insert_text); - const actual_replace_text = try zls.diff.applyTextEdits(allocator, text, &.{replace_text_edit}, ctx.server.offset_encoding); - defer allocator.free(actual_replace_text); + const actual_replace_text = try zls.diff.applyTextEdits(allocator, text, &.{replace_text_edit}, ctx.server.offset_encoding); + defer allocator.free(actual_replace_text); - try std.testing.expectEqualStrings(expected_insert_text, actual_insert_text); - try std.testing.expectEqualStrings(expected_replace_text, actual_replace_text); - }, - } - } + try std.testing.expectEqualStrings(expected_insert_text, actual_insert_text); + try std.testing.expectEqualStrings(expected_replace_text, actual_replace_text); } fn searchCompletionItemWithLabel(completion_list: types.completion.List, label: []const u8) !types.completion.Item {