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); }