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::{Highlighter, StyledText};
12use std::sync::{Arc, Mutex};
13
14struct HighlightCache {
15    line: String,
16    global_span_offset: usize,
17    shapes: Arc<Vec<(Span, FlatShape)>>,
18}
19
20pub struct NuHighlighter {
21    pub engine_state: Arc<EngineState>,
22    pub stack: Arc<Stack>,
23    cache: Mutex<Option<HighlightCache>>,
24}
25
26impl NuHighlighter {
27    pub fn new(engine_state: Arc<EngineState>, stack: Arc<Stack>) -> Self {
28        Self {
29            engine_state,
30            stack,
31            cache: Mutex::new(None),
32        }
33    }
34}
35
36impl Highlighter for NuHighlighter {
37    fn highlight(&self, line: &str, cursor: usize) -> StyledText {
38        let result = highlight_syntax(&self.engine_state, &self.stack, line, cursor);
39        *self.cache.lock().unwrap_or_else(|e| e.into_inner()) = Some(HighlightCache {
40            line: line.to_string(),
41            global_span_offset: result.global_span_offset,
42            shapes: Arc::new(result.shapes),
43        });
44        result.text
45    }
46
47    fn is_inside_string_literal(&self, line: &str, cursor: usize) -> bool {
48        let (global_span_offset, shapes) = match self
49            .cache
50            .lock()
51            .ok()
52            .as_deref()
53            .and_then(|c| c.as_ref())
54            .filter(|c| c.line == line)
55        {
56            Some(c) => (c.global_span_offset, Arc::clone(&c.shapes)),
57            None => {
58                let mut working_set = StateWorkingSet::new(&self.engine_state);
59                let block = parse(&mut working_set, None, line.as_bytes(), false);
60                (
61                    self.engine_state.next_span_start(),
62                    Arc::new(flatten_block(&working_set, &block)),
63                )
64            }
65        };
66
67        let global_cursor = cursor + global_span_offset;
68        shapes.iter().any(|(span, shape)| {
69            span.contains(global_cursor)
70                && matches!(
71                    shape,
72                    FlatShape::String
73                        | FlatShape::RawString
74                        | FlatShape::StringInterpolation
75                        | FlatShape::ExternalArg
76                )
77        })
78    }
79}
80
81/// Result of a syntax highlight operation
82#[derive(Default)]
83pub(crate) struct HighlightResult {
84    pub(crate) text: StyledText,
85    pub(crate) found_garbage: Option<Span>,
86    pub(crate) global_span_offset: usize,
87    pub(crate) shapes: Vec<(Span, FlatShape)>,
88}
89
90pub(crate) fn highlight_syntax(
91    engine_state: &EngineState,
92    stack: &Stack,
93    line: &str,
94    cursor: usize,
95) -> HighlightResult {
96    trace!("highlighting: {line}");
97
98    let config = stack.get_config(engine_state);
99    let highlight_resolved_externals = config.highlight_resolved_externals;
100    let mut working_set = StateWorkingSet::new(engine_state);
101    let block = parse(&mut working_set, None, line.as_bytes(), false);
102    // TODO: Traverse::flat_map based highlighting?
103    let shapes = flatten_block(&working_set, &block);
104    let global_span_offset = engine_state.next_span_start();
105    let mut result = HighlightResult {
106        global_span_offset,
107        ..Default::default()
108    };
109    let mut last_seen_span_end = global_span_offset;
110
111    let global_cursor_offset = cursor + global_span_offset;
112    let matching_brackets_pos = find_matching_brackets(
113        line,
114        &working_set,
115        &block,
116        global_span_offset,
117        global_cursor_offset,
118    );
119
120    for (raw_span, flat_shape) in &shapes {
121        // NOTE: Currently we expand aliases while flattening for tasks such as completion
122        // https://github.com/nushell/nushell/issues/16944
123        let span = if let FlatShape::External(alias_span) = flat_shape {
124            alias_span
125        } else {
126            raw_span
127        };
128
129        if span.end <= last_seen_span_end
130            || last_seen_span_end < global_span_offset
131            || span.start < global_span_offset
132        {
133            // We've already output something for this span
134            // so just skip this one
135            continue;
136        }
137        if span.start > last_seen_span_end {
138            let gap = line
139                [(last_seen_span_end - global_span_offset)..(span.start - global_span_offset)]
140                .to_string();
141            result.text.push((Style::new(), gap));
142        }
143        let next_token =
144            line[(span.start - global_span_offset)..(span.end - global_span_offset)].to_string();
145
146        let mut add_colored_token = |shape: &FlatShape, text: String| {
147            result
148                .text
149                .push((get_shape_color(shape.as_str(), &config), text));
150        };
151
152        match flat_shape {
153            FlatShape::Garbage => {
154                result.found_garbage.get_or_insert_with(|| {
155                    Span::new(
156                        span.start - global_span_offset,
157                        span.end - global_span_offset,
158                    )
159                });
160                add_colored_token(flat_shape, next_token)
161            }
162            FlatShape::External(_) => {
163                let mut true_shape = flat_shape.clone();
164                // Highlighting externals has a config point because of concerns that using which to resolve
165                // externals may slow down things too much.
166                if highlight_resolved_externals {
167                    // use `raw_span` here for aliased external calls
168                    let str_contents = working_set.get_span_contents(*raw_span);
169                    let str_word = String::from_utf8_lossy(str_contents).to_string();
170                    let paths = env::path_str(engine_state, stack, *raw_span).ok();
171                    let res = if let Ok(cwd) = engine_state.cwd(Some(stack)) {
172                        which::which_in(str_word, paths.as_ref(), cwd).ok()
173                    } else {
174                        which::which_in_global(str_word, paths.as_ref())
175                            .ok()
176                            .and_then(|mut i| i.next())
177                    };
178                    if res.is_some() {
179                        true_shape = FlatShape::ExternalResolved;
180                    }
181                }
182                add_colored_token(&true_shape, next_token);
183            }
184            FlatShape::List
185            | FlatShape::Table
186            | FlatShape::Record
187            | FlatShape::Block
188            | FlatShape::Closure => {
189                let spans = split_span_by_highlight_positions(
190                    line,
191                    *span,
192                    &matching_brackets_pos,
193                    global_span_offset,
194                );
195                for (part, highlight) in spans {
196                    let start = part.start - span.start;
197                    let end = part.end - span.start;
198                    let text = next_token[start..end].to_string();
199                    let mut style = get_shape_color(flat_shape.as_str(), &config);
200                    if highlight {
201                        style = get_matching_brackets_style(style, &config);
202                    }
203                    result.text.push((style, text));
204                }
205            }
206            _ => add_colored_token(flat_shape, next_token),
207        }
208        last_seen_span_end = span.end;
209    }
210
211    let remainder = line[(last_seen_span_end - global_span_offset)..].to_string();
212    if !remainder.is_empty() {
213        result.text.push((Style::new(), remainder));
214    }
215
216    result.shapes = shapes;
217    result
218}
219
220fn split_span_by_highlight_positions(
221    line: &str,
222    span: Span,
223    highlight_positions: &[usize],
224    global_span_offset: usize,
225) -> Vec<(Span, bool)> {
226    let mut start = span.start;
227    let mut result: Vec<(Span, bool)> = Vec::new();
228    for pos in highlight_positions {
229        if start <= *pos && pos < &span.end {
230            if start < *pos {
231                result.push((Span::new(start, *pos), false));
232            }
233            let span_str = &line[pos - global_span_offset..span.end - global_span_offset];
234            let end = span_str
235                .chars()
236                .next()
237                .map(|c| pos + get_char_length(c))
238                .unwrap_or(pos + 1);
239            result.push((Span::new(*pos, end), true));
240            start = end;
241        }
242    }
243    if start < span.end {
244        result.push((Span::new(start, span.end), false));
245    }
246    result
247}
248
249fn find_matching_brackets(
250    line: &str,
251    working_set: &StateWorkingSet,
252    block: &Block,
253    global_span_offset: usize,
254    global_cursor_offset: usize,
255) -> Vec<usize> {
256    const BRACKETS: &str = "{}[]()";
257
258    // calculate first bracket position
259    let global_end_offset = line.len() + global_span_offset;
260    let global_bracket_pos =
261        if global_cursor_offset == global_end_offset && global_end_offset > global_span_offset {
262            // cursor is at the end of a non-empty string -- find block end at the previous position
263            if let Some(last_char) = line.chars().last() {
264                global_cursor_offset - get_char_length(last_char)
265            } else {
266                global_cursor_offset
267            }
268        } else {
269            // cursor is in the middle of a string -- find block end at the current position
270            global_cursor_offset
271        };
272
273    // check that position contains bracket
274    let match_idx = global_bracket_pos - global_span_offset;
275    if match_idx >= line.len()
276        || !BRACKETS.contains(get_char_at_index(line, match_idx).unwrap_or_default())
277    {
278        return Vec::new();
279    }
280
281    // find matching bracket by finding matching block end
282    let matching_block_end = find_matching_block_end_in_block(
283        line,
284        working_set,
285        block,
286        global_span_offset,
287        global_bracket_pos,
288    );
289    if let Some(pos) = matching_block_end {
290        let matching_idx = pos - global_span_offset;
291        if BRACKETS.contains(get_char_at_index(line, matching_idx).unwrap_or_default()) {
292            return if global_bracket_pos < pos {
293                vec![global_bracket_pos, pos]
294            } else {
295                vec![pos, global_bracket_pos]
296            };
297        }
298    }
299    Vec::new()
300}
301
302fn find_matching_block_end_in_block(
303    line: &str,
304    working_set: &StateWorkingSet,
305    block: &Block,
306    global_span_offset: usize,
307    global_cursor_offset: usize,
308) -> Option<usize> {
309    for p in &block.pipelines {
310        for e in &p.elements {
311            if e.expr.span.contains(global_cursor_offset)
312                && let Some(pos) = find_matching_block_end_in_expr(
313                    line,
314                    working_set,
315                    &e.expr,
316                    global_span_offset,
317                    global_cursor_offset,
318                )
319            {
320                return Some(pos);
321            }
322
323            if let Some(redirection) = e.redirection.as_ref() {
324                match redirection {
325                    PipelineRedirection::Single { target, .. }
326                    | PipelineRedirection::Separate { out: target, .. }
327                    | PipelineRedirection::Separate { err: target, .. }
328                        if target.span().contains(global_cursor_offset) =>
329                    {
330                        if let Some(pos) = target.expr().and_then(|expr| {
331                            find_matching_block_end_in_expr(
332                                line,
333                                working_set,
334                                expr,
335                                global_span_offset,
336                                global_cursor_offset,
337                            )
338                        }) {
339                            return Some(pos);
340                        }
341                    }
342                    _ => {}
343                }
344            }
345        }
346    }
347    None
348}
349
350fn find_matching_block_end_in_expr(
351    line: &str,
352    working_set: &StateWorkingSet,
353    expression: &Expression,
354    global_span_offset: usize,
355    global_cursor_offset: usize,
356) -> Option<usize> {
357    if expression.span.contains(global_cursor_offset) && expression.span.start >= global_span_offset
358    {
359        let expr_first = expression.span.start;
360        let span_str = &line
361            [expression.span.start - global_span_offset..expression.span.end - global_span_offset];
362        let expr_last = span_str
363            .chars()
364            .last()
365            .map(|c| expression.span.end - get_char_length(c))
366            .unwrap_or(expression.span.start);
367
368        return match &expression.expr {
369            // TODO: Can't these be handled with an `_ => None` branch? Refactor
370            Expr::Bool(_) => None,
371            Expr::Int(_) => None,
372            Expr::Float(_) => None,
373            Expr::Binary(_) => None,
374            Expr::Range(..) => None,
375            Expr::Var(_) => None,
376            Expr::VarDecl(_) => None,
377            Expr::ExternalCall(..) => None,
378            Expr::Operator(_) => None,
379            Expr::UnaryNot(_) => None,
380            Expr::Keyword(..) => None,
381            Expr::ValueWithUnit(..) => None,
382            Expr::DateTime(_) => None,
383            Expr::Filepath(_, _) => None,
384            Expr::Directory(_, _) => None,
385            Expr::GlobPattern(_, _) => None,
386            Expr::String(_) => None,
387            Expr::RawString(_) => None,
388            Expr::CellPath(_) => None,
389            Expr::ImportPattern(_) => None,
390            Expr::Overlay(_) => None,
391            Expr::Signature(_) => None,
392            Expr::MatchBlock(_) => None,
393            Expr::Nothing => None,
394            Expr::Garbage => None,
395
396            Expr::AttributeBlock(ab) => ab
397                .attributes
398                .iter()
399                .find_map(|attr| {
400                    find_matching_block_end_in_expr(
401                        line,
402                        working_set,
403                        &attr.expr,
404                        global_span_offset,
405                        global_cursor_offset,
406                    )
407                })
408                .or_else(|| {
409                    find_matching_block_end_in_expr(
410                        line,
411                        working_set,
412                        &ab.item,
413                        global_span_offset,
414                        global_cursor_offset,
415                    )
416                }),
417
418            Expr::Table(table) => {
419                if expr_last == global_cursor_offset {
420                    // cursor is at table end
421                    Some(expr_first)
422                } else if expr_first == global_cursor_offset {
423                    // cursor is at table start
424                    Some(expr_last)
425                } else {
426                    // cursor is inside table
427                    table
428                        .columns
429                        .iter()
430                        .chain(table.rows.iter().flat_map(AsRef::as_ref))
431                        .find_map(|expr| {
432                            find_matching_block_end_in_expr(
433                                line,
434                                working_set,
435                                expr,
436                                global_span_offset,
437                                global_cursor_offset,
438                            )
439                        })
440                }
441            }
442
443            Expr::Record(exprs) => {
444                if expr_last == global_cursor_offset {
445                    // cursor is at record end
446                    Some(expr_first)
447                } else if expr_first == global_cursor_offset {
448                    // cursor is at record start
449                    Some(expr_last)
450                } else {
451                    // cursor is inside record
452                    exprs.iter().find_map(|expr| match expr {
453                        RecordItem::Pair(k, v) => find_matching_block_end_in_expr(
454                            line,
455                            working_set,
456                            k,
457                            global_span_offset,
458                            global_cursor_offset,
459                        )
460                        .or_else(|| {
461                            find_matching_block_end_in_expr(
462                                line,
463                                working_set,
464                                v,
465                                global_span_offset,
466                                global_cursor_offset,
467                            )
468                        }),
469                        RecordItem::Spread(_, record) => find_matching_block_end_in_expr(
470                            line,
471                            working_set,
472                            record,
473                            global_span_offset,
474                            global_cursor_offset,
475                        ),
476                    })
477                }
478            }
479
480            Expr::Call(call) => call.arguments.iter().find_map(|arg| {
481                arg.expr().and_then(|expr| {
482                    find_matching_block_end_in_expr(
483                        line,
484                        working_set,
485                        expr,
486                        global_span_offset,
487                        global_cursor_offset,
488                    )
489                })
490            }),
491
492            Expr::FullCellPath(b) => find_matching_block_end_in_expr(
493                line,
494                working_set,
495                &b.head,
496                global_span_offset,
497                global_cursor_offset,
498            ),
499
500            Expr::BinaryOp(lhs, op, rhs) => [lhs, op, rhs].into_iter().find_map(|expr| {
501                find_matching_block_end_in_expr(
502                    line,
503                    working_set,
504                    expr,
505                    global_span_offset,
506                    global_cursor_offset,
507                )
508            }),
509
510            Expr::Collect(_, expr) => find_matching_block_end_in_expr(
511                line,
512                working_set,
513                expr,
514                global_span_offset,
515                global_cursor_offset,
516            ),
517
518            Expr::Block(block_id)
519            | Expr::Closure(block_id)
520            | Expr::RowCondition(block_id)
521            | Expr::Subexpression(block_id) => {
522                if expr_last == global_cursor_offset {
523                    // cursor is at block end
524                    Some(expr_first)
525                } else if expr_first == global_cursor_offset {
526                    // cursor is at block start
527                    Some(expr_last)
528                } else {
529                    // cursor is inside block
530                    let nested_block = working_set.get_block(*block_id);
531                    find_matching_block_end_in_block(
532                        line,
533                        working_set,
534                        nested_block,
535                        global_span_offset,
536                        global_cursor_offset,
537                    )
538                }
539            }
540
541            Expr::StringInterpolation(exprs) | Expr::GlobInterpolation(exprs, _) => {
542                exprs.iter().find_map(|expr| {
543                    find_matching_block_end_in_expr(
544                        line,
545                        working_set,
546                        expr,
547                        global_span_offset,
548                        global_cursor_offset,
549                    )
550                })
551            }
552
553            Expr::List(list) => {
554                if expr_last == global_cursor_offset {
555                    // cursor is at list end
556                    Some(expr_first)
557                } else if expr_first == global_cursor_offset {
558                    // cursor is at list start
559                    Some(expr_last)
560                } else {
561                    list.iter().find_map(|item| {
562                        find_matching_block_end_in_expr(
563                            line,
564                            working_set,
565                            item.expr(),
566                            global_span_offset,
567                            global_cursor_offset,
568                        )
569                    })
570                }
571            }
572        };
573    }
574    None
575}
576
577fn get_char_at_index(s: &str, index: usize) -> Option<char> {
578    s[index..].chars().next()
579}
580
581fn get_char_length(c: char) -> usize {
582    c.to_string().len()
583}
584
585#[cfg(test)]
586mod tests {
587    use super::NuHighlighter;
588    use nu_protocol::engine::{EngineState, Stack};
589    use reedline::Highlighter;
590    use rstest::rstest;
591    use std::sync::Arc;
592
593    fn make_highlighter() -> NuHighlighter {
594        NuHighlighter::new(Arc::new(EngineState::new()), Arc::new(Stack::new()))
595    }
596
597    #[rstest]
598    // 4-byte emoji
599    #[case("\"hello ๐ŸŽ‰\" hi", 7, true)] // first byte of ๐ŸŽ‰
600    #[case("\"hello ๐ŸŽ‰\" hi", 9, true)] // third byte of ๐ŸŽ‰
601    #[case("\"hello ๐ŸŽ‰\" hi", 13, false)] // after closing quote
602    // 8-byte zwj emoji
603    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 9, true)] // inside ๐Ÿค
604    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 11, true)] // first byte of ๐Ÿฟ
605    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 13, true)] // inside ๐Ÿฟ
606    #[case("\"hello ๐Ÿค๐Ÿฟ\" hi", 17, false)] // after closing quote
607    // 3-byte unicode
608    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 2, true)] // inside ใ“
609    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 5, true)] // inside ใ‚“
610    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 13, true)] // start of ใฏ
611    #[case("\"ใ“ใ‚“ใซใกใฏ\" hi", 18, false)] // after closing quote
612    // raw string
613    #[case("r#'hello'# hi", 4, true)] // inside 'e'
614    #[case("r#'hello'# hi", 11, false)] // after closing #
615    // string interpolation
616    #[case("$\"hello\" hi", 0, true)] // $ โ€” opening StringInterpolation span (0..2)
617    #[case("$\"hello\" hi", 4, true)] // inside literal 'hello'
618    #[case("$\"hello\" hi", 9, false)] // after closing quote
619    // no string
620    #[case("1 + 2", 0, false)]
621    #[case("1 + 2", 2, false)]
622    // ExternalArg is treated as a string literal to suppress abbreviation expansion in external commands
623    #[case("ls -la", 0, false)] // on 'ls'  โ€” FlatShape::External
624    #[case("ls -la", 3, true)] // on '-la' โ€” FlatShape::ExternalArg
625    #[case("bash -c \"echo hello\"", 0, false)] // on 'bash'            โ€” FlatShape::External
626    #[case("bash -c \"echo hello\"", 5, true)] // on '-c'              โ€” FlatShape::ExternalArg
627    #[case("bash -c \"echo hello\"", 10, true)] // inside "echo hello"  โ€” FlatShape::ExternalArg
628    fn test_is_inside_string_literal(
629        #[case] line: &str,
630        #[case] cursor: usize,
631        #[case] expected: bool,
632    ) {
633        let h = make_highlighter();
634        assert_eq!(h.is_inside_string_literal(line, cursor), expected);
635    }
636}