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