Skip to main content

editor_core/
model.rs

1//! Command data model types.
2
3use crate::intervals::StyleId;
4use crate::layout::{WrapIndent, WrapMode};
5use crate::search::SearchOptions;
6use crate::snapshot::HeadlessGrid;
7use editor_core_lang::{CommentConfig, IndentationConfig};
8#[cfg(feature = "serde")]
9use serde::{Deserialize, Serialize};
10use std::cmp::Ordering;
11
12const COMMAND_HISTORY_TEXT_PREVIEW_BYTES: usize = 256;
13
14/// Position coordinates (line and column numbers)
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
17pub struct Position {
18    /// Zero-based logical line index.
19    pub line: usize,
20    /// Zero-based column in characters within the logical line.
21    pub column: usize,
22}
23
24impl Position {
25    /// Create a new logical position.
26    pub fn new(line: usize, column: usize) -> Self {
27        Self { line, column }
28    }
29}
30
31impl Ord for Position {
32    fn cmp(&self, other: &Self) -> Ordering {
33        self.line
34            .cmp(&other.line)
35            .then_with(|| self.column.cmp(&other.column))
36    }
37}
38
39impl PartialOrd for Position {
40    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
41        Some(self.cmp(other))
42    }
43}
44
45/// Selection range
46#[derive(Debug, Clone, PartialEq, Eq)]
47#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
48pub struct Selection {
49    /// Selection start position
50    pub start: Position,
51    /// Selection end position
52    pub end: Position,
53    /// Selection direction
54    pub direction: SelectionDirection,
55}
56
57/// Selection direction
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
60pub enum SelectionDirection {
61    /// Forward selection (from start to end)
62    Forward,
63    /// Backward selection (from end to start)
64    Backward,
65}
66
67/// Selection expansion unit for [`CursorCommand::ExpandSelectionBy`].
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum ExpandSelectionUnit {
70    /// Expand by Unicode scalar values (Rust `char` indices).
71    Character,
72    /// Expand by "word" units (configured word boundary rules).
73    Word,
74    /// Expand by logical lines.
75    Line,
76}
77
78/// Selection expansion direction for [`CursorCommand::ExpandSelectionBy`].
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum ExpandSelectionDirection {
81    /// Expand towards the beginning of the document.
82    Backward,
83    /// Expand towards the end of the document.
84    Forward,
85}
86
87/// Controls how a Tab key press is handled by the editor when using [`EditCommand::InsertTab`].
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum TabKeyBehavior {
90    /// Insert a literal tab character (`'\t'`).
91    Tab,
92    /// Insert spaces up to the next tab stop (based on the current `tab_width` setting).
93    Spaces,
94}
95
96/// A single auto-pair entry (opening + closing delimiter).
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
99pub struct AutoPair {
100    /// Opening delimiter.
101    pub open: char,
102    /// Closing delimiter.
103    pub close: char,
104}
105
106impl AutoPair {
107    /// Create a new auto-pair entry.
108    pub const fn new(open: char, close: char) -> Self {
109        Self { open, close }
110    }
111}
112
113/// Auto-pairs configuration used by [`EditCommand::TypeChar`], and optionally by delete-like
114/// commands (pair deletion).
115#[derive(Debug, Clone, PartialEq, Eq)]
116#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
117pub struct AutoPairsConfig {
118    /// Master enable switch for auto-pairs behaviors.
119    pub enabled: bool,
120    /// Configured delimiter pairs (order matters when overlapping; first match wins).
121    pub pairs: Vec<AutoPair>,
122    /// When typing an opening delimiter over a non-empty selection, wrap the selection.
123    pub wrap_selection: bool,
124    /// When typing a closing delimiter and the next character matches, skip over it instead of inserting.
125    pub skip_over_closing: bool,
126    /// When backspacing/deleting adjacent matching delimiters, delete both.
127    pub delete_pair: bool,
128}
129
130impl Default for AutoPairsConfig {
131    fn default() -> Self {
132        Self {
133            enabled: false,
134            pairs: vec![
135                AutoPair::new('(', ')'),
136                AutoPair::new('[', ']'),
137                AutoPair::new('{', '}'),
138                AutoPair::new('"', '"'),
139                AutoPair::new('\'', '\''),
140                AutoPair::new('`', '`'),
141            ],
142            wrap_selection: true,
143            skip_over_closing: true,
144            delete_pair: true,
145        }
146    }
147}
148
149impl AutoPairsConfig {
150    pub(super) fn close_for_open(&self, open: char) -> Option<char> {
151        self.pairs.iter().find(|p| p.open == open).map(|p| p.close)
152    }
153
154    pub(super) fn open_for_close(&self, close: char) -> Option<char> {
155        self.pairs.iter().find(|p| p.close == close).map(|p| p.open)
156    }
157
158    pub(super) fn is_matching_pair(&self, open: char, close: char) -> bool {
159        self.pairs
160            .iter()
161            .any(|p| p.open == open && p.close == close)
162    }
163}
164
165/// A simple document text edit (character offsets, half-open).
166///
167/// This is commonly used for applying a batch of "simultaneous" edits (e.g. rename, refactor, or
168/// workspace-wide search/replace), where the edit list is expressed in **pre-edit** coordinates.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct TextEditSpec {
171    /// Inclusive start character offset.
172    pub start: usize,
173    /// Exclusive end character offset.
174    pub end: usize,
175    /// Replacement text.
176    pub text: String,
177}
178
179impl TextEditSpec {
180    fn history_summary(&self) -> Self {
181        Self {
182            start: self.start,
183            end: self.end,
184            text: summarize_history_text(&self.text),
185        }
186    }
187}
188
189fn summarize_history_text(text: &str) -> String {
190    if text.len() <= COMMAND_HISTORY_TEXT_PREVIEW_BYTES {
191        return text.to_string();
192    }
193
194    let mut preview_end = 0;
195    for (byte_idx, ch) in text.char_indices() {
196        let next = byte_idx + ch.len_utf8();
197        if next > COMMAND_HISTORY_TEXT_PREVIEW_BYTES {
198            break;
199        }
200        preview_end = next;
201    }
202
203    format!(
204        "{}...[history truncated: {} bytes]",
205        &text[..preview_end],
206        text.len()
207    )
208}
209
210/// Text editing commands
211#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum EditCommand {
213    /// Insert text at the specified position
214    Insert {
215        /// Character offset to insert at.
216        offset: usize,
217        /// Text to insert.
218        text: String,
219    },
220    /// Delete text in specified range
221    Delete {
222        /// Character offset of the deletion start.
223        start: usize,
224        /// Length of the deletion in characters.
225        length: usize,
226    },
227    /// Replace text in specified range
228    Replace {
229        /// Character offset of the replacement start.
230        start: usize,
231        /// Length of the replaced range in characters.
232        length: usize,
233        /// Replacement text.
234        text: String,
235    },
236    /// Replace text in specified range, requesting undo coalescing when the edit is eligible.
237    ///
238    /// This is primarily useful for UI layers that need to keep IME composition updates and the
239    /// final commit in one explicitly delimited undo group.
240    ///
241    /// Notes:
242    /// - Normal typing coalescing is intentionally limited to pure adjacent insertions without
243    ///   newlines.
244    /// - This explicit composition path may coalesce replacements only when each update replaces
245    ///   the exact range inserted by the previous update and the selection state is continuous.
246    /// - The caller is expected to explicitly delimit boundaries via [`EditCommand::EndUndoGroup`]
247    ///   so eligible IME insertions do not merge with normal typing groups.
248    ReplaceCoalescingUndo {
249        /// Character offset of the replacement start.
250        start: usize,
251        /// Length of the replaced range in characters.
252        length: usize,
253        /// Replacement text.
254        text: String,
255    },
256    /// Like [`EditCommand::ReplaceCoalescingUndo`], but also sets the primary selection/caret.
257    ///
258    /// This is primarily useful for IME composition updates where the host provides a
259    /// selection range inside the marked (preedit) string while keeping composition updates in
260    /// one explicitly delimited undo group.
261    ///
262    /// Notes:
263    /// - `selection_start/selection_end` are **post-edit** character offsets (Unicode scalar indices)
264    ///   in the resulting document.
265    /// - If `selection_start == selection_end`, the selection is cleared and the caret is moved
266    ///   to `selection_end`.
267    ReplaceCoalescingUndoWithSelection {
268        /// Character offset of the replacement start.
269        start: usize,
270        /// Length of the replaced range in characters.
271        length: usize,
272        /// Replacement text.
273        text: String,
274        /// Selection start (post-edit) in character offsets.
275        selection_start: usize,
276        /// Selection end (post-edit) in character offsets.
277        selection_end: usize,
278    },
279    /// VSCode-like typing/paste: apply to all carets/selections (primary + secondary)
280    InsertText {
281        /// Text to insert/replace at each selection/caret.
282        text: String,
283    },
284    /// Type a single character using auto-pairs rules (if enabled).
285    ///
286    /// This is intended for UI "typing" paths (not paste). It supports:
287    /// - auto-close pairs (`()`, `{}`, `[]`, quotes)
288    /// - skip over existing closing delimiters
289    /// - wrap selection with pairs (optional)
290    TypeChar {
291        /// The typed character.
292        ch: char,
293    },
294    /// Insert a tab at each caret (or replace each selection), using the current tab settings.
295    ///
296    /// - If `TabKeyBehavior::Tab`, inserts `'\t'`.
297    /// - If `TabKeyBehavior::Spaces`, inserts spaces up to the next tab stop.
298    InsertTab,
299    /// Insert a newline at each caret (or replace each selection).
300    ///
301    /// If `auto_indent` is true, the inserted newline is followed by the leading whitespace
302    /// prefix of the current logical line.
303    InsertNewline {
304        /// Whether to auto-indent the new line.
305        auto_indent: bool,
306    },
307    /// Indent the selected lines (or the current line for an empty selection).
308    Indent,
309    /// Outdent the selected lines (or the current line for an empty selection).
310    Outdent,
311    /// Duplicate the selected line(s) (or the current line for an empty selection).
312    ///
313    /// This is a line-based operation and will act on all carets/selections (primary + secondary),
314    /// including rectangular selections.
315    DuplicateLines,
316    /// Delete the selected line(s) (or the current line for an empty selection).
317    ///
318    /// This is a line-based operation and will act on all carets/selections (primary + secondary),
319    /// including rectangular selections.
320    DeleteLines,
321    /// Move the selected line(s) up by one line.
322    ///
323    /// This is a line-based operation and will act on all carets/selections (primary + secondary),
324    /// including rectangular selections.
325    MoveLinesUp,
326    /// Move the selected line(s) down by one line.
327    ///
328    /// This is a line-based operation and will act on all carets/selections (primary + secondary),
329    /// including rectangular selections.
330    MoveLinesDown,
331    /// Join the current line with the next line (for each caret/selection).
332    ///
333    /// If multiple carets/selections exist, joins are applied from bottom to top to keep offsets stable.
334    JoinLines,
335    /// Split the current line at each caret (or replace each selection) by inserting a newline.
336    ///
337    /// This is a convenience alias for [`EditCommand::InsertNewline`] with `auto_indent: false`.
338    SplitLine,
339    /// Toggle comments for the selected line(s) or selection ranges, using a language-provided
340    /// comment configuration.
341    ToggleComment {
342        /// Comment tokens/config for the current language (data-driven).
343        config: CommentConfig,
344    },
345    /// Apply a batch of text edits as a single undoable step.
346    ///
347    /// - Edits are interpreted in **pre-edit** character offsets.
348    /// - Edits must be non-overlapping; they are applied in descending offset order internally.
349    ApplyTextEdits {
350        /// The edit list (character offsets, half-open).
351        edits: Vec<TextEditSpec>,
352    },
353    /// Apply a snippet-shaped insert as a single undoable step.
354    ///
355    /// This is primarily intended for LSP completion items with `insertTextFormat == 2`.
356    ///
357    /// - `start`/`end` are interpreted in **pre-edit** character offsets (half-open).
358    /// - `additional_edits` are applied in the same undo step (also in pre-edit coordinates).
359    /// - The snippet is expanded (placeholders removed / defaults inserted), and the first
360    ///   placeholder (lowest index) is selected for navigation via
361    ///   [`CursorCommand::SnippetNextPlaceholder`] / [`CursorCommand::SnippetPrevPlaceholder`].
362    ApplySnippet {
363        /// Inclusive start character offset.
364        start: usize,
365        /// Exclusive end character offset.
366        end: usize,
367        /// Snippet text in TextMate / VS Code snippet syntax.
368        snippet: String,
369        /// Additional text edits (LSP `additionalTextEdits`), in pre-edit coordinates.
370        additional_edits: Vec<TextEditSpec>,
371    },
372    /// Smart backspace: if the caret is in leading whitespace, delete back to the previous tab stop.
373    ///
374    /// Otherwise, behaves like [`EditCommand::Backspace`].
375    DeleteToPrevTabStop,
376    /// Delete the previous Unicode grapheme cluster (UAX #29) for each caret/selection.
377    DeleteGraphemeBack,
378    /// Delete the next Unicode grapheme cluster (UAX #29) for each caret/selection.
379    DeleteGraphemeForward,
380    /// Delete back to the previous Unicode word boundary (UAX #29) for each caret/selection.
381    DeleteWordBack,
382    /// Delete forward to the next Unicode word boundary (UAX #29) for each caret/selection.
383    DeleteWordForward,
384    /// Backspace-like deletion: delete selection(s) if any, otherwise delete 1 char before each caret.
385    Backspace,
386    /// Delete key-like deletion: delete selection(s) if any, otherwise delete 1 char after each caret.
387    DeleteForward,
388    /// Undo last edit operation (supports grouping)
389    Undo,
390    /// Redo last undone operation (supports grouping)
391    Redo,
392    /// Explicitly end the current undo group (for idle or external boundaries)
393    EndUndoGroup,
394    /// Replace the current occurrence of `query` (based on selection/caret) with `replacement`.
395    ///
396    /// - Honors `options` (case sensitivity / whole-word / regex).
397    /// - Treated as a single undoable edit.
398    ReplaceCurrent {
399        /// Search query.
400        query: String,
401        /// Replacement text.
402        replacement: String,
403        /// Search options (case sensitivity, whole-word, regex).
404        options: SearchOptions,
405    },
406    /// Replace all occurrences of `query` with `replacement`.
407    ///
408    /// - Honors `options` (case sensitivity / whole-word / regex).
409    /// - Treated as a single undoable edit.
410    ReplaceAll {
411        /// Search query.
412        query: String,
413        /// Replacement text.
414        replacement: String,
415        /// Search options (case sensitivity, whole-word, regex).
416        options: SearchOptions,
417    },
418}
419
420/// Cursor & selection commands
421#[derive(Debug, Clone, PartialEq, Eq)]
422pub enum CursorCommand {
423    /// Move cursor to the specified position
424    MoveTo {
425        /// Target logical line index.
426        line: usize,
427        /// Target column in characters (will be clamped to line length).
428        column: usize,
429    },
430    /// Move cursor relatively
431    MoveBy {
432        /// Delta in logical lines.
433        delta_line: isize,
434        /// Delta in columns (characters).
435        delta_column: isize,
436    },
437    /// Move cursor by visual rows (soft wrap + folding aware).
438    ///
439    /// This uses a "preferred x" in **cells** (sticky column) similar to many editors:
440    /// horizontal moves update preferred x, while vertical visual moves try to preserve it.
441    MoveVisualBy {
442        /// Delta in global visual rows (after wrapping/folding).
443        delta_rows: isize,
444    },
445    /// Move cursor to a visual position (global visual row + x in cells).
446    MoveToVisual {
447        /// Target global visual row (after wrapping/folding).
448        row: usize,
449        /// Target x offset in cells within that visual row.
450        x_cells: usize,
451    },
452    /// Move cursor to the start of the current logical line.
453    MoveToLineStart,
454    /// Move cursor to the end of the current logical line.
455    MoveToLineEnd,
456    /// Move cursor to the start of the current visual line segment (wrap-aware).
457    MoveToVisualLineStart,
458    /// Move cursor to the end of the current visual line segment (wrap-aware).
459    MoveToVisualLineEnd,
460    /// Move cursor left by one Unicode grapheme cluster (UAX #29).
461    MoveGraphemeLeft,
462    /// Move cursor right by one Unicode grapheme cluster (UAX #29).
463    MoveGraphemeRight,
464    /// Move cursor left to the previous Unicode word boundary (UAX #29).
465    MoveWordLeft,
466    /// Move cursor right to the next Unicode word boundary (UAX #29).
467    MoveWordRight,
468    /// Move each caret to its matching bracket (if the caret is on or adjacent to a bracket).
469    ///
470    /// Matching is performed for the configured bracket pairs (typically `()`, `[]`, `{}`).
471    MoveToMatchingBracket,
472    /// If a snippet session is active, jump to the **next** snippet tabstop (placeholder).
473    ///
474    /// This is typically bound to the Tab key while a completion snippet is active.
475    SnippetNextPlaceholder,
476    /// If a snippet session is active, jump to the **previous** snippet tabstop (placeholder).
477    ///
478    /// This is typically bound to Shift-Tab while a completion snippet is active.
479    SnippetPrevPlaceholder,
480    /// Set selection range
481    SetSelection {
482        /// Selection start position.
483        start: Position,
484        /// Selection end position.
485        end: Position,
486    },
487    /// Extend selection range
488    ExtendSelection {
489        /// New active end position.
490        to: Position,
491    },
492    /// Clear selection
493    ClearSelection,
494    /// Set multiple selections/multi-cursor (including primary)
495    SetSelections {
496        /// All selections (including primary).
497        selections: Vec<Selection>,
498        /// Index of the primary selection in `selections`.
499        primary_index: usize,
500    },
501    /// Clear secondary selections/cursors, keeping only primary
502    ClearSecondarySelections,
503    /// Set rectangular selection (box/column selection), which expands into one Selection per line
504    SetRectSelection {
505        /// Anchor position (fixed corner).
506        anchor: Position,
507        /// Active position (moving corner).
508        active: Position,
509    },
510    /// Select the entire current line (or the set of lines covered by the selection), for all carets.
511    SelectLine,
512    /// Select the word under each caret (or keep existing selections if already non-empty).
513    SelectWord,
514    /// Expand selection in a basic, editor-friendly way.
515    ///
516    /// - If the selection is empty, expands to the word under the caret.
517    /// - If the selection is non-empty, expands to full line(s).
518    ExpandSelection,
519    /// Expand selection by a configurable unit and direction.
520    ///
521    /// Notes:
522    /// - This is an **expand-only** operation: it never shrinks the current selection.
523    /// - The expansion direction is absolute (backward/forward in document order). If you call
524    ///   it with different directions over time, the selection can expand on both ends.
525    ExpandSelectionBy {
526        /// Expansion unit.
527        unit: ExpandSelectionUnit,
528        /// Number of units to expand by. `0` is a no-op.
529        count: usize,
530        /// Expansion direction in document order.
531        direction: ExpandSelectionDirection,
532    },
533    /// Add a new caret above each existing caret/selection (at the same column, clamped to line length).
534    AddCursorAbove,
535    /// Add a new caret below each existing caret/selection (at the same column, clamped to line length).
536    AddCursorBelow,
537    /// Multi-cursor match op: add the next occurrence of the current selection/word as a new selection.
538    AddNextOccurrence {
539        /// Search options (case sensitivity, whole-word, regex).
540        options: SearchOptions,
541    },
542    /// Multi-cursor match op: select all occurrences of the current selection/word.
543    AddAllOccurrences {
544        /// Search options (case sensitivity, whole-word, regex).
545        options: SearchOptions,
546    },
547    /// Find the next occurrence of `query` and select it (primary selection only).
548    FindNext {
549        /// Search query.
550        query: String,
551        /// Search options (case sensitivity, whole-word, regex).
552        options: SearchOptions,
553    },
554    /// Find the previous occurrence of `query` and select it (primary selection only).
555    FindPrev {
556        /// Search query.
557        query: String,
558        /// Search options (case sensitivity, whole-word, regex).
559        options: SearchOptions,
560    },
561}
562
563/// View commands
564#[derive(Debug, Clone, PartialEq, Eq)]
565pub enum ViewCommand {
566    /// Set viewport width
567    SetViewportWidth {
568        /// Width in character cells.
569        width: usize,
570    },
571    /// Set soft wrap mode.
572    SetWrapMode {
573        /// Wrap mode.
574        mode: WrapMode,
575    },
576    /// Set wrapped-line indentation policy.
577    SetWrapIndent {
578        /// Wrap indent policy.
579        indent: WrapIndent,
580    },
581    /// Set tab width (in character cells) used for measuring `'\t'` and tab stops.
582    SetTabWidth {
583        /// Tab width in character cells (must be greater than 0).
584        width: usize,
585    },
586    /// Configure how [`EditCommand::InsertTab`] inserts text.
587    SetTabKeyBehavior {
588        /// Tab key behavior.
589        behavior: TabKeyBehavior,
590    },
591    /// Configure language-aware auto-indentation behavior used by [`EditCommand::InsertNewline`]
592    /// when `auto_indent=true`.
593    ///
594    /// Notes:
595    /// - This is view-local (each view can have different indentation rules).
596    SetIndentationConfig {
597        /// Indentation configuration.
598        config: IndentationConfig,
599    },
600    /// Configure auto-pairs behavior used by [`EditCommand::TypeChar`].
601    SetAutoPairsConfig {
602        /// Auto-pairs config.
603        config: AutoPairsConfig,
604    },
605    /// Enable/disable auto-pairs behavior (convenience wrapper over [`ViewCommand::SetAutoPairsConfig`]).
606    SetAutoPairsEnabled {
607        /// Whether auto-pairs are enabled.
608        enabled: bool,
609    },
610    /// Override the ASCII word-boundary character set used by editor-friendly "word" operations.
611    ///
612    /// This is similar in spirit to VSCode's `wordSeparators`.
613    ///
614    /// Notes:
615    /// - Only ASCII characters are configurable here; non-ASCII characters are always treated as boundaries.
616    /// - ASCII whitespace is always treated as a boundary.
617    SetWordBoundaryAsciiBoundaryChars {
618        /// ASCII word-boundary characters (as a string of separators).
619        boundary_chars: String,
620    },
621    /// Reset word-boundary configuration to the default (ASCII identifier-like words).
622    ResetWordBoundaryDefaults,
623    /// Scroll to specified line
624    ScrollTo {
625        /// Logical line index to scroll to.
626        line: usize,
627    },
628    /// Get current viewport content
629    GetViewport {
630        /// Starting visual row.
631        start_row: usize,
632        /// Number of visual rows requested.
633        count: usize,
634    },
635}
636
637/// Style and folding commands
638#[derive(Debug, Clone, PartialEq, Eq)]
639pub enum StyleCommand {
640    /// Add style interval
641    AddStyle {
642        /// Interval start offset in characters.
643        start: usize,
644        /// Interval end offset in characters (exclusive).
645        end: usize,
646        /// Style identifier.
647        style_id: StyleId,
648    },
649    /// Remove style interval
650    RemoveStyle {
651        /// Interval start offset in characters.
652        start: usize,
653        /// Interval end offset in characters (exclusive).
654        end: usize,
655        /// Style identifier.
656        style_id: StyleId,
657    },
658    /// Fold code block
659    Fold {
660        /// Start logical line (inclusive).
661        start_line: usize,
662        /// End logical line (inclusive).
663        end_line: usize,
664    },
665    /// Unfold code block
666    Unfold {
667        /// Start logical line (inclusive) of the fold region to unfold.
668        start_line: usize,
669    },
670    /// Unfold all folds
671    UnfoldAll,
672    /// Recompute bracket-match highlights for the current cursor/selections.
673    ///
674    /// This updates the derived style layer [`StyleLayerId::BRACKET_MATCHES`].
675    UpdateBracketMatchHighlights,
676    /// Clear bracket-match highlights (removes [`StyleLayerId::BRACKET_MATCHES`]).
677    ClearBracketMatchHighlights,
678}
679
680/// Unified command enum
681#[derive(Debug, Clone, PartialEq, Eq)]
682pub enum Command {
683    /// Text editing commands
684    Edit(EditCommand),
685    /// Cursor command
686    Cursor(CursorCommand),
687    /// View commands
688    View(ViewCommand),
689    /// Style command
690    Style(StyleCommand),
691}
692
693impl EditCommand {
694    fn is_mutating(&self) -> bool {
695        match self {
696            EditCommand::Insert { .. }
697            | EditCommand::Delete { .. }
698            | EditCommand::Replace { .. }
699            | EditCommand::ReplaceCoalescingUndo { .. }
700            | EditCommand::ReplaceCoalescingUndoWithSelection { .. }
701            | EditCommand::InsertText { .. }
702            | EditCommand::TypeChar { .. }
703            | EditCommand::InsertTab
704            | EditCommand::InsertNewline { .. }
705            | EditCommand::Indent
706            | EditCommand::Outdent
707            | EditCommand::DuplicateLines
708            | EditCommand::DeleteLines
709            | EditCommand::MoveLinesUp
710            | EditCommand::MoveLinesDown
711            | EditCommand::JoinLines
712            | EditCommand::SplitLine
713            | EditCommand::ToggleComment { .. }
714            | EditCommand::ApplyTextEdits { .. }
715            | EditCommand::ApplySnippet { .. }
716            | EditCommand::DeleteToPrevTabStop
717            | EditCommand::DeleteGraphemeBack
718            | EditCommand::DeleteGraphemeForward
719            | EditCommand::DeleteWordBack
720            | EditCommand::DeleteWordForward
721            | EditCommand::Backspace
722            | EditCommand::DeleteForward
723            | EditCommand::Undo
724            | EditCommand::Redo
725            | EditCommand::EndUndoGroup
726            | EditCommand::ReplaceCurrent { .. }
727            | EditCommand::ReplaceAll { .. } => true,
728        }
729    }
730
731    fn history_summary(&self) -> Self {
732        match self {
733            EditCommand::Insert { offset, text } => EditCommand::Insert {
734                offset: *offset,
735                text: summarize_history_text(text),
736            },
737            EditCommand::Delete { start, length } => EditCommand::Delete {
738                start: *start,
739                length: *length,
740            },
741            EditCommand::Replace {
742                start,
743                length,
744                text,
745            } => EditCommand::Replace {
746                start: *start,
747                length: *length,
748                text: summarize_history_text(text),
749            },
750            EditCommand::ReplaceCoalescingUndo {
751                start,
752                length,
753                text,
754            } => EditCommand::ReplaceCoalescingUndo {
755                start: *start,
756                length: *length,
757                text: summarize_history_text(text),
758            },
759            EditCommand::ReplaceCoalescingUndoWithSelection {
760                start,
761                length,
762                text,
763                selection_start,
764                selection_end,
765            } => EditCommand::ReplaceCoalescingUndoWithSelection {
766                start: *start,
767                length: *length,
768                text: summarize_history_text(text),
769                selection_start: *selection_start,
770                selection_end: *selection_end,
771            },
772            EditCommand::InsertText { text } => EditCommand::InsertText {
773                text: summarize_history_text(text),
774            },
775            EditCommand::TypeChar { ch } => EditCommand::TypeChar { ch: *ch },
776            EditCommand::InsertTab => EditCommand::InsertTab,
777            EditCommand::InsertNewline { auto_indent } => EditCommand::InsertNewline {
778                auto_indent: *auto_indent,
779            },
780            EditCommand::Indent => EditCommand::Indent,
781            EditCommand::Outdent => EditCommand::Outdent,
782            EditCommand::DuplicateLines => EditCommand::DuplicateLines,
783            EditCommand::DeleteLines => EditCommand::DeleteLines,
784            EditCommand::MoveLinesUp => EditCommand::MoveLinesUp,
785            EditCommand::MoveLinesDown => EditCommand::MoveLinesDown,
786            EditCommand::JoinLines => EditCommand::JoinLines,
787            EditCommand::SplitLine => EditCommand::SplitLine,
788            EditCommand::ToggleComment { config } => EditCommand::ToggleComment {
789                config: config.clone(),
790            },
791            EditCommand::ApplyTextEdits { edits } => EditCommand::ApplyTextEdits {
792                edits: edits.iter().map(TextEditSpec::history_summary).collect(),
793            },
794            EditCommand::ApplySnippet {
795                start,
796                end,
797                snippet,
798                additional_edits,
799            } => EditCommand::ApplySnippet {
800                start: *start,
801                end: *end,
802                snippet: summarize_history_text(snippet),
803                additional_edits: additional_edits
804                    .iter()
805                    .map(TextEditSpec::history_summary)
806                    .collect(),
807            },
808            EditCommand::DeleteToPrevTabStop => EditCommand::DeleteToPrevTabStop,
809            EditCommand::DeleteGraphemeBack => EditCommand::DeleteGraphemeBack,
810            EditCommand::DeleteGraphemeForward => EditCommand::DeleteGraphemeForward,
811            EditCommand::DeleteWordBack => EditCommand::DeleteWordBack,
812            EditCommand::DeleteWordForward => EditCommand::DeleteWordForward,
813            EditCommand::Backspace => EditCommand::Backspace,
814            EditCommand::DeleteForward => EditCommand::DeleteForward,
815            EditCommand::Undo => EditCommand::Undo,
816            EditCommand::Redo => EditCommand::Redo,
817            EditCommand::EndUndoGroup => EditCommand::EndUndoGroup,
818            EditCommand::ReplaceCurrent {
819                query,
820                replacement,
821                options,
822            } => EditCommand::ReplaceCurrent {
823                query: summarize_history_text(query),
824                replacement: summarize_history_text(replacement),
825                options: *options,
826            },
827            EditCommand::ReplaceAll {
828                query,
829                replacement,
830                options,
831            } => EditCommand::ReplaceAll {
832                query: summarize_history_text(query),
833                replacement: summarize_history_text(replacement),
834                options: *options,
835            },
836        }
837    }
838}
839
840impl CursorCommand {
841    fn is_mutating(&self) -> bool {
842        match self {
843            CursorCommand::MoveTo { .. }
844            | CursorCommand::MoveBy { .. }
845            | CursorCommand::MoveVisualBy { .. }
846            | CursorCommand::MoveToVisual { .. }
847            | CursorCommand::MoveToLineStart
848            | CursorCommand::MoveToLineEnd
849            | CursorCommand::MoveToVisualLineStart
850            | CursorCommand::MoveToVisualLineEnd
851            | CursorCommand::MoveGraphemeLeft
852            | CursorCommand::MoveGraphemeRight
853            | CursorCommand::MoveWordLeft
854            | CursorCommand::MoveWordRight
855            | CursorCommand::MoveToMatchingBracket
856            | CursorCommand::SnippetNextPlaceholder
857            | CursorCommand::SnippetPrevPlaceholder
858            | CursorCommand::SetSelection { .. }
859            | CursorCommand::ExtendSelection { .. }
860            | CursorCommand::ClearSelection
861            | CursorCommand::SetSelections { .. }
862            | CursorCommand::ClearSecondarySelections
863            | CursorCommand::SetRectSelection { .. }
864            | CursorCommand::SelectLine
865            | CursorCommand::SelectWord
866            | CursorCommand::ExpandSelection
867            | CursorCommand::ExpandSelectionBy { .. }
868            | CursorCommand::AddCursorAbove
869            | CursorCommand::AddCursorBelow
870            | CursorCommand::AddNextOccurrence { .. }
871            | CursorCommand::AddAllOccurrences { .. }
872            | CursorCommand::FindNext { .. }
873            | CursorCommand::FindPrev { .. } => false,
874        }
875    }
876
877    fn history_summary(&self) -> Self {
878        match self {
879            CursorCommand::FindNext { query, options } => CursorCommand::FindNext {
880                query: summarize_history_text(query),
881                options: *options,
882            },
883            CursorCommand::FindPrev { query, options } => CursorCommand::FindPrev {
884                query: summarize_history_text(query),
885                options: *options,
886            },
887            _ => self.clone(),
888        }
889    }
890}
891
892impl ViewCommand {
893    fn is_mutating(&self) -> bool {
894        match self {
895            ViewCommand::SetViewportWidth { .. }
896            | ViewCommand::SetWrapMode { .. }
897            | ViewCommand::SetWrapIndent { .. }
898            | ViewCommand::SetTabWidth { .. }
899            | ViewCommand::SetTabKeyBehavior { .. }
900            | ViewCommand::SetIndentationConfig { .. }
901            | ViewCommand::SetAutoPairsConfig { .. }
902            | ViewCommand::SetAutoPairsEnabled { .. }
903            | ViewCommand::SetWordBoundaryAsciiBoundaryChars { .. }
904            | ViewCommand::ResetWordBoundaryDefaults => true,
905            ViewCommand::ScrollTo { .. } | ViewCommand::GetViewport { .. } => false,
906        }
907    }
908
909    fn history_summary(&self) -> Self {
910        match self {
911            ViewCommand::SetWordBoundaryAsciiBoundaryChars { boundary_chars } => {
912                ViewCommand::SetWordBoundaryAsciiBoundaryChars {
913                    boundary_chars: summarize_history_text(boundary_chars),
914                }
915            }
916            _ => self.clone(),
917        }
918    }
919}
920
921impl StyleCommand {
922    fn is_mutating(&self) -> bool {
923        match self {
924            StyleCommand::AddStyle { .. }
925            | StyleCommand::RemoveStyle { .. }
926            | StyleCommand::Fold { .. }
927            | StyleCommand::Unfold { .. }
928            | StyleCommand::UnfoldAll
929            | StyleCommand::UpdateBracketMatchHighlights
930            | StyleCommand::ClearBracketMatchHighlights => true,
931        }
932    }
933}
934
935impl Command {
936    /// Returns whether this command mutates document content, history, configuration, or styles.
937    pub fn is_mutating(&self) -> bool {
938        match self {
939            Command::Edit(command) => command.is_mutating(),
940            Command::Cursor(command) => command.is_mutating(),
941            Command::View(command) => command.is_mutating(),
942            Command::Style(command) => command.is_mutating(),
943        }
944    }
945
946    pub(super) fn history_summary(&self) -> Self {
947        match self {
948            Command::Edit(command) => Command::Edit(command.history_summary()),
949            Command::Cursor(command) => Command::Cursor(command.history_summary()),
950            Command::View(command) => Command::View(command.history_summary()),
951            Command::Style(command) => Command::Style(command.clone()),
952        }
953    }
954}
955
956/// Command execution result
957#[derive(Debug, Clone)]
958pub enum CommandResult {
959    /// Success, no return value
960    Success,
961    /// Success, returns text
962    Text(String),
963    /// Success, returns position
964    Position(Position),
965    /// Success, returns offset
966    Offset(usize),
967    /// Success, returns viewport content
968    Viewport(HeadlessGrid),
969    /// Find/search result: a match in char offsets (half-open).
970    SearchMatch {
971        /// Inclusive start character offset.
972        start: usize,
973        /// Exclusive end character offset.
974        end: usize,
975    },
976    /// Find/search result: no match found.
977    SearchNotFound,
978    /// Replace result: how many occurrences were replaced.
979    ReplaceResult {
980        /// Number of occurrences replaced.
981        replaced: usize,
982    },
983}
984
985/// Command error type
986#[derive(Debug, Clone, PartialEq, Eq)]
987pub enum CommandError {
988    /// Invalid offset
989    InvalidOffset(usize),
990    /// Invalid position
991    InvalidPosition {
992        /// Logical line index.
993        line: usize,
994        /// Column in characters.
995        column: usize,
996    },
997    /// Invalid range
998    InvalidRange {
999        /// Inclusive start character offset.
1000        start: usize,
1001        /// Exclusive end character offset.
1002        end: usize,
1003    },
1004    /// Empty text
1005    EmptyText,
1006    /// Other error
1007    Other(String),
1008}
1009
1010impl std::fmt::Display for CommandError {
1011    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1012        match self {
1013            CommandError::InvalidOffset(offset) => {
1014                write!(f, "Invalid offset: {}", offset)
1015            }
1016            CommandError::InvalidPosition { line, column } => {
1017                write!(f, "Invalid position: line {}, column {}", line, column)
1018            }
1019            CommandError::InvalidRange { start, end } => {
1020                write!(f, "Invalid range: {}..{}", start, end)
1021            }
1022            CommandError::EmptyText => {
1023                write!(f, "Text cannot be empty")
1024            }
1025            CommandError::Other(msg) => {
1026                write!(f, "{}", msg)
1027            }
1028        }
1029    }
1030}
1031
1032impl std::error::Error for CommandError {}