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::protocol::Renderable;
29use crate::segment::Segment;
30use crate::style::Style;
31use crate::text::is_control_code;
32
33/// The default theme (a dark base16 palette shipped with `syntect`).
34const DEFAULT_THEME: &str = "base16-ocean.dark";
35
36/// Upstream's `Syntax(tab_size=4)`.
37const DEFAULT_TAB_SIZE: usize = 4;
38
39/// A block of syntax-highlighted source code. Mirrors `rich.syntax.Syntax`.
40pub 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
49/// Port of Python's `str.expandtabs(tab_size)`, which `Syntax._process_code`
50/// runs over the source before highlighting it.
51///
52/// A tab advances to the next multiple of `tab_size` **counted in characters,
53/// not cells** (CPython's `unicode_expandtabs` walks code points), and the
54/// column resets at `\n` and `\r`. `tab_size == 0` deletes the tab, matching
55/// CPython's `tabsize <= 0` branch.
56///
57/// Without this the raw U+0009 reached the terminal, where it jumps to the next
58/// 8-cell stop while we had measured it as one cell: a block asked to be 30
59/// wide rendered 31-32 cells and tore the background panel.
60fn 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    /// Wrap lines wider than the render width instead of cropping them.
90    ///
91    /// Off by default, matching upstream's `Syntax(word_wrap=False)`: a long
92    /// line is cut at the width. Upstream's **CLI** turns this on, which is why
93    /// `rich --syntax` does too — cropping a source file silently loses code.
94    pub fn word_wrap(mut self, wrap: bool) -> Self {
95        self.word_wrap = wrap;
96        self
97    }
98
99    /// Highlight `code` as `language` (a name or file extension, e.g. `"rust"`
100    /// or `"rs"`). Pass an empty/unknown language to render as plain text.
101    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    /// How far a tab advances the column, in characters. Upstream's
113    /// `Syntax(tab_size=…)`, default 4.
114    ///
115    /// Tabs are *expanded* to spaces before highlighting (upstream's
116    /// `code.expandtabs(self.tab_size)`), so this is the only tab handling in
117    /// play — the rendered code contains no U+0009 at all.
118    pub fn tab_size(mut self, tab_size: usize) -> Self {
119        self.tab_size = tab_size;
120        self
121    }
122
123    /// Surround the code with `padding` cells of background on every side.
124    ///
125    /// Upstream's Markdown renders a fenced block as `Syntax(..., padding=1)`,
126    /// which is what gives a code block its blank inset row above and below and
127    /// its one-column gutter. Without it the code sat flush against the
128    /// surrounding text and every document containing a fence diverged.
129    pub fn padding(mut self, padding: usize) -> Self {
130        self.padding = padding;
131        self
132    }
133
134    /// Choose the highlighting theme (a `syntect` theme name). Unknown names fall
135    /// back to the default.
136    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
152/// Convert a `syntect` RGBA color to a truecolor [`Color`] (alpha dropped).
153fn to_color(c: SynColor) -> Color {
154    Color::from_rgb(c.r, c.g, c.b)
155}
156
157/// Convert a `syntect` style (fg/bg + font flags) to a rich [`Style`].
158fn 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        // Resolve the language by token (name) or extension; else plain text.
192        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        // The gutter eats into the space the code itself may occupy.
207        let width = options.max_width;
208        let code_width = width.saturating_sub(self.padding * 2);
209
210        // `Syntax._process_code`: the source is tab-expanded before it reaches
211        // the highlighter, so no U+0009 ever survives into a segment.
212        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                // Upstream's Syntax builds a `Text`, so `strip_control_codes`
227                // runs on every token. We emit segments directly, which let BEL,
228                // backspace, vertical tab and form feed through to the terminal
229                // — a backspace run rewrites what the reader sees.
230                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        // Upstream splits the source with Python's `str.split("\n")`, which keeps
242        // the empty element after a trailing newline — so a file ending in `\n`
243        // gets one final padded blank row. `LinesWithEndings` yields no such
244        // element, so every source (i.e. nearly every real file) rendered one row
245        // short of upstream. An empty source splits to `[""]`, one row, too.
246        if code.is_empty() || code.ends_with('\n') {
247            lines.push(Vec::new());
248        }
249
250        // Wrapping happens before padding, so every *visual* row gets the same
251        // background treatment rather than only the first.
252        if self.word_wrap {
253            lines = lines
254                .into_iter()
255                .flat_map(|row| {
256                    // A blank source line has no segments at all, and folding an
257                    // empty row yields *zero* rows rather than one empty one — so
258                    // wrapping silently deleted every blank line in the file.
259                    // `rich -x` on a 2698-line source dropped all 386 of them, and
260                    // the loss was baked into HTML exports too.
261                    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        // Left gutter, then the blank inset rows, both in the block background.
271        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        // Pad each line to the full width with the theme background, so the
293        // block reads as a solid panel of code.
294        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        // Functional (not byte-parity): assert the code text survives and the
335        // output is colored (contains SGR sequences).
336        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        // No panic, code preserved, still padded/colored to a block.
352        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        // Measured against upstream: Syntax(word_wrap=False) at width 80 keeps
359        // 80 of 300 characters. The default must not diverge from that.
360        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    /// Syntax emits segments directly rather than going through `Text`, so the
379    /// shared `strip_control_codes` never ran and `rich -x` leaked backspaces
380    /// and BELs that `rich -m` did not.
381    #[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    /// A blank source line has no segments, and folding an empty row yielded
394    /// zero rows rather than one empty one — so wrapping silently deleted every
395    /// blank line in the file, and the loss was baked into exports.
396    #[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        // Four rows, not three: upstream splits with Python's `str.split("\n")`,
403        // so the trailing newline contributes a final empty row —
404        // `"a = 1\n\nb = 2\n".split("\n") == ["a = 1", "", "b = 2", ""]`, and
405        // rich 15.0.0 prints four padded rows for it. This assertion previously
406        // said three, pinning our own missing-row bug as the expectation.
407        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    /// `Syntax._process_code` runs `code.expandtabs(self.tab_size)` before
419    /// anything is highlighted. We emitted the raw U+0009 and measured it as one
420    /// cell, so a tabbed line reached the terminal 31-32 cells wide against a
421    /// requested 30 and tore the background block.
422    ///
423    /// Both expectations captured verbatim from real rich 15.0.0.
424    #[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    /// A tab advances to the next multiple of the tab size, so it is *not* a
444    /// fixed run of spaces — the width of the text before it decides.
445    #[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    /// Every row must occupy exactly the requested width *on screen*.
463    ///
464    /// Measuring against [`cell_len`] cannot catch this: it counted a raw tab as
465    /// one cell and the padding was computed the same way, so the row looked
466    /// exactly `width` wide to us while the terminal advanced the tab to the
467    /// next 8-cell stop and the block overran by seven.
468    #[test]
469    fn a_tabbed_line_measures_the_requested_width() {
470        /// Width as the *terminal* renders it: a tab jumps to the next 8-cell
471        /// stop, which is the only measure that reveals the defect.
472        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    /// `str.expandtabs` counts *characters*, not cells, and resets its column at
494    /// `\n` and `\r`.
495    #[test]
496    fn expand_tabs_matches_pythons_str_expandtabs() {
497        // Left column verified against CPython's `str.expandtabs(4)`.
498        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            // A wide char counts as one column, exactly as in Python.
507            ("\u{4e2d}\tx", "\u{4e2d}   x"),
508        ] {
509            assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
510        }
511        // `tabsize <= 0` deletes the tab (CPython's own branch).
512        assert_eq!(expand_tabs("a\tb", 0), "ab");
513    }
514
515    /// Upstream's word_wrap breaks at word boundaries; we folded wherever the
516    /// row filled up, splitting identifiers mid-word.
517    #[test]
518    fn word_wrap_breaks_between_words() {
519        let console = Console::builder().width(30).no_color(true).build();
520        // This exact line is the one character-folding splits as `z` / `eta`,
521        // which is what makes the assertion discriminating.
522        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        // Every identifier must survive on a single row. Folding mid-word split
525        // `epsilon` across the break as `e` / `psilon`.
526        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}