Skip to main content

osp_cli/repl/
engine.rs

1use std::borrow::Cow;
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{self, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use super::highlight::ReplHighlighter;
8pub use super::highlight::{HighlightDebugSpan, debug_highlight};
9pub use super::history_store::{
10    HistoryConfig, HistoryEntry, HistoryShellContext, OspHistoryStore, SharedHistory,
11    expand_history,
12};
13use super::menu::{MenuDebug, MenuStyleDebug, OspCompletionMenu, debug_snapshot, display_text};
14use crate::completion::{
15    ArgNode, CompletionEngine, CompletionNode, CompletionTree, SuggestionEntry, SuggestionOutput,
16};
17use anyhow::Result;
18use nu_ansi_term::{Color, Style};
19use reedline::{
20    Completer, EditCommand, EditMode, Editor, Emacs, KeyCode, KeyModifiers, Menu, MenuEvent,
21    Prompt, PromptEditMode, PromptHistorySearch, PromptHistorySearchStatus, Reedline,
22    ReedlineEvent, ReedlineMenu, ReedlineRawEvent, Signal, Span, Suggestion, UndoBehavior,
23    default_emacs_keybindings,
24};
25use serde::Serialize;
26
27#[derive(Debug, Clone)]
28pub struct ReplPrompt {
29    pub left: String,
30    pub indicator: String,
31}
32
33pub type PromptRightRenderer = Arc<dyn Fn() -> String + Send + Sync>;
34
35#[derive(Debug, Clone, Default, PartialEq, Eq)]
36pub struct LineProjection {
37    pub line: String,
38    pub hidden_suggestions: BTreeSet<String>,
39}
40
41impl LineProjection {
42    pub fn passthrough(line: impl Into<String>) -> Self {
43        Self {
44            line: line.into(),
45            hidden_suggestions: BTreeSet::new(),
46        }
47    }
48
49    pub fn with_hidden_suggestions(mut self, hidden_suggestions: BTreeSet<String>) -> Self {
50        self.hidden_suggestions = hidden_suggestions;
51        self
52    }
53}
54
55pub type LineProjector = Arc<dyn Fn(&str) -> LineProjection + Send + Sync>;
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum ReplInputMode {
59    Auto,
60    Interactive,
61    Basic,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum ReplReloadKind {
66    Default,
67    WithIntro,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ReplLineResult {
72    Continue(String),
73    ReplaceInput(String),
74    Exit(i32),
75    Restart {
76        output: String,
77        reload: ReplReloadKind,
78    },
79}
80
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum ReplRunResult {
83    Exit(i32),
84    Restart {
85        output: String,
86        reload: ReplReloadKind,
87    },
88}
89
90pub struct ReplRunConfig {
91    pub prompt: ReplPrompt,
92    pub completion_words: Vec<String>,
93    pub completion_tree: Option<CompletionTree>,
94    pub appearance: ReplAppearance,
95    pub history_config: HistoryConfig,
96    pub input_mode: ReplInputMode,
97    pub prompt_right: Option<PromptRightRenderer>,
98    pub line_projector: Option<LineProjector>,
99}
100
101#[derive(Debug, Clone, Serialize)]
102pub struct CompletionDebugMatch {
103    pub id: String,
104    pub label: String,
105    pub description: Option<String>,
106    pub kind: String,
107}
108
109#[derive(Debug, Clone, Serialize)]
110pub struct CompletionDebug {
111    pub line: String,
112    pub cursor: usize,
113    pub replace_range: [usize; 2],
114    pub stub: String,
115    pub matches: Vec<CompletionDebugMatch>,
116    pub selected: i64,
117    pub selected_row: u16,
118    pub selected_col: u16,
119    pub columns: u16,
120    pub rows: u16,
121    pub visible_rows: u16,
122    pub menu_indent: u16,
123    pub menu_styles: MenuStyleDebug,
124    pub menu_description: Option<String>,
125    pub menu_description_rendered: Option<String>,
126    pub width: u16,
127    pub height: u16,
128    pub unicode: bool,
129    pub color: bool,
130    pub rendered: Vec<String>,
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum DebugStep {
135    Tab,
136    BackTab,
137    Up,
138    Down,
139    Left,
140    Right,
141    Accept,
142    Close,
143}
144
145impl DebugStep {
146    pub fn parse(raw: &str) -> Option<Self> {
147        match raw.trim().to_ascii_lowercase().as_str() {
148            "tab" => Some(Self::Tab),
149            "backtab" | "shift-tab" | "shift_tab" => Some(Self::BackTab),
150            "up" => Some(Self::Up),
151            "down" => Some(Self::Down),
152            "left" => Some(Self::Left),
153            "right" => Some(Self::Right),
154            "accept" | "enter" => Some(Self::Accept),
155            "close" | "esc" | "escape" => Some(Self::Close),
156            _ => None,
157        }
158    }
159
160    pub fn as_str(&self) -> &'static str {
161        match self {
162            Self::Tab => "tab",
163            Self::BackTab => "backtab",
164            Self::Up => "up",
165            Self::Down => "down",
166            Self::Left => "left",
167            Self::Right => "right",
168            Self::Accept => "accept",
169            Self::Close => "close",
170        }
171    }
172}
173
174#[derive(Debug, Clone, Serialize)]
175pub struct CompletionDebugFrame {
176    pub step: String,
177    pub state: CompletionDebug,
178}
179
180#[derive(Debug, Clone, Copy)]
181pub struct CompletionDebugOptions<'a> {
182    pub width: u16,
183    pub height: u16,
184    pub ansi: bool,
185    pub unicode: bool,
186    pub appearance: Option<&'a ReplAppearance>,
187}
188
189impl<'a> CompletionDebugOptions<'a> {
190    pub fn new(width: u16, height: u16) -> Self {
191        Self {
192            width,
193            height,
194            ansi: false,
195            unicode: false,
196            appearance: None,
197        }
198    }
199
200    pub fn ansi(mut self, ansi: bool) -> Self {
201        self.ansi = ansi;
202        self
203    }
204
205    pub fn unicode(mut self, unicode: bool) -> Self {
206        self.unicode = unicode;
207        self
208    }
209
210    pub fn appearance(mut self, appearance: Option<&'a ReplAppearance>) -> Self {
211        self.appearance = appearance;
212        self
213    }
214}
215
216pub fn debug_completion(
217    tree: &CompletionTree,
218    line: &str,
219    cursor: usize,
220    options: CompletionDebugOptions<'_>,
221) -> CompletionDebug {
222    let (editor, mut completer, mut menu) =
223        build_debug_completion_session(tree, line, cursor, options.appearance);
224    let mut editor = editor;
225
226    menu.menu_event(MenuEvent::Activate(false));
227    menu.apply_event(&mut editor, &mut completer);
228
229    snapshot_completion_debug(
230        tree,
231        &mut menu,
232        &editor,
233        options.width,
234        options.height,
235        options.ansi,
236        options.unicode,
237    )
238}
239
240pub fn debug_completion_steps(
241    tree: &CompletionTree,
242    line: &str,
243    cursor: usize,
244    options: CompletionDebugOptions<'_>,
245    steps: &[DebugStep],
246) -> Vec<CompletionDebugFrame> {
247    let (mut editor, mut completer, mut menu) =
248        build_debug_completion_session(tree, line, cursor, options.appearance);
249
250    let steps = steps.to_vec();
251    if steps.is_empty() {
252        return Vec::new();
253    }
254
255    let mut frames = Vec::with_capacity(steps.len());
256    for step in steps {
257        apply_debug_step(step, &mut menu, &mut editor, &mut completer);
258        let state = snapshot_completion_debug(
259            tree,
260            &mut menu,
261            &editor,
262            options.width,
263            options.height,
264            options.ansi,
265            options.unicode,
266        );
267        frames.push(CompletionDebugFrame {
268            step: step.as_str().to_string(),
269            state,
270        });
271    }
272
273    frames
274}
275
276fn build_debug_completion_session(
277    tree: &CompletionTree,
278    line: &str,
279    cursor: usize,
280    appearance: Option<&ReplAppearance>,
281) -> (Editor, ReplCompleter, OspCompletionMenu) {
282    let mut editor = Editor::default();
283    editor.edit_buffer(
284        |buf| {
285            buf.set_buffer(line.to_string());
286            buf.set_insertion_point(cursor.min(buf.get_buffer().len()));
287        },
288        UndoBehavior::CreateUndoPoint,
289    );
290
291    let completer = ReplCompleter::new(Vec::new(), Some(tree.clone()), None);
292    let menu = if let Some(appearance) = appearance {
293        build_completion_menu(appearance)
294    } else {
295        OspCompletionMenu::default()
296    };
297
298    (editor, completer, menu)
299}
300
301fn apply_debug_step(
302    step: DebugStep,
303    menu: &mut OspCompletionMenu,
304    editor: &mut Editor,
305    completer: &mut ReplCompleter,
306) {
307    match step {
308        DebugStep::Tab => {
309            if menu.is_active() {
310                dispatch_menu_event(menu, editor, completer, MenuEvent::NextElement);
311            } else {
312                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
313            }
314        }
315        DebugStep::BackTab => {
316            if menu.is_active() {
317                dispatch_menu_event(menu, editor, completer, MenuEvent::PreviousElement);
318            } else {
319                dispatch_menu_event(menu, editor, completer, MenuEvent::Activate(false));
320            }
321        }
322        DebugStep::Up => {
323            if menu.is_active() {
324                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveUp);
325            }
326        }
327        DebugStep::Down => {
328            if menu.is_active() {
329                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveDown);
330            }
331        }
332        DebugStep::Left => {
333            if menu.is_active() {
334                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveLeft);
335            }
336        }
337        DebugStep::Right => {
338            if menu.is_active() {
339                dispatch_menu_event(menu, editor, completer, MenuEvent::MoveRight);
340            }
341        }
342        DebugStep::Accept => {
343            if menu.is_active() {
344                menu.accept_selection_in_buffer(editor);
345                dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
346            }
347        }
348        DebugStep::Close => {
349            dispatch_menu_event(menu, editor, completer, MenuEvent::Deactivate);
350        }
351    }
352}
353
354fn dispatch_menu_event(
355    menu: &mut OspCompletionMenu,
356    editor: &mut Editor,
357    completer: &mut ReplCompleter,
358    event: MenuEvent,
359) {
360    menu.menu_event(event);
361    menu.apply_event(editor, completer);
362}
363
364fn snapshot_completion_debug(
365    tree: &CompletionTree,
366    menu: &mut OspCompletionMenu,
367    editor: &Editor,
368    width: u16,
369    height: u16,
370    ansi: bool,
371    unicode: bool,
372) -> CompletionDebug {
373    let line = editor.get_buffer().to_string();
374    let cursor = editor.line_buffer().insertion_point();
375    let values = menu.get_values();
376    let engine = CompletionEngine::new(tree.clone());
377    let analysis = engine.analyze(&line, cursor);
378
379    let (stub, replace_range) = if let Some(first) = values.first() {
380        let start = first.span.start;
381        let end = first.span.end;
382        let stub = line.get(start..end).unwrap_or("").to_string();
383        (stub, [start, end])
384    } else {
385        (
386            analysis.cursor.raw_stub.clone(),
387            [
388                analysis.cursor.replace_range.start,
389                analysis.cursor.replace_range.end,
390            ],
391        )
392    };
393
394    let matches = values
395        .iter()
396        .map(|item| CompletionDebugMatch {
397            id: item.value.clone(),
398            label: display_text(item).to_string(),
399            description: item.description.clone(),
400            kind: engine
401                .classify_match(&analysis, &item.value)
402                .as_str()
403                .to_string(),
404        })
405        .collect::<Vec<_>>();
406
407    let MenuDebug {
408        columns,
409        rows,
410        visible_rows,
411        indent,
412        selected_index,
413        selected_row,
414        selected_col,
415        description,
416        description_rendered,
417        styles,
418        rendered,
419    } = debug_snapshot(menu, editor, width, height, ansi);
420
421    let selected = if matches.is_empty() {
422        -1
423    } else {
424        selected_index
425    };
426
427    CompletionDebug {
428        line,
429        cursor,
430        replace_range,
431        stub,
432        matches,
433        selected,
434        selected_row,
435        selected_col,
436        columns,
437        rows,
438        visible_rows,
439        menu_indent: indent,
440        menu_styles: styles,
441        menu_description: description,
442        menu_description_rendered: description_rendered,
443        width,
444        height,
445        unicode,
446        color: ansi,
447        rendered,
448    }
449}
450
451impl ReplPrompt {
452    pub fn simple(left: impl Into<String>) -> Self {
453        Self {
454            left: left.into(),
455            indicator: String::new(),
456        }
457    }
458}
459
460#[derive(Debug, Clone, Default)]
461pub struct ReplAppearance {
462    pub completion_text_style: Option<String>,
463    pub completion_background_style: Option<String>,
464    pub completion_highlight_style: Option<String>,
465    pub command_highlight_style: Option<String>,
466}
467
468struct AutoCompleteEmacs {
469    inner: Emacs,
470    menu_name: String,
471}
472
473impl AutoCompleteEmacs {
474    fn new(inner: Emacs, menu_name: impl Into<String>) -> Self {
475        Self {
476            inner,
477            menu_name: menu_name.into(),
478        }
479    }
480
481    fn should_reopen_menu(commands: &[EditCommand]) -> bool {
482        commands.iter().any(|cmd| {
483            matches!(
484                cmd,
485                EditCommand::InsertChar(_)
486                    | EditCommand::InsertString(_)
487                    | EditCommand::ReplaceChar(_)
488                    | EditCommand::ReplaceChars(_, _)
489                    | EditCommand::Backspace
490                    | EditCommand::Delete
491                    | EditCommand::CutChar
492                    | EditCommand::BackspaceWord
493                    | EditCommand::DeleteWord
494                    | EditCommand::Clear
495                    | EditCommand::ClearToLineEnd
496                    | EditCommand::CutCurrentLine
497                    | EditCommand::CutFromStart
498                    | EditCommand::CutFromLineStart
499                    | EditCommand::CutToEnd
500                    | EditCommand::CutToLineEnd
501                    | EditCommand::CutWordLeft
502                    | EditCommand::CutBigWordLeft
503                    | EditCommand::CutWordRight
504                    | EditCommand::CutBigWordRight
505                    | EditCommand::CutWordRightToNext
506                    | EditCommand::CutBigWordRightToNext
507                    | EditCommand::PasteCutBufferBefore
508                    | EditCommand::PasteCutBufferAfter
509                    | EditCommand::Undo
510                    | EditCommand::Redo
511            )
512        })
513    }
514}
515
516impl EditMode for AutoCompleteEmacs {
517    fn parse_event(&mut self, event: ReedlineRawEvent) -> ReedlineEvent {
518        let parsed = self.inner.parse_event(event);
519        match parsed {
520            ReedlineEvent::Edit(commands) if Self::should_reopen_menu(&commands) => {
521                ReedlineEvent::Multiple(vec![
522                    ReedlineEvent::Edit(commands),
523                    ReedlineEvent::Menu(self.menu_name.clone()),
524                ])
525            }
526            other => other,
527        }
528    }
529
530    fn edit_mode(&self) -> PromptEditMode {
531        self.inner.edit_mode()
532    }
533}
534
535enum SubmissionResult {
536    Noop,
537    Print(String),
538    ReplaceInput(String),
539    Exit(i32),
540    Restart {
541        output: String,
542        reload: ReplReloadKind,
543    },
544}
545
546struct SubmissionContext<'a, F> {
547    history_store: &'a SharedHistory,
548    execute: &'a mut F,
549}
550
551impl<'a, F> SubmissionContext<'a, F> where F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult> {}
552
553fn process_submission<F>(raw: &str, ctx: &mut SubmissionContext<'_, F>) -> Result<SubmissionResult>
554where
555    F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult>,
556{
557    let raw = raw.trim();
558    if raw.is_empty() {
559        return Ok(SubmissionResult::Noop);
560    }
561    let result = match (ctx.execute)(raw, ctx.history_store) {
562        Ok(ReplLineResult::Continue(output)) => SubmissionResult::Print(output),
563        Ok(ReplLineResult::ReplaceInput(buffer)) => SubmissionResult::ReplaceInput(buffer),
564        Ok(ReplLineResult::Exit(code)) => SubmissionResult::Exit(code),
565        Ok(ReplLineResult::Restart { output, reload }) => {
566            SubmissionResult::Restart { output, reload }
567        }
568        Err(err) => {
569            eprintln!("{err}");
570            SubmissionResult::Noop
571        }
572    };
573    Ok(result)
574}
575
576pub fn run_repl<F>(config: ReplRunConfig, mut execute: F) -> Result<ReplRunResult>
577where
578    F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult>,
579{
580    let ReplRunConfig {
581        prompt,
582        completion_words,
583        completion_tree,
584        appearance,
585        history_config,
586        input_mode,
587        prompt_right,
588        line_projector,
589    } = config;
590    let history_store = SharedHistory::new(history_config)?;
591    let mut submission = SubmissionContext {
592        history_store: &history_store,
593        execute: &mut execute,
594    };
595    let prompt = OspPrompt::new(prompt.left, prompt.indicator, prompt_right);
596
597    if matches!(input_mode, ReplInputMode::Basic)
598        || !io::stdin().is_terminal()
599        || !io::stdout().is_terminal()
600    {
601        if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
602            eprintln!("Warning: Input is not a terminal (fd=0).");
603        }
604        run_repl_basic(&prompt, &mut submission)?;
605        return Ok(ReplRunResult::Exit(0));
606    }
607
608    let tree = completion_tree.unwrap_or_else(|| build_repl_tree(&completion_words));
609    let completer = Box::new(ReplCompleter::new(
610        completion_words,
611        Some(tree.clone()),
612        line_projector.clone(),
613    ));
614    let completion_menu = Box::new(build_completion_menu(&appearance));
615    let highlighter = build_repl_highlighter(&tree, &appearance, line_projector);
616    let mut keybindings = default_emacs_keybindings();
617    keybindings.add_binding(
618        KeyModifiers::NONE,
619        KeyCode::Enter,
620        ReedlineEvent::Multiple(vec![ReedlineEvent::Esc, ReedlineEvent::Submit]),
621    );
622    keybindings.add_binding(
623        KeyModifiers::NONE,
624        KeyCode::Tab,
625        ReedlineEvent::UntilFound(vec![
626            ReedlineEvent::Menu("completion_menu".to_string()),
627            ReedlineEvent::MenuNext,
628        ]),
629    );
630    keybindings.add_binding(
631        KeyModifiers::SHIFT,
632        KeyCode::BackTab,
633        ReedlineEvent::UntilFound(vec![
634            ReedlineEvent::Menu("completion_menu".to_string()),
635            ReedlineEvent::MenuPrevious,
636        ]),
637    );
638    keybindings.add_binding(
639        KeyModifiers::CONTROL,
640        KeyCode::Char(' '),
641        ReedlineEvent::Menu("completion_menu".to_string()),
642    );
643    let edit_mode = Box::new(AutoCompleteEmacs::new(
644        Emacs::new(keybindings),
645        "completion_menu",
646    ));
647
648    let mut editor = Reedline::create()
649        .with_completer(completer)
650        .with_menu(ReedlineMenu::EngineCompleter(completion_menu))
651        .with_edit_mode(edit_mode);
652    if let Some(highlighter) = highlighter {
653        editor = editor.with_highlighter(Box::new(highlighter));
654    }
655    editor = editor.with_history(Box::new(history_store.clone()));
656
657    loop {
658        let signal = match editor.read_line(&prompt) {
659            Ok(signal) => signal,
660            Err(err) => {
661                if is_cursor_position_error(&err) {
662                    eprintln!(
663                        "WARNING: terminal does not support cursor position requests; \
664falling back to basic input mode."
665                    );
666                    run_repl_basic(&prompt, &mut submission)?;
667                    return Ok(ReplRunResult::Exit(0));
668                }
669                return Err(err.into());
670            }
671        };
672
673        match signal {
674            Signal::Success(line) => match process_submission(&line, &mut submission)? {
675                SubmissionResult::Noop => continue,
676                SubmissionResult::Print(output) => print!("{output}"),
677                SubmissionResult::ReplaceInput(buffer) => {
678                    editor.run_edit_commands(&[
679                        EditCommand::Clear,
680                        EditCommand::InsertString(buffer),
681                    ]);
682                    continue;
683                }
684                SubmissionResult::Exit(code) => return Ok(ReplRunResult::Exit(code)),
685                SubmissionResult::Restart { output, reload } => {
686                    return Ok(ReplRunResult::Restart { output, reload });
687                }
688            },
689            Signal::CtrlD => return Ok(ReplRunResult::Exit(0)),
690            Signal::CtrlC => continue,
691        }
692    }
693}
694
695fn is_cursor_position_error(err: &io::Error) -> bool {
696    if matches!(err.raw_os_error(), Some(6 | 25)) {
697        return true;
698    }
699    let message = err.to_string().to_ascii_lowercase();
700    message.contains("cursor position could not be read")
701        || message.contains("no such device or address")
702        || message.contains("inappropriate ioctl")
703}
704
705fn run_repl_basic<F>(prompt: &OspPrompt, submission: &mut SubmissionContext<'_, F>) -> Result<()>
706where
707    F: FnMut(&str, &SharedHistory) -> Result<ReplLineResult>,
708{
709    let stdin = io::stdin();
710    loop {
711        print!("{}{}", prompt.left, prompt.indicator);
712        io::stdout().flush()?;
713
714        let mut line = String::new();
715        let read = stdin.read_line(&mut line)?;
716        if read == 0 {
717            break;
718        }
719
720        match process_submission(&line, submission)? {
721            SubmissionResult::Noop => continue,
722            SubmissionResult::Print(output) => print!("{output}"),
723            SubmissionResult::ReplaceInput(buffer) => {
724                println!("{buffer}");
725                continue;
726            }
727            SubmissionResult::Exit(_) => break,
728            SubmissionResult::Restart { output, .. } => {
729                print!("{output}");
730                break;
731            }
732        }
733    }
734    Ok(())
735}
736
737struct ReplCompleter {
738    engine: CompletionEngine,
739    line_projector: Option<LineProjector>,
740}
741
742impl ReplCompleter {
743    fn new(
744        mut words: Vec<String>,
745        completion_tree: Option<CompletionTree>,
746        line_projector: Option<LineProjector>,
747    ) -> Self {
748        words.sort();
749        words.dedup();
750        let tree = completion_tree.unwrap_or_else(|| build_repl_tree(&words));
751        Self {
752            engine: CompletionEngine::new(tree),
753            line_projector,
754        }
755    }
756}
757
758impl Completer for ReplCompleter {
759    fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
760        debug_assert!(
761            pos <= line.len(),
762            "completer received pos {pos} beyond line length {}",
763            line.len()
764        );
765        let projected = self
766            .line_projector
767            .as_ref()
768            .map(|project| project(line))
769            .unwrap_or_else(|| LineProjection::passthrough(line));
770        let (cursor_state, outputs) = self.engine.complete(&projected.line, pos);
771        let span = Span {
772            start: cursor_state.replace_range.start,
773            end: cursor_state.replace_range.end,
774        };
775
776        let mut ranked = Vec::new();
777        let mut has_path_sentinel = false;
778        for output in outputs {
779            match output {
780                SuggestionOutput::Item(item) => ranked.push(item),
781                SuggestionOutput::PathSentinel => has_path_sentinel = true,
782            }
783        }
784
785        let mut suggestions = ranked
786            .into_iter()
787            .filter(|item| !projected.hidden_suggestions.contains(&item.text))
788            .map(|item| Suggestion {
789                value: item.text,
790                description: item.meta,
791                extra: item.display.map(|display| vec![display]),
792                span,
793                append_whitespace: true,
794                ..Suggestion::default()
795            })
796            .collect::<Vec<_>>();
797
798        if has_path_sentinel {
799            suggestions.extend(path_suggestions(&cursor_state.raw_stub, span));
800        }
801
802        suggestions
803    }
804}
805
806pub fn default_pipe_verbs() -> BTreeMap<String, String> {
807    BTreeMap::from([
808        ("F".to_string(), "Filter rows".to_string()),
809        ("P".to_string(), "Project columns".to_string()),
810        ("S".to_string(), "Sort rows".to_string()),
811        ("G".to_string(), "Group rows".to_string()),
812        ("A".to_string(), "Aggregate rows/groups".to_string()),
813        ("L".to_string(), "Limit rows".to_string()),
814        ("Z".to_string(), "Collapse grouped output".to_string()),
815        ("C".to_string(), "Count rows".to_string()),
816        ("Y".to_string(), "Mark output for copy".to_string()),
817        ("H".to_string(), "Show DSL help".to_string()),
818        ("V".to_string(), "Value-only quick search".to_string()),
819        ("K".to_string(), "Key-only quick search".to_string()),
820        ("?".to_string(), "Clean rows / exists filter".to_string()),
821        ("U".to_string(), "Unroll list field".to_string()),
822        ("JQ".to_string(), "Run jq expression".to_string()),
823        ("VAL".to_string(), "Extract values".to_string()),
824        ("VALUE".to_string(), "Extract values".to_string()),
825    ])
826}
827
828fn build_repl_tree(words: &[String]) -> CompletionTree {
829    let suggestions = words
830        .iter()
831        .map(|word| SuggestionEntry::value(word.clone()))
832        .collect::<Vec<_>>();
833    let args = (0..12)
834        .map(|_| ArgNode {
835            suggestions: suggestions.clone(),
836            ..ArgNode::default()
837        })
838        .collect::<Vec<_>>();
839
840    CompletionTree {
841        root: CompletionNode {
842            args,
843            ..CompletionNode::default()
844        },
845        pipe_verbs: default_pipe_verbs(),
846    }
847}
848
849fn build_completion_menu(appearance: &ReplAppearance) -> OspCompletionMenu {
850    let text_color = appearance
851        .completion_text_style
852        .as_deref()
853        .and_then(color_from_style_spec);
854    let background_color = appearance
855        .completion_background_style
856        .as_deref()
857        .and_then(color_from_style_spec);
858    let highlight_color = appearance
859        .completion_highlight_style
860        .as_deref()
861        .and_then(color_from_style_spec);
862
863    OspCompletionMenu::default()
864        .with_name("completion_menu")
865        .with_only_buffer_difference(false)
866        .with_marker("")
867        .with_columns(u16::MAX)
868        .with_max_rows(u16::MAX)
869        .with_description_rows(1)
870        .with_column_padding(2)
871        .with_text_style(style_with_fg_bg(text_color, background_color))
872        .with_description_text_style(style_with_fg_bg(text_color, highlight_color))
873        .with_match_text_style(style_with_fg_bg(highlight_color, background_color))
874        .with_selected_text_style(style_with_fg_bg(highlight_color, text_color))
875        .with_selected_match_text_style(style_with_fg_bg(highlight_color, text_color))
876}
877
878fn build_repl_highlighter(
879    tree: &CompletionTree,
880    appearance: &ReplAppearance,
881    line_projector: Option<LineProjector>,
882) -> Option<ReplHighlighter> {
883    let command_color = appearance
884        .command_highlight_style
885        .as_deref()
886        .and_then(color_from_style_spec);
887    Some(ReplHighlighter::new(
888        tree.clone(),
889        command_color?,
890        line_projector,
891    ))
892}
893
894fn style_with_fg_bg(fg: Option<Color>, bg: Option<Color>) -> Style {
895    let mut style = Style::new();
896    if let Some(fg) = fg {
897        style = style.fg(fg);
898    }
899    if let Some(bg) = bg {
900        style = style.on(bg);
901    }
902    style
903}
904
905pub fn color_from_style_spec(spec: &str) -> Option<Color> {
906    let token = extract_color_token(spec)?;
907    parse_color_token(token)
908}
909
910fn extract_color_token(spec: &str) -> Option<&str> {
911    let attrs = [
912        "bold",
913        "dim",
914        "dimmed",
915        "italic",
916        "underline",
917        "blink",
918        "reverse",
919        "hidden",
920        "strikethrough",
921    ];
922
923    let mut last: Option<&str> = None;
924    for part in spec.split_whitespace() {
925        let token = part
926            .trim()
927            .strip_prefix("fg:")
928            .or_else(|| part.trim().strip_prefix("bg:"))
929            .unwrap_or(part.trim());
930        if token.is_empty() {
931            continue;
932        }
933        if attrs.iter().any(|attr| token.eq_ignore_ascii_case(attr)) {
934            continue;
935        }
936        last = Some(token);
937    }
938    last
939}
940
941fn parse_color_token(token: &str) -> Option<Color> {
942    let normalized = token.trim().to_ascii_lowercase();
943
944    if let Some(value) = normalized.strip_prefix('#') {
945        if value.len() == 6 {
946            let r = u8::from_str_radix(&value[0..2], 16).ok()?;
947            let g = u8::from_str_radix(&value[2..4], 16).ok()?;
948            let b = u8::from_str_radix(&value[4..6], 16).ok()?;
949            return Some(Color::Rgb(r, g, b));
950        }
951        if value.len() == 3 {
952            let r = u8::from_str_radix(&value[0..1], 16).ok()?;
953            let g = u8::from_str_radix(&value[1..2], 16).ok()?;
954            let b = u8::from_str_radix(&value[2..3], 16).ok()?;
955            return Some(Color::Rgb(
956                r.saturating_mul(17),
957                g.saturating_mul(17),
958                b.saturating_mul(17),
959            ));
960        }
961    }
962
963    if let Some(value) = normalized.strip_prefix("ansi")
964        && let Ok(index) = value.parse::<u8>()
965    {
966        return Some(Color::Fixed(index));
967    }
968
969    if let Some(value) = normalized
970        .strip_prefix("rgb(")
971        .and_then(|value| value.strip_suffix(')'))
972    {
973        let mut parts = value.split(',').map(|part| part.trim().parse::<u8>().ok());
974        if let (Some(Some(r)), Some(Some(g)), Some(Some(b))) =
975            (parts.next(), parts.next(), parts.next())
976        {
977            return Some(Color::Rgb(r, g, b));
978        }
979    }
980
981    match normalized.as_str() {
982        "black" => Some(Color::Black),
983        "red" => Some(Color::Red),
984        "green" => Some(Color::Green),
985        "yellow" => Some(Color::Yellow),
986        "blue" => Some(Color::Blue),
987        "magenta" | "purple" => Some(Color::Purple),
988        "cyan" => Some(Color::Cyan),
989        "white" => Some(Color::White),
990        "darkgray" | "dark_gray" | "gray" | "grey" => Some(Color::DarkGray),
991        "lightgray" | "light_gray" | "lightgrey" | "light_grey" => Some(Color::LightGray),
992        "lightred" | "light_red" => Some(Color::LightRed),
993        "lightgreen" | "light_green" => Some(Color::LightGreen),
994        "lightyellow" | "light_yellow" => Some(Color::LightYellow),
995        "lightblue" | "light_blue" => Some(Color::LightBlue),
996        "lightmagenta" | "light_magenta" | "lightpurple" | "light_purple" => {
997            Some(Color::LightPurple)
998        }
999        "lightcyan" | "light_cyan" => Some(Color::LightCyan),
1000        _ => None,
1001    }
1002}
1003
1004#[derive(Debug, Clone, Serialize)]
1005pub(crate) struct CompletionTraceMenuState {
1006    pub selected_index: i64,
1007    pub selected_row: u16,
1008    pub selected_col: u16,
1009    pub active: bool,
1010    pub just_activated: bool,
1011    pub columns: u16,
1012    pub visible_rows: u16,
1013    pub rows: u16,
1014    pub menu_indent: u16,
1015}
1016
1017#[derive(Debug, Clone, Serialize)]
1018struct CompletionTracePayload<'a> {
1019    event: &'a str,
1020    line: &'a str,
1021    cursor: usize,
1022    stub: &'a str,
1023    matches: Vec<String>,
1024    #[serde(skip_serializing_if = "Option::is_none")]
1025    buffer_before: Option<&'a str>,
1026    #[serde(skip_serializing_if = "Option::is_none")]
1027    buffer_after: Option<&'a str>,
1028    #[serde(skip_serializing_if = "Option::is_none")]
1029    cursor_before: Option<usize>,
1030    #[serde(skip_serializing_if = "Option::is_none")]
1031    cursor_after: Option<usize>,
1032    #[serde(skip_serializing_if = "Option::is_none")]
1033    accepted_value: Option<&'a str>,
1034    #[serde(skip_serializing_if = "Option::is_none")]
1035    replace_range: Option<[usize; 2]>,
1036    #[serde(skip_serializing_if = "Option::is_none")]
1037    selected_index: Option<i64>,
1038    #[serde(skip_serializing_if = "Option::is_none")]
1039    selected_row: Option<u16>,
1040    #[serde(skip_serializing_if = "Option::is_none")]
1041    selected_col: Option<u16>,
1042    #[serde(skip_serializing_if = "Option::is_none")]
1043    active: Option<bool>,
1044    #[serde(skip_serializing_if = "Option::is_none")]
1045    just_activated: Option<bool>,
1046    #[serde(skip_serializing_if = "Option::is_none")]
1047    columns: Option<u16>,
1048    #[serde(skip_serializing_if = "Option::is_none")]
1049    visible_rows: Option<u16>,
1050    #[serde(skip_serializing_if = "Option::is_none")]
1051    rows: Option<u16>,
1052    #[serde(skip_serializing_if = "Option::is_none")]
1053    menu_indent: Option<u16>,
1054}
1055
1056#[derive(Debug, Clone)]
1057pub(crate) struct CompletionTraceEvent<'a> {
1058    pub event: &'a str,
1059    pub line: &'a str,
1060    pub cursor: usize,
1061    pub stub: &'a str,
1062    pub matches: Vec<String>,
1063    pub replace_range: Option<[usize; 2]>,
1064    pub menu: Option<CompletionTraceMenuState>,
1065    pub buffer_before: Option<&'a str>,
1066    pub buffer_after: Option<&'a str>,
1067    pub cursor_before: Option<usize>,
1068    pub cursor_after: Option<usize>,
1069    pub accepted_value: Option<&'a str>,
1070}
1071
1072pub(crate) fn trace_completion(trace: CompletionTraceEvent<'_>) {
1073    if !trace_completion_enabled() {
1074        return;
1075    }
1076
1077    let (
1078        selected_index,
1079        selected_row,
1080        selected_col,
1081        active,
1082        just_activated,
1083        columns,
1084        visible_rows,
1085        rows,
1086        menu_indent,
1087    ) = if let Some(menu) = trace.menu {
1088        (
1089            Some(menu.selected_index),
1090            Some(menu.selected_row),
1091            Some(menu.selected_col),
1092            Some(menu.active),
1093            Some(menu.just_activated),
1094            Some(menu.columns),
1095            Some(menu.visible_rows),
1096            Some(menu.rows),
1097            Some(menu.menu_indent),
1098        )
1099    } else {
1100        (None, None, None, None, None, None, None, None, None)
1101    };
1102
1103    let payload = CompletionTracePayload {
1104        event: trace.event,
1105        line: trace.line,
1106        cursor: trace.cursor,
1107        stub: trace.stub,
1108        matches: trace.matches,
1109        buffer_before: trace.buffer_before,
1110        buffer_after: trace.buffer_after,
1111        cursor_before: trace.cursor_before,
1112        cursor_after: trace.cursor_after,
1113        accepted_value: trace.accepted_value,
1114        replace_range: trace.replace_range,
1115        selected_index,
1116        selected_row,
1117        selected_col,
1118        active,
1119        just_activated,
1120        columns,
1121        visible_rows,
1122        rows,
1123        menu_indent,
1124    };
1125
1126    let serialized = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
1127    if let Ok(path) = std::env::var("OSP_REPL_TRACE_PATH")
1128        && !path.trim().is_empty()
1129    {
1130        if let Ok(mut file) = std::fs::OpenOptions::new()
1131            .create(true)
1132            .append(true)
1133            .open(path)
1134        {
1135            let _ = writeln!(file, "{serialized}");
1136        }
1137    } else {
1138        eprintln!("{serialized}");
1139    }
1140}
1141
1142pub(crate) fn trace_completion_enabled() -> bool {
1143    let Ok(raw) = std::env::var("OSP_REPL_TRACE_COMPLETION") else {
1144        return false;
1145    };
1146    !matches!(
1147        raw.trim().to_ascii_lowercase().as_str(),
1148        "" | "0" | "false" | "off" | "no"
1149    )
1150}
1151
1152fn path_suggestions(stub: &str, span: Span) -> Vec<Suggestion> {
1153    let (lookup, insert_prefix, typed_prefix) = split_path_stub(stub);
1154    let read_dir = std::fs::read_dir(&lookup);
1155    let Ok(entries) = read_dir else {
1156        return Vec::new();
1157    };
1158
1159    let mut out = Vec::new();
1160    for entry in entries.flatten() {
1161        let file_name = entry.file_name().to_string_lossy().to_string();
1162        if !file_name.starts_with(&typed_prefix) {
1163            continue;
1164        }
1165
1166        let path = entry.path();
1167        let is_dir = path.is_dir();
1168        let suffix = if is_dir { "/" } else { "" };
1169
1170        out.push(Suggestion {
1171            value: format!("{insert_prefix}{file_name}{suffix}"),
1172            description: Some(if is_dir { "dir" } else { "file" }.to_string()),
1173            span,
1174            append_whitespace: !is_dir,
1175            ..Suggestion::default()
1176        });
1177    }
1178
1179    out
1180}
1181
1182fn split_path_stub(stub: &str) -> (PathBuf, String, String) {
1183    if stub.is_empty() {
1184        return (PathBuf::from("."), String::new(), String::new());
1185    }
1186
1187    let expanded = expand_home(stub);
1188    let mut lookup = PathBuf::from(&expanded);
1189    if stub.ends_with('/') {
1190        return (lookup, stub.to_string(), String::new());
1191    }
1192
1193    let typed_prefix = Path::new(stub)
1194        .file_name()
1195        .and_then(|value| value.to_str())
1196        .map(ToOwned::to_owned)
1197        .unwrap_or_default();
1198
1199    let insert_prefix = match stub.rfind('/') {
1200        Some(index) => stub[..=index].to_string(),
1201        None => String::new(),
1202    };
1203
1204    if let Some(parent) = lookup.parent() {
1205        if parent.as_os_str().is_empty() {
1206            lookup = PathBuf::from(".");
1207        } else {
1208            lookup = parent.to_path_buf();
1209        }
1210    } else {
1211        lookup = PathBuf::from(".");
1212    }
1213
1214    (lookup, insert_prefix, typed_prefix)
1215}
1216
1217fn expand_home(path: &str) -> String {
1218    if path == "~" {
1219        return std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
1220    }
1221    if let Some(rest) = path.strip_prefix("~/")
1222        && let Ok(home) = std::env::var("HOME")
1223    {
1224        return format!("{home}/{rest}");
1225    }
1226    path.to_string()
1227}
1228
1229struct OspPrompt {
1230    left: String,
1231    indicator: String,
1232    right: Option<PromptRightRenderer>,
1233}
1234
1235impl OspPrompt {
1236    fn new(left: String, indicator: String, right: Option<PromptRightRenderer>) -> Self {
1237        Self {
1238            left,
1239            indicator,
1240            right,
1241        }
1242    }
1243}
1244
1245impl Prompt for OspPrompt {
1246    fn render_prompt_left(&self) -> Cow<'_, str> {
1247        Cow::Borrowed(self.left.as_str())
1248    }
1249
1250    fn render_prompt_right(&self) -> Cow<'_, str> {
1251        match &self.right {
1252            Some(render) => Cow::Owned(render()),
1253            None => Cow::Borrowed(""),
1254        }
1255    }
1256
1257    fn render_prompt_indicator(&self, _prompt_mode: PromptEditMode) -> Cow<'_, str> {
1258        Cow::Borrowed(self.indicator.as_str())
1259    }
1260
1261    fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
1262        Cow::Borrowed("... ")
1263    }
1264
1265    fn render_prompt_history_search_indicator(
1266        &self,
1267        history_search: PromptHistorySearch,
1268    ) -> Cow<'_, str> {
1269        let prefix = match history_search.status {
1270            PromptHistorySearchStatus::Passing => "",
1271            PromptHistorySearchStatus::Failing => "failing ",
1272        };
1273        Cow::Owned(format!(
1274            "({prefix}reverse-search: {}) ",
1275            history_search.term
1276        ))
1277    }
1278
1279    fn get_prompt_color(&self) -> reedline::Color {
1280        reedline::Color::Reset
1281    }
1282
1283    fn get_indicator_color(&self) -> reedline::Color {
1284        reedline::Color::Reset
1285    }
1286}
1287
1288#[cfg(test)]
1289mod tests {
1290    use crate::completion::{CompletionNode, CompletionTree, FlagNode};
1291    use nu_ansi_term::Color;
1292    use reedline::{
1293        Completer, EditCommand, Prompt, PromptEditMode, PromptHistorySearch,
1294        PromptHistorySearchStatus,
1295    };
1296    use std::collections::BTreeSet;
1297    use std::io;
1298    use std::path::PathBuf;
1299    use std::sync::{Arc, Mutex, OnceLock};
1300
1301    use super::{
1302        AutoCompleteEmacs, CompletionDebugOptions, DebugStep, HistoryConfig, HistoryShellContext,
1303        OspPrompt, PromptRightRenderer, ReplAppearance, ReplCompleter, ReplLineResult, ReplPrompt,
1304        ReplReloadKind, ReplRunResult, SharedHistory, SubmissionContext, SubmissionResult,
1305        build_repl_highlighter, color_from_style_spec, debug_completion, debug_completion_steps,
1306        default_pipe_verbs, expand_history, expand_home, is_cursor_position_error,
1307        path_suggestions, process_submission, split_path_stub, trace_completion,
1308        trace_completion_enabled,
1309    };
1310    use crate::repl::LineProjection;
1311
1312    fn env_lock() -> &'static Mutex<()> {
1313        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1314        LOCK.get_or_init(|| Mutex::new(()))
1315    }
1316
1317    fn completion_tree_with_config_show() -> CompletionTree {
1318        let mut config = CompletionNode::default();
1319        config
1320            .children
1321            .insert("show".to_string(), CompletionNode::default());
1322
1323        let mut root = CompletionNode::default();
1324        root.children.insert("config".to_string(), config);
1325        CompletionTree {
1326            root,
1327            ..CompletionTree::default()
1328        }
1329    }
1330
1331    fn disabled_history() -> SharedHistory {
1332        SharedHistory::new(
1333            HistoryConfig {
1334                path: None,
1335                max_entries: 0,
1336                enabled: false,
1337                dedupe: false,
1338                profile_scoped: false,
1339                exclude_patterns: Vec::new(),
1340                profile: None,
1341                terminal: None,
1342                shell_context: HistoryShellContext::default(),
1343            }
1344            .normalized(),
1345        )
1346        .expect("history config should build")
1347    }
1348
1349    #[test]
1350    fn expands_double_bang() {
1351        let history = vec!["ldap user oistes".to_string()];
1352        assert_eq!(
1353            expand_history("!!", &history, None, false),
1354            Some("ldap user oistes".to_string())
1355        );
1356    }
1357
1358    #[test]
1359    fn expands_relative() {
1360        let history = vec![
1361            "ldap user oistes".to_string(),
1362            "ldap netgroup ucore".to_string(),
1363        ];
1364        assert_eq!(
1365            expand_history("!-1", &history, None, false),
1366            Some("ldap netgroup ucore".to_string())
1367        );
1368    }
1369
1370    #[test]
1371    fn expands_prefix() {
1372        let history = vec![
1373            "ldap user oistes".to_string(),
1374            "ldap netgroup ucore".to_string(),
1375        ];
1376        assert_eq!(
1377            expand_history("!ldap user", &history, None, false),
1378            Some("ldap user oistes".to_string())
1379        );
1380    }
1381
1382    #[test]
1383    fn submission_delegates_help_and_exit_to_host() {
1384        let history = disabled_history();
1385        let mut seen = Vec::new();
1386        let mut execute = |line: &str, _: &SharedHistory| {
1387            seen.push(line.to_string());
1388            Ok(match line {
1389                "help" => ReplLineResult::Continue("host help".to_string()),
1390                "!!" => ReplLineResult::ReplaceInput("ldap user oistes".to_string()),
1391                "exit" => ReplLineResult::Exit(7),
1392                other => ReplLineResult::Continue(other.to_string()),
1393            })
1394        };
1395        let mut submission = SubmissionContext {
1396            history_store: &history,
1397            execute: &mut execute,
1398        };
1399
1400        let help = process_submission("help", &mut submission).expect("help should succeed");
1401        let bang = process_submission("!!", &mut submission).expect("bang should succeed");
1402        let exit = process_submission("exit", &mut submission).expect("exit should succeed");
1403
1404        assert!(matches!(help, SubmissionResult::Print(text) if text == "host help"));
1405        assert!(matches!(bang, SubmissionResult::ReplaceInput(text) if text == "ldap user oistes"));
1406        assert!(matches!(exit, SubmissionResult::Exit(7)));
1407        assert_eq!(
1408            seen,
1409            vec!["help".to_string(), "!!".to_string(), "exit".to_string()]
1410        );
1411    }
1412
1413    #[test]
1414    fn completer_suggests_word_prefixes() {
1415        let mut completer = ReplCompleter::new(
1416            vec![
1417                "ldap".to_string(),
1418                "plugins".to_string(),
1419                "theme".to_string(),
1420            ],
1421            None,
1422            None,
1423        );
1424
1425        let completions = completer.complete("ld", 2);
1426        let values = completions
1427            .into_iter()
1428            .map(|suggestion| suggestion.value)
1429            .collect::<Vec<_>>();
1430        assert_eq!(values, vec!["ldap".to_string()]);
1431    }
1432
1433    #[test]
1434    fn completer_supports_fuzzy_word_matching() {
1435        let mut completer = ReplCompleter::new(
1436            vec![
1437                "ldap".to_string(),
1438                "plugins".to_string(),
1439                "theme".to_string(),
1440            ],
1441            None,
1442            None,
1443        );
1444
1445        let completions = completer.complete("lap", 3);
1446        let values = completions
1447            .into_iter()
1448            .map(|suggestion| suggestion.value)
1449            .collect::<Vec<_>>();
1450        assert!(values.contains(&"ldap".to_string()));
1451    }
1452
1453    #[test]
1454    fn completer_suggests_pipe_verbs_after_pipe() {
1455        let mut completer = ReplCompleter::new(vec!["ldap".to_string()], None, None);
1456        let completions = completer.complete("ldap user | F", "ldap user | F".len());
1457        let values = completions
1458            .into_iter()
1459            .map(|suggestion| suggestion.value)
1460            .collect::<Vec<_>>();
1461        assert!(values.contains(&"F".to_string()));
1462    }
1463
1464    #[test]
1465    fn default_pipe_verbs_include_extended_dsl_surface() {
1466        let verbs = default_pipe_verbs();
1467
1468        assert_eq!(
1469            verbs.get("?"),
1470            Some(&"Clean rows / exists filter".to_string())
1471        );
1472        assert_eq!(verbs.get("JQ"), Some(&"Run jq expression".to_string()));
1473        assert_eq!(verbs.get("VALUE"), Some(&"Extract values".to_string()));
1474    }
1475
1476    #[test]
1477    fn completer_with_tree_does_not_fallback_to_word_list() {
1478        let mut root = CompletionNode::default();
1479        root.children
1480            .insert("config".to_string(), CompletionNode::default());
1481        let tree = CompletionTree {
1482            root,
1483            ..CompletionTree::default()
1484        };
1485
1486        let mut completer = ReplCompleter::new(vec!["ldap".to_string()], Some(tree), None);
1487        let completions = completer.complete("zzz", 3);
1488        assert!(completions.is_empty());
1489    }
1490
1491    #[test]
1492    fn completer_can_use_projected_line_for_host_flags_unit() {
1493        let tree = completion_tree_with_config_show();
1494        let projector = Arc::new(|line: &str| {
1495            LineProjection::passthrough(line.replacen("--json", "      ", 1))
1496        });
1497        let mut completer = ReplCompleter::new(Vec::new(), Some(tree), Some(projector));
1498
1499        let completions = completer.complete("--json config sh", "--json config sh".len());
1500        let values = completions
1501            .into_iter()
1502            .map(|suggestion| suggestion.value)
1503            .collect::<Vec<_>>();
1504
1505        assert!(values.contains(&"show".to_string()));
1506    }
1507
1508    #[test]
1509    fn completer_hides_suggestions_requested_by_projection_unit() {
1510        let mut root = CompletionNode::default();
1511        root.flags
1512            .insert("--json".to_string(), FlagNode::new().flag_only());
1513        root.flags
1514            .insert("--debug".to_string(), FlagNode::new().flag_only());
1515        let tree = CompletionTree {
1516            root,
1517            ..CompletionTree::default()
1518        };
1519        let projector = Arc::new(|line: &str| {
1520            let mut hidden = BTreeSet::new();
1521            hidden.insert("--json".to_string());
1522            LineProjection {
1523                line: line.to_string(),
1524                hidden_suggestions: hidden,
1525            }
1526        });
1527        let mut completer = ReplCompleter::new(Vec::new(), Some(tree), Some(projector));
1528
1529        let values = completer
1530            .complete("-", 1)
1531            .into_iter()
1532            .map(|suggestion| suggestion.value)
1533            .collect::<Vec<_>>();
1534
1535        assert!(!values.contains(&"--json".to_string()));
1536        assert!(values.contains(&"--debug".to_string()));
1537    }
1538
1539    #[test]
1540    fn completer_uses_engine_metadata_for_subcommands() {
1541        let mut ldap = CompletionNode {
1542            tooltip: Some("Directory lookup".to_string()),
1543            ..CompletionNode::default()
1544        };
1545        ldap.children
1546            .insert("user".to_string(), CompletionNode::default());
1547        ldap.children
1548            .insert("host".to_string(), CompletionNode::default());
1549
1550        let tree = CompletionTree {
1551            root: CompletionNode::default().with_child("ldap", ldap),
1552            ..CompletionTree::default()
1553        };
1554
1555        let mut completer = ReplCompleter::new(Vec::new(), Some(tree), None);
1556        let completion = completer
1557            .complete("ld", 2)
1558            .into_iter()
1559            .find(|item| item.value == "ldap")
1560            .expect("ldap completion should exist");
1561
1562        assert!(completion.description.as_deref().is_some_and(|value| {
1563            value.contains("Directory lookup")
1564                && value.contains("subcommands:")
1565                && value.contains("host")
1566                && value.contains("user")
1567        }));
1568    }
1569
1570    #[test]
1571    fn color_parser_extracts_hex_and_named_colors() {
1572        assert_eq!(
1573            color_from_style_spec("bold #ff79c6"),
1574            Some(Color::Rgb(255, 121, 198))
1575        );
1576        assert_eq!(
1577            color_from_style_spec("fg:cyan underline"),
1578            Some(Color::Cyan)
1579        );
1580        assert_eq!(color_from_style_spec("bg:ansi141"), Some(Color::Fixed(141)));
1581        assert_eq!(
1582            color_from_style_spec("fg:rgb(80,250,123)"),
1583            Some(Color::Rgb(80, 250, 123))
1584        );
1585        assert!(color_from_style_spec("not-a-color").is_none());
1586    }
1587
1588    #[test]
1589    fn split_path_stub_without_slash_uses_current_directory_lookup() {
1590        let (lookup, insert_prefix, typed_prefix) = super::split_path_stub("do");
1591
1592        assert_eq!(lookup, PathBuf::from("."));
1593        assert_eq!(insert_prefix, "");
1594        assert_eq!(typed_prefix, "do");
1595    }
1596
1597    #[test]
1598    fn debug_step_parse_round_trips_known_values_unit() {
1599        assert_eq!(DebugStep::Tab.as_str(), "tab");
1600        assert_eq!(DebugStep::Up.as_str(), "up");
1601        assert_eq!(DebugStep::Down.as_str(), "down");
1602        assert_eq!(DebugStep::Left.as_str(), "left");
1603        assert_eq!(DebugStep::parse("shift-tab"), Some(DebugStep::BackTab));
1604        assert_eq!(DebugStep::parse("ENTER"), Some(DebugStep::Accept));
1605        assert_eq!(DebugStep::parse("esc"), Some(DebugStep::Close));
1606        assert_eq!(DebugStep::Right.as_str(), "right");
1607        assert_eq!(DebugStep::parse("wat"), None);
1608    }
1609
1610    #[test]
1611    fn debug_completion_and_steps_surface_menu_state_unit() {
1612        let tree = completion_tree_with_config_show();
1613        let debug = debug_completion(
1614            &tree,
1615            "config sh",
1616            "config sh".len(),
1617            CompletionDebugOptions::new(80, 6),
1618        );
1619        assert_eq!(debug.stub, "sh");
1620        assert!(debug.matches.iter().any(|item| item.id == "show"));
1621
1622        let frames = debug_completion_steps(
1623            &tree,
1624            "config sh",
1625            "config sh".len(),
1626            CompletionDebugOptions::new(80, 6),
1627            &[DebugStep::Tab, DebugStep::Accept],
1628        );
1629        assert_eq!(frames.len(), 2);
1630        assert_eq!(frames[0].step, "tab");
1631        assert!(frames[0].state.matches.iter().any(|item| item.id == "show"));
1632        assert_eq!(frames[1].step, "accept");
1633        assert_eq!(frames[1].state.line, "config show ");
1634    }
1635
1636    #[test]
1637    fn debug_completion_options_and_empty_steps_cover_builder_paths_unit() {
1638        let appearance = ReplAppearance {
1639            completion_text_style: Some("white".to_string()),
1640            completion_background_style: Some("black".to_string()),
1641            completion_highlight_style: Some("cyan".to_string()),
1642            command_highlight_style: Some("green".to_string()),
1643        };
1644        let options = CompletionDebugOptions::new(90, 12)
1645            .ansi(true)
1646            .unicode(true)
1647            .appearance(Some(&appearance));
1648
1649        assert!(options.ansi);
1650        assert!(options.unicode);
1651        assert!(options.appearance.is_some());
1652
1653        let tree = completion_tree_with_config_show();
1654        let frames = debug_completion_steps(&tree, "config sh", 9, options, &[]);
1655        assert!(frames.is_empty());
1656    }
1657
1658    #[test]
1659    fn debug_completion_navigation_variants_and_empty_matches_are_stable_unit() {
1660        let tree = completion_tree_with_config_show();
1661        let frames = debug_completion_steps(
1662            &tree,
1663            "config sh",
1664            "config sh".len(),
1665            CompletionDebugOptions::new(80, 6),
1666            &[
1667                DebugStep::Tab,
1668                DebugStep::Down,
1669                DebugStep::Right,
1670                DebugStep::Left,
1671                DebugStep::Up,
1672                DebugStep::BackTab,
1673                DebugStep::Close,
1674            ],
1675        );
1676        assert_eq!(frames.len(), 7);
1677        assert_eq!(
1678            frames.last().map(|frame| frame.step.as_str()),
1679            Some("close")
1680        );
1681
1682        let debug = debug_completion(&tree, "zzz", 3, CompletionDebugOptions::new(80, 6));
1683        assert!(debug.matches.is_empty());
1684        assert_eq!(debug.selected, -1);
1685    }
1686
1687    #[test]
1688    fn autocomplete_policy_and_path_helpers_cover_non_happy_paths_unit() {
1689        assert!(AutoCompleteEmacs::should_reopen_menu(&[
1690            EditCommand::InsertChar('x')
1691        ]));
1692        assert!(!AutoCompleteEmacs::should_reopen_menu(&[
1693            EditCommand::MoveToStart { select: false }
1694        ]));
1695
1696        let missing = path_suggestions(
1697            "/definitely/not/a/real/dir/",
1698            reedline::Span { start: 0, end: 0 },
1699        );
1700        assert!(missing.is_empty());
1701
1702        let (lookup, insert_prefix, typed_prefix) = split_path_stub("/tmp/demo/");
1703        assert_eq!(lookup, PathBuf::from("/tmp/demo/"));
1704        assert_eq!(insert_prefix, "/tmp/demo/");
1705        assert!(typed_prefix.is_empty());
1706    }
1707
1708    #[test]
1709    fn completion_debug_options_builders_and_empty_steps_unit() {
1710        let appearance = super::ReplAppearance {
1711            completion_text_style: Some("cyan".to_string()),
1712            ..Default::default()
1713        };
1714        let options = CompletionDebugOptions::new(120, 40)
1715            .ansi(true)
1716            .unicode(true)
1717            .appearance(Some(&appearance));
1718
1719        assert_eq!(options.width, 120);
1720        assert_eq!(options.height, 40);
1721        assert!(options.ansi);
1722        assert!(options.unicode);
1723        assert!(options.appearance.is_some());
1724
1725        let tree = completion_tree_with_config_show();
1726        let frames = debug_completion_steps(&tree, "config sh", 9, options, &[]);
1727        assert!(frames.is_empty());
1728    }
1729
1730    #[test]
1731    fn debug_completion_navigation_steps_cover_menu_branches_unit() {
1732        let tree = completion_tree_with_config_show();
1733        let frames = debug_completion_steps(
1734            &tree,
1735            "config sh",
1736            9,
1737            CompletionDebugOptions::new(80, 6),
1738            &[
1739                DebugStep::Tab,
1740                DebugStep::Down,
1741                DebugStep::Right,
1742                DebugStep::Left,
1743                DebugStep::Up,
1744                DebugStep::BackTab,
1745                DebugStep::Close,
1746            ],
1747        );
1748
1749        assert_eq!(frames.len(), 7);
1750        assert_eq!(frames[0].step, "tab");
1751        assert_eq!(frames[1].step, "down");
1752        assert_eq!(frames[2].step, "right");
1753        assert_eq!(frames[3].step, "left");
1754        assert_eq!(frames[4].step, "up");
1755        assert_eq!(frames[5].step, "backtab");
1756        assert_eq!(frames[6].step, "close");
1757    }
1758
1759    #[test]
1760    fn debug_completion_without_matches_reports_unmatched_cursor_state_unit() {
1761        let tree = completion_tree_with_config_show();
1762        let debug = debug_completion(&tree, "zzz", 99, CompletionDebugOptions::new(80, 6));
1763
1764        assert_eq!(debug.line, "zzz");
1765        assert_eq!(debug.cursor, 3);
1766        assert!(debug.matches.is_empty());
1767        assert_eq!(debug.selected, -1);
1768        assert_eq!(debug.stub, "zzz");
1769        assert_eq!(debug.replace_range, [0, 3]);
1770    }
1771
1772    #[test]
1773    fn autocomplete_emacs_reopens_for_edits_but_not_movement_unit() {
1774        assert!(super::AutoCompleteEmacs::should_reopen_menu(&[
1775            EditCommand::InsertChar('x')
1776        ]));
1777        assert!(super::AutoCompleteEmacs::should_reopen_menu(&[
1778            EditCommand::BackspaceWord
1779        ]));
1780        assert!(!super::AutoCompleteEmacs::should_reopen_menu(&[
1781            EditCommand::MoveLeft { select: false }
1782        ]));
1783        assert!(!super::AutoCompleteEmacs::should_reopen_menu(&[
1784            EditCommand::MoveToLineEnd { select: false }
1785        ]));
1786    }
1787
1788    #[test]
1789    fn process_submission_handles_restart_and_error_paths_unit() {
1790        let history = disabled_history();
1791
1792        let mut restart_execute = |_line: &str, _: &SharedHistory| {
1793            Ok(ReplLineResult::Restart {
1794                output: "restarting".to_string(),
1795                reload: ReplReloadKind::WithIntro,
1796            })
1797        };
1798        let mut submission = SubmissionContext {
1799            history_store: &history,
1800            execute: &mut restart_execute,
1801        };
1802        let restart =
1803            process_submission("config set", &mut submission).expect("restart should map");
1804        assert!(matches!(
1805            restart,
1806            SubmissionResult::Restart {
1807                output,
1808                reload: ReplReloadKind::WithIntro
1809            } if output == "restarting"
1810        ));
1811
1812        let mut failing_execute =
1813            |_line: &str, _: &SharedHistory| -> anyhow::Result<ReplLineResult> {
1814                Err(anyhow::anyhow!("boom"))
1815            };
1816        let mut failing_submission = SubmissionContext {
1817            history_store: &history,
1818            execute: &mut failing_execute,
1819        };
1820        let result = process_submission("broken", &mut failing_submission)
1821            .expect("error should be absorbed");
1822        assert!(matches!(result, SubmissionResult::Noop));
1823
1824        let mut noop_execute =
1825            |_line: &str, _: &SharedHistory| Ok(ReplLineResult::Continue("ignored".to_string()));
1826        let mut noop_submission = SubmissionContext {
1827            history_store: &history,
1828            execute: &mut noop_execute,
1829        };
1830        let result =
1831            process_submission("   ", &mut noop_submission).expect("blank lines should noop");
1832        assert!(matches!(result, SubmissionResult::Noop));
1833    }
1834
1835    #[test]
1836    fn highlighter_builder_requires_command_color_unit() {
1837        let tree = completion_tree_with_config_show();
1838        let none = build_repl_highlighter(&tree, &super::ReplAppearance::default(), None);
1839        assert!(none.is_none());
1840
1841        let some = build_repl_highlighter(
1842            &tree,
1843            &super::ReplAppearance {
1844                command_highlight_style: Some("green".to_string()),
1845                ..Default::default()
1846            },
1847            None,
1848        );
1849        assert!(some.is_some());
1850    }
1851
1852    #[test]
1853    fn path_suggestions_distinguish_files_and_directories_unit() {
1854        let root = make_temp_dir("osp-repl-paths");
1855        std::fs::write(root.join("alpha.txt"), "x").expect("file should be written");
1856        std::fs::create_dir_all(root.join("alpine")).expect("dir should be created");
1857        let stub = format!("{}/al", root.display());
1858
1859        let suggestions = path_suggestions(
1860            &stub,
1861            reedline::Span {
1862                start: 0,
1863                end: stub.len(),
1864            },
1865        );
1866        let values = suggestions
1867            .iter()
1868            .map(|item| {
1869                (
1870                    item.value.clone(),
1871                    item.description.clone(),
1872                    item.append_whitespace,
1873                )
1874            })
1875            .collect::<Vec<_>>();
1876
1877        assert!(values.iter().any(|(value, desc, append)| {
1878            value.ends_with("alpha.txt") && desc.as_deref() == Some("file") && *append
1879        }));
1880        assert!(values.iter().any(|(value, desc, append)| {
1881            value.ends_with("alpine/") && desc.as_deref() == Some("dir") && !*append
1882        }));
1883    }
1884
1885    #[test]
1886    fn trace_completion_writes_jsonl_when_enabled_unit() {
1887        let _guard = env_lock().lock().expect("env lock should not be poisoned");
1888        let temp_dir = make_temp_dir("osp-repl-trace");
1889        let trace_path = temp_dir.join("trace.jsonl");
1890        let previous_enabled = std::env::var("OSP_REPL_TRACE_COMPLETION").ok();
1891        let previous_path = std::env::var("OSP_REPL_TRACE_PATH").ok();
1892        set_env_var_for_test("OSP_REPL_TRACE_COMPLETION", "1");
1893        set_env_var_for_test("OSP_REPL_TRACE_PATH", &trace_path);
1894
1895        assert!(trace_completion_enabled());
1896        trace_completion(super::CompletionTraceEvent {
1897            event: "complete",
1898            line: "config sh",
1899            cursor: 9,
1900            stub: "sh",
1901            matches: vec!["show".to_string()],
1902            replace_range: Some([7, 9]),
1903            menu: None,
1904            buffer_before: None,
1905            buffer_after: None,
1906            cursor_before: None,
1907            cursor_after: None,
1908            accepted_value: None,
1909        });
1910
1911        let contents = std::fs::read_to_string(&trace_path).expect("trace file should exist");
1912        assert!(contents.contains("\"event\":\"complete\""));
1913        assert!(contents.contains("\"stub\":\"sh\""));
1914
1915        restore_env("OSP_REPL_TRACE_COMPLETION", previous_enabled);
1916        restore_env("OSP_REPL_TRACE_PATH", previous_path);
1917    }
1918
1919    #[test]
1920    fn trace_completion_enabled_recognizes_falsey_values_unit() {
1921        let _guard = env_lock().lock().expect("env lock should not be poisoned");
1922        let previous = std::env::var("OSP_REPL_TRACE_COMPLETION").ok();
1923
1924        set_env_var_for_test("OSP_REPL_TRACE_COMPLETION", "off");
1925        assert!(!trace_completion_enabled());
1926        set_env_var_for_test("OSP_REPL_TRACE_COMPLETION", "yes");
1927        assert!(trace_completion_enabled());
1928
1929        restore_env("OSP_REPL_TRACE_COMPLETION", previous);
1930    }
1931
1932    #[test]
1933    fn cursor_position_errors_are_recognized_unit() {
1934        assert!(is_cursor_position_error(&io::Error::from_raw_os_error(25)));
1935        assert!(is_cursor_position_error(&io::Error::other(
1936            "Cursor position could not be read"
1937        )));
1938        assert!(!is_cursor_position_error(&io::Error::other(
1939            "permission denied"
1940        )));
1941    }
1942
1943    #[test]
1944    fn expand_home_and_prompt_renderers_behave_unit() {
1945        let _guard = env_lock().lock().expect("env lock should not be poisoned");
1946        let previous_home = std::env::var("HOME").ok();
1947        set_env_var_for_test("HOME", "/tmp/osp-home");
1948        assert_eq!(expand_home("~"), "/tmp/osp-home");
1949        assert_eq!(expand_home("~/cache"), "/tmp/osp-home/cache");
1950        assert_eq!(expand_home("/etc/hosts"), "/etc/hosts");
1951
1952        let right: PromptRightRenderer = Arc::new(|| "rhs".to_string());
1953        let prompt = OspPrompt::new("left".to_string(), "> ".to_string(), Some(right));
1954        assert_eq!(prompt.render_prompt_left(), "left");
1955        assert_eq!(prompt.render_prompt_right(), "rhs");
1956        assert_eq!(
1957            prompt.render_prompt_indicator(PromptEditMode::Default),
1958            "> "
1959        );
1960        assert_eq!(prompt.render_prompt_multiline_indicator(), "... ");
1961        assert_eq!(
1962            prompt.render_prompt_history_search_indicator(PromptHistorySearch {
1963                status: PromptHistorySearchStatus::Passing,
1964                term: "ldap".to_string(),
1965            }),
1966            "(reverse-search: ldap) "
1967        );
1968
1969        let simple = ReplPrompt::simple("osp");
1970        assert_eq!(simple.left, "osp");
1971        assert!(simple.indicator.is_empty());
1972
1973        let restart = ReplRunResult::Restart {
1974            output: "x".to_string(),
1975            reload: ReplReloadKind::Default,
1976        };
1977        assert!(matches!(
1978            restart,
1979            ReplRunResult::Restart {
1980                output,
1981                reload: ReplReloadKind::Default
1982            } if output == "x"
1983        ));
1984
1985        restore_env("HOME", previous_home);
1986    }
1987
1988    fn make_temp_dir(prefix: &str) -> PathBuf {
1989        let mut dir = std::env::temp_dir();
1990        let nonce = std::time::SystemTime::now()
1991            .duration_since(std::time::UNIX_EPOCH)
1992            .expect("time should be valid")
1993            .as_nanos();
1994        dir.push(format!("{prefix}-{nonce}"));
1995        std::fs::create_dir_all(&dir).expect("temp dir should be created");
1996        dir
1997    }
1998
1999    fn restore_env(key: &str, value: Option<String>) {
2000        if let Some(value) = value {
2001            set_env_var_for_test(key, value);
2002        } else {
2003            remove_env_var_for_test(key);
2004        }
2005    }
2006
2007    fn set_env_var_for_test(key: &str, value: impl AsRef<std::ffi::OsStr>) {
2008        // Test-only environment mutation is process-global on Rust 2024.
2009        // Keep the unsafe boundary explicit and local to these regression
2010        // tests instead of spreading raw calls through the module.
2011        unsafe {
2012            std::env::set_var(key, value);
2013        }
2014    }
2015
2016    fn remove_env_var_for_test(key: &str) {
2017        // See `set_env_var_for_test`; these tests intentionally restore the
2018        // process environment after probing env-dependent behavior.
2019        unsafe {
2020            std::env::remove_var(key);
2021        }
2022    }
2023}