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]
136#[must_use]
137pub struct CompletionDebugOptions<'a> {
138    /// Virtual render width for the debug menu.
139    pub width: u16,
140    /// Virtual render height for the debug menu.
141    pub height: u16,
142    /// Whether ANSI styling should be enabled in captured output.
143    pub ansi: bool,
144    /// Whether Unicode menu chrome should be enabled in captured output.
145    pub unicode: bool,
146    /// Optional appearance override used for the debug session.
147    pub appearance: Option<&'a ReplAppearance>,
148}
149
150impl<'a> CompletionDebugOptions<'a> {
151    /// Creates a new debug snapshot configuration for a virtual terminal size.
152    ///
153    /// # Examples
154    ///
155    /// ```
156    /// use osp_cli::repl::CompletionDebugOptions;
157    ///
158    /// let options = CompletionDebugOptions::new(100, 12)
159    ///     .with_ansi(true)
160    ///     .with_unicode(true);
161    ///
162    /// assert_eq!(options.width, 100);
163    /// assert_eq!(options.height, 12);
164    /// assert!(options.ansi);
165    /// assert!(options.unicode);
166    /// ```
167    pub fn new(width: u16, height: u16) -> Self {
168        Self {
169            width,
170            height,
171            ansi: false,
172            unicode: false,
173            appearance: None,
174        }
175    }
176
177    /// Enables ANSI styling in captured menu output.
178    pub fn with_ansi(mut self, ansi: bool) -> Self {
179        self.ansi = ansi;
180        self
181    }
182
183    /// Selects unicode or ASCII menu chrome when rendering debug output.
184    pub fn with_unicode(mut self, unicode: bool) -> Self {
185        self.unicode = unicode;
186        self
187    }
188
189    /// Applies REPL appearance overrides to the debug menu session.
190    pub fn with_appearance(mut self, appearance: Option<&'a ReplAppearance>) -> Self {
191        self.appearance = appearance;
192        self
193    }
194}
195
196/// Builds a single completion-debug snapshot for `line` at `cursor`.
197pub fn debug_completion(
198    tree: &CompletionTree,
199    line: &str,
200    cursor: usize,
201    options: CompletionDebugOptions<'_>,
202) -> CompletionDebug {
203    let (editor, mut completer, mut menu) =
204        build_debug_completion_session(tree, line, cursor, options.appearance);
205    let mut editor = editor;
206
207    menu.menu_event(MenuEvent::Activate(false));
208    menu.apply_event(&mut editor, completer.as_mut());
209
210    snapshot_completion_debug(
211        tree,
212        &mut menu,
213        &editor,
214        options.width,
215        options.height,
216        options.ansi,
217        options.unicode,
218    )
219}
220
221/// Builds a single history-menu debug snapshot for `line` at `cursor`.
222pub fn debug_history_menu(
223    history: &SharedHistory,
224    line: &str,
225    cursor: usize,
226    options: CompletionDebugOptions<'_>,
227) -> CompletionDebug {
228    let (editor, mut completer, mut menu) =
229        build_debug_history_session(history, line, cursor, options.appearance);
230    let mut editor = editor;
231
232    menu.menu_event(MenuEvent::Activate(false));
233    menu.apply_event(&mut editor, completer.as_mut());
234
235    snapshot_history_debug(
236        &mut menu,
237        &editor,
238        cursor,
239        options.width,
240        options.height,
241        options.ansi,
242        options.unicode,
243    )
244}
245
246/// Replays a sequence of synthetic editor actions and captures each frame.
247pub fn debug_completion_steps(
248    tree: &CompletionTree,
249    line: &str,
250    cursor: usize,
251    options: CompletionDebugOptions<'_>,
252    steps: &[DebugStep],
253) -> Vec<CompletionDebugFrame> {
254    let (mut editor, mut completer, mut menu) =
255        build_debug_completion_session(tree, line, cursor, options.appearance);
256
257    let steps = steps.to_vec();
258    if steps.is_empty() {
259        return Vec::new();
260    }
261
262    let mut frames = Vec::with_capacity(steps.len());
263    for step in steps {
264        apply_debug_step(step, &mut menu, &mut editor, completer.as_mut());
265        let state = snapshot_completion_debug(
266            tree,
267            &mut menu,
268            &editor,
269            options.width,
270            options.height,
271            options.ansi,
272            options.unicode,
273        );
274        frames.push(CompletionDebugFrame {
275            step: step.as_str().to_string(),
276            state,
277        });
278    }
279
280    frames
281}
282
283/// Replays synthetic editor actions for the history menu and captures each frame.
284pub fn debug_history_menu_steps(
285    history: &SharedHistory,
286    line: &str,
287    cursor: usize,
288    options: CompletionDebugOptions<'_>,
289    steps: &[DebugStep],
290) -> Vec<CompletionDebugFrame> {
291    let (mut editor, mut completer, mut menu) =
292        build_debug_history_session(history, line, cursor, options.appearance);
293
294    let steps = steps.to_vec();
295    if steps.is_empty() {
296        return Vec::new();
297    }
298
299    let mut frames = Vec::with_capacity(steps.len());
300    for step in steps {
301        apply_debug_step(step, &mut menu, &mut editor, completer.as_mut());
302        let state = snapshot_history_debug(
303            &mut menu,
304            &editor,
305            cursor,
306            options.width,
307            options.height,
308            options.ansi,
309            options.unicode,
310        );
311        frames.push(CompletionDebugFrame {
312            step: step.as_str().to_string(),
313            state,
314        });
315    }
316
317    frames
318}
319
320fn build_debug_completion_session(
321    tree: &CompletionTree,
322    line: &str,
323    cursor: usize,
324    appearance: Option<&ReplAppearance>,
325) -> (Editor, Box<dyn Completer>, OspCompletionMenu) {
326    let mut editor = Editor::default();
327    editor.edit_buffer(
328        |buf| {
329            buf.set_buffer(line.to_string());
330            buf.set_insertion_point(cursor.min(buf.get_buffer().len()));
331        },
332        UndoBehavior::CreateUndoPoint,
333    );
334
335    let completer = Box::new(ReplCompleter::new(Vec::new(), Some(tree.clone()), None));
336    let menu = if let Some(appearance) = appearance {
337        build_completion_menu(appearance)
338    } else {
339        OspCompletionMenu::default()
340    };
341
342    (editor, completer, menu)
343}
344
345fn build_debug_history_session(
346    history: &SharedHistory,
347    line: &str,
348    cursor: usize,
349    appearance: Option<&ReplAppearance>,
350) -> (Editor, Box<dyn Completer>, OspCompletionMenu) {
351    let mut editor = Editor::default();
352    editor.edit_buffer(
353        |buf| {
354            buf.set_buffer(line.to_string());
355            buf.set_insertion_point(cursor.min(buf.get_buffer().len()));
356        },
357        UndoBehavior::CreateUndoPoint,
358    );
359
360    let completer = Box::new(ReplHistoryCompleter::new(history.clone()));
361    let menu = if let Some(appearance) = appearance {
362        build_history_menu(appearance)
363    } else {
364        OspCompletionMenu::default()
365            .with_name(HISTORY_MENU_NAME)
366            .with_quick_complete(false)
367            .with_columns(1)
368            .with_max_rows(DEFAULT_HISTORY_MENU_ROWS)
369    };
370
371    (editor, completer, menu)
372}
373
374fn apply_debug_step(
375    step: DebugStep,
376    menu: &mut OspCompletionMenu,
377    editor: &mut Editor,
378    completer: &mut dyn Completer,
379) {
380    match step {
381        DebugStep::Tab => {
382            if menu.is_active() {
383                dispatch_menu_event(menu, editor, completer, MenuEvent::NextElement);
384            } else {
385                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
386            }
387        }
388        DebugStep::BackTab => {
389            if menu.is_active() {
390                dispatch_menu_event(menu, editor, completer, MenuEvent::PreviousElement);
391            } else {
392                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
393            }
394        }
395        DebugStep::Up => {
396            if menu.is_active() {
397                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveUp);
398            }
399        }
400        DebugStep::Down => {
401            if menu.is_active() {
402                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveDown);
403            }
404        }
405        DebugStep::Left => {
406            if menu.is_active() {
407                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveLeft);
408            }
409        }
410        DebugStep::Right => {
411            if menu.is_active() {
412                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveRight);
413            }
414        }
415        DebugStep::Accept => {
416            if menu.is_active() {
417                menu.accept_selection_in_buffer(editor);
418                dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
419            }
420        }
421        DebugStep::Close => {
422            dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
423        }
424    }
425}
426
427fn dispatch_menu_event(
428    menu: &mut OspCompletionMenu,
429    editor: &mut Editor,
430    completer: &mut dyn Completer,
431    event: MenuEvent,
432) {
433    menu.menu_event(event);
434    menu.apply_event(editor, completer);
435}
436
437fn snapshot_completion_debug(
438    tree: &CompletionTree,
439    menu: &mut OspCompletionMenu,
440    editor: &Editor,
441    width: u16,
442    height: u16,
443    ansi: bool,
444    unicode: bool,
445) -> CompletionDebug {
446    let line = editor.get_buffer().to_string();
447    let cursor = editor.line_buffer().insertion_point();
448    let values = menu.get_values();
449    let engine = CompletionEngine::new(tree.clone());
450    let analysis = engine.analyze(&line, cursor);
451
452    let (stub, replace_range) = if let Some(first) = values.first() {
453        let start = first.span.start;
454        let end = first.span.end;
455        let stub = line.get(start..end).unwrap_or("").to_string();
456        (stub, [start, end])
457    } else {
458        (
459            analysis.cursor.raw_stub.clone(),
460            [
461                analysis.cursor.replace_range.start,
462                analysis.cursor.replace_range.end,
463            ],
464        )
465    };
466
467    let matches = values
468        .iter()
469        .map(|item| CompletionDebugMatch {
470            id: item.value.clone(),
471            label: display_text(item).to_string(),
472            description: item.description.clone(),
473            kind: engine
474                .classify_match(&analysis, &item.value)
475                .as_str()
476                .to_string(),
477        })
478        .collect::<Vec<_>>();
479
480    let MenuDebug {
481        columns,
482        rows,
483        visible_rows,
484        indent,
485        selected_index,
486        selected_row,
487        selected_col,
488        description,
489        description_rendered,
490        styles,
491        rendered,
492    } = debug_snapshot(menu, editor, width, height, ansi);
493
494    let selected = if matches.is_empty() {
495        -1
496    } else {
497        selected_index
498    };
499
500    CompletionDebug {
501        line,
502        cursor,
503        replace_range,
504        stub,
505        matches,
506        selected,
507        selected_row,
508        selected_col,
509        columns,
510        rows,
511        visible_rows,
512        menu_indent: indent,
513        menu_styles: styles,
514        menu_description: description,
515        menu_description_rendered: description_rendered,
516        width,
517        height,
518        unicode,
519        color: ansi,
520        rendered,
521    }
522}
523
524fn snapshot_history_debug(
525    menu: &mut OspCompletionMenu,
526    editor: &Editor,
527    cursor: usize,
528    width: u16,
529    height: u16,
530    ansi: bool,
531    unicode: bool,
532) -> CompletionDebug {
533    let line = editor.get_buffer().to_string();
534    let cursor = cursor
535        .min(editor.line_buffer().insertion_point())
536        .min(line.len());
537    let values = menu.get_values();
538    let query = line.get(..cursor).unwrap_or(&line).trim().to_string();
539
540    let (stub, replace_range) = if let Some(first) = values.first() {
541        let start = first.span.start;
542        let end = first.span.end;
543        let stub = line.get(start..end).unwrap_or("").to_string();
544        (stub, [start, end])
545    } else {
546        (query, [0, line.len()])
547    };
548
549    let matches = values
550        .iter()
551        .map(|item| CompletionDebugMatch {
552            id: item.value.clone(),
553            label: display_text(item).to_string(),
554            description: item.description.clone(),
555            kind: "history".to_string(),
556        })
557        .collect::<Vec<_>>();
558
559    let MenuDebug {
560        columns,
561        rows,
562        visible_rows,
563        indent,
564        selected_index,
565        selected_row,
566        selected_col,
567        description,
568        description_rendered,
569        styles,
570        rendered,
571    } = debug_snapshot(menu, editor, width, height, ansi);
572
573    let selected = if matches.is_empty() {
574        -1
575    } else {
576        selected_index
577    };
578
579    CompletionDebug {
580        line,
581        cursor,
582        replace_range,
583        stub,
584        matches,
585        selected,
586        selected_row,
587        selected_col,
588        columns,
589        rows,
590        visible_rows,
591        menu_indent: indent,
592        menu_styles: styles,
593        menu_description: description,
594        menu_description_rendered: description_rendered,
595        width,
596        height,
597        unicode,
598        color: ansi,
599        rendered,
600    }
601}