1use std::collections::{BTreeMap, BTreeSet};
9use std::io::Write;
10use std::path::{Path, PathBuf};
11
12use crate::completion::{
13 ArgNode, CompletionEngine, CompletionNode, CompletionTree, SuggestionEntry, SuggestionOutput,
14};
15use crate::core::fuzzy::fold_case;
16use crate::core::shell_words::{QuoteStyle, escape_for_shell, quote_for_shell};
17use crate::dsl::registered_verbs;
18use crate::repl::highlight::ReplHighlighter;
19use nu_ansi_term::{Color, Style};
20use reedline::{Completer, Span, Suggestion};
21use serde::Serialize;
22
23use super::config::DEFAULT_HISTORY_MENU_ROWS;
24use super::{HistoryEntry, LineProjection, LineProjector, ReplAppearance, SharedHistory};
25
26pub(crate) struct ReplCompleter {
27 engine: CompletionEngine,
28 line_projector: Option<LineProjector>,
29}
30
31impl ReplCompleter {
32 pub(crate) fn new(
33 mut words: Vec<String>,
34 completion_tree: Option<CompletionTree>,
35 line_projector: Option<LineProjector>,
36 ) -> Self {
37 words.sort();
38 words.dedup();
39 let tree = completion_tree.unwrap_or_else(|| build_repl_tree(&words));
40 Self {
41 engine: CompletionEngine::new(tree),
42 line_projector,
43 }
44 }
45}
46
47impl Completer for ReplCompleter {
48 fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
49 debug_assert!(
50 pos <= line.len(),
51 "completer received pos {pos} beyond line length {}",
52 line.len()
53 );
54 let projected = self
55 .line_projector
56 .as_ref()
57 .map(|project| project(line))
58 .unwrap_or_else(|| LineProjection::passthrough(line));
59 let (cursor_state, outputs) = self.engine.complete(&projected.line, pos);
62 let span = Span {
63 start: cursor_state.replace_range.start,
64 end: cursor_state.replace_range.end,
65 };
66
67 let mut ranked = Vec::new();
68 let mut has_path_sentinel = false;
69 for output in outputs {
70 match output {
71 SuggestionOutput::Item(item) => ranked.push(item),
72 SuggestionOutput::PathSentinel => has_path_sentinel = true,
73 }
74 }
75
76 let mut hidden_suggestions = projected.hidden_suggestions.clone();
77 if !cursor_state.token_stub.is_empty() {
78 hidden_suggestions
83 .retain(|value| !value.eq_ignore_ascii_case(cursor_state.token_stub.as_str()));
84 }
85
86 let mut suggestions = ranked
87 .into_iter()
88 .filter(|item| !hidden_suggestions.contains(&item.text))
89 .map(|item| Suggestion {
90 value: item.text,
91 description: item.meta,
92 extra: item.display.map(|display| vec![display]),
93 span,
94 append_whitespace: true,
95 ..Suggestion::default()
96 })
97 .collect::<Vec<_>>();
98
99 if has_path_sentinel {
100 suggestions.extend(path_suggestions(
103 &cursor_state.raw_stub,
104 &cursor_state.token_stub,
105 cursor_state.quote_style,
106 span,
107 ));
108 }
109
110 suggestions
111 }
112}
113
114pub(crate) struct ReplHistoryCompleter {
115 history: SharedHistory,
116}
117
118impl ReplHistoryCompleter {
119 pub(crate) fn new(history: SharedHistory) -> Self {
120 Self { history }
121 }
122}
123
124impl Completer for ReplHistoryCompleter {
125 fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
126 let query = line
127 .get(..pos.min(line.len()))
128 .unwrap_or(line)
129 .trim()
130 .to_string();
131 let query_folded = fold_case(&query);
132 let replace_span = Span {
133 start: 0,
134 end: line.len(),
135 };
136
137 let mut seen = BTreeSet::new();
138 let mut exact = Vec::new();
139 let mut prefix = Vec::new();
140 let mut substring = Vec::new();
141 let mut recent = Vec::new();
142
143 for entry in self.history.list_entries().into_iter().rev() {
144 if !seen.insert(entry.command.clone()) {
145 continue;
146 }
147
148 if query_folded.is_empty() {
149 recent.push(history_suggestion(entry, replace_span));
150 if recent.len() >= DEFAULT_HISTORY_MENU_ROWS as usize {
151 break;
152 }
153 continue;
154 }
155
156 let command_folded = fold_case(&entry.command);
157 let suggestion = history_suggestion(entry.clone(), replace_span);
158 if command_folded == query_folded {
159 exact.push(suggestion);
160 } else if command_folded.starts_with(&query_folded) {
161 prefix.push(suggestion);
162 } else if command_folded.contains(&query_folded) {
163 substring.push(suggestion);
164 }
165 }
166
167 if query_folded.is_empty() {
168 return recent;
169 }
170
171 exact
172 .into_iter()
173 .chain(prefix)
174 .chain(substring)
175 .take(DEFAULT_HISTORY_MENU_ROWS as usize)
176 .collect()
177 }
178}
179
180fn history_suggestion(entry: HistoryEntry, span: Span) -> Suggestion {
181 Suggestion {
182 value: entry.command.clone(),
183 extra: Some(vec![format!("{} {}", entry.id, entry.command)]),
184 span,
185 append_whitespace: false,
186 ..Suggestion::default()
187 }
188}
189
190pub fn default_pipe_verbs() -> BTreeMap<String, String> {
192 registered_verbs()
193 .iter()
194 .map(|info| (info.verb.to_string(), info.summary.to_string()))
195 .collect()
196}
197
198pub(crate) fn build_repl_tree(words: &[String]) -> CompletionTree {
199 let suggestions = words
200 .iter()
201 .map(|word| SuggestionEntry::value(word.clone()))
202 .collect::<Vec<_>>();
203 let args = (0..12)
204 .map(|_| ArgNode {
205 suggestions: suggestions.clone(),
206 ..ArgNode::default()
207 })
208 .collect::<Vec<_>>();
209
210 CompletionTree {
211 root: CompletionNode {
212 args,
213 ..CompletionNode::default()
214 },
215 pipe_verbs: default_pipe_verbs(),
216 }
217}
218
219pub(crate) fn build_repl_highlighter(
220 tree: &CompletionTree,
221 appearance: &ReplAppearance,
222 line_projector: Option<LineProjector>,
223) -> Option<ReplHighlighter> {
224 let command_color = appearance
225 .command_highlight_style
226 .as_deref()
227 .and_then(color_from_style_spec);
228 Some(ReplHighlighter::new(
229 tree.clone(),
230 command_color?,
231 line_projector,
232 ))
233}
234
235pub(crate) fn style_with_fg_bg(fg: Option<Color>, bg: Option<Color>) -> Style {
236 let mut style = Style::new();
237 if let Some(fg) = fg {
238 style = style.fg(fg);
239 }
240 if let Some(bg) = bg {
241 style = style.on(bg);
242 }
243 style
244}
245
246pub fn color_from_style_spec(spec: &str) -> Option<Color> {
252 let token = extract_color_token(spec)?;
253 parse_color_token(token)
254}
255
256fn extract_color_token(spec: &str) -> Option<&str> {
257 let attrs = [
258 "bold",
259 "dim",
260 "dimmed",
261 "italic",
262 "underline",
263 "blink",
264 "reverse",
265 "hidden",
266 "strikethrough",
267 ];
268
269 let mut last: Option<&str> = None;
270 for part in spec.split_whitespace() {
271 let token = part
272 .trim()
273 .strip_prefix("fg:")
274 .or_else(|| part.trim().strip_prefix("bg:"))
275 .unwrap_or(part.trim());
276 if token.is_empty() {
277 continue;
278 }
279 if attrs.iter().any(|attr| token.eq_ignore_ascii_case(attr)) {
280 continue;
281 }
282 last = Some(token);
283 }
284 last
285}
286
287fn parse_color_token(token: &str) -> Option<Color> {
288 let normalized = token.trim().to_ascii_lowercase();
289
290 if let Some(value) = normalized.strip_prefix('#') {
291 if value.len() == 6 {
292 let r = u8::from_str_radix(&value[0..2], 16).ok()?;
293 let g = u8::from_str_radix(&value[2..4], 16).ok()?;
294 let b = u8::from_str_radix(&value[4..6], 16).ok()?;
295 return Some(Color::Rgb(r, g, b));
296 }
297 if value.len() == 3 {
298 let r = u8::from_str_radix(&value[0..1], 16).ok()?;
299 let g = u8::from_str_radix(&value[1..2], 16).ok()?;
300 let b = u8::from_str_radix(&value[2..3], 16).ok()?;
301 return Some(Color::Rgb(
302 r.saturating_mul(17),
303 g.saturating_mul(17),
304 b.saturating_mul(17),
305 ));
306 }
307 }
308
309 if let Some(value) = normalized.strip_prefix("ansi")
310 && let Ok(index) = value.parse::<u8>()
311 {
312 return Some(Color::Fixed(index));
313 }
314
315 if let Some(value) = normalized
316 .strip_prefix("rgb(")
317 .and_then(|value| value.strip_suffix(')'))
318 {
319 let mut parts = value.split(',').map(|part| part.trim().parse::<u8>().ok());
320 if let (Some(Some(r)), Some(Some(g)), Some(Some(b))) =
321 (parts.next(), parts.next(), parts.next())
322 {
323 return Some(Color::Rgb(r, g, b));
324 }
325 }
326
327 match normalized.as_str() {
328 "black" => Some(Color::Black),
329 "red" => Some(Color::Red),
330 "green" => Some(Color::Green),
331 "yellow" => Some(Color::Yellow),
332 "blue" => Some(Color::Blue),
333 "magenta" | "purple" => Some(Color::Purple),
334 "cyan" => Some(Color::Cyan),
335 "white" => Some(Color::White),
336 "darkgray" | "dark_gray" | "gray" | "grey" => Some(Color::DarkGray),
337 "lightgray" | "light_gray" | "lightgrey" | "light_grey" => Some(Color::LightGray),
338 "lightred" | "light_red" => Some(Color::LightRed),
339 "lightgreen" | "light_green" => Some(Color::LightGreen),
340 "lightyellow" | "light_yellow" => Some(Color::LightYellow),
341 "lightblue" | "light_blue" => Some(Color::LightBlue),
342 "lightmagenta" | "light_magenta" | "lightpurple" | "light_purple" => {
343 Some(Color::LightPurple)
344 }
345 "lightcyan" | "light_cyan" => Some(Color::LightCyan),
346 _ => None,
347 }
348}
349
350#[derive(Debug, Clone, Serialize)]
351pub(crate) struct CompletionTraceMenuState {
352 pub selected_index: i64,
353 pub selected_row: u16,
354 pub selected_col: u16,
355 pub active: bool,
356 pub just_activated: bool,
357 pub columns: u16,
358 pub visible_rows: u16,
359 pub rows: u16,
360 pub menu_indent: u16,
361}
362
363#[derive(Debug, Clone, Serialize)]
364struct CompletionTracePayload<'a> {
365 event: &'a str,
366 line: &'a str,
367 cursor: usize,
368 stub: &'a str,
369 matches: Vec<String>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 buffer_before: Option<&'a str>,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 buffer_after: Option<&'a str>,
374 #[serde(skip_serializing_if = "Option::is_none")]
375 cursor_before: Option<usize>,
376 #[serde(skip_serializing_if = "Option::is_none")]
377 cursor_after: Option<usize>,
378 #[serde(skip_serializing_if = "Option::is_none")]
379 accepted_value: Option<&'a str>,
380 #[serde(skip_serializing_if = "Option::is_none")]
381 replace_range: Option<[usize; 2]>,
382 #[serde(skip_serializing_if = "Option::is_none")]
383 selected_index: Option<i64>,
384 #[serde(skip_serializing_if = "Option::is_none")]
385 selected_row: Option<u16>,
386 #[serde(skip_serializing_if = "Option::is_none")]
387 selected_col: Option<u16>,
388 #[serde(skip_serializing_if = "Option::is_none")]
389 active: Option<bool>,
390 #[serde(skip_serializing_if = "Option::is_none")]
391 just_activated: Option<bool>,
392 #[serde(skip_serializing_if = "Option::is_none")]
393 columns: Option<u16>,
394 #[serde(skip_serializing_if = "Option::is_none")]
395 visible_rows: Option<u16>,
396 #[serde(skip_serializing_if = "Option::is_none")]
397 rows: Option<u16>,
398 #[serde(skip_serializing_if = "Option::is_none")]
399 menu_indent: Option<u16>,
400}
401
402#[derive(Debug, Clone)]
403pub(crate) struct CompletionTraceEvent<'a> {
404 pub event: &'a str,
405 pub line: &'a str,
406 pub cursor: usize,
407 pub stub: &'a str,
408 pub matches: Vec<String>,
409 pub replace_range: Option<[usize; 2]>,
410 pub menu: Option<CompletionTraceMenuState>,
411 pub buffer_before: Option<&'a str>,
412 pub buffer_after: Option<&'a str>,
413 pub cursor_before: Option<usize>,
414 pub cursor_after: Option<usize>,
415 pub accepted_value: Option<&'a str>,
416}
417
418pub(crate) fn trace_completion(trace: CompletionTraceEvent<'_>) {
419 if !trace_completion_enabled() {
420 return;
421 }
422
423 let (
424 selected_index,
425 selected_row,
426 selected_col,
427 active,
428 just_activated,
429 columns,
430 visible_rows,
431 rows,
432 menu_indent,
433 ) = if let Some(menu) = trace.menu {
434 (
435 Some(menu.selected_index),
436 Some(menu.selected_row),
437 Some(menu.selected_col),
438 Some(menu.active),
439 Some(menu.just_activated),
440 Some(menu.columns),
441 Some(menu.visible_rows),
442 Some(menu.rows),
443 Some(menu.menu_indent),
444 )
445 } else {
446 (None, None, None, None, None, None, None, None, None)
447 };
448
449 let payload = CompletionTracePayload {
450 event: trace.event,
451 line: trace.line,
452 cursor: trace.cursor,
453 stub: trace.stub,
454 matches: trace.matches,
455 buffer_before: trace.buffer_before,
456 buffer_after: trace.buffer_after,
457 cursor_before: trace.cursor_before,
458 cursor_after: trace.cursor_after,
459 accepted_value: trace.accepted_value,
460 replace_range: trace.replace_range,
461 selected_index,
462 selected_row,
463 selected_col,
464 active,
465 just_activated,
466 columns,
467 visible_rows,
468 rows,
469 menu_indent,
470 };
471
472 let serialized = serde_json::to_string(&payload).unwrap_or_else(|_| "{}".to_string());
473 if let Ok(path) = std::env::var("OSP_REPL_TRACE_PATH")
474 && !path.trim().is_empty()
475 {
476 if let Ok(mut file) = std::fs::OpenOptions::new()
477 .create(true)
478 .append(true)
479 .open(path)
480 {
481 let _ = writeln!(file, "{serialized}");
482 }
483 } else {
484 eprintln!("{serialized}");
485 }
486}
487
488pub(crate) fn trace_completion_enabled() -> bool {
489 let Ok(raw) = std::env::var("OSP_REPL_TRACE_COMPLETION") else {
490 return false;
491 };
492 !matches!(
493 raw.trim().to_ascii_lowercase().as_str(),
494 "" | "0" | "false" | "off" | "no"
495 )
496}
497
498pub(crate) fn path_suggestions(
499 raw_stub: &str,
500 token_stub: &str,
501 quote_style: Option<QuoteStyle>,
502 span: Span,
503) -> Vec<Suggestion> {
504 let (lookup, insert_prefix, typed_prefix) = split_path_stub(token_stub);
505 let read_dir = std::fs::read_dir(&lookup);
506 let Ok(entries) = read_dir else {
507 return Vec::new();
508 };
509
510 let mut out = Vec::new();
511 for entry in entries.flatten() {
512 let file_name = entry.file_name().to_string_lossy().to_string();
513 if !file_name.starts_with(&typed_prefix) {
514 continue;
515 }
516
517 let path = entry.path();
518 let is_dir = path.is_dir();
519 let suffix = if is_dir { "/" } else { "" };
520 let inserted = render_path_completion(
521 raw_stub,
522 &format!("{insert_prefix}{file_name}{suffix}"),
523 quote_style,
524 );
525
526 out.push(Suggestion {
527 value: inserted,
528 description: Some(if is_dir { "dir" } else { "file" }.to_string()),
529 span,
530 append_whitespace: !is_dir,
531 ..Suggestion::default()
532 });
533 }
534
535 out
536}
537
538fn render_path_completion(
539 raw_stub: &str,
540 candidate: &str,
541 quote_style: Option<QuoteStyle>,
542) -> String {
543 match infer_quote_context(raw_stub, quote_style) {
544 PathQuoteContext::Open(style) => quoted_completion_tail(candidate, style),
545 PathQuoteContext::Closed(style) => quote_for_shell(candidate, style),
546 PathQuoteContext::Unquoted => escape_for_shell(candidate),
547 }
548}
549
550fn quoted_completion_tail(candidate: &str, style: QuoteStyle) -> String {
551 let quoted = quote_for_shell(candidate, style);
552 quoted.chars().skip(1).collect()
553}
554
555fn infer_quote_context(raw_stub: &str, quote_style: Option<QuoteStyle>) -> PathQuoteContext {
556 if let Some(style) = quote_style {
557 return PathQuoteContext::Open(style);
558 }
559
560 if raw_stub.len() >= 2 && raw_stub.starts_with('\'') && raw_stub.ends_with('\'') {
561 return PathQuoteContext::Closed(QuoteStyle::Single);
562 }
563 if raw_stub.len() >= 2 && raw_stub.starts_with('"') && raw_stub.ends_with('"') {
564 return PathQuoteContext::Closed(QuoteStyle::Double);
565 }
566
567 PathQuoteContext::Unquoted
568}
569
570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571enum PathQuoteContext {
572 Unquoted,
573 Open(QuoteStyle),
574 Closed(QuoteStyle),
575}
576
577pub(crate) fn split_path_stub(stub: &str) -> (PathBuf, String, String) {
578 if stub.is_empty() {
579 return (PathBuf::from("."), String::new(), String::new());
580 }
581
582 let expanded = expand_home(stub);
583 let mut lookup = PathBuf::from(&expanded);
584 if stub.ends_with('/') {
585 return (lookup, stub.to_string(), String::new());
586 }
587
588 let typed_prefix = Path::new(stub)
589 .file_name()
590 .and_then(|value| value.to_str())
591 .map(ToOwned::to_owned)
592 .unwrap_or_default();
593
594 let insert_prefix = match stub.rfind('/') {
595 Some(index) => stub[..=index].to_string(),
596 None => String::new(),
597 };
598
599 if let Some(parent) = lookup.parent() {
600 if parent.as_os_str().is_empty() {
601 lookup = PathBuf::from(".");
602 } else {
603 lookup = parent.to_path_buf();
604 }
605 } else {
606 lookup = PathBuf::from(".");
607 }
608
609 (lookup, insert_prefix, typed_prefix)
610}
611
612pub(crate) fn expand_home(path: &str) -> String {
613 if path == "~" {
614 return crate::config::default_home_dir()
615 .map(|home| home.display().to_string())
616 .unwrap_or_else(|| "~".to_string());
617 }
618 if let Some(home) = crate::config::default_home_dir()
619 && let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\"))
620 {
621 return home.join(rest).display().to_string();
622 }
623 path.to_string()
624}