Skip to content

Commit

Permalink
feat: Implement string literal conversion code actions, add testing code
Browse files Browse the repository at this point in the history
  • Loading branch information
WillLillis committed Jul 18, 2024
1 parent 41dae22 commit bdd29e0
Show file tree
Hide file tree
Showing 3 changed files with 276 additions and 0 deletions.
2 changes: 2 additions & 0 deletions src/Server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1579,6 +1579,8 @@ fn codeActionHandler(server: *Server, arena: std.mem.Allocator, request: types.C
}

const Result = lsp.types.getRequestMetadata("textDocument/codeAction").?.Result;
try builder.addCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, request, &actions);
try builder.addCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" }, request, &actions);
const result = try arena.alloc(std.meta.Child(std.meta.Child(Result)), actions.items.len);
for (actions.items, result) |action, *out| {
out.* = .{ .CodeAction = action };
Expand Down
139 changes: 139 additions & 0 deletions src/features/code_actions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@ pub const Builder = struct {
}
}

pub fn addCodeAction(
builder: *Builder,
kind: UserActionKind,
params: types.CodeActionParams,
actions: *std.ArrayListUnmanaged(types.CodeAction),
) error{OutOfMemory}!void {
const loc = offsets.rangeToLoc(builder.handle.tree.source, params.range, builder.offset_encoding);

switch (kind) {
.str_kind_conv => |conv_kind| switch (conv_kind) {
.@"string literal to multiline string" => try handleStringLiteralToMultiline(builder, actions, loc),
.@"multiline string to string literal" => try handleMultilineStringToLiteral(builder, actions, loc),
},
}
}

pub fn createTextEditLoc(self: *Builder, loc: offsets.Loc, new_text: []const u8) types.TextEdit {
const range = offsets.locToRange(self.handle.tree.source, loc, self.offset_encoding);
return types.TextEdit{ .range = range, .newText = new_text };
Expand Down Expand Up @@ -366,6 +382,120 @@ fn handleVariableNeverMutated(builder: *Builder, actions: *std.ArrayListUnmanage
});
}

fn handleStringLiteralToMultiline(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
const tokens = builder.handle.tree.tokens;

const str_tok_idx = offsets.sourceIndexToTokenIndex(builder.handle.tree, loc.start);
if (tokens.items(.tag)[str_tok_idx] != .string_literal) return;
const token_src = builder.handle.tree.tokenSlice(str_tok_idx);
const str_conts = token_src[1 .. token_src.len - 1]; // Omit leading and trailing '"'
const edit_loc_start = builder.handle.tree.tokenLocation(tokens.items(.start)[str_tok_idx], str_tok_idx).line_start;

var multiline = std.ArrayList(u8).init(builder.arena);
const writer = multiline.writer();

if (builder.handle.tree.tokensOnSameLine(str_tok_idx -| 1, str_tok_idx)) {
try writer.writeByte('\n');
}

var iter = std.mem.splitSequence(u8, str_conts, "\\n");
while (iter.next()) |line| {
try writer.print("\\\\{s}\n", .{line});
}

// remove trailing newline in cases where it's not needed
if (str_tok_idx + 1 < tokens.len and !builder.handle.tree.tokensOnSameLine(str_tok_idx, str_tok_idx + 1)) {
_ = multiline.pop();
}

try actions.append(builder.arena, .{
.title = "string literal to multiline string",
.kind = .@"refactor.rewrite",
.isPreferred = false,
.edit = try builder.createWorkspaceEdit(&.{
builder.createTextEditLoc(
.{
.start = edit_loc_start,
.end = edit_loc_start + token_src.len,
},
multiline.items,
),
}),
});
}

fn handleMultilineStringToLiteral(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
const token_tags = builder.handle.tree.tokens.items(.tag);
const token_starts = builder.handle.tree.tokens.items(.start);

var multiline_tok_idx = offsets.sourceIndexToTokenIndex(builder.handle.tree, loc.start);
if (token_tags[multiline_tok_idx] != .multiline_string_literal_line) return;

// walk up to the first multiline string literal
const start_tok_idx = blk: {
while (multiline_tok_idx > 0) : (multiline_tok_idx -= 1) {
if (token_tags[multiline_tok_idx] != .multiline_string_literal_line) {
break :blk multiline_tok_idx + 1;
}
}
break :blk multiline_tok_idx;
};

var str_literal = std.ArrayList(u8).init(builder.arena);
const writer = str_literal.writer();

// place string literal on same line as the left adjacent equals sign, if it's there
const prev_tok_idx = start_tok_idx -| 1;
const edit_loc_start = blk: {
if (token_tags[prev_tok_idx] == .equal and !builder.handle.tree.tokensOnSameLine(prev_tok_idx, start_tok_idx)) {
try writer.writeAll(" \"");
break :blk builder.handle.tree.tokenLocation(token_starts[prev_tok_idx], prev_tok_idx).line_end;
} else {
try writer.writeByte('\"');
break :blk builder.handle.tree.tokenLocation(token_starts[start_tok_idx], start_tok_idx).line_start;
}
};

// construct string literal out of multiline string literals
var curr_tok_idx = start_tok_idx;
var edit_loc_end: usize = undefined;
while (curr_tok_idx < token_tags.len and token_tags[curr_tok_idx] == .multiline_string_literal_line) : (curr_tok_idx += 1) {
if (curr_tok_idx > start_tok_idx) {
try writer.writeAll("\\n");
}
const line = builder.handle.tree.tokenSlice(curr_tok_idx);
const end = if (line[line.len - 1] == '\n')
line.len - 1
else
line.len;
try writer.writeAll(line[2..end]); // Omit the leading '\\', trailing '\n' (if it's there)
edit_loc_end = builder.handle.tree.tokenLocation(token_starts[curr_tok_idx], curr_tok_idx).line_end;
}

try writer.writeByte('\"');

// bring up the semicolon from the next line, if it's there
if (curr_tok_idx < token_tags.len and token_tags[curr_tok_idx] == .semicolon) {
try writer.writeByte(';');
edit_loc_end = builder.handle.tree.tokenLocation(token_starts[curr_tok_idx], curr_tok_idx).line_start + 1;
}

try actions.append(builder.arena, .{
.title = "multiline string to string literal",
.kind = .@"refactor.rewrite",
.isPreferred = false,
.edit = try builder.createWorkspaceEdit(&.{
builder.createTextEditLoc(
.{
.start = edit_loc_start,
.end = edit_loc_end,
},
str_literal.items,
),
}),
});
}

fn detectIndentation(source: []const u8) []const u8 {
// Essentially I'm looking for the first indentation in the file.
var i: usize = 0;
Expand Down Expand Up @@ -575,6 +705,15 @@ const DiagnosticKind = union(enum) {
}
};

const UserActionKind = union(enum) {
str_kind_conv: StrCat,

const StrCat = enum {
@"string literal to multiline string",
@"multiline string to string literal",
};
};

/// 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 {
Expand Down
135 changes: 135 additions & 0 deletions tests/lsp_features/code_actions.zig
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,93 @@ test "ignore autofix comment whitespace" {
);
}

test "string literal to multiline string literal" {
try testUserCodeAction(
\\const foo = <cursor>"line one\nline two\nline three";
,
\\const foo =
\\\\line one
\\\\line two
\\\\line three
\\;
);
try testUserCodeAction(
\\const foo = "Hello, <cursor>World!\n";
,
\\const foo =
\\\\Hello, World!
\\\\
\\;
);
try testUserCodeAction(
\\std.debug.print(<cursor>"Hi\nHey\nHello\n", .{});
,
\\std.debug.print(
\\\\Hi
\\\\Hey
\\\\Hello
\\\\
\\, .{});
);
try testUserCodeAction(
\\const blank = <cursor>""
\\;
,
\\const blank =
\\\\
\\;
);
}

test "multiline string literal to string literal" {
try testUserCodeAction(
\\const bleh =
\\ \\hello
\\ \\world<cursor>
\\ ++
\\ \\oh?
\\;
,
\\const bleh = "hello\nworld"
\\ ++
\\ \\oh?
\\;
);
try testUserCodeAction(
\\std.debug.print(
\\\\Hi<cursor>
\\\\Hey
\\\\Hello
\\\\
\\, .{});
,
\\std.debug.print(
\\"Hi\nHey\nHello\n"
\\, .{});
);
try testUserCodeAction(
\\const nums =
\\ \\123
\\ \\456<cursor>
\\ \\789
\\ ;
,
\\const nums = "123\n456\n789";
);
try testUserCodeAction(
\\for (0..42) |idx| {
\\ std.debug.print("{}: {}\n<cursor>", .{ idx, my_foos[idx] });
\\}
,
\\for (0..42) |idx| {
\\ std.debug.print(
\\\\{}: {}
\\\\
\\, .{ idx, my_foos[idx] });
\\}
);
}

fn testAutofix(before: []const u8, after: []const u8) !void {
try testAutofixOptions(before, after, true); // diagnostics come from our AstGen fork
try testAutofixOptions(before, after, false); // diagnostics come from calling zig ast-check
Expand Down Expand Up @@ -415,3 +502,51 @@ fn testAutofixOptions(before: []const u8, after: []const u8, want_zir: bool) !vo

try std.testing.expectEqualStrings(after, handle.tree.source);
}

fn testUserCodeAction(source: []const u8, expected: []const u8) !void {
var ctx = try Context.init();
defer ctx.deinit();

const cursor_idx = std.mem.indexOf(u8, source, "<cursor>").?;
const text = try std.mem.concat(allocator, u8, &.{ source[0..cursor_idx], source[cursor_idx + "<cursor>".len ..] });
defer allocator.free(text);

const uri = try ctx.addDocument(text);
const handle = ctx.server.document_store.getHandle(uri).?;
const pos = offsets.indexToPosition(text, cursor_idx, ctx.server.offset_encoding);

const params = types.CodeActionParams{
.textDocument = .{ .uri = uri },
.range = .{
.start = pos,
.end = pos,
},
.context = .{ .diagnostics = &[_]zls.types.Diagnostic{} },
};

@setEvalBranchQuota(5000);
const response = try ctx.server.sendRequestSync(ctx.arena.allocator(), "textDocument/codeAction", params) orelse {
std.debug.print("Server returned `null` as the result\n", .{});
return error.InvalidResponse;
};

var text_edits: std.ArrayListUnmanaged(types.TextEdit) = .{};
defer text_edits.deinit(allocator);

for (response) |action| {
const code_action = action.CodeAction;
if (code_action.kind.? == .@"source.fixAll") continue;
const workspace_edit = code_action.edit.?;
const changes = workspace_edit.changes.?.map;
try std.testing.expectEqual(@as(usize, 1), changes.count());
try std.testing.expect(changes.contains(uri));

try text_edits.appendSlice(allocator, changes.get(uri).?);
}

const actual = try zls.diff.applyTextEdits(allocator, text, text_edits.items, ctx.server.offset_encoding);
defer allocator.free(actual);
try ctx.server.document_store.refreshDocument(uri, try allocator.dupeZ(u8, actual));

try std.testing.expectEqualStrings(expected, handle.tree.source);
}

0 comments on commit bdd29e0

Please sign in to comment.