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;