1use std::collections::BTreeSet;
25use std::sync::Arc;
26
27#[cfg(feature = "syntax-cache")]
28#[path = "syntax_cache.rs"]
29mod cache;
30#[cfg(feature = "syntax")]
31#[path = "syntax_syntect.rs"]
32mod syntect_adapter;
33
34#[cfg(feature = "syntax")]
35pub use syntect_adapter::SyntectHighlighter;
36
37use crate::cells::cell_len;
38use crate::color::{Color, ColorSystem, ColorTriplet, ColorType};
39use crate::console::{Console, ConsoleOptions};
40use crate::console::{Justify, Overflow};
41use crate::measure::Measurement;
42use crate::protocol::{
43 CodeHighlighter, HighlightError, HighlightedCode, HighlightedLine, Renderable,
44};
45use crate::segment::Segment;
46use crate::style::{Style, StyleType};
47use crate::text::python_splitlines;
48use crate::text::Text;
49
50const DEFAULT_TAB_SIZE: usize = 4;
52
53pub struct Syntax {
70 code: String,
71 language: Option<String>,
72 theme: Option<String>,
74 word_wrap: bool,
75 padding: (usize, usize, usize, usize),
77 tab_size: usize,
78 dedent: bool,
79 highlighter: Option<Arc<dyn CodeHighlighter>>,
81 line_numbers: bool,
82 start_line: i64,
83 line_range: Option<(Option<i64>, Option<i64>)>,
84 highlight_lines: BTreeSet<i64>,
85 code_width: Option<usize>,
86 background_color: Option<String>,
87 indent_guides: bool,
88 stylized_ranges: Vec<StylizedRange>,
89}
90
91pub type SyntaxPosition = (i64, i64);
95
96#[derive(Clone, Debug)]
98struct StylizedRange {
99 style: StyleType,
100 start: SyntaxPosition,
101 end: SyntaxPosition,
102 style_before: bool,
103}
104
105const NUMBERS_COLUMN_DEFAULT_PADDING: usize = 2;
107
108fn dedent(code: &str) -> String {
123 let lines: Vec<&str> = code
124 .split('\n')
125 .map(|line| {
126 if !line.is_empty() && line.bytes().all(|b| b == b' ' || b == b'\t') {
127 ""
128 } else {
129 line
130 }
131 })
132 .collect();
133 let mut margin: Option<&str> = None;
134 for line in &lines {
135 let indent_len = line.len() - line.trim_start_matches([' ', '\t']).len();
136 if indent_len == line.len() {
137 continue;
138 }
139 let indent = &line[..indent_len];
140 margin = Some(match margin {
141 None => indent,
142 Some(margin) if indent.starts_with(margin) => margin,
143 Some(margin) if margin.starts_with(indent) => indent,
144 Some(margin) => {
145 let common = margin
146 .bytes()
147 .zip(indent.bytes())
148 .take_while(|(x, y)| x == y)
149 .count();
150 &margin[..common]
151 }
152 });
153 }
154 let margin = margin.unwrap_or("");
155 lines
156 .iter()
157 .map(|line| line.strip_prefix(margin).unwrap_or(line))
158 .collect::<Vec<_>>()
159 .join("\n")
160}
161
162fn expand_tabs(code: &str, tab_size: usize) -> String {
163 if !code.contains('\t') {
164 return code.to_string();
165 }
166 let mut out = String::with_capacity(code.len());
167 let mut column = 0usize;
168 for ch in code.chars() {
169 match ch {
170 '\t' => {
171 if tab_size > 0 {
172 let advance = tab_size - (column % tab_size);
173 out.extend(std::iter::repeat_n(' ', advance));
174 column += advance;
175 }
176 }
177 '\n' | '\r' => {
178 out.push(ch);
179 column = 0;
180 }
181 _ => {
182 out.push(ch);
183 column += 1;
184 }
185 }
186 }
187 out
188}
189
190impl Syntax {
191 pub fn word_wrap(mut self, wrap: bool) -> Self {
197 self.word_wrap = wrap;
198 self
199 }
200
201 pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
204 Syntax {
205 word_wrap: false,
206 padding: (0, 0, 0, 0),
207 tab_size: DEFAULT_TAB_SIZE,
208 dedent: false,
209 code: code.into(),
210 language: Some(language.into()).filter(|l| !l.is_empty()),
211 theme: None,
212 highlighter: None,
213 line_numbers: false,
214 start_line: 1,
215 line_range: None,
216 highlight_lines: BTreeSet::new(),
217 code_width: None,
218 background_color: None,
219 indent_guides: false,
220 stylized_ranges: Vec::new(),
221 }
222 }
223
224 pub fn highlighter(mut self, highlighter: Arc<dyn CodeHighlighter>) -> Self {
228 self.highlighter = Some(highlighter);
229 self
230 }
231
232 pub fn tab_size(mut self, tab_size: usize) -> Self {
239 self.tab_size = tab_size;
240 self
241 }
242
243 pub fn dedent(mut self, dedent: bool) -> Self {
246 self.dedent = dedent;
247 self
248 }
249
250 pub fn from_path(
255 path: impl AsRef<std::path::Path>,
256 lexer: Option<&str>,
257 ) -> std::io::Result<Self> {
258 let path = path.as_ref();
259 let code = std::fs::read_to_string(path)?;
260 let lexer = match lexer {
261 Some(lexer) if !lexer.is_empty() => lexer.to_string(),
262 _ => Syntax::guess_lexer(path),
263 };
264 Ok(Syntax::new(code, lexer))
265 }
266
267 pub fn guess_lexer(path: impl AsRef<std::path::Path>) -> String {
272 let path = path.as_ref();
273 path.extension()
274 .map(|ext| ext.to_string_lossy().to_lowercase())
275 .or_else(|| {
276 path.file_name()
277 .map(|name| name.to_string_lossy().into_owned())
278 })
279 .filter(|name| !name.is_empty())
280 .unwrap_or_else(|| "default".to_string())
281 }
282
283 pub fn padding(mut self, padding: usize) -> Self {
290 self.padding = (padding, padding, padding, padding);
291 self
292 }
293
294 pub fn padding_sides(mut self, padding: (usize, usize, usize, usize)) -> Self {
297 self.padding = padding;
298 self
299 }
300
301 pub fn line_numbers(mut self, line_numbers: bool) -> Self {
303 self.line_numbers = line_numbers;
304 self
305 }
306
307 pub fn start_line(mut self, start_line: i64) -> Self {
310 self.start_line = start_line;
311 self
312 }
313
314 pub fn line_range(mut self, start: Option<i64>, end: Option<i64>) -> Self {
320 self.line_range = Some((start, end));
321 self
322 }
323
324 pub fn highlight_lines(mut self, lines: impl IntoIterator<Item = i64>) -> Self {
327 self.highlight_lines = lines.into_iter().collect();
328 self
329 }
330
331 pub fn code_width(mut self, code_width: usize) -> Self {
334 self.code_width = Some(code_width);
335 self
336 }
337
338 pub fn background_color(mut self, color: impl Into<String>) -> Self {
340 self.background_color = Some(color.into());
341 self
342 }
343
344 pub fn indent_guides(mut self, indent_guides: bool) -> Self {
347 self.indent_guides = indent_guides;
348 self
349 }
350
351 pub fn stylize_range(
355 &mut self,
356 style: impl Into<StyleType>,
357 start: SyntaxPosition,
358 end: SyntaxPosition,
359 style_before: bool,
360 ) -> &mut Self {
361 self.stylized_ranges.push(StylizedRange {
362 style: style.into(),
363 start,
364 end,
365 style_before,
366 });
367 self
368 }
369
370 pub fn theme(mut self, theme: impl Into<String>) -> Self {
375 self.theme = Some(theme.into());
376 self
377 }
378
379 fn highlighted(&self, code: &str, console: Option<&Console>) -> HighlightedCode {
386 let (engine, theme) = self.engine_and_theme(console);
387 let language = self.language.as_deref();
388 let result = match engine.highlight(code, language, &theme) {
389 Err(HighlightError::UnknownTheme(_)) => {
390 engine.highlight(code, language, engine.default_theme())
391 }
392 other => other,
393 };
394 let highlighted = result.unwrap_or_default();
395 validate(code, highlighted)
396 }
397}
398
399#[cfg(feature = "syntax")]
401fn builtin_highlighter() -> Arc<dyn CodeHighlighter> {
402 SyntectHighlighter::shared()
403}
404
405#[cfg(not(feature = "syntax"))]
408fn builtin_highlighter() -> Arc<dyn CodeHighlighter> {
409 use std::sync::OnceLock;
410 static SHARED: OnceLock<Arc<dyn CodeHighlighter>> = OnceLock::new();
411 SHARED.get_or_init(|| Arc::new(PlainCode)).clone()
412}
413
414#[cfg(not(feature = "syntax"))]
416struct PlainCode;
417
418#[cfg(not(feature = "syntax"))]
419impl CodeHighlighter for PlainCode {
420 fn highlight(
421 &self,
422 code: &str,
423 _language: Option<&str>,
424 _theme: &str,
425 ) -> Result<HighlightedCode, HighlightError> {
426 Ok(HighlightedCode {
427 lines: code
428 .split('\n')
429 .map(|_| HighlightedLine::default())
430 .collect(),
431 ..HighlightedCode::default()
432 })
433 }
434
435 fn default_theme(&self) -> &str {
436 "plain"
437 }
438
439 fn themes(&self) -> Vec<String> {
440 vec!["plain".to_string()]
441 }
442
443 fn languages(&self) -> Vec<String> {
444 Vec::new()
445 }
446}
447
448fn validate(code: &str, mut highlighted: HighlightedCode) -> HighlightedCode {
452 let sources: Vec<&str> = code.split('\n').collect();
453 highlighted
454 .lines
455 .resize_with(sources.len(), HighlightedLine::default);
456 for (line, source) in highlighted.lines.iter_mut().zip(&sources) {
457 let mut end = 0usize;
458 line.spans.retain(|span| {
459 let keep = span.range.start < span.range.end
460 && span.range.start >= end
461 && span.range.end <= source.len()
462 && source.is_char_boundary(span.range.start)
463 && source.is_char_boundary(span.range.end);
464 if keep {
465 end = span.range.end;
466 }
467 keep
468 });
469 for span in &mut line.spans {
470 span.style = span.style.update_link(None);
471 }
472 line.newline_style = line.newline_style.as_ref().map(|s| s.update_link(None));
473 }
474 highlighted.default_style = highlighted.default_style.update_link(None);
475 highlighted
476}
477
478fn line_pieces<'a>(
481 source: &'a str,
482 line: &HighlightedLine,
483 default_style: &Style,
484) -> Vec<(&'a str, Style)> {
485 let mut pieces = Vec::with_capacity(line.spans.len() + 1);
486 let mut position = 0usize;
487 for span in &line.spans {
488 if span.range.start > position {
489 pieces.push((&source[position..span.range.start], default_style.clone()));
490 }
491 pieces.push((&source[span.range.clone()], span.style.clone()));
492 position = span.range.end;
493 }
494 if position < source.len() {
495 pieces.push((&source[position..], default_style.clone()));
496 }
497 pieces
498}
499
500impl Syntax {
501 fn engine_and_theme(&self, console: Option<&Console>) -> (Arc<dyn CodeHighlighter>, String) {
506 use crate::protocol::ConsoleCodeHighlighting;
507 let default = match &self.highlighter {
508 Some(_) => None,
509 None => console.and_then(|console| console.code_highlighting()),
510 };
511 let engine = self
512 .highlighter
513 .clone()
514 .or_else(|| default.map(|d| d.highlighter.clone()))
515 .unwrap_or_else(builtin_highlighter);
516 let theme = self
517 .theme
518 .as_deref()
519 .or_else(|| default.and_then(|d| d.theme.as_deref()))
520 .unwrap_or(engine.default_theme())
521 .to_string();
522 (engine, theme)
523 }
524
525 fn token_style(&self, console: &Console, token: &str) -> Style {
528 let (engine, theme) = self.engine_and_theme(Some(console));
529 let theme = if engine.themes().contains(&theme) {
530 theme
531 } else {
532 engine.default_theme().to_string()
533 };
534 engine.token_style(&theme, token).unwrap_or_default()
535 }
536
537 fn background_style(&self) -> Style {
539 self.background_color
540 .as_deref()
541 .and_then(|color| Color::parse(color).ok())
542 .map_or_else(Style::new, |color| Style::new().with_bgcolor(color))
543 }
544
545 pub fn highlight(&self) -> crate::text::Text {
550 self.highlight_text(None)
551 }
552
553 pub fn highlight_for(&self, console: &Console) -> crate::text::Text {
557 self.highlight_text(Some(console))
558 }
559
560 fn process_code(&self) -> String {
565 let mut code = if self.dedent {
566 expand_tabs(&dedent(&self.code), self.tab_size)
567 } else {
568 expand_tabs(&self.code, self.tab_size)
569 };
570 if !code.contains('\r') {
571 return code;
572 }
573 if code.ends_with('\r') {
576 code.pop();
577 }
578 code.replace("\r\n", "\n").replace('\r', "\n")
579 }
580
581 pub fn highlight_range(
588 &self,
589 line_range: Option<(Option<i64>, Option<i64>)>,
590 console: Option<&Console>,
591 ) -> crate::text::Text {
592 let mut code = self.process_code();
593 if !code.ends_with('\n') {
594 code.push('\n');
595 }
596 let highlighted = self.highlighted(&code, console);
597 let base_style = highlighted
598 .background
599 .clone()
600 .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
601 .combine(&self.background_style());
602 let transparent = base_style.bgcolor().is_none();
603 let mut text = self.highlighted_text(&code, &highlighted, line_range);
604 text.set_base_style(base_style);
605 text.set_justify(if transparent {
606 Justify::Default
607 } else {
608 Justify::Left
609 });
610 text.set_tab_size(Some(self.tab_size));
611 text.set_no_wrap(Some(!self.word_wrap));
612 text
613 }
614
615 fn highlight_text(&self, console: Option<&Console>) -> crate::text::Text {
616 let code = self.process_code();
617 let highlighted = self.highlighted(&code, console);
618 let mut text = self.highlighted_text(&code, &highlighted, None);
619 if let Some(background) = &highlighted.background {
620 text.set_base_style(Style::new().with_bgcolor(background.clone()));
621 }
622 text
623 }
624
625 fn highlighted_text(
630 &self,
631 code: &str,
632 highlighted: &HighlightedCode,
633 line_range: Option<(Option<i64>, Option<i64>)>,
634 ) -> Text {
635 let mut text = Text::new("");
636 let (line_start, line_end) = line_range.unwrap_or((None, None));
637 let skip = line_start.filter(|&start| start != 0).map_or(0, |start| {
640 usize::try_from(start.saturating_sub(1)).unwrap_or(0)
641 });
642 let sources: Vec<&str> = code.split('\n').collect();
643 let last = sources.len().saturating_sub(1);
644 for (index, (source, line)) in sources.iter().zip(&highlighted.lines).enumerate() {
645 let mut pieces: Vec<(String, Option<Style>)> =
646 line_pieces(source, line, &highlighted.default_style)
647 .into_iter()
648 .map(|(piece, style)| (piece.to_string(), Some(style)))
649 .collect();
650 if index != last {
651 let style = line.newline_style.clone().unwrap_or_default();
654 match pieces.last_mut() {
655 Some((piece, Some(last_style)))
656 if line.newline_style.is_some() && *last_style == style =>
657 {
658 piece.push('\n');
659 }
660 _ => pieces.push(("\n".to_string(), Some(style))),
661 }
662 }
663 for (piece, style) in pieces {
664 let style = style.filter(|_| index >= skip);
666 text.append(piece.as_str(), style.map(Into::into));
667 }
668 if line_end.is_some_and(|end| end != 0 && (index as i64) + 1 >= end && index >= skip) {
671 break;
672 }
673 }
674 if let Some(color) = &self.background_color {
675 let len = text.plain().len();
676 text.stylize(StyleType::Name(format!("on {color}")), 0, len);
677 }
678 self.apply_stylized_ranges(&mut text);
679 text
680 }
681
682 fn apply_stylized_ranges(&self, text: &mut Text) {
686 if self.stylized_ranges.is_empty() {
687 return;
688 }
689 let plain = text.plain().to_string();
690 let mut offsets = vec![0usize];
692 let mut chars = 0usize;
693 for ch in plain.chars() {
694 chars += 1;
695 if ch == '\n' {
696 offsets.push(chars);
697 }
698 }
699 offsets.push(chars + 1);
700 let count = offsets.len() as i64;
705 let offset_at = |index: i64| -> Option<i64> {
706 let index = if index < 0 {
707 count.saturating_add(index)
708 } else {
709 index
710 };
711 usize::try_from(index)
712 .ok()
713 .and_then(|index| offsets.get(index))
714 .map(|&offset| offset as i64)
715 };
716 let index_for = |(line_number, column): SyntaxPosition| -> Option<i64> {
717 if line_number > count || count < line_number.saturating_add(1) {
718 return None;
719 }
720 let line_index = line_number.saturating_sub(1);
721 let line_length = offset_at(line_index.saturating_add(1))? - offset_at(line_index)? - 1;
722 Some(offset_at(line_index)?.saturating_add(column.min(line_length)))
723 };
724 let length = chars as i64;
725 let byte = |char_index: usize| {
726 plain
727 .char_indices()
728 .nth(char_index)
729 .map_or(plain.len(), |(at, _)| at)
730 };
731 for range in &self.stylized_ranges {
732 let (Some(start), Some(end)) = (index_for(range.start), index_for(range.end)) else {
733 continue;
734 };
735 let start = if start < 0 {
738 length.saturating_add(start)
739 } else {
740 start
741 };
742 let end = if end < 0 {
743 length.saturating_add(end)
744 } else {
745 end
746 };
747 if start >= length || end <= start {
748 continue;
749 }
750 let (start, end) = (byte(start.max(0) as usize), byte(end.min(length) as usize));
751 if range.style_before {
752 text.stylize_before(range.style.clone(), start, end);
753 } else {
754 text.stylize(range.style.clone(), start, end);
755 }
756 }
757 }
758}
759
760impl Syntax {
761 fn numbers_column_width(&self) -> usize {
763 if !self.line_numbers {
764 return 0;
765 }
766 let last = i128::from(self.start_line) + self.code.matches('\n').count() as i128;
768 last.to_string().len() + NUMBERS_COLUMN_DEFAULT_PADDING
769 }
770
771 fn number_styles(&self, console: &Console, base_style: &Style) -> (Style, Style, Style) {
774 let dim = |on: bool| Style::parse(if on { "dim" } else { "not dim" }).unwrap_or_default();
775 if base_style.bgcolor().is_none() {
776 return (Style::new(), dim(true), Style::new());
777 }
778 if matches!(
779 console.color_system(),
780 Some(ColorSystem::EightBit | ColorSystem::Truecolor)
781 ) {
782 let text_style = self.token_style(console, "Text");
783 let background = self.background_style();
784 let number = base_style
785 .combine(&text_style)
786 .combine(
787 &Style::new().with_color(self.line_numbers_color(console, base_style, 0.3)),
788 )
789 .combine(&background);
790 let highlight = base_style
791 .combine(&text_style)
792 .combine(
793 &Style::parse("bold")
794 .unwrap_or_default()
795 .with_color(self.line_numbers_color(console, base_style, 0.9)),
796 )
797 .combine(&background);
798 (base_style.clone(), number, highlight)
799 } else {
800 (
801 base_style.clone(),
802 base_style.combine(&dim(true)),
803 base_style.combine(&dim(false)),
804 )
805 }
806 }
807
808 fn line_numbers_color(&self, console: &Console, base_style: &Style, blend: f64) -> Color {
811 let system_defined =
812 |color: &Color| !matches!(color.kind, ColorType::EightBit | ColorType::Truecolor);
813 let Some(background) = base_style.bgcolor().filter(|color| !system_defined(color)) else {
814 return Color::default_color();
815 };
816 let text_style = self.token_style(console, "Text");
817 let Some(foreground) = text_style.color().filter(|color| !system_defined(color)) else {
818 return text_style
819 .color()
820 .cloned()
821 .unwrap_or_else(Color::default_color);
822 };
823 let (Some(bg), Some(fg)) = (background.get_truecolor(), foreground.get_truecolor()) else {
824 return Color::default_color();
825 };
826 let mix = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * blend) as u8;
827 let ColorTriplet { red, green, blue } = ColorTriplet::new(
828 mix(bg.red, fg.red),
829 mix(bg.green, fg.green),
830 mix(bg.blue, fg.blue),
831 );
832 Color::from_rgb(red, green, blue)
833 }
834
835 fn syntax_lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Vec<Segment>> {
837 let (_, pad_right, _, pad_left) = self.padding;
838 let horizontal_padding = pad_left + pad_right;
839 let numbers_column_width = self.numbers_column_width();
840 let code_width = match self.code_width {
841 Some(code_width) => code_width,
842 None => {
843 let width = if self.line_numbers {
844 options.max_width.saturating_sub(numbers_column_width + 1)
845 } else {
846 options.max_width
847 };
848 width.saturating_sub(horizontal_padding)
849 }
850 };
851
852 let ends_on_nl = self.code.ends_with('\n');
854 let mut code = self.process_code();
855 if !code.ends_with('\n') {
856 code.push('\n');
857 }
858 let highlighted = self.highlighted(&code, Some(console));
859 let background_style = self.background_style();
860 let base_style = highlighted
861 .background
862 .clone()
863 .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
864 .combine(&background_style);
865 let transparent = base_style.bgcolor().is_none();
866 let mut text = self.highlighted_text(&code, &highlighted, self.line_range);
867 text.set_base_style(base_style.clone());
868 text.set_justify(if transparent {
869 Justify::Default
870 } else {
871 Justify::Left
872 });
873 text.set_tab_size(Some(self.tab_size));
874 text.set_no_wrap(Some(!self.word_wrap));
875 let guide_style = || {
876 base_style
877 .combine(&self.token_style(console, "Comment"))
878 .combine(&Style::parse("dim").unwrap_or_default())
879 .combine(&background_style)
880 };
881 let render_text = |text: &Text, width: usize, justify: Justify| {
882 let overflow = text
883 .get_overflow()
884 .or(options.overflow)
885 .unwrap_or(Overflow::Fold);
886 let no_wrap = text.get_no_wrap().or(options.no_wrap).unwrap_or(false);
887 let mut lines = text.render_lines_wrapped_tabs(
888 console.theme(),
889 console.base_style(),
890 Some(width),
891 justify,
892 overflow,
893 no_wrap,
894 self.tab_size.max(1),
895 );
896 if lines.is_empty() {
897 lines.push(Vec::new());
898 }
899 lines
900 };
901
902 if !self.line_numbers && !self.word_wrap && self.line_range.is_none() {
903 if !ends_on_nl && text.plain().ends_with('\n') {
904 text.right_crop(1);
905 }
906 if self.indent_guides && !options.ascii_only() {
907 text = text.with_indent_guides(Some(self.tab_size), "│", guide_style());
908 text.set_overflow(Some(Overflow::Crop));
909 }
910 if code_width == 0 {
911 return Vec::new();
912 }
913 if transparent {
914 return render_text(&text, code_width, text.get_justify());
916 }
917 return render_text(&text, code_width, Justify::Left)
920 .into_iter()
921 .map(|line| {
922 let line = if background_style.is_null() {
923 line
924 } else {
925 Segment::apply_style(&line, &background_style)
926 };
927 Segment::adjust_line_length(&line, code_width, Some(background_style.clone()))
928 })
929 .collect();
930 }
931
932 let (start_line, end_line) = self.line_range.unwrap_or((None, None));
933 let line_offset = start_line.filter(|&start| start != 0).map_or(0, |start| {
935 usize::try_from(start.saturating_sub(1)).unwrap_or(0)
936 });
937 let mut lines = text.split("\n", false, ends_on_nl);
938 if self.line_range.is_some() {
939 if line_offset > lines.len() {
940 return Vec::new();
941 }
942 let len = lines.len() as i64;
944 let end = match end_line {
945 None => len,
946 Some(end) if end < 0 => (len + end).max(0),
947 Some(end) => end.min(len),
948 } as usize;
949 lines = if end > line_offset {
950 lines[line_offset..end].to_vec()
951 } else {
952 Vec::new()
953 };
954 }
955 if self.indent_guides && !options.ascii_only() {
956 let style = guide_style().combine(&Style::parse("not italic").unwrap_or_default());
957 lines = Text::new("\n")
958 .join(&lines)
959 .with_indent_guides(Some(self.tab_size), "│", style)
960 .split("\n", false, true);
961 }
962
963 let (background, number_style, highlight_number_style) =
964 self.number_styles(console, &base_style);
965 let line_pointer = if options.legacy_windows { "> " } else { "❱ " };
966 let mut out: Vec<Vec<Segment>> = Vec::new();
967 for (index, line) in lines.iter().enumerate() {
968 let line_no = i128::from(self.start_line) + (line_offset + index) as i128;
971 let wrapped_lines: Vec<Vec<Segment>> = if self.word_wrap {
972 if code_width == 0 {
973 Vec::new()
974 } else {
975 render_text(line, code_width, line.get_justify())
976 .into_iter()
977 .map(|wrapped| {
978 let wrapped = if background.is_null() {
979 wrapped
980 } else {
981 Segment::apply_style(&wrapped, &background)
982 };
983 if transparent {
984 wrapped
985 } else {
986 Segment::adjust_line_length(
987 &wrapped,
988 code_width,
989 Some(background.clone()),
990 )
991 }
992 })
993 .collect()
994 }
995 } else {
996 let segments = line.render(console.theme(), console.base_style());
997 if options.no_wrap == Some(true) {
998 vec![segments]
999 } else if transparent {
1000 vec![crop_line(&segments, code_width)]
1001 } else {
1002 vec![Segment::adjust_line_length(
1003 &segments,
1004 code_width,
1005 Some(background.clone()),
1006 )]
1007 }
1008 };
1009 if self.line_numbers {
1010 let left_pad = Segment::new(
1011 " ".repeat(numbers_column_width + 1),
1012 Some(background.clone()),
1013 );
1014 for (first, wrapped) in wrapped_lines.into_iter().enumerate() {
1015 let mut row = Vec::new();
1016 if first == 0 {
1017 let line_column = format!(
1018 "{:>width$} ",
1019 line_no,
1020 width = numbers_column_width.saturating_sub(2)
1021 );
1022 if i64::try_from(line_no)
1023 .is_ok_and(|line_no| self.highlight_lines.contains(&line_no))
1024 {
1025 row.push(Segment::new(
1026 line_pointer,
1027 Some(Style::parse("red").unwrap_or_default()),
1028 ));
1029 row.push(Segment::new(
1030 line_column,
1031 Some(highlight_number_style.clone()),
1032 ));
1033 } else {
1034 row.push(Segment::new(" ", Some(highlight_number_style.clone())));
1035 row.push(Segment::new(line_column, Some(number_style.clone())));
1036 }
1037 } else {
1038 row.push(left_pad.clone());
1039 }
1040 row.extend(wrapped);
1041 out.push(row);
1042 }
1043 } else {
1044 out.extend(wrapped_lines);
1045 }
1046 }
1047 out
1048 }
1049}
1050
1051impl Syntax {}
1052
1053fn crop_line(line: &[Segment], width: usize) -> Vec<Segment> {
1056 let length: usize = line.iter().map(Segment::cell_length).sum();
1057 if length > width {
1058 Segment::adjust_line_length(line, width, None)
1059 } else {
1060 line.to_vec()
1061 }
1062}
1063
1064struct RenderedLines(Vec<Vec<Segment>>);
1066
1067impl Renderable for RenderedLines {
1068 fn rich_render(&self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
1069 join_lines(self.0.clone())
1070 }
1071
1072 fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
1074 Measurement::new(0, options.max_width)
1075 }
1076}
1077
1078fn join_lines(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
1082 let mut segments = Vec::new();
1083 let last = lines.len().saturating_sub(1);
1084 for (index, line) in lines.into_iter().enumerate() {
1085 if index == last && line.is_empty() {
1087 segments.push(Segment::new("", None));
1088 }
1089 segments.extend(line);
1090 if index != last {
1091 segments.push(Segment::line());
1092 }
1093 }
1094 segments
1095}
1096
1097impl Renderable for Syntax {
1098 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
1101 let (_, right, _, left) = self.padding;
1102 let padding = left + right;
1103 let numbers = self.numbers_column_width();
1104 if let Some(code_width) = self.code_width {
1105 return Measurement::new(numbers, code_width + numbers + padding + 1);
1106 }
1107 let widest = python_splitlines(&self.code)
1108 .into_iter()
1109 .map(cell_len)
1110 .max()
1111 .unwrap_or(0);
1112 let mut width = numbers + padding + widest;
1113 if self.line_numbers {
1114 width += 1;
1115 }
1116 Measurement::new(numbers, width)
1117 }
1118
1119 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1122 let theme_background = self.highlighted("", Some(console)).background;
1124 let lines = self.syntax_lines(console, options);
1125 let (top, right, bottom, left) = self.padding;
1126 if top == 0 && right == 0 && bottom == 0 && left == 0 {
1127 return join_lines(lines);
1128 }
1129 let base_style = theme_background
1130 .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
1131 .combine(&self.background_style());
1132 crate::padding::Padding::new(Box::new(RenderedLines(lines)), self.padding)
1133 .style(base_style)
1134 .rich_render(console, options)
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141 use crate::color::ColorSystem;
1142 use std::sync::Arc;
1143
1144 fn render(code: &str, lang: &str, width: usize) -> String {
1145 Console::builder()
1146 .force_terminal(true)
1147 .color_system(Some(ColorSystem::Truecolor))
1148 .width(width)
1149 .no_color(false)
1150 .build()
1151 .render_to_string(&Syntax::new(code, lang))
1152 }
1153
1154 #[cfg(feature = "syntax")]
1156 #[test]
1157 fn measured_syntax_still_prints_at_full_width() {
1158 let console = Console::builder().width(30).color_system(None).build();
1161 let syntax = Syntax::new("x = 1", "python");
1162 assert_eq!(syntax.measure(&console, &console.options()).maximum, 5);
1163 let out = console.render_to_string(&syntax);
1164 assert!(!out.contains('\x1b'), "{out:?}");
1165 assert_eq!(cell_len(out.lines().next().unwrap()), 30, "{out:?}");
1166 }
1167
1168 #[cfg(all(feature = "syntax", feature = "markdown"))]
1170 #[test]
1171 fn a_lone_carriage_return_breaks_the_line_like_pygments() {
1172 let render = |renderable: &dyn Renderable, width| {
1175 let console = Console::builder().width(width).color_system(None).build();
1176 console.render_to_string(renderable) + "\n"
1177 };
1178 assert_eq!(
1179 render(&Syntax::new("ab\rcd\r", "python"), 10),
1180 "ab \ncd \n"
1181 );
1182 assert_eq!(
1183 render(&Syntax::new("ab\r\ncd", "text"), 10),
1184 "ab \ncd \n"
1185 );
1186 assert_eq!(
1187 render(
1188 &crate::panel::Panel::fit(Box::new(Syntax::new("ab\rcd", "python"))),
1189 20
1190 ),
1191 "╭────╮\n│ ab │\n│ cd │\n╰────╯\n"
1192 );
1193 assert_eq!(
1194 render(&crate::markdown::Markdown::new("```python\nab\rcd\n```"), 20),
1195 " \n ab \n cd \n \n"
1196 );
1197 }
1198
1199 #[cfg(not(feature = "syntax"))]
1202 #[test]
1203 fn without_syntect_code_renders_plain_in_the_same_layout() {
1204 let console = Console::builder()
1205 .force_terminal(true)
1206 .color_system(Some(ColorSystem::Truecolor))
1207 .width(20)
1208 .build();
1209 let plain = Syntax::new("fn main() {}\nlet x;", "rust").padding(1);
1210 let out = console.render_to_string(&plain);
1211 assert!(!out.contains('\x1b'), "{out:?}");
1212 assert_eq!(
1213 out,
1214 " \n fn main() {} \n let x; \n "
1215 );
1216 let numbered = console.render_to_string(
1217 &Syntax::new("a\nb", "python")
1218 .line_numbers(true)
1219 .theme("monokai"),
1220 );
1221 assert!(
1222 numbered.contains("1 ") && numbered.contains("a"),
1223 "{numbered:?}"
1224 );
1225 let text = Syntax::new("x = 1", "python").highlight();
1226 assert_eq!(text.plain(), "x = 1");
1227 assert!(text.spans().is_empty());
1228 }
1229
1230 #[test]
1231 fn splitlines_matches_python() {
1232 assert_eq!(
1233 python_splitlines("a\r\nb\rc\u{2028}d\n"),
1234 ["a", "b", "c", "d"]
1235 );
1236 assert_eq!(python_splitlines("\n\n"), ["", ""]);
1237 assert!(python_splitlines("").is_empty());
1238 }
1239
1240 #[cfg(feature = "syntax")]
1242 #[test]
1243 fn highlights_rust_keyword() {
1244 let out = render("fn main() {}", "rust", 20);
1247 assert!(out.contains("fn"));
1248 assert!(out.contains("main"));
1249 assert!(out.contains('\x1b'), "expected ANSI color codes");
1250 }
1251
1252 #[test]
1253 fn multiple_lines_are_separated() {
1254 let out = render("let x = 1;\nlet y = 2;", "rust", 20);
1255 assert_eq!(out.matches('\n').count(), 1);
1256 assert!(out.contains("let"));
1257 }
1258
1259 #[test]
1260 fn unknown_language_renders_plain() {
1261 let out = render("just some text", "nonsense-lang", 20);
1263 assert!(out.contains("just some text"));
1264 }
1265
1266 #[test]
1267 fn word_wrap_is_off_by_default_matching_upstream() {
1268 let code = "A".repeat(300);
1271 let out = render(&code, "python", 80);
1272 assert_eq!(out.matches('A').count(), 80, "default should crop");
1273 }
1274
1275 #[test]
1276 fn word_wrap_keeps_every_character() {
1277 let code = "A".repeat(300);
1278 let console = Console::builder().width(80).color_system(None).build();
1279 let out = console.render_to_string(&Syntax::new(code.as_str(), "python").word_wrap(true));
1280 assert_eq!(
1281 out.matches('A').count(),
1282 300,
1283 "wrapping must not lose characters:
1284{out}"
1285 );
1286 }
1287
1288 #[test]
1292 fn control_codes_are_stripped_from_highlighted_code() {
1293 let out = render("let x = 1;\u{7}\u{8}\u{b}\u{c}", "rust", 40);
1294 for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}'] {
1295 assert!(
1296 !out.contains(code),
1297 "control code {code:?} reached the output"
1298 );
1299 }
1300 assert!(out.contains("let"), "content lost with the control codes");
1301 }
1302
1303 #[cfg(feature = "syntax")]
1308 #[test]
1309 fn word_wrap_keeps_blank_lines() {
1310 let console = Console::builder().width(20).color_system(None).build();
1311 let out =
1312 console.render_to_string(&Syntax::new("a = 1\n\nb = 2\n", "python").word_wrap(true));
1313 let rows: Vec<&str> = out.trim_end_matches('\n').split('\n').collect();
1314 assert_eq!(rows.len(), 4, "blank line lost: {rows:?}");
1320 assert!(
1321 rows[1].trim().is_empty(),
1322 "middle row should be blank: {rows:?}"
1323 );
1324 assert!(
1325 rows[3].trim().is_empty(),
1326 "trailing row should be blank: {rows:?}"
1327 );
1328 }
1329
1330 #[cfg(feature = "syntax")]
1338 #[test]
1339 fn tabs_are_expanded_before_highlighting() {
1340 let console = Console::builder().width(30).color_system(None).build();
1341 let out = console.render_to_string(&Syntax::new(
1342 "def f():\n\tif x:\n\t\treturn 1\n\treturn 0",
1343 "python",
1344 ));
1345 assert_eq!(
1346 out.split('\n').collect::<Vec<_>>(),
1347 [
1348 "def f(): ",
1349 " if x: ",
1350 " return 1 ",
1351 " return 0 ",
1352 ]
1353 );
1354 assert!(!out.contains('\t'), "a raw tab survived: {out:?}");
1355 }
1356
1357 #[cfg(feature = "syntax")]
1361 #[test]
1362 fn a_tab_advances_to_the_next_tab_stop() {
1363 let console = Console::builder().width(20).color_system(None).build();
1364 let out = console.render_to_string(&Syntax::new(
1365 "a\tb\tc\nab\tcd\tef\nabcd\tefgh\tijkl",
1366 "python",
1367 ));
1368 assert_eq!(
1369 out.split('\n').collect::<Vec<_>>(),
1370 [
1371 "a b c ",
1372 "ab cd ef ",
1373 "abcd efgh ijkl",
1374 ]
1375 );
1376 }
1377
1378 #[cfg(feature = "syntax")]
1386 #[test]
1387 fn a_tabbed_line_measures_the_requested_width() {
1388 fn screen_width(row: &str) -> usize {
1391 let mut column = 0usize;
1392 for ch in row.chars() {
1393 column += if ch == '\t' {
1394 8 - (column % 8)
1395 } else {
1396 cell_len(ch.encode_utf8(&mut [0u8; 4]))
1397 };
1398 }
1399 column
1400 }
1401
1402 for width in [10usize, 20, 30, 40] {
1403 let console = Console::builder().width(width).color_system(None).build();
1404 let out = console.render_to_string(&Syntax::new("\tvalue = compute(a, b)", "python"));
1405 for row in out.split('\n') {
1406 assert_eq!(screen_width(row), width, "row {row:?} at width {width}");
1407 }
1408 }
1409 }
1410
1411 #[test]
1414 fn guess_lexer_takes_the_extension_or_the_name() {
1415 assert_eq!(Syntax::guess_lexer("src/main.RS"), "rs");
1416 assert_eq!(Syntax::guess_lexer("Makefile"), "Makefile");
1417 assert_eq!(Syntax::guess_lexer(""), "default");
1418 }
1419
1420 #[test]
1421 fn expand_tabs_matches_pythons_str_expandtabs() {
1422 for (input, expected) in [
1424 ("a\tb", "a b"),
1425 ("ab\tb", "ab b"),
1426 ("abc\tb", "abc b"),
1427 ("abcd\tb", "abcd b"),
1428 ("\t", " "),
1429 ("a\nbb\tc", "a\nbb c"),
1430 ("a\rbb\tc", "a\rbb c"),
1431 ("\u{4e2d}\tx", "\u{4e2d} x"),
1433 ] {
1434 assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
1435 }
1436 assert_eq!(expand_tabs("a\tb", 0), "ab");
1438 }
1439
1440 #[test]
1443 fn word_wrap_breaks_between_words() {
1444 let console = Console::builder().width(30).color_system(None).build();
1445 let code = "result = compute_total(alpha, beta, gamma, delta, epsilon, zeta, eta, theta)\n";
1448 let out = console.render_to_string(&Syntax::new(code, "python").word_wrap(true));
1449 for word in [
1452 "compute_total",
1453 "alpha",
1454 "gamma",
1455 "epsilon",
1456 "zeta",
1457 "theta",
1458 ] {
1459 assert!(
1460 out.split('\n').any(|row| row.contains(word)),
1461 "{word:?} was split across rows: {out:?}"
1462 );
1463 }
1464 }
1465
1466 use crate::protocol::HighlightSpan;
1469
1470 struct Fixed(Vec<HighlightedLine>);
1472
1473 impl CodeHighlighter for Fixed {
1474 fn highlight(
1475 &self,
1476 _code: &str,
1477 _language: Option<&str>,
1478 _theme: &str,
1479 ) -> Result<HighlightedCode, HighlightError> {
1480 Ok(HighlightedCode {
1481 lines: self.0.clone(),
1482 ..Default::default()
1483 })
1484 }
1485 fn default_theme(&self) -> &str {
1486 "fixed"
1487 }
1488 fn themes(&self) -> Vec<String> {
1489 vec!["fixed".into()]
1490 }
1491 fn languages(&self) -> Vec<String> {
1492 Vec::new()
1493 }
1494 }
1495
1496 struct ByTheme;
1498
1499 impl CodeHighlighter for ByTheme {
1500 fn highlight(
1501 &self,
1502 code: &str,
1503 _language: Option<&str>,
1504 theme: &str,
1505 ) -> Result<HighlightedCode, HighlightError> {
1506 let style = match theme {
1507 "bold" | "underline" => Style::parse(theme).unwrap(),
1508 other => return Err(HighlightError::UnknownTheme(other.into())),
1509 };
1510 let lines = code
1511 .split('\n')
1512 .map(|line| HighlightedLine {
1513 spans: (!line.is_empty())
1514 .then(|| HighlightSpan {
1515 range: 0..line.len(),
1516 style: style.clone(),
1517 })
1518 .into_iter()
1519 .collect(),
1520 newline_style: None,
1521 })
1522 .collect();
1523 Ok(HighlightedCode {
1524 lines,
1525 ..Default::default()
1526 })
1527 }
1528 fn default_theme(&self) -> &str {
1529 "bold"
1530 }
1531 fn themes(&self) -> Vec<String> {
1532 vec!["bold".into(), "underline".into()]
1533 }
1534 fn languages(&self) -> Vec<String> {
1535 Vec::new()
1536 }
1537 }
1538
1539 #[test]
1543 fn a_console_default_highlighter_applies_where_none_is_given() {
1544 use crate::protocol::{CodeHighlighting, ConsoleCodeHighlighting};
1545 let plain = || {
1546 Console::builder()
1547 .width(20)
1548 .force_terminal(true)
1549 .color_system(Some(crate::color::ColorSystem::Truecolor))
1550 .build()
1551 };
1552 let with = |theme: Option<&str>| {
1553 let mut console = plain();
1554 console.set_code_highlighting(Some(CodeHighlighting {
1555 highlighter: Arc::new(ByTheme),
1556 theme: theme.map(str::to_string),
1557 }));
1558 console
1559 };
1560 let syntax = || Syntax::new("x = 1", "python");
1561
1562 assert!(with(None)
1563 .render_to_string(&syntax())
1564 .contains("\x1b[1mx = 1"));
1565 assert!(with(Some("underline"))
1566 .render_to_string(&syntax())
1567 .contains("\x1b[4mx = 1"));
1568 assert!(with(Some("underline"))
1570 .render_to_string(&syntax().theme("bold"))
1571 .contains("\x1b[1mx = 1"));
1572 #[cfg(feature = "syntax")]
1575 let own = with(Some("underline"))
1576 .render_to_string(&syntax().highlighter(SyntectHighlighter::shared()));
1577 #[cfg(feature = "syntax")]
1578 assert_eq!(own, plain().render_to_string(&syntax()));
1579 #[cfg(feature = "markdown")]
1581 {
1582 let markdown = crate::markdown::Markdown::new("```python\nx = 1\n```");
1583 assert!(with(None)
1584 .render_to_string(&markdown)
1585 .contains("\x1b[1mx = 1"));
1586 }
1587 assert!(!syntax().highlight_for(&with(None)).spans().is_empty());
1589 assert_eq!(
1590 syntax().highlight_for(&plain()).spans(),
1591 syntax().highlight().spans()
1592 );
1593 let mut cleared = with(None);
1595 cleared.set_code_highlighting(None);
1596 assert_eq!(
1597 cleared.render_to_string(&syntax()),
1598 plain().render_to_string(&syntax())
1599 );
1600 }
1601
1602 struct Broken;
1604
1605 impl CodeHighlighter for Broken {
1606 fn highlight(
1607 &self,
1608 _code: &str,
1609 _language: Option<&str>,
1610 _theme: &str,
1611 ) -> Result<HighlightedCode, HighlightError> {
1612 Err(HighlightError::Engine("boom".into()))
1613 }
1614 fn default_theme(&self) -> &str {
1615 "x"
1616 }
1617 fn themes(&self) -> Vec<String> {
1618 vec!["x".into()]
1619 }
1620 fn languages(&self) -> Vec<String> {
1621 Vec::new()
1622 }
1623 }
1624
1625 fn span(range: std::ops::Range<usize>, style: &str) -> HighlightSpan {
1626 HighlightSpan {
1627 range,
1628 style: Style::parse(style).unwrap(),
1629 }
1630 }
1631
1632 fn line(spans: Vec<HighlightSpan>) -> HighlightedLine {
1633 HighlightedLine {
1634 spans,
1635 newline_style: None,
1636 }
1637 }
1638
1639 fn render_with(code: &str, highlighter: Arc<dyn CodeHighlighter>, width: usize) -> String {
1640 Console::builder()
1641 .force_terminal(true)
1642 .color_system(Some(ColorSystem::Standard))
1643 .width(width)
1644 .build()
1645 .render_to_string(&Syntax::new(code, "python").highlighter(highlighter))
1646 }
1647
1648 #[test]
1649 fn a_custom_highlighter_drives_the_rendered_styles() {
1650 let fixed = Fixed(vec![line(vec![span(0..1, "red"), span(4..5, "bold")])]);
1651 let out = render_with("x = 1", Arc::new(fixed), 7);
1652 assert_eq!(out, "\u{1b}[31mx\u{1b}[0m = \u{1b}[1m1\u{1b}[0m");
1655 }
1656
1657 #[test]
1658 fn invalid_spans_are_dropped_not_rendered() {
1659 let fixed = Fixed(vec![line(vec![
1660 span(0..3, "red"),
1661 span(1..2, "green"), span(3..99, "blue"), span(std::ops::Range { start: 2, end: 1 }, "yellow"), ])]);
1665 let out = render_with("abcdef", Arc::new(fixed), 6);
1666 assert_eq!(out, "\u{1b}[31mabc\u{1b}[0mdef");
1667 let fixed = Fixed(vec![line(vec![span(0..1, "red")])]);
1669 let out = render_with("é", Arc::new(fixed), 1);
1670 assert_eq!(out, "é");
1671 }
1672
1673 #[test]
1674 fn missing_and_extra_lines_are_reconciled_with_the_source() {
1675 let fixed = Fixed(vec![line(vec![span(0..1, "red")])]);
1677 let out = render_with("a\nb\nc", Arc::new(fixed), 1);
1678 assert_eq!(out, "\u{1b}[31ma\u{1b}[0m\nb\nc");
1679 let fixed = Fixed(vec![
1681 line(vec![]),
1682 line(vec![]),
1683 line(vec![span(0..1, "red")]),
1684 ]);
1685 assert_eq!(render_with("a", Arc::new(fixed), 1), "a");
1686 }
1687
1688 #[test]
1689 fn highlighter_styles_cannot_add_links_or_text() {
1690 let style = Style::parse("red")
1691 .unwrap()
1692 .with_link("https://evil.example/\u{1b}]0;x\u{7}");
1693 let fixed = Fixed(vec![line(vec![HighlightSpan { range: 0..1, style }])]);
1694 let out = render_with("a\u{8}", Arc::new(fixed), 1);
1695 assert!(!out.contains("\u{1b}]8"), "hyperlink escaped: {out:?}");
1696 assert!(!out.contains("evil"), "{out:?}");
1697 assert!(!out.contains('\u{8}'), "control code survived: {out:?}");
1698 }
1699
1700 #[test]
1701 fn an_engine_failure_renders_the_source_unstyled() {
1702 let out = render_with("print(1)", Arc::new(Broken), 8);
1703 assert_eq!(out, "print(1)");
1704 }
1705
1706 #[test]
1707 fn an_unknown_theme_falls_back_to_the_default_theme() {
1708 let console = Console::builder()
1709 .force_terminal(true)
1710 .color_system(Some(ColorSystem::Truecolor))
1711 .width(20)
1712 .build();
1713 let default = console.render_to_string(&Syntax::new("x = 1", "python"));
1714 let unknown =
1715 console.render_to_string(&Syntax::new("x = 1", "python").theme("no-such-theme"));
1716 assert_eq!(default, unknown);
1717 }
1718
1719 #[test]
1720 fn highlight_text_uses_the_same_highlighter() {
1721 let fixed = Fixed(vec![line(vec![span(0..1, "red")]), line(vec![])]);
1722 let text = Syntax::new("ab\ncd", "python")
1723 .highlighter(Arc::new(fixed))
1724 .highlight();
1725 assert_eq!(text.plain(), "ab\ncd");
1726 assert_eq!(text.spans()[0].start, 0);
1727 assert_eq!(text.spans()[0].end, 1);
1728 }
1729
1730 #[cfg(feature = "syntax")]
1734 #[test]
1735 fn ansi_themes_use_upstream_palette_colours() {
1736 let code = "# note\n@wraps\ndef greet(name):\n return f\"hi {name}\" and 42\n";
1737 let render = |theme: &str, system: ColorSystem| {
1738 Console::builder()
1739 .force_terminal(true)
1740 .color_system(Some(system))
1741 .width(40)
1742 .build()
1743 .render_to_string(&Syntax::new(code, "python").theme(theme))
1744 };
1745 let dark = render("ansi_dark", ColorSystem::Truecolor);
1746 assert!(!dark.contains("38;2;") && !dark.contains("48;"), "{dark:?}");
1748 assert!(dark.contains("\u{1b}[2m#"), "comment is dim: {dark:?}");
1749 assert!(
1750 dark.contains("\u{1b}[1;95m@"),
1751 "decorator bold bright_magenta: {dark:?}"
1752 );
1753 assert!(
1754 dark.contains("\u{1b}[33mf"),
1755 "string prefix is part of the string: {dark:?}"
1756 );
1757 assert!(
1758 dark.contains("\u{1b}[94mdef"),
1759 "keyword bright_blue: {dark:?}"
1760 );
1761 assert!(
1762 dark.contains("\u{1b}[92mgreet"),
1763 "function bright_green: {dark:?}"
1764 );
1765 assert!(
1766 dark.contains("\u{1b}[94m42"),
1767 "number bright_blue: {dark:?}"
1768 );
1769 assert!(
1770 dark.contains("\u{1b}[95mand"),
1771 "operator word bright_magenta: {dark:?}"
1772 );
1773 assert!(dark.contains("\u{1b}[33m"), "string yellow: {dark:?}");
1774 let light = render("ansi_light", ColorSystem::Truecolor);
1775 assert!(light.contains("\u{1b}[34mdef"), "keyword blue: {light:?}");
1776 assert!(
1777 light.contains("\u{1b}[32mgreet"),
1778 "function green: {light:?}"
1779 );
1780 assert_eq!(render("ansi_dark", ColorSystem::Standard), dark);
1782 }
1783}