Skip to main content

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(&current) 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}