From 8edf2604751ea69aa42bea0e6f0e575386a95fa4 Mon Sep 17 00:00:00 2001 From: Will Lillis Date: Sun, 23 Jun 2024 00:29:35 -0400 Subject: [PATCH 1/2] feat: Implement string literal conversion code actions, add testing code --- src/Server.zig | 2 + src/features/code_actions.zig | 139 ++++++++++++++++++++++++++++ tests/lsp_features/code_actions.zig | 135 +++++++++++++++++++++++++++ 3 files changed, 276 insertions(+) diff --git a/src/Server.zig b/src/Server.zig index 2451b87b7..9fe2433b0 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -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 }; diff --git a/src/features/code_actions.zig b/src/features/code_actions.zig index 3b706da78..98b05af5c 100644 --- a/src/features/code_actions.zig +++ b/src/features/code_actions.zig @@ -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 }; @@ -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; @@ -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 { diff --git a/tests/lsp_features/code_actions.zig b/tests/lsp_features/code_actions.zig index fc1835c26..8fda76b11 100644 --- a/tests/lsp_features/code_actions.zig +++ b/tests/lsp_features/code_actions.zig @@ -363,6 +363,93 @@ test "ignore autofix comment whitespace" { ); } +test "string literal to multiline string literal" { + try testUserCodeAction( + \\const foo = "line one\nline two\nline three"; + , + \\const foo = + \\\\line one + \\\\line two + \\\\line three + \\; + ); + try testUserCodeAction( + \\const foo = "Hello, World!\n"; + , + \\const foo = + \\\\Hello, World! + \\\\ + \\; + ); + try testUserCodeAction( + \\std.debug.print("Hi\nHey\nHello\n", .{}); + , + \\std.debug.print( + \\\\Hi + \\\\Hey + \\\\Hello + \\\\ + \\, .{}); + ); + try testUserCodeAction( + \\const blank = "" + \\; + , + \\const blank = + \\\\ + \\; + ); +} + +test "multiline string literal to string literal" { + try testUserCodeAction( + \\const bleh = + \\ \\hello + \\ \\world + \\ ++ + \\ \\oh? + \\; + , + \\const bleh = "hello\nworld" + \\ ++ + \\ \\oh? + \\; + ); + try testUserCodeAction( + \\std.debug.print( + \\\\Hi + \\\\Hey + \\\\Hello + \\\\ + \\, .{}); + , + \\std.debug.print( + \\"Hi\nHey\nHello\n" + \\, .{}); + ); + try testUserCodeAction( + \\const nums = + \\ \\123 + \\ \\456 + \\ \\789 + \\ ; + , + \\const nums = "123\n456\n789"; + ); + try testUserCodeAction( + \\for (0..42) |idx| { + \\ std.debug.print("{}: {}\n", .{ 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 @@ -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, "").?; + const text = try std.mem.concat(allocator, u8, &.{ source[0..cursor_idx], source[cursor_idx + "".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); +} From e3e23dea21660f4e248cb861a38c172929012aaa Mon Sep 17 00:00:00 2001 From: Will Lillis Date: Fri, 19 Jul 2024 17:30:46 -0400 Subject: [PATCH 2/2] rework code action test fn to take code action kind, fix string literal code action handlers --- src/Server.zig | 2 +- src/features/code_actions.zig | 65 ++++++++++++------ tests/lsp_features/code_actions.zig | 100 +++++++++++++++++----------- 3 files changed, 106 insertions(+), 61 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index 9fe2433b0..76e35eb37 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -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, diff --git a/src/features/code_actions.zig b/src/features/code_actions.zig index 98b05af5c..922247498 100644 --- a/src/features/code_actions.zig +++ b/src/features/code_actions.zig @@ -388,25 +388,45 @@ fn handleStringLiteralToMultiline(builder: *Builder, actions: *std.ArrayListUnma 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(); + var parsed = std.ArrayList(u8).init(builder.arena); + defer parsed.deinit(); + const writer = parsed.writer(); - if (builder.handle.tree.tokensOnSameLine(str_tok_idx -| 1, str_tok_idx)) { - try writer.writeByte('\n'); + 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; - var iter = std.mem.splitSequence(u8, str_conts, "\\n"); - while (iter.next()) |line| { - try writer.print("\\\\{s}\n", .{line}); - } + 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(); - // 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(); + 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", @@ -418,7 +438,7 @@ fn handleStringLiteralToMultiline(builder: *Builder, actions: *std.ArrayListUnma .start = edit_loc_start, .end = edit_loc_start + token_src.len, }, - multiline.items, + buf.items, ), }), }); @@ -430,15 +450,18 @@ fn handleMultilineStringToLiteral(builder: *Builder, actions: *std.ArrayListUnma 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 (multiline_tok_idx > 0) : (multiline_tok_idx -= 1) { + 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; } } - break :blk multiline_tok_idx; + unreachable; }; var str_literal = std.ArrayList(u8).init(builder.arena); @@ -464,16 +487,14 @@ fn handleMultilineStringToLiteral(builder: *Builder, actions: *std.ArrayListUnma 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) + 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(';'); @@ -705,7 +726,7 @@ const DiagnosticKind = union(enum) { } }; -const UserActionKind = union(enum) { +pub const UserActionKind = union(enum) { str_kind_conv: StrCat, const StrCat = enum { diff --git a/tests/lsp_features/code_actions.zig b/tests/lsp_features/code_actions.zig index 8fda76b11..a23c58668 100644 --- a/tests/lsp_features/code_actions.zig +++ b/tests/lsp_features/code_actions.zig @@ -364,7 +364,7 @@ test "ignore autofix comment whitespace" { } test "string literal to multiline string literal" { - try testUserCodeAction( + try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, \\const foo = "line one\nline two\nline three"; , \\const foo = @@ -373,7 +373,7 @@ test "string literal to multiline string literal" { \\\\line three \\; ); - try testUserCodeAction( + try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, \\const foo = "Hello, World!\n"; , \\const foo = @@ -381,7 +381,7 @@ test "string literal to multiline string literal" { \\\\ \\; ); - try testUserCodeAction( + try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, \\std.debug.print("Hi\nHey\nHello\n", .{}); , \\std.debug.print( @@ -391,7 +391,7 @@ test "string literal to multiline string literal" { \\\\ \\, .{}); ); - try testUserCodeAction( + try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, \\const blank = "" \\; , @@ -399,10 +399,36 @@ test "string literal to multiline string literal" { \\\\ \\; ); + try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, + \\for (0..42) |idx| { + \\ std.debug.print("{}: {}\n", .{ 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 = "\t"; + , + \\const s1 = + \\\\ + \\; + ); + try testUserCodeAction(.{ .str_kind_conv = .@"string literal to multiline string" }, + \\const s1 = "pre text\tpost text"; + , + \\const s1 = + \\\\pre text post text + \\; + ); } test "multiline string literal to string literal" { - try testUserCodeAction( + try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" }, \\const bleh = \\ \\hello \\ \\world @@ -415,7 +441,7 @@ test "multiline string literal to string literal" { \\ \\oh? \\; ); - try testUserCodeAction( + try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" }, \\std.debug.print( \\\\Hi \\\\Hey @@ -427,7 +453,7 @@ test "multiline string literal to string literal" { \\"Hi\nHey\nHello\n" \\, .{}); ); - try testUserCodeAction( + try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" }, \\const nums = \\ \\123 \\ \\456 @@ -436,17 +462,19 @@ test "multiline string literal to string literal" { , \\const nums = "123\n456\n789"; ); - try testUserCodeAction( - \\for (0..42) |idx| { - \\ std.debug.print("{}: {}\n", .{ idx, my_foos[idx] }); - \\} + try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" }, + \\const s3 = + \\ \\" + \\; , - \\for (0..42) |idx| { - \\ std.debug.print( - \\\\{}: {} - \\\\ - \\, .{ idx, my_foos[idx] }); - \\} + \\const s3 = "\""; + ); + try testUserCodeAction(.{ .str_kind_conv = .@"multiline string to string literal" }, + \\const s3 = + \\ \\\ + \\; + , + \\const s3 = "\\"; ); } @@ -503,7 +531,7 @@ 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 { +fn testUserCodeAction(action_kind: zls.code_actions.UserActionKind, source: []const u8, expected: []const u8) !void { var ctx = try Context.init(); defer ctx.deinit(); @@ -514,7 +542,6 @@ fn testUserCodeAction(source: []const u8, expected: []const u8) !void { 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 = .{ @@ -524,29 +551,26 @@ fn testUserCodeAction(source: []const u8, expected: []const u8) !void { .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 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){}; - 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).?); - } + 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, text_edits.items, ctx.server.offset_encoding); + 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); }