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::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        // Completion runs against the projected line so host-only flags and
60        // aliases do not distort command-path or DSL suggestions.
61        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            // Keep the actively edited token visible even when projection-level
79            // policy would normally hide it. This prevents menu refresh from
80            // dropping the exact value that is already inserted in the prompt
81            // until a real delimiter commits the token's scope.
82            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            // The pure completion engine reports that this slot expects a path;
101            // filesystem enumeration happens here at the editor boundary.
102            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
190/// Returns the default DSL verbs exposed after `|` in the REPL.
191pub 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
246/// Parses a REPL style string and extracts a terminal color.
247///
248/// The parser accepts simple named colors as well as `#rrggbb`, `#rgb`,
249/// `ansiNN`, and `rgb(r,g,b)` forms. Non-color attributes such as `bold` are
250/// ignored when selecting the effective color token.
251pub 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}