Skip to main content

osp_cli/repl/
highlight.rs

1use std::collections::BTreeSet;
2use std::sync::Arc;
3
4use crate::completion::{CommandLineParser, CompletionNode, CompletionTree, TokenSpan};
5use nu_ansi_term::Color;
6use reedline::{Highlighter, StyledText};
7use serde::Serialize;
8
9use crate::repl::LineProjection;
10
11/// Highlighting intentionally stays small and opinionated:
12/// - color only the visible command path the tree can resolve
13/// - keep partial tokens and flags plain
14/// - preserve `help <command>` as a first-class alias case
15/// - self-highlight hex color literals
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub(crate) enum HighlightTokenKind {
18    Plain,
19    CommandValid,
20    ColorLiteral(Color),
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub(crate) struct HighlightedSpan {
25    pub start: usize,
26    pub end: usize,
27    pub kind: HighlightTokenKind,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
31pub struct HighlightDebugSpan {
32    pub start: usize,
33    pub end: usize,
34    pub text: String,
35    pub kind: String,
36    pub rgb: Option<[u8; 3]>,
37}
38
39pub(crate) type LineProjector = Arc<dyn Fn(&str) -> LineProjection + Send + Sync>;
40
41pub(crate) struct ReplHighlighter {
42    tree: CompletionTree,
43    parser: CommandLineParser,
44    command_color: Color,
45    line_projector: Option<LineProjector>,
46}
47
48impl ReplHighlighter {
49    pub(crate) fn new(
50        tree: CompletionTree,
51        command_color: Color,
52        line_projector: Option<LineProjector>,
53    ) -> Self {
54        Self {
55            tree,
56            parser: CommandLineParser,
57            command_color,
58            line_projector,
59        }
60    }
61
62    pub(crate) fn classify(&self, line: &str) -> Vec<HighlightedSpan> {
63        if line.is_empty() {
64            return Vec::new();
65        }
66
67        let projected = self
68            .line_projector
69            .as_ref()
70            .map(|project| project(line))
71            .unwrap_or_else(|| LineProjection::passthrough(line));
72        let raw_spans = self.parser.tokenize_with_spans(line);
73        if raw_spans.is_empty() {
74            return Vec::new();
75        }
76
77        let mut command_ranges =
78            command_token_ranges(&self.tree.root, &self.parser, &projected.line);
79        if let Some(range) = blanked_help_keyword_range(&raw_spans, &projected.line) {
80            command_ranges.insert(range);
81        }
82
83        raw_spans
84            .into_iter()
85            .map(|span| HighlightedSpan {
86                start: span.start,
87                end: span.end,
88                kind: if command_ranges.contains(&(span.start, span.end)) {
89                    HighlightTokenKind::CommandValid
90                } else if let Some(color) = parse_hex_color_token(&span.value) {
91                    HighlightTokenKind::ColorLiteral(color)
92                } else {
93                    HighlightTokenKind::Plain
94                },
95            })
96            .collect()
97    }
98
99    fn classify_debug(&self, line: &str) -> Vec<HighlightDebugSpan> {
100        self.classify(line)
101            .into_iter()
102            .map(|span| HighlightDebugSpan {
103                start: span.start,
104                end: span.end,
105                text: line[span.start..span.end].to_string(),
106                kind: debug_kind_name(span.kind).to_string(),
107                rgb: debug_kind_rgb(span.kind),
108            })
109            .collect()
110    }
111}
112
113impl Highlighter for ReplHighlighter {
114    fn highlight(&self, line: &str, _cursor: usize) -> StyledText {
115        let mut styled = StyledText::new();
116        if line.is_empty() {
117            return styled;
118        }
119
120        let spans = self.classify(line);
121        if spans.is_empty() {
122            styled.push((nu_ansi_term::Style::new(), line.to_string()));
123            return styled;
124        }
125
126        let mut pos = 0usize;
127        for span in spans {
128            if span.start > pos {
129                styled.push((
130                    nu_ansi_term::Style::new(),
131                    line[pos..span.start].to_string(),
132                ));
133            }
134
135            let style = match span.kind {
136                HighlightTokenKind::Plain => nu_ansi_term::Style::new(),
137                HighlightTokenKind::CommandValid => {
138                    nu_ansi_term::Style::new().fg(self.command_color)
139                }
140                HighlightTokenKind::ColorLiteral(color) => nu_ansi_term::Style::new().fg(color),
141            };
142            styled.push((style, line[span.start..span.end].to_string()));
143            pos = span.end;
144        }
145
146        if pos < line.len() {
147            styled.push((nu_ansi_term::Style::new(), line[pos..].to_string()));
148        }
149
150        styled
151    }
152}
153
154pub fn debug_highlight(
155    tree: &CompletionTree,
156    line: &str,
157    command_color: Color,
158    line_projector: Option<LineProjector>,
159) -> Vec<HighlightDebugSpan> {
160    ReplHighlighter::new(tree.clone(), command_color, line_projector).classify_debug(line)
161}
162
163fn command_token_ranges(
164    root: &CompletionNode,
165    parser: &CommandLineParser,
166    projected_line: &str,
167) -> BTreeSet<(usize, usize)> {
168    let mut ranges = BTreeSet::new();
169    let spans = parser.tokenize_with_spans(projected_line);
170    if spans.is_empty() {
171        return ranges;
172    }
173
174    let mut node = root;
175    for span in spans {
176        let token = span.value.as_str();
177        if token.is_empty() || token == "|" || token.starts_with('-') {
178            break;
179        }
180
181        let Some(child) = node.children.get(token) else {
182            break;
183        };
184
185        ranges.insert((span.start, span.end));
186        node = child;
187    }
188
189    ranges
190}
191
192// `help <command>` is projected to a blanked keyword plus the target path.
193// Preserve highlighting for the hidden keyword itself when that projection applies.
194fn blanked_help_keyword_range(
195    raw_spans: &[TokenSpan],
196    projected_line: &str,
197) -> Option<(usize, usize)> {
198    raw_spans
199        .iter()
200        .find(|span| {
201            span.value == "help"
202                && projected_line
203                    .get(span.start..span.end)
204                    .is_some_and(|segment| segment.trim().is_empty())
205        })
206        .map(|span| (span.start, span.end))
207}
208
209fn parse_hex_color_token(token: &str) -> Option<Color> {
210    let normalized = token.trim();
211    let hex = normalized.strip_prefix('#')?;
212    if hex.len() == 6 {
213        let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
214        let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
215        let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
216        return Some(Color::Rgb(r, g, b));
217    }
218    if hex.len() == 3 {
219        let r = u8::from_str_radix(&hex[0..1], 16).ok()?;
220        let g = u8::from_str_radix(&hex[1..2], 16).ok()?;
221        let b = u8::from_str_radix(&hex[2..3], 16).ok()?;
222        return Some(Color::Rgb(
223            r.saturating_mul(17),
224            g.saturating_mul(17),
225            b.saturating_mul(17),
226        ));
227    }
228    None
229}
230
231fn debug_kind_name(kind: HighlightTokenKind) -> &'static str {
232    match kind {
233        HighlightTokenKind::Plain => "plain",
234        HighlightTokenKind::CommandValid => "command_valid",
235        HighlightTokenKind::ColorLiteral(_) => "color_literal",
236    }
237}
238
239fn debug_kind_rgb(kind: HighlightTokenKind) -> Option<[u8; 3]> {
240    let color = match kind {
241        HighlightTokenKind::ColorLiteral(color) => color,
242        _ => return None,
243    };
244
245    let rgb = match color {
246        Color::Black => [0, 0, 0],
247        Color::DarkGray => [128, 128, 128],
248        Color::Red => [128, 0, 0],
249        Color::Green => [0, 128, 0],
250        Color::Yellow => [128, 128, 0],
251        Color::Blue => [0, 0, 128],
252        Color::Purple => [128, 0, 128],
253        Color::Magenta => [128, 0, 128],
254        Color::Cyan => [0, 128, 128],
255        Color::White => [192, 192, 192],
256        Color::Fixed(_) => return None,
257        Color::LightRed => [255, 0, 0],
258        Color::LightGreen => [0, 255, 0],
259        Color::LightYellow => [255, 255, 0],
260        Color::LightBlue => [0, 0, 255],
261        Color::LightPurple => [255, 0, 255],
262        Color::LightMagenta => [255, 0, 255],
263        Color::LightCyan => [0, 255, 255],
264        Color::LightGray => [255, 255, 255],
265        Color::Rgb(r, g, b) => [r, g, b],
266        Color::Default => return None,
267    };
268    Some(rgb)
269}
270
271#[cfg(test)]
272mod tests {
273    use super::{ReplHighlighter, debug_highlight};
274    use crate::completion::{CompletionNode, CompletionTree};
275    use crate::repl::LineProjection;
276    use nu_ansi_term::Color;
277    use reedline::Highlighter;
278    use std::sync::Arc;
279
280    fn token_styles(styled: &StyledText) -> Vec<(String, Option<Color>)> {
281        styled
282            .buffer
283            .iter()
284            .filter_map(|(style, text)| {
285                if text.chars().all(|ch| ch.is_whitespace()) {
286                    None
287                } else {
288                    Some((text.clone(), style.foreground))
289                }
290            })
291            .collect()
292    }
293
294    use reedline::StyledText;
295
296    fn completion_tree_with_config_show() -> CompletionTree {
297        let mut config = CompletionNode::default();
298        config
299            .children
300            .insert("show".to_string(), CompletionNode::default());
301        CompletionTree {
302            root: CompletionNode::default().with_child("config", config),
303            ..CompletionTree::default()
304        }
305    }
306
307    #[test]
308    fn colors_full_command_chain_only_unit() {
309        let tree = completion_tree_with_config_show();
310        let highlighter = ReplHighlighter::new(tree, Color::Green, None);
311
312        let tokens = token_styles(&highlighter.highlight("config show", 0));
313        assert_eq!(
314            tokens,
315            vec![
316                ("config".to_string(), Some(Color::Green)),
317                ("show".to_string(), Some(Color::Green)),
318            ]
319        );
320    }
321
322    #[test]
323    fn skips_partial_subcommand_and_flags_unit() {
324        let tree = completion_tree_with_config_show();
325        let highlighter = ReplHighlighter::new(tree, Color::Green, None);
326
327        let tokens = token_styles(&highlighter.highlight("config sho", 0));
328        assert_eq!(
329            tokens,
330            vec![
331                ("config".to_string(), Some(Color::Green)),
332                ("sho".to_string(), None),
333            ]
334        );
335
336        let tokens = token_styles(&highlighter.highlight("config --flag", 0));
337        assert_eq!(
338            tokens,
339            vec![
340                ("config".to_string(), Some(Color::Green)),
341                ("--flag".to_string(), None),
342            ]
343        );
344    }
345
346    #[test]
347    fn colors_help_alias_keyword_and_target_unit() {
348        let tree = CompletionTree {
349            root: CompletionNode::default().with_child("history", CompletionNode::default()),
350            ..CompletionTree::default()
351        };
352        let projector =
353            Arc::new(|line: &str| LineProjection::passthrough(line.replacen("help", "    ", 1)));
354        let highlighter = ReplHighlighter::new(tree, Color::Green, Some(projector));
355
356        let tokens = token_styles(&highlighter.highlight("help history", 0));
357        assert_eq!(
358            tokens,
359            vec![
360                ("help".to_string(), Some(Color::Green)),
361                ("history".to_string(), Some(Color::Green)),
362            ]
363        );
364
365        let tokens = token_styles(&highlighter.highlight("help his", 0));
366        assert_eq!(
367            tokens,
368            vec![
369                ("help".to_string(), Some(Color::Green)),
370                ("his".to_string(), None),
371            ]
372        );
373    }
374
375    #[test]
376    fn highlights_hex_color_literals_unit() {
377        let highlighter = ReplHighlighter::new(CompletionTree::default(), Color::Green, None);
378        let spans = debug_highlight(&CompletionTree::default(), "#ff00cc", Color::Green, None);
379        assert_eq!(spans.len(), 1);
380        assert_eq!(spans[0].kind, "color_literal");
381        assert_eq!(spans[0].rgb, Some([255, 0, 204]));
382        let tokens = token_styles(&highlighter.highlight("#ff00cc", 0));
383        assert_eq!(
384            tokens,
385            vec![("#ff00cc".to_string(), Some(Color::Rgb(255, 0, 204)))]
386        );
387    }
388
389    #[test]
390    fn debug_spans_preserve_help_alias_ranges_unit() {
391        let tree = CompletionTree {
392            root: CompletionNode::default().with_child("history", CompletionNode::default()),
393            ..CompletionTree::default()
394        };
395        let projector =
396            Arc::new(|line: &str| LineProjection::passthrough(line.replacen("help", "    ", 1)));
397        let spans = debug_highlight(&tree, "help history -", Color::Green, Some(projector));
398
399        assert_eq!(
400            spans
401                .into_iter()
402                .filter(|span| span.kind == "command_valid")
403                .map(|span| (span.start, span.end, span.text))
404                .collect::<Vec<_>>(),
405            vec![(0, 4, "help".to_string()), (5, 12, "history".to_string())]
406        );
407    }
408
409    #[test]
410    fn three_digit_hex_and_invalid_tokens_cover_debug_paths_unit() {
411        let spans = debug_highlight(&CompletionTree::default(), "#0af", Color::Green, None);
412        assert_eq!(spans[0].rgb, Some([0, 170, 255]));
413
414        let highlighter = ReplHighlighter::new(CompletionTree::default(), Color::Green, None);
415        let tokens = token_styles(&highlighter.highlight("unknown #nope", 0));
416        assert_eq!(
417            tokens,
418            vec![("unknown".to_string(), None), ("#nope".to_string(), None),]
419        );
420    }
421}