Skip to main content

qem/document/
types.rs

1use super::{CompactionRecommendation, CompactionUrgency, FragmentationStats, LineEnding};
2use encoding_rs::{Encoding, UTF_16BE, UTF_16LE, UTF_8};
3use std::fmt;
4use std::io;
5use std::ops::Deref;
6use std::path::{Path, PathBuf};
7
8/// Named text encoding used for explicit open/save operations.
9#[derive(Clone, Copy)]
10pub struct DocumentEncoding(&'static Encoding);
11
12impl DocumentEncoding {
13    /// Returns the stable UTF-8 encoding used by Qem's default fast path.
14    pub const fn utf8() -> Self {
15        Self(UTF_8)
16    }
17
18    /// Returns UTF-16LE for BOM-backed reinterpret/open flows.
19    pub const fn utf16le() -> Self {
20        Self(UTF_16LE)
21    }
22
23    /// Returns UTF-16BE for BOM-backed reinterpret/open flows.
24    pub const fn utf16be() -> Self {
25        Self(UTF_16BE)
26    }
27
28    /// Looks up an encoding by label accepted by `encoding_rs`.
29    pub fn from_label(label: &str) -> Option<Self> {
30        Encoding::for_label(label.as_bytes()).map(Self)
31    }
32
33    /// Returns the canonical label for this encoding.
34    pub fn name(self) -> &'static str {
35        self.0.name()
36    }
37
38    /// Returns `true` when this is UTF-8.
39    pub fn is_utf8(self) -> bool {
40        self.0 == UTF_8
41    }
42
43    /// Returns `true` when `encoding_rs` can round-trip saves using this encoding.
44    pub fn can_roundtrip_save(self) -> bool {
45        self.0.output_encoding() == self.0
46    }
47
48    pub(crate) const fn as_encoding(self) -> &'static Encoding {
49        self.0
50    }
51}
52
53impl Default for DocumentEncoding {
54    fn default() -> Self {
55        Self::utf8()
56    }
57}
58
59impl PartialEq for DocumentEncoding {
60    fn eq(&self, other: &Self) -> bool {
61        std::ptr::eq(self.0, other.0)
62    }
63}
64
65impl Eq for DocumentEncoding {}
66
67impl std::hash::Hash for DocumentEncoding {
68    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
69        self.name().hash(state);
70    }
71}
72
73impl fmt::Debug for DocumentEncoding {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.debug_tuple("DocumentEncoding")
76            .field(&self.name())
77            .finish()
78    }
79}
80
81impl fmt::Display for DocumentEncoding {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        f.write_str(self.name())
84    }
85}
86
87/// Open policy for choosing between the UTF-8 mmap fast path, initial
88/// BOM-backed detection, or an explicit reinterpretation encoding.
89#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
90pub enum OpenEncodingPolicy {
91    /// Keep the existing UTF-8/ASCII mmap fast path and its current semantics.
92    #[default]
93    Utf8FastPath,
94    /// Detect BOM-backed encodings on open and otherwise fall back to the
95    /// normal UTF-8/ASCII fast path.
96    ///
97    /// This first detection slice intentionally avoids heavyweight legacy
98    /// charset guessing so open-time cost stays predictable.
99    AutoDetect,
100    /// Reinterpret the source bytes through the requested encoding.
101    ///
102    /// This is the option to use for legacy encodings such as
103    /// `windows-1251`, `Shift_JIS`, or `GB18030` when the caller already knows
104    /// the intended source encoding.
105    Reinterpret(DocumentEncoding),
106}
107
108/// Explicit document-open options for choosing between the default UTF-8 path
109/// and encoding-aware reinterpretation.
110#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
111pub struct DocumentOpenOptions {
112    encoding_policy: OpenEncodingPolicy,
113}
114
115impl DocumentOpenOptions {
116    /// Creates open options that use Qem's default UTF-8 fast path.
117    pub const fn new() -> Self {
118        Self {
119            encoding_policy: OpenEncodingPolicy::Utf8FastPath,
120        }
121    }
122
123    /// Returns options that enable the initial BOM-backed auto-detect path.
124    pub const fn with_auto_encoding_detection(mut self) -> Self {
125        self.encoding_policy = OpenEncodingPolicy::AutoDetect;
126        self
127    }
128
129    /// Returns options that reinterpret the source through the given encoding.
130    pub const fn with_reinterpretation(mut self, encoding: DocumentEncoding) -> Self {
131        self.encoding_policy = OpenEncodingPolicy::Reinterpret(encoding);
132        self
133    }
134
135    /// Returns options that force decoding the source through the given encoding.
136    ///
137    /// This is an alias for [`Self::with_reinterpretation`] kept for ergonomic
138    /// compatibility with the first encoding-support release.
139    pub const fn with_encoding(mut self, encoding: DocumentEncoding) -> Self {
140        self.encoding_policy = OpenEncodingPolicy::Reinterpret(encoding);
141        self
142    }
143
144    /// Returns the current open encoding policy.
145    pub const fn encoding_policy(self) -> OpenEncodingPolicy {
146        self.encoding_policy
147    }
148
149    /// Returns the explicit reinterpretation encoding, if one was requested.
150    ///
151    /// This compatibility helper returns `None` for the default fast path and
152    /// for auto-detect mode.
153    pub const fn encoding_override(self) -> Option<DocumentEncoding> {
154        match self.encoding_policy {
155            OpenEncodingPolicy::Reinterpret(encoding) => Some(encoding),
156            OpenEncodingPolicy::Utf8FastPath | OpenEncodingPolicy::AutoDetect => None,
157        }
158    }
159}
160
161/// Save policy for preserving the current document encoding or converting to a
162/// different target encoding on write.
163#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
164pub enum SaveEncodingPolicy {
165    /// Save using the document's current encoding contract.
166    #[default]
167    Preserve,
168    /// Convert the current document text into the requested target encoding.
169    Convert(DocumentEncoding),
170}
171
172/// Explicit document-save options.
173#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
174pub struct DocumentSaveOptions {
175    encoding_policy: SaveEncodingPolicy,
176}
177
178impl DocumentSaveOptions {
179    /// Creates save options that preserve the current document encoding.
180    pub const fn new() -> Self {
181        Self {
182            encoding_policy: SaveEncodingPolicy::Preserve,
183        }
184    }
185
186    /// Returns options that convert the current document text to `encoding` on save.
187    pub const fn with_encoding(mut self, encoding: DocumentEncoding) -> Self {
188        self.encoding_policy = SaveEncodingPolicy::Convert(encoding);
189        self
190    }
191
192    /// Returns the encoding policy that will be used when saving.
193    pub const fn encoding_policy(self) -> SaveEncodingPolicy {
194        self.encoding_policy
195    }
196}
197
198#[derive(Debug, Clone, Default, PartialEq, Eq)]
199pub struct LineSlice {
200    text: String,
201    exact: bool,
202}
203
204impl LineSlice {
205    /// Creates a new line slice and marks whether it is exact.
206    pub fn new(text: String, exact: bool) -> Self {
207        Self { text, exact }
208    }
209
210    /// Returns the slice text.
211    pub fn text(&self) -> &str {
212        &self.text
213    }
214
215    /// Consumes the slice and returns the owned text.
216    pub fn into_text(self) -> String {
217        self.text
218    }
219
220    /// Returns `true` if the slice was produced from exact indexes rather than heuristics.
221    pub fn is_exact(&self) -> bool {
222        self.exact
223    }
224
225    /// Returns `true` if the slice is empty.
226    pub fn is_empty(&self) -> bool {
227        self.text.is_empty()
228    }
229}
230
231impl AsRef<str> for LineSlice {
232    fn as_ref(&self) -> &str {
233        self.text()
234    }
235}
236
237impl Deref for LineSlice {
238    type Target = str;
239
240    fn deref(&self) -> &Self::Target {
241        self.text()
242    }
243}
244
245impl fmt::Display for LineSlice {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        f.write_str(self.text())
248    }
249}
250
251impl From<LineSlice> for String {
252    fn from(value: LineSlice) -> Self {
253        value.into_text()
254    }
255}
256
257/// Text slice returned by typed range/selection reads.
258///
259/// The slice applies lossy UTF-8 decoding and tracks whether the underlying
260/// range was anchored by exact document indexes or a heuristic mmap guess.
261#[derive(Debug, Clone, Default, PartialEq, Eq)]
262pub struct TextSlice {
263    text: String,
264    exact: bool,
265}
266
267impl TextSlice {
268    /// Creates a new text slice and marks whether it is exact.
269    pub fn new(text: String, exact: bool) -> Self {
270        Self { text, exact }
271    }
272
273    /// Returns the slice text.
274    pub fn text(&self) -> &str {
275        &self.text
276    }
277
278    /// Consumes the slice and returns the owned text.
279    pub fn into_text(self) -> String {
280        self.text
281    }
282
283    /// Returns `true` if the slice was produced from exact indexes rather than heuristics.
284    pub fn is_exact(&self) -> bool {
285        self.exact
286    }
287
288    /// Returns `true` if the slice is empty.
289    pub fn is_empty(&self) -> bool {
290        self.text.is_empty()
291    }
292}
293
294impl AsRef<str> for TextSlice {
295    fn as_ref(&self) -> &str {
296        self.text()
297    }
298}
299
300impl Deref for TextSlice {
301    type Target = str;
302
303    fn deref(&self) -> &Self::Target {
304        self.text()
305    }
306}
307
308impl fmt::Display for TextSlice {
309    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310        f.write_str(self.text())
311    }
312}
313
314impl From<TextSlice> for String {
315    fn from(value: TextSlice) -> Self {
316        value.into_text()
317    }
318}
319
320/// Zero-based document position used by frontend integrations.
321///
322/// Qem keeps positions in document coordinates instead of screen coordinates so
323/// applications remain free to implement their own cursor, scrollbar, and
324/// selection rendering. `col0` uses document text columns: for UTF-8 text this
325/// means Unicode scalar values, not grapheme clusters and not terminal/display
326/// cells.
327#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
328pub struct TextPosition {
329    line0: usize,
330    col0: usize,
331}
332
333impl TextPosition {
334    /// Creates a zero-based text position.
335    pub const fn new(line0: usize, col0: usize) -> Self {
336        Self { line0, col0 }
337    }
338
339    /// Returns the zero-based line index.
340    pub const fn line0(self) -> usize {
341        self.line0
342    }
343
344    /// Returns the zero-based document column index in text units.
345    pub const fn col0(self) -> usize {
346        self.col0
347    }
348}
349
350impl From<(usize, usize)> for TextPosition {
351    fn from(value: (usize, usize)) -> Self {
352        Self::new(value.0, value.1)
353    }
354}
355
356impl From<TextPosition> for (usize, usize) {
357    fn from(value: TextPosition) -> Self {
358        (value.line0, value.col0)
359    }
360}
361
362/// Typed text range used by edit operations.
363///
364/// The range is expressed as a starting position together with a text-unit
365/// length, matching the semantics of
366/// [`crate::document::Document::try_replace_range`]. For UTF-8 text, line-local
367/// units are Unicode scalar values rather than grapheme clusters or display
368/// cells. Between lines, a stored CRLF sequence still counts as one text unit.
369#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
370pub struct TextRange {
371    start: TextPosition,
372    len_chars: usize,
373}
374
375impl TextRange {
376    /// Creates a text range from a starting position and text-unit length.
377    pub const fn new(start: TextPosition, len_chars: usize) -> Self {
378        Self { start, len_chars }
379    }
380
381    /// Creates an empty text range at the given position.
382    pub const fn empty(start: TextPosition) -> Self {
383        Self::new(start, 0)
384    }
385
386    /// Returns the starting position of the range.
387    pub const fn start(self) -> TextPosition {
388        self.start
389    }
390
391    /// Returns the number of text units in the range.
392    pub const fn len_chars(self) -> usize {
393        self.len_chars
394    }
395
396    /// Returns `true` when the range is empty.
397    pub const fn is_empty(self) -> bool {
398        self.len_chars == 0
399    }
400}
401
402/// Typed literal-search match within the current document contents.
403#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
404pub struct SearchMatch {
405    range: TextRange,
406    end: TextPosition,
407}
408
409impl SearchMatch {
410    /// Creates a search match from its range and end position.
411    pub const fn new(range: TextRange, end: TextPosition) -> Self {
412        Self { range, end }
413    }
414
415    /// Returns the typed range covered by the match.
416    pub const fn range(self) -> TextRange {
417        self.range
418    }
419
420    /// Returns the typed start position of the match.
421    pub const fn start(self) -> TextPosition {
422        self.range.start()
423    }
424
425    /// Returns the typed end position of the match.
426    pub const fn end(self) -> TextPosition {
427        self.end
428    }
429
430    /// Returns the match length in document text units.
431    pub const fn len_chars(self) -> usize {
432        self.range.len_chars()
433    }
434
435    /// Returns `true` when the match is empty.
436    pub const fn is_empty(self) -> bool {
437        self.range.is_empty()
438    }
439
440    /// Returns the match as an anchor/head selection.
441    pub const fn selection(self) -> TextSelection {
442        TextSelection::new(self.start(), self.end())
443    }
444}
445
446/// Anchor/head text selection used by frontend integrations.
447///
448/// Qem keeps this selection in document coordinates so applications remain
449/// free to own their own painting, cursor visuals, and interaction model.
450#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
451pub struct TextSelection {
452    anchor: TextPosition,
453    head: TextPosition,
454}
455
456impl TextSelection {
457    /// Creates a selection from an anchor and active head position.
458    pub const fn new(anchor: TextPosition, head: TextPosition) -> Self {
459        Self { anchor, head }
460    }
461
462    /// Creates a caret selection at a single position.
463    pub const fn caret(position: TextPosition) -> Self {
464        Self::new(position, position)
465    }
466
467    /// Returns the anchor position.
468    pub const fn anchor(self) -> TextPosition {
469        self.anchor
470    }
471
472    /// Returns the active head position.
473    pub const fn head(self) -> TextPosition {
474        self.head
475    }
476
477    /// Returns `true` when the selection is only a caret.
478    pub fn is_caret(self) -> bool {
479        self.anchor == self.head
480    }
481}
482
483/// Viewport request used by frontend code to read only visible rows.
484#[derive(Debug, Clone, Copy, PartialEq, Eq)]
485pub struct ViewportRequest {
486    first_line0: usize,
487    line_count: usize,
488    start_col: usize,
489    max_cols: usize,
490}
491
492impl Default for ViewportRequest {
493    fn default() -> Self {
494        Self::new(0, 0)
495    }
496}
497
498impl ViewportRequest {
499    /// Creates a viewport request for a contiguous line range.
500    ///
501    /// Columns default to the full visible width of the line. Horizontal
502    /// columns use the same document text-unit semantics as [`TextPosition`].
503    pub const fn new(first_line0: usize, line_count: usize) -> Self {
504        Self {
505            first_line0,
506            line_count,
507            start_col: 0,
508            max_cols: usize::MAX,
509        }
510    }
511
512    /// Sets the horizontal slice within each requested row.
513    ///
514    /// `start_col` and `max_cols` count document text columns, not grapheme
515    /// clusters and not display cells.
516    pub const fn with_columns(mut self, start_col: usize, max_cols: usize) -> Self {
517        self.start_col = start_col;
518        self.max_cols = max_cols;
519        self
520    }
521
522    /// Returns the first requested zero-based line index.
523    pub const fn first_line0(self) -> usize {
524        self.first_line0
525    }
526
527    /// Returns the requested number of lines.
528    pub const fn line_count(self) -> usize {
529        self.line_count
530    }
531
532    /// Returns the requested starting column.
533    pub const fn start_col(self) -> usize {
534        self.start_col
535    }
536
537    /// Returns the requested maximum number of columns.
538    pub const fn max_cols(self) -> usize {
539        self.max_cols
540    }
541}
542
543/// One row returned by a viewport read.
544#[derive(Debug, Clone, PartialEq, Eq)]
545pub struct ViewportRow {
546    line0: usize,
547    slice: LineSlice,
548}
549
550impl ViewportRow {
551    /// Creates a viewport row.
552    pub fn new(line0: usize, slice: LineSlice) -> Self {
553        Self { line0, slice }
554    }
555
556    /// Returns the zero-based line index for this row.
557    pub fn line0(&self) -> usize {
558        self.line0
559    }
560
561    /// Returns the 1-based line number for display.
562    pub fn line_number(&self) -> usize {
563        self.line0.saturating_add(1)
564    }
565
566    /// Returns the rendered line slice.
567    pub fn slice(&self) -> &LineSlice {
568        &self.slice
569    }
570
571    /// Consumes the row and returns the line slice.
572    pub fn into_slice(self) -> LineSlice {
573        self.slice
574    }
575
576    /// Returns the row text.
577    pub fn text(&self) -> &str {
578        self.slice.text()
579    }
580
581    /// Returns `true` when the row is backed by exact line indexes.
582    pub fn is_exact(&self) -> bool {
583        self.slice.is_exact()
584    }
585}
586
587/// Viewport read response returned by [`crate::document::Document::read_viewport`].
588#[derive(Debug, Clone, PartialEq, Eq)]
589pub struct Viewport {
590    request: ViewportRequest,
591    total_lines: LineCount,
592    rows: Vec<ViewportRow>,
593}
594
595impl Viewport {
596    /// Creates a viewport response.
597    pub fn new(request: ViewportRequest, total_lines: LineCount, rows: Vec<ViewportRow>) -> Self {
598        Self {
599            request,
600            total_lines,
601            rows,
602        }
603    }
604
605    /// Returns the request that produced this viewport.
606    pub fn request(&self) -> ViewportRequest {
607        self.request
608    }
609
610    /// Returns the current total document line count.
611    pub fn total_lines(&self) -> LineCount {
612        self.total_lines
613    }
614
615    /// Returns the visible rows.
616    pub fn rows(&self) -> &[ViewportRow] {
617        &self.rows
618    }
619
620    /// Consumes the viewport and returns the visible rows.
621    pub fn into_rows(self) -> Vec<ViewportRow> {
622        self.rows
623    }
624
625    /// Returns the number of visible rows.
626    pub fn len(&self) -> usize {
627        self.rows.len()
628    }
629
630    /// Returns `true` when the viewport contains no rows.
631    pub fn is_empty(&self) -> bool {
632        self.rows.is_empty()
633    }
634}
635
636/// Result of an edit command together with the resulting cursor position.
637#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
638pub struct EditResult {
639    changed: bool,
640    cursor: TextPosition,
641}
642
643impl EditResult {
644    /// Creates an edit result.
645    pub const fn new(changed: bool, cursor: TextPosition) -> Self {
646        Self { changed, cursor }
647    }
648
649    /// Returns `true` when the document changed.
650    pub const fn changed(self) -> bool {
651        self.changed
652    }
653
654    /// Returns the resulting cursor position.
655    pub const fn cursor(self) -> TextPosition {
656        self.cursor
657    }
658}
659
660/// Result of cutting a selection together with the resulting edit outcome.
661#[derive(Debug, Clone, Default, PartialEq, Eq)]
662pub struct CutResult {
663    text: String,
664    edit: EditResult,
665}
666
667impl CutResult {
668    /// Creates a cut result.
669    pub fn new(text: String, edit: EditResult) -> Self {
670        Self { text, edit }
671    }
672
673    /// Returns the cut text.
674    pub fn text(&self) -> &str {
675        &self.text
676    }
677
678    /// Consumes the result and returns the owned cut text.
679    pub fn into_text(self) -> String {
680        self.text
681    }
682
683    /// Returns the underlying edit result.
684    pub const fn edit(&self) -> EditResult {
685        self.edit
686    }
687
688    /// Returns `true` when the document changed.
689    pub const fn changed(&self) -> bool {
690        self.edit.changed()
691    }
692
693    /// Returns the resulting cursor position.
694    pub const fn cursor(&self) -> TextPosition {
695        self.edit.cursor()
696    }
697}
698
699/// Typed byte progress used by indexing and other document-local background work.
700#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
701pub struct ByteProgress {
702    completed_bytes: usize,
703    total_bytes: usize,
704}
705
706impl ByteProgress {
707    /// Creates a byte progress value.
708    pub const fn new(completed_bytes: usize, total_bytes: usize) -> Self {
709        Self {
710            completed_bytes,
711            total_bytes,
712        }
713    }
714
715    /// Returns the completed byte count.
716    pub const fn completed_bytes(self) -> usize {
717        self.completed_bytes
718    }
719
720    /// Returns the total byte count.
721    pub const fn total_bytes(self) -> usize {
722        self.total_bytes
723    }
724
725    /// Returns completion as a `0.0..=1.0` fraction.
726    pub fn fraction(self) -> f32 {
727        if self.total_bytes == 0 {
728            0.0
729        } else {
730            self.completed_bytes as f32 / self.total_bytes as f32
731        }
732    }
733}
734
735/// Total document line count, represented either as an exact value or as a
736/// scrolling estimate while background indexing is still incomplete.
737#[derive(Debug, Clone, Copy, PartialEq, Eq)]
738#[must_use]
739pub enum LineCount {
740    Exact(usize),
741    Estimated(usize),
742}
743
744impl LineCount {
745    /// Returns the exact line count when it is known.
746    pub fn exact(self) -> Option<usize> {
747        match self {
748            Self::Exact(lines) => Some(lines),
749            Self::Estimated(_) => None,
750        }
751    }
752
753    /// Returns the value that should be used for viewport sizing and scrolling.
754    pub fn display_rows(self) -> usize {
755        match self {
756            Self::Exact(lines) | Self::Estimated(lines) => lines.max(1),
757        }
758    }
759
760    /// Returns `true` when the total line count is exact.
761    pub fn is_exact(self) -> bool {
762        matches!(self, Self::Exact(_))
763    }
764}
765
766/// Current backing mode of the document text.
767#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
768pub enum DocumentBacking {
769    Mmap,
770    PieceTable,
771    Rope,
772}
773
774impl DocumentBacking {
775    /// Returns a short display label for the current backing mode.
776    pub const fn as_str(self) -> &'static str {
777        match self {
778            Self::Mmap => "mmap",
779            Self::PieceTable => "piece-table",
780            Self::Rope => "rope",
781        }
782    }
783}
784
785/// Typed editability state for a document position or range.
786#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
787pub enum EditCapability {
788    Editable {
789        backing: DocumentBacking,
790    },
791    RequiresPromotion {
792        from: DocumentBacking,
793        to: DocumentBacking,
794    },
795    Unsupported {
796        backing: DocumentBacking,
797        reason: &'static str,
798    },
799}
800
801impl EditCapability {
802    /// Returns `true` when an edit can proceed, possibly after a backend promotion.
803    pub const fn is_editable(self) -> bool {
804        !matches!(self, Self::Unsupported { .. })
805    }
806
807    /// Returns `true` when the edit would require promoting to another backing.
808    pub const fn requires_promotion(self) -> bool {
809        matches!(self, Self::RequiresPromotion { .. })
810    }
811
812    /// Returns the current backing mode before any edit is attempted.
813    pub const fn current_backing(self) -> DocumentBacking {
814        match self {
815            Self::Editable { backing } | Self::Unsupported { backing, .. } => backing,
816            Self::RequiresPromotion { from, .. } => from,
817        }
818    }
819
820    /// Returns the resulting backing mode after promotion, if one is required.
821    pub const fn target_backing(self) -> Option<DocumentBacking> {
822        match self {
823            Self::RequiresPromotion { to, .. } => Some(to),
824            _ => None,
825        }
826    }
827
828    /// Returns an unsupported-edit reason when one is available.
829    pub const fn reason(self) -> Option<&'static str> {
830        match self {
831            Self::Unsupported { reason, .. } => Some(reason),
832            _ => None,
833        }
834    }
835}
836
837/// Snapshot of the current document state for frontend polling.
838#[derive(Debug, Clone, PartialEq, Eq)]
839pub struct DocumentStatus {
840    path: Option<PathBuf>,
841    dirty: bool,
842    file_len: usize,
843    line_count: LineCount,
844    line_ending: LineEnding,
845    encoding: DocumentEncoding,
846    decoding_had_errors: bool,
847    indexing: Option<ByteProgress>,
848    backing: DocumentBacking,
849}
850
851impl DocumentStatus {
852    /// Creates a document status snapshot.
853    #[allow(clippy::too_many_arguments)]
854    pub fn new(
855        path: Option<PathBuf>,
856        dirty: bool,
857        file_len: usize,
858        line_count: LineCount,
859        line_ending: LineEnding,
860        encoding: DocumentEncoding,
861        decoding_had_errors: bool,
862        indexing: Option<ByteProgress>,
863        backing: DocumentBacking,
864    ) -> Self {
865        Self {
866            path,
867            dirty,
868            file_len,
869            line_count,
870            line_ending,
871            encoding,
872            decoding_had_errors,
873            indexing,
874            backing,
875        }
876    }
877
878    /// Returns the current document path, if one is set.
879    pub fn path(&self) -> Option<&Path> {
880        self.path.as_deref()
881    }
882
883    /// Returns `true` when the document has unsaved changes.
884    pub fn is_dirty(&self) -> bool {
885        self.dirty
886    }
887
888    /// Returns the current document length in bytes.
889    pub fn file_len(&self) -> usize {
890        self.file_len
891    }
892
893    /// Returns the current document line count.
894    pub fn line_count(&self) -> LineCount {
895        self.line_count
896    }
897
898    /// Returns the exact line count when it is known.
899    pub fn exact_line_count(&self) -> Option<usize> {
900        self.line_count.exact()
901    }
902
903    /// Returns the best-effort line count for viewport sizing and scrolling.
904    pub fn display_line_count(&self) -> usize {
905        self.line_count.display_rows()
906    }
907
908    /// Returns `true` when the current line count is exact.
909    pub fn is_line_count_exact(&self) -> bool {
910        self.line_count.is_exact()
911    }
912
913    /// Returns the currently detected line ending style.
914    pub fn line_ending(&self) -> LineEnding {
915        self.line_ending
916    }
917
918    /// Returns the current document encoding contract.
919    pub fn encoding(&self) -> DocumentEncoding {
920        self.encoding
921    }
922
923    /// Returns `true` when opening the source required lossy decode replacement.
924    pub fn decoding_had_errors(&self) -> bool {
925        self.decoding_had_errors
926    }
927
928    /// Returns typed indexing progress while background indexing is active.
929    pub fn indexing_state(&self) -> Option<ByteProgress> {
930        self.indexing
931    }
932
933    /// Returns `true` when document-local indexing is still running.
934    pub fn is_indexing(&self) -> bool {
935        self.indexing.is_some()
936    }
937
938    /// Returns the current document backing mode.
939    pub fn backing(&self) -> DocumentBacking {
940        self.backing
941    }
942
943    /// Returns `true` when the document currently uses any edit buffer.
944    pub fn has_edit_buffer(&self) -> bool {
945        !matches!(self.backing, DocumentBacking::Mmap)
946    }
947
948    /// Returns `true` when the document is currently backed by a rope.
949    pub fn has_rope(&self) -> bool {
950        matches!(self.backing, DocumentBacking::Rope)
951    }
952
953    /// Returns `true` when the document is currently backed by a piece table.
954    pub fn has_piece_table(&self) -> bool {
955        matches!(self.backing, DocumentBacking::PieceTable)
956    }
957}
958
959/// Snapshot of maintenance-oriented document state such as fragmentation and
960/// compaction advice.
961#[derive(Debug, Clone, Copy, PartialEq)]
962pub struct DocumentMaintenanceStatus {
963    backing: DocumentBacking,
964    fragmentation: Option<FragmentationStats>,
965    compaction: Option<CompactionRecommendation>,
966}
967
968/// High-level maintenance action suggested by the current compaction policy.
969#[derive(Debug, Clone, Copy, PartialEq, Eq)]
970pub enum MaintenanceAction {
971    /// No maintenance work is currently recommended.
972    None,
973    /// The frontend can run idle compaction now.
974    IdleCompaction,
975    /// Heavier maintenance should wait for an explicit operator/save boundary.
976    ExplicitCompaction,
977}
978
979impl MaintenanceAction {
980    /// Returns a stable lowercase identifier for logs, JSON output, or UI glue.
981    pub const fn as_str(self) -> &'static str {
982        match self {
983            Self::None => "none",
984            Self::IdleCompaction => "idle-compaction",
985            Self::ExplicitCompaction => "explicit-compaction",
986        }
987    }
988}
989
990impl DocumentMaintenanceStatus {
991    /// Creates a maintenance status snapshot.
992    pub const fn new(
993        backing: DocumentBacking,
994        fragmentation: Option<FragmentationStats>,
995        compaction: Option<CompactionRecommendation>,
996    ) -> Self {
997        Self {
998            backing,
999            fragmentation,
1000            compaction,
1001        }
1002    }
1003
1004    /// Returns the current document backing mode.
1005    pub const fn backing(self) -> DocumentBacking {
1006        self.backing
1007    }
1008
1009    /// Returns `true` when the document currently uses a piece table.
1010    pub const fn has_piece_table(self) -> bool {
1011        matches!(self.backing, DocumentBacking::PieceTable)
1012    }
1013
1014    /// Returns fragmentation metrics when they are meaningful for the current backing.
1015    pub const fn fragmentation_stats(self) -> Option<FragmentationStats> {
1016        self.fragmentation
1017    }
1018
1019    /// Returns `true` when fragmentation metrics are available.
1020    pub const fn has_fragmentation_stats(self) -> bool {
1021        self.fragmentation.is_some()
1022    }
1023
1024    /// Returns the current compaction recommendation, if any.
1025    pub const fn compaction_recommendation(self) -> Option<CompactionRecommendation> {
1026        self.compaction
1027    }
1028
1029    /// Returns `true` when the current maintenance policy recommends compaction.
1030    pub const fn is_compaction_recommended(self) -> bool {
1031        self.compaction.is_some()
1032    }
1033
1034    /// Returns the current compaction urgency, if a recommendation exists.
1035    pub fn compaction_urgency(self) -> Option<CompactionUrgency> {
1036        self.compaction
1037            .map(|recommendation| recommendation.urgency())
1038    }
1039
1040    /// Returns the high-level maintenance action implied by this snapshot.
1041    pub fn recommended_action(self) -> MaintenanceAction {
1042        match self.compaction_urgency() {
1043            Some(CompactionUrgency::Deferred) => MaintenanceAction::IdleCompaction,
1044            Some(CompactionUrgency::Forced) => MaintenanceAction::ExplicitCompaction,
1045            None => MaintenanceAction::None,
1046        }
1047    }
1048
1049    /// Returns `true` when idle compaction is currently recommended.
1050    pub fn should_run_idle_compaction(self) -> bool {
1051        self.recommended_action() == MaintenanceAction::IdleCompaction
1052    }
1053
1054    /// Returns `true` when heavier maintenance should be deferred to an
1055    /// explicit operator/save boundary.
1056    pub fn should_wait_for_explicit_compaction(self) -> bool {
1057        self.recommended_action() == MaintenanceAction::ExplicitCompaction
1058    }
1059}
1060
1061/// File-system, mapping, and edit-capability errors produced by [`crate::document::Document`].
1062#[derive(Debug)]
1063pub enum DocumentError {
1064    /// The source file could not be opened.
1065    Open { path: PathBuf, source: io::Error },
1066    /// The source file could not be memory-mapped.
1067    Map { path: PathBuf, source: io::Error },
1068    /// A write, rename, or reload step failed.
1069    Write { path: PathBuf, source: io::Error },
1070    /// Encoding negotiation, decode, or save conversion failed.
1071    Encoding {
1072        path: PathBuf,
1073        operation: &'static str,
1074        encoding: DocumentEncoding,
1075        message: String,
1076    },
1077    /// The requested edit operation is unsupported for the current document state.
1078    EditUnsupported {
1079        path: Option<PathBuf>,
1080        reason: &'static str,
1081    },
1082}
1083
1084impl std::fmt::Display for DocumentError {
1085    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1086        match self {
1087            Self::Open { path, source } => write!(f, "open `{}`: {source}", path.display()),
1088            Self::Map { path, source } => write!(f, "mmap `{}`: {source}", path.display()),
1089            Self::Write { path, source } => write!(f, "write `{}`: {source}", path.display()),
1090            Self::Encoding {
1091                path,
1092                operation,
1093                encoding,
1094                message,
1095            } => write!(
1096                f,
1097                "{operation} `{}` with encoding `{encoding}`: {message}",
1098                path.display()
1099            ),
1100            Self::EditUnsupported { path, reason } => {
1101                if let Some(path) = path {
1102                    write!(f, "edit `{}`: {reason}", path.display())
1103                } else {
1104                    write!(f, "edit: {reason}")
1105                }
1106            }
1107        }
1108    }
1109}
1110
1111impl std::error::Error for DocumentError {
1112    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1113        match self {
1114            Self::Open { source, .. } | Self::Map { source, .. } | Self::Write { source, .. } => {
1115                Some(source)
1116            }
1117            Self::Encoding { .. } | Self::EditUnsupported { .. } => None,
1118        }
1119    }
1120}