Something went wrong. Try again.
My version of https://github.com/vercel-labs/fx
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807const std = @import("std");const image_attachments = @import("../images/image_attachments.zig");const debug_trace = @import("../shared/debug_trace.zig");const entity_spans = @import("../shared/entity_spans.zig");const types = @import("../shared/types.zig");const skill_contract = @import("../skills/skill_contract.zig");const editor_state = @import("editor_state.zig");const input_limit_rejection = @import("input_limit_rejection.zig");const paste_blocks = @import("pasted_blocks.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 ImageBlocks = std.ArrayList(types.ImageAttachment);
pub const YankResult = union(enum) { inactive, limit_exceeded: usize, inserted: usize,};
pub const LineBounds = struct { start: usize, cursor: usize, end: usize,};
pub const DeleteRange = struct { start: usize, end: usize,};
pub const ActiveComposer = struct { edit: *editor_state.State, entities: *registered_entities.State, picker: *picker_state.State, vertical_navigation: *vertical_navigation.State, input_limit_rejection: *input_limit_rejection.State, images: ?*ImageBlocks,};
pub const State = struct { text: std.ArrayList(u8) = .empty, images: ImageBlocks = .empty, image_tokens: std.ArrayList(entity_spans.ImageTokenSpan) = .empty, skill_tokens: std.ArrayList(registered_entities.SkillTokenSpan) = .empty, owns_snapshots: bool = false,
pub fn deinit(self: *State, alloc: Allocator) void { self.text.deinit(alloc); for (self.images.items) |image| { if (self.owns_snapshots) { image_attachments.discardImageAttachment(alloc, image); } else { types.freeImageAttachment(alloc, image); } } self.images.deinit(alloc); self.image_tokens.deinit(alloc); for (self.skill_tokens.items) |token| { alloc.free(token.name); alloc.free(token.path); } self.skill_tokens.deinit(alloc); self.* = .{}; }
pub fn resetForSession(self: *State, alloc: Allocator) void { if (self.text.items.len > 0) { debug_trace.logf( "input", "kill ring dropped bytes={d} images={d} skills={d} reason=session_reset", .{ self.text.items.len, self.images.items.len, self.skill_tokens.items.len, }, ); } self.deinit(alloc); }
pub fn deleteRange( self: *State, alloc: Allocator, active: ActiveComposer, bounds: LineBounds, requested: DeleteRange, ) !bool { if (requested.start >= requested.end) return false; const actual = actualDeleteRange( active.entities.pasted_blocks.items, active.entities.image_tokens.items, bounds, requested, ) orelse return false;
var next = try capturePayload(alloc, active, actual); errdefer next.deinit(alloc);
active.picker.clearModelPickerFlow(); active.picker.resetFilePickerIndex(); removeBackingMetadata(alloc, active, actual); next.owns_snapshots = true; deleteInputRange(alloc, active, actual.start, actual.end); _ = active.edit.setCursor(actual.start); active.picker.reconcileInlinePickerAfterEdit(active.edit); active.picker.resetActiveCompletionIndex();
var previous = self.*; self.* = next; previous.deinit(alloc); return true; }
pub fn yank( self: *const State, alloc: Allocator, active: ActiveComposer, first_image_id: usize, max_len: usize, ) !YankResult { if (self.text.items.len == 0) return .inactive;
const replacement_len = try self.replacementLen(first_image_id); const selection = active.edit.selectionRange(); const admitted = if (selection) |range| canReplaceInputRange(active, range.start, range.end, replacement_len, max_len) else canInsertInput(active, replacement_len, max_len); if (!admitted) return .{ .limit_exceeded = replacement_len };
var prepared = try self.prepareYank(alloc, first_image_id); defer prepared.deinit(alloc); std.debug.assert(prepared.text.items.len == replacement_len);
const image_count = prepared.images.items.len; const blocks = if (image_count > 0) active.images orelse return error.MissingImageAttachmentOwner else null; try active.edit.input.ensureUnusedCapacity(alloc, prepared.text.items.len); try active.entities.image_tokens.ensureUnusedCapacity(alloc, prepared.image_tokens.items.len); try active.entities.skill_tokens.ensureUnusedCapacity(alloc, prepared.skill_tokens.items.len); if (blocks) |active_images| { try active_images.ensureUnusedCapacity(alloc, prepared.images.items.len); if (active_images.items.len > 0) { std.debug.assert(active_images.items[active_images.items.len - 1].id < first_image_id); } }
const insertion_start = if (selection) |range| range.start else active.edit.cursor; active.vertical_navigation.reset(); if (selection) |range| { removeRegisteredEntitiesOverlapping(alloc, active, range.start, range.end); deleteInputRange(alloc, active, range.start, range.end); _ = active.edit.setCursor(range.start); _ = active.edit.clearSelection(); } insertInputSliceAssumeCapacity(alloc, active, prepared.text.items); if (blocks) |active_images| { active_images.appendSliceAssumeCapacity(prepared.images.items); prepared.images.clearRetainingCapacity(); } for (prepared.image_tokens.items) |token| { active.entities.registerImageTokenAssumeCapacity(.{ .id = token.id, .span = .{ .raw_start = insertion_start + token.span.raw_start, .raw_end = insertion_start + token.span.raw_end, }, }); } for (prepared.skill_tokens.items) |token| { active.entities.registerSkillTokenAssumeCapacity(.{ .raw_start = insertion_start + token.raw_start, .raw_end = insertion_start + token.raw_end, .name = token.name, .path = token.path, .display_source = token.display_source, .owns_trailing_separator = token.owns_trailing_separator, }); } prepared.skill_tokens.clearRetainingCapacity(); active.input_limit_rejection.* = input_limit_rejection.clear(); return .{ .inserted = image_count }; }
fn replacementLen(self: *const State, first_image_id: usize) !usize { _ = try std.math.add(usize, first_image_id, self.image_tokens.items.len); var replacement_len = self.text.items.len; var previous_end: usize = 0; for (self.image_tokens.items, 0..) |token, index| { if (!token.span.isValid(self.text.items.len) or token.span.raw_start < previous_end) { return error.InvalidKillPayload; } const match = image_attachments.matchImagePlaceholder( self.text.items, token.span.raw_start, ) orelse return error.InvalidKillPayload; if (match.id != token.id or token.span.raw_start + match.length != token.span.raw_end) { return error.InvalidKillPayload; } const next_id = try std.math.add(usize, first_image_id, index); var placeholder_buf: [64]u8 = undefined; const placeholder = try image_attachments.formatImagePlaceholder( &placeholder_buf, next_id, ); replacement_len = try std.math.sub( usize, replacement_len, token.span.raw_end - token.span.raw_start, ); replacement_len = try std.math.add(usize, replacement_len, placeholder.len); previous_end = token.span.raw_end; } return replacement_len; }
fn prepareYank( self: *const State, alloc: Allocator, first_image_id: usize, ) !State { var prepared = State{ .owns_snapshots = true }; errdefer prepared.deinit(alloc);
var source_offset: usize = 0; for (self.image_tokens.items, 0..) |token, index| { if (index >= self.images.items.len or self.images.items[index].id != token.id) { return error.InvalidKillPayload; } try prepared.text.appendSlice( alloc, self.text.items[source_offset..token.span.raw_start], ); const relative_start = prepared.text.items.len; const next_id = try std.math.add(usize, first_image_id, index); var placeholder_buf: [64]u8 = undefined; const placeholder = try image_attachments.formatImagePlaceholder( &placeholder_buf, next_id, ); try prepared.text.appendSlice(alloc, placeholder); try prepared.appendYankedImage( alloc, self.images.items[index], .{ .id = next_id, .span = .{ .raw_start = relative_start, .raw_end = relative_start + placeholder.len, }, }, ); source_offset = token.span.raw_end; } if (self.images.items.len != self.image_tokens.items.len) { return error.InvalidKillPayload; } try prepared.text.appendSlice(alloc, self.text.items[source_offset..]);
for (self.skill_tokens.items) |token| { if (token.raw_start >= token.raw_end or token.raw_end > self.text.items.len) { return error.InvalidKillPayload; } const raw = self.text.items[token.raw_start..token.raw_end]; if (raw.len != token.name.len + 1 or raw[0] != '$' or !std.mem.eql(u8, raw[1..], token.name)) { return error.InvalidKillPayload; } const relative_start = image_attachments.projectOffsetThroughPlaceholderRewrite( self.image_tokens.items, prepared.image_tokens.items, self.text.items.len, token.raw_start, ) orelse return error.InvalidKillPayload; const relative_end = image_attachments.projectOffsetThroughPlaceholderRewrite( self.image_tokens.items, prepared.image_tokens.items, self.text.items.len, token.raw_end, ) orelse return error.InvalidKillPayload; try prepared.appendSkillCopy( alloc, relative_start, relative_end, token.name, token.path, token.display_source, token.owns_trailing_separator, ); } return prepared; }
fn appendImageCopy( self: *State, alloc: Allocator, attachment: types.ImageAttachment, token: entity_spans.ImageTokenSpan, ) !void { try self.image_tokens.append(alloc, token); errdefer self.image_tokens.items.len -= 1;
const path = try alloc.dupe(u8, attachment.path); errdefer alloc.free(path); const media_type = try alloc.dupe(u8, attachment.media_type); errdefer alloc.free(media_type); const snapshot_path = if (attachment.snapshot_path) |value| try alloc.dupe(u8, value) else null; errdefer if (snapshot_path) |value| alloc.free(value); const snapshot_sha256 = if (attachment.snapshot_sha256) |value| try alloc.dupe(u8, value) else null; errdefer if (snapshot_sha256) |value| alloc.free(value); try self.images.append(alloc, .{ .id = token.id, .path = path, .media_type = media_type, .snapshot_path = snapshot_path, .snapshot_sha256 = snapshot_sha256, }); }
fn appendYankedImage( self: *State, alloc: Allocator, attachment: types.ImageAttachment, token: entity_spans.ImageTokenSpan, ) !void { try self.image_tokens.append(alloc, token); errdefer self.image_tokens.items.len -= 1;
const cloned = try image_attachments.cloneVerifiedImageAttachment( alloc, attachment, token.id, ); errdefer image_attachments.discardImageAttachment(alloc, cloned); try self.images.append(alloc, cloned); }
fn appendSkillCopy( self: *State, alloc: Allocator, raw_start: usize, raw_end: usize, name: []const u8, path: []const u8, display_source: ?skill_contract.SkillSource, owns_trailing_separator: bool, ) !void { const owned_name = try alloc.dupe(u8, name); errdefer alloc.free(owned_name); const owned_path = try alloc.dupe(u8, path); errdefer alloc.free(owned_path); try self.skill_tokens.append(alloc, .{ .raw_start = raw_start, .raw_end = raw_end, .name = owned_name, .path = owned_path, .display_source = display_source, .owns_trailing_separator = owns_trailing_separator, }); }};
pub fn lineBounds(input: []const u8, cursor: usize) LineBounds { const clamped = @min(cursor, input.len); return .{ .start = text_boundaries.logicalLineStart(input, clamped), .cursor = clamped, .end = text_boundaries.logicalLineEnd(input, clamped), };}
fn actualDeleteRange( pasted: []const paste_blocks.PastedBlock, image_tokens: []const entity_spans.ImageTokenSpan, bounds: LineBounds, requested: DeleteRange,) ?DeleteRange { if (requested.start >= requested.end) return requested;
var actual = requested; while (true) { var changed = false; for (pasted) |block| { const span_range = DeleteRange{ .start = block.span.raw_start, .end = block.span.raw_end, }; if (rangesOverlap(actual, span_range) and !rangeContains(actual, span_range)) { actual.start = @min(actual.start, span_range.start); actual.end = @max(actual.end, span_range.end); if (actual.start < bounds.start or actual.end > bounds.end) return null; changed = true; } }
for (image_tokens) |token| { const span_range = DeleteRange{ .start = token.span.raw_start, .end = token.span.raw_end, }; if (rangesOverlap(actual, span_range) and !rangeContains(actual, span_range)) { actual.start = @min(actual.start, span_range.start); actual.end = @max(actual.end, span_range.end); if (actual.start < bounds.start or actual.end > bounds.end) return null; changed = true; } } if (!changed) break; } return actual;}
fn capturePayload( alloc: Allocator, active: ActiveComposer, actual: DeleteRange,) !State { var payload: State = .{}; errdefer payload.deinit(alloc);
const expanded = try paste_blocks.expandRange( alloc, active.edit.input.items, active.entities.pasted_blocks.items, actual.start, actual.end, ); defer if (expanded.owned) alloc.free(expanded.text); try payload.text.appendSlice(alloc, expanded.text);
const expanded_origin = paste_blocks.projectOffsetThroughExpansion( active.edit.input.items, active.entities.pasted_blocks.items, actual.start, ) orelse return error.InvalidKillRange; const actual_span = entity_spans.Span{ .raw_start = actual.start, .raw_end = actual.end, };
for (active.entities.image_tokens.items) |token| { if (!entity_spans.containsSpan(actual_span, token.span)) continue; if (!active.entities.isValidImageToken(active.edit.input.items, token)) continue; const blocks = active.images orelse return error.MissingImageAttachment; const attachment = image_attachments.findById(blocks.items, token.id) orelse return error.MissingImageAttachment; const relative_start = try projectKillOffset( active, actual, expanded_origin, token.span.raw_start, ); const relative_end = try projectKillOffset( active, actual, expanded_origin, token.span.raw_end, ); try payload.appendImageCopy(alloc, attachment, .{ .id = token.id, .span = .{ .raw_start = relative_start, .raw_end = relative_end, }, }); }
for (active.entities.skill_tokens.items) |token| { const span = entity_spans.Span{ .raw_start = token.raw_start, .raw_end = token.raw_end, }; if (!entity_spans.containsSpan(actual_span, span)) continue; if (!active.entities.isValidSkillToken(active.edit.input.items, token)) continue; const relative_start = try projectKillOffset( active, actual, expanded_origin, token.raw_start, ); const relative_end = try projectKillOffset( active, actual, expanded_origin, token.raw_end, ); try payload.appendSkillCopy( alloc, relative_start, relative_end, token.name, token.path, token.display_source, token.owns_trailing_separator and token.raw_end < actual.end and token.raw_end < active.edit.input.items.len and active.edit.input.items[token.raw_end] == ' ', ); } return payload;}
fn projectKillOffset( active: ActiveComposer, actual: DeleteRange, expanded_origin: usize, raw_offset: usize,) !usize { if (raw_offset < actual.start or raw_offset > actual.end) { return error.InvalidKillRange; } const projected = paste_blocks.projectOffsetThroughExpansion( active.edit.input.items, active.entities.pasted_blocks.items, raw_offset, ) orelse return error.InvalidKillRange; return std.math.sub(usize, projected, expanded_origin);}
fn removeBackingMetadata( alloc: Allocator, active: ActiveComposer, actual: DeleteRange,) void { var paste_index: usize = 0; while (paste_index < active.entities.pasted_blocks.items.len) { const block = active.entities.pasted_blocks.items[paste_index]; const span_range = DeleteRange{ .start = block.span.raw_start, .end = block.span.raw_end, }; if (span_range.start >= span_range.end or !rangeContains(actual, span_range)) { paste_index += 1; continue; } debug_trace.logf( "input", "placeholder backing dropped kind=paste id={d} backing_bytes={d} reason=atomic_line_delete", .{ block.id, block.text.len }, ); const removed = active.entities.pasted_blocks.orderedRemove(paste_index); alloc.free(removed.text); }
var image_index: usize = 0; while (image_index < active.entities.image_tokens.items.len) { const token = active.entities.image_tokens.items[image_index]; const span_range = DeleteRange{ .start = token.span.raw_start, .end = token.span.raw_end, }; if (span_range.start >= span_range.end or !rangeContains(actual, span_range)) { image_index += 1; continue; } debug_trace.logf( "input", "placeholder backing dropped kind=image id={d} reason=atomic_line_delete", .{token.id}, ); _ = active.entities.image_tokens.orderedRemove(image_index); if (active.images) |blocks| { if (imageBlockIndexById(blocks.items, token.id)) |block_index| { const removed = blocks.orderedRemove(block_index); types.freeImageAttachment(alloc, removed); } } }}
fn canInsertInput(active: ActiveComposer, inserted_len: usize, max_len: usize) bool { const current_len = paste_blocks.expandedLen( active.edit.input.items, active.entities.pasted_blocks.items, ) orelse return false; return editor_state.canInsert(current_len, inserted_len, max_len);}
fn canReplaceInputRange( active: ActiveComposer, replace_start: usize, replace_end: usize, inserted_len: usize, max_len: usize,) bool { const current_len = paste_blocks.expandedLen( active.edit.input.items, active.entities.pasted_blocks.items, ) orelse return false; const replaced_len = paste_blocks.expandedRangeLen( active.edit.input.items, active.entities.pasted_blocks.items, replace_start, replace_end, ) orelse return false; return editor_state.canReplace(current_len, replaced_len, inserted_len, max_len);}
fn deleteInputRange( alloc: Allocator, active: ActiveComposer, start: usize, end: usize,) void { if (start >= end or end > active.edit.input.items.len) return; active.entities.discardPendingAutoSeparator(); active.entities.adjustForDelete(alloc, start, end); std.debug.assert(active.edit.deleteTextRange(start, end)); active.input_limit_rejection.* = input_limit_rejection.clear();}
fn insertInputSliceAssumeCapacity( alloc: Allocator, active: ActiveComposer, bytes: []const u8,) void { if (bytes.len == 0) return; active.entities.discardPendingAutoSeparator(); const insert_start = active.edit.cursor; active.entities.convertSkillTokenContaining(alloc, insert_start); active.edit.insertSliceAssumeCapacity(bytes); active.entities.shiftForInsert(alloc, insert_start, bytes.len); active.picker.reconcileInlinePickerAfterEdit(active.edit); active.picker.resetActiveCompletionIndex();}
fn removeRegisteredEntitiesOverlapping( alloc: Allocator, active: ActiveComposer, raw_start: usize, raw_end: usize,) void { while (active.entities.entityOverlapping(raw_start, raw_end)) |entity| { if (active.entities.remove(alloc, entity)) |image_id| { if (active.images) |blocks| removeImageBlockById(alloc, blocks, image_id); } }}
fn removeImageBlockById(alloc: Allocator, blocks: *ImageBlocks, id: usize) void { var index: usize = 0; while (index < blocks.items.len) : (index += 1) { if (blocks.items[index].id == id) { const removed = blocks.orderedRemove(index); image_attachments.discardImageAttachment(alloc, removed); return; } }}
fn rangesOverlap(a: DeleteRange, b: DeleteRange) bool { return a.start < b.end and b.start < a.end;}
fn rangeContains(outer: DeleteRange, inner: DeleteRange) bool { return outer.start <= inner.start and inner.end <= outer.end;}
fn imageBlockIndexById(blocks: []const types.ImageAttachment, id: usize) ?usize { for (blocks, 0..) |image, index| { if (image.id == id) return index; } return null;}
test "state captures and yanks a logical line prefix through the active composer" { const alloc = std.testing.allocator; var state: State = .{}; defer state.deinit(alloc); var edit: editor_state.State = .{}; defer edit.deinit(alloc); try edit.setText(alloc, "alpha\nleftMIDright"); edit.cursor = "alpha\nleftMID".len; var entities: registered_entities.State = .{}; defer entities.deinit(alloc); var picker: picker_state.State = .{}; defer picker.deinit(alloc); var vertical_intent: vertical_navigation.State = .{ .preferred_column = 4 }; var limit_rejection: input_limit_rejection.State = .{ .owner = .composer }; const active = ActiveComposer{ .edit = &edit, .entities = &entities, .picker = &picker, .vertical_navigation = &vertical_intent, .input_limit_rejection = &limit_rejection, .images = null, };
const bounds = lineBounds(edit.input.items, edit.cursor); try std.testing.expect(try state.deleteRange( alloc, active, bounds, .{ .start = bounds.start, .end = bounds.cursor }, )); try std.testing.expectEqualStrings("alpha\nright", edit.input.items); try std.testing.expectEqualStrings("leftMID", state.text.items); try std.testing.expectEqual(input_limit_rejection.State{}, limit_rejection);
try std.testing.expectEqual( YankResult{ .inserted = 0 }, try state.yank(alloc, active, 1, 4096), ); try std.testing.expectEqualStrings("alpha\nleftMIDright", edit.input.items);}
test "limit rejection leaves active composer and kill state unchanged" { const alloc = std.testing.allocator; var state: State = .{}; defer state.deinit(alloc); try state.text.appendSlice(alloc, "XY"); var edit: editor_state.State = .{}; defer edit.deinit(alloc); try edit.setText(alloc, "abc"); var entities: registered_entities.State = .{}; defer entities.deinit(alloc); var picker: picker_state.State = .{}; defer picker.deinit(alloc); var vertical_intent: vertical_navigation.State = .{ .preferred_column = 2 }; var limit_rejection: input_limit_rejection.State = .{ .owner = .composer }; const active = ActiveComposer{ .edit = &edit, .entities = &entities, .picker = &picker, .vertical_navigation = &vertical_intent, .input_limit_rejection = &limit_rejection, .images = null, };
try std.testing.expectEqual( YankResult{ .limit_exceeded = 2 }, try state.yank(alloc, active, 1, 4), ); try std.testing.expectEqualStrings("abc", edit.input.items); try std.testing.expectEqualStrings("XY", state.text.items); try std.testing.expectEqual(@as(?usize, 2), vertical_intent.preferredColumn()); try std.testing.expectEqual( input_limit_rejection.State{ .owner = .composer }, limit_rejection, );}
test "line bounds assign the cursor to logical lines" { const cases = [_]struct { input: []const u8, cursor: usize, expected: LineBounds, }{ .{ .input = "", .cursor = 0, .expected = .{ .start = 0, .cursor = 0, .end = 0 } }, .{ .input = "alpha", .cursor = 99, .expected = .{ .start = 0, .cursor = 5, .end = 5 } }, .{ .input = "alpha\nbeta\ngamma", .cursor = 5, .expected = .{ .start = 0, .cursor = 5, .end = 5 } }, .{ .input = "alpha\nbeta\ngamma", .cursor = 6, .expected = .{ .start = 6, .cursor = 6, .end = 10 } }, .{ .input = "alpha\n\nbeta", .cursor = 6, .expected = .{ .start = 6, .cursor = 6, .end = 6 } }, .{ .input = "alpha\n", .cursor = 6, .expected = .{ .start = 6, .cursor = 6, .end = 6 } }, .{ .input = "\nalpha", .cursor = 0, .expected = .{ .start = 0, .cursor = 0, .end = 0 } }, .{ .input = "\nalpha", .cursor = 1, .expected = .{ .start = 1, .cursor = 1, .end = 6 } }, };
for (cases) |case| { try std.testing.expectEqual(case.expected, lineBounds(case.input, case.cursor)); }}
test "delete ranges expand registered entities and reject cross-line spans" { const blocks = [_]paste_blocks.PastedBlock{.{ .id = 7, .text = @constCast("outer"), .line_count = 1, .span = .{ .raw_start = 3, .raw_end = 12 }, }}; const images = [_]entity_spans.ImageTokenSpan{.{ .id = 8, .span = .{ .raw_start = 12, .raw_end = 20 }, }}; const expanded = actualDeleteRange( &blocks, &images, .{ .start = 0, .cursor = 11, .end = 20 }, .{ .start = 11, .end = 13 }, ).?; try std.testing.expectEqual(DeleteRange{ .start = 3, .end = 20 }, expanded);
try std.testing.expect(actualDeleteRange( &blocks, &.{}, .{ .start = 10, .cursor = 11, .end = 20 }, .{ .start = 11, .end = 12 }, ) == null);}