Skip to main content

osp_cli/repl/engine/
debug.rs

1use super::adapter::{ReplCompleter, ReplHistoryCompleter};
2use super::config::{DEFAULT_HISTORY_MENU_ROWS, ReplAppearance};
3use super::overlay::{build_completion_menu, build_history_menu};
4use super::{HISTORY_MENU_NAME, SharedHistory};
5use crate::completion::{CompletionEngine, CompletionTree};
6use crate::repl::menu::{
7    MenuDebug, MenuStyleDebug, OspCompletionMenu, debug_snapshot, display_text,
8};
9use reedline::{Completer, Editor, Menu, MenuEvent, UndoBehavior};
10use serde::Serialize;
11
12/// One rendered completion entry in the debug surface.
13#[derive(Debug, Clone, Serialize)]
14pub struct CompletionDebugMatch {
15    /// Stable completion identifier or inserted value.
16    pub id: String,
17    /// Primary rendered label for the suggestion.
18    pub label: String,
19    /// Optional secondary description for the suggestion.
20    pub description: Option<String>,
21    /// Classified suggestion kind used by the debug view.
22    pub kind: String,
23}
24
25/// Snapshot of completion/menu state for a given line and cursor position.
26///
27/// This is primarily consumed by REPL debug commands and tests.
28#[derive(Debug, Clone, Serialize)]
29pub struct CompletionDebug {
30    /// Original input line.
31    pub line: String,
32    /// Cursor position used for analysis.
33    pub cursor: usize,
34    /// Replacement byte range for the active suggestion set.
35    pub replace_range: [usize; 2],
36    /// Current completion stub under the cursor.
37    pub stub: String,
38    /// Candidate matches visible to the menu.
39    pub matches: Vec<CompletionDebugMatch>,
40    /// Selected match index, or `-1` when no match is selected.
41    pub selected: i64,
42    /// Selected menu row.
43    pub selected_row: u16,
44    /// Selected menu column.
45    pub selected_col: u16,
46    /// Number of menu columns.
47    pub columns: u16,
48    /// Number of menu rows.
49    pub rows: u16,
50    /// Number of visible menu rows after clipping.
51    pub visible_rows: u16,
52    /// Menu indentation used for rendering.
53    pub menu_indent: u16,
54    /// Effective menu style snapshot.
55    pub menu_styles: MenuStyleDebug,
56    /// Optional selected-item description.
57    pub menu_description: Option<String>,
58    /// Rendered description as shown in the menu.
59    pub menu_description_rendered: Option<String>,
60    /// Virtual render width.
61    pub width: u16,
62    /// Virtual render height.
63    pub height: u16,
64    /// Whether Unicode menu chrome was enabled.
65    pub unicode: bool,
66    /// Whether ANSI styling was enabled.
67    pub color: bool,
68    /// Rendered menu lines captured for debugging.
69    pub rendered: Vec<String>,
70}
71
72/// Synthetic editor action used by completion-debug stepping.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum DebugStep {
75    /// Advance to the next item or open the menu.
76    Tab,
77    /// Move to the previous item.
78    BackTab,
79    /// Move selection upward.
80    Up,
81    /// Move selection downward.
82    Down,
83    /// Move selection left.
84    Left,
85    /// Move selection right.
86    Right,
87    /// Accept the selected item.
88    Accept,
89    /// Close the completion menu.
90    Close,
91}
92
93impl DebugStep {
94    /// Parses a step name accepted by REPL debug commands.
95    pub fn parse(raw: &str) -> Option<Self> {
96        match raw.trim().to_ascii_lowercase().as_str() {
97            "tab" => Some(Self::Tab),
98            "backtab" | "shift-tab" | "shift_tab" => Some(Self::BackTab),
99            "up" => Some(Self::Up),
100            "down" => Some(Self::Down),
101            "left" => Some(Self::Left),
102            "right" => Some(Self::Right),
103            "accept" | "enter" => Some(Self::Accept),
104            "close" | "esc" | "escape" => Some(Self::Close),
105            _ => None,
106        }
107    }
108
109    /// Returns the stable lowercase name used in debug payloads.
110    pub fn as_str(&self) -> &'static str {
111        match self {
112            Self::Tab => "tab",
113            Self::BackTab => "backtab",
114            Self::Up => "up",
115            Self::Down => "down",
116            Self::Left => "left",
117            Self::Right => "right",
118            Self::Accept => "accept",
119            Self::Close => "close",
120        }
121    }
122}
123
124/// One frame from a stepped completion-debug session.
125#[derive(Debug, Clone, Serialize)]
126pub struct CompletionDebugFrame {
127    /// Synthetic step that produced this frame.
128    pub step: String,
129    /// Captured completion state after the step.
130    pub state: CompletionDebug,
131}
132
133/// Rendering and capture options for completion-debug helpers.
134#[derive(Debug, Clone, Copy)]
135#[non_exhaustive]
136pub struct CompletionDebugOptions<'a> {
137    /// Virtual render width for the debug menu.
138    pub width: u16,
139    /// Virtual render height for the debug menu.
140    pub height: u16,
141    /// Whether ANSI styling should be enabled in captured output.
142    pub ansi: bool,
143    /// Whether Unicode menu chrome should be enabled in captured output.
144    pub unicode: bool,
145    /// Optional appearance override used for the debug session.
146    pub appearance: Option<&'a ReplAppearance>,
147}
148
149impl<'a> CompletionDebugOptions<'a> {
150    /// Creates a new debug snapshot configuration for a virtual terminal size.
151    ///
152    /// # Examples
153    ///
154    /// ```
155    /// use osp_cli::repl::CompletionDebugOptions;
156    ///
157    /// let options = CompletionDebugOptions::new(100, 12)
158    ///     .with_ansi(true)
159    ///     .with_unicode(true);
160    ///
161    /// assert_eq!(options.width, 100);
162    /// assert_eq!(options.height, 12);
163    /// assert!(options.ansi);
164    /// assert!(options.unicode);
165    /// ```
166    pub fn new(width: u16, height: u16) -> Self {
167        Self {
168            width,
169            height,
170            ansi: false,
171            unicode: false,
172            appearance: None,
173        }
174    }
175
176    /// Enables ANSI styling in captured menu output.
177    pub fn with_ansi(mut self, ansi: bool) -> Self {
178        self.ansi = ansi;
179        self
180    }
181
182    /// Selects unicode or ASCII menu chrome when rendering debug output.
183    pub fn with_unicode(mut self, unicode: bool) -> Self {
184        self.unicode = unicode;
185        self
186    }
187
188    /// Applies REPL appearance overrides to the debug menu session.
189    pub fn with_appearance(mut self, appearance: Option<&'a ReplAppearance>) -> Self {
190        self.appearance = appearance;
191        self
192    }
193}
194
195/// Builds a single completion-debug snapshot for `line` at `cursor`.
196pub fn debug_completion(
197    tree: &CompletionTree,
198    line: &str,
199    cursor: usize,
200    options: CompletionDebugOptions<'_>,
201) -> CompletionDebug {
202    let (editor, mut completer, mut menu) =
203        build_debug_completion_session(tree, line, cursor, options.appearance);
204    let mut editor = editor;
205
206    menu.menu_event(MenuEvent::Activate(false));
207    menu.apply_event(&mut editor, completer.as_mut());
208
209    snapshot_completion_debug(
210        tree,
211        &mut menu,
212        &editor,
213        options.width,
214        options.height,
215        options.ansi,
216        options.unicode,
217    )
218}
219
220/// Builds a single history-menu debug snapshot for `line` at `cursor`.
221pub fn debug_history_menu(
222    history: &SharedHistory,
223    line: &str,
224    cursor: usize,
225    options: CompletionDebugOptions<'_>,
226) -> CompletionDebug {
227    let (editor, mut completer, mut menu) =
228        build_debug_history_session(history, line, cursor, options.appearance);
229    let mut editor = editor;
230
231    menu.menu_event(MenuEvent::Activate(false));
232    menu.apply_event(&mut editor, completer.as_mut());
233
234    snapshot_history_debug(
235        &mut menu,
236        &editor,
237        cursor,
238        options.width,
239        options.height,
240        options.ansi,
241        options.unicode,
242    )
243}
244
245/// Replays a sequence of synthetic editor actions and captures each frame.
246pub fn debug_completion_steps(
247    tree: &CompletionTree,
248    line: &str,
249    cursor: usize,
250    options: CompletionDebugOptions<'_>,
251    steps: &[DebugStep],
252) -> Vec<CompletionDebugFrame> {
253    let (mut editor, mut completer, mut menu) =
254        build_debug_completion_session(tree, line, cursor, options.appearance);
255
256    let steps = steps.to_vec();
257    if steps.is_empty() {
258        return Vec::new();
259    }
260
261    let mut frames = Vec::with_capacity(steps.len());
262    for step in steps {
263        apply_debug_step(step, &mut menu, &mut editor, completer.as_mut());
264        let state = snapshot_completion_debug(
265            tree,
266            &mut menu,
267            &editor,
268            options.width,
269            options.height,
270            options.ansi,
271            options.unicode,
272        );
273        frames.push(CompletionDebugFrame {
274            step: step.as_str().to_string(),
275            state,
276        });
277    }
278
279    frames
280}
281
282/// Replays synthetic editor actions for the history menu and captures each frame.
283pub fn debug_history_menu_steps(
284    history: &SharedHistory,
285    line: &str,
286    cursor: usize,
287    options: CompletionDebugOptions<'_>,
288    steps: &[DebugStep],
289) -> Vec<CompletionDebugFrame> {
290    let (mut editor, mut completer, mut menu) =
291        build_debug_history_session(history, line, cursor, options.appearance);
292
293    let steps = steps.to_vec();
294    if steps.is_empty() {
295        return Vec::new();
296    }
297
298    let mut frames = Vec::with_capacity(steps.len());
299    for step in steps {
300        apply_debug_step(step, &mut menu, &mut editor, completer.as_mut());
301        let state = snapshot_history_debug(
302            &mut menu,
303            &editor,
304            cursor,
305            options.width,
306            options.height,
307            options.ansi,
308            options.unicode,
309        );
310        frames.push(CompletionDebugFrame {
311            step: step.as_str().to_string(),
312            state,
313        });
314    }
315
316    frames
317}
318
319fn build_debug_completion_session(
320    tree: &CompletionTree,
321    line: &str,
322    cursor: usize,
323    appearance: Option<&ReplAppearance>,
324) -> (Editor, Box<dyn Completer>, OspCompletionMenu) {
325    let mut editor = Editor::default();
326    editor.edit_buffer(
327        |buf| {
328            buf.set_buffer(line.to_string());
329            buf.set_insertion_point(cursor.min(buf.get_buffer().len()));
330        },
331        UndoBehavior::CreateUndoPoint,
332    );
333
334    let completer = Box::new(ReplCompleter::new(Vec::new(), Some(tree.clone()), None));
335    let menu = if let Some(appearance) = appearance {
336        build_completion_menu(appearance)
337    } else {
338        OspCompletionMenu::default()
339    };
340
341    (editor, completer, menu)
342}
343
344fn build_debug_history_session(
345    history: &SharedHistory,
346    line: &str,
347    cursor: usize,
348    appearance: Option<&ReplAppearance>,
349) -> (Editor, Box<dyn Completer>, OspCompletionMenu) {
350    let mut editor = Editor::default();
351    editor.edit_buffer(
352        |buf| {
353            buf.set_buffer(line.to_string());
354            buf.set_insertion_point(cursor.min(buf.get_buffer().len()));
355        },
356        UndoBehavior::CreateUndoPoint,
357    );
358
359    let completer = Box::new(ReplHistoryCompleter::new(history.clone()));
360    let menu = if let Some(appearance) = appearance {
361        build_history_menu(appearance)
362    } else {
363        OspCompletionMenu::default()
364            .with_name(HISTORY_MENU_NAME)
365            .with_quick_complete(false)
366            .with_columns(1)
367            .with_max_rows(DEFAULT_HISTORY_MENU_ROWS)
368    };
369
370    (editor, completer, menu)
371}
372
373fn apply_debug_step(
374    step: DebugStep,
375    menu: &mut OspCompletionMenu,
376    editor: &mut Editor,
377    completer: &mut dyn Completer,
378) {
379    match step {
380        DebugStep::Tab => {
381            if menu.is_active() {
382                dispatch_menu_event(menu, editor, completer, MenuEvent::NextElement);
383            } else {
384                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
385            }
386        }
387        DebugStep::BackTab => {
388            if menu.is_active() {
389                dispatch_menu_event(menu, editor, completer, MenuEvent::PreviousElement);
390            } else {
391                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
392            }
393        }
394        DebugStep::Up => {
395            if menu.is_active() {
396                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveUp);
397            }
398        }
399        DebugStep::Down => {
400            if menu.is_active() {
401                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveDown);
402            }
403        }
404        DebugStep::Left => {
405            if menu.is_active() {
406                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveLeft);
407            }
408        }
409        DebugStep::Right => {
410            if menu.is_active() {
411                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveRight);
412            }
413        }
414        DebugStep::Accept => {
415            if menu.is_active() {
416                menu.accept_selection_in_buffer(editor);
417                dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
418            }
419        }
420        DebugStep::Close => {
421            dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
422        }
423    }
424}
425
426fn dispatch_menu_event(
427    menu: &mut OspCompletionMenu,
428    editor: &mut Editor,
429    completer: &mut dyn Completer,
430    event: MenuEvent,
431) {
432    menu.menu_event(event);
433    menu.apply_event(editor, completer);
434}
435
436fn snapshot_completion_debug(
437    tree: &CompletionTree,
438    menu: &mut OspCompletionMenu,
439    editor: &Editor,
440    width: u16,
441    height: u16,
442    ansi: bool,
443    unicode: bool,
444) -> CompletionDebug {
445    let line = editor.get_buffer().to_string();
446    let cursor = editor.line_buffer().insertion_point();
447    let values = menu.get_values();
448    let engine = CompletionEngine::new(tree.clone());
449    let analysis = engine.analyze(&line, cursor);
450
451    let (stub, replace_range) = if let Some(first) = values.first() {
452        let start = first.span.start;
453        let end = first.span.end;
454        let stub = line.get(start..end).unwrap_or("").to_string();
455        (stub, [start, end])
456    } else {
457        (
458            analysis.cursor.raw_stub.clone(),
459            [
460                analysis.cursor.replace_range.start,
461                analysis.cursor.replace_range.end,
462            ],
463        )
464    };
465
466    let matches = values
467        .iter()
468        .map(|item| CompletionDebugMatch {
469            id: item.value.clone(),
470            label: display_text(item).to_string(),
471            description: item.description.clone(),
472            kind: engine
473                .classify_match(&analysis, &item.value)
474                .as_str()
475                .to_string(),
476        })
477        .collect::<Vec<_>>();
478
479    let MenuDebug {
480        columns,
481        rows,
482        visible_rows,
483        indent,
484        selected_index,
485        selected_row,
486        selected_col,
487        description,
488        description_rendered,
489        styles,
490        rendered,
491    } = debug_snapshot(menu, editor, width, height, ansi);
492
493    let selected = if matches.is_empty() {
494        -1
495    } else {
496        selected_index
497    };
498
499    CompletionDebug {
500        line,
501        cursor,
502        replace_range,
503        stub,
504        matches,
505        selected,
506        selected_row,
507        selected_col,
508        columns,
509        rows,
510        visible_rows,
511        menu_indent: indent,
512        menu_styles: styles,
513        menu_description: description,
514        menu_description_rendered: description_rendered,
515        width,
516        height,
517        unicode,
518        color: ansi,
519        rendered,
520    }
521}
522
523fn snapshot_history_debug(
524    menu: &mut OspCompletionMenu,
525    editor: &Editor,
526    cursor: usize,
527    width: u16,
528    height: u16,
529    ansi: bool,
530    unicode: bool,
531) -> CompletionDebug {
532    let line = editor.get_buffer().to_string();
533    let cursor = cursor
534        .min(editor.line_buffer().insertion_point())
535        .min(line.len());
536    let values = menu.get_values();
537    let query = line.get(..cursor).unwrap_or(&line).trim().to_string();
538
539    let (stub, replace_range) = if let Some(first) = values.first() {
540        let start = first.span.start;
541        let end = first.span.end;
542        let stub = line.get(start..end).unwrap_or("").to_string();
543        (stub, [start, end])
544    } else {
545        (query, [0, line.len()])
546    };
547
548    let matches = values
549        .iter()
550        .map(|item| CompletionDebugMatch {
551            id: item.value.clone(),
552            label: display_text(item).to_string(),
553            description: item.description.clone(),
554            kind: "history".to_string(),
555        })
556        .collect::<Vec<_>>();
557
558    let MenuDebug {
559        columns,
560        rows,
561        visible_rows,
562        indent,
563        selected_index,
564        selected_row,
565        selected_col,
566        description,
567        description_rendered,
568        styles,
569        rendered,
570    } = debug_snapshot(menu, editor, width, height, ansi);
571
572    let selected = if matches.is_empty() {
573        -1
574    } else {
575        selected_index
576    };
577
578    CompletionDebug {
579        line,
580        cursor,
581        replace_range,
582        stub,
583        matches,
584        selected,
585        selected_row,
586        selected_col,
587        columns,
588        rows,
589        visible_rows,
590        menu_indent: indent,
591        menu_styles: styles,
592        menu_description: description,
593        menu_description_rendered: description_rendered,
594        width,
595        height,
596        unicode,
597        color: ansi,
598        rendered,
599    }
600}