pmacs/src/buffer.rs

3000 lines
117 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// buffer.rs --- Rope + identity + attached views + per-buffer state.
//! Buffers: the unit of editable content.
//!
//! Implements the buffer contract from spec §3.2 and the edit flow from
//! spec §3.5. A buffer owns a [`Rope`], a list of attached views, a name,
//! a modified flag, and undo/redo stacks. It coordinates rather than
//! computes: cursors, line indices, and selection state live in views.
//!
//! # Edit flow
//!
//! On [`Buffer::apply_edit`]:
//! 1. Each attached view's `intercept_edit` runs in registration order,
//! possibly rewriting the operation.
//! 2. The rope edit is applied, producing a new rope and an [`Edit`]
//! description.
//! 3. The buffer swaps in the new rope. The old rope is pushed onto the
//! undo stack; the redo stack is cleared (any forward edit forks the
//! history).
//! 4. Each view's `on_edit` is called with the [`Edit`] description.
//!
//! # Re-entry
//!
//! Views never hold a back-pointer to the buffer (spec §2.6). The buffer
//! passes itself in to each callback. Internally, the buffer temporarily
//! moves its view list out of `self` before iterating, so the views can
//! observe `&Buffer` while the buffer's own `&mut self` is held.
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::file_io::FileMeta;
use crate::rope::{Edit, Position, Range, Rope, RopeError};
use crate::view::{InterceptContext, View};
// ---------------------------------------------------------------------------
// Identifiers
// ---------------------------------------------------------------------------
/// Opaque, per-process identifier for a buffer.
///
/// The internal representation is private (R22): callers cannot reach for
/// `.0`; construction goes through [`BufferId::next`].
///
/// T M10.5: `Serialize` / `Deserialize` derived so `BufferId` can be the
/// routing key on `InstanceMessage::CrdtOp` / `FrontendEvent::CrdtOp`.
/// The serialized form is the bare `u64` (transparent newtype).
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub struct BufferId(u64);
impl BufferId {
/// Allocate a fresh [`BufferId`] from the process-wide counter.
///
/// Threading: any thread.
#[must_use]
pub fn next() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(1);
Self(COUNTER.fetch_add(1, Ordering::Relaxed))
}
/// Inspect the raw value. Useful for logging and FFI.
#[must_use]
pub const fn raw(self) -> u64 {
self.0
}
/// Rebuild an ID from a raw value for crate-internal references that
/// persist an already-issued buffer identity in generated text.
#[must_use]
pub(crate) const fn from_raw(raw: u64) -> Self {
Self(raw)
}
}
/// Opaque, per-buffer identifier for an attached view.
///
/// Identity is scoped to the buffer that issued it; two different buffers
/// may both hand out a `ViewId(0)`. View IDs are returned by
/// [`Buffer::attach_view`] and accepted by [`Buffer::detach_view`].
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct ViewId(u64);
impl ViewId {
/// Inspect the raw value. Useful for logging and FFI.
#[must_use]
pub const fn raw(self) -> u64 {
self.0
}
}
/// Opaque, per-buffer identifier for a position mark.
///
/// Marks are owned by a [`Buffer`] and move through edits according to
/// their gravity. They are intentionally not process-global: a
/// `MarkId(0)` from one buffer has no meaning in another buffer.
#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug)]
pub struct MarkId(u64);
impl MarkId {
/// Inspect the raw value. Useful for logging and FFI.
#[must_use]
pub const fn raw(self) -> u64 {
self.0
}
}
/// Which side of an insertion/replacement a mark sticks to.
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
pub enum MarkGravity {
/// Stay before bytes inserted exactly at the mark.
Left,
/// Move after bytes inserted exactly at the mark.
Right,
}
#[derive(Copy, Clone, Debug)]
struct Mark {
pos: Position,
gravity: MarkGravity,
}
// ---------------------------------------------------------------------------
// Edit operations
// ---------------------------------------------------------------------------
/// A pending edit before it reaches the rope.
///
/// Carries a borrow of the bytes to insert; the borrow only needs to live
/// for the duration of the [`Buffer::apply_edit`] call (the rope copies
/// bytes into its leaf chunks).
#[derive(Debug)]
pub enum EditOp<'a> {
/// Insert `bytes` at byte position `pos`.
Insert {
/// Byte position. Must satisfy `pos <= rope.len()`.
pos: Position,
/// Bytes to insert.
bytes: &'a [u8],
},
/// Delete the byte range `[range.start, range.end)`.
Delete {
/// Range of bytes to remove.
range: Range,
},
/// Replace the byte range `[range.start, range.end)` with `bytes`.
Replace {
/// Range of bytes to replace.
range: Range,
/// Replacement bytes.
bytes: &'a [u8],
},
}
// ---------------------------------------------------------------------------
// Buffer
// ---------------------------------------------------------------------------
/// One entry in the undo (or redo) stack.
struct UndoEntry {
/// Pre-edit rope. Cheap to retain: persistent rope, structural sharing.
rope: Rope,
/// Description of the edit that produced the current rope from this
/// entry's rope. Used to broadcast a precise inverse edit on undo.
edit: EditDescription,
}
/// A reduced [`Edit`] descriptor used by the undo stack.
///
/// Stored separately from `Edit` because `Edit` carries a `new_rope` that
/// duplicates `UndoEntry::rope`; we keep only the deltas here.
#[derive(Copy, Clone, Debug)]
struct EditDescription {
/// Range in the *pre-edit* rope that was affected.
pre_range: Range,
/// Number of bytes inserted at `pre_range.start` to produce the
/// post-edit rope.
inserted_len: u64,
}
/// The unit of editable content: rope + identity + views + undo.
///
/// # Threading
///
/// Main thread only. The buffer holds `Box<dyn View>` trait objects whose
/// methods take `&mut self`; the buffer is single-owner. Workers receive
/// rope snapshots via [`Buffer::snapshot_rope`], not buffer references.
pub struct Buffer {
id: BufferId,
rope: Rope,
name: String,
is_modified: bool,
/// Monotonic counter bumped by every successful forward edit, undo,
/// and redo. Used by the editor to detect "did this command modify
/// the buffer?" without reaching into the rope. LSP `did_change`
/// notifications carry this as the document version.
revision: u64,
/// `(id, view)` pairs in attach order. Views are stored as trait
/// objects (R32) because the set of view types is open --- Lua
/// packages will define new ones.
views: Vec<(ViewId, Box<dyn View>)>,
/// Per-buffer counter for [`ViewId`] allocation.
next_view_id: u64,
/// Buffer-relative marks. Kept as a small vector because current
/// consumers create a handful per buffer; if this grows into
/// thousands, this can become an indexed table without changing
/// the public API.
marks: Vec<(MarkId, Mark)>,
/// Per-buffer counter for [`MarkId`] allocation.
next_mark_id: u64,
/// Undo stack. Most recent entry on top.
undo: Vec<UndoEntry>,
/// Redo stack. Cleared by any forward edit.
redo: Vec<UndoEntry>,
/// Path this buffer is bound to on disk, if any (T M4.5 L1:
/// relocated here from `EditorCore` so cross-file navigation can
/// keep each buffer's identity straight — the v0.1 single-file
/// `EditorCore.file_path` shortcut no longer holds once multiple
/// files are open). `None` for scratch / unsaved buffers.
file_path: Option<PathBuf>,
/// Filesystem metadata captured at the last successful load/save,
/// used for external-change detection. Relocated alongside
/// [`Self::file_path`].
file_meta: Option<FileMeta>,
/// True while an edit is in flight on this buffer (T M7.4).
/// Set by [`Buffer::begin_edit`], cleared by [`Buffer::end_edit`].
/// A re-entrant `apply_edit` / `apply_edit_skip_intercepts` while
/// the flag is set returns [`BufferError::ConcurrentEdit`] rather
/// than mutating the rope mid-intercept; cross-buffer re-entry
/// is unaffected.
editing_in_progress: bool,
/// Optional CRDT-backed state (T M10.2). When `Some`, every
/// successful edit (forward, undo, or redo) is also applied to
/// the CRDT, keeping the invariant `rope contents ≡ CRDT
/// projection` at all times. Set at construction via
/// [`Buffer::new_with_crdt`] / [`Buffer::from_bytes_with_crdt`]
/// or attached to an existing buffer via
/// [`Buffer::upgrade_to_crdt`]; never cleared (per the M10.2
/// "Option set at construction, not toggled later" rule).
///
/// Workers consume the rope projection via
/// [`Buffer::snapshot_rope`] and never see the CRDT directly,
/// per the rope-projection redirect (M10.1, §sec:m10-crdt-choice).
#[cfg(feature = "crdt")]
crdt: Option<crate::crdt::CrdtState>,
}
impl Buffer {
/// Construct an empty buffer with the given identity and name.
///
/// Threading: main thread only.
#[must_use]
pub fn new(id: BufferId, name: impl Into<String>) -> Self {
Self::from_rope(id, name, Rope::new())
}
/// Construct a buffer holding the given bytes.
///
/// Convenience over `from_rope(id, name, Rope::from_bytes(bytes))`,
/// used by file load. Threading: main thread only.
#[must_use]
pub fn from_bytes(id: BufferId, name: impl Into<String>, bytes: &[u8]) -> Self {
Self::from_rope(id, name, Rope::from_bytes(bytes))
}
/// Construct a buffer wrapping an existing rope.
///
/// Threading: main thread only.
#[must_use]
pub fn from_rope(id: BufferId, name: impl Into<String>, rope: Rope) -> Self {
Self {
id,
rope,
name: name.into(),
is_modified: false,
revision: 0,
views: Vec::new(),
next_view_id: 0,
marks: Vec::new(),
next_mark_id: 0,
undo: Vec::new(),
redo: Vec::new(),
file_path: None,
file_meta: None,
editing_in_progress: false,
#[cfg(feature = "crdt")]
crdt: None,
}
}
/// The path this buffer is bound to on disk, if any.
#[must_use]
pub fn file_path(&self) -> Option<&Path> {
self.file_path.as_deref()
}
/// Bind (or unbind, with `None`) this buffer to a disk path.
pub fn set_file_path(&mut self, path: Option<PathBuf>) {
self.file_path = path;
}
/// Filesystem metadata from the last load/save, if any.
#[must_use]
pub fn file_meta(&self) -> Option<&FileMeta> {
self.file_meta.as_ref()
}
/// Record filesystem metadata (after a successful load/save).
pub fn set_file_meta(&mut self, meta: Option<FileMeta>) {
self.file_meta = meta;
}
/// Construct an empty CRDT-backed buffer.
///
/// `peer_id` identifies this frontend's edits in the CRDT op
/// stream; M10.4's per-frontend undo and M10.5's wire-protocol
/// op messages consume it. Threading: main thread only.
///
/// The `Option<CrdtState>` is set here and never toggled
/// afterward (M10.2 "set at construction, not later" rule).
/// To attach a CRDT to an existing rope-only buffer, use
/// [`Buffer::upgrade_to_crdt`].
#[cfg(feature = "crdt")]
pub fn new_with_crdt(
id: BufferId,
name: impl Into<String>,
peer_id: u64,
) -> Result<Self, BufferError> {
let mut buf = Self::new(id, name);
buf.crdt = Some(crate::crdt::CrdtState::new(peer_id)?);
Ok(buf)
}
/// Construct a CRDT-backed buffer seeded with the given bytes.
///
/// The bytes are loaded into the rope (byte-faithful) and into
/// the CRDT (UTF-8-normalized via `String::from_utf8_lossy`,
/// matching [`crate::crdt::CrdtState::from_bytes`]). For valid
/// UTF-8 input the two are identical; for ill-formed input the
/// CRDT loses ill-formed-byte detail to U+FFFD replacement
/// while the rope retains the original bytes — a documented
/// divergence the v0.1 `from_bytes` already accepted.
///
/// Threading: main thread only.
#[cfg(feature = "crdt")]
pub fn from_bytes_with_crdt(
id: BufferId,
name: impl Into<String>,
bytes: &[u8],
peer_id: u64,
) -> Result<Self, BufferError> {
let mut buf = Self::from_bytes(id, name, bytes);
buf.crdt = Some(crate::crdt::CrdtState::from_bytes(peer_id, bytes)?);
Ok(buf)
}
/// Attach a CRDT to an existing rope-only buffer.
///
/// Materializes a fresh `CrdtState` seeded from the buffer's
/// current rope contents. Existing intercepts, marks, views,
/// undo stack, revision, and `is_modified` flag are preserved.
/// The buffer's `BufferId` is unchanged so existing references
/// stay valid.
///
/// **Undo-history loss**: Pre-upgrade entries in the v0.1 undo
/// stack (and redo stack) are cleared explicitly during the
/// upgrade. Post-upgrade undo routes through loro's `UndoManager`,
/// which has no knowledge of pre-upgrade edits. Users wishing to
/// preserve undo history should attach collaboration before
/// making edits, or accept that mid-session collaboration loses
/// prior undo state. A v0.2+ refinement preserving v0.1 history
/// alongside `UndoManager` is feasible but out of scope for v1.0
/// (the synthesis from v0.1 entries → CRDT ops is structurally
/// problematic since the pre-upgrade ops have no `peer_id` to
/// attribute to `UndoManager`).
///
/// Used by M10.8 (multi-frontend instance state) when a v0.1
/// frontend's buffer is promoted to CRDT-backed at attach time
/// because a v1.0 frontend has joined the session. M10.2 ships
/// the API surface; the M10.8 caller wires invocation.
///
/// Returns an error if the CRDT was already attached (the
/// "set once" rule); callers should check
/// [`Buffer::is_crdt_backed`] if uncertain.
///
/// Threading: main thread only.
#[cfg(feature = "crdt")]
pub fn upgrade_to_crdt(&mut self, peer_id: u64) -> Result<(), BufferError> {
if self.crdt.is_some() {
// Already CRDT-backed. The "set once" rule rejects re-
// attachment; callers should not invoke this on an
// already-upgraded buffer.
return Err(BufferError::CrdtRejected {
reason: "buffer is already CRDT-backed".to_owned(),
});
}
// Materialize CRDT state from the current rope. The rope's
// bytes are read in chunks to avoid one large allocation
// (matters for the 10MB+ case the M10.1 audit measured at
// 92ms cold-path materialization).
let rope_len = self.rope.len();
let mut bytes = vec![0u8; rope_len as usize];
if rope_len > 0 {
self.rope.slice(0, rope_len, &mut bytes);
}
self.crdt = Some(crate::crdt::CrdtState::from_bytes(peer_id, &bytes)?);
// M10.4 reframe: clear v0.1 undo/redo stacks on upgrade. The
// pre-upgrade entries can't be replayed through UndoManager
// (no peer_id attribution); leaving them in self.undo would
// make them unreachable through CRDT-mode undo (which
// bypasses self.undo). Clear explicitly + log so the data
// loss is visible. v0.2+ may revisit (preserve alongside
// UndoManager, route undo to v0.1 stack first then switch).
if !self.undo.is_empty() || !self.redo.is_empty() {
// The buffer-registry / Lua-binding layer wraps this in
// a user-facing notification; the log here is for
// developer audit. eprintln intentionally for visibility
// at upgrade time without taking a dep on pmacs's error
// surface from inside the rope/buffer layer.
eprintln!(
"Buffer {} ({:?}): upgrade_to_crdt clearing {} undo + {} redo entries; \
v0.1 history is not preserved across CRDT mode upgrade. See M10.4 audit doc \
for the v0.2+ refinement path.",
self.name,
self.id,
self.undo.len(),
self.redo.len()
);
}
self.undo.clear();
self.redo.clear();
Ok(())
}
/// Whether this buffer is CRDT-backed.
///
/// Threading: main thread only.
#[cfg(feature = "crdt")]
#[must_use]
pub fn is_crdt_backed(&self) -> bool {
self.crdt.is_some()
}
/// Read-only access to the CRDT state.
///
/// Used by the consistency property test
/// (`rope ≡ CRDT projection`) and by T M10.10's daemon-side
/// `BufferSnapshot` export: the dispatcher calls
/// `crdt_state().export_snapshot()` on each active buffer to
/// bootstrap a newly-attaching frontend's `BufferMirror`.
///
/// Workers continue to consume the rope projection per M10.1's
/// redirect; CRDT access is main-thread-only and limited to the
/// snapshot-export + wire-protocol paths.
#[cfg(feature = "crdt")]
pub fn crdt_state(&self) -> Option<&crate::crdt::CrdtState> {
self.crdt.as_ref()
}
/// This buffer's identifier.
///
/// Threading: main thread only (entire `Buffer` API is main-only).
#[must_use]
pub fn id(&self) -> BufferId {
self.id
}
/// This buffer's name. Typically a file path or a synthetic label
/// like `*scratch*`.
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
/// Set the buffer's name. Used by save-as and rename operations.
pub fn set_name(&mut self, name: impl Into<String>) {
self.name = name.into();
}
/// Whether the buffer has been modified since the last save / load.
#[must_use]
pub fn is_modified(&self) -> bool {
self.is_modified
}
/// Monotonic edit counter. Bumped by every successful forward edit,
/// undo, and redo. The active-buffer revision delta across a key
/// dispatch is the editor's "did this command edit the buffer?"
/// signal; LSP wiring uses it as the `textDocument/didChange`
/// document version.
#[must_use]
pub fn revision(&self) -> u64 {
self.revision
}
/// Mark the buffer as unmodified. Called after a successful save.
pub fn mark_clean(&mut self) {
self.is_modified = false;
}
/// Total length of the buffer in bytes.
#[must_use]
pub fn len(&self) -> Position {
self.rope.len()
}
/// True iff the buffer holds zero bytes.
#[must_use]
pub fn is_empty(&self) -> bool {
self.rope.is_empty()
}
/// Take a rope snapshot.
///
/// O(1) (an `Arc` bump). The returned [`Rope`] is independent of the
/// buffer: subsequent edits to the buffer do not affect it. Use this to
/// hand a consistent view to a worker (M3+).
///
/// Threading: main thread only (requires `&self`); the result is
/// `Send + Sync` and may be passed across threads.
#[must_use]
pub fn snapshot_rope(&self) -> Rope {
self.rope.snapshot()
}
/// Number of attached views.
#[must_use]
pub fn view_count(&self) -> usize {
self.views.len()
}
/// Iterate over the attached views' IDs in attach order.
pub fn view_ids(&self) -> impl Iterator<Item = ViewId> + '_ {
self.views.iter().map(|(id, _)| *id)
}
/// Attach a view. Returns the freshly allocated [`ViewId`].
pub fn attach_view(&mut self, view: Box<dyn View>) -> ViewId {
let id = ViewId(self.next_view_id);
self.next_view_id += 1;
self.views.push((id, view));
id
}
/// Detach the view with the given ID, returning ownership to the
/// caller. Returns `None` if `id` is not attached.
pub fn detach_view(&mut self, id: ViewId) -> Option<Box<dyn View>> {
let idx = self.views.iter().position(|(v, _)| *v == id)?;
Some(self.views.remove(idx).1)
}
/// Create a mark at byte position `pos`.
///
/// The position must be inside the current buffer (`pos <= len`).
/// The returned ID is scoped to this buffer.
pub fn create_mark(
&mut self,
pos: Position,
gravity: MarkGravity,
) -> Result<MarkId, BufferError> {
if pos > self.len() {
return Err(BufferError::Rope(RopeError::OutOfBounds {
pos,
len: self.len(),
}));
}
let id = MarkId(self.next_mark_id);
self.next_mark_id += 1;
self.marks.push((id, Mark { pos, gravity }));
Ok(id)
}
/// Current byte position of a mark, or `None` if it has been removed.
#[must_use]
pub fn mark_pos(&self, id: MarkId) -> Option<Position> {
self.marks
.iter()
.find_map(|(mark_id, mark)| (*mark_id == id).then_some(mark.pos))
}
/// Move an existing mark to `pos`.
///
/// Returns `Ok(false)` for an unknown mark ID. Out-of-bounds
/// positions are errors and leave the mark unchanged.
pub fn set_mark(&mut self, id: MarkId, pos: Position) -> Result<bool, BufferError> {
if pos > self.len() {
return Err(BufferError::Rope(RopeError::OutOfBounds {
pos,
len: self.len(),
}));
}
let Some((_, mark)) = self.marks.iter_mut().find(|(mark_id, _)| *mark_id == id) else {
return Ok(false);
};
mark.pos = pos;
Ok(true)
}
/// Remove a mark. Returns `true` if the mark existed.
pub fn remove_mark(&mut self, id: MarkId) -> bool {
let Some(idx) = self.marks.iter().position(|(mark_id, _)| *mark_id == id) else {
return false;
};
self.marks.remove(idx);
true
}
/// Take all attached views out of the buffer, returning ownership
/// to the caller (T M7.4).
///
/// Pair with [`Buffer::restore_views`]. While the views are taken
/// out, the buffer's view list is empty: `attach_view` calls
/// during this window land in the empty list and will be
/// preserved by `restore_views`.
///
/// Used by the Lua bindings to run the intercept chain with the
/// registry borrow released, so an intercept body may safely
/// re-enter the buffer API on any buffer (including this one,
/// modulo the `editing_in_progress` gate).
pub fn take_views(&mut self) -> Vec<(ViewId, Box<dyn View>)> {
std::mem::take(&mut self.views)
}
/// Restore previously-taken views.
///
/// Views attached during the take/restore window are preserved
/// and ordered after the restored set. Use case: a Lua intercept
/// body on buffer A calls `pmacs.buffer.add_intercept(A, ...)` to
/// install another intercept; the new view should sit after the
/// existing chain so the existing chain still runs first on
/// future edits.
pub fn restore_views(&mut self, mut original: Vec<(ViewId, Box<dyn View>)>) {
let new_additions = std::mem::take(&mut self.views);
original.extend(new_additions);
self.views = original;
}
/// Mark the buffer as mid-edit (T M7.4). Pairs with [`Buffer::end_edit`].
///
/// Returns [`BufferError::ConcurrentEdit`] if a previous
/// `begin_edit` is unmatched. The Lua bindings call this at the
/// start of the three-phase edit flow so that a re-entrant Lua
/// call into the same buffer's `apply_edit` /
/// `apply_edit_skip_intercepts` surfaces a typed error rather
/// than silently corrupting state.
pub fn begin_edit(&mut self) -> Result<(), BufferError> {
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
name: self.name.clone(),
});
}
self.editing_in_progress = true;
Ok(())
}
/// Clear the mid-edit flag set by [`Buffer::begin_edit`].
/// Idempotent. Lua bindings call this at the end of the edit
/// flow, before the final `apply_edit_skip_intercepts`.
pub fn end_edit(&mut self) {
self.editing_in_progress = false;
}
/// Whether the buffer is currently mid-edit (T M7.4).
/// Useful for diagnostic tooling; the in-process flow's
/// re-entrancy check happens inside `apply_edit` itself.
#[must_use]
pub fn editing_in_progress(&self) -> bool {
self.editing_in_progress
}
/// Apply an edit.
///
/// Walks the intercept-edit chain in attach order; applies the
/// (possibly rewritten) operation to the rope; pushes the previous
/// rope onto the undo stack and clears redo; broadcasts the edit to
/// each view's `on_edit`. Returns the [`Edit`] description.
///
/// On error the buffer is left in its pre-edit state and the undo
/// stack is unchanged.
///
/// # Re-entrancy (T M7.4)
///
/// In-process Rust callers run intercepts under the same `&mut Buffer`
/// borrow that owns the apply --- no re-entry path exists, so the
/// `editing_in_progress` flag is not set by this method (it is set
/// only by [`Buffer::begin_edit`], which the Lua bindings use to gate
/// same-buffer re-entry). A caller that does `b.apply_edit(...)`
/// while another `apply_edit` is on the stack for the same `b`
/// would already fail at `&mut` aliasing in safe Rust.
///
/// Threading: main thread only.
pub fn apply_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
name: self.name.clone(),
});
}
// Take views out so the loop body can borrow `&self` while iterating.
// The buffer is left view-less only for the duration of this call;
// panics during it would leave an empty view list (acceptable: views
// are held by `Box`, no resource leak).
let mut views = std::mem::take(&mut self.views);
let result = self.apply_edit_inner(&mut views, op);
// Restore views even on error.
self.views = views;
result
}
/// T M10.10 Finding 3 — apply a remotely-produced CRDT op to
/// this buffer.
///
/// Used by the daemon's `FrontendEvent::CrdtOp` handler when a
/// replica frontend forwards a CRDT op. The flow:
///
/// 1. Capture the rope's current bytes (rope ≡ CRDT projection
/// invariant — both have the same content pre-import).
/// 2. `crdt.import_updates(op_bytes)` — integrates the remote op
/// into the local CRDT state. CRDT convergence handles
/// concurrent edits.
/// 3. Materialize the post-import CRDT content.
/// 4. Compute the diff between pre- and post-content as a single
/// `Replace` `EditOp` (single insert/delete falls out as
/// Replace with empty inserted or empty range).
/// 5. Apply the rope stages (rope mutation + mark adjustment +
/// revision bump + modified flag + `on_edit` broadcast).
/// Skips the CRDT-application stage (already done in step 2)
/// AND the undo push (remote ops aren't locally undoable per
/// M10.4's per-peer undo design — loro's `UndoManager` tracks
/// history).
///
/// # Why the diff-then-EditOp shape
///
/// `on_edit` subscribers (`TextView`'s line cache, syntax
/// highlighter, overlay style maps, marks) require Edit
/// descriptions to maintain their incremental state. A naive
/// "replace rope wholesale" approach (set `self.rope =
/// new_materialized`) bypasses these subscribers — caches go
/// stale, marks lose translation. The single-Replace `EditOp`
/// preserves incremental updates throughout.
///
/// # Limitations / v0.2+ work
///
/// - Compound CRDT ops carrying multiple inserts/deletes
/// collapse to one Replace covering the whole changed region.
/// For M10.10's per-keystroke-op flow this is the actual shape
/// (one insert OR one delete per op); compound ops appearing
/// in v0.2+ would land here as a single coarse Replace,
/// acceptable but less efficient than per-sub-op processing.
/// - Marks within the replaced range are still subject to the
/// existing mark-adjustment-for-Replace logic; if mark
/// semantics need finer-grained handling for multi-op CRDT
/// updates, v0.2+ may migrate marks to loro cursor primitives.
///
/// # Errors
///
/// - Returns `BufferError::CrdtRejected` if the buffer isn't
/// CRDT-backed (caller should upgrade first per
/// `send_buffer_snapshots`/`ensure_active_buffer_crdt_backed`).
/// - Returns `BufferError::CrdtRejected` if `import_updates`
/// fails (e.g., malformed op bytes from a buggy peer).
///
/// Returns `Ok(None)` if the imported op produced no actual
/// content change (rare; could happen for already-integrated
/// ops in a CRDT-redundant edge case).
#[cfg(feature = "crdt")]
pub fn apply_remote_crdt_op(&mut self, op_bytes: &[u8]) -> Result<Option<Edit>, BufferError> {
if self.editing_in_progress {
return Err(BufferError::ConcurrentEdit {
id: self.id,
name: self.name.clone(),
});
}
let Some(crdt) = self.crdt.as_ref() else {
return Err(BufferError::CrdtRejected {
reason: "remote CrdtOp requires CRDT-backed buffer; daemon \
should call upgrade_to_crdt first"
.to_owned(),
});
};
// Step 1: capture pre-import bytes (rope ≡ CRDT projection
// invariant means rope.slice == crdt.materialize_string here).
let old_len = self.rope.len();
let mut old_bytes = vec![0u8; old_len as usize];
if old_len > 0 {
self.rope.slice(0, old_len, &mut old_bytes);
}
// Step 2: integrate the remote op into the CRDT state.
crdt.import_updates(op_bytes)
.map_err(|e| BufferError::CrdtRejected {
reason: format!("import_updates: {e:?}"),
})?;
// Step 3: materialize the post-import content.
let new_content = crdt.materialize_string();
let new_bytes = new_content.as_bytes();
// Step 4: compute common prefix/suffix at byte level, then
// **back off to UTF-8 char boundaries** in both strings.
//
// # Post-audit-round-4 F25: char-boundary alignment
//
// Naively splitting on byte equality can land mid-codepoint
// for compound CRDT updates that change a single character.
// Example: 'é' (`0xC3 0xA9`) → 'è' (`0xC3 0xA8`). Byte
// prefix = 1; range_start = 1 puts the rope edit's start
// inside the first codepoint, so the resulting `inserted`
// slice (`[0xA8]`) is not valid UTF-8 and downstream
// consumers (TextView line indexing, char-aware cursor
// motion) get an invalid byte stream.
//
// Fix: after computing byte-level prefix and suffix, walk
// both bounds outward (decreasing prefix, decreasing suffix)
// until they sit on char boundaries in BOTH old_str and
// new_str. `str::is_char_boundary(n)` is the standard test.
// The result: `range_start..range_end` always covers
// complete codepoints in both old and new content; the
// `inserted` slice is always a valid UTF-8 substring.
let old_str =
std::str::from_utf8(&old_bytes).expect("rope content is UTF-8 by project invariant");
let new_str = new_content.as_str();
let mut prefix = old_bytes
.iter()
.zip(new_bytes.iter())
.take_while(|(a, b)| a == b)
.count();
while prefix > 0 && !old_str.is_char_boundary(prefix) {
prefix -= 1;
}
// Cap suffix so prefix and suffix don't overlap on either side.
let max_suffix = (old_bytes.len() - prefix).min(new_bytes.len() - prefix);
let mut suffix = old_bytes
.iter()
.rev()
.zip(new_bytes.iter().rev())
.take_while(|(a, b)| a == b)
.count()
.min(max_suffix);
while suffix > 0
&& (!old_str.is_char_boundary(old_bytes.len() - suffix)
|| !new_str.is_char_boundary(new_bytes.len() - suffix))
{
suffix -= 1;
}
if prefix + suffix == old_bytes.len() && prefix + suffix == new_bytes.len() {
// Pre- and post-content are identical — import was a
// no-op (already-integrated op, or content-equivalent
// concurrent edit). Skip rope mutation; return None to
// signal "nothing changed."
return Ok(None);
}
let range_start = prefix as Position;
let range_end = (old_bytes.len() - suffix) as Position;
let inserted = &new_bytes[prefix..new_bytes.len() - suffix];
// Step 5: apply rope stages without re-applying to CRDT
// (CRDT was applied above in step 2) and without undo push
// (remote ops aren't locally undoable per M10.4).
let mut views = std::mem::take(&mut self.views);
let result = self.run_remote_rope_stages(&mut views, range_start, range_end, inserted);
self.views = views;
result.map(Some)
}
/// T M10.10 post-audit-round-4 F26 — verify that importing the
/// remote update `bytes` would attribute every new op to
/// `expected_peer_id`. Forked-import; doesn't mutate the buffer.
///
/// Returns `Ok(())` on match (or non-CRDT buffer — caller's
/// other validations gate that case). Returns `Err(actual)` for
/// the first peer mismatch found.
///
/// The daemon's `validate_remote_crdt_op` calls this after the
/// other identity / scope checks to ensure the loro-internal
/// peer attribution agrees with the wire wrapper's
/// `op.peer_id` (and therefore with the authenticated source).
#[cfg(feature = "crdt")]
pub fn validate_remote_op_peer_ids(
&self,
expected_peer_id: u64,
bytes: &[u8],
) -> Result<(), u64> {
if let Some(crdt) = self.crdt.as_ref() {
crdt.validate_update_peer_ids(expected_peer_id, bytes)
} else {
// Non-CRDT buffer: the apply will fail downstream with a
// clearer error. Nothing to validate here.
Ok(())
}
}
/// T M10.10 — rope-stages-only path for remote CRDT ops. Mirrors
/// `run_rope_edit_and_broadcast`'s stages 24 but skips CRDT
/// application (already done) and undo push (remote ops aren't
/// locally undoable). Always called from
/// [`apply_remote_crdt_op`](Self::apply_remote_crdt_op).
#[cfg(feature = "crdt")]
fn run_remote_rope_stages(
&mut self,
views: &mut [(ViewId, Box<dyn View>)],
range_start: Position,
range_end: Position,
inserted: &[u8],
) -> Result<Edit, BufferError> {
// Stage 2: rope edit (single Replace covering the diff).
let edit = self.rope.replace(range_start, range_end, inserted)?;
// Stage 3: state update (mark adjustment + revision bump +
// modified flag; no undo push for remote ops).
let pre_range = edit.range;
let inserted_len = edit.inserted_len;
self.rope = edit.new_rope.clone();
self.adjust_marks_for_edit(pre_range, inserted_len);
self.is_modified = true;
self.revision = self.revision.wrapping_add(1);
// Stage 4: broadcast on_edit so views update incrementally
// (TextView line cache, syntax highlighter, overlays, etc.).
for (_, view) in views.iter_mut() {
view.on_edit(self, &edit)?;
}
Ok(edit)
}
/// Apply an edit, skipping the intercept chain.
///
/// Used by the Lua bindings (T M7.4) after they have run intercepts
/// out-of-band with the registry borrow released. Behaves like
/// [`Buffer::apply_edit`] from "rope edit" onward: rope mutation,
/// undo bookkeeping, modified flag, revision bump, and `on_edit`
/// broadcast all happen here.
///
/// In-process Rust callers should use [`Buffer::apply_edit`]
/// instead --- this primitive exists for the case where the
/// caller has already evaluated the intercept chain and has the
/// final [`EditOp`] in hand.
#[allow(
clippy::needless_pass_by_value,
reason = "by-value mirrors apply_edit's signature; the Lua bindings build a fresh EditOp per call"
)]
pub fn apply_edit_skip_intercepts(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> {
let mut views = std::mem::take(&mut self.views);
let result = self.run_rope_edit_and_broadcast(&mut views, &op);
self.views = views;
result
}
fn apply_edit_inner(
&mut self,
views: &mut [(ViewId, Box<dyn View>)],
op: EditOp<'_>,
) -> Result<Edit, BufferError> {
// Stage 1: intercept chain.
let mut current = op;
let ctx = InterceptContext::snapshot(self);
for (_, view) in views.iter_mut() {
current = view.intercept_edit(&ctx, current)?;
}
// Stages 2-4: rope edit + state update + broadcast.
self.run_rope_edit_and_broadcast(views, &current)
}
/// T M10.2: apply an `EditOp` to the CRDT, return the lossy-
/// normalized byte payload (if any) so the rope can mirror it,
/// AND (Day 3) the wire-format CRDT op bytes for the originating
/// edit so the resulting `Edit::crdt_op` carries them.
///
/// Per Q2 (defense-in-depth): the CRDT op runs first; on failure
/// the rope is untouched. The bytes are converted to UTF-8 via
/// `from_utf8_lossy` (matching `CrdtState::from_bytes`); for valid
/// UTF-8 inputs this is a no-op, for ill-formed bytes the CRDT
/// sees U+FFFD substitution and the rope must too (to preserve
/// the `rope ≡ CRDT projection` invariant).
///
/// Day 3 addition: the version-capture / export idiom captures
/// pre-version BEFORE applying ops, then exports the delta AFTER.
/// This produces the wire-format bytes for THIS edit's ops (one
/// op for Insert/Delete, two ops for Replace). The bytes are
/// what M10.5 (wire protocol) sends across the network.
///
/// Returns `(normalized_bytes, crdt_op)`:
///
/// * `normalized_bytes`: `Some` if the rope must mirror lossy-
/// converted bytes (UTF-8 normalization happened); `None` if
/// the original bytes round-trip cleanly OR for delete-only ops.
/// * `crdt_op`: `Some` carrying `peer_id` + wire bytes if any
/// CRDT op was applied; `None` for true no-op edits (Q5
/// detection path: pre-checked at the `EditOp` level so true
/// no-ops skip the CRDT path entirely; this function isn't
/// invoked for them).
#[cfg(feature = "crdt")]
fn apply_to_crdt_then_normalize_bytes(
crdt: &crate::crdt::CrdtState,
op: &EditOp<'_>,
) -> Result<CrdtRoutingResult, BufferError> {
// Capture the pre-edit oplog frontier so the post-edit export
// returns exactly the ops produced by THIS edit. Loro's
// transactional model gives a consistent before/after pair.
let pre_version = crdt.version();
let normalized: Option<Vec<u8>> = match op {
EditOp::Insert { pos, bytes } => {
if bytes.is_empty() {
// Pre-checked at the caller (no-op detection),
// but defensive-return-None-here in case a future
// caller forgets.
return Ok((None, None));
}
let s = String::from_utf8_lossy(bytes);
crdt.insert(*pos as usize, &s)?;
if matches!(s, std::borrow::Cow::Borrowed(_)) {
None
} else {
Some(s.into_owned().into_bytes())
}
}
EditOp::Delete { range } => {
if range.is_empty() {
return Ok((None, None));
}
crdt.delete(range.start as usize, range.len() as usize)?;
None
}
EditOp::Replace { range, bytes } => {
// Two CRDT ops (no splice_utf8 in loro 1.12 per the
// morning audit). Order: delete, then insert. If
// delete succeeds and insert fails, the CRDT is
// mid-transaction (range deleted but replacement
// not inserted) and the rope is unchanged. This is
// an invariant violation; loro's insert is
// expected to succeed if the position is valid
// (which it is by construction). Treat insert
// failure here as a bug worth surfacing.
if !range.is_empty() {
crdt.delete(range.start as usize, range.len() as usize)?;
}
if bytes.is_empty() {
// Replace { non-empty range, empty bytes } is
// semantically a delete; the delete above already
// ran, no further op is needed. Fall through to
// exporting the delta below.
None
} else {
let s = String::from_utf8_lossy(bytes);
crdt.insert(range.start as usize, &s)?;
if matches!(s, std::borrow::Cow::Borrowed(_)) {
None
} else {
Some(s.into_owned().into_bytes())
}
}
}
};
// Export the wire bytes for the delta produced by the ops
// above. This is the `crdt_op` field on the resulting Edit.
let bytes = crdt.export_updates_since(&pre_version)?;
let crdt_op = Box::new(crate::rope::CrdtOp {
peer_id: crdt.peer_id(),
bytes,
});
Ok((normalized, Some(crdt_op)))
}
fn run_rope_edit_and_broadcast(
&mut self,
views: &mut [(ViewId, Box<dyn View>)],
current: &EditOp<'_>,
) -> Result<Edit, BufferError> {
// T M10.2: CRDT routing (Q2 defense-in-depth ordering — CRDT
// first, then rope; if CRDT errors, abort before rope mutation).
// The byte → str conversion uses `from_utf8_lossy` per the
// documented divergence: ill-formed bytes become U+FFFD in the
// CRDT and (under v0.1 byte-permissive rope) would diverge. To
// keep the invariant `rope ≡ CRDT projection`, the rope ALSO
// sees the lossy bytes when CRDT mode is active. v0.1 mode
// (CRDT off) is unchanged.
#[cfg(feature = "crdt")]
let (lossy_owned, captured_crdt_op): (
Option<Vec<u8>>,
Option<Box<crate::rope::CrdtOp>>,
) = match (&self.crdt, is_no_op_edit(current)) {
(Some(crdt), false) => Self::apply_to_crdt_then_normalize_bytes(crdt, current)?,
_ => (None, None),
};
// Stage 2: rope edit. In CRDT mode, the EditOp's byte payload
// is replaced by the lossy-normalized version so the rope
// matches the CRDT projection (the invariant the proptest
// pins).
#[cfg(feature = "crdt")]
let normalized: Option<EditOp<'_>> = lossy_owned.as_deref().map(|bytes| match current {
EditOp::Insert { pos, .. } => EditOp::Insert { pos: *pos, bytes },
EditOp::Replace { range, .. } => EditOp::Replace {
range: *range,
bytes,
},
EditOp::Delete { range } => EditOp::Delete { range: *range },
});
#[cfg(feature = "crdt")]
let current = normalized.as_ref().unwrap_or(current);
#[cfg_attr(not(feature = "crdt"), allow(unused_mut))]
let mut edit = match current {
EditOp::Insert { pos, bytes } => self.rope.insert(*pos, bytes)?,
EditOp::Delete { range } => self.rope.delete(range.start, range.end)?,
EditOp::Replace { range, bytes } => self.rope.replace(range.start, range.end, bytes)?,
};
// T M10.2 Day 3: populate the Edit's crdt_op field with the
// wire-format bytes captured by the CRDT routing above.
// Mutation pattern: rope returns Edit with crdt_op = None;
// Buffer mutates the field before the Edit is returned to
// any consumer. The "moment of partial construction" is
// internal to apply_edit; consumers always see fully-
// constructed Edits. Don't refactor this away under
// "Edits should be immutable" reasoning — the alternative
// is double-allocation per edit.
#[cfg(feature = "crdt")]
{
edit.crdt_op = captured_crdt_op;
}
// No-op: empty insert, empty-range delete, or replace-empty-with-
// empty all roundtrip with no actual change. Skip undo bookkeeping
// and don't mark the buffer modified --- the spec's edit flow
// describes "successful edits" pushing onto undo, and a no-op
// doesn't count.
if edit.range.is_empty() && edit.inserted_len == 0 {
// We still broadcast on_edit so views don't miss a deliberate
// no-op (e.g. callers that count the call). The rope is
// unchanged, so we don't swap it.
for (_, view) in views.iter_mut() {
view.on_edit(self, &edit)?;
}
return Ok(edit);
}
// Stage 3: state update.
let pre_range = edit.range;
let inserted_len = edit.inserted_len;
let old_rope = std::mem::replace(&mut self.rope, edit.new_rope.clone());
self.adjust_marks_for_edit(pre_range, inserted_len);
// T M10.4: in CRDT mode, loro's UndoManager tracks undo
// history; the v0.1 self.undo stack is bypassed (would grow
// unboundedly otherwise since nothing pops it in CRDT mode).
// The redo stack is similarly unused in CRDT mode.
#[cfg(feature = "crdt")]
let in_crdt_mode = self.crdt.is_some();
#[cfg(not(feature = "crdt"))]
let in_crdt_mode = false;
if in_crdt_mode {
// CRDT mode: loro's UndoManager tracks history; drop the
// old rope (in v0.1 it's owned by the pushed UndoEntry,
// in CRDT mode it's released here).
drop(old_rope);
} else {
self.undo.push(UndoEntry {
rope: old_rope,
edit: EditDescription {
pre_range,
inserted_len,
},
});
self.redo.clear();
}
self.is_modified = true;
self.revision = self.revision.wrapping_add(1);
// Stage 4: broadcast.
for (_, view) in views.iter_mut() {
view.on_edit(self, &edit)?;
}
Ok(edit)
}
/// Undo the most recent edit.
///
/// Returns the [`Edit`] description of the inverse change. The most
/// recent forward edit is moved from the undo stack to the redo stack.
/// On error (nothing to undo), the buffer is unchanged.
///
/// Threading: main thread only.
pub fn undo(&mut self) -> Result<Edit, BufferError> {
// T M10.4: in CRDT mode, route through loro's UndoManager via
// the materialize-and-replace path (Day 1 morning audit
// decision — path (a)). Inverse ops are produced as proper
// CRDT ops by UndoManager, interacting with concurrent remote
// ops via CRDT convergence rules.
#[cfg(feature = "crdt")]
if self.crdt.is_some() {
return self.undo_crdt_mode();
}
// v0.1 mode: pop the saved UndoEntry, swap the rope back,
// push onto redo stack.
let entry = self.undo.pop().ok_or(BufferError::NothingToUndo)?;
// The pre-edit rope held in `entry.rope` becomes current. The
// inverse edit affects the post-state's range
// `[pre_range.start, pre_range.start + inserted_len)` and produces
// `pre_range.len()` bytes (the bytes originally at `pre_range`).
let inverse_pre_range = Range::new(
entry.edit.pre_range.start,
entry.edit.pre_range.start + entry.edit.inserted_len,
);
let inverse_inserted_len = entry.edit.pre_range.len();
let new_rope = entry.rope.clone();
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
self.adjust_marks_for_edit(inverse_pre_range, inverse_inserted_len);
self.redo.push(UndoEntry {
rope: old_rope,
edit: EditDescription {
pre_range: inverse_pre_range,
inserted_len: inverse_inserted_len,
},
});
self.is_modified = !self.undo.is_empty();
self.revision = self.revision.wrapping_add(1);
let inverse_edit = Edit {
new_rope,
range: inverse_pre_range,
inserted_len: inverse_inserted_len,
crdt_op: None,
};
self.broadcast_on_edit(&inverse_edit)?;
Ok(inverse_edit)
}
/// T M10.4: CRDT-mode undo via loro's `UndoManager`.
///
/// Materialize-and-replace path (Day 1 morning audit decision).
/// Inverse ops are produced by `UndoManager` as proper CRDT ops
/// (not synthetic Replace as M10.2's path did); they interact
/// with concurrent remote ops via CRDT convergence rules.
///
/// The Edit description is derived via [`derive_replacement_edit`]:
/// longest-common-prefix + longest-common-suffix trim against the
/// pre-undo rope. This produces a minimal `(range, inserted_len)`
/// covering exactly the bytes that changed, so marks adjust
/// correctly and tree-sitter's incremental parse stays
/// incremental. Cost: O(min(`old_len`, `new_len`)) byte compare via
/// rope chunks; sub-ms at typical edit sizes.
#[cfg(feature = "crdt")]
fn undo_crdt_mode(&mut self) -> Result<Edit, BufferError> {
// Extract everything we need from `self.crdt` before mutating
// `self.rope` / `self.marks` / `self.revision` etc., to
// avoid a borrow-checker conflict between the immutable
// crdt-ref and the upcoming `&mut self` method calls.
let (new_text, bytes, peer_id, can_undo_after) = {
let crdt = self.crdt.as_ref().expect("checked");
let pre_version = crdt.version();
let undid = crdt.undo()?;
if !undid {
return Err(BufferError::NothingToUndo);
}
let new_text = crdt.materialize_string();
let bytes = crdt.export_updates_since(&pre_version)?;
(new_text, bytes, crdt.peer_id(), crdt.can_undo())
};
let new_rope = crate::rope::Rope::from_bytes(new_text.as_bytes());
let (range, inserted_len) = derive_replacement_edit(&self.rope, &new_rope);
self.rope = new_rope.clone();
self.adjust_marks_for_edit(range, inserted_len);
self.revision = self.revision.wrapping_add(1);
// is_modified stays true while there's still anything in the
// CRDT's undo stack (i.e. local edits not yet at the buffer's
// saved baseline). Matches v0.1 mode's `!self.undo.is_empty()`
// semantics translated to CrdtState's bookkeeping.
self.is_modified = can_undo_after;
let inverse_edit = Edit {
new_rope,
range,
inserted_len,
crdt_op: Some(Box::new(crate::rope::CrdtOp { peer_id, bytes })),
};
self.broadcast_on_edit(&inverse_edit)?;
Ok(inverse_edit)
}
/// Redo a previously undone edit.
///
/// Symmetric to [`Buffer::undo`]. The redo stack is cleared by any
/// forward edit, so `redo` is only meaningful immediately after a
/// sequence of `undo`s.
///
/// Threading: main thread only.
pub fn redo(&mut self) -> Result<Edit, BufferError> {
// T M10.4: in CRDT mode, route through loro's UndoManager.
#[cfg(feature = "crdt")]
if self.crdt.is_some() {
return self.redo_crdt_mode();
}
// v0.1 mode: pop the saved redo entry, swap the rope forward.
let entry = self.redo.pop().ok_or(BufferError::NothingToRedo)?;
let inverse_pre_range = Range::new(
entry.edit.pre_range.start,
entry.edit.pre_range.start + entry.edit.inserted_len,
);
let inverse_inserted_len = entry.edit.pre_range.len();
let new_rope = entry.rope.clone();
let old_rope = std::mem::replace(&mut self.rope, new_rope.clone());
self.adjust_marks_for_edit(inverse_pre_range, inverse_inserted_len);
self.undo.push(UndoEntry {
rope: old_rope,
edit: EditDescription {
pre_range: inverse_pre_range,
inserted_len: inverse_inserted_len,
},
});
self.is_modified = true;
self.revision = self.revision.wrapping_add(1);
let inverse_edit = Edit {
new_rope,
range: inverse_pre_range,
inserted_len: inverse_inserted_len,
crdt_op: None,
};
self.broadcast_on_edit(&inverse_edit)?;
Ok(inverse_edit)
}
/// T M10.4: CRDT-mode redo via loro's `UndoManager`.
///
/// Symmetric to [`Self::undo_crdt_mode`]; same materialize-and-
/// replace path.
#[cfg(feature = "crdt")]
fn redo_crdt_mode(&mut self) -> Result<Edit, BufferError> {
let (new_text, bytes, peer_id) = {
let crdt = self.crdt.as_ref().expect("checked");
let pre_version = crdt.version();
let redid = crdt.redo()?;
if !redid {
return Err(BufferError::NothingToRedo);
}
let new_text = crdt.materialize_string();
let bytes = crdt.export_updates_since(&pre_version)?;
(new_text, bytes, crdt.peer_id())
};
let new_rope = crate::rope::Rope::from_bytes(new_text.as_bytes());
let (range, inserted_len) = derive_replacement_edit(&self.rope, &new_rope);
self.rope = new_rope.clone();
self.adjust_marks_for_edit(range, inserted_len);
self.revision = self.revision.wrapping_add(1);
self.is_modified = true;
let inverse_edit = Edit {
new_rope,
range,
inserted_len,
crdt_op: Some(Box::new(crate::rope::CrdtOp { peer_id, bytes })),
};
self.broadcast_on_edit(&inverse_edit)?;
Ok(inverse_edit)
}
/// T M10.4: per-frontend undo for a specific attached frontend.
///
/// **M10.11 architecture-record:** the M10.4 framing predicted
/// that this method would dispatch by `frontend_id` to a
/// `HashMap<FrontendId, UndoManager>` on the buffer. M10.11's
/// Day 2 verification surfaced that loro's `UndoManager` binds
/// to one peer at construction (`src/crdt.rs:60-65`,
/// `loro-internal/src/undo.rs:572-672`) — you can't maintain
/// per-peer `UndoManager` instances on a single doc. The
/// CRDT-native per-frontend undo path lives on the **frontend**
/// side: each `BufferMirror` holds its own `CrdtState` whose
/// `UndoManager` is bound to that frontend's `peer_id` (see
/// `BufferMirror::apply_local_undo` and
/// `optimistic::frontend_event_for_keystroke`'s
/// `OptimisticAction::Undo` arm). The frontend produces an
/// inverse `CrdtOp` and the daemon imports it as an ordinary
/// update. The daemon-side `Buffer::undo` (this method's
/// no-arg sibling) remains the daemon-peer-only undo path —
/// used for Lua-driven daemon-side edits and the v0.1 single-
/// frontend mode.
///
/// This method therefore routes `frontend_id` arguments to
/// `Self::undo` directly: there is no per-frontend dispatch to
/// do at the buffer level. The signature is preserved for any
/// callers that were threading a frontend id; behavior is
/// unchanged from the M10.4 single-frontend semantics.
///
/// Threading: main thread only.
pub fn undo_for(
&mut self,
_frontend_id: crate::protocol::FrontendId,
) -> Result<Edit, BufferError> {
// Per the M10.11 architecture record above: per-frontend
// undo lives frontend-side via BufferMirror's peer-bound
// UndoManager. Daemon-side undo is daemon-peer-scoped.
self.undo()
}
/// T M10.4: symmetric to [`Self::undo_for`]. Same architecture
/// record applies: per-frontend redo lives frontend-side.
pub fn redo_for(
&mut self,
_frontend_id: crate::protocol::FrontendId,
) -> Result<Edit, BufferError> {
self.redo()
}
// T M10.4: `sync_crdt_for_history_swap` removed. M10.2 Day 2's
// synthetic-Replace path produced inverse ops attributed to the
// editing peer that looked like fresh edits to the CRDT (not
// semantic undos). M10.4 replaces this with loro's UndoManager
// (see `undo_crdt_mode` / `redo_crdt_mode` above), which produces
// proper inverse ops that interact correctly with concurrent
// remote edits per the M10.4 acceptance: "B's edit lands on
// whatever surrounding text remains."
fn broadcast_on_edit(&mut self, edit: &Edit) -> Result<(), BufferError> {
let mut views = std::mem::take(&mut self.views);
let result = (|| {
for (_, view) in &mut views {
view.on_edit(self, edit)?;
}
Ok(())
})();
self.views = views;
result
}
fn adjust_marks_for_edit(&mut self, range: Range, inserted_len: u64) {
let start = range.start;
let end = range.end;
let old_len = range.len();
let new_end = start.saturating_add(inserted_len);
for (_, mark) in &mut self.marks {
let pos = mark.pos;
mark.pos = if pos < start {
pos
} else if pos > end {
pos - old_len + inserted_len
} else if pos == start {
if old_len == 0 && mark.gravity == MarkGravity::Right {
new_end
} else if old_len == 0 {
start
} else {
match mark.gravity {
MarkGravity::Left => start,
MarkGravity::Right => new_end,
}
}
} else {
match mark.gravity {
MarkGravity::Left => start,
MarkGravity::Right => new_end,
}
};
}
}
}
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/// T M10.2 Day 3 type alias: the routing result tuple — lossy-
/// normalized bytes (if any) plus the captured CRDT op (if any).
/// Factored out to silence clippy's `type_complexity`.
#[cfg(feature = "crdt")]
type CrdtRoutingResult = (Option<Vec<u8>>, Option<Box<crate::rope::CrdtOp>>);
/// T M10.4: derive a fine-grained `(range, inserted_len)` Edit
/// description for the change from `old_rope` to `new_rope` via
/// longest-common-prefix + longest-common-suffix trim.
///
/// CRDT-mode undo/redo materializes the post-undo rope via the CRDT
/// projection (path (a) from Day 1 morning's audit). A coarse Edit
/// description `(0..old_len, new_len)` would break mark positions
/// (every mark would be moved through a full-replace) and force
/// tree-sitter to re-parse the whole document. This helper computes
/// the minimal Edit description by trimming matching prefix +
/// suffix from both ropes.
///
/// Correctness: assumes the change is a single contiguous edit
/// (which `UndoManager`.undo / .redo always produces — each undo
/// reverses one logical `apply_edit` op). For multi-edit changes
/// (concurrent remote ops applied during the undo, hypothetically)
/// the derived Edit description is still correct in the sense that
/// applying it to `old_rope` produces `new_rope` — the change just
/// covers a wider range.
///
/// Cost: O(min(`old_len`, `new_len`)) byte comparison via rope
/// chunk iteration. At 1MB doc size with one-keystroke undo, the
/// prefix walk hits the divergence point within microseconds; same
/// for the suffix walk.
#[cfg(feature = "crdt")]
fn derive_replacement_edit(old_rope: &Rope, new_rope: &Rope) -> (Range, u64) {
let old_len = old_rope.len();
let new_len = new_rope.len();
if old_len == 0 && new_len == 0 {
return (Range::new(0, 0), 0);
}
// Longest common prefix.
let mut prefix = 0u64;
let max_prefix = old_len.min(new_len);
let chunk = 4096u64.min(max_prefix);
while prefix < max_prefix {
let n = chunk.min(max_prefix - prefix);
let mut a = vec![0u8; n as usize];
let mut b = vec![0u8; n as usize];
old_rope.slice(prefix, prefix + n, &mut a);
new_rope.slice(prefix, prefix + n, &mut b);
let mismatch = a.iter().zip(b.iter()).position(|(x, y)| x != y);
if let Some(off) = mismatch {
prefix += off as u64;
break;
}
prefix += n;
}
// Longest common suffix (bounded so we don't overlap with prefix).
let mut suffix = 0u64;
let max_suffix = (old_len - prefix).min(new_len - prefix);
while suffix < max_suffix {
let n = chunk.min(max_suffix - suffix);
let mut a = vec![0u8; n as usize];
let mut b = vec![0u8; n as usize];
old_rope.slice(old_len - suffix - n, old_len - suffix, &mut a);
new_rope.slice(new_len - suffix - n, new_len - suffix, &mut b);
let mismatch = a.iter().rev().zip(b.iter().rev()).position(|(x, y)| x != y);
if let Some(off) = mismatch {
suffix += off as u64;
break;
}
suffix += n;
}
let range = Range::new(prefix, old_len - suffix);
let inserted_len = new_len - prefix - suffix;
(range, inserted_len)
}
/// T M10.2 Day 3: pre-check an `EditOp` for the no-op case so the
/// CRDT path can be skipped entirely (Q5 detection: pre-check the
/// `EditOp` variants explicitly; truly empty edits skip the CRDT
/// path; partially-empty Replace variants delegate to insert or
/// delete semantics and are NOT no-ops).
///
/// Truly no-op cases:
/// * `Insert { bytes: empty }` — inserts nothing
/// * `Delete { range: empty }` — deletes nothing
/// * `Replace { range: empty, bytes: empty }` — neither deletes nor inserts
///
/// `Replace` with non-empty range OR non-empty bytes is NOT a no-op:
/// it has actual semantic effect (delete-only or insert-only or
/// both) that the CRDT must observe to keep the rope ≡ projection
/// invariant. The rope path's own no-op short-circuit handles the
/// truly-empty cases AFTER the rope mutation runs; this helper lets
/// the CRDT path skip the round-trip BEFORE the rope runs.
#[cfg(feature = "crdt")]
fn is_no_op_edit(op: &EditOp<'_>) -> bool {
match op {
EditOp::Insert { bytes, .. } => bytes.is_empty(),
EditOp::Delete { range } => range.is_empty(),
EditOp::Replace { range, bytes } => range.is_empty() && bytes.is_empty(),
}
}
/// Errors produced by [`Buffer`] operations.
#[derive(Debug, thiserror::Error)]
pub enum BufferError {
/// The underlying rope rejected the operation.
#[error("rope error: {0}")]
Rope(#[from] RopeError),
/// `undo` was called with an empty undo stack.
#[error("nothing to undo")]
NothingToUndo,
/// `redo` was called with an empty redo stack.
#[error("nothing to redo")]
NothingToRedo,
/// A view's `intercept_edit` rejected the operation with a typed
/// reason. Used by the M6.4 REPL package's Lua intercept to surface
/// "this region is read-only" without coopting an unrelated rope
/// error variant; available to any future view that wants to
/// reject with a human-readable message.
#[error("intercept rejected the edit: {reason}")]
Intercepted {
/// Human-readable reason. Surfaced verbatim to the user.
reason: String,
},
/// A re-entrant edit was attempted on a buffer that is already
/// mid-edit (T M7.4). The most common path: a Lua intercept body
/// running on buffer A called `A:insert(...)` or similar.
/// Cross-buffer re-entry (`A`'s intercept editing `B`) is allowed
/// and does not surface this error.
///
/// The message names a workaround per the project convention.
#[error(
"buffer `{name}` (id {id:?}) is already being edited; \
re-entrant edits on the same buffer are not supported. \
To compose with the current edit, return a transformed table \
from this intercept; to schedule a follow-up edit, register an \
on-edit hook that runs after the current edit completes, or \
edit a different buffer."
)]
ConcurrentEdit {
/// The buffer's identifier.
id: BufferId,
/// The buffer's name, for diagnostics.
name: String,
},
/// The CRDT-backed buffer mode rejected an op (T M10.2). Reaches
/// the caller when loro returns an error during edit routing — the
/// most common case is a mid-codepoint position that loro's
/// `insert_utf8` / `delete_utf8` rejects (per the M10.2 Day 2
/// morning audit). Per the Q2 defense-in-depth ordering, the
/// CRDT op is attempted before the rope mutation, so this error
/// leaves the rope unchanged.
#[cfg(feature = "crdt")]
#[error("CRDT edit rejected: {reason}")]
CrdtRejected {
/// Human-readable reason from loro. Surfaced verbatim.
reason: String,
},
}
#[cfg(feature = "crdt")]
impl From<loro::LoroError> for BufferError {
fn from(e: loro::LoroError) -> Self {
BufferError::CrdtRejected {
reason: e.to_string(),
}
}
}
#[cfg(feature = "crdt")]
impl From<loro::LoroEncodeError> for BufferError {
fn from(e: loro::LoroEncodeError) -> Self {
BufferError::CrdtRejected {
reason: format!("CRDT encode failed: {e}"),
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::view::View;
use std::sync::Mutex;
// T M10.2 Day 5: `fresh()` helper removed — every test that used
// it has been converted to `dual_mode_test!` and uses the
// injected `make`/`make_bytes` factories instead. v0.1-only tests
// construct via `Buffer::new` / `Buffer::from_bytes` directly;
// CRDT-only tests use `Buffer::new_with_crdt` / `from_bytes_with_crdt`.
// -----------------------------------------------------------------
// T M10.2 Day 5 — dual-mode test harness.
//
// `dual_mode_test!` generates two `#[test]` entries (`<name>::v01`
// and `<name>::crdt`) from one test body. Each entry runs the body
// with a different buffer factory:
//
// * `make(name)` → empty buffer
// * `make_bytes(name, bytes)` → buffer seeded with bytes
//
// The body picks whichever factory it needs; tests that only want
// the empty case ignore `make_bytes` (and vice versa) via `_`-
// prefixed names. The CRDT entry is `#[cfg(feature = "crdt")]`,
// so v0.1 builds get only the v0.1 test.
//
// Why per-mode `#[test]` entries vs one test running both modes:
// failure messages identify which mode failed without ambiguity.
// `<name>::v01` and `<name>::crdt` show up as distinct entries in
// the test runner's output.
//
// Day 5 converts the existing buffer tests that exercise the
// apply_edit / undo / redo / intercept paths. Day 6 classifies
// any divergences this surfaces.
// -----------------------------------------------------------------
macro_rules! dual_mode_test {
($name:ident, |$make:ident, $make_bytes:ident| $body:block) => {
mod $name {
use super::*;
#[test]
fn v01() {
let $make = |n: &str| Buffer::new(BufferId::next(), n.to_owned());
let $make_bytes = |n: &str, bytes: &[u8]| {
Buffer::from_bytes(BufferId::next(), n.to_owned(), bytes)
};
$body
}
#[cfg(feature = "crdt")]
#[test]
fn crdt() {
let $make = |n: &str| {
Buffer::new_with_crdt(BufferId::next(), n.to_owned(), 1)
.expect("CRDT-mode buffer construction failed")
};
let $make_bytes = |n: &str, bytes: &[u8]| {
Buffer::from_bytes_with_crdt(BufferId::next(), n.to_owned(), bytes, 1)
.expect("CRDT-mode from_bytes failed")
};
$body
}
}
};
}
fn collect(buf: &Buffer) -> Vec<u8> {
let mut out = vec![0u8; buf.len() as usize];
if !out.is_empty() {
buf.snapshot_rope().slice(0, buf.len(), &mut out);
}
out
}
// A view that records every callback for assertions.
#[derive(Default)]
struct RecorderView {
events: std::sync::Arc<Mutex<Vec<RecorderEvent>>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum RecorderEvent {
Intercept { pre_len: u64 },
OnEdit { post_len: u64, inserted: u64 },
}
impl View for RecorderView {
fn intercept_edit<'a>(
&mut self,
ctx: &crate::view::InterceptContext,
op: EditOp<'a>,
) -> Result<EditOp<'a>, BufferError> {
self.events.lock().unwrap().push(RecorderEvent::Intercept {
pre_len: ctx.buf_len,
});
Ok(op)
}
fn on_edit(&mut self, buf: &Buffer, edit: &Edit) -> Result<(), BufferError> {
self.events.lock().unwrap().push(RecorderEvent::OnEdit {
post_len: buf.len(),
inserted: edit.inserted_len,
});
Ok(())
}
}
// A view that rewrites every Insert into Insert with reversed bytes.
// Used to confirm intercept_edit runs *before* the rope edit and
// its rewrite is what reaches the rope.
struct ReverseInsertView;
impl View for ReverseInsertView {
fn intercept_edit<'a>(
&mut self,
_ctx: &crate::view::InterceptContext,
op: EditOp<'a>,
) -> Result<EditOp<'a>, BufferError> {
// Cannot return EditOp with owned bytes given the lifetime
// constraint; instead pass through and use Replace with the
// same range. Demonstration uses a different transform:
// unconditionally reject deletes.
match op {
EditOp::Delete { .. } => Err(BufferError::NothingToUndo),
other => Ok(other),
}
}
}
// ----- identity / construction -----
#[test]
fn buffer_id_is_unique() {
let a = BufferId::next();
let b = BufferId::next();
assert_ne!(a, b);
}
dual_mode_test!(new_is_empty_and_clean, |make, _make_bytes| {
let b = make("*scratch*");
assert!(b.is_empty());
assert!(!b.is_modified());
assert_eq!(b.name(), "*scratch*");
assert_eq!(b.view_count(), 0);
});
dual_mode_test!(from_bytes_preserves_content, |_make, make_bytes| {
let b = make_bytes("hello.txt", b"hello world");
assert_eq!(b.len(), 11);
assert_eq!(collect(&b), b"hello world");
assert!(!b.is_modified());
});
// ----- view attach / detach -----
dual_mode_test!(attach_and_detach_view, |make, _make_bytes| {
let mut b = make("*scratch*");
let id = b.attach_view(Box::new(RecorderView::default()));
assert_eq!(b.view_count(), 1);
let detached = b.detach_view(id).expect("present");
let _ = detached;
assert_eq!(b.view_count(), 0);
assert!(b.detach_view(id).is_none());
});
// ----- edit flow -----
dual_mode_test!(apply_insert_edit_updates_state, |make, _make_bytes| {
let mut b = make("*scratch*");
let edit = b
.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"abc",
})
.unwrap();
assert_eq!(edit.range, Range::new(0, 0));
assert_eq!(edit.inserted_len, 3);
assert_eq!(b.len(), 3);
assert!(b.is_modified());
assert_eq!(collect(&b), b"abc");
});
dual_mode_test!(marks_apply_insertion_gravity, |_make, make_bytes| {
let mut b = make_bytes("test", b"abcd");
let left = b.create_mark(2, MarkGravity::Left).unwrap();
let right = b.create_mark(2, MarkGravity::Right).unwrap();
b.apply_edit(EditOp::Insert {
pos: 2,
bytes: b"XX",
})
.unwrap();
assert_eq!(b.mark_pos(left), Some(2));
assert_eq!(b.mark_pos(right), Some(4));
});
dual_mode_test!(marks_shift_and_clamp_through_delete, |_make, make_bytes| {
let mut b = make_bytes("test", b"abcdef");
let before = b.create_mark(1, MarkGravity::Right).unwrap();
let inside = b.create_mark(3, MarkGravity::Left).unwrap();
let after = b.create_mark(5, MarkGravity::Right).unwrap();
b.apply_edit(EditOp::Delete {
range: Range::new(2, 4),
})
.unwrap();
assert_eq!(b.mark_pos(before), Some(1));
assert_eq!(b.mark_pos(inside), Some(2));
assert_eq!(b.mark_pos(after), Some(3));
});
dual_mode_test!(marks_follow_undo_and_redo, |_make, make_bytes| {
let mut b = make_bytes("test", b"abcd");
let mark = b.create_mark(3, MarkGravity::Right).unwrap();
b.apply_edit(EditOp::Insert {
pos: 1,
bytes: b"XX",
})
.unwrap();
assert_eq!(b.mark_pos(mark), Some(5));
b.undo().unwrap();
assert_eq!(b.mark_pos(mark), Some(3));
b.redo().unwrap();
assert_eq!(b.mark_pos(mark), Some(5));
});
dual_mode_test!(
intercept_runs_before_on_edit_and_before_rope_mutation,
|_make, make_bytes| {
let mut b = make_bytes("test", b"hi");
let view = RecorderView::default();
let events = view.events.clone();
b.attach_view(Box::new(view));
let _ = b
.apply_edit(EditOp::Insert {
pos: 2,
bytes: b"!",
})
.unwrap();
let events = events.lock().unwrap();
assert_eq!(events.len(), 2);
// intercept runs first, observing pre-edit rope (len 2).
assert_eq!(events[0], RecorderEvent::Intercept { pre_len: 2 });
// on_edit runs after, observing post-edit rope (len 3) with the
// edit description.
assert_eq!(
events[1],
RecorderEvent::OnEdit {
post_len: 3,
inserted: 1
}
);
}
);
dual_mode_test!(intercept_can_reject_edit, |_make, make_bytes| {
let mut b = make_bytes("test", b"abc");
b.attach_view(Box::new(ReverseInsertView));
// Delete is rejected by the view.
let err = b.apply_edit(EditOp::Delete {
range: Range::new(0, 1),
});
assert!(err.is_err());
// Buffer state unchanged.
assert_eq!(b.len(), 3);
assert!(!b.is_modified());
// Undo stack unchanged.
assert!(matches!(b.undo(), Err(BufferError::NothingToUndo)));
});
dual_mode_test!(delete_and_replace, |_make, make_bytes| {
let mut b = make_bytes("test", b"hello world");
b.apply_edit(EditOp::Delete {
range: Range::new(5, 11),
})
.unwrap();
assert_eq!(collect(&b), b"hello");
b.apply_edit(EditOp::Replace {
range: Range::new(0, 5),
bytes: b"HELLO",
})
.unwrap();
assert_eq!(collect(&b), b"HELLO");
});
// ----- undo / redo -----
dual_mode_test!(undo_round_trips_to_original, |_make, make_bytes| {
let mut b = make_bytes("test", b"original");
b.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"X",
})
.unwrap();
b.apply_edit(EditOp::Insert {
pos: 4,
bytes: b"Y",
})
.unwrap();
b.apply_edit(EditOp::Delete {
range: Range::new(2, 5),
})
.unwrap();
assert_ne!(collect(&b), b"original");
b.undo().unwrap();
b.undo().unwrap();
b.undo().unwrap();
assert_eq!(collect(&b), b"original");
assert!(!b.is_modified());
// Nothing left to undo.
assert!(matches!(b.undo(), Err(BufferError::NothingToUndo)));
});
dual_mode_test!(redo_replays_undone_edit, |_make, make_bytes| {
let mut b = make_bytes("test", b"abc");
b.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"def",
})
.unwrap();
assert_eq!(collect(&b), b"abcdef");
b.undo().unwrap();
assert_eq!(collect(&b), b"abc");
b.redo().unwrap();
assert_eq!(collect(&b), b"abcdef");
assert!(b.is_modified());
});
dual_mode_test!(empty_insert_is_a_noop, |make, _make_bytes| {
// Inserting zero bytes must not mark the buffer modified or push
// anything onto the undo stack.
let mut b = make("*scratch*");
b.apply_edit(EditOp::Insert { pos: 0, bytes: b"" }).unwrap();
assert!(!b.is_modified());
assert!(matches!(b.undo(), Err(BufferError::NothingToUndo)));
});
dual_mode_test!(empty_range_delete_is_a_noop, |_make, make_bytes| {
let mut b = make_bytes("test", b"abc");
b.apply_edit(EditOp::Delete {
range: Range::new(1, 1),
})
.unwrap();
assert!(!b.is_modified());
assert_eq!(collect(&b), b"abc");
assert!(matches!(b.undo(), Err(BufferError::NothingToUndo)));
});
dual_mode_test!(replace_empty_with_empty_is_a_noop, |_make, make_bytes| {
let mut b = make_bytes("test", b"abc");
b.apply_edit(EditOp::Replace {
range: Range::new(2, 2),
bytes: b"",
})
.unwrap();
assert!(!b.is_modified());
assert_eq!(collect(&b), b"abc");
assert!(matches!(b.undo(), Err(BufferError::NothingToUndo)));
});
dual_mode_test!(forward_edit_clears_redo, |_make, make_bytes| {
let mut b = make_bytes("test", b"abc");
b.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"d",
})
.unwrap();
b.undo().unwrap();
assert!(b.redo().is_ok());
// Set up redo state again.
b.undo().unwrap();
// Forward edit clears redo.
b.apply_edit(EditOp::Insert {
pos: 3,
bytes: b"X",
})
.unwrap();
assert!(matches!(b.redo(), Err(BufferError::NothingToRedo)));
});
dual_mode_test!(
random_edit_then_full_undo_recovers_original,
|_make, make_bytes| {
// Reuses the rope's fuzz pattern but at the buffer level: any
// arbitrary sequence must undo to the starting bytes exactly.
//
// Seed bytes are ASCII-only (lower 7 bits) so the CRDT-mode
// run doesn't trip on lossy UTF-8 normalization. The
// v0.1-mode seed in the rope-level fuzz test uses 0..251
// which includes ill-formed UTF-8; that's a rope-only
// property and stays in `src/rope.rs`'s tests.
let mut b = make_bytes(
"test",
&(0..512u32).map(|i| (i % 128) as u8).collect::<Vec<_>>(),
);
let original = collect(&b);
let mut rng_state: u64 = 0x1234_5678;
let mut rng = || {
rng_state = rng_state
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1);
(rng_state >> 33) as u32
};
let mut edit_count = 0usize;
for _ in 0..200 {
let len = b.len();
match rng() % 3 {
0 => {
let pos = u64::from(rng()) % (len + 1);
let n = (rng() % 32 + 1) as usize;
// ASCII-only bytes so CRDT mode's lossy-utf8
// normalization is a no-op (rope ≡ projection
// for the v0.1-byte-permissive AND CRDT-utf8-
// normalized paths).
let bytes: Vec<u8> = (0..n)
.map(|i| ((rng() & 0x7F) as u8).wrapping_add(i as u8) & 0x7F)
.collect();
b.apply_edit(EditOp::Insert { pos, bytes: &bytes }).unwrap();
edit_count += 1;
}
1 if len > 0 => {
let s = u64::from(rng()) % len;
let e = s + u64::from(rng()) % (len - s + 1).max(1);
let e = e.min(len);
if s < e {
b.apply_edit(EditOp::Delete {
range: Range::new(s, e),
})
.unwrap();
edit_count += 1;
}
}
_ if len > 0 => {
let s = u64::from(rng()) % len;
let e = s + u64::from(rng()) % (len - s + 1).max(1);
let e = e.min(len);
if s < e {
let n = (rng() % 16) as usize;
let bytes: Vec<u8> = (0..n)
.map(|i| ((rng() & 0x7F) as u8).wrapping_add(i as u8) & 0x7F)
.collect();
b.apply_edit(EditOp::Replace {
range: Range::new(s, e),
bytes: &bytes,
})
.unwrap();
edit_count += 1;
}
}
_ => {}
}
}
for _ in 0..edit_count {
b.undo().unwrap();
}
assert_eq!(collect(&b), original);
assert!(!b.is_modified());
}
);
// -----------------------------------------------------------------
// T M10.2 CRDT-backed buffer tests.
//
// Targeted tests for Day 2's routing work: rope ≡ CRDT projection
// invariant must hold after apply_edit / undo / redo / arbitrary
// sequences. Day 5+'s parameterized harness re-runs all of the
// existing buffer tests against CRDT mode; Day 2's tests are
// direct.
// -----------------------------------------------------------------
// -----------------------------------------------------------------
// T M10.2 Day 6 — coverage-audit gap closures.
//
// Day 5's 17 dual-mode tests cover canonical edit semantics but
// miss two non-canonical paths that the Day 6 coverage audit
// surfaced as substantive M10.2 scope:
//
// * `apply_edit_skip_intercepts` — the Lua-bindings path that
// bypasses the intercept chain. Routes through the same
// `run_rope_edit_and_broadcast` as `apply_edit`, which is
// where CRDT routing happens; in principle CRDT mode applies
// equally, but worth an explicit test to lock it in.
// * `begin_edit` / `end_edit` — the re-entrancy gate that
// surfaces `BufferError::ConcurrentEdit` on nested edits.
// Independent of CRDT mode but the gate's interaction with
// CRDT routing wasn't tested.
//
// Other gaps the audit surfaced but classified as deferred:
//
// * Boundary edits (insert at 0, insert at end, delete entire
// buffer, replace covering whole) — implicitly covered by
// the random_edit fuzz and explicit tests. Adding dedicated
// boundary tests is cheap polish, not M10.2 scope.
// * Large edits (1MB+) — exercises the export-overhead cost
// model; Day 7's perf check covers these.
// * `set_name`, `mark_clean`, `editing_in_progress` getter —
// non-edit-path methods; behavior is identical across modes
// by construction. Not worth a dual-mode test each.
// -----------------------------------------------------------------
dual_mode_test!(
apply_edit_skip_intercepts_routes_through_crdt,
|_make, make_bytes| {
// skip_intercepts bypasses the intercept chain but routes
// through the same rope-edit-and-broadcast path. In CRDT
// mode the CRDT routing still applies; the rope projection
// and CRDT state must stay in sync as they do for `apply_edit`.
let mut b = make_bytes("test", b"hello");
// Attach a view whose intercept would reject any edit; the
// skip_intercepts call must succeed because the intercept is
// bypassed, demonstrating the path works.
b.attach_view(Box::new(ReverseInsertView));
let edit = b
.apply_edit_skip_intercepts(EditOp::Delete {
range: Range::new(0, 1),
})
.expect("skip_intercepts bypasses the rejecting intercept");
assert_eq!(edit.range, Range::new(0, 1));
assert_eq!(edit.inserted_len, 0);
assert_eq!(collect(&b), b"ello");
assert!(b.is_modified());
}
);
dual_mode_test!(
begin_edit_blocks_reentrant_apply_edit,
|make, _make_bytes| {
// begin_edit sets the editing_in_progress flag; a subsequent
// apply_edit returns ConcurrentEdit until end_edit. The flag
// is independent of CRDT mode but the gate's interaction
// with CRDT routing is worth pinning.
let mut b = make("test");
b.begin_edit().expect("first begin");
assert!(b.editing_in_progress());
let err = b.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"X",
});
assert!(
matches!(err, Err(BufferError::ConcurrentEdit { .. })),
"expected ConcurrentEdit, got {err:?}"
);
// end_edit clears the flag; subsequent apply_edit succeeds.
b.end_edit();
assert!(!b.editing_in_progress());
b.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"X",
})
.expect("after end_edit");
assert_eq!(collect(&b), b"X");
}
);
dual_mode_test!(
begin_edit_is_reentrant_safe_via_returned_error,
|make, _make_bytes| {
// Two consecutive begin_edit calls without an intervening
// end_edit must return ConcurrentEdit on the second, not
// double-set the flag. Pins the gate's idempotency.
let mut b = make("*scratch*");
b.begin_edit().expect("first begin");
let r = b.begin_edit();
assert!(matches!(r, Err(BufferError::ConcurrentEdit { .. })));
// First begin's flag is still set; end_edit clears it.
assert!(b.editing_in_progress());
b.end_edit();
assert!(!b.editing_in_progress());
}
);
#[cfg(feature = "crdt")]
fn rope_string(b: &Buffer) -> String {
let mut bytes = vec![0u8; b.len() as usize];
if !bytes.is_empty() {
b.snapshot_rope().slice(0, b.len(), &mut bytes);
}
String::from_utf8(bytes).expect("rope contents must be UTF-8 in CRDT mode")
}
#[cfg(feature = "crdt")]
fn assert_invariant(b: &Buffer) {
let rope = rope_string(b);
let crdt = b
.crdt_state()
.expect("CRDT-backed in this test")
.materialize_string();
assert_eq!(rope, crdt, "rope ≡ CRDT projection invariant violated");
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_apply_edit_keeps_invariant_basic() {
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*scratch*", 1).expect("crdt construction");
b.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.unwrap();
assert_eq!(rope_string(&b), "hello");
assert_invariant(&b);
b.apply_edit(EditOp::Insert {
pos: 5,
bytes: b" world",
})
.unwrap();
assert_eq!(rope_string(&b), "hello world");
assert_invariant(&b);
b.apply_edit(EditOp::Delete {
range: Range::new(5, 6),
})
.unwrap();
assert_eq!(rope_string(&b), "helloworld");
assert_invariant(&b);
b.apply_edit(EditOp::Replace {
range: Range::new(0, 5),
bytes: b"howdy",
})
.unwrap();
assert_eq!(rope_string(&b), "howdyworld");
assert_invariant(&b);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_undo_keeps_invariant() {
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*scratch*", 1).expect("crdt construction");
b.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.unwrap();
b.apply_edit(EditOp::Insert {
pos: 5,
bytes: b" world",
})
.unwrap();
assert_eq!(rope_string(&b), "hello world");
assert_invariant(&b);
b.undo().expect("undo");
assert_eq!(rope_string(&b), "hello");
assert_invariant(&b);
b.undo().expect("undo");
assert_eq!(rope_string(&b), "");
assert_invariant(&b);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_undo_redo_keeps_invariant() {
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*scratch*", 1).expect("crdt construction");
b.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"abcdef",
})
.unwrap();
b.apply_edit(EditOp::Replace {
range: Range::new(2, 4),
bytes: b"XY",
})
.unwrap();
assert_eq!(rope_string(&b), "abXYef");
assert_invariant(&b);
b.undo().expect("undo replace");
assert_eq!(rope_string(&b), "abcdef");
assert_invariant(&b);
b.redo().expect("redo replace");
assert_eq!(rope_string(&b), "abXYef");
assert_invariant(&b);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_from_bytes_seeds_both_rope_and_crdt() {
let b =
Buffer::from_bytes_with_crdt(BufferId::next(), "*seeded*", b"the quick brown fox", 7)
.expect("crdt seeded");
assert_eq!(rope_string(&b), "the quick brown fox");
assert_invariant(&b);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_upgrade_to_crdt_clears_v01_history() {
// T M10.4 reframe: upgrade_to_crdt clears v0.1 undo/redo
// stacks explicitly. The pre-upgrade entries can't be
// replayed through UndoManager (no peer_id attribution); the
// M10.4 audit doc records this as documented behavior with
// a v0.2+ refinement path. This test pins the cleared-stack
// behavior so future contributors don't reintroduce silent-
// persist-but-unreachable semantics.
let mut b = Buffer::from_bytes(BufferId::next(), "*upgrade*", b"initial");
b.apply_edit(EditOp::Insert {
pos: 7,
bytes: b" content",
})
.unwrap();
let pre_upgrade_rev = b.revision();
assert!(!b.is_crdt_backed());
assert_eq!(b.undo.len(), 1);
b.upgrade_to_crdt(42).expect("upgrade");
assert!(b.is_crdt_backed());
// Content preserved.
assert_eq!(rope_string(&b), "initial content");
assert_invariant(&b);
// Revision counter preserved (the rope contents themselves
// didn't change at upgrade time; revision tracks rope-version
// not history).
assert_eq!(b.revision(), pre_upgrade_rev);
// v0.1 undo/redo stacks cleared per M10.4 reframe.
assert!(b.undo.is_empty());
assert!(b.redo.is_empty());
// CRDT-mode undo has nothing yet (the seeded content from
// upgrade isn't an undoable edit — see CrdtState::from_bytes).
let undo_result = b.undo();
assert!(matches!(undo_result, Err(BufferError::NothingToUndo)));
// Subsequent edits on the upgraded buffer keep the invariant
// and ARE undoable via CRDT-mode undo.
b.apply_edit(EditOp::Delete {
range: Range::new(0, 8),
})
.unwrap();
assert_eq!(rope_string(&b), "content");
assert_invariant(&b);
b.undo().expect("undo the delete");
assert_eq!(rope_string(&b), "initial content");
assert_invariant(&b);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_upgrade_rejects_double_attach() {
let mut b = Buffer::new_with_crdt(BufferId::next(), "*twice*", 1).expect("first attach");
let r = b.upgrade_to_crdt(2);
assert!(matches!(r, Err(BufferError::CrdtRejected { .. })));
}
// ---------------------------------------------------------------
// Consistency property test: rope ≡ CRDT projection holds under
// arbitrary apply_edit + undo + redo sequences.
//
// Generators per the framing-pass methodology:
// - Insert / Delete / Replace at random aligned positions
// - Undo / Redo at ~510% probability each
// - Byte content from a small UTF-8 alphabet (avoids
// ill-formed-bytes drift; the wrapper handles those via
// from_utf8_lossy but the proptest is testing routing
// correctness, not normalization correctness)
// - Sequence length 50; 64 cases (proptest's default)
// ---------------------------------------------------------------
#[cfg(feature = "crdt")]
mod proptests {
use super::*;
use proptest::prelude::*;
// Codepoint-aligned generators. The mid-codepoint case is its
// own test (`crdt_mid_codepoint_position_is_rejected_cleanly`);
// the property test exercises the well-formed path so failures
// surface routing bugs, not codepoint-alignment surprises.
const ALPHABET: &[&str] = &["a", "b", "c", " ", "\n"];
#[derive(Clone, Debug)]
enum GenOp {
Insert(usize, String),
Delete(usize, usize),
Replace(usize, usize, String),
Undo,
Redo,
}
fn gen_payload() -> impl Strategy<Value = String> {
prop::collection::vec(prop::sample::select(ALPHABET.to_vec()), 0..6)
.prop_map(|parts| parts.concat())
}
fn gen_op() -> impl Strategy<Value = GenOp> {
// Weighted: forward edits dominate, undo/redo at ~5% each.
prop_oneof![
30 => (any::<u8>(), gen_payload()).prop_map(|(p, s)| GenOp::Insert(p as usize, s)),
20 => (any::<u8>(), any::<u8>()).prop_map(|(p, l)| GenOp::Delete(p as usize, l as usize)),
10 => (any::<u8>(), any::<u8>(), gen_payload())
.prop_map(|(p, l, s)| GenOp::Replace(p as usize, l as usize, s)),
3 => Just(GenOp::Undo),
3 => Just(GenOp::Redo),
]
}
// T M10.2 Day 3 helper: applies a `GenOp` and returns the
// resulting Edit so the proptest can assert per-op shape.
// Each op is best-effort: out-of-range positions are clamped
// before dispatch so the proptest doesn't fail on benign rope
// errors. Routing bugs (CRDT/rope drift) are what the post-
// condition catches. Returns None on no-op short-circuits and
// on history-stack-empty errors (NothingToUndo / NothingToRedo).
fn apply_capturing(b: &mut Buffer, op: GenOp) -> Option<Edit> {
let len = b.len() as usize;
match op {
GenOp::Insert(pos, s) => {
let pos = pos.min(len);
b.apply_edit(EditOp::Insert {
pos: pos as u64,
bytes: s.as_bytes(),
})
.ok()
}
GenOp::Delete(pos, l) => {
let pos = pos.min(len);
let l = l.min(len.saturating_sub(pos));
b.apply_edit(EditOp::Delete {
range: Range::new(pos as u64, (pos + l) as u64),
})
.ok()
}
GenOp::Replace(pos, l, s) => {
let pos = pos.min(len);
let l = l.min(len.saturating_sub(pos));
b.apply_edit(EditOp::Replace {
range: Range::new(pos as u64, (pos + l) as u64),
bytes: s.as_bytes(),
})
.ok()
}
GenOp::Undo => b.undo().ok(),
GenOp::Redo => b.redo().ok(),
}
}
proptest! {
// Smaller proptest case count than the default (64) to keep
// CI overhead modest; the per-op invariant check is the
// load-bearing part, not the diversity of sequences.
#![proptest_config(ProptestConfig::with_cases(32))]
#[test]
fn rope_matches_crdt_projection_after_arbitrary_edits(
ops in prop::collection::vec(gen_op(), 1..50),
) {
let mut b = Buffer::new_with_crdt(BufferId::next(), "*proptest*", 1)
.expect("crdt construction");
for op in ops {
let op_repr = format!("{op:?}");
let edit = apply_capturing(&mut b, op);
// Per-op invariant check: catches drift the moment
// it happens, with the failing op visible in the
// shrinker output.
let rope = rope_string(&b);
let crdt = b.crdt_state().unwrap().materialize_string();
prop_assert_eq!(
&rope, &crdt,
"invariant violated after op {}: rope={:?} crdt={:?}",
op_repr, rope, crdt
);
// Day 3: crdt_op shape invariant.
// - real edits in CRDT mode populate crdt_op
// - no-op short-circuits leave crdt_op = None
// - history-stack-empty errors return None Edit
if let Some(edit) = edit {
let is_no_op_edit_result =
edit.range.is_empty() && edit.inserted_len == 0;
if is_no_op_edit_result {
prop_assert!(
edit.crdt_op.is_none(),
"no-op edit must have crdt_op = None ({})",
op_repr
);
} else {
prop_assert!(
edit.crdt_op.is_some(),
"non-no-op CRDT-mode edit must have crdt_op = Some ({})",
op_repr
);
let crdt_op = edit.crdt_op.as_ref().unwrap();
prop_assert_eq!(
crdt_op.peer_id, 1,
"peer_id must thread from CrdtState ({})",
op_repr
);
prop_assert!(
!crdt_op.bytes.is_empty(),
"wire bytes must be non-empty ({})",
op_repr
);
}
}
}
}
// T M10.3: round-trip property. Arbitrary EditOp sequences
// on Buffer A (peer_id 1) produce per-edit crdt_op bytes.
// Replaying those bytes on a fresh CrdtState B (peer_id 2)
// must produce a projection identical to A's. This is the
// stronger property than Day 3's single-instance test:
// proves that the wire-format bytes are independently
// re-applicable on a remote CRDT instance, exercising the
// path M10.5 will use for InstanceMessage::CrdtOp delivery.
//
// Excludes undo/redo from the gen — those produce synthetic
// Replace ops that, when re-applied on B from base zero,
// create a state inconsistent with A's history-swap
// semantics. M10.5's actual wire protocol delivers undo
// ops only when the originating peer's prior history is
// already known to the receiver; the proptest scope is
// forward edits only (insert / delete / replace).
#[test]
fn crdt_op_bytes_round_trip_via_remote_crdt_state(
ops in prop::collection::vec(gen_op_forward_only(), 1..30),
) {
let mut a = Buffer::new_with_crdt(BufferId::next(), "A", 1)
.expect("A construction");
let receiver = crate::crdt::CrdtState::new(2)
.expect("receiver construction");
for op in ops {
let op_repr = format!("{op:?}");
let edit = apply_capturing(&mut a, op);
if let Some(edit) = edit
&& let Some(crdt_op) = edit.crdt_op.as_ref() {
// Apply the wire-format bytes to the
// receiver. Receiver projection must match
// A's projection after this.
receiver
.import_updates(&crdt_op.bytes)
.expect("receiver import");
let a_proj = a
.crdt_state()
.unwrap()
.materialize_string();
let b_proj = receiver.materialize_string();
prop_assert_eq!(
&a_proj, &b_proj,
"A and remote receiver diverged after op {}: \
A={:?} B={:?}",
op_repr, a_proj, b_proj
);
}
}
}
}
/// T M10.3 generator: forward-only ops (no Undo/Redo). The
/// round-trip proptest excludes history-nav because the
/// synthetic-Replace ops undo/redo produce don't round-trip
/// cleanly when replayed on a peer without the originating
/// history. M10.5's wire protocol handles this; M10.3's
/// scope is forward edits.
fn gen_op_forward_only() -> impl Strategy<Value = GenOp> {
prop_oneof![
30 => (any::<u8>(), gen_payload()).prop_map(|(p, s)| GenOp::Insert(p as usize, s)),
20 => (any::<u8>(), any::<u8>()).prop_map(|(p, l)| GenOp::Delete(p as usize, l as usize)),
10 => (any::<u8>(), any::<u8>(), gen_payload())
.prop_map(|(p, l, s)| GenOp::Replace(p as usize, l as usize, s)),
]
}
}
// ---------------------------------------------------------------
// T M10.2 Day 3 — crdt_op population on Edit.
// ---------------------------------------------------------------
#[cfg(feature = "crdt")]
#[test]
fn crdt_op_is_none_in_v01_mode() {
let mut b = Buffer::new(BufferId::next(), "*v01*");
let edit = b
.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.unwrap();
assert!(
edit.crdt_op.is_none(),
"v0.1 mode (no CRDT) must produce Edit with crdt_op = None"
);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_op_is_some_in_crdt_mode_for_real_edits() {
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*crdt*", 42).expect("crdt construction");
let edit = b
.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.unwrap();
let op = edit
.crdt_op
.as_ref()
.expect("CRDT mode must populate crdt_op");
assert_eq!(op.peer_id, 42, "peer_id must thread through");
assert!(!op.bytes.is_empty(), "wire bytes must be non-empty");
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_op_is_none_for_no_op_edits_even_in_crdt_mode() {
// Q5: truly empty edits skip the CRDT path entirely. The
// returned Edit's crdt_op must be None — the rope's no-op
// short-circuit doesn't reach the CRDT routing, so no op
// bytes are captured.
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*crdt*", 1).expect("crdt construction");
let edit = b.apply_edit(EditOp::Insert { pos: 0, bytes: b"" }).unwrap();
assert!(edit.crdt_op.is_none(), "true no-op insert must yield None");
let edit = b
.apply_edit(EditOp::Delete {
range: Range::new(0, 0),
})
.unwrap();
assert!(edit.crdt_op.is_none(), "true no-op delete must yield None");
let edit = b
.apply_edit(EditOp::Replace {
range: Range::new(0, 0),
bytes: b"",
})
.unwrap();
assert!(edit.crdt_op.is_none(), "true no-op replace must yield None");
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_op_carries_distinct_bytes_per_edit() {
// The framing pass's verification target: apply two edits in
// succession, verify each Edit's crdt_op contains exactly its
// own delta (not the cumulative). Loro's transactional model
// gives a consistent before/after pair via version capture.
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*twin*", 7).expect("crdt construction");
let e1 = b
.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"first",
})
.unwrap();
let e2 = b
.apply_edit(EditOp::Insert {
pos: 5,
bytes: b"-second",
})
.unwrap();
let bytes1 = &e1.crdt_op.as_ref().unwrap().bytes;
let bytes2 = &e2.crdt_op.as_ref().unwrap().bytes;
assert_ne!(
bytes1, bytes2,
"each edit's crdt_op must carry its own delta, not cumulative state"
);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_op_populated_for_undo_and_redo() {
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*history*", 3).expect("crdt construction");
b.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.unwrap();
let undo_edit = b.undo().expect("undo");
assert!(
undo_edit.crdt_op.is_some(),
"undo's inverse Edit must carry the synthetic-Replace bytes"
);
assert_eq!(undo_edit.crdt_op.as_ref().unwrap().peer_id, 3);
let redo_edit = b.redo().expect("redo");
assert!(
redo_edit.crdt_op.is_some(),
"redo's inverse Edit must carry the synthetic-Replace bytes"
);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_op_imports_into_a_fresh_doc_to_reproduce_state() {
// Day 3 acceptance verification (framing-pass risk #2 follow-
// up): the wire-format bytes a single edit produces, when
// imported into a fresh CRDT doc, should reproduce the
// post-edit content. This is what M10.5's wire protocol will
// rely on — receiving frontends import the bytes to apply
// ops on their local CRDT.
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*wire*", 1).expect("crdt construction");
// First edit: empty -> "hello".
let e1 = b
.apply_edit(EditOp::Insert {
pos: 0,
bytes: b"hello",
})
.unwrap();
// Second edit: -> "hello world".
let e2 = b
.apply_edit(EditOp::Insert {
pos: 5,
bytes: b" world",
})
.unwrap();
// Replay both deltas into a fresh CRDT doc; result should
// match the originating buffer's rope.
let receiver = crate::crdt::CrdtState::new(99).unwrap();
receiver
.import_snapshot(&e1.crdt_op.as_ref().unwrap().bytes)
.expect("import e1");
receiver
.import_snapshot(&e2.crdt_op.as_ref().unwrap().bytes)
.expect("import e2");
assert_eq!(
receiver.materialize_string(),
"hello world",
"replaying the wire bytes on a fresh doc must reproduce the originating state"
);
}
#[cfg(feature = "crdt")]
#[test]
fn crdt_mid_codepoint_position_is_rejected_cleanly() {
// Per the Q1 morning audit, loro rejects mid-codepoint
// delete_utf8 / insert_utf8. The Buffer routing must surface
// the rejection as a clean error and leave both rope and CRDT
// untouched (the rope mutation never runs because Q2 ordering
// applies CRDT first).
let mut b =
Buffer::new_with_crdt(BufferId::next(), "*midcp*", 1).expect("crdt construction");
b.apply_edit(EditOp::Insert {
pos: 0,
bytes: "héllo".as_bytes(),
})
.unwrap();
assert_invariant(&b);
// Try to delete starting mid-é (byte 2 of "héllo"). Should
// surface as CrdtRejected; rope and CRDT both unchanged.
let pre_rope = rope_string(&b);
let r = b.apply_edit(EditOp::Delete {
range: Range::new(2, 3),
});
assert!(
matches!(r, Err(BufferError::CrdtRejected { .. })),
"got {r:?}"
);
assert_eq!(rope_string(&b), pre_rope, "rope must be unchanged");
assert_invariant(&b);
}
// -----------------------------------------------------------------
// T M10.10 Finding 3 — apply_remote_crdt_op acceptance.
// -----------------------------------------------------------------
#[cfg(feature = "crdt")]
#[test]
fn apply_remote_crdt_op_integrates_op_and_keeps_invariant() {
// Donor peer (frontend B simulated) produces an op against
// an empty starting state. Receiver (daemon-side buffer)
// applies the op via apply_remote_crdt_op.
let donor = crate::crdt::CrdtState::new(2).expect("donor");
let v_before = donor.version();
donor.insert(0, "hello").expect("donor seed");
let op_bytes = donor
.export_updates_since(&v_before)
.expect("export updates");
// Receiver buffer starts empty under peer 1 (the daemon's
// LOCAL peer id).
let mut buf = Buffer::new_with_crdt(BufferId::next(), "*remote*", 1).expect("receiver buf");
assert_eq!(rope_string(&buf), "");
let edit = buf
.apply_remote_crdt_op(&op_bytes)
.expect("apply remote")
.expect("non-empty edit");
// Rope ≡ CRDT projection invariant after remote op.
assert_eq!(rope_string(&buf), "hello");
assert_invariant(&buf);
// Edit's crdt_op stays None — remote op doesn't get re-broadcast.
assert!(
edit.crdt_op.is_none(),
"remote-applied Edit must not carry crdt_op"
);
// Modified + revision bumped.
assert!(buf.is_modified());
assert!(buf.revision() > 0);
}
#[cfg(feature = "crdt")]
#[test]
fn apply_remote_crdt_op_on_non_crdt_buffer_errors() {
let mut buf = Buffer::new(BufferId::next(), "*plain*");
let result = buf.apply_remote_crdt_op(&[0x00, 0x01, 0x02]);
assert!(
matches!(result, Err(BufferError::CrdtRejected { .. })),
"got {result:?}"
);
}
#[cfg(feature = "crdt")]
#[test]
fn apply_remote_crdt_op_invokes_on_edit_subscribers() {
// Verify the Edit subscriber path is honored — TextView's
// line cache + syntax highlighting + overlays all depend on
// on_edit notifications. A simple recording View confirms
// the broadcast fires.
use crate::view::View;
use std::cell::Cell;
use std::rc::Rc;
struct RecorderView {
count: Rc<Cell<usize>>,
}
impl View for RecorderView {
fn on_edit(&mut self, _buf: &Buffer, _edit: &Edit) -> Result<(), BufferError> {
self.count.set(self.count.get() + 1);
Ok(())
}
}
let donor = crate::crdt::CrdtState::new(2).expect("donor");
let v_before = donor.version();
donor.insert(0, "abc").expect("donor seed");
let op_bytes = donor.export_updates_since(&v_before).expect("export");
let mut buf = Buffer::new_with_crdt(BufferId::next(), "*recorder*", 1).expect("buf");
let count = Rc::new(Cell::new(0usize));
buf.attach_view(Box::new(RecorderView {
count: Rc::clone(&count),
}));
let _edit = buf.apply_remote_crdt_op(&op_bytes).expect("apply").unwrap();
assert_eq!(count.get(), 1, "on_edit must fire for remote op");
}
/// F25 (post-audit-round-4): a CRDT update that changes one
/// codepoint into another with a shared leading UTF-8 byte
/// must produce a char-boundary-aligned diff. Pre-fix, the
/// byte-prefix walk landed mid-codepoint and the rope edit
/// carried an invalid byte slice.
///
/// Setup: receiver starts empty; donor inserts 'é'; receiver
/// applies that first op (state → 'é'). Donor then deletes the
/// 'é' and inserts 'è'; receiver applies that second op. The
/// second op's diff path sees pre = 'é' (`0xC3 0xA9`) vs post
/// = 'è' (`0xC3 0xA8`) — byte prefix 1, lands mid-codepoint.
#[cfg(feature = "crdt")]
#[test]
fn apply_remote_crdt_op_preserves_utf8_boundaries_for_single_char_change_f25() {
let donor = crate::crdt::CrdtState::new(2).expect("donor");
let v0 = donor.version();
donor.insert(0, "é").expect("donor seed");
let seed_bytes = donor.export_updates_since(&v0).expect("seed export");
let v_after_seed = donor.version();
donor.delete(0, "é".len()).expect("donor delete");
donor.insert(0, "è").expect("donor insert");
let replace_bytes = donor
.export_updates_since(&v_after_seed)
.expect("replace export");
let mut buf = Buffer::new_with_crdt(BufferId::next(), "*utf8*", 1).expect("buf");
// Step 1: seed receiver with 'é'.
let seed_edit = buf
.apply_remote_crdt_op(&seed_bytes)
.expect("apply seed")
.expect("seed edit");
assert_eq!(rope_string(&buf), "é");
assert_eq!(seed_edit.range.start, 0);
assert_eq!(seed_edit.range.end, 0);
// Step 2: the F25 codepath. Apply the replace op WITHOUT
// panicking on a mid-codepoint rope split.
let replace_edit = buf
.apply_remote_crdt_op(&replace_bytes)
.expect("apply replace")
.expect("replace edit");
assert_eq!(rope_string(&buf), "è");
// The Edit's range should cover the WHOLE codepoint (0..2),
// not the byte-naive (1..2) that pre-fix would have produced.
assert_eq!(
replace_edit.range.start, 0,
"F25: range_start must be char-boundary-aligned (0, not 1)"
);
assert_eq!(replace_edit.range.end, "é".len() as u64);
assert_invariant(&buf);
}
#[cfg(feature = "crdt")]
#[test]
fn apply_remote_crdt_op_for_delete_produces_correct_diff() {
// Donor inserts then deletes; the second op is the "delete"
// we'll apply remotely. Replicates the multi-step convergence
// path.
let donor = crate::crdt::CrdtState::new(2).expect("donor");
donor.insert(0, "hello world").expect("seed");
let mut buf = Buffer::new_with_crdt(BufferId::next(), "*remote-del*", 1).expect("buf");
// Sync receiver up to donor's initial state.
let donor_snap = donor.export_snapshot().expect("snap");
buf.crdt
.as_ref()
.expect("crdt")
.import_snapshot(&donor_snap)
.expect("init from snap");
// Replace rope to match (test setup; production daemon side
// doesn't do this manually).
buf.rope = crate::rope::Rope::from_bytes(b"hello world");
assert_eq!(rope_string(&buf), "hello world");
// Donor deletes " world" (6 bytes from position 5).
let v_before = donor.version();
donor.delete(5, 6).expect("donor delete");
let op_bytes = donor.export_updates_since(&v_before).expect("export");
let _ = buf.apply_remote_crdt_op(&op_bytes).expect("apply").unwrap();
assert_eq!(rope_string(&buf), "hello");
assert_invariant(&buf);
}
}