1use log::trace;
2use nu_ansi_term::Style;
3use nu_color_config::{get_matching_brackets_style, get_shape_color};
4use nu_engine::env;
5use nu_parser::{FlatShape, flatten_block, parse};
6use nu_protocol::{
7 Span,
8 ast::{Block, Expr, Expression, PipelineRedirection, RecordItem},
9 engine::{EngineState, Stack, StateWorkingSet},
10};
11use reedline::{AbbrExpandContext, Highlighter, StyledText};
12use std::sync::{Arc, Mutex};
13
14#[derive(Default)]
19pub struct NoOpHighlighter {}
20
21impl Highlighter for NoOpHighlighter {
22 fn highlight(&self, _line: &str, _cursor: usize) -> reedline::StyledText {
23 StyledText::new()
24 }
25}
26
27struct HighlightCache {
28 line: String,
29 global_span_offset: usize,
30 shapes: Arc<Vec<(Span, FlatShape)>>,
31}
32
33pub struct NuHighlighter {
34 pub engine_state: Arc<EngineState>,
35 pub stack: Arc<Stack>,
36 cache: Mutex<Option<HighlightCache>>,
37}
38
39impl NuHighlighter {
40 pub fn new(engine_state: Arc<EngineState>, stack: Arc<Stack>) -> Self {
41 Self {
42 engine_state,
43 stack,
44 cache: Mutex::new(None),
45 }
46 }
47}
48
49impl Highlighter for NuHighlighter {
50 fn highlight(&self, line: &str, cursor: usize) -> StyledText {
51 let result = highlight_syntax(&self.engine_state, &self.stack, line, cursor);
52 *self.cache.lock().unwrap_or_else(|e| e.into_inner()) = Some(HighlightCache {
53 line: line.to_string(),
54 global_span_offset: result.global_span_offset,
55 shapes: Arc::new(result.shapes),
56 });
57 result.text
58 }
59
60 fn should_expand_abbr(&self, line: &str, cursor: usize, context: AbbrExpandContext) -> bool {
61 let (global_span_offset, shapes) = match self
62 .cache
63 .lock()
64 .ok()
65 .as_deref()
66 .and_then(|c| c.as_ref())
67 .filter(|c| c.line == line)
68 {
69 Some(c) => (c.global_span_offset, Arc::clone(&c.shapes)),
70 None => {
71 let mut working_set = StateWorkingSet::new(&self.engine_state);
72 let block = parse(&mut working_set, None, line.as_bytes(), false);
73 (
74 self.engine_state.next_span_start(),
75 Arc::new(flatten_block(&working_set, &block)),
76 )
77 }
78 };
79
80 let global_cursor = cursor + global_span_offset;
81 !shapes.iter().any(|(span, shape)| {
82 span.contains(global_cursor)
83 && match context {
84 AbbrExpandContext::WordAbbreviation => matches!(
85 shape,
86 FlatShape::String
87 | FlatShape::RawString
88 | FlatShape::StringInterpolation
89 | FlatShape::ExternalArg
90 ),
91 AbbrExpandContext::BangExpansion => false,
92 }
93 })
94 }
95}
96
97#[derive(Default)]
99pub(crate) struct HighlightResult {
100 pub(crate) text: StyledText,
101 pub(crate) found_garbage: Option<Span>,
102 pub(crate) global_span_offset: usize,
103 pub(crate) shapes: Vec<(Span, FlatShape)>,
104}
105
106pub(crate) fn highlight_syntax(
107 engine_state: &EngineState,
108 stack: &Stack,
109 line: &str,
110 cursor: usize,
111) -> HighlightResult {
112 trace!("highlighting: {line}");
113
114 let config = stack.get_config(engine_state);
115 let highlight_resolved_externals = config.highlight_resolved_externals;
116 let mut working_set = StateWorkingSet::new(engine_state);
117 let block = parse(&mut working_set, None, line.as_bytes(), false);
118 let shapes = flatten_block(&working_set, &block);
120 let global_span_offset = engine_state.next_span_start();
121 let mut result = HighlightResult {
122 global_span_offset,
123 ..Default::default()
124 };
125 let mut last_seen_span_end = global_span_offset;
126
127 let global_cursor_offset = cursor + global_span_offset;
128 let matching_brackets_pos = find_matching_brackets(
129 line,
130 &working_set,
131 &block,
132 global_span_offset,
133 global_cursor_offset,
134 );
135
136 for (raw_span, flat_shape) in &shapes {
137 let span = if let FlatShape::External(alias_span) = flat_shape {
140 alias_span
141 } else {
142 raw_span
143 };
144
145 if span.end <= last_seen_span_end
146 || last_seen_span_end < global_span_offset
147 || span.start < global_span_offset
148 {
149 continue;
152 }
153 if span.start > last_seen_span_end {
154 let gap = line
155 [(last_seen_span_end - global_span_offset)..(span.start - global_span_offset)]
156 .to_string();
157 result.text.push((Style::new(), gap));
158 }
159 let next_token =
160 line[(span.start - global_span_offset)..(span.end - global_span_offset)].to_string();
161
162 let mut add_colored_token = |shape: &FlatShape, text: String| {
163 result
164 .text
165 .push((get_shape_color(shape.as_str(), &config), text));
166 };
167
168 match flat_shape {
169 FlatShape::Garbage => {
170 result.found_garbage.get_or_insert_with(|| {
171 Span::new(
172 span.start - global_span_offset,
173 span.end - global_span_offset,
174 )
175 });
176 add_colored_token(flat_shape, next_token)
177 }
178 FlatShape::External(_) => {
179 let mut true_shape = flat_shape.clone();
180 if highlight_resolved_externals {
183 let str_contents = working_set.get_span_contents(*raw_span);
185 let str_word = String::from_utf8_lossy(str_contents).to_string();
186 let paths = env::path_str(engine_state, stack, *raw_span).ok();
187 let res = if let Ok(cwd) = engine_state.cwd(Some(stack)) {
188 which::which_in(str_word, paths.as_ref(), cwd).ok()
189 } else {
190 which::which_in_global(str_word, paths.as_ref())
191 .ok()
192 .and_then(|mut i| i.next())
193 };
194 if res.is_some() {
195 true_shape = FlatShape::ExternalResolved;
196 }
197 }
198 add_colored_token(&true_shape, next_token);
199 }
200 FlatShape::List
201 | FlatShape::Table
202 | FlatShape::Record
203 | FlatShape::Block
204 | FlatShape::Closure => {
205 let spans = split_span_by_highlight_positions(
206 line,
207 *span,
208 &matching_brackets_pos,
209 global_span_offset,
210 );
211 for (part, highlight) in spans {
212 let start = part.start - span.start;
213 let end = part.end - span.start;
214 let text = next_token[start..end].to_string();
215 let mut style = get_shape_color(flat_shape.as_str(), &config);
216 if highlight {
217 style = get_matching_brackets_style(style, &config);
218 }
219 result.text.push((style, text));
220 }
221 }
222 _ => add_colored_token(flat_shape, next_token),
223 }
224 last_seen_span_end = span.end;
225 }
226
227 let remainder = line[(last_seen_span_end - global_span_offset)..].to_string();
228 if !remainder.is_empty() {
229 result.text.push((Style::new(), remainder));
230 }
231
232 result.shapes = shapes;
233 result
234}
235
236fn split_span_by_highlight_positions(
237 line: &str,
238 span: Span,
239 highlight_positions: &[usize],
240 global_span_offset: usize,
241) -> Vec<(Span, bool)> {
242 let mut start = span.start;
243 let mut result: Vec<(Span, bool)> = Vec::new();
244 for pos in highlight_positions {
245 if start <= *pos && pos < &span.end {
246 if start < *pos {
247 result.push((Span::new(start, *pos), false));
248 }
249 let span_str = &line[pos - global_span_offset..span.end - global_span_offset];
250 let end = span_str
251 .chars()
252 .next()
253 .map(|c| pos + get_char_length(c))
254 .unwrap_or(pos + 1);
255 result.push((Span::new(*pos, end), true));
256 start = end;
257 }
258 }
259 if start < span.end {
260 result.push((Span::new(start, span.end), false));
261 }
262 result
263}
264
265fn find_matching_brackets(
266 line: &str,
267 working_set: &StateWorkingSet,
268 block: &Block,
269 global_span_offset: usize,
270 global_cursor_offset: usize,
271) -> Vec<usize> {
272 const BRACKETS: &str = "{}[]()";
273
274 let global_end_offset = line.len() + global_span_offset;
276 let global_bracket_pos =
277 if global_cursor_offset == global_end_offset && global_end_offset > global_span_offset {
278 if let Some(last_char) = line.chars().last() {
280 global_cursor_offset - get_char_length(last_char)
281 } else {
282 global_cursor_offset
283 }
284 } else {
285 global_cursor_offset
287 };
288
289 let match_idx = global_bracket_pos - global_span_offset;
291 if match_idx >= line.len()
292 || !BRACKETS.contains(get_char_at_index(line, match_idx).unwrap_or_default())
293 {
294 return Vec::new();
295 }
296
297 let matching_block_end = find_matching_block_end_in_block(
299 line,
300 working_set,
301 block,
302 global_span_offset,
303 global_bracket_pos,
304 );
305 if let Some(pos) = matching_block_end {
306 let matching_idx = pos - global_span_offset;
307 if BRACKETS.contains(get_char_at_index(line, matching_idx).unwrap_or_default()) {
308 return if global_bracket_pos < pos {
309 vec![global_bracket_pos, pos]
310 } else {
311 vec![pos, global_bracket_pos]
312 };
313 }
314 }
315 Vec::new()
316}
317
318fn find_matching_block_end_in_block(
319 line: &str,
320 working_set: &StateWorkingSet,
321 block: &Block,
322 global_span_offset: usize,
323 global_cursor_offset: usize,
324) -> Option<usize> {
325 for p in &block.pipelines {
326 for e in &p.elements {
327 if e.expr.span.contains(global_cursor_offset)
328 && let Some(pos) = find_matching_block_end_in_expr(
329 line,
330 working_set,
331 &e.expr,
332 global_span_offset,
333 global_cursor_offset,
334 )
335 {
336 return Some(pos);
337 }
338
339 if let Some(redirection) = e.redirection.as_ref() {
340 match redirection {
341 PipelineRedirection::Single { target, .. }
342 | PipelineRedirection::Separate { out: target, .. }
343 | PipelineRedirection::Separate { err: target, .. }
344 if target.span().contains(global_cursor_offset) =>
345 {
346 if let Some(pos) = target.expr().and_then(|expr| {
347 find_matching_block_end_in_expr(
348 line,
349 working_set,
350 expr,
351 global_span_offset,
352 global_cursor_offset,
353 )
354 }) {
355 return Some(pos);
356 }
357 }
358 _ => {}
359 }
360 }
361 }
362 }
363 None
364}
365
366fn find_matching_block_end_in_expr(
367 line: &str,
368 working_set: &StateWorkingSet,
369 expression: &Expression,
370 global_span_offset: usize,
371 global_cursor_offset: usize,
372) -> Option<usize> {
373 if expression.span.contains(global_cursor_offset) && expression.span.start >= global_span_offset
374 {
375 let expr_first = expression.span.start;
376 let span_str = &line
377 [expression.span.start - global_span_offset..expression.span.end - global_span_offset];
378 let expr_last = span_str
379 .chars()
380 .last()
381 .map(|c| expression.span.end - get_char_length(c))
382 .unwrap_or(expression.span.start);
383
384 return match &expression.expr {
385 Expr::Bool(_) => None,
387 Expr::Int(_) => None,
388 Expr::Float(_) => None,
389 Expr::Binary(_) => None,
390 Expr::Range(..) => None,
391 Expr::Var(_) => None,
392 Expr::VarDecl(_) => None,
393 Expr::ExternalCall(..) => None,
394 Expr::Operator(_) => None,
395 Expr::UnaryNot(_) => None,
396 Expr::Keyword(..) => None,
397 Expr::ValueWithUnit(..) => None,
398 Expr::DateTime(_) => None,
399 Expr::Filepath(_, _) => None,
400 Expr::Directory(_, _) => None,
401 Expr::GlobPattern(_, _) => None,
402 Expr::String(_) => None,
403 Expr::RawString(_) => None,
404 Expr::CellPath(_) => None,
405 Expr::ImportPattern(_) => None,
406 Expr::Overlay(_) => None,
407 Expr::Signature(_) => None,
408 Expr::MatchBlock(_) => None,
409 Expr::Nothing => None,
410 Expr::Garbage => None,
411
412 Expr::AttributeBlock(ab) => ab
413 .attributes
414 .iter()
415 .find_map(|attr| {
416 find_matching_block_end_in_expr(
417 line,
418 working_set,
419 &attr.expr,
420 global_span_offset,
421 global_cursor_offset,
422 )
423 })
424 .or_else(|| {
425 find_matching_block_end_in_expr(
426 line,
427 working_set,
428 &ab.item,
429 global_span_offset,
430 global_cursor_offset,
431 )
432 }),
433
434 Expr::Table(table) => {
435 if expr_last == global_cursor_offset {
436 Some(expr_first)
438 } else if expr_first == global_cursor_offset {
439 Some(expr_last)
441 } else {
442 table
444 .columns
445 .iter()
446 .chain(table.rows.iter().flat_map(AsRef::as_ref))
447 .find_map(|expr| {
448 find_matching_block_end_in_expr(
449 line,
450 working_set,
451 expr,
452 global_span_offset,
453 global_cursor_offset,
454 )
455 })
456 }
457 }
458
459 Expr::Record(exprs) => {
460 if expr_last == global_cursor_offset {
461 Some(expr_first)
463 } else if expr_first == global_cursor_offset {
464 Some(expr_last)
466 } else {
467 exprs.iter().find_map(|expr| match expr {
469 RecordItem::Pair(k, v) => find_matching_block_end_in_expr(
470 line,
471 working_set,
472 k,
473 global_span_offset,
474 global_cursor_offset,
475 )
476 .or_else(|| {
477 find_matching_block_end_in_expr(
478 line,
479 working_set,
480 v,
481 global_span_offset,
482 global_cursor_offset,
483 )
484 }),
485 RecordItem::Spread(_, record) => find_matching_block_end_in_expr(
486 line,
487 working_set,
488 record,
489 global_span_offset,
490 global_cursor_offset,
491 ),
492 })
493 }
494 }
495
496 Expr::Call(call) => call.arguments.iter().find_map(|arg| {
497 arg.expr().and_then(|expr| {
498 find_matching_block_end_in_expr(
499 line,
500 working_set,
501 expr,
502 global_span_offset,
503 global_cursor_offset,
504 )
505 })
506 }),
507
508 Expr::FullCellPath(b) => find_matching_block_end_in_expr(
509 line,
510 working_set,
511 &b.head,
512 global_span_offset,
513 global_cursor_offset,
514 ),
515
516 Expr::BinaryOp(lhs, op, rhs) => [lhs, op, rhs].into_iter().find_map(|expr| {
517 find_matching_block_end_in_expr(
518 line,
519 working_set,
520 expr,
521 global_span_offset,
522 global_cursor_offset,
523 )
524 }),
525
526 Expr::Collect(_, expr) => find_matching_block_end_in_expr(
527 line,
528 working_set,
529 expr,
530 global_span_offset,
531 global_cursor_offset,
532 ),
533
534 Expr::Block(block_id)
535 | Expr::Closure(block_id)
536 | Expr::RowCondition(block_id)
537 | Expr::Subexpression(block_id) => {
538 if expr_last == global_cursor_offset {
539 Some(expr_first)
541 } else if expr_first == global_cursor_offset {
542 Some(expr_last)
544 } else {
545 let nested_block = working_set.get_block(*block_id);
547 find_matching_block_end_in_block(
548 line,
549 working_set,
550 nested_block,
551 global_span_offset,
552 global_cursor_offset,
553 )
554 }
555 }
556
557 Expr::StringInterpolation(exprs) | Expr::GlobInterpolation(exprs, _) => {
558 exprs.iter().find_map(|expr| {
559 find_matching_block_end_in_expr(
560 line,
561 working_set,
562 expr,
563 global_span_offset,
564 global_cursor_offset,
565 )
566 })
567 }
568
569 Expr::List(list) => {
570 if expr_last == global_cursor_offset {
571 Some(expr_first)
573 } else if expr_first == global_cursor_offset {
574 Some(expr_last)
576 } else {
577 list.iter().find_map(|item| {
578 find_matching_block_end_in_expr(
579 line,
580 working_set,
581 item.expr(),
582 global_span_offset,
583 global_cursor_offset,
584 )
585 })
586 }
587 }
588 };
589 }
590 None
591}
592
593fn get_char_at_index(s: &str, index: usize) -> Option<char> {
594 s[index..].chars().next()
595}
596
597fn get_char_length(c: char) -> usize {
598 c.to_string().len()
599}
600
601#[cfg(test)]
602mod tests {
603 use super::NuHighlighter;
604 use nu_protocol::engine::{EngineState, Stack};
605 use reedline::{AbbrExpandContext, Highlighter};
606 use rstest::rstest;
607 use std::sync::Arc;
608
609 fn make_highlighter() -> NuHighlighter {
610 NuHighlighter::new(Arc::new(EngineState::new()), Arc::new(Stack::new()))
611 }
612
613 #[rstest]
614 #[case("\"hello ๐\" hi", 7, false)] #[case("\"hello ๐\" hi", 9, false)] #[case("\"hello ๐\" hi", 13, true)] #[case("\"hello ๐ค๐ฟ\" hi", 9, false)] #[case("\"hello ๐ค๐ฟ\" hi", 11, false)] #[case("\"hello ๐ค๐ฟ\" hi", 13, false)] #[case("\"hello ๐ค๐ฟ\" hi", 17, true)] #[case("\"ใใใซใกใฏ\" hi", 2, false)] #[case("\"ใใใซใกใฏ\" hi", 5, false)] #[case("\"ใใใซใกใฏ\" hi", 13, false)] #[case("\"ใใใซใกใฏ\" hi", 18, true)] #[case("r#'hello'# hi", 4, false)] #[case("r#'hello'# hi", 11, true)] #[case("$\"hello\" hi", 0, false)] #[case("$\"hello\" hi", 4, false)] #[case("$\"hello\" hi", 9, true)] #[case("1 + 2", 0, true)]
637 #[case("1 + 2", 2, true)]
638 #[case("ls -la", 0, true)] #[case("ls -la", 3, false)] #[case("bash -c \"echo hello\"", 0, true)] #[case("bash -c \"echo hello\"", 5, false)] #[case("bash -c \"echo hello\"", 10, false)] fn test_should_expand_word_abbr(
645 #[case] line: &str,
646 #[case] cursor: usize,
647 #[case] expected: bool,
648 ) {
649 let h = make_highlighter();
650 assert_eq!(
651 h.should_expand_abbr(line, cursor, AbbrExpandContext::WordAbbreviation),
652 expected
653 );
654 }
655
656 #[rstest]
657 #[case("!!", 0, true)]
659 #[case("!!", 1, true)]
660 #[case("!ls", 0, true)]
661 #[case("!ls", 2, true)]
662 #[case("!-1", 1, true)]
663 #[case("\"!!\"", 1, true)]
665 #[case("\"!ls\"", 2, true)]
666 #[case("r#'!!'#", 3, true)]
667 #[case("$\"!!\"", 2, true)]
668 #[case("bash -c !!", 9, true)]
670 #[case("bash -c !ls", 9, true)]
671 #[case("echo \"hi !!\"", 9, true)]
674 fn test_should_expand_abbr_bang(
675 #[case] line: &str,
676 #[case] cursor: usize,
677 #[case] expected: bool,
678 ) {
679 let h = make_highlighter();
680 assert_eq!(
681 h.should_expand_abbr(line, cursor, AbbrExpandContext::BangExpansion),
682 expected
683 );
684 }
685}