PranitInitial commit439f83c3 months agoHistory
353 lines · 11.5 KB · Zig
RawDownload
const std = @import("std");const display_width = @import("../shared/display_width.zig");const edit_history = @import("edit_history.zig");const editor_state = @import("editor_state.zig");const input_limit_rejection = @import("input_limit_rejection.zig");const kill_ring = @import("kill_ring.zig");const picker_state = @import("picker_state.zig");const registered_entities = @import("registered_entities.zig");const text_boundaries = @import("text_boundaries.zig");const vertical_navigation = @import("vertical_navigation.zig"); const Allocator = std.mem.Allocator; pub const DeleteKind = enum {    whitespace_word_left,    line_start,    line_end,}; const DeleteRequest = struct {    bounds: kill_ring.LineBounds,    range: kill_ring.DeleteRange,}; /// Borrows the Core state changed by composer kill and yank actions./// The containing input runtime retains ownership of every pointer and allocation.pub const State = struct {    ring: *kill_ring.State,    history: *edit_history.State,    active: kill_ring.ActiveComposer,     pub fn delete(self: State, alloc: Allocator, kind: DeleteKind) !bool {        if (kind == .whitespace_word_left) {            self.active.vertical_navigation.reset();        }        const request = deleteRequest(            self.active.edit,            self.active.entities,            kind,        ) orelse return false;        return self.deleteRange(alloc, request);    }     pub fn yank(        self: State,        alloc: Allocator,        first_image_id: usize,        max_len: usize,    ) !kill_ring.YankResult {        const selection = self.active.edit.selectionRange();        const start = if (selection) |range| range.start else self.active.edit.cursor;        const removed = if (selection) |range|            self.active.edit.input.items[range.start..range.end]        else            "";        const selection_has_entity = if (selection) |range|            self.active.entities.entityOverlapping(range.start, range.end) != null        else            false;        const structured = self.ring.images.items.len > 0 or            self.ring.image_tokens.items.len > 0 or            self.ring.skill_tokens.items.len > 0 or            selection_has_entity;        var prepared = if (structured)            edit_history.Prepared.boundary        else            try self.history.prepare(                alloc,                start,                removed,                self.ring.text.items,                self.active.edit.cursor,                start + self.ring.text.items.len,            );        defer prepared.deinit(alloc);         const result = try self.ring.yank(            alloc,            self.active,            first_image_id,            max_len,        );        switch (result) {            .inserted => self.history.commit(alloc, &prepared),            .inactive, .limit_exceeded => {},        }        return result;    }     fn deleteRange(        self: State,        alloc: Allocator,        request: DeleteRequest,    ) !bool {        const structured = self.active.entities.entityOverlapping(            request.range.start,            request.range.end,        ) != null;        var prepared = if (structured)            edit_history.Prepared.boundary        else            try self.history.prepare(                alloc,                request.range.start,                self.active.edit.input.items[request.range.start..request.range.end],                "",                self.active.edit.cursor,                request.range.start,            );        defer prepared.deinit(alloc);         const changed = try self.ring.deleteRange(            alloc,            self.active,            request.bounds,            request.range,        );        if (changed) self.history.commit(alloc, &prepared);        return changed;    }}; fn deleteRequest(    edit: *const editor_state.State,    entities: *const registered_entities.State,    kind: DeleteKind,) ?DeleteRequest {    if (edit.selectionRange()) |selection| {        return .{            .bounds = .{                .start = selection.start,                .cursor = selection.end,                .end = selection.end,            },            .range = .{ .start = selection.start, .end = selection.end },        };    }     const bounds = kill_ring.lineBounds(edit.input.items, edit.cursor);    const range = switch (kind) {        .whitespace_word_left => whitespaceWordLeftRange(            edit.input.items,            bounds,            entities,        ),        .line_start => kill_ring.DeleteRange{            .start = bounds.start,            .end = bounds.cursor,        },        .line_end => kill_ring.DeleteRange{            .start = bounds.cursor,            .end = if (bounds.cursor == bounds.end and bounds.end < edit.input.items.len)                bounds.end + 1            else                bounds.end,        },    };    if (range.start >= range.end) return null;    return .{ .bounds = bounds, .range = range };} fn whitespaceWordLeftRange(    input: []const u8,    bounds: kill_ring.LineBounds,    entities: *const registered_entities.State,) kill_ring.DeleteRange {    var start = bounds.cursor;    while (start > bounds.start) {        const previous = text_boundaries.previousCharacterStart(input, start);        const rune = display_width.decodeNextRune(input, previous);        if (!isWhitespaceRune(rune.codepoint)) break;        start = previous;    }    while (start > bounds.start) {        if (entities.entityEndingAt(input, start)) |entity| {            start = entity.span.raw_start;            break;        }        const previous = text_boundaries.previousCharacterStart(input, start);        const rune = display_width.decodeNextRune(input, previous);        if (isWhitespaceRune(rune.codepoint)) break;        start = previous;    }    return .{ .start = start, .end = bounds.cursor };} fn isWhitespaceRune(codepoint: u21) bool {    return codepoint <= 0x7f and std.ascii.isWhitespace(@intCast(codepoint));} const Fixture = struct {    ring: kill_ring.State = .{},    edit: editor_state.State = .{},    history: edit_history.State = .{},    picker: picker_state.State = .{},    entities: registered_entities.State = .{},    vertical_navigation: vertical_navigation.State = .{},    input_limit_rejection: input_limit_rejection.State = .{},     fn deinit(self: *Fixture, alloc: Allocator) void {        self.ring.deinit(alloc);        self.edit.deinit(alloc);        self.history.deinit(alloc);        self.picker.deinit(alloc);        self.entities.deinit(alloc);    }     fn state(self: *Fixture) State {        return .{            .ring = &self.ring,            .history = &self.history,            .active = .{                .edit = &self.edit,                .entities = &self.entities,                .picker = &self.picker,                .vertical_navigation = &self.vertical_navigation,                .input_limit_rejection = &self.input_limit_rejection,                .images = null,            },        };    }}; test "delete requests keep selection precedence and logical line ownership pure" {    const alloc = std.testing.allocator;    var fixture: Fixture = .{};    defer fixture.deinit(alloc);    try fixture.edit.setText(alloc, "alpha\nleftMIDright\ngamma");    fixture.edit.cursor = "alpha\nleftMID".len;     try std.testing.expectEqual(        DeleteRequest{            .bounds = .{                .start = "alpha\n".len,                .cursor = "alpha\nleftMID".len,                .end = "alpha\nleftMIDright".len,            },            .range = .{                .start = "alpha\n".len,                .end = "alpha\nleftMID".len,            },        },        deleteRequest(&fixture.edit, &fixture.entities, .line_start).?,    );    try std.testing.expectEqual(        DeleteRequest{            .bounds = .{                .start = "alpha\n".len,                .cursor = "alpha\nleftMID".len,                .end = "alpha\nleftMIDright".len,            },            .range = .{                .start = "alpha\nleftMID".len,                .end = "alpha\nleftMIDright".len,            },        },        deleteRequest(&fixture.edit, &fixture.entities, .line_end).?,    );     _ = fixture.edit.beginSelection(14);    _ = fixture.edit.extendSelection(8);    for (std.enums.values(DeleteKind)) |kind| {        try std.testing.expectEqual(            DeleteRequest{                .bounds = .{ .start = 8, .cursor = 14, .end = 14 },                .range = .{ .start = 8, .end = 14 },            },            deleteRequest(&fixture.edit, &fixture.entities, kind).?,        );    }} test "plain kill and yank each record one edit history delta" {    const alloc = std.testing.allocator;    var fixture: Fixture = .{};    defer fixture.deinit(alloc);    try fixture.edit.setText(alloc, "foo-bar  ");    fixture.edit.cursor = fixture.edit.input.items.len;     try std.testing.expect(try fixture.state().delete(        alloc,        .whitespace_word_left,    ));    try std.testing.expectEqualStrings("", fixture.edit.input.items);    try std.testing.expectEqualStrings("foo-bar  ", fixture.ring.text.items);    try std.testing.expectEqualStrings(        "foo-bar  ",        fixture.history.peekUndo().?.removed.items,    );     fixture.history.reset(alloc);    try std.testing.expectEqual(        kill_ring.YankResult{ .inserted = 0 },        try fixture.state().yank(alloc, 1, 4096),    );    try std.testing.expectEqualStrings("foo-bar  ", fixture.edit.input.items);    try std.testing.expectEqualStrings(        "foo-bar  ",        fixture.history.peekUndo().?.inserted.items,    );} test "structured kill and yank remain edit history boundaries" {    const alloc = std.testing.allocator;    var fixture: Fixture = .{};    defer fixture.deinit(alloc);    try fixture.edit.setText(alloc, "$rev");    try fixture.entities.bindSkillToken(        alloc,        &fixture.edit,        0,        fixture.edit.input.items.len,        "review",        "/skills/review.md",        null,    );     var before_kill = try fixture.history.prepare(alloc, 0, "", "x", 0, 1);    defer before_kill.deinit(alloc);    fixture.history.commit(alloc, &before_kill);    try std.testing.expect(try fixture.state().delete(alloc, .line_start));    try std.testing.expect(fixture.history.peekUndo() == null);     var before_yank = try fixture.history.prepare(alloc, 0, "", "x", 0, 1);    defer before_yank.deinit(alloc);    fixture.history.commit(alloc, &before_yank);    try std.testing.expectEqual(        kill_ring.YankResult{ .inserted = 0 },        try fixture.state().yank(alloc, 1, 4096),    );    try std.testing.expect(fixture.history.peekUndo() == null);} test "history allocation failure preserves composer and kill ring state" {    const alloc = std.testing.allocator;    var fixture: Fixture = .{};    defer fixture.deinit(alloc);    try fixture.edit.setText(alloc, "alpha");    fixture.edit.cursor = fixture.edit.input.items.len;    try fixture.ring.text.appendSlice(alloc, "previous kill");     var failing = std.testing.FailingAllocator.init(        alloc,        .{ .fail_index = 0 },    );    try std.testing.expectError(        error.OutOfMemory,        fixture.state().delete(failing.allocator(), .line_start),    );    try std.testing.expectEqualStrings("alpha", fixture.edit.input.items);    try std.testing.expectEqualStrings("previous kill", fixture.ring.text.items);    try std.testing.expect(fixture.history.peekUndo() == null);}