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