Skip to content

edit: replace with empty content leaves stray trailing newline (no way to truly empty a file) #409

@justrach

Description

@justrach

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 = "":

  1. Source split: ["abc", ""]. Trailing empty pop: ["abc"]. had_trailing_newline = true.
  2. new_content = "" splits to [""]. replaceRange(0, 1, [""])[""].
  3. Restore trailing newline: ["", ""].
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingpriority:p2Medium priority

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions