Skip to main content

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