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#[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 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 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 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 if highlight_resolved_externals {
167 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 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 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 global_cursor_offset
271 };
272
273 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 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 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 Some(expr_first)
422 } else if expr_first == global_cursor_offset {
423 Some(expr_last)
425 } else {
426 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 Some(expr_first)
447 } else if expr_first == global_cursor_offset {
448 Some(expr_last)
450 } else {
451 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 Some(expr_first)
525 } else if expr_first == global_cursor_offset {
526 Some(expr_last)
528 } else {
529 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 Some(expr_first)
557 } else if expr_first == global_cursor_offset {
558 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 #[case("\"hello ๐\" hi", 7, true)] #[case("\"hello ๐\" hi", 9, true)] #[case("\"hello ๐\" hi", 13, false)] #[case("\"hello ๐ค๐ฟ\" hi", 9, true)] #[case("\"hello ๐ค๐ฟ\" hi", 11, true)] #[case("\"hello ๐ค๐ฟ\" hi", 13, true)] #[case("\"hello ๐ค๐ฟ\" hi", 17, false)] #[case("\"ใใใซใกใฏ\" hi", 2, true)] #[case("\"ใใใซใกใฏ\" hi", 5, true)] #[case("\"ใใใซใกใฏ\" hi", 13, true)] #[case("\"ใใใซใกใฏ\" hi", 18, false)] #[case("r#'hello'# hi", 4, true)] #[case("r#'hello'# hi", 11, false)] #[case("$\"hello\" hi", 0, true)] #[case("$\"hello\" hi", 4, true)] #[case("$\"hello\" hi", 9, false)] #[case("1 + 2", 0, false)]
621 #[case("1 + 2", 2, false)]
622 #[case("ls -la", 0, false)] #[case("ls -la", 3, true)] #[case("bash -c \"echo hello\"", 0, false)] #[case("bash -c \"echo hello\"", 5, true)] #[case("bash -c \"echo hello\"", 10, true)] 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}