Skip to main content

nu_cli/
syntax_highlight.rs

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/// A highlighter that does nothing
15///
16/// Used to remove highlighting from a reedline instance
17/// (letting NuHighlighter structs be dropped)
18#[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/// Result of a syntax highlight operation
98#[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    // TODO: Traverse::flat_map based highlighting?
119    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        // NOTE: Currently we expand aliases while flattening for tasks such as completion
138        // https://github.com/nushell/nushell/issues/16944
139        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            // We've already output something for this span
150            // so just skip this one
151            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                // Highlighting externals has a config point because of concerns that using which to resolve
181                // externals may slow down things too much.
182                if highlight_resolved_externals {
183                    // use `raw_span` here for aliased external calls
184                    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    // calculate first bracket position
275    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            // cursor is at the end of a non-empty string -- find block end at the previous position
279            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            // cursor is in the middle of a string -- find block end at the current position
286            global_cursor_offset
287        };
288
289    // check that position contains bracket
290    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    // find matching bracket by finding matching block end
298    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            // TODO: Can't these be handled with an `_ => None` branch? Refactor
386            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                    // cursor is at table end
437                    Some(expr_first)
438                } else if expr_first == global_cursor_offset {
439                    // cursor is at table start
440                    Some(expr_last)
441                } else {
442                    // cursor is inside table
443                    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                    // cursor is at record end
462                    Some(expr_first)
463                } else if expr_first == global_cursor_offset {
464                    // cursor is at record start
465                    Some(expr_last)
466                } else {
467                    // cursor is inside record
468                    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                    // cursor is at block end
540                    Some(expr_first)
541                } else if expr_first == global_cursor_offset {
542                    // cursor is at block start
543                    Some(expr_last)
544                } else {
545                    // cursor is inside block
546                    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                    // cursor is at list end
572                    Some(expr_first)
573                } else if expr_first == global_cursor_offset {
574                    // cursor is at list start
575                    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    // 4-byte emoji
615    #[case("\"hello ๐ŸŽ‰\" hi", 7, false)] // first byte of ๐ŸŽ‰
616    #[case("\"hello ๐ŸŽ‰\" hi", 9, false)] // third byte of ๐ŸŽ‰
617    #[case("\"hello ๐ŸŽ‰\" hi", 13, true)] // after closing quote
618    // 8-byte zwj emoji
619    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 9, false)] // inside ๐Ÿค
620    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 11, false)] // first byte of ๐Ÿฟ
621    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 13, false)] // inside ๐Ÿฟ
622    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 17, true)] // after closing quote
623    // 3-byte unicode
624    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 2, false)] // inside ใ“
625    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 5, false)] // inside ใ‚“
626    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 13, false)] // start of ใฏ
627    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 18, true)] // after closing quote
628    // raw string
629    #[case("r#'hello'# hi", 4, false)] // inside 'e'
630    #[case("r#'hello'# hi", 11, true)] // after closing #
631    // string interpolation
632    #[case("$\"hello\" hi", 0, false)] // $ โ€” opening StringInterpolation span (0..2)
633    #[case("$\"hello\" hi", 4, false)] // inside literal 'hello'
634    #[case("$\"hello\" hi", 9, true)] // after closing quote
635    // no string
636    #[case("1 + 2", 0, true)]
637    #[case("1 + 2", 2, true)]
638    // suppress abbreviation expansion in external commands
639    #[case("ls -la", 0, true)] // on 'ls'  โ€” FlatShape::External
640    #[case("ls -la", 3, false)] // on '-la' โ€” FlatShape::ExternalArg
641    #[case("bash -c \"echo hello\"", 0, true)] // on 'bash'            โ€” FlatShape::External
642    #[case("bash -c \"echo hello\"", 5, false)] // on '-c'              โ€” FlatShape::ExternalArg
643    #[case("bash -c \"echo hello\"", 10, false)] // inside "echo hello"  โ€” FlatShape::ExternalArg
644    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    // bare bang expressions allow expansion
658    #[case("!!", 0, true)]
659    #[case("!!", 1, true)]
660    #[case("!ls", 0, true)]
661    #[case("!ls", 2, true)]
662    #[case("!-1", 1, true)]
663    // bang inside string literals does not suppress expansion
664    #[case("\"!!\"", 1, true)]
665    #[case("\"!ls\"", 2, true)]
666    #[case("r#'!!'#", 3, true)]
667    #[case("$\"!!\"", 2, true)]
668    // bang as external arg does not suppress expansion
669    #[case("bash -c !!", 9, true)]
670    #[case("bash -c !ls", 9, true)]
671    // bang inside a string that is itself an external arg โ€” shape is ExternalArg, not String.
672    // currently there is no way to avoid this
673    #[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}