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::protocol::Renderable;
29use crate::segment::Segment;
30use crate::style::Style;
31use crate::text::is_control_code;
32
33const DEFAULT_THEME: &str = "base16-ocean.dark";
35
36const DEFAULT_TAB_SIZE: usize = 4;
38
39pub struct Syntax {
41 code: String,
42 language: Option<String>,
43 theme: String,
44 word_wrap: bool,
45 padding: usize,
46 tab_size: usize,
47}
48
49fn expand_tabs(code: &str, tab_size: usize) -> String {
61 if !code.contains('\t') {
62 return code.to_string();
63 }
64 let mut out = String::with_capacity(code.len());
65 let mut column = 0usize;
66 for ch in code.chars() {
67 match ch {
68 '\t' => {
69 if tab_size > 0 {
70 let advance = tab_size - (column % tab_size);
71 out.extend(std::iter::repeat_n(' ', advance));
72 column += advance;
73 }
74 }
75 '\n' | '\r' => {
76 out.push(ch);
77 column = 0;
78 }
79 _ => {
80 out.push(ch);
81 column += 1;
82 }
83 }
84 }
85 out
86}
87
88impl Syntax {
89 pub fn word_wrap(mut self, wrap: bool) -> Self {
95 self.word_wrap = wrap;
96 self
97 }
98
99 pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
102 Syntax {
103 word_wrap: false,
104 padding: 0,
105 tab_size: DEFAULT_TAB_SIZE,
106 code: code.into(),
107 language: Some(language.into()).filter(|l| !l.is_empty()),
108 theme: DEFAULT_THEME.to_string(),
109 }
110 }
111
112 pub fn tab_size(mut self, tab_size: usize) -> Self {
119 self.tab_size = tab_size;
120 self
121 }
122
123 pub fn padding(mut self, padding: usize) -> Self {
130 self.padding = padding;
131 self
132 }
133
134 pub fn theme(mut self, theme: impl Into<String>) -> Self {
137 self.theme = theme.into();
138 self
139 }
140}
141
142fn syntax_set() -> &'static SyntaxSet {
143 static SET: OnceLock<SyntaxSet> = OnceLock::new();
144 SET.get_or_init(SyntaxSet::load_defaults_newlines)
145}
146
147fn theme_set() -> &'static ThemeSet {
148 static SET: OnceLock<ThemeSet> = OnceLock::new();
149 SET.get_or_init(ThemeSet::load_defaults)
150}
151
152fn to_color(c: SynColor) -> Color {
154 Color::from_rgb(c.r, c.g, c.b)
155}
156
157fn to_style(s: SynStyle) -> Style {
159 let mut style = Style::new()
160 .with_color(to_color(s.foreground))
161 .with_bgcolor(to_color(s.background));
162 if s.font_style.contains(FontStyle::BOLD) {
163 style = style.combine(&Style::parse("bold").expect("valid style"));
164 }
165 if s.font_style.contains(FontStyle::ITALIC) {
166 style = style.combine(&Style::parse("italic").expect("valid style"));
167 }
168 if s.font_style.contains(FontStyle::UNDERLINE) {
169 style = style.combine(&Style::parse("underline").expect("valid style"));
170 }
171 style
172}
173
174impl Syntax {
175 fn theme_ref<'a>(&self, themes: &'a ThemeSet) -> &'a Theme {
176 themes
177 .themes
178 .get(&self.theme)
179 .or_else(|| themes.themes.get(DEFAULT_THEME))
180 .expect("default theme present")
181 }
182}
183
184impl Renderable for Syntax {
185 fn rich_render(&self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
186 let syntaxes = syntax_set();
187 let themes = theme_set();
188 let theme = self.theme_ref(themes);
189 let background = theme.settings.background.map(to_color);
190
191 let syntax = self
193 .language
194 .as_deref()
195 .and_then(|lang| {
196 syntaxes
197 .find_syntax_by_token(lang)
198 .or_else(|| syntaxes.find_syntax_by_extension(lang))
199 })
200 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
201
202 #[cfg(not(feature = "syntax-cache"))]
203 let mut highlighter = HighlightLines::new(syntax, theme);
204 #[cfg(feature = "syntax-cache")]
205 let mut highlighter = cache::CachedHighlighter::new(syntax, theme);
206 let width = options.max_width;
208 let code_width = width.saturating_sub(self.padding * 2);
209
210 let code = expand_tabs(&self.code, self.tab_size);
213
214 let mut lines: Vec<Vec<Segment>> = Vec::new();
215 for line in LinesWithEndings::from(&code) {
216 let ranges = highlighter
217 .highlight_line(line, syntaxes)
218 .unwrap_or_default();
219 let mut row: Vec<Segment> = Vec::new();
220 let mut used = 0usize;
221 for (syn_style, text) in ranges {
222 let text = text.strip_suffix('\n').unwrap_or(text);
223 if text.is_empty() {
224 continue;
225 }
226 let text: String = text.chars().filter(|c| !is_control_code(*c)).collect();
231 if text.is_empty() {
232 continue;
233 }
234 used += cell_len(&text);
235 row.push(Segment::new(text, Some(to_style(syn_style))));
236 }
237 let _ = used;
238 lines.push(row);
239 }
240
241 if code.is_empty() || code.ends_with('\n') {
247 lines.push(Vec::new());
248 }
249
250 if self.word_wrap {
253 lines = lines
254 .into_iter()
255 .flat_map(|row| {
256 if row.is_empty() {
262 vec![Vec::new()]
263 } else {
264 Segment::split_lines(&Segment::fold_lines_words(&row, code_width))
265 }
266 })
267 .collect();
268 }
269
270 let pad_style = {
272 let mut style = Style::new();
273 if let Some(bg) = &background {
274 style = style.with_bgcolor(bg.clone());
275 }
276 style
277 };
278 if self.padding > 0 {
279 for row in &mut lines {
280 row.insert(
281 0,
282 Segment::new(" ".repeat(self.padding), Some(pad_style.clone())),
283 );
284 }
285 let blank = vec![Segment::new(" ".repeat(width), Some(pad_style.clone()))];
286 for _ in 0..self.padding {
287 lines.insert(0, blank.clone());
288 lines.push(blank.clone());
289 }
290 }
291
292 for row in &mut lines {
295 let used: usize = row.iter().map(Segment::cell_length).sum();
296 if width > used {
297 let mut pad = Style::new();
298 if let Some(bg) = &background {
299 pad = pad.with_bgcolor(bg.clone());
300 }
301 row.push(Segment::new(" ".repeat(width - used), Some(pad)));
302 }
303 }
304
305 let mut segments = Vec::new();
306 let last = lines.len().saturating_sub(1);
307 for (index, line) in lines.into_iter().enumerate() {
308 segments.extend(line);
309 if index != last {
310 segments.push(Segment::line());
311 }
312 }
313 segments
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use crate::color::ColorSystem;
321
322 fn render(code: &str, lang: &str, width: usize) -> String {
323 Console::builder()
324 .force_terminal(true)
325 .color_system(Some(ColorSystem::Truecolor))
326 .width(width)
327 .no_color(false)
328 .build()
329 .render_to_string(&Syntax::new(code, lang))
330 }
331
332 #[test]
333 fn highlights_rust_keyword() {
334 let out = render("fn main() {}", "rust", 20);
337 assert!(out.contains("fn"));
338 assert!(out.contains("main"));
339 assert!(out.contains('\x1b'), "expected ANSI color codes");
340 }
341
342 #[test]
343 fn multiple_lines_are_separated() {
344 let out = render("let x = 1;\nlet y = 2;", "rust", 20);
345 assert_eq!(out.matches('\n').count(), 1);
346 assert!(out.contains("let"));
347 }
348
349 #[test]
350 fn unknown_language_renders_plain() {
351 let out = render("just some text", "nonsense-lang", 20);
353 assert!(out.contains("just some text"));
354 }
355
356 #[test]
357 fn word_wrap_is_off_by_default_matching_upstream() {
358 let code = "A".repeat(300);
361 let out = render(&code, "python", 80);
362 assert_eq!(out.matches('A').count(), 80, "default should crop");
363 }
364
365 #[test]
366 fn word_wrap_keeps_every_character() {
367 let code = "A".repeat(300);
368 let console = Console::builder().width(80).no_color(true).build();
369 let out = console.render_to_string(&Syntax::new(code.as_str(), "python").word_wrap(true));
370 assert_eq!(
371 out.matches('A').count(),
372 300,
373 "wrapping must not lose characters:
374{out}"
375 );
376 }
377
378 #[test]
382 fn control_codes_are_stripped_from_highlighted_code() {
383 let out = render("let x = 1;\u{7}\u{8}\u{b}\u{c}", "rust", 40);
384 for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}'] {
385 assert!(
386 !out.contains(code),
387 "control code {code:?} reached the output"
388 );
389 }
390 assert!(out.contains("let"), "content lost with the control codes");
391 }
392
393 #[test]
397 fn word_wrap_keeps_blank_lines() {
398 let console = Console::builder().width(20).no_color(true).build();
399 let out =
400 console.render_to_string(&Syntax::new("a = 1\n\nb = 2\n", "python").word_wrap(true));
401 let rows: Vec<&str> = out.trim_end_matches('\n').split('\n').collect();
402 assert_eq!(rows.len(), 4, "blank line lost: {rows:?}");
408 assert!(
409 rows[1].trim().is_empty(),
410 "middle row should be blank: {rows:?}"
411 );
412 assert!(
413 rows[3].trim().is_empty(),
414 "trailing row should be blank: {rows:?}"
415 );
416 }
417
418 #[test]
425 fn tabs_are_expanded_before_highlighting() {
426 let console = Console::builder().width(30).no_color(true).build();
427 let out = console.render_to_string(&Syntax::new(
428 "def f():\n\tif x:\n\t\treturn 1\n\treturn 0",
429 "python",
430 ));
431 assert_eq!(
432 out.split('\n').collect::<Vec<_>>(),
433 [
434 "def f(): ",
435 " if x: ",
436 " return 1 ",
437 " return 0 ",
438 ]
439 );
440 assert!(!out.contains('\t'), "a raw tab survived: {out:?}");
441 }
442
443 #[test]
446 fn a_tab_advances_to_the_next_tab_stop() {
447 let console = Console::builder().width(20).no_color(true).build();
448 let out = console.render_to_string(&Syntax::new(
449 "a\tb\tc\nab\tcd\tef\nabcd\tefgh\tijkl",
450 "python",
451 ));
452 assert_eq!(
453 out.split('\n').collect::<Vec<_>>(),
454 [
455 "a b c ",
456 "ab cd ef ",
457 "abcd efgh ijkl",
458 ]
459 );
460 }
461
462 #[test]
469 fn a_tabbed_line_measures_the_requested_width() {
470 fn screen_width(row: &str) -> usize {
473 let mut column = 0usize;
474 for ch in row.chars() {
475 column += if ch == '\t' {
476 8 - (column % 8)
477 } else {
478 cell_len(ch.encode_utf8(&mut [0u8; 4]))
479 };
480 }
481 column
482 }
483
484 for width in [10usize, 20, 30, 40] {
485 let console = Console::builder().width(width).no_color(true).build();
486 let out = console.render_to_string(&Syntax::new("\tvalue = compute(a, b)", "python"));
487 for row in out.split('\n') {
488 assert_eq!(screen_width(row), width, "row {row:?} at width {width}");
489 }
490 }
491 }
492
493 #[test]
496 fn expand_tabs_matches_pythons_str_expandtabs() {
497 for (input, expected) in [
499 ("a\tb", "a b"),
500 ("ab\tb", "ab b"),
501 ("abc\tb", "abc b"),
502 ("abcd\tb", "abcd b"),
503 ("\t", " "),
504 ("a\nbb\tc", "a\nbb c"),
505 ("a\rbb\tc", "a\rbb c"),
506 ("\u{4e2d}\tx", "\u{4e2d} x"),
508 ] {
509 assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
510 }
511 assert_eq!(expand_tabs("a\tb", 0), "ab");
513 }
514
515 #[test]
518 fn word_wrap_breaks_between_words() {
519 let console = Console::builder().width(30).no_color(true).build();
520 let code = "result = compute_total(alpha, beta, gamma, delta, epsilon, zeta, eta, theta)\n";
523 let out = console.render_to_string(&Syntax::new(code, "python").word_wrap(true));
524 for word in [
527 "compute_total",
528 "alpha",
529 "gamma",
530 "epsilon",
531 "zeta",
532 "theta",
533 ] {
534 assert!(
535 out.split('\n').any(|row| row.contains(word)),
536 "{word:?} was split across rows: {out:?}"
537 );
538 }
539 }
540}