Skip to content

Commit 6cf5235

Browse files
committed
feat: Implement string literal conversion code actions, add testing code
1 parent 4408eb2 commit 6cf5235

File tree

3 files changed

+277
-0
lines changed

3 files changed

+277
-0
lines changed

src/Server.zig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,9 @@ fn codeActionHandler(server: *Server, arena: std.mem.Allocator, request: types.C
15761576
try builder.generateCodeAction(diagnostic, &actions, &remove_capture_actions);
15771577
}
15781578

1579+
try builder.addCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, request, &actions);
1580+
try builder.addCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" }, request, &actions);
1581+
15791582
const Result = getRequestMetadata("textDocument/codeAction").?.Result;
15801583
const result = try arena.alloc(std.meta.Child(std.meta.Child(Result)), actions.items.len);
15811584
for (actions.items, result) |action, *out| {

src/features/code_actions.zig

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,22 @@ pub const Builder = struct {
5050
}
5151
}
5252

53+
pub fn addCodeAction(
54+
builder: *Builder,
55+
kind: UserActionKind,
56+
params: types.CodeActionParams,
57+
actions: *std.ArrayListUnmanaged(types.CodeAction),
58+
) error{OutOfMemory}!void {
59+
const loc = offsets.rangeToLoc(builder.handle.tree.source, params.range, builder.offset_encoding);
60+
61+
switch (kind) {
62+
.str_kind_conv => |conv_kind| switch (conv_kind) {
63+
.@"string literal to multiline string" => try handleStringLiteralToMultiline(builder, actions, loc),
64+
.@"multiline string to string literal" => try handleMultilineStringToLiteral(builder, actions, loc),
65+
},
66+
}
67+
}
68+
5369
pub fn createTextEditLoc(self: *Builder, loc: offsets.Loc, new_text: []const u8) types.TextEdit {
5470
const range = offsets.locToRange(self.handle.tree.source, loc, self.offset_encoding);
5571
return types.TextEdit{ .range = range, .newText = new_text };
@@ -366,6 +382,120 @@ fn handleVariableNeverMutated(builder: *Builder, actions: *std.ArrayListUnmanage
366382
});
367383
}
368384

385+
fn handleStringLiteralToMultiline(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
386+
const tokens = builder.handle.tree.tokens;
387+
388+
const str_tok_idx = offsets.sourceIndexToTokenIndex(builder.handle.tree, loc.start);
389+
if (tokens.items(.tag)[str_tok_idx] != .string_literal) return;
390+
const token_src = builder.handle.tree.tokenSlice(str_tok_idx);
391+
const str_conts = token_src[1 .. token_src.len - 1]; // Omit leading and trailing '"'
392+
const edit_loc_start = builder.handle.tree.tokenLocation(tokens.items(.start)[str_tok_idx], str_tok_idx).line_start;
393+
394+
var multiline = std.ArrayList(u8).init(builder.arena);
395+
const writer = multiline.writer();
396+
397+
if (builder.handle.tree.tokensOnSameLine(str_tok_idx -| 1, str_tok_idx)) {
398+
try writer.writeByte('\n');
399+
}
400+
401+
var iter = std.mem.splitSequence(u8, str_conts, "\\n");
402+
while (iter.next()) |line| {
403+
try writer.print("\\\\{s}\n", .{line});
404+
}
405+
406+
// remove trailing newline in cases where it's not needed
407+
if (str_tok_idx + 1 < tokens.len and !builder.handle.tree.tokensOnSameLine(str_tok_idx, str_tok_idx + 1)) {
408+
_ = multiline.pop();
409+
}
410+
411+
try actions.append(builder.arena, .{
412+
.title = "string literal to multiline string",
413+
.kind = .@"refactor.rewrite",
414+
.isPreferred = false,
415+
.edit = try builder.createWorkspaceEdit(&.{
416+
builder.createTextEditLoc(
417+
.{
418+
.start = edit_loc_start,
419+
.end = edit_loc_start + token_src.len,
420+
},
421+
multiline.items,
422+
),
423+
}),
424+
});
425+
}
426+
427+
fn handleMultilineStringToLiteral(builder: *Builder, actions: *std.ArrayListUnmanaged(types.CodeAction), loc: offsets.Loc) !void {
428+
const token_tags = builder.handle.tree.tokens.items(.tag);
429+
const token_starts = builder.handle.tree.tokens.items(.start);
430+
431+
var multiline_tok_idx = offsets.sourceIndexToTokenIndex(builder.handle.tree, loc.start);
432+
if (token_tags[multiline_tok_idx] != .multiline_string_literal_line) return;
433+
434+
// walk up to the first multiline string literal
435+
const start_tok_idx = blk: {
436+
while (multiline_tok_idx > 0) : (multiline_tok_idx -= 1) {
437+
if (token_tags[multiline_tok_idx] != .multiline_string_literal_line) {
438+
break :blk multiline_tok_idx + 1;
439+
}
440+
}
441+
break :blk multiline_tok_idx;
442+
};
443+
444+
var str_literal = std.ArrayList(u8).init(builder.arena);
445+
const writer = str_literal.writer();
446+
447+
// place string literal on same line as the left adjacent equals sign, if it's there
448+
const prev_tok_idx = start_tok_idx -| 1;
449+
const edit_loc_start = blk: {
450+
if (token_tags[prev_tok_idx] == .equal and !builder.handle.tree.tokensOnSameLine(prev_tok_idx, start_tok_idx)) {
451+
try writer.writeAll(" \"");
452+
break :blk builder.handle.tree.tokenLocation(token_starts[prev_tok_idx], prev_tok_idx).line_end;
453+
} else {
454+
try writer.writeByte('\"');
455+
break :blk builder.handle.tree.tokenLocation(token_starts[start_tok_idx], start_tok_idx).line_start;
456+
}
457+
};
458+
459+
// construct string literal out of multiline string literals
460+
var curr_tok_idx = start_tok_idx;
461+
var edit_loc_end: usize = undefined;
462+
while (curr_tok_idx < token_tags.len and token_tags[curr_tok_idx] == .multiline_string_literal_line) : (curr_tok_idx += 1) {
463+
if (curr_tok_idx > start_tok_idx) {
464+
try writer.writeAll("\\n");
465+
}
466+
const line = builder.handle.tree.tokenSlice(curr_tok_idx);
467+
const end = if (line[line.len - 1] == '\n')
468+
line.len - 1
469+
else
470+
line.len;
471+
try writer.writeAll(line[2..end]); // Omit the leading '\\', trailing '\n' (if it's there)
472+
edit_loc_end = builder.handle.tree.tokenLocation(token_starts[curr_tok_idx], curr_tok_idx).line_end;
473+
}
474+
475+
try writer.writeByte('\"');
476+
477+
// bring up the semicolon from the next line, if it's there
478+
if (curr_tok_idx < token_tags.len and token_tags[curr_tok_idx] == .semicolon) {
479+
try writer.writeByte(';');
480+
edit_loc_end = builder.handle.tree.tokenLocation(token_starts[curr_tok_idx], curr_tok_idx).line_start + 1;
481+
}
482+
483+
try actions.append(builder.arena, .{
484+
.title = "multiline string to string literal",
485+
.kind = .@"refactor.rewrite",
486+
.isPreferred = false,
487+
.edit = try builder.createWorkspaceEdit(&.{
488+
builder.createTextEditLoc(
489+
.{
490+
.start = edit_loc_start,
491+
.end = edit_loc_end,
492+
},
493+
str_literal.items,
494+
),
495+
}),
496+
});
497+
}
498+
369499
fn detectIndentation(source: []const u8) []const u8 {
370500
// Essentially I'm looking for the first indentation in the file.
371501
var i: usize = 0;
@@ -575,6 +705,15 @@ const DiagnosticKind = union(enum) {
575705
}
576706
};
577707

708+
const UserActionKind = union(enum) {
709+
str_kind_conv: StrCat,
710+
711+
const StrCat = enum {
712+
@"string literal to multiline string",
713+
@"multiline string to string literal",
714+
};
715+
};
716+
578717
/// takes the location of an identifier which is part of a discard `_ = location_here;`
579718
/// and returns the location from '_' until ';' or null on failure
580719
fn getDiscardLoc(text: []const u8, loc: offsets.Loc) ?offsets.Loc {

tests/lsp_features/code_actions.zig

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,93 @@ test "ignore autofix comment whitespace" {
363363
);
364364
}
365365

366+
test "string literal to multiline string literal" {
367+
try testUserCodeAction(
368+
\\const foo = <cursor>"line one\nline two\nline three";
369+
,
370+
\\const foo =
371+
\\\\line one
372+
\\\\line two
373+
\\\\line three
374+
\\;
375+
);
376+
try testUserCodeAction(
377+
\\const foo = "Hello, <cursor>World!\n";
378+
,
379+
\\const foo =
380+
\\\\Hello, World!
381+
\\\\
382+
\\;
383+
);
384+
try testUserCodeAction(
385+
\\std.debug.print(<cursor>"Hi\nHey\nHello\n", .{});
386+
,
387+
\\std.debug.print(
388+
\\\\Hi
389+
\\\\Hey
390+
\\\\Hello
391+
\\\\
392+
\\, .{});
393+
);
394+
try testUserCodeAction(
395+
\\const blank = <cursor>""
396+
\\;
397+
,
398+
\\const blank =
399+
\\\\
400+
\\;
401+
);
402+
}
403+
404+
test "multiline string literal to string literal" {
405+
try testUserCodeAction(
406+
\\const bleh =
407+
\\ \\hello
408+
\\ \\world<cursor>
409+
\\ ++
410+
\\ \\oh?
411+
\\;
412+
,
413+
\\const bleh = "hello\nworld"
414+
\\ ++
415+
\\ \\oh?
416+
\\;
417+
);
418+
try testUserCodeAction(
419+
\\std.debug.print(
420+
\\\\Hi<cursor>
421+
\\\\Hey
422+
\\\\Hello
423+
\\\\
424+
\\, .{});
425+
,
426+
\\std.debug.print(
427+
\\"Hi\nHey\nHello\n"
428+
\\, .{});
429+
);
430+
try testUserCodeAction(
431+
\\const nums =
432+
\\ \\123
433+
\\ \\456<cursor>
434+
\\ \\789
435+
\\ ;
436+
,
437+
\\const nums = "123\n456\n789";
438+
);
439+
try testUserCodeAction(
440+
\\for (0..42) |idx| {
441+
\\ std.debug.print("{}: {}\n<cursor>", .{ idx, my_foos[idx] });
442+
\\}
443+
,
444+
\\for (0..42) |idx| {
445+
\\ std.debug.print(
446+
\\\\{}: {}
447+
\\\\
448+
\\, .{ idx, my_foos[idx] });
449+
\\}
450+
);
451+
}
452+
366453
fn testAutofix(before: []const u8, after: []const u8) !void {
367454
try testAutofixOptions(before, after, true); // diagnostics come from our AstGen fork
368455
try testAutofixOptions(before, after, false); // diagnostics come from calling zig ast-check
@@ -415,3 +502,51 @@ fn testAutofixOptions(before: []const u8, after: []const u8, want_zir: bool) !vo
415502

416503
try std.testing.expectEqualStrings(after, handle.tree.source);
417504
}
505+
506+
fn testUserCodeAction(source: []const u8, expected: []const u8) !void {
507+
var ctx = try Context.init();
508+
defer ctx.deinit();
509+
510+
const cursor_idx = std.mem.indexOf(u8, source, "<cursor>").?;
511+
const text = try std.mem.concat(allocator, u8, &.{ source[0..cursor_idx], source[cursor_idx + "<cursor>".len ..] });
512+
defer allocator.free(text);
513+
514+
const uri = try ctx.addDocument(text);
515+
const handle = ctx.server.document_store.getHandle(uri).?;
516+
const pos = offsets.indexToPosition(text, cursor_idx, ctx.server.offset_encoding);
517+
518+
const params = types.CodeActionParams{
519+
.textDocument = .{ .uri = uri },
520+
.range = .{
521+
.start = pos,
522+
.end = pos,
523+
},
524+
.context = .{ .diagnostics = &[_]zls.types.Diagnostic{} },
525+
};
526+
527+
@setEvalBranchQuota(5000);
528+
const response = try ctx.server.sendRequestSync(ctx.arena.allocator(), "textDocument/codeAction", params) orelse {
529+
std.debug.print("Server returned `null` as the result\n", .{});
530+
return error.InvalidResponse;
531+
};
532+
533+
var text_edits: std.ArrayListUnmanaged(types.TextEdit) = .{};
534+
defer text_edits.deinit(allocator);
535+
536+
for (response) |action| {
537+
const code_action = action.CodeAction;
538+
if (code_action.kind.? == .@"source.fixAll") continue;
539+
const workspace_edit = code_action.edit.?;
540+
const changes = workspace_edit.changes.?.map;
541+
try std.testing.expectEqual(@as(usize, 1), changes.count());
542+
try std.testing.expect(changes.contains(uri));
543+
544+
try text_edits.appendSlice(allocator, changes.get(uri).?);
545+
}
546+
547+
const actual = try zls.diff.applyTextEdits(allocator, text, text_edits.items, ctx.server.offset_encoding);
548+
defer allocator.free(actual);
549+
try ctx.server.document_store.refreshDocument(uri, try allocator.dupeZ(u8, actual));
550+
551+
try std.testing.expectEqualStrings(expected, handle.tree.source);
552+
}

0 commit comments

Comments
 (0)