Skip to main content

reovim_module_cmdline/
state.rs

1#![allow(clippy::doc_markdown)] // Session extensions use CamelCase in docs
2//! Command-line mode state - POLICY layer.
3//!
4//! `CmdlineState` stores all command-line mode data: input buffer, cursor,
5//! prompt type, history, and completion state.
6//!
7//! # Architecture (#468)
8//!
9//! CmdlineState is a per-client `SessionExtension`. It lives in the module
10//! layer (POLICY) because it defines HOW command-line mode behaves.
11//! The driver layer only provides the `SessionExtension` trait (MECHANISM).
12
13use reovim_driver_session::{SessionExtension, TextInputSink};
14
15/// Command-line prompt type for session extensions.
16///
17/// Determines the display prompt character and history pool.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum CmdlinePrompt {
20    /// Ex command prompt (`:`)
21    #[default]
22    Command,
23    /// Forward search prompt (`/`)
24    SearchForward,
25    /// Backward search prompt (`?`)
26    SearchBackward,
27}
28
29impl CmdlinePrompt {
30    /// Get the prompt character for display.
31    #[must_use]
32    pub const fn char(self) -> char {
33        match self {
34            Self::Command => ':',
35            Self::SearchForward => '/',
36            Self::SearchBackward => '?',
37        }
38    }
39
40    /// Check if this is a search prompt.
41    #[must_use]
42    pub const fn is_search(self) -> bool {
43        matches!(self, Self::SearchForward | Self::SearchBackward)
44    }
45}
46
47/// A message to display in the command-line area.
48///
49/// Used for ex-command errors ("E492: Not an editor command") and
50/// informational messages. Cleared on next keypress in normal mode.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum CmdlineMessage {
53    /// Error message (displayed with error highlighting).
54    Error(String),
55    /// Informational message (displayed normally).
56    Info(String),
57}
58
59impl CmdlineMessage {
60    /// Get the message text.
61    #[must_use]
62    pub fn text(&self) -> &str {
63        match self {
64            Self::Error(s) | Self::Info(s) => s,
65        }
66    }
67
68    /// Get the message kind as a string for serialization.
69    #[must_use]
70    pub const fn kind(&self) -> &str {
71        match self {
72            Self::Error(_) => "error",
73            Self::Info(_) => "info",
74        }
75    }
76}
77
78/// Maximum number of history entries per type.
79const MAX_HISTORY: usize = 100;
80
81/// Session extension for command-line state.
82///
83/// Policy (modules) sets this when entering/exiting command-line mode.
84/// Mechanism (runner) reads this to sync display state.
85#[derive(Debug, Default)]
86pub struct CmdlineState {
87    /// Whether cmdline is active.
88    active: bool,
89    /// The prompt type for the current command-line session.
90    prompt: CmdlinePrompt,
91    /// Whether the exit was a cancellation (Escape) vs execution (Enter).
92    cancelled: bool,
93    /// Input text buffer for the command line.
94    input: String,
95    /// Cursor position within the input buffer.
96    cursor: usize,
97    /// History for `:` commands.
98    command_history: Vec<String>,
99    /// History for `/` and `?` searches.
100    search_history: Vec<String>,
101    /// Current history navigation index (`None` = new input).
102    history_index: Option<usize>,
103    /// Saved input when navigating history.
104    saved_input: String,
105    /// Available completion candidates.
106    completions: Vec<String>,
107    /// Currently selected completion index.
108    completion_index: Option<usize>,
109    /// The prefix that generated the current completions.
110    completion_prefix: String,
111    /// Message to display in the command-line area (cleared on next keypress).
112    message: Option<CmdlineMessage>,
113}
114
115impl SessionExtension for CmdlineState {
116    fn create() -> Self {
117        Self::default()
118    }
119
120    /// `CmdlineState` accepts text input, so it implements `TextInputSink`.
121    ///
122    /// This enables the runner to route characters from command-line mode
123    /// to this extension without string-based mode detection (#482).
124    fn as_text_input_sink(&mut self) -> Option<&mut dyn TextInputSink> {
125        Some(self)
126    }
127}
128
129/// `TextInputSink` implementation for `CmdlineState`.
130///
131/// Delegates to the existing `insert_char` method.
132impl TextInputSink for CmdlineState {
133    fn insert_char(&mut self, ch: char) {
134        // Delegate to the existing method
135        Self::insert_char(self, ch);
136    }
137}
138
139impl CmdlineState {
140    /// Enter cmdline mode with specified prompt type.
141    pub fn enter(&mut self, prompt: CmdlinePrompt) {
142        self.active = true;
143        self.prompt = prompt;
144        self.cancelled = false;
145        self.input.clear();
146        self.cursor = 0;
147        self.history_index = None;
148        self.saved_input.clear();
149        self.message = None;
150    }
151
152    /// Exit cmdline mode (execute action).
153    ///
154    /// Note: Preserves `prompt` so runner can read it after deactivation
155    /// to determine what action to take (search vs ex command).
156    pub const fn exit(&mut self) {
157        self.active = false;
158        self.cancelled = false;
159        // prompt is preserved - runner reads it after exit
160        // input is cleared by take_cmdline_input() before this is called
161    }
162
163    /// Cancel cmdline mode (don't execute action).
164    ///
165    /// Note: Preserves `prompt` for consistency, though `was_cancelled`
166    /// prevents execution regardless.
167    pub fn cancel(&mut self) {
168        self.active = false;
169        self.cancelled = true;
170        self.input.clear();
171        self.cursor = 0;
172        // prompt is preserved for consistency
173    }
174
175    /// Check if cmdline is active.
176    #[must_use]
177    pub const fn is_active(&self) -> bool {
178        self.active
179    }
180
181    /// Check if cmdline was cancelled (vs executed).
182    #[must_use]
183    pub const fn was_cancelled(&self) -> bool {
184        self.cancelled
185    }
186
187    /// Get the current prompt type.
188    #[must_use]
189    pub const fn prompt(&self) -> CmdlinePrompt {
190        self.prompt
191    }
192
193    /// Insert a character at cursor position.
194    pub fn insert_char(&mut self, ch: char) {
195        if self.cursor >= self.input.len() {
196            self.input.push(ch);
197        } else {
198            self.input.insert(self.cursor, ch);
199        }
200        self.cursor += 1;
201        self.clear_completions();
202    }
203
204    /// Delete character before cursor (Backspace).
205    pub fn backspace(&mut self) {
206        if self.cursor > 0 {
207            self.cursor -= 1;
208            self.input.remove(self.cursor);
209            self.clear_completions();
210        }
211    }
212
213    /// Get the current input buffer.
214    #[must_use]
215    pub fn input(&self) -> &str {
216        &self.input
217    }
218
219    /// Take ownership of the input, clearing the buffer.
220    ///
221    /// Called by commands when exiting cmdline mode to get the entered text.
222    pub fn take_cmdline_input(&mut self) -> String {
223        self.cursor = 0;
224        std::mem::take(&mut self.input)
225    }
226
227    /// Get cursor position.
228    #[must_use]
229    pub const fn cursor(&self) -> usize {
230        self.cursor
231    }
232
233    // =========================================================================
234    // Enhanced editing methods (#451)
235    // =========================================================================
236
237    /// Move cursor left by one position.
238    pub const fn move_cursor_left(&mut self) {
239        if self.cursor > 0 {
240            self.cursor -= 1;
241        }
242    }
243
244    /// Move cursor right by one position.
245    pub const fn move_cursor_right(&mut self) {
246        if self.cursor < self.input.len() {
247            self.cursor += 1;
248        }
249    }
250
251    /// Move cursor to start of input.
252    pub const fn move_to_start(&mut self) {
253        self.cursor = 0;
254    }
255
256    /// Move cursor to end of input.
257    pub const fn move_to_end(&mut self) {
258        self.cursor = self.input.len();
259    }
260
261    /// Delete character at cursor position (Del key).
262    pub fn delete_at_cursor(&mut self) {
263        if self.cursor < self.input.len() {
264            self.input.remove(self.cursor);
265            self.clear_completions();
266        }
267    }
268
269    /// Delete word before cursor (Ctrl-W).
270    #[cfg_attr(coverage_nightly, coverage(off))]
271    pub fn delete_word_back(&mut self) {
272        if self.cursor == 0 {
273            return;
274        }
275        let mut pos = self.cursor;
276        // Skip trailing whitespace
277        while pos > 0 && self.input.as_bytes()[pos - 1] == b' ' {
278            pos -= 1;
279        }
280        // Delete back to start of word
281        while pos > 0 && self.input.as_bytes()[pos - 1] != b' ' {
282            pos -= 1;
283        }
284        self.input.drain(pos..self.cursor);
285        self.cursor = pos;
286        self.clear_completions();
287    }
288
289    /// Delete from cursor to start of line (Ctrl-U).
290    pub fn delete_to_start(&mut self) {
291        if self.cursor > 0 {
292            self.input.drain(..self.cursor);
293            self.cursor = 0;
294            self.clear_completions();
295        }
296    }
297
298    // =========================================================================
299    // History methods (#451)
300    // =========================================================================
301
302    /// Get the history for the current prompt type.
303    fn current_history(&self) -> &[String] {
304        if self.prompt.is_search() {
305            &self.search_history
306        } else {
307            &self.command_history
308        }
309    }
310
311    /// Get mutable history for the current prompt type.
312    const fn current_history_mut(&mut self) -> &mut Vec<String> {
313        if self.prompt.is_search() {
314            &mut self.search_history
315        } else {
316            &mut self.command_history
317        }
318    }
319
320    /// Navigate to an older history entry (Up / Ctrl-P).
321    pub fn history_up(&mut self) {
322        let history_len = self.current_history().len();
323        if history_len == 0 {
324            return;
325        }
326
327        let idx = match self.history_index {
328            None => {
329                // Save current input and show most recent history
330                self.saved_input.clone_from(&self.input);
331                history_len - 1
332            }
333            Some(0) => return, // already at oldest
334            Some(i) => i - 1,
335        };
336
337        self.history_index = Some(idx);
338        let entry = self.current_history()[idx].clone();
339        self.input = entry;
340        self.cursor = self.input.len();
341    }
342
343    /// Navigate to a newer history entry (Down / Ctrl-N).
344    pub fn history_down(&mut self) {
345        let Some(idx) = self.history_index else {
346            return; // not navigating history
347        };
348
349        let history_len = self.current_history().len();
350        if idx + 1 >= history_len {
351            // Past newest → restore saved input
352            self.history_index = None;
353            self.input = std::mem::take(&mut self.saved_input);
354        } else {
355            let entry = self.current_history()[idx + 1].clone();
356            self.history_index = Some(idx + 1);
357            self.input = entry;
358        }
359        self.cursor = self.input.len();
360    }
361
362    /// Push the current input to history (called on successful execution).
363    pub fn push_to_history(&mut self) {
364        if self.input.is_empty() {
365            return;
366        }
367
368        let input_clone = self.input.clone();
369        let history = self.current_history_mut();
370
371        // Deduplicate: remove if already present
372        history.retain(|entry| *entry != input_clone);
373        history.push(input_clone);
374
375        // Cap size
376        if history.len() > MAX_HISTORY {
377            history.remove(0);
378        }
379    }
380
381    // =========================================================================
382    // Completion methods (#451)
383    // =========================================================================
384
385    /// Set available completions for a given prefix.
386    pub fn set_completions(&mut self, prefix: String, candidates: Vec<String>) {
387        self.completion_prefix = prefix;
388        self.completions = candidates;
389        self.completion_index = None;
390    }
391
392    /// Clear all completion state.
393    pub fn clear_completions(&mut self) {
394        self.completions.clear();
395        self.completion_index = None;
396        self.completion_prefix.clear();
397    }
398
399    /// Cycle to the next completion (Tab).
400    ///
401    /// Returns `true` if a completion was applied, `false` if no completions exist.
402    pub fn complete_next(&mut self) -> bool {
403        if self.completions.is_empty() {
404            return false;
405        }
406        let idx = match self.completion_index {
407            None => 0,
408            Some(i) => (i + 1) % self.completions.len(),
409        };
410        self.completion_index = Some(idx);
411        self.apply_completion(idx);
412        true
413    }
414
415    /// Cycle to the previous completion (Shift-Tab).
416    ///
417    /// Returns `true` if a completion was applied, `false` if no completions exist.
418    pub fn complete_prev(&mut self) -> bool {
419        if self.completions.is_empty() {
420            return false;
421        }
422        let idx = match self.completion_index {
423            None | Some(0) => self.completions.len() - 1,
424            Some(i) => i - 1,
425        };
426        self.completion_index = Some(idx);
427        self.apply_completion(idx);
428        true
429    }
430
431    /// Apply a completion at the given index to the input.
432    #[cfg_attr(coverage_nightly, coverage(off))]
433    fn apply_completion(&mut self, idx: usize) {
434        if let Some(completion) = self.completions.get(idx) {
435            self.input = completion.clone();
436            self.cursor = self.input.len();
437        }
438    }
439
440    /// Get the current completions list.
441    #[must_use]
442    pub fn completions(&self) -> &[String] {
443        &self.completions
444    }
445
446    /// Get the currently selected completion index.
447    #[must_use]
448    pub const fn completion_index(&self) -> Option<usize> {
449        self.completion_index
450    }
451
452    // =========================================================================
453    // Message methods (#558)
454    // =========================================================================
455
456    /// Set a message to display in the command-line area.
457    ///
458    /// The message persists until cleared (by next keypress or entering cmdline).
459    pub fn set_message(&mut self, message: CmdlineMessage) {
460        self.message = Some(message);
461    }
462
463    /// Clear the current message.
464    pub fn clear_message(&mut self) {
465        self.message = None;
466    }
467
468    /// Get the current message, if any.
469    #[must_use]
470    pub const fn message(&self) -> Option<&CmdlineMessage> {
471        self.message.as_ref()
472    }
473}
474
475#[cfg(test)]
476#[path = "state_tests.rs"]
477mod tests;