Skip to main content

osp_cli/repl/engine/
adapter.rs

1//! Internal completion, history, highlight, and trace adapters for the REPL
2//! engine.
3//!
4//! These helpers sit at the boundary where the semantic REPL surface meets
5//! editor-facing mechanics such as reedline suggestions, path enumeration,
6//! menu styling, and trace payloads.
7
8use 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        // Completion runs against the projected line so host-only flags and
59        // aliases do not distort command-path or DSL suggestions.
60        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            // Keep the actively edited token visible even when projection-level
78            // policy would normally hide it. This prevents menu refresh from
79            // dropping the exact value that is already inserted in the prompt
80            // until a real delimiter commits the token's scope.
81            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            // The pure completion engine reports that this slot expects a path;
100            // filesystem enumeration happens here at the editor boundary.
101            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
189/// Returns the default DSL verbs exposed after `|` in the REPL.
190pub 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
260/// Parses a REPL style string and extracts a terminal color.
261///
262/// The parser accepts simple named colors as well as `#rrggbb`, `#rgb`,
263/// `ansiNN`, and `rgb(r,g,b)` forms. Non-color attributes such as `bold` are
264/// ignored when selecting the effective color token.
265pub 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}