Skip to main content

termesh_editor/
buffer.rs

1//! [`Buffer`] — a rope-backed document and the only thing that applies transactions.
2//!
3//! This is the chokepoint ARCHITECTURE.md §8 asks for. Nothing above this type mutates
4//! text: callers hand over an [`EditTransaction`] and the buffer validates it, computes
5//! the inverse while the pre-image is still live, applies it, bumps the version, carries
6//! the selection, and records the undo step. A transaction authored against a version the
7//! buffer has moved past is rejected here rather than silently corrupting the document —
8//! which is what makes an *asynchronous* agent safe to accept edits from.
9
10use std::path::{Path, PathBuf};
11
12use ropey::Rope;
13use termesh_core::BufferId;
14use termesh_filesystem::{FileSystemService, FsError};
15
16use crate::change::ChangeSet;
17use crate::decoration::DecorationSet;
18use crate::history::History;
19use crate::movement;
20use crate::selection::Selection;
21use crate::transaction::{EditSource, EditTransaction, Version};
22
23/// How a file's lines were terminated on disk.
24///
25/// The rope always holds `\n` so every offset calculation in the editor sees one
26/// character per line break. The original ending is remembered and restored on save, so
27/// opening a CRLF file and pressing save does not rewrite every line of somebody's diff.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum LineEnding {
30    #[default]
31    Lf,
32    Crlf,
33}
34
35impl LineEnding {
36    /// What the file mostly used. A mixed file is normalized to the dominant ending —
37    /// picking per-line would mean tracking an ending per line for no practical gain.
38    fn detect(text: &str) -> Self {
39        let crlf = text.matches("\r\n").count();
40        let lf = text.matches('\n').count() - crlf;
41        if crlf > lf {
42            LineEnding::Crlf
43        } else {
44            LineEnding::Lf
45        }
46    }
47}
48
49/// Why a transaction was refused.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum EditError {
52    /// Authored against a revision the buffer has moved past. Agent proposals hit this
53    /// when the human has typed since; the fix is to rebase, never to force.
54    StaleVersion {
55        expected: Version,
56        found: Version,
57    },
58    /// The changeset describes a document of a different size. A programming error —
59    /// the changeset and the buffer were never the same document.
60    LengthMismatch {
61        expected: usize,
62        found: usize,
63    },
64    /// The file is not valid UTF-8. V1 edits UTF-8 only (ARCHITECTURE.md §10).
65    NotUtf8(PathBuf),
66    Fs(FsError),
67}
68
69impl std::fmt::Display for EditError {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            EditError::StaleVersion { expected, found } => write!(
73                f,
74                "edit was written against version {} but the buffer is at {}",
75                expected.0, found.0
76            ),
77            EditError::LengthMismatch { expected, found } => {
78                write!(f, "edit expects a {expected}-char document, buffer has {found}")
79            }
80            EditError::NotUtf8(p) => write!(f, "not valid UTF-8: {}", p.display()),
81            EditError::Fs(e) => write!(f, "{e}"),
82        }
83    }
84}
85
86impl std::error::Error for EditError {}
87
88impl From<FsError> for EditError {
89    fn from(e: FsError) -> Self {
90        EditError::Fs(e)
91    }
92}
93
94pub type EditResult<T> = Result<T, EditError>;
95
96/// A text buffer: the document, where the cursor is, and how it got here.
97#[derive(Debug)]
98pub struct Buffer {
99    id: BufferId,
100    /// `None` for an untitled buffer that has never been saved.
101    path: Option<PathBuf>,
102    text: Rope,
103    version: Version,
104    selection: Selection,
105    history: History,
106    line_ending: LineEnding,
107    /// The version last written to disk. `None` when nothing has been saved yet.
108    saved_version: Option<Version>,
109    /// The column vertical motion is aiming for, so stepping through a short line and
110    /// back returns the cursor to where it started. Cleared by anything horizontal.
111    goal_column: Option<usize>,
112    /// Syntax, diagnostic, and agent-hunk overlays. Carried forward on every applied
113    /// transaction, so a pending proposal stays anchored to the code it describes while
114    /// the human keeps typing (ADR-0006 §3).
115    decorations: DecorationSet,
116    /// Changes already applied locally but not yet drained for document sync.
117    pending_changes: Vec<ChangeSet>,
118    /// First visible line.
119    ///
120    /// Remembered rather than derived from the cursor: a viewport computed purely from
121    /// `(cursor, height)` pins the cursor to one screen row and slides the file beneath
122    /// it. Scrolling has to be the *minimum* move that keeps the cursor on screen, and
123    /// "minimum" needs to know where the viewport already was.
124    scroll_top: usize,
125}
126
127impl Buffer {
128    /// An empty, untitled buffer.
129    pub fn new(id: BufferId) -> Self {
130        Self {
131            id,
132            path: None,
133            text: Rope::new(),
134            version: Version::default(),
135            selection: Selection::default(),
136            history: History::new(),
137            line_ending: LineEnding::default(),
138            saved_version: None,
139            goal_column: None,
140            decorations: DecorationSet::new(),
141            pending_changes: Vec::new(),
142            scroll_top: 0,
143        }
144    }
145
146    /// A buffer over text already in hand. Used by tests and by the agent's view of a
147    /// file it supplied; [`Buffer::load`] is the path for files on disk.
148    pub fn from_text(id: BufferId, path: Option<PathBuf>, text: &str) -> Self {
149        let line_ending = LineEnding::detect(text);
150        Self {
151            id,
152            path,
153            text: Rope::from_str(&text.replace("\r\n", "\n")),
154            version: Version::default(),
155            selection: Selection::default(),
156            history: History::new(),
157            line_ending,
158            saved_version: Some(Version::default()),
159            goal_column: None,
160            decorations: DecorationSet::new(),
161            pending_changes: Vec::new(),
162            scroll_top: 0,
163        }
164    }
165
166    /// Read a file through the service boundary — never `std::fs` (CONTRIBUTING.md invariants).
167    pub fn load(id: BufferId, fs: &dyn FileSystemService, path: &Path) -> EditResult<Self> {
168        let bytes = fs.read_file(path)?;
169        let text = String::from_utf8(bytes).map_err(|_| EditError::NotUtf8(path.to_path_buf()))?;
170        Ok(Self::from_text(id, Some(path.to_path_buf()), &text))
171    }
172
173    /// Write the buffer back, restoring the line ending it arrived with.
174    ///
175    /// Saving ends the current undo group: a save is a boundary the user thinks in, so
176    /// typing afterwards should not merge into what was already written out.
177    pub fn save(&mut self, fs: &dyn FileSystemService) -> EditResult<()> {
178        let path = self.path.clone().ok_or_else(|| {
179            EditError::Fs(FsError::Other {
180                path: PathBuf::new(),
181                message: "buffer has no path; save-as is not wired up yet".into(),
182            })
183        })?;
184
185        fs.write_file(&path, self.to_disk_string().as_bytes())?;
186        self.saved_version = Some(self.version);
187        self.history.break_group();
188        Ok(())
189    }
190
191    /// The document as it would be written out, with the on-disk line ending restored.
192    pub fn to_disk_string(&self) -> String {
193        match self.line_ending {
194            LineEnding::Lf => self.text.to_string(),
195            LineEnding::Crlf => self.text.to_string().replace('\n', "\r\n"),
196        }
197    }
198
199    pub fn id(&self) -> BufferId {
200        self.id
201    }
202
203    pub fn path(&self) -> Option<&Path> {
204        self.path.as_deref()
205    }
206
207    pub fn text(&self) -> &Rope {
208        &self.text
209    }
210
211    pub fn version(&self) -> Version {
212        self.version
213    }
214
215    pub fn line_ending(&self) -> LineEnding {
216        self.line_ending
217    }
218
219    pub fn selection(&self) -> &Selection {
220        &self.selection
221    }
222
223    /// Move the cursor. Ends the current undo group, because a deliberate move is where
224    /// a user expects one undo step to stop and the next to begin.
225    pub fn set_selection(&mut self, selection: Selection) {
226        self.selection = selection;
227        self.history.break_group();
228    }
229
230    /// Whether there are changes not yet written to disk.
231    pub fn is_dirty(&self) -> bool {
232        self.saved_version != Some(self.version)
233    }
234
235    /// The name to show on a tab.
236    pub fn display_name(&self) -> String {
237        match &self.path {
238            Some(p) => p.file_name().unwrap_or(p.as_os_str()).to_string_lossy().into_owned(),
239            None => "untitled".to_string(),
240        }
241    }
242
243    pub fn can_undo(&self) -> bool {
244        self.history.can_undo()
245    }
246
247    pub fn can_redo(&self) -> bool {
248        self.history.can_redo()
249    }
250
251    /// Drain the changes this buffer has not yet reported for document sync.
252    ///
253    /// Captured at mutation time because it cannot be reconstructed afterwards:
254    /// `History::Entry` records neither a version nor a source.
255    pub fn take_pending_changes(&mut self) -> Vec<ChangeSet> {
256        std::mem::take(&mut self.pending_changes)
257    }
258
259    /// Build a transaction against the current state, for `source`.
260    ///
261    /// Goes through [`History::group_for`], so a run of typing coalesces and anything
262    /// else gets its own undo step without the caller having to know the policy.
263    pub fn transaction(&mut self, changes: ChangeSet, source: EditSource) -> EditTransaction {
264        let group = self.history.group_for(&source);
265        EditTransaction::new(self.id, self.version, changes, source, group)
266    }
267
268    /// Apply a transaction — the single path by which this document ever changes.
269    ///
270    /// Rejects anything authored against a different revision or a different-sized
271    /// document. That check is the whole reason agent edits are safe: a proposal written
272    /// while the human was typing cannot land blind, it lands rebased or not at all.
273    pub fn apply(&mut self, transaction: &EditTransaction) -> EditResult<()> {
274        if transaction.base_version != self.version {
275            return Err(EditError::StaleVersion {
276                expected: transaction.base_version,
277                found: self.version,
278            });
279        }
280        if transaction.changes.len_before() != self.text.len_chars() {
281            return Err(EditError::LengthMismatch {
282                expected: transaction.changes.len_before(),
283                found: self.text.len_chars(),
284            });
285        }
286        if transaction.is_empty() {
287            return Ok(());
288        }
289
290        // Computed here, while `self.text` is still the pre-image (ADR-0006 §6).
291        let inverse = transaction.changes.invert(&self.text);
292
293        self.text = transaction.changes.apply(&self.text);
294        self.version = self.version.next();
295        // Overlays ride the edit. Pending hunks conflict rather than vanish; derived
296        // spans are dropped for their producer to regenerate (see `DecorationSet::map`).
297        self.decorations.map(&transaction.changes);
298        self.selection = match &transaction.selection {
299            Some(explicit) => explicit.clone(),
300            None => self.selection.map(&transaction.changes),
301        };
302        self.pending_changes.push(transaction.changes.clone());
303        self.history.push(transaction, inverse);
304        Ok(())
305    }
306
307    /// Convenience for the common shape: replace `from..to` with `insert`.
308    pub fn edit(
309        &mut self,
310        from: usize,
311        to: usize,
312        insert: &str,
313        source: EditSource,
314    ) -> EditResult<()> {
315        let changes = ChangeSet::replace(self.text.len_chars(), from, to, insert);
316        let transaction = self.transaction(changes, source);
317        self.apply(&transaction)
318    }
319
320    /// Record that `version` reached disk.
321    ///
322    /// The async save path: the write happens on the worker thread, so by the time it
323    /// succeeds the user may have typed again. Comparing versions rather than clearing a
324    /// flag means a buffer edited mid-write stays correctly dirty.
325    pub fn mark_saved(&mut self, version: Version) {
326        self.saved_version = Some(version);
327        self.history.break_group();
328    }
329
330    // --- cursor motion ------------------------------------------------------------
331    //
332    // Single cursor for V1 (ARCHITECTURE.md §10), so these drive the primary range. The
333    // rules themselves live in `movement`, as pure functions over the rope.
334
335    fn cursor(&self) -> usize {
336        self.selection.primary().head
337    }
338
339    /// Put the cursor at `pos`, forgetting any sticky column.
340    fn place_cursor(&mut self, pos: usize) {
341        self.goal_column = None;
342        self.set_selection(Selection::point(pos));
343    }
344
345    pub fn move_left(&mut self) {
346        let pos = movement::left(&self.text, self.cursor());
347        self.place_cursor(pos);
348    }
349
350    pub fn move_right(&mut self) {
351        let pos = movement::right(&self.text, self.cursor());
352        self.place_cursor(pos);
353    }
354
355    pub fn move_line_start(&mut self) {
356        let pos = movement::line_start(&self.text, self.cursor());
357        self.place_cursor(pos);
358    }
359
360    pub fn move_line_end(&mut self) {
361        let pos = movement::line_end(&self.text, self.cursor());
362        self.place_cursor(pos);
363    }
364
365    /// Move a line up or down, keeping the sticky column so a short line in between does
366    /// not permanently drag the cursor left.
367    pub fn move_line(&mut self, down: bool) {
368        let cursor = self.cursor();
369        let goal = self.goal_column.or_else(|| Some(movement::column_of(&self.text, cursor)));
370        let pos = if down {
371            movement::down(&self.text, cursor, goal)
372        } else {
373            movement::up(&self.text, cursor, goal)
374        };
375        self.set_selection(Selection::point(pos));
376        self.goal_column = goal;
377    }
378
379    /// The cursor as a `(line, column)` pair, for the status bar and the renderer.
380    pub fn decorations(&self) -> &DecorationSet {
381        &self.decorations
382    }
383
384    pub fn decorations_mut(&mut self) -> &mut DecorationSet {
385        &mut self.decorations
386    }
387
388    /// The char range of `line`, for clipping decorations to it.
389    pub fn line_range(&self, line: usize) -> (usize, usize) {
390        if line >= self.text.len_lines() {
391            let end = self.text.len_chars();
392            return (end, end);
393        }
394        let start = self.text.line_to_char(line);
395        (start, movement::line_end(&self.text, start))
396    }
397
398    pub fn scroll_top(&self) -> usize {
399        self.scroll_top
400    }
401
402    /// Scroll the least amount that brings the cursor back into a `height`-line viewport.
403    ///
404    /// Called by the commands that move the cursor, never by the renderer — `render`
405    /// stays a pure function of the model (ARCHITECTURE.md §7.1).
406    pub fn scroll_to_cursor(&mut self, height: usize) {
407        if height == 0 {
408            return;
409        }
410        // Keep a line of context beyond the cursor where the viewport is tall enough.
411        let margin = if height > 4 { 1 } else { 0 };
412        let (line, _) = self.cursor_position();
413
414        if line < self.scroll_top + margin {
415            self.scroll_top = line.saturating_sub(margin);
416        } else if line + margin >= self.scroll_top + height {
417            self.scroll_top = (line + margin + 1).saturating_sub(height);
418        }
419    }
420
421    pub fn cursor_position(&self) -> (usize, usize) {
422        let cursor = self.cursor();
423        (movement::line_of(&self.text, cursor), movement::column_of(&self.text, cursor))
424    }
425
426    // --- text entry ---------------------------------------------------------------
427
428    /// Insert text at the cursor, replacing the selection if there is one.
429    pub fn insert(&mut self, text: &str, source: EditSource) -> EditResult<()> {
430        let range = self.selection.primary();
431        self.goal_column = None;
432        self.edit(range.start(), range.end(), text, source)
433    }
434
435    /// Delete backwards: the selection if there is one, otherwise the preceding char.
436    pub fn delete_backward(&mut self) -> EditResult<()> {
437        let range = self.selection.primary();
438        self.goal_column = None;
439        if !range.is_empty() {
440            return self.edit(range.start(), range.end(), "", EditSource::Keyboard);
441        }
442        let cursor = range.head;
443        if cursor == 0 {
444            return Ok(());
445        }
446        self.edit(cursor - 1, cursor, "", EditSource::Keyboard)
447    }
448
449    /// Delete forwards: the selection if there is one, otherwise the following char.
450    pub fn delete_forward(&mut self) -> EditResult<()> {
451        let range = self.selection.primary();
452        self.goal_column = None;
453        if !range.is_empty() {
454            return self.edit(range.start(), range.end(), "", EditSource::Keyboard);
455        }
456        let cursor = range.head;
457        if cursor >= self.text.len_chars() {
458            return Ok(());
459        }
460        self.edit(cursor, cursor + 1, "", EditSource::Keyboard)
461    }
462
463    /// Reverse the most recent undo group. Returns whether anything happened.
464    pub fn undo(&mut self) -> bool {
465        match self.history.undo() {
466            Some(changes) => {
467                self.replay(&changes);
468                true
469            }
470            None => false,
471        }
472    }
473
474    pub fn redo(&mut self) -> bool {
475        match self.history.redo() {
476            Some(changes) => {
477                self.replay(&changes);
478                true
479            }
480            None => false,
481        }
482    }
483
484    /// Apply a changeset the history handed back.
485    ///
486    /// Deliberately not routed through [`Self::apply`]: these are already-recorded edits
487    /// being replayed, so re-recording them would push undo steps for undoing.
488    fn replay(&mut self, changes: &ChangeSet) {
489        debug_assert_eq!(changes.len_before(), self.text.len_chars());
490        self.text = changes.apply(&self.text);
491        self.version = self.version.next();
492        self.selection = self.selection.map(changes);
493        self.decorations.map(changes);
494        self.pending_changes.push(changes.clone());
495    }
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501    use crate::decoration::{Decoration, DecorationClass, HunkSide};
502    use crate::selection::Range;
503    use crate::HunkState;
504    use termesh_core::ProposalId;
505    use termesh_test_support::FakeFileSystem;
506
507    fn buffer(text: &str) -> Buffer {
508        Buffer::from_text(BufferId::new(1), Some(PathBuf::from("/proj/main.rs")), text)
509    }
510
511    #[test]
512    fn a_new_buffer_is_empty_untitled_and_clean() {
513        let b = Buffer::new(BufferId::new(1));
514        assert_eq!(b.text().to_string(), "");
515        assert_eq!(b.display_name(), "untitled");
516        assert!(b.path().is_none());
517        assert!(b.is_dirty(), "an unsaved untitled buffer has nowhere to have been saved to");
518    }
519
520    #[test]
521    fn editing_bumps_the_version_and_marks_it_dirty() {
522        let mut b = buffer("hello");
523        assert!(!b.is_dirty());
524        let before = b.version();
525
526        b.edit(5, 5, " world", EditSource::Keyboard).unwrap();
527        assert_eq!(b.text().to_string(), "hello world");
528        assert_eq!(b.version(), before.next());
529        assert!(b.is_dirty());
530    }
531
532    #[test]
533    fn an_applied_transaction_is_queued_for_document_sync() {
534        let mut b = Buffer::from_text(BufferId::new(1), None, "fn main() {}");
535        b.edit(3, 7, "test", EditSource::Keyboard).unwrap();
536        let queued = b.take_pending_changes();
537        assert_eq!(queued.len(), 1);
538        assert!(b.take_pending_changes().is_empty(), "draining is destructive");
539    }
540
541    #[test]
542    fn undo_and_redo_are_queued_too() {
543        // A hook in `apply` alone desyncs the server on every undo: `replay` mutates the
544        // rope and bumps the version without going through `apply` (ADR-0011 §4).
545        let mut b = Buffer::from_text(BufferId::new(1), None, "abc");
546        b.edit(0, 0, "x", EditSource::Keyboard).unwrap();
547        let _ = b.take_pending_changes();
548
549        assert!(b.undo());
550        assert_eq!(b.take_pending_changes().len(), 1, "undo must be sent to the server");
551
552        assert!(b.redo());
553        assert_eq!(b.take_pending_changes().len(), 1, "redo must be sent to the server");
554    }
555
556    #[test]
557    fn an_empty_transaction_queues_nothing() {
558        let mut b = Buffer::from_text(BufferId::new(1), None, "abc");
559        let tx = b.transaction(ChangeSet::identity(3), EditSource::Keyboard);
560        b.apply(&tx).unwrap();
561        assert!(b.take_pending_changes().is_empty());
562    }
563
564    #[test]
565    fn a_stale_transaction_is_refused_rather_than_applied() {
566        let mut b = buffer("hello");
567        // Authored against the current version...
568        let stale = b.transaction(
569            ChangeSet::replace(5, 0, 5, "goodbye"),
570            EditSource::Agent(ProposalId::new(1)),
571        );
572        // ...but the human types first.
573        b.edit(5, 5, "!", EditSource::Keyboard).unwrap();
574
575        let err = b.apply(&stale).unwrap_err();
576        assert!(matches!(err, EditError::StaleVersion { .. }), "got {err:?}");
577        assert_eq!(b.text().to_string(), "hello!", "the document is untouched");
578    }
579
580    #[test]
581    fn a_changeset_for_a_different_document_is_refused() {
582        let mut b = buffer("hello");
583        let wrong = EditTransaction::new(
584            b.id(),
585            b.version(),
586            ChangeSet::replace(99, 0, 1, "x"),
587            EditSource::Keyboard,
588            Default::default(),
589        );
590        assert!(matches!(b.apply(&wrong), Err(EditError::LengthMismatch { .. })));
591        assert_eq!(b.text().to_string(), "hello");
592    }
593
594    #[test]
595    fn an_empty_transaction_changes_nothing_and_is_not_an_error() {
596        let mut b = buffer("hello");
597        let before = b.version();
598        b.edit(2, 2, "", EditSource::Keyboard).unwrap();
599        assert_eq!(b.version(), before, "a no-op does not advance the revision");
600        assert!(!b.can_undo());
601    }
602
603    #[test]
604    fn the_cursor_rides_along_with_an_edit_before_it() {
605        let mut b = buffer("hello world");
606        b.set_selection(Selection::point(6));
607        b.edit(0, 0, ">> ", EditSource::Paste).unwrap();
608        assert_eq!(b.selection().primary(), Range::point(9));
609    }
610
611    #[test]
612    fn a_transaction_can_pin_the_cursor_explicitly() {
613        let mut b = buffer("hello");
614        let tx = b
615            .transaction(ChangeSet::replace(5, 0, 0, "abc"), EditSource::Keyboard)
616            .with_selection(Selection::point(0));
617        b.apply(&tx).unwrap();
618        assert_eq!(b.selection().primary(), Range::point(0), "the explicit choice wins");
619    }
620
621    // --- undo/redo through the buffer --------------------------------------------
622
623    #[test]
624    fn undo_and_redo_move_the_document_and_the_version() {
625        let mut b = buffer("hello");
626        b.edit(5, 5, " world", EditSource::Paste).unwrap();
627
628        assert!(b.undo());
629        assert_eq!(b.text().to_string(), "hello");
630        assert!(b.redo());
631        assert_eq!(b.text().to_string(), "hello world");
632        assert!(!b.redo(), "nothing left to redo");
633    }
634
635    #[test]
636    fn an_agent_edit_undoes_in_one_step_and_stays_traceable() {
637        let mut b = buffer("fn main() {}");
638        let source = EditSource::Agent(ProposalId::new(7));
639
640        let tx = b.transaction(ChangeSet::replace(12, 3, 7, "run"), source);
641        assert_eq!(tx.proposal(), Some(ProposalId::new(7)));
642        b.apply(&tx).unwrap();
643        assert_eq!(b.text().to_string(), "fn run() {}");
644
645        assert!(b.undo());
646        assert_eq!(b.text().to_string(), "fn main() {}");
647    }
648
649    // --- disk round trip ----------------------------------------------------------
650
651    #[test]
652    fn a_file_loads_edits_and_saves_through_the_service() {
653        let fs = FakeFileSystem::with_paths(&["/proj/main.rs"]);
654        fs.add_file("/proj/main.rs", b"fn main() {}\n");
655
656        let mut b = Buffer::load(BufferId::new(1), &fs, Path::new("/proj/main.rs")).unwrap();
657        assert_eq!(b.text().to_string(), "fn main() {}\n");
658        assert_eq!(b.display_name(), "main.rs");
659        assert!(!b.is_dirty());
660
661        b.edit(3, 7, "run", EditSource::Keyboard).unwrap();
662        assert!(b.is_dirty());
663
664        b.save(&fs).unwrap();
665        assert!(!b.is_dirty(), "saving settles the dirty flag");
666        assert_eq!(fs.read_file(Path::new("/proj/main.rs")).unwrap(), b"fn run() {}\n");
667    }
668
669    #[test]
670    fn saving_ends_the_undo_group() {
671        let fs = FakeFileSystem::with_paths(&["/proj/main.rs"]);
672        fs.add_file("/proj/main.rs", b"()");
673        let mut b = Buffer::load(BufferId::new(1), &fs, Path::new("/proj/main.rs")).unwrap();
674
675        b.edit(1, 1, "a", EditSource::Keyboard).unwrap();
676        b.save(&fs).unwrap();
677        b.edit(2, 2, "b", EditSource::Keyboard).unwrap();
678
679        b.undo();
680        assert_eq!(b.text().to_string(), "(a)", "typing after a save is its own step");
681    }
682
683    #[test]
684    fn crlf_survives_a_round_trip() {
685        let fs = FakeFileSystem::with_paths(&["/proj/win.rs"]);
686        fs.add_file("/proj/win.rs", b"one\r\ntwo\r\n");
687
688        let mut b = Buffer::load(BufferId::new(1), &fs, Path::new("/proj/win.rs")).unwrap();
689        // Internally one char per break, so offsets never have to know about \r.
690        assert_eq!(b.text().to_string(), "one\ntwo\n");
691        assert_eq!(b.line_ending(), LineEnding::Crlf);
692
693        b.save(&fs).unwrap();
694        assert_eq!(
695            fs.read_file(Path::new("/proj/win.rs")).unwrap(),
696            b"one\r\ntwo\r\n",
697            "saving must not rewrite every line of somebody's diff"
698        );
699    }
700
701    #[test]
702    fn lf_files_stay_lf() {
703        let mut b = buffer("one\ntwo\n");
704        assert_eq!(b.line_ending(), LineEnding::Lf);
705        b.edit(0, 0, "x", EditSource::Keyboard).unwrap();
706        assert_eq!(b.to_disk_string(), "xone\ntwo\n");
707    }
708
709    #[test]
710    fn a_non_utf8_file_is_refused_by_name() {
711        let fs = FakeFileSystem::with_paths(&["/proj/blob.bin"]);
712        fs.add_file("/proj/blob.bin", &[0xff, 0xfe, 0x00]);
713
714        let err = Buffer::load(BufferId::new(1), &fs, Path::new("/proj/blob.bin")).unwrap_err();
715        assert!(matches!(err, EditError::NotUtf8(_)), "got {err:?}");
716        assert!(err.to_string().contains("blob.bin"), "the message names the file");
717    }
718
719    #[test]
720    fn a_missing_file_reports_the_filesystem_error() {
721        let fs = FakeFileSystem::with_paths(&["/proj/main.rs"]);
722        let err = Buffer::load(BufferId::new(1), &fs, Path::new("/proj/nope.rs")).unwrap_err();
723        assert!(matches!(err, EditError::Fs(FsError::NotFound(_))), "got {err:?}");
724    }
725
726    // --- typing and motion through the buffer -------------------------------------
727
728    #[test]
729    fn typing_inserts_at_the_cursor_and_carries_it_along() {
730        let mut b = buffer("()");
731        b.set_selection(Selection::point(1));
732        for ch in ["a", "b", "c"] {
733            b.insert(ch, EditSource::Keyboard).unwrap();
734        }
735        assert_eq!(b.text().to_string(), "(abc)");
736        assert_eq!(b.cursor_position(), (0, 4));
737    }
738
739    #[test]
740    fn a_run_of_typing_is_one_undo_step_but_a_cursor_move_splits_it() {
741        let mut b = buffer("()");
742        b.set_selection(Selection::point(1));
743        b.insert("a", EditSource::Keyboard).unwrap();
744        b.insert("b", EditSource::Keyboard).unwrap();
745        b.undo();
746        assert_eq!(b.text().to_string(), "()", "one run, one undo");
747
748        b.set_selection(Selection::point(1));
749        b.insert("x", EditSource::Keyboard).unwrap();
750        b.move_right(); // a deliberate move
751        b.insert("y", EditSource::Keyboard).unwrap();
752        b.undo();
753        assert_eq!(b.text().to_string(), "(x)", "the move ended the run");
754    }
755
756    #[test]
757    fn typing_over_a_selection_replaces_it() {
758        let mut b = buffer("hello world");
759        b.set_selection(Selection::single(Range::new(0, 5)));
760        b.insert("bye", EditSource::Keyboard).unwrap();
761        assert_eq!(b.text().to_string(), "bye world");
762    }
763
764    #[test]
765    fn backspace_and_delete_take_one_character_each_way() {
766        let mut b = buffer("abcd");
767        b.set_selection(Selection::point(2));
768        b.delete_backward().unwrap();
769        assert_eq!(b.text().to_string(), "acd");
770        b.delete_forward().unwrap();
771        assert_eq!(b.text().to_string(), "ad");
772    }
773
774    #[test]
775    fn deleting_at_the_edges_of_the_document_does_nothing() {
776        let mut b = buffer("ab");
777        b.set_selection(Selection::point(0));
778        b.delete_backward().unwrap();
779        b.set_selection(Selection::point(2));
780        b.delete_forward().unwrap();
781        assert_eq!(b.text().to_string(), "ab", "no edit, and no panic at the boundaries");
782        assert!(!b.can_undo(), "and nothing recorded to undo");
783    }
784
785    #[test]
786    fn deleting_removes_the_selection_when_there_is_one() {
787        let mut b = buffer("hello world");
788        b.set_selection(Selection::single(Range::new(5, 11)));
789        b.delete_backward().unwrap();
790        assert_eq!(b.text().to_string(), "hello");
791    }
792
793    #[test]
794    fn a_newline_splits_the_line_and_moves_the_cursor_down() {
795        let mut b = buffer("ab");
796        b.set_selection(Selection::point(1));
797        b.insert("\n", EditSource::Keyboard).unwrap();
798        assert_eq!(b.text().to_string(), "a\nb");
799        assert_eq!(b.cursor_position(), (1, 0));
800    }
801
802    #[test]
803    fn vertical_motion_keeps_its_column_across_a_short_line() {
804        let mut b = buffer("abcdefgh\nxy\nabcdefgh\n");
805        b.set_selection(Selection::point(6)); // line 0, column 6
806
807        b.move_line(true);
808        assert_eq!(b.cursor_position(), (1, 2), "clamped to the short line");
809        b.move_line(true);
810        assert_eq!(b.cursor_position(), (2, 6), "and restored below it");
811    }
812
813    #[test]
814    fn horizontal_motion_forgets_the_sticky_column() {
815        let mut b = buffer("abcdefgh\nxy\nabcdefgh\n");
816        b.set_selection(Selection::point(6));
817        b.move_line(true); // clamped to column 2
818        b.move_left(); // a deliberate horizontal move re-aims
819        b.move_line(true);
820        assert_eq!(b.cursor_position(), (2, 1), "the new column wins");
821    }
822
823    #[test]
824    fn home_and_end_land_on_the_visible_ends_of_the_line() {
825        let mut b = buffer("  indented\nnext\n");
826        b.set_selection(Selection::point(5));
827        b.move_line_end();
828        assert_eq!(b.cursor_position(), (0, 10), "before the newline, not after it");
829        b.move_line_start();
830        assert_eq!(b.cursor_position(), (0, 0));
831    }
832
833    // --- decorations ride edits ---------------------------------------------------
834
835    #[test]
836    fn a_hunk_stays_anchored_to_its_code_while_the_human_types_above_it() {
837        // The property continuous rebasing exists for: review stays correct mid-typing.
838        let mut b = buffer("fn main() {}\n");
839        b.decorations_mut().push(Decoration::new(
840            3,
841            7,
842            DecorationClass::Hunk {
843                proposal: ProposalId::new(1),
844                side: HunkSide::Removed,
845                state: HunkState::Clean,
846            },
847        ));
848
849        b.set_selection(Selection::point(0));
850        b.insert("pub ", EditSource::Keyboard).unwrap();
851
852        let d = b.decorations().iter().next().unwrap();
853        assert_eq!((d.start, d.end), (7, 11), "still on `main`, four chars further along");
854        assert_eq!(b.text().to_string(), "pub fn main() {}\n");
855    }
856
857    #[test]
858    fn editing_inside_a_hunk_conflicts_it_rather_than_dropping_it() {
859        let mut b = buffer("fn main() {}\n");
860        b.decorations_mut().push(Decoration::new(
861            3,
862            7,
863            DecorationClass::Hunk {
864                proposal: ProposalId::new(1),
865                side: HunkSide::Removed,
866                state: HunkState::Clean,
867            },
868        ));
869
870        b.set_selection(Selection::point(5));
871        b.insert("X", EditSource::Keyboard).unwrap();
872
873        let d = b.decorations().iter().next().unwrap();
874        assert!(
875            matches!(d.class, DecorationClass::Hunk { state: HunkState::Conflicted(_), .. }),
876            "the human must be told, not silently overruled"
877        );
878    }
879
880    #[test]
881    fn line_ranges_cover_the_visible_text_of_each_line() {
882        let b = buffer("abc\ndefgh\n");
883        assert_eq!(b.line_range(0), (0, 3), "excludes the newline");
884        assert_eq!(b.line_range(1), (4, 9));
885    }
886
887    #[test]
888    fn a_line_past_the_end_reports_an_empty_range_at_the_end() {
889        let b = buffer("abc");
890        let end = b.text().len_chars();
891        assert_eq!(b.line_range(99), (end, end));
892    }
893
894    // --- async save ---------------------------------------------------------------
895
896    #[test]
897    fn marking_saved_settles_the_dirty_flag() {
898        let mut b = buffer("hello");
899        b.edit(5, 5, "!", EditSource::Keyboard).unwrap();
900        let version = b.version();
901        assert!(b.is_dirty());
902
903        b.mark_saved(version);
904        assert!(!b.is_dirty());
905    }
906
907    /// The race the async save path exists to survive: the write is on a worker thread,
908    /// so the user can type before it lands.
909    #[test]
910    fn typing_while_a_save_is_in_flight_leaves_the_buffer_dirty() {
911        let mut b = buffer("hello");
912        b.edit(5, 5, "!", EditSource::Keyboard).unwrap();
913        let in_flight = b.version();
914
915        b.edit(6, 6, "?", EditSource::Keyboard).unwrap(); // typed before the write returned
916        b.mark_saved(in_flight);
917
918        assert!(b.is_dirty(), "what reached disk is not what is in the buffer");
919    }
920
921    #[test]
922    fn multibyte_content_edits_by_char_offset() {
923        let mut b = buffer("héllo wörld");
924        b.edit(6, 11, "there", EditSource::Keyboard).unwrap();
925        assert_eq!(b.text().to_string(), "héllo there");
926    }
927}