Skip to main content

strop_engine/editor/document/
mod.rs

1//! The document lifecycle (0014 wave 2): the Document struct, the
2//! arena accessors, open/close/scratch, MRU. One document owns its text,
3//! highlighter, and git surface — no parallel vectors.
4
5use strop_core::Buffer;
6
7mod indentation;
8pub(crate) mod remote;
9pub mod surfaces;
10pub use indentation::{detect_indent, Detection, Indent, IndentOverride, IndentSource};
11mod directory;
12pub use directory::Directory;
13pub use remote::RemoteDocument;
14mod snapshot;
15pub(crate) use snapshot::last_position;
16
17pub use super::jumps::JumpRecord;
18pub use surfaces::{DiffRow, DocumentSource, Surface};
19
20use super::Editor;
21
22/// One document: the text buffer plus everything that used to live in
23/// parallel vectors keyed by buffer index (0014 wave 2). One struct,
24/// one arena — the alignment invariant is the type system now.
25pub struct Document {
26    pub buf: Buffer,
27    pub(crate) syntax_hint: Option<std::path::PathBuf>,
28    /// Resolved at open/reload and after `:tab-size`/`:indent-style`
29    /// (0051 R08): manual override → confident detection → config.
30    pub indent: Indent,
31    /// Explicit `:tab-size N` / `:indent-style …` choices; they survive
32    /// reloads and config refreshes, and other buffers never touch them.
33    pub indent_override: IndentOverride,
34    /// What the last detection pass concluded (None when `indent_detect`
35    /// is off) — `:explain` reports it with its confidence or reason.
36    pub detection: Option<Detection>,
37    /// What backs this document (0021 §4): the surface payload lives in
38    /// the source variant; readonly derives from it at construction.
39    pub source: DocumentSource,
40}
41
42impl Document {
43    pub fn label(&self, cwd: &std::path::Path) -> String {
44        if let Some(remote) = self.remote_metadata() {
45            remote.file.to_string()
46        } else if let DocumentSource::Container { container, path } = &self.source {
47            strop_workspace::ResourceLocation {
48                filesystem: strop_workspace::Filesystem::Container(container.clone()),
49                path: path.clone(),
50            }
51            .label()
52        } else {
53            self.buf
54                .path
55                .as_ref()
56                .map(|path| path.strip_prefix(cwd).unwrap_or(path).display().to_string())
57                .or_else(|| self.buf.name.clone())
58                .unwrap_or_else(|| "[scratch]".into())
59        }
60    }
61
62    pub(crate) fn documentation(mut buffer: Buffer) -> Self {
63        buffer.name = Some("documentation".into());
64        let mut document = Self::output(buffer);
65        document.syntax_hint = Some(std::path::PathBuf::from("documentation.md"));
66        document
67    }
68    pub fn new(buf: Buffer) -> Self {
69        let detection = Some(detect_indent(buf.text()));
70        Self {
71            buf,
72            syntax_hint: None,
73            indent: Indent::default(),
74            indent_override: IndentOverride::default(),
75            detection,
76            source: DocumentSource::File,
77        }
78    }
79
80    /// A scratch document (no file).
81    pub fn scratch(buf: Buffer) -> Self {
82        let detection = Some(detect_indent(buf.text()));
83        Self {
84            buf,
85            syntax_hint: None,
86            indent: Indent::default(),
87            indent_override: IndentOverride::default(),
88            detection,
89            source: DocumentSource::Scratch,
90        }
91    }
92
93    /// A git-memory surface: job-owned content, readonly derived from
94    /// the source — not set by hand (0021 §4).
95    pub fn surface(mut buf: Buffer, surface: Surface, context: strop_git::GitContext) -> Self {
96        buf.readonly = true;
97        Self {
98            buf,
99            syntax_hint: None,
100            indent: Indent::default(),
101            source: DocumentSource::Surface(Box::new(surfaces::GitSurface {
102                context,
103                content: surface,
104            })),
105            indent_override: IndentOverride::default(),
106            detection: None,
107        }
108    }
109
110    /// Named virtual content (`:!cmd` output, help, the undo browser):
111    /// readonly derived from the source. Temporary surfaces opened
112    /// through [`Editor::open_temporary_output`] also carry the return
113    /// point `:q` restores (0051 §7 R07).
114    pub fn output(mut buf: Buffer) -> Self {
115        buf.readonly = true;
116        Self {
117            buf,
118            syntax_hint: None,
119            indent: Indent::default(),
120            indent_override: IndentOverride::default(),
121            detection: None,
122            source: DocumentSource::Output { return_to: None },
123        }
124    }
125
126    /// A file read from a container (0037 DC1a/b): readonly derived from
127    /// the source; the path names container bytes, never a local file.
128    pub fn container_file(
129        mut buf: Buffer,
130        container: strop_workspace::ContainerId,
131        path: std::path::PathBuf,
132    ) -> Self {
133        buf.readonly = true;
134        let detection = Some(detect_indent(buf.text()));
135        Self {
136            buf,
137            syntax_hint: None,
138            indent: Indent::default(),
139            indent_override: IndentOverride::default(),
140            detection,
141            source: DocumentSource::Container { container, path },
142        }
143    }
144
145    /// Syntax identity is data; parsers live on the display-analysis worker.
146    pub fn syntax_path(&self) -> Option<&std::path::Path> {
147        if let Some(path) = &self.syntax_hint {
148            return Some(path);
149        }
150        match &self.source {
151            DocumentSource::Remote(source) => Some(source.file.path()),
152            DocumentSource::Surface(source) => match &source.content {
153                Surface::Diff {
154                    commit: Some(commit),
155                    ..
156                } => Some(&commit.current),
157                Surface::Diff { hunks, .. } => Some(std::path::Path::new(hunks.label())),
158                _ => None,
159            },
160            DocumentSource::File | DocumentSource::Scratch => self.buf.path.as_deref(),
161            DocumentSource::Container { path, .. } => Some(path),
162            DocumentSource::Directory(_) | DocumentSource::Output { .. } => None,
163        }
164    }
165
166    pub fn matches_target(&self, target: &crate::files::FileTarget) -> bool {
167        match (&self.source, target) {
168            (DocumentSource::Remote(source), crate::files::FileTarget::Remote(location)) => {
169                location.absolute_file() == Some(&source.file)
170            }
171            (DocumentSource::Directory(source), target) => {
172                target.matches_location(&source.location)
173            }
174            (
175                DocumentSource::Container { container, path },
176                crate::files::FileTarget::Container {
177                    container: expected,
178                    path: requested,
179                },
180            ) => container == expected && path == requested,
181            (DocumentSource::File, crate::files::FileTarget::Local(path)) => {
182                self.buf.path.as_ref() == Some(path)
183                    || self.buf.file_identity() == Some(path.as_path())
184            }
185            _ => false,
186        }
187    }
188
189    pub(crate) fn file_target(&self, cwd: &std::path::Path) -> Option<crate::files::FileTarget> {
190        use crate::files::FileTarget;
191        match &self.source {
192            DocumentSource::Remote(source) => Some(FileTarget::Remote(source.file.clone().into())),
193            DocumentSource::Directory(source) => FileTarget::from_location(&source.location).ok(),
194            DocumentSource::Container { container, path } => Some(FileTarget::Container {
195                container: container.clone(),
196                path: path.clone(),
197            }),
198            _ => self
199                .buf
200                .file_identity()
201                .or(self.buf.path.as_deref())
202                .map(|path| FileTarget::Local(cwd.join(path))),
203        }
204    }
205
206    /// The surface payload, when this document is one.
207    pub fn surface_payload(&self) -> Option<&Surface> {
208        match &self.source {
209            DocumentSource::Surface(s) => Some(&s.content),
210            _ => None,
211        }
212    }
213
214    /// Mutable surface payload, when this document is one.
215    pub fn surface_payload_mut(&mut self) -> Option<&mut Surface> {
216        match &mut self.source {
217            DocumentSource::Surface(s) => Some(&mut s.content),
218            _ => None,
219        }
220    }
221
222    pub(crate) fn git_context(&self) -> Option<&strop_git::GitContext> {
223        match &self.source {
224            DocumentSource::Surface(surface) => Some(&surface.context),
225            _ => None,
226        }
227    }
228}
229
230impl Editor {
231    /// Mark a document most-recently-used.
232    pub fn touch_mru(&mut self, i: strop_core::id::DocumentId) {
233        self.mru.retain(|&x| x != i);
234        self.mru.insert(0, i);
235    }
236
237    /// The current document. Invariant: the editor always has one live
238    /// document while it runs (closing the last one sets should_quit).
239    pub fn cur(&self) -> &Document {
240        self.docs
241            .get(self.current())
242            .expect("invariant: current document is live")
243    }
244
245    pub(crate) fn cur_mut(&mut self) -> super::transact::DocumentEdit<'_> {
246        self.doc_mut(self.current())
247    }
248
249    pub fn buf(&self) -> &Buffer {
250        &self.cur().buf
251    }
252
253    pub fn buf_mut(&mut self) -> super::transact::BufferEdit<'_> {
254        super::transact::BufferEdit::new(self.cur_mut())
255    }
256
257    /// One document by id — stale ids panic: an id outliving its
258    /// document is a bug, and the generation check is what keeps it
259    /// from silently resolving to the wrong one (0014 wave 2).
260    pub fn doc(&self, id: strop_core::id::DocumentId) -> &Document {
261        self.docs.get(id).expect("stale document id")
262    }
263
264    pub(crate) fn doc_mut(
265        &mut self,
266        id: strop_core::id::DocumentId,
267    ) -> super::transact::DocumentEdit<'_> {
268        super::transact::DocumentEdit::new(self, id)
269    }
270
271    /// Tests: the first live document's id (the "buffers[0]" of the
272    /// index era).
273    #[cfg(any(test, feature = "test-support"))]
274    pub fn first_doc(&self) -> strop_core::id::DocumentId {
275        self.docs
276            .iter()
277            .next()
278            .map(|(id, _)| id)
279            .expect("test document")
280    }
281
282    /// vim's [No Name] rule: the untouched initial scratch buffer is
283    /// replaced by the first real thing you open. `replacement` is the
284    /// document taking over (0023 §1): panes on the scratch rebind to
285    /// it FIRST — the drop used to strand them (the :vs crash probe).
286    pub(crate) fn drop_stale_scratch(&mut self, replacement: strop_core::id::DocumentId) {
287        // find the pristine scratch (pathless, untouched) wherever it is
288        // — the replacement exists by now, so len() is no longer the
289        // signal (0023: it must fire AFTER the insert so panes can rebind)
290        let scratch = self.docs.iter().find_map(|(id, d)| {
291            let b = &d.buf;
292            (b.path.is_none()
293                && !b.dirty
294                && b.len_bytes() == 0
295                && b.name.is_none()
296                && id != replacement)
297                .then_some(id)
298        });
299        let Some(scratch) = scratch else {
300            return;
301        };
302        if self
303            .pending
304            .prompt()
305            .is_some_and(|prompt| prompt.origin().pane.doc == scratch)
306        {
307            self.cancel_pending();
308        }
309        for pane in &mut self.panes {
310            if pane.doc == scratch {
311                pane.doc = replacement;
312            }
313        }
314        self.lsp_close_document(scratch);
315        self.docs.remove(scratch);
316        self.mru.retain(|&x| x != scratch);
317        if self.view().doc == scratch {
318            self.view_mut().doc = replacement;
319        }
320    }
321
322    /// The active document (derived: the active view's document).
323    #[inline]
324    pub fn current(&self) -> strop_core::id::DocumentId {
325        self.view().doc
326    }
327
328    /// Switch the active view to a document.
329    pub fn switch_to(&mut self, id: strop_core::id::DocumentId) {
330        if self.current() != id {
331            self.remember_directory_view();
332        }
333        self.cancel_pending();
334        self.cancel_open(strop_core::worker::CancelReason::Superseded);
335        self.focus_epoch += 1;
336        if self.current() != id {
337            let view = self.view_mut();
338            view.sels = strop_core::selection::SelectionSet::default();
339            view.view_top = 0;
340            view.hscroll = strop_core::id::DisplayColumn::new(0);
341            view.desired_column = None;
342        }
343        self.view_mut().doc = id;
344        self.touch_mru(id);
345    }
346
347    /// Open a temporary readonly output surface — help, explain, the
348    /// undo browser, a review or save report (0051 §7 R07): the jump is
349    /// recorded AND the new document carries the exact origin view as
350    /// its navigation record, so ctrl-o AND `:q` hand back the caret,
351    /// viewport and horizontal origin the user came from. A stale
352    /// scratch origin dies with its buffer; close_buffer skips dead
353    /// return points on its own.
354    pub(crate) fn open_temporary_output(
355        &mut self,
356        buf: strop_core::Buffer,
357    ) -> strop_core::id::DocumentId {
358        self.push_jump();
359        let mut document = Document::output(buf);
360        document.set_return_point(self.jump_record());
361        let id = self.docs.insert(document);
362        self.drop_stale_scratch(id);
363        self.switch_to(id);
364        self.set_head(0);
365        id
366    }
367
368    /// The active view's selections.
369    #[inline]
370    pub fn sels(&self) -> &strop_core::selection::SelectionSet {
371        &self.view().sels
372    }
373
374    #[inline]
375    pub fn sels_mut(&mut self) -> &mut strop_core::selection::SelectionSet {
376        &mut self.view_mut().sels
377    }
378
379    /// The active view's scroll offset.
380    #[inline]
381    /// The active pane's text-area height in rows (render-loop fed).
382    pub fn view_rows(&self) -> usize {
383        self.view_rows
384    }
385
386    pub fn view_top(&self) -> usize {
387        self.view().view_top
388    }
389
390    /// Close the current document; quits when the last one closes.
391    /// Returns false when unsaved changes block the close. Generational
392    /// ids mean no reindexing anywhere (0014 wave 2).
393    pub fn close_buffer(&mut self, force: bool) -> bool {
394        self.remember_directory_view();
395        if !force && self.docs.len() == 1 && self.filesystem.unconfirmed() > 0 {
396            self.message =
397                "filesystem outcomes are unconfirmed; :fs verify before quitting, or :q! to force"
398                    .into();
399            return false;
400        }
401        // A collection's dirty bit is presentation state (0049 §5): the
402        // sources hold the real unsaved edits — the view always closes.
403        let view_only = self.collections.contains_key(&self.current());
404        if self.buf().dirty && !force && !view_only {
405            self.message = "unsaved changes — :q! to force".into();
406            return false;
407        }
408        self.cancel_pending();
409        self.cancel_open(strop_core::worker::CancelReason::OwnerClosed);
410        if self.docs.len() == 1 {
411            self.request_session_save();
412        }
413        let closed = self.current();
414        let mut affected_collections = Vec::new();
415        for (id, collection) in &mut self.collections {
416            let before = collection.excerpts.len();
417            collection
418                .excerpts
419                .retain(|excerpt| excerpt.source != closed);
420            collection
421                .pending_commit
422                .retain(|(source, _)| *source != closed);
423            if collection.excerpts.len() != before {
424                collection.match_count = collection
425                    .excerpts
426                    .iter()
427                    .map(|excerpt| excerpt.matches.len().max(excerpt.hit_anchors.len()))
428                    .sum();
429                affected_collections.push(*id);
430            }
431        }
432        self.revoke_remote_write(closed);
433        self.analysis
434            .forget(super::analysis::AnalysisTarget::Document(closed));
435        self.stop_remote_follow(closed);
436        self.cancel_directory_filter(closed);
437        self.lsp_close_document(closed);
438        self.shell_document_closed(closed);
439        self.revoke_git_requests_for(closed);
440        self.blame_gutters.remove(&closed);
441        self.collections.remove(&closed);
442        self.review.forget(closed);
443        self.filesystem_forget_view(closed);
444        let return_to = self
445            .docs
446            .remove(closed)
447            .and_then(|document| document.return_point().cloned());
448        if self.docs.is_empty() {
449            self.collection_build = None;
450            self.panes.clear();
451            self.active_pane = 0;
452            self.should_quit = true;
453        } else {
454            self.mru.retain(|&x| x != closed);
455            self.generation += 1; // document set changed: old jobs are stale (0011 §2)
456            let next = self.mru.first().copied().unwrap_or_else(|| {
457                self.docs
458                    .iter()
459                    .next()
460                    .map(|(id, _)| id)
461                    .expect("docs non-empty")
462            });
463            for pane in &mut self.panes {
464                if pane.doc == closed {
465                    pane.doc = next;
466                    pane.sels = Default::default();
467                    pane.view_top = 0;
468                    pane.hscroll = strop_core::id::DisplayColumn::new(0);
469                    pane.desired_column = None;
470                }
471            }
472            self.switch_to(next);
473            self.set_head(0);
474            self.view_mut().view_top = 0;
475            self.view_mut().hscroll = strop_core::id::DisplayColumn::new(0);
476            // a closing surface hands the cursor and view back to the
477            // document it opened from — by id, no index math (0011 §1)
478            if let Some(ret) = return_to.filter(|ret| self.docs.get(ret.document).is_some()) {
479                self.jump_to(ret);
480            } else if self.doc(next).directory_metadata_ref().is_some() {
481                self.restore_directory_view(next);
482            }
483        }
484        for collection in affected_collections {
485            self.collection_render_view(collection);
486        }
487        self.lsp_retire_remote_servers();
488        true
489    }
490    /// Any path-backed or scratch document holding unsaved content.
491    pub fn any_dirty(&self) -> bool {
492        self.docs.iter().any(|(_, d)| d.buf.dirty)
493    }
494
495    /// ctrl-c's quit intent (0015): warn once when dirty work exists,
496    /// force on the second press. Returns true when the app may exit.
497    pub fn ctrl_c_quit(&mut self) -> bool {
498        if self.ctrl_c_armed || (!self.any_dirty() && self.filesystem.unconfirmed() == 0) {
499            return true;
500        }
501        self.ctrl_c_armed = true;
502        self.message = if self.filesystem.unconfirmed() > 0 {
503            "filesystem outcomes are unconfirmed — ctrl-c again to force-quit".into()
504        } else {
505            "unsaved changes — ctrl-c again to force-quit".into()
506        };
507        false
508    }
509
510    /// Fixture convenience exercises the production request and completion path.
511    #[cfg(any(test, feature = "test-support"))]
512    pub fn open_fixture(
513        &mut self,
514        path: &std::path::Path,
515    ) -> Result<strop_core::id::DocumentId, String> {
516        self.request_open(
517            path.to_owned(),
518            super::io::OpenIntent::Switch { readonly: false },
519        );
520        self.wait_io()?;
521        Ok(self.current())
522    }
523}
524
525#[cfg(test)]
526mod pathbuf_tests {
527    //! 0026: non-UTF-8 filenames (linux names are bytes) work end to end.
528    use super::*;
529    use std::os::unix::ffi::OsStrExt;
530
531    #[test]
532    fn non_utf8_filename_opens_highlights_and_saves() {
533        let dir = tempfile::tempdir().unwrap();
534        let path = dir.path().join(std::ffi::OsStr::from_bytes(b"\xff\xfe.rs"));
535        std::fs::write(&path, "fn main() {}\n").unwrap();
536        let mut e = Editor::new(Buffer::from_text("x\n"));
537        let id = e.open_fixture(&path).expect("opens by bytes");
538        e.switch_to(id);
539        // extension detection works through the OsStr, not a lossy str
540        assert!(e.analysis_fixture().spans.iter().any(|span| span.start == 0
541            && span.end == 2
542            && span.class == strop_syntax::Class::Keyword));
543        e.feed_text("dd"); // delete the line
544        e.feed_text(":w\r");
545        e.wait_io().unwrap();
546        assert_eq!(std::fs::read_to_string(&path).unwrap(), "");
547    }
548}