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