Skip to main content

termesh_editor/
lib.rs

1//! The editor core and the **shared-state transaction spine** (ARCHITECTURE.md §8).
2//!
3//! Both the human and the agent change buffers, so there is exactly one edit path: every
4//! change is an [`EditTransaction`] stamped with the buffer version it was authored
5//! against. That yields one undo history, clean LSP/syntax sync, and — the reason it
6//! matters — *safe agent diff-review*: an agent proposal is a [`ChangeSet`] against
7//! `base_version`; on accept we apply directly if the buffer is still there, or carry it
8//! forward through the intervening edits. Modeled on Helix / CodeMirror 6.
9//!
10//! The design decisions behind this module — including what "carries forward cleanly"
11//! actually means, case by case — are in **ADR-0006**.
12//!
13//! ```
14//! use termesh_editor::{Assoc, ChangeSet};
15//! use ropey::Rope;
16//!
17//! let original = Rope::from_str("fn main() {}");
18//! // An agent proposes renaming `main`, anchored at char 3.
19//! let proposal = ChangeSet::replace(original.len_chars(), 3, 7, "run");
20//!
21//! // Meanwhile the human types at the start of the line.
22//! let human = ChangeSet::replace(original.len_chars(), 0, 0, "pub ");
23//! let current = human.apply(&original);
24//!
25//! // The proposal's anchor rides forward over the human's edit instead of going stale.
26//! assert_eq!(human.map_pos(3, Assoc::After), 7);
27//! assert_eq!(current.to_string(), "pub fn main() {}");
28//! # let _ = proposal;
29//! ```
30#![forbid(unsafe_code)]
31
32pub mod buffer;
33pub mod change;
34pub mod decoration;
35pub mod history;
36pub mod movement;
37pub mod position;
38pub mod search;
39pub mod selection;
40pub mod transaction;
41
42pub use buffer::{Buffer, EditError, EditResult, LineEnding};
43pub use change::{Assoc, ChangeSet, ChangeSetBuilder, ChangedSpan, Operation, RangeEffect};
44pub use decoration::{
45    Decoration, DecorationClass, DecorationSet, HunkSide, LineDecoration, Severity, SyntaxKind,
46};
47pub use history::History;
48pub use search::{find_all, CaseMode, Match};
49pub use selection::{Range, Selection};
50pub use transaction::{EditSource, EditTransaction, UndoGroupId, Version};
51
52/// Why a proposal hunk could not be carried forward onto the current buffer.
53///
54/// Each variant names a case from ADR-0006 §4 so the review UI can say *what happened*
55/// ("you edited inside this change") rather than reporting a generic failure. That
56/// distinction is most of the difference between a reviewable tool and a mysterious one.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ConflictReason {
59    /// ADR-0006 §4 case 2 — the human typed inside text this hunk wanted to replace.
60    /// Applying would destroy their edit without ever showing it to them.
61    EditedInsideRange,
62    /// ADR-0006 §4 case 4 — the text this hunk was anchored to is partly gone, so where
63    /// it belongs is guesswork.
64    AnchorDeleted,
65}
66
67impl ConflictReason {
68    /// The conflict implied by what an applied change did to a hunk's range, or `None`
69    /// if the hunk can still be carried forward.
70    ///
71    /// This is ADR-0006 §4's table as code: [`ChangeSet::touches`] reports what happened,
72    /// and this decides what it means for review. Case 5 (the human already made the same
73    /// change) is deliberately *not* here — it is a content check that runs before this
74    /// one, because a satisfied hunk looks exactly like a deleted anchor from here.
75    pub fn from_effect(effect: RangeEffect) -> Option<Self> {
76        match effect {
77            RangeEffect::Untouched => None,
78            RangeEffect::InsertedInside => Some(ConflictReason::EditedInsideRange),
79            RangeEffect::PartlyDeleted => Some(ConflictReason::AnchorDeleted),
80        }
81    }
82}
83
84impl core::fmt::Display for ConflictReason {
85    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
86        match self {
87            ConflictReason::EditedInsideRange => f.write_str("you edited inside this change"),
88            ConflictReason::AnchorDeleted => {
89                f.write_str("the code this change referred to is gone")
90            }
91        }
92    }
93}
94
95/// Whether a proposal hunk can still be applied (ADR-0006 §4, §5).
96///
97/// State lives on the *hunk*, never the proposal: a conflict in one hunk must not
98/// invalidate its siblings, because ARCHITECTURE.md §9.3 requires per-hunk review.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum HunkState {
101    /// Applies as-is.
102    Clean,
103    /// Cannot be applied; the human resolves it or re-asks the agent.
104    Conflicted(ConflictReason),
105    /// The human already made this change themselves, so there is nothing left to do.
106    Satisfied,
107}
108
109impl HunkState {
110    pub fn is_applicable(&self) -> bool {
111        matches!(self, HunkState::Clean)
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn only_clean_hunks_apply() {
121        assert!(HunkState::Clean.is_applicable());
122        assert!(!HunkState::Conflicted(ConflictReason::AnchorDeleted).is_applicable());
123        assert!(!HunkState::Satisfied.is_applicable(), "already done is not applied again");
124    }
125
126    #[test]
127    fn a_surviving_range_is_not_a_conflict() {
128        assert_eq!(ConflictReason::from_effect(RangeEffect::Untouched), None);
129    }
130
131    #[test]
132    fn each_overlap_becomes_the_conflict_it_implies() {
133        assert_eq!(
134            ConflictReason::from_effect(RangeEffect::InsertedInside),
135            Some(ConflictReason::EditedInsideRange)
136        );
137        assert_eq!(
138            ConflictReason::from_effect(RangeEffect::PartlyDeleted),
139            Some(ConflictReason::AnchorDeleted)
140        );
141    }
142
143    /// The path a hunk actually takes: a human edit, what it did to the hunk's range,
144    /// and the verdict the reviewer sees.
145    #[test]
146    fn a_human_edit_inside_a_hunk_makes_it_unapplicable() {
147        let hunk = (10, 20);
148        let typed_inside = ChangeSet::replace(40, 15, 15, "mine");
149
150        let state = match ConflictReason::from_effect(typed_inside.touches(hunk.0, hunk.1)) {
151            Some(reason) => HunkState::Conflicted(reason),
152            None => HunkState::Clean,
153        };
154
155        assert_eq!(state, HunkState::Conflicted(ConflictReason::EditedInsideRange));
156        assert!(!state.is_applicable(), "never silently destroy what the human wrote");
157    }
158
159    #[test]
160    fn every_conflict_reason_explains_itself_to_the_user() {
161        for reason in [ConflictReason::EditedInsideRange, ConflictReason::AnchorDeleted] {
162            let msg = reason.to_string();
163            assert!(!msg.is_empty());
164            assert!(!msg.contains("Conflict"), "should read as prose, got {msg:?}");
165        }
166    }
167}