diff --git a/.changeset/richtext-wasm-positions.md b/.changeset/richtext-wasm-positions.md new file mode 100644 index 000000000..252ffda91 --- /dev/null +++ b/.changeset/richtext-wasm-positions.md @@ -0,0 +1,9 @@ +--- +"loro-crdt": patch +--- + +Fix three rich-text bugs that a randomized comparison with loro.js found: + +- Text inserted next to a style anchor could land on the wrong side of it, depending on an internal cursor cache (native Rust and WASM): for example, after an `unmark` that changed nothing, typing right after a mark that does not expand after put the new text inside the mark. +- In the WASM build, a delete spanning text from separate inserts could record a `start_id` naming the wrong characters when an astral character (for example, an emoji) ended one of the inserts. Imports apply deletes by position, but shallow snapshot imports and cursors on deleted text use the recorded IDs, so they could disagree with a full import. This fixes new ops only; deletes already in a history keep their IDs (loro-dev/loro#1149). +- In the WASM build, committing a transaction that merged backspaces over astral characters panicked with "Op/hint length mismatch" when the document had a subscriber or an `UndoManager`. diff --git a/AGENTS.md b/AGENTS.md index cb00f0dd4..40324f36e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,8 @@ Loro is a Rust CRDT workspace with JS/WASM packaging and a MoonBit codec. - Shallow-snapshot dead-style redaction (null-only, both-expand exclusion, root-whitelisted latest state): [context/shallow-snapshot-style-redaction.md](context/shallow-snapshot-style-redaction.md). +- Rich-text insert positions next to style anchors, the insert cursor cache, + and delete `start_id`s: [context/richtext-insert-positions.md](context/richtext-insert-positions.md). - User-facing Loro usage, sync, editor integration, and performance guidance: [skills/loro/SKILL.md](skills/loro/SKILL.md). - Pure TypeScript runtime indexes, complexity contracts, benchmarks, and remaining gaps: diff --git a/context/richtext-insert-positions.md b/context/richtext-insert-positions.md new file mode 100644 index 000000000..ce3813369 --- /dev/null +++ b/context/richtext-insert-positions.md @@ -0,0 +1,67 @@ +# Rich-Text Insert Positions and Delete IDs + +Verified against code 2026-09-28. + +## Where local text goes next to style anchors + +`RichtextState` (`crates/loro-internal/src/container/richtext/richtext_state.rs`) +keeps every style's start and end anchors in the text. An anchor has entity +length 1 and length 0 in bytes, Unicode, UTF-16, and event indexes, so one user +position can name several entity positions. `find_best_insert_pos` picks one: + +1. Scan the anchors after the previous character from left to right. Stop at + the first anchor that new text stays in front of + (`insert_stays_before_anchor`): the start of an unmark (null or false + value), or an anchor that prefers inserts before it + (`TextStyleInfoFlag::prefer_insert_before`: a start anchor that does not + expand before, an end anchor that expands after). +2. Walk back over the scanned anchors while they prefer inserts before them. + +`TextHandler::insert` and both ends of `TextHandler::mark` go through +`get_entity_index_for_text_insert`, which applies this rule. loro.js follows +the same rule from loro-dev/loro#1137 on (`LoroText.#insertPosition`). + +## The insert cursor cache + +`get_entity_index_for_text_insert` first tries the cursor left by the previous +lookup or insert (`try_get_cache_or_clean`), so consecutive typing skips the +tree query. The cache is only used where the anchor rules cannot move the +insert (`anchor_rules_may_move_insert`): + +- the text has no style; +- the cursor is inside a text chunk, between two text chunks, or at the start + or end of the text; +- the cursor is at the end of a text chunk and the next element is an anchor + that new text stays in front of. The scan stops at that anchor, so the answer + is the cursor itself; this is typing at the end of a bold range. + +A cursor on an anchor, or at the start of a text chunk right after an anchor, +falls back to `find_best_insert_pos`. Typing continues from the end of the new +text, which is usually followed by text or by an anchor the text stays in front +of, so typically only the first keystroke next to an anchor pays for the query. +Before loro-dev/loro#1135 the cache was trusted at +every chunk boundary, so the side of an anchor that text landed on depended on +the previous lookup (a no-op `unmark` could move the next insert into a style). + +- Test: `richtext_state/insert_pos_cache_test.rs` compares every cached answer + with a cold lookup for all five `PosType`s on random documents (all expand + types, overlapping styles, null/false values, adjacent and empty anchor + pairs, astral/ZWJ/flag text). `LORO_INSERT_CACHE_SEEDS=` runs more seeds. +- Benchmark: `crates/loro/examples/richtext_typing_bench.rs` (typing next to + and away from anchors; see its header for how to run it). + +## Delete op IDs + +A text delete op records its position and the ID of its first deleted character +(`start_id`). `get_text_entity_ranges` splits a local delete into runs that are +contiguous in both entity position and ID, and the handler writes them last run +first. Up to `loro-crdt` 1.16.3 the WASM build tested ID contiguity with the +UTF-16 length, so after an astral character a delete could record a `start_id` +that names other characters (fixed in loro-dev/loro#1135). + +Replaying a delete by position (`Tracker::delete`) gives the right text, but +some readers trust `start_id`: placeholders from a shallow snapshot take their +real IDs from it (`CrdtRope::delete`), so a shallow import can end with other +text than a full import, and a cursor on a deleted character that no delete op +names does not resolve. Histories that already contain such deletes are tracked +in loro-dev/loro#1149. diff --git a/crates/loro-internal/AGENTS.md b/crates/loro-internal/AGENTS.md index 8f019a78d..de5a8ef3d 100644 --- a/crates/loro-internal/AGENTS.md +++ b/crates/loro-internal/AGENTS.md @@ -27,6 +27,9 @@ over graceful degradation. - `src/handler.rs`: typed container handlers, local operation creation, and `MapHandler::ensure_mergeable_*`. - `src/diff_calc/`: diff calculation when moving between versions. +- `src/container/richtext/`: text state with style anchors. Where local inserts + go next to anchors, the insert cursor cache, and delete `start_id`s: + [../../context/richtext-insert-positions.md](../../context/richtext-insert-positions.md). - `docs/diff_calc.md`: design notes for diff calculation. - `docs/critical-version-spec.md`: specification and proof skeleton for replay-base selection (Eg-walker-aligned terminology; defines critical diff --git a/crates/loro-internal/src/container/richtext/richtext_state.rs b/crates/loro-internal/src/container/richtext/richtext_state.rs index db79b1baa..60c1720ce 100644 --- a/crates/loro-internal/src/container/richtext/richtext_state.rs +++ b/crates/loro-internal/src/container/richtext/richtext_state.rs @@ -1408,6 +1408,14 @@ mod query { } } +/// Whether new text inserted right in front of this anchor stays in front of +/// it: the start of an unmark (case 1 of `find_best_insert_pos`), or an anchor +/// that prefers inserts before it (case 2). +fn insert_stays_before_anchor(style: &StyleOp, anchor_type: AnchorType) -> bool { + (anchor_type == AnchorType::Start && (style.value.is_null() || style.value.is_false())) + || style.info.prefer_insert_before(anchor_type) +} + impl RichtextState { pub(crate) fn from_chunks>>(i: I) -> Self { Self { @@ -1428,7 +1436,12 @@ impl RichtextState { return Ok((0, None)); } - if let Some(c) = self.try_get_cache_or_clean(pos, pos_type) { + // A cached cursor next to a style anchor would skip the anchor + // rules of `find_best_insert_pos`. + let cached = self + .try_get_cache_or_clean(pos, pos_type) + .filter(|c| !self.has_styles() || !self.anchor_rules_may_move_insert(*c)); + if let Some(c) = cached { let entity_index = self.get_cache_entity_index().unwrap(); Ok((entity_index + c.offset, Some(c))) } else { @@ -1460,6 +1473,40 @@ impl RichtextState { result } + /// Whether the anchor rules of `find_best_insert_pos` could put an insert + /// somewhere other than `cursor`. They cannot inside a text chunk, between + /// two text chunks, or at the end of a text chunk followed by an anchor + /// that new text stays in front of (the rules stop at that anchor). + fn anchor_rules_may_move_insert(&self, cursor: Cursor) -> bool { + let Some(elem) = self.tree.get_elem(cursor.leaf) else { + return true; + }; + if !matches!(elem, RichtextStateChunk::Text { .. }) { + return true; + } + if cursor.offset == 0 { + return self.tree.prev_elem(cursor).is_some_and(|prev| { + matches!( + self.tree.get_elem(prev.leaf), + Some(RichtextStateChunk::Style { .. }) + ) + }); + } + if cursor.offset < elem.rle_len() { + return false; + } + match self + .tree + .next_elem(cursor) + .and_then(|next| self.tree.get_elem(next.leaf)) + { + Some(RichtextStateChunk::Style { style, anchor_type }) => { + !insert_stays_before_anchor(style, *anchor_type) + } + _ => false, + } + } + pub(crate) fn has_styles(&self) -> bool { self.style_ranges .as_ref() @@ -1892,14 +1939,8 @@ impl RichtextState { }; visited.push((style, anchor_type, iter, entity_index)); - if anchor_type == AnchorType::Start && (style.value.is_null() || style.value.is_false()) - { - // case 1. should be before this anchor - break; - } - - if style.info.prefer_insert_before(anchor_type) { - // case 2. + if insert_stays_before_anchor(style, anchor_type) { + // case 1 or 2. break; } @@ -2016,9 +2057,11 @@ impl RichtextState { let event_len = s.entity_range_to_event_range(start..end).len(); let id = s.id().inc(start as i32); match ans.last_mut() { + // Every entity of a range is text, so its IDs advance by + // the entity length (not the UTF-16 event length). Some(last) if last.entity_end == entity_index - && last.id_start.inc(last.event_len as i32) == id => + && last.id_start.inc(last.entity_len() as i32) == id => { last.entity_end += len; last.event_len += event_len; @@ -3077,6 +3120,9 @@ mod converter { } } +#[cfg(test)] +mod insert_pos_cache_test; + #[cfg(test)] mod test { use append_only_bytes::AppendOnlyBytes; diff --git a/crates/loro-internal/src/container/richtext/richtext_state/insert_pos_cache_test.rs b/crates/loro-internal/src/container/richtext/richtext_state/insert_pos_cache_test.rs new file mode 100644 index 000000000..53b9d9320 --- /dev/null +++ b/crates/loro-internal/src/container/richtext/richtext_state/insert_pos_cache_test.rs @@ -0,0 +1,172 @@ +//! `get_entity_index_for_text_insert` can start from the cursor cached by an +//! earlier lookup. That shortcut must never move an insert across a style +//! anchor, so these tests compare every cached answer with a cold lookup +//! (`find_best_insert_pos`) on documents full of anchors. + +use loro_common::LoroValue; +use rand::{rngs::StdRng, seq::SliceRandom, Rng, SeedableRng}; + +use super::{PosType, RichtextState}; +use crate::{ + container::richtext::{ + config::{StyleConfig, StyleConfigMap}, + ExpandType, + }, + handler::{HandlerTrait, TextHandler}, + LoroDoc, +}; + +/// One key per expand type. +const STYLES: [(&str, ExpandType); 4] = [ + ("after", ExpandType::After), + ("before", ExpandType::Before), + ("both", ExpandType::Both), + ("none", ExpandType::None), +]; + +/// BMP, astral, ZWJ sequences, flags, and modifiers. +const SNIPPETS: [&str; 9] = ["a", "bc", "Γ©", "δΈ­", "πŸ˜€", "𝒳y", "πŸ‘πŸ½", "πŸ‘¨β€πŸ‘©β€πŸ‘§", "πŸ‡¨πŸ‡³"]; + +const POS_TYPES: [PosType; 5] = [ + PosType::Bytes, + PosType::Unicode, + PosType::Utf16, + PosType::Event, + PosType::Entity, +]; + +const MAX_LEN: usize = 40; + +fn random_edit(rng: &mut StdRng, text: &TextHandler) { + let len = text.len_unicode(); + match rng.gen_range(0..10) { + 0..=3 if len < MAX_LEN => { + let s = SNIPPETS.choose(rng).unwrap(); + text.insert(rng.gen_range(0..=len), s, PosType::Unicode) + .unwrap(); + } + 0..=4 if len > 0 => { + let pos = rng.gen_range(0..len); + let del = rng.gen_range(1..=(len - pos).min(4)); + text.delete(pos, del, PosType::Unicode).unwrap(); + } + _ if len > 0 => { + let start = rng.gen_range(0..len); + let end = rng.gen_range(start + 1..=len); + let (key, _) = *STYLES.choose(rng).unwrap(); + let value = match rng.gen_range(0..5) { + 0 => LoroValue::Null, + 1 => false.into(), + 2 => "v".into(), + _ => true.into(), + }; + if rng.gen_bool(0.2) { + text.unmark(start, end, key, PosType::Unicode).unwrap(); + } else { + text.mark(start, end, key, value, PosType::Unicode).unwrap(); + } + if rng.gen_bool(0.2) { + // Leaves the anchors of the style next to each other. + text.delete(start, end - start, PosType::Unicode).unwrap(); + } + } + _ => text.insert(0, "a", PosType::Unicode).unwrap(), + } +} + +fn boundaries(text: &str, pos_type: PosType, entity_len: usize) -> Vec { + let unit_len = |c: char| match pos_type { + PosType::Bytes => c.len_utf8(), + PosType::Unicode => 1, + PosType::Utf16 => c.len_utf16(), + PosType::Event if cfg!(feature = "wasm") => c.len_utf16(), + PosType::Event => 1, + PosType::Entity => unreachable!(), + }; + if pos_type == PosType::Entity { + return (0..=entity_len).collect(); + } + let mut ans = vec![0]; + for c in text.chars() { + ans.push(ans.last().unwrap() + unit_len(c)); + } + ans +} + +fn cold_lookup(state: &RichtextState, pos: usize, pos_type: PosType) -> usize { + let mut cold = state.clone(); + cold.clear_cache(); + cold.get_entity_index_for_text_insert(pos, pos_type) + .unwrap() + .0 +} + +fn assert_cache_matches_cold_lookup(state: &mut RichtextState, text: &str, context: &str) { + for pos_type in POS_TYPES { + let positions = boundaries(text, pos_type, state.len_entity()); + // Starting from the cursor the last edit left behind. + for &pos in &positions { + let expected = cold_lookup(state, pos, pos_type); + let (actual, _) = state + .clone() + .get_entity_index_for_text_insert(pos, pos_type) + .unwrap(); + assert_eq!( + actual, expected, + "{context}: {pos_type:?} {pos} from the edit's cursor in {text:?}" + ); + } + // Starting from the cursor of the previous position, as when typing. + let mut warm = state.clone(); + for &pos in &positions { + let expected = cold_lookup(&warm, pos, pos_type); + let (actual, cursor) = warm + .get_entity_index_for_text_insert(pos, pos_type) + .unwrap(); + assert_eq!( + actual, expected, + "{context}: {pos_type:?} {pos} from the previous position in {text:?}" + ); + if let Some(cursor) = cursor { + assert_eq!( + warm.get_index_from_cursor(cursor, PosType::Entity), + Some(actual) + ); + } + } + } +} + +#[test] +fn cached_insert_position_matches_cold_lookup_near_style_anchors() { + let seeds: u64 = std::env::var("LORO_INSERT_CACHE_SEEDS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(12); + for seed in 0..seeds { + let mut rng = StdRng::seed_from_u64(seed); + let doc = LoroDoc::new_auto_commit(); + let mut styles = StyleConfigMap::new(); + for (key, expand) in STYLES { + styles.insert(key.into(), StyleConfig::new().expand(expand)); + } + doc.config_text_style(styles); + let text = doc.get_text("text"); + for step in 0..60 { + random_edit(&mut rng, &text); + if rng.gen_bool(0.3) { + doc.commit_then_renew(); + } + let value = text.to_string(); + // The clone keeps the cursor cache of the document's state. + let mut state = doc.app_state().lock().with_state_mut(text.idx(), |s| { + s.as_richtext_state_mut().unwrap().inner_state_mut().clone() + }); + assert_cache_matches_cold_lookup( + &mut state, + &value, + &format!("seed {seed} step {step}"), + ); + } + } +} diff --git a/crates/loro-internal/src/state/richtext_state.rs b/crates/loro-internal/src/state/richtext_state.rs index 5bddb0e56..d95415c20 100644 --- a/crates/loro-internal/src/state/richtext_state.rs +++ b/crates/loro-internal/src/state/richtext_state.rs @@ -991,6 +991,11 @@ impl RichtextState { self.state.get_mut().len_unicode() } + #[cfg(test)] + pub(crate) fn inner_state_mut(&mut self) -> &mut InnerState { + self.state.get_mut() + } + #[inline] pub(crate) fn get_entity_index_for_text_insert( &mut self, diff --git a/crates/loro-internal/src/txn.rs b/crates/loro-internal/src/txn.rs index ff207e7fb..444245f0e 100644 --- a/crates/loro-internal/src/txn.rs +++ b/crates/loro-internal/src/txn.rs @@ -10,7 +10,7 @@ use loro_common::{ContainerType, IdLp, IdSpan, LoroResult}; use loro_delta::{array_vec::ArrayVec, DeltaRopeBuilder}; use rle::{HasLength, Mergable, RleVec, Sliceable}; use rustc_hash::FxHashMap; -use smallvec::{smallvec, SmallVec}; +use smallvec::SmallVec; use crate::{ change::{Change, Lamport, Timestamp}, @@ -27,7 +27,7 @@ use crate::{ id::{Counter, PeerID, ID}, lock::{LoroMutex, LoroMutexGuard}, loro::CommitOptions, - op::{Op, RawOp, RawOpContent}, + op::{InnerContent, Op, RawOp, RawOpContent}, pre_commit::{ChangeModifier, PreCommitCallbackPayload}, span::HasIdSpan, version::Frontiers, @@ -783,31 +783,32 @@ fn change_to_diff( unreachable!("Missing hint for op"); }; - // Collect ops that belong to this hint - let mut ops_for_hint: SmallVec<[Op; 1]> = smallvec![container_ops[op_index].clone()]; - let mut total_len = container_ops[op_index].atom_len(); - - // If hint spans multiple ops, collect them + // Collect the ops that belong to this hint. Ops and hints merge by + // different rules (text delete hints use UTF-16 spans in WASM), so a + // hint can cover several ops and an op can cover several hints. + let mut ops_for_hint: SmallVec<[Op; 1]> = SmallVec::new(); + let mut total_len = 0; while total_len < hint.rle_len() { - op_index += 1; - let next_op_len = container_ops[op_index].atom_len(); - let op = if next_op_len + total_len > hint.rle_len() { - let new_len = hint.rle_len() - total_len; - let left = container_ops[op_index].slice(0, new_len); - let right = container_ops[op_index].slice(new_len, next_op_len); - container_ops[op_index] = right; - op_index -= 1; - left - } else { - container_ops[op_index].clone() + let Some(op) = container_ops.get_mut(op_index) else { + unreachable!("Op/hint length mismatch: {hint:?}"); }; - - total_len += op.atom_len(); - ops_for_hint.push(op); + let op_len = op.atom_len(); + let needed = hint.rle_len() - total_len; + if op_len > needed { + ops_for_hint.push(op.slice(0, needed)); + *op = op.slice(needed, op_len); + total_len += needed; + } else { + total_len += op_len; + ops_for_hint.push(op.clone()); + op_index += 1; + } } - op_index += 1; - assert_eq!(total_len, hint.rle_len(), "Op/hint length mismatch"); + debug_assert!( + ops_for_hint.iter().all(|op| op_matches_hint(op, &hint)), + "Op/hint kind mismatch: {hint:?} {ops_for_hint:?}" + ); // Move to next hint current_hint = hint_iter.next(); @@ -986,11 +987,42 @@ fn change_to_diff( .map(|x| x.content_len() as Lamport) .sum::(); } + + debug_assert!( + current_hint.is_none(), + "Unused event hint: {current_hint:?}" + ); } ans } +/// Whether `op` is the kind of op `hint` was recorded for. +fn op_matches_hint(op: &Op, hint: &EventHint) -> bool { + match &op.content { + InnerContent::List(list_op) => matches!( + (list_op, hint), + (InnerListOp::StyleStart { .. }, EventHint::Mark { .. }) + | (InnerListOp::StyleEnd, EventHint::MarkEnd) + | (InnerListOp::InsertText { .. }, EventHint::InsertText { .. }) + | ( + InnerListOp::Delete(_), + EventHint::DeleteText { .. } | EventHint::DeleteList(_) + ) + | (InnerListOp::Insert { .. }, EventHint::InsertList { .. }) + | (InnerListOp::Move { .. }, EventHint::Move { .. }) + | (InnerListOp::Set { .. }, EventHint::SetList { .. }) + ), + InnerContent::Map(_) => matches!(hint, EventHint::Map { .. }), + InnerContent::Tree(_) => matches!(hint, EventHint::Tree(_)), + #[cfg(feature = "counter")] + InnerContent::Future(crate::op::FutureInnerContent::Counter(_)) => { + matches!(hint, EventHint::Counter(_)) + } + InnerContent::Future(_) => false, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/loro-wasm/tests/richtext.test.ts b/crates/loro-wasm/tests/richtext.test.ts index 19adf1925..92cd643a5 100644 --- a/crates/loro-wasm/tests/richtext.test.ts +++ b/crates/loro-wasm/tests/richtext.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest"; -import { Delta, LoroDoc, TextDiff, Cursor, OpId } from "../bundler/index"; +import { + Delta, + LoroDoc, + TextDiff, + Cursor, + OpId, + UndoManager, +} from "../bundler/index"; import { expectDefined } from "./helpers"; describe("richtext", () => { @@ -615,4 +622,62 @@ describe("richtext", () => { }, ]); }); + + it("records delete IDs by Unicode length around astral text", () => { + const doc = new LoroDoc(); + doc.setPeerId(7); + const text = doc.getText("text"); + text.insert(0, "aπŸ˜€b"); + // x's ID follows πŸ˜€'s Unicode end, not its UTF-16 end. + text.insert(3, "x"); + doc.commit(); + text.delete(0, 4); // "aπŸ˜€x" + doc.commit(); + expect(text.toString()).toBe("b"); + const deletes = doc + .exportJsonUpdates() + .changes.flatMap((change) => change.ops.map((op) => op.content)) + .filter((content) => content.type === "delete"); + // aπŸ˜€ (IDs 0..2) and x (ID 3) are separate ID runs. + expect(deletes).toStrictEqual([ + { type: "delete", pos: 2, len: 1, start_id: "3@0" }, + { type: "delete", pos: 0, len: 2, start_id: "0@0" }, + ]); + const replica = new LoroDoc(); + replica.importJsonUpdates(doc.exportJsonUpdates()); + expect(replica.getText("text").toString()).toBe("b"); + }); + + it("emits events for merged deletes of astral text", () => { + const doc = new LoroDoc(); + const events: unknown[] = []; + doc.subscribe((event) => events.push(event)); + const text = doc.getText("text"); + text.insert(0, "𝒳y"); + doc.commit(); + // Backspace twice: the ops merge by Unicode length, the event hints by + // UTF-16 span. + text.delete(2, 1); + text.delete(0, 2); + doc.commit(); + expect(text.toString()).toBe(""); + expect(events.length).toBe(2); + }); + + it("undoes merged deletes of astral text without a subscriber", () => { + const doc = new LoroDoc(); + // The UndoManager alone makes the transaction build its events. + const undo = new UndoManager(doc, { mergeInterval: 0 }); + const text = doc.getText("text"); + text.insert(0, "𝒳y"); + doc.commit(); + text.delete(2, 1); + text.delete(0, 2); + doc.commit(); + expect(text.toString()).toBe(""); + undo.undo(); + expect(text.toString()).toBe("𝒳y"); + undo.redo(); + expect(text.toString()).toBe(""); + }); }); diff --git a/crates/loro/examples/richtext_typing_bench.rs b/crates/loro/examples/richtext_typing_bench.rs new file mode 100644 index 000000000..55621d0f6 --- /dev/null +++ b/crates/loro/examples/richtext_typing_bench.rs @@ -0,0 +1,72 @@ +//! Local typing cost in a text with style anchors (loro-dev/loro#1135 review). +//! +//! ```sh +//! cargo run -p loro --release --example richtext_typing_bench -- in-bold commit +//! ``` +//! +//! Each run builds a 100k-character text, applies the scenario's marks, then +//! times 100k single-character inserts at consecutive positions, committing +//! after every insert (`commit`) or once at the end (`txn`). Scenarios: +//! +//! - `plain`: no style. +//! - `in-bold`: in the middle of a bold range. +//! - `bold-end`: at the end of a bold range (the text goes before the end +//! anchor, so each insert runs the anchor rules). +//! - `marks-2000`: between two of 2,000 short bold ranges. +//! +//! Prints `scenario,mode,ms`. + +use loro::{ExpandType, LoroDoc, StyleConfig, StyleConfigMap}; +use std::time::Instant; + +const LEN: usize = 100_000; +const INPUTS: usize = 100_000; + +fn main() { + let mut args = std::env::args().skip(1); + let scenario = args.next().expect("scenario"); + let mode = args.next().unwrap_or_else(|| "commit".into()); + let commit_each = match mode.as_str() { + "commit" => true, + "txn" => false, + _ => panic!("mode must be commit or txn"), + }; + + let doc = LoroDoc::new(); + let mut styles = StyleConfigMap::new(); + styles.insert("bold".into(), StyleConfig::new().expand(ExpandType::After)); + doc.config_text_style(styles); + let text = doc.get_text("text"); + text.insert(0, &"a".repeat(LEN)).unwrap(); + let start = match scenario.as_str() { + "plain" => LEN / 2, + "in-bold" => { + text.mark(0..LEN, "bold", true).unwrap(); + LEN / 2 + } + "bold-end" => { + text.mark(0..LEN / 2, "bold", true).unwrap(); + LEN / 2 + } + "marks-2000" => { + for i in 0..2000 { + text.mark(i * 50..i * 50 + 10, "bold", true).unwrap(); + } + 1000 * 50 + 30 + } + _ => panic!("unknown scenario {scenario}"), + }; + doc.commit(); + + let t = Instant::now(); + for i in 0..INPUTS { + text.insert(start + i, "x").unwrap(); + if commit_each { + doc.commit(); + } + } + doc.commit(); + let ms = t.elapsed().as_secs_f64() * 1000.0; + assert_eq!(text.len_unicode(), LEN + INPUTS); + println!("{scenario},{mode},{ms:.1}"); +} diff --git a/crates/loro/tests/contracts/text_richtext_advanced.rs b/crates/loro/tests/contracts/text_richtext_advanced.rs index 78d24cdb3..77adc0317 100644 --- a/crates/loro/tests/contracts/text_richtext_advanced.rs +++ b/crates/loro/tests/contracts/text_richtext_advanced.rs @@ -619,3 +619,50 @@ fn richtext_overlapping_styles_keep_neighbor_intersections_after_insert_and_snap Ok(()) } + +#[test] +fn insert_position_near_style_anchors_does_not_depend_on_cursor_cache() -> LoroResult<()> { + let expected = vec![ + TextDelta::Insert { + insert: "b".to_string(), + attributes: Some([("em".to_string(), "y".into())].into_iter().collect()), + }, + TextDelta::Insert { + insert: "a".to_string(), + attributes: None, + }, + ]; + for skipped_unmark in [false, true] { + let doc = LoroDoc::new(); + let mut styles = StyleConfigMap::new(); + styles.insert( + "em".into(), + StyleConfig { + expand: ExpandType::Before, + }, + ); + styles.insert( + "bold".into(), + StyleConfig { + expand: ExpandType::After, + }, + ); + doc.config_text_style(styles); + let text = doc.get_text("text"); + text.insert(0, "b")?; + text.mark(0..1, "em", "y")?; + if skipped_unmark { + // Writes nothing, but its position lookups used to leave a cursor + // cache that put the next insert before em's end anchor. + text.unmark(0..1, "bold")?; + } + // em does not expand after, so the insert goes after its end anchor. + text.insert(1, "a")?; + assert_eq!( + text.to_delta(), + expected, + "skipped unmark: {skipped_unmark}" + ); + } + Ok(()) +}