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