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