common/database/rope_helpers.rs
1//! Helpers for writing the global character rope from use cases.
2//!
3//! Each helper mutates `store.rope` and `store.block_offsets`
4//! together so callers can stay oblivious to the underlying layout.
5//! Read helpers (`block_content_via_store`) source content from the
6//! rope and return empty when a block is not yet registered in the
7//! offset index.
8
9use crate::database::Store;
10use crate::database::block_offset_index::OffsetMarker;
11use crate::entities::Block;
12use crate::format_runs::{
13 FormatRunError, ReplaceFormatPolicy, check_well_formed, logical_offset_to_byte,
14 shift_images_for_delete, shift_images_for_insert, shift_runs_for_replace,
15};
16use crate::types::EntityId;
17
18/// Read a block's content from the global rope via `block_offsets`,
19/// stripping the trailing `\n` boundary that `range_of` includes for
20/// non-last entries. Returns an empty string if the block isn't
21/// registered in the offset index (e.g. a freshly-created block that
22/// hasn't been spliced into the rope yet — `setup_with_text` test
23/// docs use this path).
24pub fn block_content_via_store(block: &Block, store: &Store) -> String {
25 let offsets = store.block_offsets.read();
26 let marker = OffsetMarker::Block(block.id);
27 let Some((bs, be, has_successor)) = offsets.range_with_successor(marker) else {
28 return String::new();
29 };
30 // Drop the trailing inter-block boundary `\n` ONLY when this block
31 // has a successor entry — that one byte is the boundary `\n` between
32 // this block and the next. The last entry has no trailing boundary;
33 // any final `\n` is real content.
34 let content_end = if has_successor && be > bs { be - 1 } else { be };
35 drop(offsets);
36 let rope = store.rope.read();
37 rope.byte_slice(bs as usize..content_end as usize)
38 .to_string()
39}
40
41/// Logical character count of a block — what the old
42/// `Block.text_length` field used to cache. Image anchors are stored as
43/// `\u{FFFC}` (one char, three bytes) inside the rope content, so the
44/// char count already covers them. Returns 0 for blocks not registered
45/// in the offset index.
46///
47/// O(log n) via `ropey::Rope::byte_to_char` — does NOT materialize the
48/// block's text into a String. Replaces the prior O(L) implementation
49/// that counted chars by walking UTF-8 over a cloned slice.
50pub fn block_char_length(block: &Block, store: &Store) -> i64 {
51 let offsets = store.block_offsets.read();
52 let marker = OffsetMarker::Block(block.id);
53 let Some((bs, be, has_successor)) = offsets.range_with_successor(marker) else {
54 return 0;
55 };
56 let content_end_bytes = if has_successor && be > bs { be - 1 } else { be };
57 drop(offsets);
58 let rope = store.rope.read();
59 let char_start = rope.byte_to_char(bs as usize);
60 let char_end = rope.byte_to_char(content_end_bytes as usize);
61 (char_end - char_start) as i64
62}
63
64/// Return the absolute character position of a block's start in the
65/// document, derived from the rope via `BlockOffsetIndex`.
66///
67/// O(log n): one `range_of_block` lookup + one `byte_to_char` conversion.
68/// Falls back to `block.document_position` for blocks not registered in
69/// the index — blocks under non-top-level frames (`insert_frame_uc` only
70/// mirrors top-level frames). Table-cell blocks ARE registered: they are
71/// mirrored inline into the rope in document order. The stored field is
72/// set authoritatively by the non-flow paths and stays correct for them
73/// across main-flow edits.
74pub fn block_document_position(block: &Block, store: &Store) -> i64 {
75 let offsets = store.block_offsets.read();
76 let Some((byte_start, _)) = offsets.range_of_block(block.id) else {
77 return block.document_position;
78 };
79 drop(offsets);
80 let rope = store.rope.read();
81 rope.byte_to_char(byte_start as usize) as i64
82}
83
84/// Whether the rope's char-position space matches the user-visible
85/// flow positions that `Block.document_position` is computed against.
86///
87/// They match when every block is mirrored to the rope as a contiguous
88/// run. They DON'T match when:
89/// 1. The document contains tables — cell content sits at separate rope
90/// byte ranges (plan §1.6), so the rope is missing the cells'
91/// flow-position contribution.
92/// 2. The document contains sub-frames whose blocks aren't mirrored —
93/// `insert_frame_uc` only mirrors top-level frames.
94///
95/// When this returns `true`, readers can derive `document_position`
96/// directly from the rope (O(log n)) and the use-case-side
97/// position-refresh loops can be skipped. When it returns `false`,
98/// readers must consult the maintained `Block.document_position`
99/// stored field, and the loops are required to keep it correct.
100pub fn rope_positions_match_flow(store: &Store) -> bool {
101 let offsets = store.block_offsets.read();
102 // The rope is authoritative as long as EVERY block is mirrored into it.
103 // Tables no longer disqualify it: cell content is mirrored inline, in
104 // document order, and the table itself occupies a 1-char anchor sentinel
105 // — so the rope's char space matches the user-visible flow order. (The
106 // flow snapshot derives its block positions from this same rope space,
107 // so the two agree by construction.) Only count Block markers; the
108 // TableAnchor sentinel entries are not blocks.
109 let indexed_block_count = offsets.entries.iter().filter(|(m, _)| m.is_block()).count();
110 drop(offsets);
111 let total_block_count = store.blocks.read().len();
112 indexed_block_count == total_block_count
113}
114
115/// Locate which block contains a given absolute char position in the
116/// document, returning `(block_id, char_offset_in_block, block_char_start)`
117/// in O(log n) using the rope + `BlockOffsetIndex` instead of an O(N)
118/// linear walk of all blocks.
119///
120/// Replaces the per-keystroke hot path in editing use cases
121/// (`find_block_at_position_sequential`) which fetched every block
122/// + called `block_char_length` per block. For an N-block document
123/// each editor keystroke now costs O(log n) lookups instead of O(N).
124///
125/// Returns `None` only when some block is unmirrored (a sub-frame
126/// inserted with a parent — `insert_frame_uc` mirrors only top-level
127/// frames), where the rope's char space diverges from flow order and
128/// callers must fall back to the slow per-block walk. Tables are fine:
129/// cell content is mirrored inline in document order and each table is a
130/// 1-char anchor sentinel, so byte→block lookup resolves correctly.
131///
132/// `position` past the document end clamps to the last block's
133/// end-of-content.
134pub fn find_block_at_char_position(store: &Store, position: i64) -> Option<(EntityId, i64, i64)> {
135 // Fast path is only valid when EVERY block in the document is
136 // mirrored to the rope. Disqualifying cases:
137 //
138 // 1. Documents containing tables — cell content sits at separate
139 // rope byte ranges (plan §1.6) so byte→block lookup finds the
140 // wrong block for cursor positions inside cells.
141 //
142 // 2. Documents containing sub-frames inserted with a parent —
143 // `insert_frame_uc` currently only mirrors top-level frames to
144 // the rope (parent=None case), leaving sub-frame blocks
145 // unregistered in the offset index. Detect by comparing block
146 // counts: if the rope index has fewer Block markers than the
147 // store has Block entities, some are unmirrored.
148 let offsets = store.block_offsets.read();
149 // Valid as long as every block is mirrored to the rope. Tables are fine:
150 // cell content is mirrored inline in document order and the table is a
151 // 1-char anchor sentinel, so byte→block lookup resolves correctly for any
152 // non-sentinel position. A position that lands exactly on the sentinel
153 // resolves to a TableAnchor marker, where `as_block()` returns None and
154 // the caller falls back to the slow walk. Only count Block markers.
155 let indexed_block_count = offsets.entries.iter().filter(|(m, _)| m.is_block()).count();
156 let total_block_count = store.blocks.read().len();
157 if indexed_block_count != total_block_count {
158 return None;
159 }
160 drop(offsets);
161
162 let rope = store.rope.read();
163 let total_chars = rope.len_chars() as i64;
164 let pos_clamped = position.clamp(0, total_chars);
165 let abs_byte = rope.char_to_byte(pos_clamped as usize);
166 drop(rope);
167
168 let offsets = store.block_offsets.read();
169 let block_id = offsets.marker_at_byte(abs_byte as u32)?.as_block()?;
170 let (bs, be, has_successor) = offsets.range_with_successor(OffsetMarker::Block(block_id))?;
171 let content_end = if has_successor && be > bs { be - 1 } else { be };
172
173 drop(offsets);
174
175 let rope = store.rope.read();
176 let block_char_start = rope.byte_to_char(bs as usize) as i64;
177 // Clamp the cursor's byte to the block's content area (not into the
178 // trailing `\n` boundary if any).
179 let byte_for_char = std::cmp::min(abs_byte, content_end as usize);
180 let abs_char = rope.byte_to_char(byte_for_char) as i64;
181 let char_in_block = abs_char - block_char_start;
182
183 // NOTE: separator semantics differ across use cases. Callers like
184 // `get_block_at_position_uc` interpret "position at the end of a
185 // non-empty block with a successor" as "belongs to the next
186 // block"; callers like `insert_text_uc` want the previous block
187 // with offset == block_char_len. This helper returns the
188 // previous-block answer (the simpler semantic); use-case-specific
189 // advance logic lives at the call site.
190 let _ = has_successor;
191
192 Some((block_id, char_in_block, block_char_start))
193}
194
195/// Convert an in-block char offset to an in-block byte offset using the
196/// rope's index. Both inputs and outputs are relative to the start of
197/// the block's content (NOT absolute rope positions). The char offset
198/// is clamped to the block's logical length so callers don't need to
199/// pre-validate.
200///
201/// O(log n) via `ropey::Rope::char_to_byte` — replaces the O(L)
202/// "materialize block text + walk char_indices" pattern that
203/// `set_text_format_uc` / `merge_text_format_uc` used. Returns
204/// `(byte_offset_in_block, content_byte_len)` so callers can also
205/// pass the content length to `debug_assert_well_formed` without
206/// materializing the text.
207///
208/// Returns `(0, 0)` for blocks not registered in the offset index.
209pub fn block_char_to_byte_in_block(
210 store: &Store,
211 block_id: EntityId,
212 char_offset: usize,
213) -> (u32, usize) {
214 let offsets = store.block_offsets.read();
215 let marker = OffsetMarker::Block(block_id);
216 let Some((bs, be, has_successor)) = offsets.range_with_successor(marker) else {
217 return (0, 0);
218 };
219 let content_end_bytes = if has_successor && be > bs { be - 1 } else { be };
220 let content_byte_len = (content_end_bytes - bs) as usize;
221 drop(offsets);
222
223 let rope = store.rope.read();
224 let block_char_start = rope.byte_to_char(bs as usize);
225 let block_char_end = rope.byte_to_char(content_end_bytes as usize);
226 let block_char_len = block_char_end - block_char_start;
227
228 // Clamp char_offset to block's char length.
229 let clamped = std::cmp::min(char_offset, block_char_len);
230 let abs_byte = rope.char_to_byte(block_char_start + clamped);
231 let byte_in_block = (abs_byte - bs as usize) as u32;
232 (byte_in_block, content_byte_len)
233}
234
235/// Fast-path full-document plain text: returns `Some(rope.to_string())`
236/// iff the document is in the canonical flat layout — no table anchors
237/// in the offset index, single top-level frame. In that case the
238/// rope's byte order is the same as the document-flow order, so one
239/// `to_string()` allocation replaces the O(N) per-block walk +
240/// per-block `Cow<str>` materialization that `build_full_text` /
241/// `export_plain_text` would otherwise do.
242///
243/// Returns `None` for documents containing tables or nested frames —
244/// those require the per-frame, per-block traversal because table
245/// cell content lives in separate byte ranges later in the rope
246/// (plan §1.6), not interleaved with the parent frame's bytes.
247///
248/// `top_frame_count` is the caller-known number of top-level frames
249/// (typically obtained from `Document.frames.len()`). Callers
250/// already have this value and pass it in to avoid a redundant uow
251/// query.
252pub fn rope_flat_text_if_simple(store: &Store, top_frame_count: usize) -> Option<String> {
253 if top_frame_count != 1 {
254 return None;
255 }
256 let offsets = store.block_offsets.read();
257 let has_table = offsets
258 .entries
259 .iter()
260 .any(|(m, _)| matches!(m, OffsetMarker::TableAnchor(_)));
261 if has_table {
262 return None;
263 }
264 drop(offsets);
265 Some(store.rope.read().to_string())
266}
267
268/// Whole-document searchable text straight from the rope, valid whenever
269/// the rope's char-position space matches the user-visible flow order
270/// (`rope_positions_match_flow`).
271///
272/// Unlike `rope_flat_text_if_simple` this does NOT bail on tables or
273/// multiple top-level frames: table-cell content is mirrored inline into
274/// the rope in document order and each table occupies a 1-char anchor
275/// sentinel, so the rope already contains all searchable text — including
276/// cell text — at the same char offsets that match positions are reported
277/// in. Returns `None` only when some block is unmirrored (e.g. a sub-frame
278/// inserted with a parent), where the caller must fall back to the
279/// per-frame, per-block traversal.
280pub fn rope_full_text_if_flow_matches(store: &Store) -> Option<String> {
281 rope_positions_match_flow(store).then(|| store.rope.read().to_string())
282}
283
284/// Reset the rope to empty and clear `block_offsets`. Called by
285/// importers when they replace the entire document content.
286pub fn rope_reset(store: &Store) {
287 *store.rope.write() = ropey::Rope::new();
288 *store.block_offsets.write() = crate::database::block_offset_index::BlockOffsetIndex::new();
289}
290
291/// Append `text` to the end of the rope and register `block_id` at
292/// the byte position where the text starts. Returns that byte offset.
293///
294/// Callers are responsible for inserting an inter-block `\n`
295/// (`rope_insert_block_boundary`) before each block AFTER the first
296/// in a contiguous frame.
297pub fn rope_append_block(store: &Store, block_id: EntityId, text: &str) -> u32 {
298 let mut rope = store.rope.write();
299 let byte_start = rope.len_bytes() as u32;
300 let char_end = rope.len_chars();
301 rope.insert(char_end, text);
302 let new_total = rope.len_bytes() as u32;
303 drop(rope);
304
305 let mut offsets = store.block_offsets.write();
306 offsets.push_block(block_id, byte_start);
307 offsets.set_total_bytes(new_total);
308 byte_start
309}
310
311/// Insert `text` as a new block at `byte_pos` in the rope, prepending
312/// a `\n` boundary. Used by `insert_table_uc` to place cell blocks at
313/// the end of their containing top-level frame's range (plan §1.6),
314/// rather than always at rope end.
315///
316/// Total bytes inserted: `1 + text.len()`. The block's content
317/// occupies `[byte_pos + 1, byte_pos + 1 + text.len())`. The block
318/// entry is registered at `byte_pos + 1` in `block_offsets`.
319///
320/// Existing entries with `byte_start == byte_pos` (e.g. a previous
321/// empty block whose end coincides with this insertion point) are
322/// kept BEFORE the new entry in the Vec, since the inserted `\n`
323/// boundary belongs after them. Entries strictly past `byte_pos`
324/// shift forward by `(1 + text.len())` bytes.
325///
326/// When `byte_pos == total_bytes`, behaves like
327/// `rope_insert_block_boundary` followed by `rope_append_block`.
328pub fn rope_insert_block_at(store: &Store, byte_pos: u32, block_id: EntityId, text: &str) {
329 let delta = (1 + text.len()) as i32;
330 // Vec position: insert AFTER any entry at byte_pos itself
331 // (those represent earlier empty blocks whose `\n` boundary
332 // we are placing now). Only entries strictly past byte_pos
333 // come after our new entry in the Vec.
334 let new_entry_vec_pos = {
335 let offsets = store.block_offsets.read();
336 offsets
337 .entries
338 .iter()
339 .position(|(_, bs)| *bs > byte_pos)
340 .unwrap_or(offsets.entries.len())
341 };
342 {
343 let mut rope = store.rope.write();
344 let char_idx = rope.byte_to_char(byte_pos as usize);
345 let mut combined = String::with_capacity(1 + text.len());
346 combined.push('\n');
347 combined.push_str(text);
348 rope.insert(char_idx, &combined);
349 }
350 let mut offsets = store.block_offsets.write();
351 // Shift entries strictly past byte_pos. Entries AT byte_pos
352 // (the prior empty block) stay where they are — the new `\n`
353 // is conceptually "after" them.
354 offsets.shift_after(byte_pos + 1, delta);
355 offsets.insert_at(
356 new_entry_vec_pos,
357 OffsetMarker::Block(block_id),
358 byte_pos + 1,
359 );
360}
361
362/// Walks up `frame.parent_frame` to find the top-level ancestor of
363/// the given frame, then returns the end byte of that top-level
364/// frame's current rope range — i.e. the byte position where blocks
365/// belonging to that frame's subtree (e.g. table cells per plan §1.6)
366/// should be inserted so they land BEFORE any following top-level
367/// frame's content.
368///
369/// Reads `block_offsets`/`frames`/`tables`/`table_cells` directly, so
370/// the result is fresh even when `Frame.byte_range` has not yet been
371/// recomputed at commit time.
372pub fn top_level_frame_end_byte(store: &Store, frame_id: EntityId) -> u32 {
373 let top_id = {
374 let frames = store.frames.read();
375 let mut current = frame_id;
376 loop {
377 let Some(f) = frames.get(¤t) else {
378 return 0;
379 };
380 match f.parent_frame {
381 None => break current,
382 Some(p) => current = p,
383 }
384 }
385 };
386 let (_min, max) = compute_frame_byte_range_recursive(store, top_id);
387 max
388}
389
390/// Append a new empty block to the end of the rope, separating it
391/// from any prior content with a `\n` boundary (only if the rope is
392/// already non-empty). Registers `block_id` at the resulting byte
393/// position. Returns that byte position. Used when `insert_frame_uc`
394/// creates a new top-level frame with a single empty block.
395pub fn rope_append_empty_block(store: &Store, block_id: EntityId) -> u32 {
396 let was_empty = store.rope.read().len_bytes() == 0;
397 if !was_empty {
398 rope_insert_block_boundary(store);
399 }
400 let pos = store.rope.read().len_bytes() as u32;
401 let mut offsets = store.block_offsets.write();
402 offsets.push_block(block_id, pos);
403 offsets.set_total_bytes(pos);
404 pos
405}
406
407/// Append a single `\n` inter-block boundary character to the end of
408/// the rope. Does NOT register a block — this is the sentinel between
409/// two adjacent blocks within the same frame (plan §1.4).
410pub fn rope_insert_block_boundary(store: &Store) {
411 let mut rope = store.rope.write();
412 let char_end = rope.len_chars();
413 rope.insert(char_end, "\n");
414 let new_total = rope.len_bytes() as u32;
415 drop(rope);
416
417 store.block_offsets.write().set_total_bytes(new_total);
418}
419
420/// Insert `text` at `byte_offset_in_block` inside the block identified
421/// by `block_id`. Looks up the block's start in the rope via
422/// `block_offsets.range_of()`, splices into the rope, and shifts
423/// subsequent block offsets by the inserted byte length.
424///
425/// Silently no-ops if the block is not registered in the offset index
426/// (this can happen for blocks whose content lives outside the global
427/// rope, e.g. table cells until step 5.5).
428pub fn rope_insert_in_block(
429 store: &Store,
430 block_id: EntityId,
431 byte_offset_in_block: u32,
432 text: &str,
433) {
434 let inserted_bytes = text.len() as u32;
435 if inserted_bytes == 0 {
436 return;
437 }
438 let block_byte_start = {
439 let offsets = store.block_offsets.read();
440 let Some((start, _end)) = offsets.range_of_block(block_id) else {
441 return;
442 };
443 start
444 };
445 let rope_byte = block_byte_start + byte_offset_in_block;
446 {
447 let mut rope = store.rope.write();
448 let char_idx = rope.byte_to_char(rope_byte as usize);
449 rope.insert(char_idx, text);
450 }
451 // Shift entries past this block by inserted_bytes. Threshold
452 // is one byte past block_byte_start so the current block's own
453 // entry isn't moved.
454 store
455 .block_offsets
456 .write()
457 .shift_after(block_byte_start + 1, inserted_bytes as i32);
458}
459
460/// Split an existing block in the rope at `byte_offset_in_block`:
461/// - inserts a `\n` inter-block boundary at the absolute byte position
462/// `block_start + byte_offset_in_block` in the rope
463/// - shifts entries past that position by +1 byte
464/// - inserts a new entry for `new_block_id` at
465/// `block_start + byte_offset_in_block + 1` (right after the newline),
466/// placed immediately after the original block in the entries Vec
467///
468/// `byte_offset_in_block` may be 0 (split before first char of block,
469/// i.e. insert empty block before this one) or equal to the block's
470/// byte length (split after last char, i.e. insert empty block after).
471pub fn rope_split_block(
472 store: &Store,
473 current_block_id: EntityId,
474 byte_offset_in_block: u32,
475 new_block_id: EntityId,
476) {
477 let current_marker = OffsetMarker::Block(current_block_id);
478 let (block_start, current_idx) = {
479 let offsets = store.block_offsets.read();
480 let Some((start, _end)) = offsets.range_of(current_marker) else {
481 return;
482 };
483 let idx = offsets
484 .entries
485 .iter()
486 .position(|(m, _)| *m == current_marker)
487 .unwrap();
488 (start, idx)
489 };
490 let split_byte = block_start + byte_offset_in_block;
491
492 // 1. Insert the `\n` boundary at the split point.
493 {
494 let mut rope = store.rope.write();
495 let char_idx = rope.byte_to_char(split_byte as usize);
496 rope.insert(char_idx, "\n");
497 }
498
499 // 2. Shift entries past the split (and total_bytes) by +1.
500 // Threshold > split_byte so the new entry we insert next
501 // isn't double-shifted.
502 store.block_offsets.write().shift_after(split_byte + 1, 1);
503
504 // 3. Register the new block at `split_byte + 1`, immediately
505 // after the original in the entries Vec.
506 store.block_offsets.write().insert_at(
507 current_idx + 1,
508 OffsetMarker::Block(new_block_id),
509 split_byte + 1,
510 );
511}
512
513/// Merge `start_block` and `end_block` by deleting the rope range
514/// `[start_block.start + byte_so .. end_block.start + byte_eo)` — i.e.
515/// the suffix of `start_block`, every block between (and their
516/// boundary newlines), and the prefix of `end_block`. Removes the
517/// index entries for every block strictly between `start_block` and
518/// `end_block` (inclusive of `end_block` itself); the surviving
519/// content lives in `start_block`. Shifts any blocks past `end_block`
520/// by the negative delta.
521///
522/// No-op if `start_block` is not in the index. Skipped for any
523/// intermediate block id whose range is missing from the index (e.g.
524/// table cells until step 5.5e).
525pub fn rope_merge_block_range(
526 store: &Store,
527 start_block_id: EntityId,
528 byte_so_in_start: u32,
529 end_block_id: EntityId,
530 byte_eo_in_end: u32,
531) {
532 let start_marker = OffsetMarker::Block(start_block_id);
533 let end_marker = OffsetMarker::Block(end_block_id);
534 let (start_block_byte, end_block_byte, start_idx, end_idx) = {
535 let offsets = store.block_offsets.read();
536 let Some((sb, _)) = offsets.range_of(start_marker) else {
537 return;
538 };
539 let Some((eb, _)) = offsets.range_of(end_marker) else {
540 return;
541 };
542 let si = offsets
543 .entries
544 .iter()
545 .position(|(m, _)| *m == start_marker)
546 .unwrap();
547 let ei = offsets
548 .entries
549 .iter()
550 .position(|(m, _)| *m == end_marker)
551 .unwrap();
552 (sb, eb, si, ei)
553 };
554 if end_idx <= start_idx {
555 return;
556 }
557
558 let delete_start = start_block_byte + byte_so_in_start;
559 let delete_end = end_block_byte + byte_eo_in_end;
560 if delete_end <= delete_start {
561 return;
562 }
563 let deleted_bytes = delete_end - delete_start;
564
565 // 1. Remove the rope range.
566 {
567 let mut rope = store.rope.write();
568 let char_start = rope.byte_to_char(delete_start as usize);
569 let char_end = rope.byte_to_char(delete_end as usize);
570 rope.remove(char_start..char_end);
571 }
572
573 // 2. Remove block_offsets entries for [start_idx+1 ..= end_idx].
574 {
575 let mut offsets = store.block_offsets.write();
576 offsets.drain_inclusive(start_idx + 1, end_idx);
577 }
578
579 // 3. Shift any remaining entries past the deletion by -deleted_bytes.
580 // Threshold > delete_start because start_block's own entry
581 // sits at delete_start - byte_so_in_start (≤ delete_start)
582 // and must not move.
583 store
584 .block_offsets
585 .write()
586 .shift_after(delete_start + 1, -(deleted_bytes as i32));
587}
588
589/// Insert a U+FFFC OBJECT REPLACEMENT CHARACTER sentinel in the rope
590/// at the table-anchor position, registering a `TableAnchor(table_id)`
591/// marker in the offset index (plan §1.6).
592///
593/// `target_block_id` is the block in the parent frame that the table
594/// is adjacent to. `after` controls whether the table goes BEFORE
595/// (`after = false`) or AFTER the target block.
596///
597/// The 3-byte sentinel is paired with an inter-marker `\n`:
598/// - `after = false`: inserts `\u{FFFC}\n` at `target.byte_start`
599/// - `after = true`, target is NOT the last entry: inserts
600/// `\u{FFFC}\n` at `target.byte_end` (between target's trailing
601/// `\n` and the next entry)
602/// - `after = true`, target IS the last entry: inserts `\n\u{FFFC}`
603/// at `target.byte_end` (rope now ends with the sentinel)
604///
605/// NOTE: cell-internal content is not yet routed through the rope —
606/// the rope reflects table *presence* (3-byte sentinel) only.
607/// Routing cell content is deferred to plan §1.6's `Frame.byte_range`
608/// model.
609///
610/// No-op if `target_block_id` is not in the index.
611pub fn rope_insert_table_anchor(
612 store: &Store,
613 table_id: EntityId,
614 target_block_id: EntityId,
615 after: bool,
616) {
617 const SENTINEL: &str = "\u{FFFC}"; // 3 bytes
618 const SENTINEL_BYTES: u32 = 3;
619
620 let (insert_pos, target_idx, target_is_last) = {
621 let offsets = store.block_offsets.read();
622 let target_marker = OffsetMarker::Block(target_block_id);
623 let Some((start, end)) = offsets.range_of(target_marker) else {
624 return;
625 };
626 let idx = offsets
627 .entries
628 .iter()
629 .position(|(m, _)| *m == target_marker)
630 .unwrap();
631 let is_last = idx + 1 == offsets.entries.len();
632 let pos = if after { end } else { start };
633 (pos, idx, is_last)
634 };
635
636 // Insertion strategy:
637 let (rope_inserted, marker_byte_start, new_entry_pos, shift_threshold, shift_delta) = if !after
638 {
639 // Before target: "\u{FFFC}\n" at target.byte_start
640 ("\u{FFFC}\n", insert_pos, target_idx, insert_pos, 4i32)
641 } else if !target_is_last {
642 // After target, with following entries: "\u{FFFC}\n"
643 // at target.byte_end. The TableAnchor sits where the
644 // next block USED to start; that following entry
645 // shifts by 4.
646 ("\u{FFFC}\n", insert_pos, target_idx + 1, insert_pos, 4i32)
647 } else {
648 // After target which is last: "\n\u{FFFC}" appended.
649 // TableAnchor's byte_start sits 1 past the original
650 // total (after the new `\n`).
651 (
652 "\n\u{FFFC}",
653 insert_pos + 1,
654 target_idx + 1,
655 insert_pos,
656 4i32,
657 )
658 };
659
660 // 1. Splice the literal bytes into the rope.
661 {
662 let mut rope = store.rope.write();
663 let char_idx = rope.byte_to_char(insert_pos as usize);
664 rope.insert(char_idx, rope_inserted);
665 }
666
667 // 2. Shift entries past the insertion point. Use shift_after
668 // BEFORE inserting our new entry so we don't double-shift.
669 store
670 .block_offsets
671 .write()
672 .shift_after(shift_threshold, shift_delta);
673
674 // 3. Register the TableAnchor at the resolved byte position
675 // and the resolved Vec position.
676 store.block_offsets.write().insert_at(
677 new_entry_pos,
678 OffsetMarker::TableAnchor(table_id),
679 marker_byte_start,
680 );
681
682 // Note: SENTINEL_BYTES is part of `shift_delta` (3 for the
683 // sentinel + 1 for the `\n`).
684 let _ = SENTINEL;
685 let _ = SENTINEL_BYTES;
686}
687
688/// Append a U+FFFC table-anchor sentinel at the end of the rope and
689/// register a `TableAnchor(table_id)` marker.
690///
691/// Used by import paths (`import_djot_uc`, `import_html_uc`,
692/// `import_markdown_uc`) that process the document linearly and append
693/// entities as they encounter them, rather than inserting relative to
694/// an existing target block.
695///
696/// # `needs_boundary`
697///
698/// True when something has already been emitted, so the sentinel needs a
699/// `\n` in front of it rather than running into the previous entry.
700///
701/// It is the **caller's** flag, not `rope.len_bytes() == 0`, and the
702/// difference is not academic. Those two answers diverge on exactly one
703/// document: one whose first block is *empty* — an empty code fence, say.
704/// The rope is then still zero bytes long even though a block has been
705/// emitted, so an emptiness check skips the boundary that block is owed.
706/// Its `block_offsets` entry and the anchor's then both point at byte 0:
707/// two entities claiming one offset, in the offset index every edit
708/// resolves through.
709///
710/// Every other element gets its boundary from the importer's own
711/// positional flag (`rope_insert_block_boundary` after the first block).
712/// Deriving the same fact a second way, from the rope's byte length, is
713/// what let the two disagree.
714pub fn rope_append_table_anchor(store: &Store, table_id: EntityId, needs_boundary: bool) {
715 let (anchor_byte_start, new_total) = {
716 let mut rope = store.rope.write();
717 let char_end = rope.len_chars();
718 let to_insert = if needs_boundary {
719 "\n\u{FFFC}"
720 } else {
721 "\u{FFFC}"
722 };
723 rope.insert(char_end, to_insert);
724 let new_total = rope.len_bytes() as u32;
725 // Sentinel is 3 bytes; if a `\n` was prepended that's 1 byte
726 // before the sentinel.
727 let anchor_byte_start = new_total - 3;
728 (anchor_byte_start, new_total)
729 };
730
731 let mut offsets = store.block_offsets.write();
732 offsets.push(OffsetMarker::TableAnchor(table_id), anchor_byte_start);
733 offsets.set_total_bytes(new_total);
734}
735
736/// Remove a TableAnchor sentinel from the rope, undoing the effect
737/// of `rope_insert_table_anchor`. Looks up the anchor's byte range
738/// (always 3 bytes for the U+FFFC plus 1 byte of inter-marker `\n`
739/// either before or after, depending on what's adjacent), removes
740/// those 4 bytes from the rope, drops the entry, shifts trailing
741/// entries by -4.
742///
743/// No-op if no TableAnchor for `table_id` exists.
744pub fn rope_remove_table_anchor(store: &Store, table_id: EntityId) {
745 let anchor_marker = OffsetMarker::TableAnchor(table_id);
746 let (anchor_byte_start, anchor_idx, anchor_is_last, has_predecessor) = {
747 let offsets = store.block_offsets.read();
748 let Some((start, _end)) = offsets.range_of(anchor_marker) else {
749 return;
750 };
751 let idx = offsets
752 .entries
753 .iter()
754 .position(|(m, _)| *m == anchor_marker)
755 .unwrap();
756 let is_last = idx + 1 == offsets.entries.len();
757 let has_pred = idx > 0;
758 (start, idx, is_last, has_pred)
759 };
760
761 // Symmetric to insert_table_anchor. The 4 bytes to remove are:
762 // - if anchor is last: [byte_start - 1 .. byte_start + 3) — the
763 // preceding `\n` + the 3-byte sentinel
764 // - otherwise: [byte_start .. byte_start + 4) — the sentinel
765 // + the following `\n`
766 let (remove_start, remove_end) = if anchor_is_last && has_predecessor {
767 (anchor_byte_start - 1, anchor_byte_start + 3)
768 } else {
769 (anchor_byte_start, anchor_byte_start + 4)
770 };
771
772 {
773 let mut rope = store.rope.write();
774 let char_start = rope.byte_to_char(remove_start as usize);
775 let char_end = rope.byte_to_char(remove_end as usize);
776 rope.remove(char_start..char_end);
777 }
778 {
779 let mut offsets = store.block_offsets.write();
780 offsets.remove_at(anchor_idx);
781 }
782 store.block_offsets.write().shift_after(remove_start, -4);
783}
784
785/// Remove a registered block from the rope: drops its content bytes
786/// plus one boundary `\n` (the one after, if the block has a
787/// successor; the one before, if it's the last entry), removes the
788/// entry from the index, and shifts trailing entries by the negative
789/// byte delta.
790///
791/// No-op if `block_id` is not in the index. No-op for the special
792/// case of a single-block document being asked to remove its sole
793/// block (we'd produce an empty rope but the block itself is being
794/// cascaded by the caller).
795pub fn rope_remove_block(store: &Store, block_id: EntityId) {
796 let block_marker = OffsetMarker::Block(block_id);
797 let (block_start, block_end, idx, is_last, has_pred) = {
798 let offsets = store.block_offsets.read();
799 let Some((start, end)) = offsets.range_of(block_marker) else {
800 return;
801 };
802 let idx = offsets
803 .entries
804 .iter()
805 .position(|(m, _)| *m == block_marker)
806 .unwrap();
807 let is_last = idx + 1 == offsets.entries.len();
808 let has_pred = idx > 0;
809 (start, end, idx, is_last, has_pred)
810 };
811
812 // Determine the byte range to delete:
813 // - if there's a successor: [block_start..block_end) — the
814 // block's content INCLUDING its trailing boundary `\n`
815 // (which is the byte at block_end - 1)
816 // - if last and has predecessor: [block_start - 1..block_end)
817 // — also delete the LEADING boundary `\n` that the previous
818 // entry placed before us
819 // - if last and no predecessor (sole entry): just delete
820 // [block_start..block_end) (no boundary `\n` exists)
821 let (remove_start, remove_end) = if is_last && has_pred {
822 (block_start.saturating_sub(1), block_end)
823 } else {
824 (block_start, block_end)
825 };
826 if remove_end <= remove_start {
827 // Drop the entry only; nothing to remove from the rope.
828 store.block_offsets.write().remove_at(idx);
829 return;
830 }
831 let deleted_bytes = remove_end - remove_start;
832
833 {
834 let mut rope = store.rope.write();
835 let char_start = rope.byte_to_char(remove_start as usize);
836 let char_end = rope.byte_to_char(remove_end as usize);
837 rope.remove(char_start..char_end);
838 }
839 store.block_offsets.write().remove_at(idx);
840 // Shift entries STRICTLY PAST the removed range. Using `remove_end` as
841 // the threshold (rather than `remove_start`) keeps an empty predecessor
842 // whose byte_start equals `remove_start` (the leading boundary `\n` we
843 // just deleted) in place — its content position is unchanged, only its
844 // trailing boundary is gone. `total_bytes` decreases by `deleted_bytes`
845 // regardless of threshold.
846 store
847 .block_offsets
848 .write()
849 .shift_after(remove_end, -(deleted_bytes as i32));
850}
851
852/// Replace the entire content of a registered block in the rope with
853/// `new_text`. Preserves the block's `byte_start` and its trailing
854/// boundary `\n` (if any); subsequent entries shift by the net
855/// length delta.
856///
857/// Used by use cases that compute a block's final content as a string
858/// and want to push that content to the rope in one shot — e.g. the
859/// block-splitting branches of `insert_html_at_position_uc` and
860/// `insert_markdown_at_position_uc`, where each affected block
861/// (head, tail, mid-replacement) gets a single new value.
862///
863/// No-op if `block_id` is not in the index.
864pub fn rope_replace_block_content(store: &Store, block_id: EntityId, new_text: &str) {
865 let (block_byte_start, content_bytes) = {
866 let offsets = store.block_offsets.read();
867 let Some((start, end)) = offsets.range_of_block(block_id) else {
868 return;
869 };
870 let total = offsets.total_bytes();
871 // `range_of` extends to the next entry's `byte_start` (or to
872 // `total_bytes`). If there's a following entry, the byte at
873 // `end - 1` is the inter-block boundary `\n` that belongs to
874 // the boundary between this block and the next, not to this
875 // block's content.
876 let has_trailing_boundary = end < total;
877 let content_bytes = if has_trailing_boundary {
878 end - start - 1
879 } else {
880 end - start
881 };
882 (start, content_bytes)
883 };
884
885 let new_bytes = new_text.len() as u32;
886 if content_bytes == 0 && new_bytes == 0 {
887 return;
888 }
889 let delta = new_bytes as i32 - content_bytes as i32;
890
891 // Splice [block_byte_start..block_byte_start + content_bytes)
892 // with `new_text`.
893 {
894 let mut rope = store.rope.write();
895 let char_start = rope.byte_to_char(block_byte_start as usize);
896 if content_bytes > 0 {
897 let char_end = rope.byte_to_char((block_byte_start + content_bytes) as usize);
898 rope.remove(char_start..char_end);
899 }
900 if new_bytes > 0 {
901 rope.insert(char_start, new_text);
902 }
903 }
904
905 if delta != 0 {
906 // Shift entries that sit strictly past this block's start
907 // (i.e. the trailing boundary and everything after).
908 store
909 .block_offsets
910 .write()
911 .shift_after(block_byte_start + 1, delta);
912 }
913}
914
915/// Delete bytes `[byte_start_in_block..byte_end_in_block)` from inside
916/// the block identified by `block_id`. Shifts subsequent block offsets
917/// by the deleted byte length. No-op for blocks not in the index.
918pub fn rope_delete_in_block(
919 store: &Store,
920 block_id: EntityId,
921 byte_start_in_block: u32,
922 byte_end_in_block: u32,
923) {
924 if byte_end_in_block <= byte_start_in_block {
925 return;
926 }
927 let deleted_bytes = byte_end_in_block - byte_start_in_block;
928 let block_byte_start = {
929 let offsets = store.block_offsets.read();
930 let Some((start, _end)) = offsets.range_of_block(block_id) else {
931 return;
932 };
933 start
934 };
935 let rope_byte_start = block_byte_start + byte_start_in_block;
936 let rope_byte_end = block_byte_start + byte_end_in_block;
937 {
938 let mut rope = store.rope.write();
939 let char_start = rope.byte_to_char(rope_byte_start as usize);
940 let char_end = rope.byte_to_char(rope_byte_end as usize);
941 rope.remove(char_start..char_end);
942 }
943 store
944 .block_offsets
945 .write()
946 .shift_after(block_byte_start + 1, -(deleted_bytes as i32));
947}
948
949/// Recompute `Frame.byte_range` for every frame in `store.frames`
950/// based on current `block_offsets` and the frame tree structure.
951/// Plan §1.6 invariant: each frame's byte_range is the (min_start,
952/// max_end) over all its descendant blocks, sub-frames, and table
953/// anchors+cells.
954///
955/// Call this after any mutation that affects rope byte positions.
956/// O(F + B) where F = frames, B = blocks in the document.
957pub fn recompute_all_frame_byte_ranges(store: &Store) {
958 let frame_ids: Vec<EntityId> = {
959 let frames = store.frames.read();
960 frames.keys().copied().collect()
961 };
962 for fid in frame_ids {
963 let new_range = compute_frame_byte_range_recursive(store, fid);
964 let mut frames = store.frames.write();
965 if let Some(f) = frames.get(&fid).cloned()
966 && f.byte_range != new_range
967 {
968 let mut updated = f;
969 updated.byte_range = new_range;
970 frames.insert(fid, updated);
971 }
972 }
973}
974
975fn compute_frame_byte_range_recursive(store: &Store, frame_id: EntityId) -> (u32, u32) {
976 let mut bounds: Option<(u32, u32)> = None;
977 walk_frame_bounds(store, frame_id, &mut bounds);
978 bounds.unwrap_or((0, 0))
979}
980
981fn walk_frame_bounds(store: &Store, frame_id: EntityId, bounds: &mut Option<(u32, u32)>) {
982 fn merge(bounds: &mut Option<(u32, u32)>, s: u32, e: u32) {
983 *bounds = Some(match *bounds {
984 None => (s, e),
985 Some((min, max)) => (min.min(s), max.max(e)),
986 });
987 }
988
989 let (blocks, child_order, table_id) = {
990 let frames = store.frames.read();
991 let Some(f) = frames.get(&frame_id) else {
992 return;
993 };
994 (f.blocks.clone(), f.child_order.clone(), f.table)
995 };
996
997 {
998 let offsets = store.block_offsets.read();
999 for bid in &blocks {
1000 if let Some((s, e)) = offsets.range_of_block(*bid) {
1001 merge(bounds, s, e);
1002 }
1003 }
1004 if let Some(tid) = table_id
1005 && let Some((s, e)) = offsets.range_of(OffsetMarker::TableAnchor(tid))
1006 {
1007 merge(bounds, s, e);
1008 }
1009 }
1010
1011 for entry in &child_order {
1012 if *entry < 0 {
1013 walk_frame_bounds(store, (-*entry) as EntityId, bounds);
1014 }
1015 }
1016
1017 if let Some(tid) = table_id {
1018 let cell_ids: Vec<EntityId> = {
1019 let tables = store.tables.read();
1020 tables
1021 .get(&tid)
1022 .map(|t| t.cells.clone())
1023 .unwrap_or_default()
1024 };
1025 for cell_id in &cell_ids {
1026 let cell_frame_id = {
1027 let cells = store.table_cells.read();
1028 cells.get(cell_id).and_then(|c| c.cell_frame)
1029 };
1030 if let Some(cfid) = cell_frame_id {
1031 walk_frame_bounds(store, cfid, bounds);
1032 }
1033 }
1034 }
1035}
1036
1037/// Replace `[char_start..char_end)` inside `block` with `replacement`, choosing what the
1038/// replacement wears where it overwrites formatted text — see [`ReplaceFormatPolicy`].
1039/// Mutates the block's format runs, image anchors, and the global rope consistently in one
1040/// step, and returns the updated `Block` (with a bumped `updated_at`) for the caller to
1041/// persist via its own unit of work.
1042///
1043/// The single shared implementation of "replace a char range inside one block" — originally
1044/// written for the project-wide replace path (`document_search::replace_core::apply_in_block`)
1045/// and moved here so `document_editing`'s interactive selection-replace path can offer the
1046/// same format-policy choice instead of being permanently pinned to
1047/// [`ReplaceFormatPolicy::InheritPreceding`]. See `ReplaceFormatPolicy`'s own doc comment for
1048/// the failure mode a second, independently-drifting copy of this would risk: a replace used
1049/// to be an unannounced delete + insert, which silently dropped formatting.
1050///
1051/// Deliberately takes no unit of work — everything here is store-level (the block's text
1052/// lives in the rope, its formatting in `format_runs`, its images in `block_images`), so the
1053/// caller's UoW only has to persist the returned `Block`.
1054///
1055/// Returns `Err` rather than corrupting the block if the replace would violate the format-run
1056/// invariants (see [`FormatRunError`]) — the caller should refuse the edit and propagate this
1057/// rather than swallow it.
1058pub fn replace_in_block(
1059 store: &Store,
1060 block: &Block,
1061 char_start: i64,
1062 char_end: i64,
1063 replacement: &str,
1064 policy: ReplaceFormatPolicy,
1065) -> Result<Block, FormatRunError> {
1066 let images_before = store
1067 .block_images
1068 .read()
1069 .get(&block.id)
1070 .cloned()
1071 .unwrap_or_default();
1072 let block_text = block_content_via_store(block, store);
1073
1074 let byte_start = logical_offset_to_byte(&block_text, &images_before, char_start);
1075 let byte_end = logical_offset_to_byte(&block_text, &images_before, char_end);
1076 let new_len = block_text.len() - (byte_end - byte_start) as usize + replacement.len();
1077
1078 let inserted_byte_len = replacement.len() as u32;
1079
1080 // Format runs under an explicit policy, and then CHECK the result rather than assert it:
1081 // `debug_assert_well_formed` is compiled out of release, so a malformed run list produced
1082 // in a shipped build went entirely undetected — and autosave wrote it to the writer's
1083 // file seconds later. A replace that would corrupt a block's formatting fails loudly.
1084 {
1085 let mut runs_map = store.format_runs.write();
1086 let runs = runs_map.entry(block.id).or_default();
1087 shift_runs_for_replace(runs, byte_start, byte_end, inserted_byte_len, policy)?;
1088 check_well_formed(runs, new_len)?;
1089 }
1090 {
1091 let mut images_map = store.block_images.write();
1092 let images = images_map.entry(block.id).or_default();
1093 shift_images_for_delete(images, byte_start, byte_end);
1094 shift_images_for_insert(images, byte_start, inserted_byte_len);
1095 }
1096
1097 // Mirror the in-block splice into the global rope.
1098 rope_delete_in_block(store, block.id, byte_start, byte_end);
1099 rope_insert_in_block(store, block.id, byte_start, replacement);
1100
1101 let mut updated = block.clone();
1102 updated.updated_at = chrono::Utc::now();
1103 Ok(updated)
1104}