Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement string literal conversion code actions #1931

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
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
4 changes: 3 additions & 1 deletion src/Server.zig
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ fn showMessage(
}
}

fn initAnalyser(server: *Server, handle: ?*DocumentStore.Handle) Analyser {
pub fn initAnalyser(server: *Server, handle: ?*DocumentStore.Handle) Analyser {
return Analyser.init(
server.allocator,
&server.document_store,
Expand Down 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
160 changes: 160 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,141 @@ 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 edit_loc_start = builder.handle.tree.tokenLocation(tokens.items(.start)[str_tok_idx], str_tok_idx).line_start;

var parsed = std.ArrayList(u8).init(builder.arena);
defer parsed.deinit();
const writer = parsed.writer();

switch (try std.zig.string_literal.parseWrite(writer, token_src)) {
.failure => return,
.success => {},
}
// carriage returns are not allowed in multiline string literals
if (std.mem.containsAtLeast(u8, parsed.items, 1, "\r")) return;

const next_token = @min(str_tok_idx + 1, builder.handle.tree.tokens.len - 1);
const leading_nl = builder.handle.tree.tokensOnSameLine(str_tok_idx -| 1, str_tok_idx);
const trailing_nl = builder.handle.tree.tokensOnSameLine(str_tok_idx, @intCast(next_token));

const len = blk: {
var tot: usize = 0;
if (leading_nl) tot += 1;
tot += std.mem.replacementSize(u8, writer.context.items, "\n", "\n\\\\") + "\\\\".len;
if (trailing_nl) tot += 1;

break :blk tot;
};
var buf = try std.ArrayList(u8).initCapacity(builder.arena, len);
errdefer buf.deinit();

var start_idx: usize = 0;
if (leading_nl) {
buf.appendAssumeCapacity('\n');
start_idx += 1;
}
buf.appendSliceAssumeCapacity("\\\\");
start_idx += 2;
try if (trailing_nl) buf.resize(len - 1) else buf.resize(len);

_ = std.mem.replace(u8, parsed.items, "\n", "\n\\\\", buf.items[start_idx..]);
if (trailing_nl) buf.appendAssumeCapacity('\n');

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,
},
buf.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;
multiline_tok_idx -|= 1;

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

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);
std.debug.assert(line.len >= 2);
const end = if (line[line.len - 1] == '\n') line.len - 1 else line.len;
// Omit the leading "\\", trailing '\n' (if it's there)
try std.zig.stringEscape(line[2..end], "", .{}, writer);
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 +726,15 @@ const DiagnosticKind = union(enum) {
}
};

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

test "string literal to multiline string literal" {
try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" },
\\const foo = <cursor>"line one\nline two\nline three";
,
\\const foo =
\\\\line one
\\\\line two
\\\\line three
\\;
);
try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" },
\\const foo = "Hello, <cursor>World!\n";
,
\\const foo =
\\\\Hello, World!
\\\\
\\;
);
try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" },
\\std.debug.print(<cursor>"Hi\nHey\nHello\n", .{});
,
\\std.debug.print(
\\\\Hi
\\\\Hey
\\\\Hello
\\\\
\\, .{});
);
try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" },
\\const blank = <cursor>""
\\;
,
\\const blank =
\\\\
\\;
);
try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" },
\\for (0..42) |idx| {
\\ std.debug.print("{}: {}\n<cursor>", .{ idx, my_foos[idx] });
\\}
,
\\for (0..42) |idx| {
\\ std.debug.print(
\\\\{}: {}
\\\\
\\, .{ idx, my_foos[idx] });
\\}
);
try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" },
\\const s1 = <cursor>"\t";
,
\\const s1 =
\\\\
\\;
);
try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" },
\\const s1 = <cursor>"pre text\tpost text";
,
\\const s1 =
\\\\pre text post text
\\;
);
}

test "multiline string literal to string literal" {
try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" },
\\const bleh =
\\ \\hello
\\ \\world<cursor>
\\ ++
\\ \\oh?
\\;
,
\\const bleh = "hello\nworld"
\\ ++
\\ \\oh?
\\;
);
try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" },
\\std.debug.print(
\\\\Hi<cursor>
\\\\Hey
\\\\Hello
\\\\
\\, .{});
,
\\std.debug.print(
\\"Hi\nHey\nHello\n"
\\, .{});
);
try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" },
\\const nums =
\\ \\123
\\ \\456<cursor>
\\ \\789
\\ ;
,
\\const nums = "123\n456\n789";
);
try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" },
\\const s3 =
\\ <cursor>\\"
\\;
,
\\const s3 = "\"";
);
try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" },
\\const s3 =
\\ <cursor>\\\
\\;
,
\\const s3 = "\\";
);
}

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 +530,47 @@ fn testAutofixOptions(before: []const u8, after: []const u8, want_zir: bool) !vo

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

fn testUserCodeAction(action_kind: zls.code_actions.UserActionKind, 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{} },
};

var analyser = ctx.server.initAnalyser(handle);
defer analyser.deinit();
var builder = zls.code_actions.Builder{
.arena = ctx.arena.allocator(),
.analyser = &analyser,
.handle = handle,
.offset_encoding = ctx.server.offset_encoding,
};
var actions = std.ArrayListUnmanaged(types.CodeAction){};

try builder.addCodeAction(action_kind, params, &actions);
try std.testing.expect(actions.items.len == 1);
const code_action = actions.items[0];
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));

const actual = try zls.diff.applyTextEdits(allocator, text, changes.get(uri).?, 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);
}