rich/syntax.rs
1//! Syntax highlighting.
2//!
3//! Port of `rich/syntax.py`'s renderable surface, powered by the `syntect`
4//! crate. A [`Syntax`] highlights a block of source code for a given language
5//! and theme, producing colored [`Segment`]s (a solid block: each line is padded
6//! to the render width with the theme background).
7//!
8//! **Divergence:** upstream uses Pygments; we use `syntect`, which ships
9//! different grammars and themes. So the *coloring is functional, not
10//! byte-identical* to Python rich — see docs/DIVERGENCES.md. Everything else
11//! (the renderable protocol, width handling) matches the port's conventions.
12
13use std::sync::OnceLock;
14
15use syntect::easy::HighlightLines;
16use syntect::highlighting::{Color as SynColor, FontStyle, Style as SynStyle, Theme, ThemeSet};
17use syntect::parsing::SyntaxSet;
18use syntect::util::LinesWithEndings;
19
20use crate::cells::cell_len;
21use crate::color::Color;
22use crate::console::{Console, ConsoleOptions};
23use crate::protocol::Renderable;
24use crate::segment::Segment;
25use crate::style::Style;
26use crate::text::is_control_code;
27
28/// The default theme (a dark base16 palette shipped with `syntect`).
29const DEFAULT_THEME: &str = "base16-ocean.dark";
30
31/// Upstream's `Syntax(tab_size=4)`.
32const DEFAULT_TAB_SIZE: usize = 4;
33
34/// A block of syntax-highlighted source code. Mirrors `rich.syntax.Syntax`.
35pub struct Syntax {
36 code: String,
37 language: Option<String>,
38 theme: String,
39 word_wrap: bool,
40 padding: usize,
41 tab_size: usize,
42}
43
44/// Port of Python's `str.expandtabs(tab_size)`, which `Syntax._process_code`
45/// runs over the source before highlighting it.
46///
47/// A tab advances to the next multiple of `tab_size` **counted in characters,
48/// not cells** (CPython's `unicode_expandtabs` walks code points), and the
49/// column resets at `\n` and `\r`. `tab_size == 0` deletes the tab, matching
50/// CPython's `tabsize <= 0` branch.
51///
52/// Without this the raw U+0009 reached the terminal, where it jumps to the next
53/// 8-cell stop while we had measured it as one cell: a block asked to be 30
54/// wide rendered 31-32 cells and tore the background panel.
55fn expand_tabs(code: &str, tab_size: usize) -> String {
56 if !code.contains('\t') {
57 return code.to_string();
58 }
59 let mut out = String::with_capacity(code.len());
60 let mut column = 0usize;
61 for ch in code.chars() {
62 match ch {
63 '\t' => {
64 if tab_size > 0 {
65 let advance = tab_size - (column % tab_size);
66 out.extend(std::iter::repeat_n(' ', advance));
67 column += advance;
68 }
69 }
70 '\n' | '\r' => {
71 out.push(ch);
72 column = 0;
73 }
74 _ => {
75 out.push(ch);
76 column += 1;
77 }
78 }
79 }
80 out
81}
82
83impl Syntax {
84 /// Wrap lines wider than the render width instead of cropping them.
85 ///
86 /// Off by default, matching upstream's `Syntax(word_wrap=False)`: a long
87 /// line is cut at the width. Upstream's **CLI** turns this on, which is why
88 /// `rich --syntax` does too — cropping a source file silently loses code.
89 pub fn word_wrap(mut self, wrap: bool) -> Self {
90 self.word_wrap = wrap;
91 self
92 }
93
94 /// Highlight `code` as `language` (a name or file extension, e.g. `"rust"`
95 /// or `"rs"`). Pass an empty/unknown language to render as plain text.
96 pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
97 Syntax {
98 word_wrap: false,
99 padding: 0,
100 tab_size: DEFAULT_TAB_SIZE,
101 code: code.into(),
102 language: Some(language.into()).filter(|l| !l.is_empty()),
103 theme: DEFAULT_THEME.to_string(),
104 }
105 }
106
107 /// How far a tab advances the column, in characters. Upstream's
108 /// `Syntax(tab_size=…)`, default 4.
109 ///
110 /// Tabs are *expanded* to spaces before highlighting (upstream's
111 /// `code.expandtabs(self.tab_size)`), so this is the only tab handling in
112 /// play — the rendered code contains no U+0009 at all.
113 pub fn tab_size(mut self, tab_size: usize) -> Self {
114 self.tab_size = tab_size;
115 self
116 }
117
118 /// Surround the code with `padding` cells of background on every side.
119 ///
120 /// Upstream's Markdown renders a fenced block as `Syntax(..., padding=1)`,
121 /// which is what gives a code block its blank inset row above and below and
122 /// its one-column gutter. Without it the code sat flush against the
123 /// surrounding text and every document containing a fence diverged.
124 pub fn padding(mut self, padding: usize) -> Self {
125 self.padding = padding;
126 self
127 }
128
129 /// Choose the highlighting theme (a `syntect` theme name). Unknown names fall
130 /// back to the default.
131 pub fn theme(mut self, theme: impl Into<String>) -> Self {
132 self.theme = theme.into();
133 self
134 }
135}
136
137fn syntax_set() -> &'static SyntaxSet {
138 static SET: OnceLock<SyntaxSet> = OnceLock::new();
139 SET.get_or_init(SyntaxSet::load_defaults_newlines)
140}
141
142fn theme_set() -> &'static ThemeSet {
143 static SET: OnceLock<ThemeSet> = OnceLock::new();
144 SET.get_or_init(ThemeSet::load_defaults)
145}
146
147/// Convert a `syntect` RGBA color to a truecolor [`Color`] (alpha dropped).
148fn to_color(c: SynColor) -> Color {
149 Color::from_rgb(c.r, c.g, c.b)
150}
151
152/// Convert a `syntect` style (fg/bg + font flags) to a rich [`Style`].
153fn to_style(s: SynStyle) -> Style {
154 let mut style = Style::new()
155 .with_color(to_color(s.foreground))
156 .with_bgcolor(to_color(s.background));
157 if s.font_style.contains(FontStyle::BOLD) {
158 style = style.combine(&Style::parse("bold").expect("valid style"));
159 }
160 if s.font_style.contains(FontStyle::ITALIC) {
161 style = style.combine(&Style::parse("italic").expect("valid style"));
162 }
163 if s.font_style.contains(FontStyle::UNDERLINE) {
164 style = style.combine(&Style::parse("underline").expect("valid style"));
165 }
166 style
167}
168
169impl Syntax {
170 fn theme_ref<'a>(&self, themes: &'a ThemeSet) -> &'a Theme {
171 themes
172 .themes
173 .get(&self.theme)
174 .or_else(|| themes.themes.get(DEFAULT_THEME))
175 .expect("default theme present")
176 }
177}
178
179impl Renderable for Syntax {
180 fn rich_render(&self, _console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
181 let syntaxes = syntax_set();
182 let themes = theme_set();
183 let theme = self.theme_ref(themes);
184 let background = theme.settings.background.map(to_color);
185
186 // Resolve the language by token (name) or extension; else plain text.
187 let syntax = self
188 .language
189 .as_deref()
190 .and_then(|lang| {
191 syntaxes
192 .find_syntax_by_token(lang)
193 .or_else(|| syntaxes.find_syntax_by_extension(lang))
194 })
195 .unwrap_or_else(|| syntaxes.find_syntax_plain_text());
196
197 let mut highlighter = HighlightLines::new(syntax, theme);
198 // The gutter eats into the space the code itself may occupy.
199 let width = options.max_width;
200 let code_width = width.saturating_sub(self.padding * 2);
201
202 // `Syntax._process_code`: the source is tab-expanded before it reaches
203 // the highlighter, so no U+0009 ever survives into a segment.
204 let code = expand_tabs(&self.code, self.tab_size);
205
206 let mut lines: Vec<Vec<Segment>> = Vec::new();
207 for line in LinesWithEndings::from(&code) {
208 let ranges = highlighter
209 .highlight_line(line, syntaxes)
210 .unwrap_or_default();
211 let mut row: Vec<Segment> = Vec::new();
212 let mut used = 0usize;
213 for (syn_style, text) in ranges {
214 let text = text.strip_suffix('\n').unwrap_or(text);
215 if text.is_empty() {
216 continue;
217 }
218 // Upstream's Syntax builds a `Text`, so `strip_control_codes`
219 // runs on every token. We emit segments directly, which let BEL,
220 // backspace, vertical tab and form feed through to the terminal
221 // — a backspace run rewrites what the reader sees.
222 let text: String = text.chars().filter(|c| !is_control_code(*c)).collect();
223 if text.is_empty() {
224 continue;
225 }
226 used += cell_len(&text);
227 row.push(Segment::new(text, Some(to_style(syn_style))));
228 }
229 let _ = used;
230 lines.push(row);
231 }
232
233 // Upstream splits the source with Python's `str.split("\n")`, which keeps
234 // the empty element after a trailing newline — so a file ending in `\n`
235 // gets one final padded blank row. `LinesWithEndings` yields no such
236 // element, so every source (i.e. nearly every real file) rendered one row
237 // short of upstream. An empty source splits to `[""]`, one row, too.
238 if code.is_empty() || code.ends_with('\n') {
239 lines.push(Vec::new());
240 }
241
242 // Wrapping happens before padding, so every *visual* row gets the same
243 // background treatment rather than only the first.
244 if self.word_wrap {
245 lines = lines
246 .into_iter()
247 .flat_map(|row| {
248 // A blank source line has no segments at all, and folding an
249 // empty row yields *zero* rows rather than one empty one — so
250 // wrapping silently deleted every blank line in the file.
251 // `rich -x` on a 2698-line source dropped all 386 of them, and
252 // the loss was baked into HTML exports too.
253 if row.is_empty() {
254 vec![Vec::new()]
255 } else {
256 Segment::split_lines(&Segment::fold_lines_words(&row, code_width))
257 }
258 })
259 .collect();
260 }
261
262 // Left gutter, then the blank inset rows, both in the block background.
263 let pad_style = {
264 let mut style = Style::new();
265 if let Some(bg) = &background {
266 style = style.with_bgcolor(bg.clone());
267 }
268 style
269 };
270 if self.padding > 0 {
271 for row in &mut lines {
272 row.insert(
273 0,
274 Segment::new(" ".repeat(self.padding), Some(pad_style.clone())),
275 );
276 }
277 let blank = vec![Segment::new(" ".repeat(width), Some(pad_style.clone()))];
278 for _ in 0..self.padding {
279 lines.insert(0, blank.clone());
280 lines.push(blank.clone());
281 }
282 }
283
284 // Pad each line to the full width with the theme background, so the
285 // block reads as a solid panel of code.
286 for row in &mut lines {
287 let used: usize = row.iter().map(Segment::cell_length).sum();
288 if width > used {
289 let mut pad = Style::new();
290 if let Some(bg) = &background {
291 pad = pad.with_bgcolor(bg.clone());
292 }
293 row.push(Segment::new(" ".repeat(width - used), Some(pad)));
294 }
295 }
296
297 let mut segments = Vec::new();
298 let last = lines.len().saturating_sub(1);
299 for (index, line) in lines.into_iter().enumerate() {
300 segments.extend(line);
301 if index != last {
302 segments.push(Segment::line());
303 }
304 }
305 segments
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::color::ColorSystem;
313
314 fn render(code: &str, lang: &str, width: usize) -> String {
315 Console::builder()
316 .force_terminal(true)
317 .color_system(Some(ColorSystem::Truecolor))
318 .width(width)
319 .no_color(false)
320 .build()
321 .render_to_string(&Syntax::new(code, lang))
322 }
323
324 #[test]
325 fn highlights_rust_keyword() {
326 // Functional (not byte-parity): assert the code text survives and the
327 // output is colored (contains SGR sequences).
328 let out = render("fn main() {}", "rust", 20);
329 assert!(out.contains("fn"));
330 assert!(out.contains("main"));
331 assert!(out.contains('\x1b'), "expected ANSI color codes");
332 }
333
334 #[test]
335 fn multiple_lines_are_separated() {
336 let out = render("let x = 1;\nlet y = 2;", "rust", 20);
337 assert_eq!(out.matches('\n').count(), 1);
338 assert!(out.contains("let"));
339 }
340
341 #[test]
342 fn unknown_language_renders_plain() {
343 // No panic, code preserved, still padded/colored to a block.
344 let out = render("just some text", "nonsense-lang", 20);
345 assert!(out.contains("just some text"));
346 }
347
348 #[test]
349 fn word_wrap_is_off_by_default_matching_upstream() {
350 // Measured against upstream: Syntax(word_wrap=False) at width 80 keeps
351 // 80 of 300 characters. The default must not diverge from that.
352 let code = "A".repeat(300);
353 let out = render(&code, "python", 80);
354 assert_eq!(out.matches('A').count(), 80, "default should crop");
355 }
356
357 #[test]
358 fn word_wrap_keeps_every_character() {
359 let code = "A".repeat(300);
360 let console = Console::builder().width(80).no_color(true).build();
361 let out = console.render_to_string(&Syntax::new(code.as_str(), "python").word_wrap(true));
362 assert_eq!(
363 out.matches('A').count(),
364 300,
365 "wrapping must not lose characters:
366{out}"
367 );
368 }
369
370 /// Syntax emits segments directly rather than going through `Text`, so the
371 /// shared `strip_control_codes` never ran and `rich -x` leaked backspaces
372 /// and BELs that `rich -m` did not.
373 #[test]
374 fn control_codes_are_stripped_from_highlighted_code() {
375 let out = render("let x = 1;\u{7}\u{8}\u{b}\u{c}", "rust", 40);
376 for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}'] {
377 assert!(
378 !out.contains(code),
379 "control code {code:?} reached the output"
380 );
381 }
382 assert!(out.contains("let"), "content lost with the control codes");
383 }
384
385 /// A blank source line has no segments, and folding an empty row yielded
386 /// zero rows rather than one empty one — so wrapping silently deleted every
387 /// blank line in the file, and the loss was baked into exports.
388 #[test]
389 fn word_wrap_keeps_blank_lines() {
390 let console = Console::builder().width(20).no_color(true).build();
391 let out =
392 console.render_to_string(&Syntax::new("a = 1\n\nb = 2\n", "python").word_wrap(true));
393 let rows: Vec<&str> = out.trim_end_matches('\n').split('\n').collect();
394 // Four rows, not three: upstream splits with Python's `str.split("\n")`,
395 // so the trailing newline contributes a final empty row —
396 // `"a = 1\n\nb = 2\n".split("\n") == ["a = 1", "", "b = 2", ""]`, and
397 // rich 15.0.0 prints four padded rows for it. This assertion previously
398 // said three, pinning our own missing-row bug as the expectation.
399 assert_eq!(rows.len(), 4, "blank line lost: {rows:?}");
400 assert!(
401 rows[1].trim().is_empty(),
402 "middle row should be blank: {rows:?}"
403 );
404 assert!(
405 rows[3].trim().is_empty(),
406 "trailing row should be blank: {rows:?}"
407 );
408 }
409
410 /// `Syntax._process_code` runs `code.expandtabs(self.tab_size)` before
411 /// anything is highlighted. We emitted the raw U+0009 and measured it as one
412 /// cell, so a tabbed line reached the terminal 31-32 cells wide against a
413 /// requested 30 and tore the background block.
414 ///
415 /// Both expectations captured verbatim from real rich 15.0.0.
416 #[test]
417 fn tabs_are_expanded_before_highlighting() {
418 let console = Console::builder().width(30).no_color(true).build();
419 let out = console.render_to_string(&Syntax::new(
420 "def f():\n\tif x:\n\t\treturn 1\n\treturn 0",
421 "python",
422 ));
423 assert_eq!(
424 out.split('\n').collect::<Vec<_>>(),
425 [
426 "def f(): ",
427 " if x: ",
428 " return 1 ",
429 " return 0 ",
430 ]
431 );
432 assert!(!out.contains('\t'), "a raw tab survived: {out:?}");
433 }
434
435 /// A tab advances to the next multiple of the tab size, so it is *not* a
436 /// fixed run of spaces — the width of the text before it decides.
437 #[test]
438 fn a_tab_advances_to_the_next_tab_stop() {
439 let console = Console::builder().width(20).no_color(true).build();
440 let out = console.render_to_string(&Syntax::new(
441 "a\tb\tc\nab\tcd\tef\nabcd\tefgh\tijkl",
442 "python",
443 ));
444 assert_eq!(
445 out.split('\n').collect::<Vec<_>>(),
446 [
447 "a b c ",
448 "ab cd ef ",
449 "abcd efgh ijkl",
450 ]
451 );
452 }
453
454 /// Every row must occupy exactly the requested width *on screen*.
455 ///
456 /// Measuring against [`cell_len`] cannot catch this: it counted a raw tab as
457 /// one cell and the padding was computed the same way, so the row looked
458 /// exactly `width` wide to us while the terminal advanced the tab to the
459 /// next 8-cell stop and the block overran by seven.
460 #[test]
461 fn a_tabbed_line_measures_the_requested_width() {
462 /// Width as the *terminal* renders it: a tab jumps to the next 8-cell
463 /// stop, which is the only measure that reveals the defect.
464 fn screen_width(row: &str) -> usize {
465 let mut column = 0usize;
466 for ch in row.chars() {
467 column += if ch == '\t' {
468 8 - (column % 8)
469 } else {
470 cell_len(ch.encode_utf8(&mut [0u8; 4]))
471 };
472 }
473 column
474 }
475
476 for width in [10usize, 20, 30, 40] {
477 let console = Console::builder().width(width).no_color(true).build();
478 let out = console.render_to_string(&Syntax::new("\tvalue = compute(a, b)", "python"));
479 for row in out.split('\n') {
480 assert_eq!(screen_width(row), width, "row {row:?} at width {width}");
481 }
482 }
483 }
484
485 /// `str.expandtabs` counts *characters*, not cells, and resets its column at
486 /// `\n` and `\r`.
487 #[test]
488 fn expand_tabs_matches_pythons_str_expandtabs() {
489 // Left column verified against CPython's `str.expandtabs(4)`.
490 for (input, expected) in [
491 ("a\tb", "a b"),
492 ("ab\tb", "ab b"),
493 ("abc\tb", "abc b"),
494 ("abcd\tb", "abcd b"),
495 ("\t", " "),
496 ("a\nbb\tc", "a\nbb c"),
497 ("a\rbb\tc", "a\rbb c"),
498 // A wide char counts as one column, exactly as in Python.
499 ("\u{4e2d}\tx", "\u{4e2d} x"),
500 ] {
501 assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
502 }
503 // `tabsize <= 0` deletes the tab (CPython's own branch).
504 assert_eq!(expand_tabs("a\tb", 0), "ab");
505 }
506
507 /// Upstream's word_wrap breaks at word boundaries; we folded wherever the
508 /// row filled up, splitting identifiers mid-word.
509 #[test]
510 fn word_wrap_breaks_between_words() {
511 let console = Console::builder().width(30).no_color(true).build();
512 // This exact line is the one character-folding splits as `z` / `eta`,
513 // which is what makes the assertion discriminating.
514 let code = "result = compute_total(alpha, beta, gamma, delta, epsilon, zeta, eta, theta)\n";
515 let out = console.render_to_string(&Syntax::new(code, "python").word_wrap(true));
516 // Every identifier must survive on a single row. Folding mid-word split
517 // `epsilon` across the break as `e` / `psilon`.
518 for word in [
519 "compute_total",
520 "alpha",
521 "gamma",
522 "epsilon",
523 "zeta",
524 "theta",
525 ] {
526 assert!(
527 out.split('\n').any(|row| row.contains(word)),
528 "{word:?} was split across rows: {out:?}"
529 );
530 }
531 }
532}