Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/ast.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 31 additions & 29 deletions src/features/completions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
};
}

Expand Down Expand Up @@ -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| {
Expand All @@ -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])) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}
}
}
Expand Down Expand Up @@ -898,18 +903,18 @@ 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;

try builder.completions.append(builder.arena, .{
.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) {
Expand Down Expand Up @@ -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
Expand Down
121 changes: 74 additions & 47 deletions tests/lsp_features/completion.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(<cursor>);
,
.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(f<cursor>oo);
,
.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(<cursor>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{<cursor>};
,
.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[<cursor>];
,
.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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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 {
Expand Down