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 unsafe {
2012 std::env::set_var(key, value);
2013 }
2014 }
2015
2016 fn remove_env_var_for_test(key: &str) {
2017 unsafe {
2020 std::env::remove_var(key);
2021 }
2022 }
2023}