Skip to main content

strop_engine/editor/
transact.rs

1//! A mutation lease is the publication boundary, independent of undo grouping.
2mod capability;
3pub use capability::{BufferEdit, DocumentEdit};
4use strop_core::id::{BufferRevision, DocumentId};
5use strop_core::{Change, EditError, Replacement};
6
7/// Every range refers to the same pre-edit document snapshot.
8#[derive(Debug, Clone)]
9pub struct ChangeSet {
10    pub edits: Vec<Replacement>,
11    pub undo_open: bool,
12}
13
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum ApplyError {
16    #[error("no such document")]
17    NoDocument,
18    #[error(transparent)]
19    Edit(#[from] EditError),
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct Committed {
24    pub revision: BufferRevision,
25}
26
27impl super::Editor {
28    pub(crate) fn apply(
29        &mut self,
30        document: DocumentId,
31        base: BufferRevision,
32        changes: ChangeSet,
33    ) -> Result<Committed, ApplyError> {
34        let buffer = &self.docs.get(document).ok_or(ApplyError::NoDocument)?.buf;
35        let prepared = buffer.prepare_replacements(base, changes.edits)?;
36        if prepared.is_empty() {
37            return Ok(Committed { revision: base });
38        }
39        if self
40            .pending
41            .prompt()
42            .is_some_and(|prompt| prompt.origin().pane.doc == document)
43        {
44            self.cancel_pending();
45        }
46        let revision = self
47            .doc_mut(document)
48            .buf
49            .apply_prepared(prepared, changes.undo_open)?;
50        Ok(Committed { revision })
51    }
52
53    pub(crate) fn replace_system(
54        &mut self,
55        document: DocumentId,
56        text: &str,
57    ) -> Result<(), EditError> {
58        self.doc_mut(document).buf.system_edit().replace_all(text)
59    }
60
61    pub(crate) fn tx_begin(&mut self) {
62        self.buf_mut().begin_undo_group();
63    }
64    pub(crate) fn tx_commit(&mut self) {
65        self.buf_mut().commit_undo_group();
66        strop_trace::record_with(strop_trace::EventKind::History, || {
67            serde_json::json!({
68                "operation":"commit","buffer":self.buf().trace_id(),"document":self.current(),
69                "revision":self.buf().revision(),"history_nodes":self.buf().history().depth(),
70            })
71        });
72    }
73
74    /// The lease cannot be released without consuming each change exactly once.
75    /// Core records geometry BEFORE mutation; no post-edit reconstruction or
76    /// cloning deleted text to reconstruct syntax/anchor effects.
77    pub(super) fn sync_document(&mut self, id: DocumentId, map_active: bool) {
78        self.sync_document_positions(id, map_active, map_position);
79    }
80
81    fn sync_document_positions(
82        &mut self,
83        id: DocumentId,
84        map_active: bool,
85        position: impl Fn(usize, &Change) -> usize,
86    ) {
87        // A collection buffer's own edits write back to their sources
88        // through the journal — live, per keystroke (0051 R05). System
89        // regenerations (render/splice) and history moves never do.
90        if self.collections.contains_key(&id)
91            && self.docs.get(id).is_some_and(|doc| {
92                doc.buf
93                    .changes()
94                    .iter()
95                    .any(|c| c.origin == strop_core::ChangeOrigin::User)
96            })
97        {
98            self.sync_collection_write_back(id, map_active, &position);
99            return;
100        }
101        self.sync_filename_draft(id);
102        let change_count = self.docs.get(id).map_or(0, |doc| doc.buf.changes().len());
103        if change_count == 0 {
104            return;
105        }
106        // Copy one small journal entry at a time: return views live in
107        // other documents, so remap before borrowing the source mutably.
108        for index in 0..change_count {
109            let change = self.doc(id).buf.changes()[index];
110            self.map_navigation_records(id, |offset| position(offset, &change));
111        }
112        // Snapshot clean views before the mutable document borrow: a
113        // view with an unsynced user edit is never regenerated (0049 §5).
114        let clean_views: std::collections::HashSet<DocumentId> = self
115            .collections
116            .iter()
117            .filter(|(cid, collection)| {
118                self.docs
119                    .get(**cid)
120                    .is_some_and(|doc| doc.buf.revision() == collection.revision)
121            })
122            .map(|(cid, _)| *cid)
123            .collect();
124        let collection_updates = self.prepare_collection_updates(id, &clean_views);
125        let Some(document) = self.docs.get_mut(id) else {
126            return;
127        };
128        if document.buf.changes().is_empty() {
129            return;
130        }
131        let active = self.active_pane;
132        for change in document.buf.changes() {
133            let map = |offset| position(offset, change);
134            for (owner, position) in self.marks.values_mut() {
135                if *owner == id {
136                    *position = map(*position);
137                }
138            }
139            for (index, pane) in self.panes.iter_mut().enumerate() {
140                if pane.doc == id && (map_active || index != active) {
141                    pane.sels.map_positions(map);
142                }
143            }
144            // Collection excerpts are SPANS, not points (0049 §5): an
145            // edit replacing the span's first byte keeps the start —
146            // the pointwise collapse rule would slide the anchor past
147            // the new text and drop it from the view.
148            for collection in self.collections.values_mut() {
149                for excerpt in &mut collection.excerpts {
150                    if excerpt.source == id {
151                        let (s, e) = (change.edit.start_byte, change.edit.old_end_byte);
152                        let new_len = change.edit.new_end_byte - change.edit.start_byte;
153                        let delta = new_len as isize - (e - s) as isize;
154                        let shift = |p: usize| (p as isize + delta) as usize;
155                        let insertion = s == e;
156                        excerpt.start = if excerpt.start <= s {
157                            excerpt.start
158                        } else if excerpt.start >= e {
159                            shift(excerpt.start)
160                        } else {
161                            s
162                        };
163                        excerpt.end = if excerpt.end < s {
164                            excerpt.end
165                        } else if excerpt.end > e || (excerpt.end == e && !insertion) {
166                            shift(excerpt.end)
167                        } else {
168                            // covered (or a boundary insertion — deleted-
169                            // then-reinserted text regrows the span)
170                            s + new_len
171                        };
172                        for anchor in &mut excerpt.hit_anchors {
173                            *anchor = map(*anchor);
174                        }
175                        excerpt.matches.retain_mut(|(start, length)| {
176                            let finish = start.saturating_add(*length);
177                            if s < finish && e > *start {
178                                return false;
179                            }
180                            let mapped = map(*start);
181                            *length = map(finish).saturating_sub(mapped);
182                            *start = mapped;
183                            true
184                        });
185                    }
186                }
187            }
188        }
189        self.analysis.edits(id, document.buf.changes());
190        document.buf.clear_changes();
191        self.publish_collection_updates(collection_updates);
192    }
193}
194
195fn map_position(position: usize, change: &Change) -> usize {
196    let edit = change.edit;
197    debug_assert!(edit.start_byte <= edit.old_end_byte);
198    // The verified kernel (strop_core::editmap, 0045): positions are
199    // byte offsets bounded by the buffer length, so its no-overflow
200    // precondition is established by the rope, not by this caller.
201    debug_assert!(edit.new_end_byte >= edit.start_byte);
202    strop_core::editmap::map_position(
203        position,
204        edit.start_byte,
205        edit.old_end_byte,
206        edit.new_end_byte,
207    )
208}