Problem
applyEdit in src/edit.zig (L94-97) unconditionally restores the source file's trailing newline after the operation, regardless of whether the resulting line buffer would naturally produce one.
When a caller asks to replace the only line of a 1-line file with empty content (content = ""), the expected outcome is an empty file. The actual outcome is a file containing just '\n'.
Trace for original = "abc\n", range = .{1,1}, content = "":
- Source split:
["abc", ""]. Trailing empty pop: ["abc"]. had_trailing_newline = true.
new_content = "" splits to [""]. replaceRange(0, 1, [""]) → [""].
- Restore trailing newline:
["", ""].
mem.join(allocator, "\n", ["", ""]) → "\n".
So the file becomes "\n" (1 byte) instead of "" (0 bytes). There is no way for an MCP edit caller to produce a 0-byte file via replace, because the trailing-newline restoration is unconditional.
Failing Test
Located on branch issue-409b-failing-test (the -b suffix avoids a clash with another agent's branch).
test "issue-409: replacing whole file with empty content leaves a stray newline" {
var tmp = testing.tmpDir(.{});
defer tmp.cleanup();
const rel_path = try std.fmt.allocPrint(testing.allocator, ".zig-cache/tmp/{s}/edit-409.txt", .{tmp.sub_path});
defer testing.allocator.free(rel_path);
const original = "abc\n";
var file = try tmp.dir.createFile(io, "edit-409.txt", .{});
defer file.close(io);
try file.writeStreamingAll(io, original);
var store = Store.init(testing.allocator);
defer store.deinit();
var agents = AgentRegistry.init(testing.allocator);
defer agents.deinit();
const agent_id = try agents.register("issue-409-agent");
const result = try edit_mod.applyEdit(io, testing.allocator, &store, &agents, null, .{
.path = rel_path,
.agent_id = agent_id,
.op = .replace,
.range = .{ 1, 1 },
.content = "",
});
const after = try std.Io.Dir.cwd().readFileAlloc(io, rel_path, testing.allocator, .limited(10 * 1024));
defer testing.allocator.free(after);
try testing.expectEqual(@as(usize, 0), after.len);
try testing.expectEqual(@as(u64, 0), result.new_size);
}
Reproduction: zig build test 2>&1 | grep "issue-409" — fails with expected 0, found 1.
Expected
Replacing all content with an empty string should produce an empty file.
Fix
Inside applyEdit, gate the trailing-newline restore on the post-edit state. For example, skip the restore when the post-edit line buffer is empty or contains only a single empty line introduced by the empty replacement.
Problem
applyEditinsrc/edit.zig(L94-97) unconditionally restores the source file's trailing newline after the operation, regardless of whether the resulting line buffer would naturally produce one.When a caller asks to replace the only line of a 1-line file with empty content (
content = ""), the expected outcome is an empty file. The actual outcome is a file containing just'\n'.Trace for
original = "abc\n",range = .{1,1},content = "":["abc", ""]. Trailing empty pop:["abc"].had_trailing_newline = true.new_content = ""splits to[""].replaceRange(0, 1, [""])→[""].["", ""].mem.join(allocator, "\n", ["", ""])→"\n".So the file becomes
"\n"(1 byte) instead of""(0 bytes). There is no way for an MCP edit caller to produce a 0-byte file viareplace, because the trailing-newline restoration is unconditional.Failing Test
Located on branch
issue-409b-failing-test(the-bsuffix avoids a clash with another agent's branch).Reproduction:
zig build test 2>&1 | grep "issue-409"— fails withexpected 0, found 1.Expected
Replacing all content with an empty string should produce an empty file.
Fix
Inside
applyEdit, gate the trailing-newline restore on the post-edit state. For example, skip the restore when the post-edit line buffer is empty or contains only a single empty line introduced by the empty replacement.