1use std::collections::BTreeSet;
17use std::sync::Arc;
18
19#[cfg(feature = "syntax-cache")]
20#[path = "syntax_cache.rs"]
21mod cache;
22#[path = "syntax_syntect.rs"]
23mod syntect_adapter;
24
25pub use syntect_adapter::SyntectHighlighter;
26
27use crate::cells::cell_len;
28use crate::color::{Color, ColorSystem, ColorTriplet, ColorType};
29use crate::console::{Console, ConsoleOptions};
30use crate::console::{Justify, Overflow};
31use crate::measure::Measurement;
32use crate::protocol::{
33 CodeHighlighter, HighlightError, HighlightedCode, HighlightedLine, Renderable,
34};
35use crate::segment::Segment;
36use crate::style::{Style, StyleType};
37use crate::text::python_splitlines;
38use crate::text::Text;
39
40const DEFAULT_TAB_SIZE: usize = 4;
42
43pub struct Syntax {
45 code: String,
46 language: Option<String>,
47 theme: Option<String>,
49 word_wrap: bool,
50 padding: (usize, usize, usize, usize),
52 tab_size: usize,
53 highlighter: Option<Arc<dyn CodeHighlighter>>,
55 line_numbers: bool,
56 start_line: i64,
57 line_range: Option<(Option<i64>, Option<i64>)>,
58 highlight_lines: BTreeSet<i64>,
59 code_width: Option<usize>,
60 background_color: Option<String>,
61 indent_guides: bool,
62 stylized_ranges: Vec<StylizedRange>,
63}
64
65pub type SyntaxPosition = (i64, i64);
69
70#[derive(Clone, Debug)]
72struct StylizedRange {
73 style: StyleType,
74 start: SyntaxPosition,
75 end: SyntaxPosition,
76 style_before: bool,
77}
78
79const NUMBERS_COLUMN_DEFAULT_PADDING: usize = 2;
81
82fn expand_tabs(code: &str, tab_size: usize) -> String {
94 if !code.contains('\t') {
95 return code.to_string();
96 }
97 let mut out = String::with_capacity(code.len());
98 let mut column = 0usize;
99 for ch in code.chars() {
100 match ch {
101 '\t' => {
102 if tab_size > 0 {
103 let advance = tab_size - (column % tab_size);
104 out.extend(std::iter::repeat_n(' ', advance));
105 column += advance;
106 }
107 }
108 '\n' | '\r' => {
109 out.push(ch);
110 column = 0;
111 }
112 _ => {
113 out.push(ch);
114 column += 1;
115 }
116 }
117 }
118 out
119}
120
121impl Syntax {
122 pub fn word_wrap(mut self, wrap: bool) -> Self {
128 self.word_wrap = wrap;
129 self
130 }
131
132 pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
135 Syntax {
136 word_wrap: false,
137 padding: (0, 0, 0, 0),
138 tab_size: DEFAULT_TAB_SIZE,
139 code: code.into(),
140 language: Some(language.into()).filter(|l| !l.is_empty()),
141 theme: None,
142 highlighter: None,
143 line_numbers: false,
144 start_line: 1,
145 line_range: None,
146 highlight_lines: BTreeSet::new(),
147 code_width: None,
148 background_color: None,
149 indent_guides: false,
150 stylized_ranges: Vec::new(),
151 }
152 }
153
154 pub fn highlighter(mut self, highlighter: Arc<dyn CodeHighlighter>) -> Self {
157 self.highlighter = Some(highlighter);
158 self
159 }
160
161 pub fn tab_size(mut self, tab_size: usize) -> Self {
168 self.tab_size = tab_size;
169 self
170 }
171
172 pub fn padding(mut self, padding: usize) -> Self {
179 self.padding = (padding, padding, padding, padding);
180 self
181 }
182
183 pub fn padding_sides(mut self, padding: (usize, usize, usize, usize)) -> Self {
186 self.padding = padding;
187 self
188 }
189
190 pub fn line_numbers(mut self, line_numbers: bool) -> Self {
192 self.line_numbers = line_numbers;
193 self
194 }
195
196 pub fn start_line(mut self, start_line: i64) -> Self {
199 self.start_line = start_line;
200 self
201 }
202
203 pub fn line_range(mut self, start: Option<i64>, end: Option<i64>) -> Self {
209 self.line_range = Some((start, end));
210 self
211 }
212
213 pub fn highlight_lines(mut self, lines: impl IntoIterator<Item = i64>) -> Self {
216 self.highlight_lines = lines.into_iter().collect();
217 self
218 }
219
220 pub fn code_width(mut self, code_width: usize) -> Self {
223 self.code_width = Some(code_width);
224 self
225 }
226
227 pub fn background_color(mut self, color: impl Into<String>) -> Self {
229 self.background_color = Some(color.into());
230 self
231 }
232
233 pub fn indent_guides(mut self, indent_guides: bool) -> Self {
236 self.indent_guides = indent_guides;
237 self
238 }
239
240 pub fn stylize_range(
244 &mut self,
245 style: impl Into<StyleType>,
246 start: SyntaxPosition,
247 end: SyntaxPosition,
248 style_before: bool,
249 ) -> &mut Self {
250 self.stylized_ranges.push(StylizedRange {
251 style: style.into(),
252 start,
253 end,
254 style_before,
255 });
256 self
257 }
258
259 pub fn theme(mut self, theme: impl Into<String>) -> Self {
264 self.theme = Some(theme.into());
265 self
266 }
267
268 fn highlighted(&self, code: &str, console: Option<&Console>) -> HighlightedCode {
275 let (engine, theme) = self.engine_and_theme(console);
276 let language = self.language.as_deref();
277 let result = match engine.highlight(code, language, &theme) {
278 Err(HighlightError::UnknownTheme(_)) => {
279 engine.highlight(code, language, engine.default_theme())
280 }
281 other => other,
282 };
283 let highlighted = result.unwrap_or_default();
284 validate(code, highlighted)
285 }
286}
287
288fn validate(code: &str, mut highlighted: HighlightedCode) -> HighlightedCode {
292 let sources: Vec<&str> = code.split('\n').collect();
293 highlighted
294 .lines
295 .resize_with(sources.len(), HighlightedLine::default);
296 for (line, source) in highlighted.lines.iter_mut().zip(&sources) {
297 let mut end = 0usize;
298 line.spans.retain(|span| {
299 let keep = span.range.start < span.range.end
300 && span.range.start >= end
301 && span.range.end <= source.len()
302 && source.is_char_boundary(span.range.start)
303 && source.is_char_boundary(span.range.end);
304 if keep {
305 end = span.range.end;
306 }
307 keep
308 });
309 for span in &mut line.spans {
310 span.style = span.style.update_link(None);
311 }
312 line.newline_style = line.newline_style.as_ref().map(|s| s.update_link(None));
313 }
314 highlighted.default_style = highlighted.default_style.update_link(None);
315 highlighted
316}
317
318fn line_pieces<'a>(
321 source: &'a str,
322 line: &HighlightedLine,
323 default_style: &Style,
324) -> Vec<(&'a str, Style)> {
325 let mut pieces = Vec::with_capacity(line.spans.len() + 1);
326 let mut position = 0usize;
327 for span in &line.spans {
328 if span.range.start > position {
329 pieces.push((&source[position..span.range.start], default_style.clone()));
330 }
331 pieces.push((&source[span.range.clone()], span.style.clone()));
332 position = span.range.end;
333 }
334 if position < source.len() {
335 pieces.push((&source[position..], default_style.clone()));
336 }
337 pieces
338}
339
340impl Syntax {
341 fn engine_and_theme(&self, console: Option<&Console>) -> (Arc<dyn CodeHighlighter>, String) {
345 use crate::protocol::ConsoleCodeHighlighting;
346 let default = match &self.highlighter {
347 Some(_) => None,
348 None => console.and_then(|console| console.code_highlighting()),
349 };
350 let engine = self
351 .highlighter
352 .clone()
353 .or_else(|| default.map(|d| d.highlighter.clone()))
354 .unwrap_or_else(SyntectHighlighter::shared);
355 let theme = self
356 .theme
357 .as_deref()
358 .or_else(|| default.and_then(|d| d.theme.as_deref()))
359 .unwrap_or(engine.default_theme())
360 .to_string();
361 (engine, theme)
362 }
363
364 fn token_style(&self, console: &Console, token: &str) -> Style {
367 let (engine, theme) = self.engine_and_theme(Some(console));
368 let theme = if engine.themes().contains(&theme) {
369 theme
370 } else {
371 engine.default_theme().to_string()
372 };
373 engine.token_style(&theme, token).unwrap_or_default()
374 }
375
376 fn background_style(&self) -> Style {
378 self.background_color
379 .as_deref()
380 .and_then(|color| Color::parse(color).ok())
381 .map_or_else(Style::new, |color| Style::new().with_bgcolor(color))
382 }
383
384 pub fn highlight(&self) -> crate::text::Text {
389 self.highlight_text(None)
390 }
391
392 pub fn highlight_for(&self, console: &Console) -> crate::text::Text {
396 self.highlight_text(Some(console))
397 }
398
399 fn process_code(&self) -> String {
404 let mut code = expand_tabs(&self.code, self.tab_size);
405 if !code.contains('\r') {
406 return code;
407 }
408 if code.ends_with('\r') {
411 code.pop();
412 }
413 code.replace("\r\n", "\n").replace('\r', "\n")
414 }
415
416 pub fn highlight_range(
423 &self,
424 line_range: Option<(Option<i64>, Option<i64>)>,
425 console: Option<&Console>,
426 ) -> crate::text::Text {
427 let mut code = self.process_code();
428 if !code.ends_with('\n') {
429 code.push('\n');
430 }
431 let highlighted = self.highlighted(&code, console);
432 let base_style = highlighted
433 .background
434 .clone()
435 .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
436 .combine(&self.background_style());
437 let transparent = base_style.bgcolor().is_none();
438 let mut text = self.highlighted_text(&code, &highlighted, line_range);
439 text.set_base_style(base_style);
440 text.set_justify(if transparent {
441 Justify::Default
442 } else {
443 Justify::Left
444 });
445 text.set_tab_size(Some(self.tab_size));
446 text.set_no_wrap(Some(!self.word_wrap));
447 text
448 }
449
450 fn highlight_text(&self, console: Option<&Console>) -> crate::text::Text {
451 let code = self.process_code();
452 let highlighted = self.highlighted(&code, console);
453 let mut text = self.highlighted_text(&code, &highlighted, None);
454 if let Some(background) = &highlighted.background {
455 text.set_base_style(Style::new().with_bgcolor(background.clone()));
456 }
457 text
458 }
459
460 fn highlighted_text(
465 &self,
466 code: &str,
467 highlighted: &HighlightedCode,
468 line_range: Option<(Option<i64>, Option<i64>)>,
469 ) -> Text {
470 let mut text = Text::new("");
471 let (line_start, line_end) = line_range.unwrap_or((None, None));
472 let skip = line_start.filter(|&start| start != 0).map_or(0, |start| {
475 usize::try_from(start.saturating_sub(1)).unwrap_or(0)
476 });
477 let sources: Vec<&str> = code.split('\n').collect();
478 let last = sources.len().saturating_sub(1);
479 for (index, (source, line)) in sources.iter().zip(&highlighted.lines).enumerate() {
480 let mut pieces: Vec<(String, Option<Style>)> =
481 line_pieces(source, line, &highlighted.default_style)
482 .into_iter()
483 .map(|(piece, style)| (piece.to_string(), Some(style)))
484 .collect();
485 if index != last {
486 let style = line.newline_style.clone().unwrap_or_default();
489 match pieces.last_mut() {
490 Some((piece, Some(last_style)))
491 if line.newline_style.is_some() && *last_style == style =>
492 {
493 piece.push('\n');
494 }
495 _ => pieces.push(("\n".to_string(), Some(style))),
496 }
497 }
498 for (piece, style) in pieces {
499 let style = style.filter(|_| index >= skip);
501 text.append(piece.as_str(), style.map(Into::into));
502 }
503 if line_end.is_some_and(|end| end != 0 && (index as i64) + 1 >= end && index >= skip) {
506 break;
507 }
508 }
509 if let Some(color) = &self.background_color {
510 let len = text.plain().len();
511 text.stylize(StyleType::Name(format!("on {color}")), 0, len);
512 }
513 self.apply_stylized_ranges(&mut text);
514 text
515 }
516
517 fn apply_stylized_ranges(&self, text: &mut Text) {
521 if self.stylized_ranges.is_empty() {
522 return;
523 }
524 let plain = text.plain().to_string();
525 let mut offsets = vec![0usize];
527 let mut chars = 0usize;
528 for ch in plain.chars() {
529 chars += 1;
530 if ch == '\n' {
531 offsets.push(chars);
532 }
533 }
534 offsets.push(chars + 1);
535 let count = offsets.len() as i64;
540 let offset_at = |index: i64| -> Option<i64> {
541 let index = if index < 0 {
542 count.saturating_add(index)
543 } else {
544 index
545 };
546 usize::try_from(index)
547 .ok()
548 .and_then(|index| offsets.get(index))
549 .map(|&offset| offset as i64)
550 };
551 let index_for = |(line_number, column): SyntaxPosition| -> Option<i64> {
552 if line_number > count || count < line_number.saturating_add(1) {
553 return None;
554 }
555 let line_index = line_number.saturating_sub(1);
556 let line_length = offset_at(line_index.saturating_add(1))? - offset_at(line_index)? - 1;
557 Some(offset_at(line_index)?.saturating_add(column.min(line_length)))
558 };
559 let length = chars as i64;
560 let byte = |char_index: usize| {
561 plain
562 .char_indices()
563 .nth(char_index)
564 .map_or(plain.len(), |(at, _)| at)
565 };
566 for range in &self.stylized_ranges {
567 let (Some(start), Some(end)) = (index_for(range.start), index_for(range.end)) else {
568 continue;
569 };
570 let start = if start < 0 {
573 length.saturating_add(start)
574 } else {
575 start
576 };
577 let end = if end < 0 {
578 length.saturating_add(end)
579 } else {
580 end
581 };
582 if start >= length || end <= start {
583 continue;
584 }
585 let (start, end) = (byte(start.max(0) as usize), byte(end.min(length) as usize));
586 if range.style_before {
587 text.stylize_before(range.style.clone(), start, end);
588 } else {
589 text.stylize(range.style.clone(), start, end);
590 }
591 }
592 }
593}
594
595impl Syntax {
596 fn numbers_column_width(&self) -> usize {
598 if !self.line_numbers {
599 return 0;
600 }
601 let last = i128::from(self.start_line) + self.code.matches('\n').count() as i128;
603 last.to_string().len() + NUMBERS_COLUMN_DEFAULT_PADDING
604 }
605
606 fn number_styles(&self, console: &Console, base_style: &Style) -> (Style, Style, Style) {
609 let dim = |on: bool| Style::parse(if on { "dim" } else { "not dim" }).unwrap_or_default();
610 if base_style.bgcolor().is_none() {
611 return (Style::new(), dim(true), Style::new());
612 }
613 if matches!(
614 console.color_system(),
615 Some(ColorSystem::EightBit | ColorSystem::Truecolor)
616 ) {
617 let text_style = self.token_style(console, "Text");
618 let background = self.background_style();
619 let number = base_style
620 .combine(&text_style)
621 .combine(
622 &Style::new().with_color(self.line_numbers_color(console, base_style, 0.3)),
623 )
624 .combine(&background);
625 let highlight = base_style
626 .combine(&text_style)
627 .combine(
628 &Style::parse("bold")
629 .unwrap_or_default()
630 .with_color(self.line_numbers_color(console, base_style, 0.9)),
631 )
632 .combine(&background);
633 (base_style.clone(), number, highlight)
634 } else {
635 (
636 base_style.clone(),
637 base_style.combine(&dim(true)),
638 base_style.combine(&dim(false)),
639 )
640 }
641 }
642
643 fn line_numbers_color(&self, console: &Console, base_style: &Style, blend: f64) -> Color {
646 let system_defined =
647 |color: &Color| !matches!(color.kind, ColorType::EightBit | ColorType::Truecolor);
648 let Some(background) = base_style.bgcolor().filter(|color| !system_defined(color)) else {
649 return Color::default_color();
650 };
651 let text_style = self.token_style(console, "Text");
652 let Some(foreground) = text_style.color().filter(|color| !system_defined(color)) else {
653 return text_style
654 .color()
655 .cloned()
656 .unwrap_or_else(Color::default_color);
657 };
658 let (Some(bg), Some(fg)) = (background.get_truecolor(), foreground.get_truecolor()) else {
659 return Color::default_color();
660 };
661 let mix = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * blend) as u8;
662 let ColorTriplet { red, green, blue } = ColorTriplet::new(
663 mix(bg.red, fg.red),
664 mix(bg.green, fg.green),
665 mix(bg.blue, fg.blue),
666 );
667 Color::from_rgb(red, green, blue)
668 }
669
670 fn syntax_lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Vec<Segment>> {
672 let (_, pad_right, _, pad_left) = self.padding;
673 let horizontal_padding = pad_left + pad_right;
674 let numbers_column_width = self.numbers_column_width();
675 let code_width = match self.code_width {
676 Some(code_width) => code_width,
677 None => {
678 let width = if self.line_numbers {
679 options.max_width.saturating_sub(numbers_column_width + 1)
680 } else {
681 options.max_width
682 };
683 width.saturating_sub(horizontal_padding)
684 }
685 };
686
687 let ends_on_nl = self.code.ends_with('\n');
689 let mut code = self.process_code();
690 if !code.ends_with('\n') {
691 code.push('\n');
692 }
693 let highlighted = self.highlighted(&code, Some(console));
694 let background_style = self.background_style();
695 let base_style = highlighted
696 .background
697 .clone()
698 .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
699 .combine(&background_style);
700 let transparent = base_style.bgcolor().is_none();
701 let mut text = self.highlighted_text(&code, &highlighted, self.line_range);
702 text.set_base_style(base_style.clone());
703 text.set_justify(if transparent {
704 Justify::Default
705 } else {
706 Justify::Left
707 });
708 text.set_tab_size(Some(self.tab_size));
709 text.set_no_wrap(Some(!self.word_wrap));
710 let guide_style = || {
711 base_style
712 .combine(&self.token_style(console, "Comment"))
713 .combine(&Style::parse("dim").unwrap_or_default())
714 .combine(&background_style)
715 };
716 let render_text = |text: &Text, width: usize, justify: Justify| {
717 let overflow = text
718 .get_overflow()
719 .or(options.overflow)
720 .unwrap_or(Overflow::Fold);
721 let no_wrap = text.get_no_wrap().or(options.no_wrap).unwrap_or(false);
722 let mut lines = text.render_lines_wrapped_tabs(
723 console.theme(),
724 console.base_style(),
725 Some(width),
726 justify,
727 overflow,
728 no_wrap,
729 self.tab_size.max(1),
730 );
731 if lines.is_empty() {
732 lines.push(Vec::new());
733 }
734 lines
735 };
736
737 if !self.line_numbers && !self.word_wrap && self.line_range.is_none() {
738 if !ends_on_nl && text.plain().ends_with('\n') {
739 text.right_crop(1);
740 }
741 if self.indent_guides && !options.ascii_only() {
742 text = text.with_indent_guides(Some(self.tab_size), "│", guide_style());
743 text.set_overflow(Some(Overflow::Crop));
744 }
745 if code_width == 0 {
746 return Vec::new();
747 }
748 if transparent {
749 return render_text(&text, code_width, text.get_justify());
751 }
752 return render_text(&text, code_width, Justify::Left)
755 .into_iter()
756 .map(|line| {
757 let line = if background_style.is_null() {
758 line
759 } else {
760 Segment::apply_style(&line, &background_style)
761 };
762 Segment::adjust_line_length(&line, code_width, Some(background_style.clone()))
763 })
764 .collect();
765 }
766
767 let (start_line, end_line) = self.line_range.unwrap_or((None, None));
768 let line_offset = start_line.filter(|&start| start != 0).map_or(0, |start| {
770 usize::try_from(start.saturating_sub(1)).unwrap_or(0)
771 });
772 let mut lines = text.split("\n", false, ends_on_nl);
773 if self.line_range.is_some() {
774 if line_offset > lines.len() {
775 return Vec::new();
776 }
777 let len = lines.len() as i64;
779 let end = match end_line {
780 None => len,
781 Some(end) if end < 0 => (len + end).max(0),
782 Some(end) => end.min(len),
783 } as usize;
784 lines = if end > line_offset {
785 lines[line_offset..end].to_vec()
786 } else {
787 Vec::new()
788 };
789 }
790 if self.indent_guides && !options.ascii_only() {
791 let style = guide_style().combine(&Style::parse("not italic").unwrap_or_default());
792 lines = Text::new("\n")
793 .join(&lines)
794 .with_indent_guides(Some(self.tab_size), "│", style)
795 .split("\n", false, true);
796 }
797
798 let (background, number_style, highlight_number_style) =
799 self.number_styles(console, &base_style);
800 let line_pointer = if options.legacy_windows { "> " } else { "❱ " };
801 let mut out: Vec<Vec<Segment>> = Vec::new();
802 for (index, line) in lines.iter().enumerate() {
803 let line_no = i128::from(self.start_line) + (line_offset + index) as i128;
806 let wrapped_lines: Vec<Vec<Segment>> = if self.word_wrap {
807 if code_width == 0 {
808 Vec::new()
809 } else {
810 render_text(line, code_width, line.get_justify())
811 .into_iter()
812 .map(|wrapped| {
813 let wrapped = if background.is_null() {
814 wrapped
815 } else {
816 Segment::apply_style(&wrapped, &background)
817 };
818 if transparent {
819 wrapped
820 } else {
821 Segment::adjust_line_length(
822 &wrapped,
823 code_width,
824 Some(background.clone()),
825 )
826 }
827 })
828 .collect()
829 }
830 } else {
831 let segments = line.render(console.theme(), console.base_style());
832 if options.no_wrap == Some(true) {
833 vec![segments]
834 } else if transparent {
835 vec![crop_line(&segments, code_width)]
836 } else {
837 vec![Segment::adjust_line_length(
838 &segments,
839 code_width,
840 Some(background.clone()),
841 )]
842 }
843 };
844 if self.line_numbers {
845 let left_pad = Segment::new(
846 " ".repeat(numbers_column_width + 1),
847 Some(background.clone()),
848 );
849 for (first, wrapped) in wrapped_lines.into_iter().enumerate() {
850 let mut row = Vec::new();
851 if first == 0 {
852 let line_column = format!(
853 "{:>width$} ",
854 line_no,
855 width = numbers_column_width.saturating_sub(2)
856 );
857 if i64::try_from(line_no)
858 .is_ok_and(|line_no| self.highlight_lines.contains(&line_no))
859 {
860 row.push(Segment::new(
861 line_pointer,
862 Some(Style::parse("red").unwrap_or_default()),
863 ));
864 row.push(Segment::new(
865 line_column,
866 Some(highlight_number_style.clone()),
867 ));
868 } else {
869 row.push(Segment::new(" ", Some(highlight_number_style.clone())));
870 row.push(Segment::new(line_column, Some(number_style.clone())));
871 }
872 } else {
873 row.push(left_pad.clone());
874 }
875 row.extend(wrapped);
876 out.push(row);
877 }
878 } else {
879 out.extend(wrapped_lines);
880 }
881 }
882 out
883 }
884}
885
886impl Syntax {}
887
888fn crop_line(line: &[Segment], width: usize) -> Vec<Segment> {
891 let length: usize = line.iter().map(Segment::cell_length).sum();
892 if length > width {
893 Segment::adjust_line_length(line, width, None)
894 } else {
895 line.to_vec()
896 }
897}
898
899struct RenderedLines(Vec<Vec<Segment>>);
901
902impl Renderable for RenderedLines {
903 fn rich_render(&self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
904 join_lines(self.0.clone())
905 }
906
907 fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
909 Measurement::new(0, options.max_width)
910 }
911}
912
913fn join_lines(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
917 let mut segments = Vec::new();
918 let last = lines.len().saturating_sub(1);
919 for (index, line) in lines.into_iter().enumerate() {
920 if index == last && line.is_empty() {
922 segments.push(Segment::new("", None));
923 }
924 segments.extend(line);
925 if index != last {
926 segments.push(Segment::line());
927 }
928 }
929 segments
930}
931
932impl Renderable for Syntax {
933 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
936 let (_, right, _, left) = self.padding;
937 let padding = left + right;
938 let numbers = self.numbers_column_width();
939 if let Some(code_width) = self.code_width {
940 return Measurement::new(numbers, code_width + numbers + padding + 1);
941 }
942 let widest = python_splitlines(&self.code)
943 .into_iter()
944 .map(cell_len)
945 .max()
946 .unwrap_or(0);
947 let mut width = numbers + padding + widest;
948 if self.line_numbers {
949 width += 1;
950 }
951 Measurement::new(numbers, width)
952 }
953
954 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
957 let theme_background = self.highlighted("", Some(console)).background;
959 let lines = self.syntax_lines(console, options);
960 let (top, right, bottom, left) = self.padding;
961 if top == 0 && right == 0 && bottom == 0 && left == 0 {
962 return join_lines(lines);
963 }
964 let base_style = theme_background
965 .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
966 .combine(&self.background_style());
967 crate::padding::Padding::new(Box::new(RenderedLines(lines)), self.padding)
968 .style(base_style)
969 .rich_render(console, options)
970 }
971}
972
973#[cfg(test)]
974mod tests {
975 use super::*;
976 use crate::color::ColorSystem;
977 use std::sync::Arc;
978
979 fn render(code: &str, lang: &str, width: usize) -> String {
980 Console::builder()
981 .force_terminal(true)
982 .color_system(Some(ColorSystem::Truecolor))
983 .width(width)
984 .no_color(false)
985 .build()
986 .render_to_string(&Syntax::new(code, lang))
987 }
988
989 #[test]
990 fn measured_syntax_still_prints_at_full_width() {
991 let console = Console::builder().width(30).color_system(None).build();
994 let syntax = Syntax::new("x = 1", "python");
995 assert_eq!(syntax.measure(&console, &console.options()).maximum, 5);
996 let out = console.render_to_string(&syntax);
997 assert!(!out.contains('\x1b'), "{out:?}");
998 assert_eq!(cell_len(out.lines().next().unwrap()), 30, "{out:?}");
999 }
1000
1001 #[test]
1002 fn a_lone_carriage_return_breaks_the_line_like_pygments() {
1003 let render = |renderable: &dyn Renderable, width| {
1006 let console = Console::builder().width(width).color_system(None).build();
1007 console.render_to_string(renderable) + "\n"
1008 };
1009 assert_eq!(
1010 render(&Syntax::new("ab\rcd\r", "python"), 10),
1011 "ab \ncd \n"
1012 );
1013 assert_eq!(
1014 render(&Syntax::new("ab\r\ncd", "text"), 10),
1015 "ab \ncd \n"
1016 );
1017 assert_eq!(
1018 render(
1019 &crate::panel::Panel::fit(Box::new(Syntax::new("ab\rcd", "python"))),
1020 20
1021 ),
1022 "╭────╮\n│ ab │\n│ cd │\n╰────╯\n"
1023 );
1024 assert_eq!(
1025 render(&crate::markdown::Markdown::new("```python\nab\rcd\n```"), 20),
1026 " \n ab \n cd \n \n"
1027 );
1028 }
1029
1030 #[test]
1031 fn splitlines_matches_python() {
1032 assert_eq!(
1033 python_splitlines("a\r\nb\rc\u{2028}d\n"),
1034 ["a", "b", "c", "d"]
1035 );
1036 assert_eq!(python_splitlines("\n\n"), ["", ""]);
1037 assert!(python_splitlines("").is_empty());
1038 }
1039
1040 #[test]
1041 fn highlights_rust_keyword() {
1042 let out = render("fn main() {}", "rust", 20);
1045 assert!(out.contains("fn"));
1046 assert!(out.contains("main"));
1047 assert!(out.contains('\x1b'), "expected ANSI color codes");
1048 }
1049
1050 #[test]
1051 fn multiple_lines_are_separated() {
1052 let out = render("let x = 1;\nlet y = 2;", "rust", 20);
1053 assert_eq!(out.matches('\n').count(), 1);
1054 assert!(out.contains("let"));
1055 }
1056
1057 #[test]
1058 fn unknown_language_renders_plain() {
1059 let out = render("just some text", "nonsense-lang", 20);
1061 assert!(out.contains("just some text"));
1062 }
1063
1064 #[test]
1065 fn word_wrap_is_off_by_default_matching_upstream() {
1066 let code = "A".repeat(300);
1069 let out = render(&code, "python", 80);
1070 assert_eq!(out.matches('A').count(), 80, "default should crop");
1071 }
1072
1073 #[test]
1074 fn word_wrap_keeps_every_character() {
1075 let code = "A".repeat(300);
1076 let console = Console::builder().width(80).color_system(None).build();
1077 let out = console.render_to_string(&Syntax::new(code.as_str(), "python").word_wrap(true));
1078 assert_eq!(
1079 out.matches('A').count(),
1080 300,
1081 "wrapping must not lose characters:
1082{out}"
1083 );
1084 }
1085
1086 #[test]
1090 fn control_codes_are_stripped_from_highlighted_code() {
1091 let out = render("let x = 1;\u{7}\u{8}\u{b}\u{c}", "rust", 40);
1092 for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}'] {
1093 assert!(
1094 !out.contains(code),
1095 "control code {code:?} reached the output"
1096 );
1097 }
1098 assert!(out.contains("let"), "content lost with the control codes");
1099 }
1100
1101 #[test]
1105 fn word_wrap_keeps_blank_lines() {
1106 let console = Console::builder().width(20).color_system(None).build();
1107 let out =
1108 console.render_to_string(&Syntax::new("a = 1\n\nb = 2\n", "python").word_wrap(true));
1109 let rows: Vec<&str> = out.trim_end_matches('\n').split('\n').collect();
1110 assert_eq!(rows.len(), 4, "blank line lost: {rows:?}");
1116 assert!(
1117 rows[1].trim().is_empty(),
1118 "middle row should be blank: {rows:?}"
1119 );
1120 assert!(
1121 rows[3].trim().is_empty(),
1122 "trailing row should be blank: {rows:?}"
1123 );
1124 }
1125
1126 #[test]
1133 fn tabs_are_expanded_before_highlighting() {
1134 let console = Console::builder().width(30).color_system(None).build();
1135 let out = console.render_to_string(&Syntax::new(
1136 "def f():\n\tif x:\n\t\treturn 1\n\treturn 0",
1137 "python",
1138 ));
1139 assert_eq!(
1140 out.split('\n').collect::<Vec<_>>(),
1141 [
1142 "def f(): ",
1143 " if x: ",
1144 " return 1 ",
1145 " return 0 ",
1146 ]
1147 );
1148 assert!(!out.contains('\t'), "a raw tab survived: {out:?}");
1149 }
1150
1151 #[test]
1154 fn a_tab_advances_to_the_next_tab_stop() {
1155 let console = Console::builder().width(20).color_system(None).build();
1156 let out = console.render_to_string(&Syntax::new(
1157 "a\tb\tc\nab\tcd\tef\nabcd\tefgh\tijkl",
1158 "python",
1159 ));
1160 assert_eq!(
1161 out.split('\n').collect::<Vec<_>>(),
1162 [
1163 "a b c ",
1164 "ab cd ef ",
1165 "abcd efgh ijkl",
1166 ]
1167 );
1168 }
1169
1170 #[test]
1177 fn a_tabbed_line_measures_the_requested_width() {
1178 fn screen_width(row: &str) -> usize {
1181 let mut column = 0usize;
1182 for ch in row.chars() {
1183 column += if ch == '\t' {
1184 8 - (column % 8)
1185 } else {
1186 cell_len(ch.encode_utf8(&mut [0u8; 4]))
1187 };
1188 }
1189 column
1190 }
1191
1192 for width in [10usize, 20, 30, 40] {
1193 let console = Console::builder().width(width).color_system(None).build();
1194 let out = console.render_to_string(&Syntax::new("\tvalue = compute(a, b)", "python"));
1195 for row in out.split('\n') {
1196 assert_eq!(screen_width(row), width, "row {row:?} at width {width}");
1197 }
1198 }
1199 }
1200
1201 #[test]
1204 fn expand_tabs_matches_pythons_str_expandtabs() {
1205 for (input, expected) in [
1207 ("a\tb", "a b"),
1208 ("ab\tb", "ab b"),
1209 ("abc\tb", "abc b"),
1210 ("abcd\tb", "abcd b"),
1211 ("\t", " "),
1212 ("a\nbb\tc", "a\nbb c"),
1213 ("a\rbb\tc", "a\rbb c"),
1214 ("\u{4e2d}\tx", "\u{4e2d} x"),
1216 ] {
1217 assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
1218 }
1219 assert_eq!(expand_tabs("a\tb", 0), "ab");
1221 }
1222
1223 #[test]
1226 fn word_wrap_breaks_between_words() {
1227 let console = Console::builder().width(30).color_system(None).build();
1228 let code = "result = compute_total(alpha, beta, gamma, delta, epsilon, zeta, eta, theta)\n";
1231 let out = console.render_to_string(&Syntax::new(code, "python").word_wrap(true));
1232 for word in [
1235 "compute_total",
1236 "alpha",
1237 "gamma",
1238 "epsilon",
1239 "zeta",
1240 "theta",
1241 ] {
1242 assert!(
1243 out.split('\n').any(|row| row.contains(word)),
1244 "{word:?} was split across rows: {out:?}"
1245 );
1246 }
1247 }
1248
1249 use crate::protocol::HighlightSpan;
1252
1253 struct Fixed(Vec<HighlightedLine>);
1255
1256 impl CodeHighlighter for Fixed {
1257 fn highlight(
1258 &self,
1259 _code: &str,
1260 _language: Option<&str>,
1261 _theme: &str,
1262 ) -> Result<HighlightedCode, HighlightError> {
1263 Ok(HighlightedCode {
1264 lines: self.0.clone(),
1265 ..Default::default()
1266 })
1267 }
1268 fn default_theme(&self) -> &str {
1269 "fixed"
1270 }
1271 fn themes(&self) -> Vec<String> {
1272 vec!["fixed".into()]
1273 }
1274 fn languages(&self) -> Vec<String> {
1275 Vec::new()
1276 }
1277 }
1278
1279 struct ByTheme;
1281
1282 impl CodeHighlighter for ByTheme {
1283 fn highlight(
1284 &self,
1285 code: &str,
1286 _language: Option<&str>,
1287 theme: &str,
1288 ) -> Result<HighlightedCode, HighlightError> {
1289 let style = match theme {
1290 "bold" | "underline" => Style::parse(theme).unwrap(),
1291 other => return Err(HighlightError::UnknownTheme(other.into())),
1292 };
1293 let lines = code
1294 .split('\n')
1295 .map(|line| HighlightedLine {
1296 spans: (!line.is_empty())
1297 .then(|| HighlightSpan {
1298 range: 0..line.len(),
1299 style: style.clone(),
1300 })
1301 .into_iter()
1302 .collect(),
1303 newline_style: None,
1304 })
1305 .collect();
1306 Ok(HighlightedCode {
1307 lines,
1308 ..Default::default()
1309 })
1310 }
1311 fn default_theme(&self) -> &str {
1312 "bold"
1313 }
1314 fn themes(&self) -> Vec<String> {
1315 vec!["bold".into(), "underline".into()]
1316 }
1317 fn languages(&self) -> Vec<String> {
1318 Vec::new()
1319 }
1320 }
1321
1322 #[test]
1326 fn a_console_default_highlighter_applies_where_none_is_given() {
1327 use crate::markdown::Markdown;
1328 use crate::protocol::{CodeHighlighting, ConsoleCodeHighlighting};
1329 let plain = || {
1330 Console::builder()
1331 .width(20)
1332 .force_terminal(true)
1333 .color_system(Some(crate::color::ColorSystem::Truecolor))
1334 .build()
1335 };
1336 let with = |theme: Option<&str>| {
1337 let mut console = plain();
1338 console.set_code_highlighting(Some(CodeHighlighting {
1339 highlighter: Arc::new(ByTheme),
1340 theme: theme.map(str::to_string),
1341 }));
1342 console
1343 };
1344 let syntax = || Syntax::new("x = 1", "python");
1345
1346 assert!(with(None)
1347 .render_to_string(&syntax())
1348 .contains("\x1b[1mx = 1"));
1349 assert!(with(Some("underline"))
1350 .render_to_string(&syntax())
1351 .contains("\x1b[4mx = 1"));
1352 assert!(with(Some("underline"))
1354 .render_to_string(&syntax().theme("bold"))
1355 .contains("\x1b[1mx = 1"));
1356 let own = with(Some("underline"))
1359 .render_to_string(&syntax().highlighter(SyntectHighlighter::shared()));
1360 assert_eq!(own, plain().render_to_string(&syntax()));
1361 let markdown = Markdown::new("```python\nx = 1\n```");
1363 assert!(with(None)
1364 .render_to_string(&markdown)
1365 .contains("\x1b[1mx = 1"));
1366 assert!(!syntax().highlight_for(&with(None)).spans().is_empty());
1368 assert_eq!(
1369 syntax().highlight_for(&plain()).spans(),
1370 syntax().highlight().spans()
1371 );
1372 let mut cleared = with(None);
1374 cleared.set_code_highlighting(None);
1375 assert_eq!(
1376 cleared.render_to_string(&syntax()),
1377 plain().render_to_string(&syntax())
1378 );
1379 }
1380
1381 struct Broken;
1383
1384 impl CodeHighlighter for Broken {
1385 fn highlight(
1386 &self,
1387 _code: &str,
1388 _language: Option<&str>,
1389 _theme: &str,
1390 ) -> Result<HighlightedCode, HighlightError> {
1391 Err(HighlightError::Engine("boom".into()))
1392 }
1393 fn default_theme(&self) -> &str {
1394 "x"
1395 }
1396 fn themes(&self) -> Vec<String> {
1397 vec!["x".into()]
1398 }
1399 fn languages(&self) -> Vec<String> {
1400 Vec::new()
1401 }
1402 }
1403
1404 fn span(range: std::ops::Range<usize>, style: &str) -> HighlightSpan {
1405 HighlightSpan {
1406 range,
1407 style: Style::parse(style).unwrap(),
1408 }
1409 }
1410
1411 fn line(spans: Vec<HighlightSpan>) -> HighlightedLine {
1412 HighlightedLine {
1413 spans,
1414 newline_style: None,
1415 }
1416 }
1417
1418 fn render_with(code: &str, highlighter: Arc<dyn CodeHighlighter>, width: usize) -> String {
1419 Console::builder()
1420 .force_terminal(true)
1421 .color_system(Some(ColorSystem::Standard))
1422 .width(width)
1423 .build()
1424 .render_to_string(&Syntax::new(code, "python").highlighter(highlighter))
1425 }
1426
1427 #[test]
1428 fn a_custom_highlighter_drives_the_rendered_styles() {
1429 let fixed = Fixed(vec![line(vec![span(0..1, "red"), span(4..5, "bold")])]);
1430 let out = render_with("x = 1", Arc::new(fixed), 7);
1431 assert_eq!(out, "\u{1b}[31mx\u{1b}[0m = \u{1b}[1m1\u{1b}[0m");
1434 }
1435
1436 #[test]
1437 fn invalid_spans_are_dropped_not_rendered() {
1438 let fixed = Fixed(vec![line(vec![
1439 span(0..3, "red"),
1440 span(1..2, "green"), span(3..99, "blue"), span(std::ops::Range { start: 2, end: 1 }, "yellow"), ])]);
1444 let out = render_with("abcdef", Arc::new(fixed), 6);
1445 assert_eq!(out, "\u{1b}[31mabc\u{1b}[0mdef");
1446 let fixed = Fixed(vec![line(vec![span(0..1, "red")])]);
1448 let out = render_with("é", Arc::new(fixed), 1);
1449 assert_eq!(out, "é");
1450 }
1451
1452 #[test]
1453 fn missing_and_extra_lines_are_reconciled_with_the_source() {
1454 let fixed = Fixed(vec![line(vec![span(0..1, "red")])]);
1456 let out = render_with("a\nb\nc", Arc::new(fixed), 1);
1457 assert_eq!(out, "\u{1b}[31ma\u{1b}[0m\nb\nc");
1458 let fixed = Fixed(vec![
1460 line(vec![]),
1461 line(vec![]),
1462 line(vec![span(0..1, "red")]),
1463 ]);
1464 assert_eq!(render_with("a", Arc::new(fixed), 1), "a");
1465 }
1466
1467 #[test]
1468 fn highlighter_styles_cannot_add_links_or_text() {
1469 let style = Style::parse("red")
1470 .unwrap()
1471 .with_link("https://evil.example/\u{1b}]0;x\u{7}");
1472 let fixed = Fixed(vec![line(vec![HighlightSpan { range: 0..1, style }])]);
1473 let out = render_with("a\u{8}", Arc::new(fixed), 1);
1474 assert!(!out.contains("\u{1b}]8"), "hyperlink escaped: {out:?}");
1475 assert!(!out.contains("evil"), "{out:?}");
1476 assert!(!out.contains('\u{8}'), "control code survived: {out:?}");
1477 }
1478
1479 #[test]
1480 fn an_engine_failure_renders_the_source_unstyled() {
1481 let out = render_with("print(1)", Arc::new(Broken), 8);
1482 assert_eq!(out, "print(1)");
1483 }
1484
1485 #[test]
1486 fn an_unknown_theme_falls_back_to_the_default_theme() {
1487 let console = Console::builder()
1488 .force_terminal(true)
1489 .color_system(Some(ColorSystem::Truecolor))
1490 .width(20)
1491 .build();
1492 let default = console.render_to_string(&Syntax::new("x = 1", "python"));
1493 let unknown =
1494 console.render_to_string(&Syntax::new("x = 1", "python").theme("no-such-theme"));
1495 assert_eq!(default, unknown);
1496 }
1497
1498 #[test]
1499 fn highlight_text_uses_the_same_highlighter() {
1500 let fixed = Fixed(vec![line(vec![span(0..1, "red")]), line(vec![])]);
1501 let text = Syntax::new("ab\ncd", "python")
1502 .highlighter(Arc::new(fixed))
1503 .highlight();
1504 assert_eq!(text.plain(), "ab\ncd");
1505 assert_eq!(text.spans()[0].start, 0);
1506 assert_eq!(text.spans()[0].end, 1);
1507 }
1508
1509 #[test]
1512 fn ansi_themes_use_upstream_palette_colours() {
1513 let code = "# note\n@wraps\ndef greet(name):\n return f\"hi {name}\" and 42\n";
1514 let render = |theme: &str, system: ColorSystem| {
1515 Console::builder()
1516 .force_terminal(true)
1517 .color_system(Some(system))
1518 .width(40)
1519 .build()
1520 .render_to_string(&Syntax::new(code, "python").theme(theme))
1521 };
1522 let dark = render("ansi_dark", ColorSystem::Truecolor);
1523 assert!(!dark.contains("38;2;") && !dark.contains("48;"), "{dark:?}");
1525 assert!(dark.contains("\u{1b}[2m#"), "comment is dim: {dark:?}");
1526 assert!(
1527 dark.contains("\u{1b}[1;95m@"),
1528 "decorator bold bright_magenta: {dark:?}"
1529 );
1530 assert!(
1531 dark.contains("\u{1b}[33mf"),
1532 "string prefix is part of the string: {dark:?}"
1533 );
1534 assert!(
1535 dark.contains("\u{1b}[94mdef"),
1536 "keyword bright_blue: {dark:?}"
1537 );
1538 assert!(
1539 dark.contains("\u{1b}[92mgreet"),
1540 "function bright_green: {dark:?}"
1541 );
1542 assert!(
1543 dark.contains("\u{1b}[94m42"),
1544 "number bright_blue: {dark:?}"
1545 );
1546 assert!(
1547 dark.contains("\u{1b}[95mand"),
1548 "operator word bright_magenta: {dark:?}"
1549 );
1550 assert!(dark.contains("\u{1b}[33m"), "string yellow: {dark:?}");
1551 let light = render("ansi_light", ColorSystem::Truecolor);
1552 assert!(light.contains("\u{1b}[34mdef"), "keyword blue: {light:?}");
1553 assert!(
1554 light.contains("\u{1b}[32mgreet"),
1555 "function green: {light:?}"
1556 );
1557 assert_eq!(render("ansi_dark", ColorSystem::Standard), dark);
1559 }
1560}