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