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#[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
192fn 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}