1use std::sync::OnceLock;
14
15use syntect::highlighting::{Color as SynColor, FontStyle, Style as SynStyle, Theme, ThemeSet};
16use syntect::parsing::SyntaxSet;
17use syntect::util::LinesWithEndings;
18
19#[cfg(not(feature = "syntax-cache"))]
20use syntect::easy::HighlightLines;
21#[cfg(feature = "syntax-cache")]
22#[path = "syntax_cache.rs"]
23mod cache;
24
25use crate::cells::cell_len;
26use crate::color::Color;
27use crate::console::{Console, ConsoleOptions};
28use crate::measure::Measurement;
29use crate::protocol::Renderable;
30use crate::segment::Segment;
31use crate::style::Style;
32use crate::text::is_control_code;
33
34const DEFAULT_THEME: &str = "base16-ocean.dark";
36
37const DEFAULT_TAB_SIZE: usize = 4;
39
40pub struct Syntax {
42 code: String,
43 language: Option<String>,
44 theme: String,
45 word_wrap: bool,
46 padding: usize,
47 tab_size: usize,
48}
49
50fn expand_tabs(code: &str, tab_size: usize) -> String {
62 if !code.contains('\t') {
63 return code.to_string();
64 }
65 let mut out = String::with_capacity(code.len());
66 let mut column = 0usize;
67 for ch in code.chars() {
68 match ch {
69 '\t' => {
70 if tab_size > 0 {
71 let advance = tab_size - (column % tab_size);
72 out.extend(std::iter::repeat_n(' ', advance));
73 column += advance;
74 }
75 }
76 '\n' | '\r' => {
77 out.push(ch);
78 column = 0;
79 }
80 _ => {
81 out.push(ch);
82 column += 1;
83 }
84 }
85 }
86 out
87}
88
89impl Syntax {
90 pub fn word_wrap(mut self, wrap: bool) -> Self {
96 self.word_wrap = wrap;
97 self
98 }
99
100 pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
103 Syntax {
104 word_wrap: false,
105 padding: 0,
106 tab_size: DEFAULT_TAB_SIZE,
107 code: code.into(),
108 language: Some(language.into()).filter(|l| !l.is_empty()),
109 theme: DEFAULT_THEME.to_string(),
110 }
111 }
112
113 pub fn tab_size(mut self, tab_size: usize) -> Self {
120 self.tab_size = tab_size;
121 self
122 }
123
124 pub fn padding(mut self, padding: usize) -> Self {
131 self.padding = padding;
132 self
133 }
134
135 pub fn theme(mut self, theme: impl Into<String>) -> Self {
138 self.theme = theme.into();
139 self
140 }
141}
142
143impl Syntax {
144 pub fn highlight(&self) -> crate::text::Text {
149 let syntaxes = syntax_set();
150 let themes = theme_set();
151 let theme = self.theme_ref(themes);
152 let syntax = self
153 .language
154 .as_deref()
155 .and_then(|lang| {
156 syntaxes
157 .find_syntax_by_token(lang)
158 .or_else(|| syntaxes.find_syntax_by_extension(lang))
159 })
160 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
161 let mut text = crate::text::Text::new("");
162 if let Some(background) = theme.settings.background.map(to_color) {
163 text.set_base_style(Style::new().with_bgcolor(background));
164 }
165 #[cfg(not(feature = "syntax-cache"))]
166 let mut highlighter = HighlightLines::new(syntax, theme);
167 #[cfg(feature = "syntax-cache")]
168 let mut highlighter = cache::CachedHighlighter::new(syntax, theme);
169 let code = expand_tabs(&self.code, self.tab_size);
170 for line in LinesWithEndings::from(&code) {
171 for (syn_style, token) in highlighter
172 .highlight_line(line, syntaxes)
173 .unwrap_or_default()
174 {
175 text.append(token, Some(to_style(syn_style).into()));
176 }
177 }
178 text
179 }
180}
181
182fn syntax_set() -> &'static SyntaxSet {
183 static SET: OnceLock<SyntaxSet> = OnceLock::new();
184 SET.get_or_init(SyntaxSet::load_defaults_newlines)
185}
186
187fn theme_set() -> &'static ThemeSet {
188 static SET: OnceLock<ThemeSet> = OnceLock::new();
189 SET.get_or_init(ThemeSet::load_defaults)
190}
191
192fn to_color(c: SynColor) -> Color {
194 Color::from_rgb(c.r, c.g, c.b)
195}
196
197fn to_style(s: SynStyle) -> Style {
199 let mut style = Style::new()
200 .with_color(to_color(s.foreground))
201 .with_bgcolor(to_color(s.background));
202 if s.font_style.contains(FontStyle::BOLD) {
203 style = style.combine(&Style::parse("bold").expect("valid style"));
204 }
205 if s.font_style.contains(FontStyle::ITALIC) {
206 style = style.combine(&Style::parse("italic").expect("valid style"));
207 }
208 if s.font_style.contains(FontStyle::UNDERLINE) {
209 style = style.combine(&Style::parse("underline").expect("valid style"));
210 }
211 style
212}
213
214impl Syntax {
215 fn theme_ref<'a>(&self, themes: &'a ThemeSet) -> &'a Theme {
216 themes
217 .themes
218 .get(&self.theme)
219 .or_else(|| themes.themes.get(DEFAULT_THEME))
220 .expect("default theme present")
221 }
222}
223
224fn python_splitlines(text: &str) -> Vec<&str> {
227 let mut lines = Vec::new();
228 let mut start = 0;
229 let mut chars = text.char_indices().peekable();
230 while let Some((i, c)) = chars.next() {
231 if matches!(
232 c,
233 '\n' | '\r'
234 | '\x0b'
235 | '\x0c'
236 | '\x1c'
237 | '\x1d'
238 | '\x1e'
239 | '\u{85}'
240 | '\u{2028}'
241 | '\u{2029}'
242 ) {
243 lines.push(&text[start..i]);
244 start = i + c.len_utf8();
245 if c == '\r' && chars.peek().map(|&(_, n)| n) == Some('\n') {
246 chars.next();
247 start += 1;
248 }
249 }
250 }
251 if start < text.len() {
252 lines.push(&text[start..]);
253 }
254 lines
255}
256
257impl Renderable for Syntax {
258 fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
262 let widest = python_splitlines(&self.code)
263 .into_iter()
264 .map(cell_len)
265 .max()
266 .unwrap_or(0);
267 Measurement::new(0, self.padding * 2 + widest)
268 }
269
270 fn rich_render(&self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
271 let syntaxes = syntax_set();
272 let themes = theme_set();
273 let theme = self.theme_ref(themes);
274 let background = theme.settings.background.map(to_color);
275
276 let syntax = self
278 .language
279 .as_deref()
280 .and_then(|lang| {
281 syntaxes
282 .find_syntax_by_token(lang)
283 .or_else(|| syntaxes.find_syntax_by_extension(lang))
284 })
285 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
286
287 #[cfg(not(feature = "syntax-cache"))]
288 let mut highlighter = HighlightLines::new(syntax, theme);
289 #[cfg(feature = "syntax-cache")]
290 let mut highlighter = cache::CachedHighlighter::new(syntax, theme);
291 let width = options.max_width;
293 let code_width = width.saturating_sub(self.padding * 2);
294
295 let code = expand_tabs(&self.code, self.tab_size);
298
299 let mut lines: Vec<Vec<Segment>> = Vec::new();
300 for line in LinesWithEndings::from(&code) {
301 let ranges = highlighter
302 .highlight_line(line, syntaxes)
303 .unwrap_or_default();
304 let mut row: Vec<Segment> = Vec::new();
305 let mut used = 0usize;
306 for (syn_style, text) in ranges {
307 let text = text.strip_suffix('\n').unwrap_or(text);
308 if text.is_empty() {
309 continue;
310 }
311 let text: String = text.chars().filter(|c| !is_control_code(*c)).collect();
316 if text.is_empty() {
317 continue;
318 }
319 used += cell_len(&text);
320 row.push(Segment::new(text, Some(to_style(syn_style))));
321 }
322 let _ = used;
323 lines.push(row);
324 }
325
326 if code.is_empty() || code.ends_with('\n') {
332 lines.push(Vec::new());
333 }
334
335 if self.word_wrap {
338 lines = lines
339 .into_iter()
340 .flat_map(|row| {
341 if row.is_empty() {
347 vec![Vec::new()]
348 } else {
349 Segment::split_lines(&Segment::fold_lines_words(&row, code_width))
350 }
351 })
352 .collect();
353 }
354
355 let pad_style = {
357 let mut style = Style::new();
358 if let Some(bg) = &background {
359 style = style.with_bgcolor(bg.clone());
360 }
361 style
362 };
363 if self.padding > 0 {
364 for row in &mut lines {
365 row.insert(
366 0,
367 Segment::new(" ".repeat(self.padding), Some(pad_style.clone())),
368 );
369 }
370 let blank = vec![Segment::new(" ".repeat(width), Some(pad_style.clone()))];
371 for _ in 0..self.padding {
372 lines.insert(0, blank.clone());
373 lines.push(blank.clone());
374 }
375 }
376
377 for row in &mut lines {
380 let used: usize = row.iter().map(Segment::cell_length).sum();
381 if width > used {
382 let mut pad = Style::new();
383 if let Some(bg) = &background {
384 pad = pad.with_bgcolor(bg.clone());
385 }
386 row.push(Segment::new(" ".repeat(width - used), Some(pad)));
387 }
388 }
389
390 let mut segments = Vec::new();
391 let last = lines.len().saturating_sub(1);
392 for (index, line) in lines.into_iter().enumerate() {
393 segments.extend(line);
394 if index != last {
395 segments.push(Segment::line());
396 }
397 }
398 segments
399 }
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::color::ColorSystem;
406
407 fn render(code: &str, lang: &str, width: usize) -> String {
408 Console::builder()
409 .force_terminal(true)
410 .color_system(Some(ColorSystem::Truecolor))
411 .width(width)
412 .no_color(false)
413 .build()
414 .render_to_string(&Syntax::new(code, lang))
415 }
416
417 #[test]
418 fn measured_syntax_still_prints_at_full_width() {
419 let console = Console::builder().width(30).color_system(None).build();
422 let syntax = Syntax::new("x = 1", "python");
423 assert_eq!(syntax.measure(&console, &console.options()).maximum, 5);
424 let out = console.render_to_string(&syntax);
425 assert!(!out.contains('\x1b'), "{out:?}");
426 assert_eq!(cell_len(out.lines().next().unwrap()), 30, "{out:?}");
427 }
428
429 #[test]
430 fn splitlines_matches_python() {
431 assert_eq!(
432 python_splitlines("a\r\nb\rc\u{2028}d\n"),
433 ["a", "b", "c", "d"]
434 );
435 assert_eq!(python_splitlines("\n\n"), ["", ""]);
436 assert!(python_splitlines("").is_empty());
437 }
438
439 #[test]
440 fn highlights_rust_keyword() {
441 let out = render("fn main() {}", "rust", 20);
444 assert!(out.contains("fn"));
445 assert!(out.contains("main"));
446 assert!(out.contains('\x1b'), "expected ANSI color codes");
447 }
448
449 #[test]
450 fn multiple_lines_are_separated() {
451 let out = render("let x = 1;\nlet y = 2;", "rust", 20);
452 assert_eq!(out.matches('\n').count(), 1);
453 assert!(out.contains("let"));
454 }
455
456 #[test]
457 fn unknown_language_renders_plain() {
458 let out = render("just some text", "nonsense-lang", 20);
460 assert!(out.contains("just some text"));
461 }
462
463 #[test]
464 fn word_wrap_is_off_by_default_matching_upstream() {
465 let code = "A".repeat(300);
468 let out = render(&code, "python", 80);
469 assert_eq!(out.matches('A').count(), 80, "default should crop");
470 }
471
472 #[test]
473 fn word_wrap_keeps_every_character() {
474 let code = "A".repeat(300);
475 let console = Console::builder().width(80).color_system(None).build();
476 let out = console.render_to_string(&Syntax::new(code.as_str(), "python").word_wrap(true));
477 assert_eq!(
478 out.matches('A').count(),
479 300,
480 "wrapping must not lose characters:
481{out}"
482 );
483 }
484
485 #[test]
489 fn control_codes_are_stripped_from_highlighted_code() {
490 let out = render("let x = 1;\u{7}\u{8}\u{b}\u{c}", "rust", 40);
491 for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}'] {
492 assert!(
493 !out.contains(code),
494 "control code {code:?} reached the output"
495 );
496 }
497 assert!(out.contains("let"), "content lost with the control codes");
498 }
499
500 #[test]
504 fn word_wrap_keeps_blank_lines() {
505 let console = Console::builder().width(20).color_system(None).build();
506 let out =
507 console.render_to_string(&Syntax::new("a = 1\n\nb = 2\n", "python").word_wrap(true));
508 let rows: Vec<&str> = out.trim_end_matches('\n').split('\n').collect();
509 assert_eq!(rows.len(), 4, "blank line lost: {rows:?}");
515 assert!(
516 rows[1].trim().is_empty(),
517 "middle row should be blank: {rows:?}"
518 );
519 assert!(
520 rows[3].trim().is_empty(),
521 "trailing row should be blank: {rows:?}"
522 );
523 }
524
525 #[test]
532 fn tabs_are_expanded_before_highlighting() {
533 let console = Console::builder().width(30).color_system(None).build();
534 let out = console.render_to_string(&Syntax::new(
535 "def f():\n\tif x:\n\t\treturn 1\n\treturn 0",
536 "python",
537 ));
538 assert_eq!(
539 out.split('\n').collect::<Vec<_>>(),
540 [
541 "def f(): ",
542 " if x: ",
543 " return 1 ",
544 " return 0 ",
545 ]
546 );
547 assert!(!out.contains('\t'), "a raw tab survived: {out:?}");
548 }
549
550 #[test]
553 fn a_tab_advances_to_the_next_tab_stop() {
554 let console = Console::builder().width(20).color_system(None).build();
555 let out = console.render_to_string(&Syntax::new(
556 "a\tb\tc\nab\tcd\tef\nabcd\tefgh\tijkl",
557 "python",
558 ));
559 assert_eq!(
560 out.split('\n').collect::<Vec<_>>(),
561 [
562 "a b c ",
563 "ab cd ef ",
564 "abcd efgh ijkl",
565 ]
566 );
567 }
568
569 #[test]
576 fn a_tabbed_line_measures_the_requested_width() {
577 fn screen_width(row: &str) -> usize {
580 let mut column = 0usize;
581 for ch in row.chars() {
582 column += if ch == '\t' {
583 8 - (column % 8)
584 } else {
585 cell_len(ch.encode_utf8(&mut [0u8; 4]))
586 };
587 }
588 column
589 }
590
591 for width in [10usize, 20, 30, 40] {
592 let console = Console::builder().width(width).color_system(None).build();
593 let out = console.render_to_string(&Syntax::new("\tvalue = compute(a, b)", "python"));
594 for row in out.split('\n') {
595 assert_eq!(screen_width(row), width, "row {row:?} at width {width}");
596 }
597 }
598 }
599
600 #[test]
603 fn expand_tabs_matches_pythons_str_expandtabs() {
604 for (input, expected) in [
606 ("a\tb", "a b"),
607 ("ab\tb", "ab b"),
608 ("abc\tb", "abc b"),
609 ("abcd\tb", "abcd b"),
610 ("\t", " "),
611 ("a\nbb\tc", "a\nbb c"),
612 ("a\rbb\tc", "a\rbb c"),
613 ("\u{4e2d}\tx", "\u{4e2d} x"),
615 ] {
616 assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
617 }
618 assert_eq!(expand_tabs("a\tb", 0), "ab");
620 }
621
622 #[test]
625 fn word_wrap_breaks_between_words() {
626 let console = Console::builder().width(30).color_system(None).build();
627 let code = "result = compute_total(alpha, beta, gamma, delta, epsilon, zeta, eta, theta)\n";
630 let out = console.render_to_string(&Syntax::new(code, "python").word_wrap(true));
631 for word in [
634 "compute_total",
635 "alpha",
636 "gamma",
637 "epsilon",
638 "zeta",
639 "theta",
640 ] {
641 assert!(
642 out.split('\n').any(|row| row.contains(word)),
643 "{word:?} was split across rows: {out:?}"
644 );
645 }
646 }
647}