Skip to main content

rich/
syntax.rs

1//! Syntax highlighting.
2//!
3//! Port of `rich/syntax.py`'s renderable surface. A [`Syntax`] highlights a
4//! block of source code for a given language and theme, producing colored
5//! [`Segment`]s (a solid block: each line is padded to the render width with
6//! the theme background).
7//!
8//! Highlighting goes through a [`CodeHighlighter`]. The default,
9//! [`SyntectHighlighter`], uses the `syntect` crate.
10//!
11//! **Divergence:** upstream uses Pygments; `syntect` ships different grammars
12//! and themes. So the *coloring is functional, not byte-identical* to Python
13//! rich — see docs/DIVERGENCES.md. Everything else (the renderable protocol,
14//! width handling) matches the port's conventions.
15
16use std::collections::BTreeSet;
17use std::sync::Arc;
18
19#[cfg(feature = "syntax-cache")]
20#[path = "syntax_cache.rs"]
21mod cache;
22#[path = "syntax_syntect.rs"]
23mod syntect_adapter;
24
25pub use syntect_adapter::SyntectHighlighter;
26
27use crate::cells::cell_len;
28use crate::color::{Color, ColorSystem, ColorTriplet, ColorType};
29use crate::console::{Console, ConsoleOptions};
30use crate::console::{Justify, Overflow};
31use crate::measure::Measurement;
32use crate::protocol::{
33    CodeHighlighter, HighlightError, HighlightedCode, HighlightedLine, Renderable,
34};
35use crate::segment::Segment;
36use crate::style::{Style, StyleType};
37use crate::text::python_splitlines;
38use crate::text::Text;
39
40/// Upstream's `Syntax(tab_size=4)`.
41const DEFAULT_TAB_SIZE: usize = 4;
42
43/// A block of syntax-highlighted source code. Mirrors `rich.syntax.Syntax`.
44pub struct Syntax {
45    code: String,
46    language: Option<String>,
47    /// `None` means the highlighter's default theme.
48    theme: Option<String>,
49    word_wrap: bool,
50    /// `(top, right, bottom, left)`.
51    padding: (usize, usize, usize, usize),
52    tab_size: usize,
53    /// `None` means [`SyntectHighlighter`].
54    highlighter: Option<Arc<dyn CodeHighlighter>>,
55    line_numbers: bool,
56    start_line: i64,
57    line_range: Option<(Option<i64>, Option<i64>)>,
58    highlight_lines: BTreeSet<i64>,
59    code_width: Option<usize>,
60    background_color: Option<String>,
61    indent_guides: bool,
62    stylized_ranges: Vec<StylizedRange>,
63}
64
65/// A `(line, column)` position in the code: 1-based line, 0-based column.
66/// Upstream's `SyntaxPosition`: a 1-based line and a 0-based column. Both
67/// are signed, as upstream's are (see [`Syntax::stylize_range`]).
68pub type SyntaxPosition = (i64, i64);
69
70/// Upstream's `_SyntaxHighlightRange`.
71#[derive(Clone, Debug)]
72struct StylizedRange {
73    style: StyleType,
74    start: SyntaxPosition,
75    end: SyntaxPosition,
76    style_before: bool,
77}
78
79/// Upstream's `NUMBERS_COLUMN_DEFAULT_PADDING`.
80const NUMBERS_COLUMN_DEFAULT_PADDING: usize = 2;
81
82/// Port of Python's `str.expandtabs(tab_size)`, which `Syntax._process_code`
83/// runs over the source before highlighting it.
84///
85/// A tab advances to the next multiple of `tab_size` **counted in characters,
86/// not cells** (CPython's `unicode_expandtabs` walks code points), and the
87/// column resets at `\n` and `\r`. `tab_size == 0` deletes the tab, matching
88/// CPython's `tabsize <= 0` branch.
89///
90/// Without this the raw U+0009 reached the terminal, where it jumps to the next
91/// 8-cell stop while we had measured it as one cell: a block asked to be 30
92/// wide rendered 31-32 cells and tore the background panel.
93fn expand_tabs(code: &str, tab_size: usize) -> String {
94    if !code.contains('\t') {
95        return code.to_string();
96    }
97    let mut out = String::with_capacity(code.len());
98    let mut column = 0usize;
99    for ch in code.chars() {
100        match ch {
101            '\t' => {
102                if tab_size > 0 {
103                    let advance = tab_size - (column % tab_size);
104                    out.extend(std::iter::repeat_n(' ', advance));
105                    column += advance;
106                }
107            }
108            '\n' | '\r' => {
109                out.push(ch);
110                column = 0;
111            }
112            _ => {
113                out.push(ch);
114                column += 1;
115            }
116        }
117    }
118    out
119}
120
121impl Syntax {
122    /// Wrap lines wider than the render width instead of cropping them.
123    ///
124    /// Off by default, matching upstream's `Syntax(word_wrap=False)`: a long
125    /// line is cut at the width. Upstream's **CLI** turns this on, which is why
126    /// `rich --syntax` does too — cropping a source file silently loses code.
127    pub fn word_wrap(mut self, wrap: bool) -> Self {
128        self.word_wrap = wrap;
129        self
130    }
131
132    /// Highlight `code` as `language` (a name or file extension, e.g. `"rust"`
133    /// or `"rs"`). Pass an empty/unknown language to render as plain text.
134    pub fn new(code: impl Into<String>, language: impl Into<String>) -> Self {
135        Syntax {
136            word_wrap: false,
137            padding: (0, 0, 0, 0),
138            tab_size: DEFAULT_TAB_SIZE,
139            code: code.into(),
140            language: Some(language.into()).filter(|l| !l.is_empty()),
141            theme: None,
142            highlighter: None,
143            line_numbers: false,
144            start_line: 1,
145            line_range: None,
146            highlight_lines: BTreeSet::new(),
147            code_width: None,
148            background_color: None,
149            indent_guides: false,
150            stylized_ranges: Vec::new(),
151        }
152    }
153
154    /// Highlight with `highlighter` instead of the default
155    /// [`SyntectHighlighter`]. Theme names are the highlighter's own.
156    pub fn highlighter(mut self, highlighter: Arc<dyn CodeHighlighter>) -> Self {
157        self.highlighter = Some(highlighter);
158        self
159    }
160
161    /// How far a tab advances the column, in characters. Upstream's
162    /// `Syntax(tab_size=…)`, default 4.
163    ///
164    /// Tabs are *expanded* to spaces before highlighting (upstream's
165    /// `code.expandtabs(self.tab_size)`), so this is the only tab handling in
166    /// play — the rendered code contains no U+0009 at all.
167    pub fn tab_size(mut self, tab_size: usize) -> Self {
168        self.tab_size = tab_size;
169        self
170    }
171
172    /// Surround the code with `padding` cells of background on every side.
173    ///
174    /// Upstream's Markdown renders a fenced block as `Syntax(..., padding=1)`,
175    /// which is what gives a code block its blank inset row above and below and
176    /// its one-column gutter. Without it the code sat flush against the
177    /// surrounding text and every document containing a fence diverged.
178    pub fn padding(mut self, padding: usize) -> Self {
179        self.padding = (padding, padding, padding, padding);
180        self
181    }
182
183    /// Padding on each side, `(top, right, bottom, left)`, in the code
184    /// background (upstream `padding` as a tuple).
185    pub fn padding_sides(mut self, padding: (usize, usize, usize, usize)) -> Self {
186        self.padding = padding;
187        self
188    }
189
190    /// Number the lines in a gutter (upstream `line_numbers`, default off).
191    pub fn line_numbers(mut self, line_numbers: bool) -> Self {
192        self.line_numbers = line_numbers;
193        self
194    }
195
196    /// The number of the first line (upstream `start_line`, default 1). Any
197    /// integer, as upstream's: zero and negative numbers are drawn as they are.
198    pub fn start_line(mut self, start_line: i64) -> Self {
199        self.start_line = start_line;
200        self
201    }
202
203    /// Render only lines `start..=end` (1-based; `None` leaves that end open).
204    /// Upstream `line_range`, with its Python semantics: a start of 0 or less
205    /// is the first line, and a negative end counts back from the last line
206    /// (`lines[start - 1:end]`) after the highlighter has stopped at the first
207    /// line it reached.
208    pub fn line_range(mut self, start: Option<i64>, end: Option<i64>) -> Self {
209        self.line_range = Some((start, end));
210        self
211    }
212
213    /// Mark these line numbers with a pointer in the gutter (upstream
214    /// `highlight_lines`; shown with `line_numbers`).
215    pub fn highlight_lines(mut self, lines: impl IntoIterator<Item = i64>) -> Self {
216        self.highlight_lines = lines.into_iter().collect();
217        self
218    }
219
220    /// A fixed width for the code, excluding line numbers (upstream
221    /// `code_width`; default all the available width).
222    pub fn code_width(mut self, code_width: usize) -> Self {
223        self.code_width = Some(code_width);
224        self
225    }
226
227    /// Override the theme's background colour (upstream `background_color`).
228    pub fn background_color(mut self, color: impl Into<String>) -> Self {
229        self.background_color = Some(color.into());
230        self
231    }
232
233    /// Draw indent guides (upstream `indent_guides`, default off; not on an
234    /// ASCII-only console).
235    pub fn indent_guides(mut self, indent_guides: bool) -> Self {
236        self.indent_guides = indent_guides;
237        self
238    }
239
240    /// Style a range of the code, from `start` to `end` (`(line, column)`,
241    /// 1-based lines, 0-based columns), on top of the highlighting — or
242    /// beneath it with `style_before`. Port of `Syntax.stylize_range`.
243    pub fn stylize_range(
244        &mut self,
245        style: impl Into<StyleType>,
246        start: SyntaxPosition,
247        end: SyntaxPosition,
248        style_before: bool,
249    ) -> &mut Self {
250        self.stylized_ranges.push(StylizedRange {
251            style: style.into(),
252            start,
253            end,
254            style_before,
255        });
256        self
257    }
258
259    /// Choose the highlighting theme, by the highlighter's name for it. The
260    /// default highlighter offers `syntect`'s themes plus upstream's
261    /// `ansi_dark` and `ansi_light`. Unknown names fall back to the
262    /// highlighter's default theme.
263    pub fn theme(mut self, theme: impl Into<String>) -> Self {
264        self.theme = Some(theme.into());
265        self
266    }
267
268    /// Highlight the (already tab-expanded) `code`, falling back to the default
269    /// theme for an unknown one and to plain text if the engine fails, then
270    /// validate the result against the source (see [`CodeHighlighter`]).
271    ///
272    /// The engine is this `Syntax`'s own, else `console`'s default (whose theme
273    /// then applies unless this `Syntax` names one), else syntect.
274    fn highlighted(&self, code: &str, console: Option<&Console>) -> HighlightedCode {
275        let (engine, theme) = self.engine_and_theme(console);
276        let language = self.language.as_deref();
277        let result = match engine.highlight(code, language, &theme) {
278            Err(HighlightError::UnknownTheme(_)) => {
279                engine.highlight(code, language, engine.default_theme())
280            }
281            other => other,
282        };
283        let highlighted = result.unwrap_or_default();
284        validate(code, highlighted)
285    }
286}
287
288/// Make a highlighter's output safe to render against `code`: one line per
289/// `code.split('\n')` element, spans sorted, in range, non-overlapping and on
290/// character boundaries, and no hyperlinks in adapter styles.
291fn validate(code: &str, mut highlighted: HighlightedCode) -> HighlightedCode {
292    let sources: Vec<&str> = code.split('\n').collect();
293    highlighted
294        .lines
295        .resize_with(sources.len(), HighlightedLine::default);
296    for (line, source) in highlighted.lines.iter_mut().zip(&sources) {
297        let mut end = 0usize;
298        line.spans.retain(|span| {
299            let keep = span.range.start < span.range.end
300                && span.range.start >= end
301                && span.range.end <= source.len()
302                && source.is_char_boundary(span.range.start)
303                && source.is_char_boundary(span.range.end);
304            if keep {
305                end = span.range.end;
306            }
307            keep
308        });
309        for span in &mut line.spans {
310            span.style = span.style.update_link(None);
311        }
312        line.newline_style = line.newline_style.as_ref().map(|s| s.update_link(None));
313    }
314    highlighted.default_style = highlighted.default_style.update_link(None);
315    highlighted
316}
317
318/// The pieces of one source line: every span, plus the gaps between them in
319/// the default style, in order.
320fn line_pieces<'a>(
321    source: &'a str,
322    line: &HighlightedLine,
323    default_style: &Style,
324) -> Vec<(&'a str, Style)> {
325    let mut pieces = Vec::with_capacity(line.spans.len() + 1);
326    let mut position = 0usize;
327    for span in &line.spans {
328        if span.range.start > position {
329            pieces.push((&source[position..span.range.start], default_style.clone()));
330        }
331        pieces.push((&source[span.range.clone()], span.style.clone()));
332        position = span.range.end;
333    }
334    if position < source.len() {
335        pieces.push((&source[position..], default_style.clone()));
336    }
337    pieces
338}
339
340impl Syntax {
341    /// The highlighting engine and theme name: this `Syntax`'s own, else
342    /// `console`'s default (whose theme then applies unless this `Syntax`
343    /// names one), else syntect's.
344    fn engine_and_theme(&self, console: Option<&Console>) -> (Arc<dyn CodeHighlighter>, String) {
345        use crate::protocol::ConsoleCodeHighlighting;
346        let default = match &self.highlighter {
347            Some(_) => None,
348            None => console.and_then(|console| console.code_highlighting()),
349        };
350        let engine = self
351            .highlighter
352            .clone()
353            .or_else(|| default.map(|d| d.highlighter.clone()))
354            .unwrap_or_else(SyntectHighlighter::shared);
355        let theme = self
356            .theme
357            .as_deref()
358            .or_else(|| default.and_then(|d| d.theme.as_deref()))
359            .unwrap_or(engine.default_theme())
360            .to_string();
361        (engine, theme)
362    }
363
364    /// The theme's style for a token type, falling back to the default theme
365    /// when the chosen one is unknown (as highlighting does).
366    fn token_style(&self, console: &Console, token: &str) -> Style {
367        let (engine, theme) = self.engine_and_theme(Some(console));
368        let theme = if engine.themes().contains(&theme) {
369            theme
370        } else {
371            engine.default_theme().to_string()
372        };
373        engine.token_style(&theme, token).unwrap_or_default()
374    }
375
376    /// `Style(bgcolor=background_color)`, or null.
377    fn background_style(&self) -> Style {
378        self.background_color
379            .as_deref()
380            .and_then(|color| Color::parse(color).ok())
381            .map_or_else(Style::new, |color| Style::new().with_bgcolor(color))
382    }
383
384    /// Highlight the code into a [`Text`](crate::text::Text) rather than a padded block. Port of
385    /// `Syntax.highlight`: the theme background is the text's base style and
386    /// every token carries its own style. Tabs are expanded first, as
387    /// `_process_code` does. Used by `Markdown(inline_code_lexer=…)`.
388    pub fn highlight(&self) -> crate::text::Text {
389        self.highlight_text(None)
390    }
391
392    /// [`highlight`](Self::highlight) with `console`'s default code
393    /// highlighter (see [`ConsoleCodeHighlighting`](crate::protocol::ConsoleCodeHighlighting))
394    /// when this `Syntax` has none of its own. Not in upstream.
395    pub fn highlight_for(&self, console: &Console) -> crate::text::Text {
396        self.highlight_text(Some(console))
397    }
398
399    /// `Syntax._process_code` plus Pygments' own preprocessing: tabs are
400    /// expanded, then `\r\n` and a lone `\r` become `\n` (Pygments' `Lexer`
401    /// does this for every lexer, the plain-text one included). Without it a
402    /// lone `\r` was stripped as a control code and joined two lines.
403    fn process_code(&self) -> String {
404        let mut code = expand_tabs(&self.code, self.tab_size);
405        if !code.contains('\r') {
406            return code;
407        }
408        // Upstream appends `\n` to code that lacks one and removes it after
409        // highlighting, so a trailing lone `\r` becomes that `\r\n` and goes.
410        if code.ends_with('\r') {
411            code.pop();
412        }
413        code.replace("\r\n", "\n").replace('\r', "\n")
414    }
415
416    /// Port of `Syntax.highlight(code, line_range)` over this `Syntax`'s
417    /// code: the highlighted `Text` in the base style (the theme's
418    /// background and `background_color`), with upstream's `justify`,
419    /// `tab_size` and `no_wrap`. With a `line_range`, lines before it are
420    /// left unstyled and lines after it dropped. `console` supplies a
421    /// default code highlighter, as in [`highlight_for`](Self::highlight_for).
422    pub fn highlight_range(
423        &self,
424        line_range: Option<(Option<i64>, Option<i64>)>,
425        console: Option<&Console>,
426    ) -> crate::text::Text {
427        let mut code = self.process_code();
428        if !code.ends_with('\n') {
429            code.push('\n');
430        }
431        let highlighted = self.highlighted(&code, console);
432        let base_style = highlighted
433            .background
434            .clone()
435            .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
436            .combine(&self.background_style());
437        let transparent = base_style.bgcolor().is_none();
438        let mut text = self.highlighted_text(&code, &highlighted, line_range);
439        text.set_base_style(base_style);
440        text.set_justify(if transparent {
441            Justify::Default
442        } else {
443            Justify::Left
444        });
445        text.set_tab_size(Some(self.tab_size));
446        text.set_no_wrap(Some(!self.word_wrap));
447        text
448    }
449
450    fn highlight_text(&self, console: Option<&Console>) -> crate::text::Text {
451        let code = self.process_code();
452        let highlighted = self.highlighted(&code, console);
453        let mut text = self.highlighted_text(&code, &highlighted, None);
454        if let Some(background) = &highlighted.background {
455            text.set_base_style(Style::new().with_bgcolor(background.clone()));
456        }
457        text
458    }
459
460    /// Port of `Syntax.highlight`'s token loop over already highlighted
461    /// `code`: with a `line_range`, lines before it are left unstyled and
462    /// lines after it are dropped. Then `background_color` and the stylized
463    /// ranges.
464    fn highlighted_text(
465        &self,
466        code: &str,
467        highlighted: &HighlightedCode,
468        line_range: Option<(Option<i64>, Option<i64>)>,
469    ) -> Text {
470        let mut text = Text::new("");
471        let (line_start, line_end) = line_range.unwrap_or((None, None));
472        // `_line_start = line_start - 1 if line_start else 0`; below zero no
473        // line is skipped.
474        let skip = line_start.filter(|&start| start != 0).map_or(0, |start| {
475            usize::try_from(start.saturating_sub(1)).unwrap_or(0)
476        });
477        let sources: Vec<&str> = code.split('\n').collect();
478        let last = sources.len().saturating_sub(1);
479        for (index, (source, line)) in sources.iter().zip(&highlighted.lines).enumerate() {
480            let mut pieces: Vec<(String, Option<Style>)> =
481                line_pieces(source, line, &highlighted.default_style)
482                    .into_iter()
483                    .map(|(piece, style)| (piece.to_string(), Some(style)))
484                    .collect();
485            if index != last {
486                // The engine's style for the line break. When it matches the
487                // last piece, the break joins that piece, as one token.
488                let style = line.newline_style.clone().unwrap_or_default();
489                match pieces.last_mut() {
490                    Some((piece, Some(last_style)))
491                        if line.newline_style.is_some() && *last_style == style =>
492                    {
493                        piece.push('\n');
494                    }
495                    _ => pieces.push(("\n".to_string(), Some(style))),
496                }
497            }
498            for (piece, style) in pieces {
499                // Tokens before the range carry no style (`yield (token, None)`).
500                let style = style.filter(|_| index >= skip);
501                text.append(piece.as_str(), style.map(Into::into));
502            }
503            // `if line_end and line_no >= line_end: break`.
504            // A negative end is truthy and already passed.
505            if line_end.is_some_and(|end| end != 0 && (index as i64) + 1 >= end && index >= skip) {
506                break;
507            }
508        }
509        if let Some(color) = &self.background_color {
510            let len = text.plain().len();
511            text.stylize(StyleType::Name(format!("on {color}")), 0, len);
512        }
513        self.apply_stylized_ranges(&mut text);
514        text
515    }
516
517    /// Port of `Syntax._apply_stylized_ranges`: positions are resolved
518    /// against the highlighted text; a column past the end of its line is
519    /// clamped, a line out of range skips the range.
520    fn apply_stylized_ranges(&self, text: &mut Text) {
521        if self.stylized_ranges.is_empty() {
522            return;
523        }
524        let plain = text.plain().to_string();
525        // Character offsets of each line start, plus `len + 1`.
526        let mut offsets = vec![0usize];
527        let mut chars = 0usize;
528        for ch in plain.chars() {
529            chars += 1;
530            if ch == '\n' {
531                offsets.push(chars);
532            }
533        }
534        offsets.push(chars + 1);
535        // Port of `_get_code_index_for_syntax_position`, with Python's
536        // negative list indexing: a line before the first counts back from
537        // the end of `offsets`, and a negative column back from the line's
538        // start. An index Python would raise `IndexError` for is skipped.
539        let count = offsets.len() as i64;
540        let offset_at = |index: i64| -> Option<i64> {
541            let index = if index < 0 {
542                count.saturating_add(index)
543            } else {
544                index
545            };
546            usize::try_from(index)
547                .ok()
548                .and_then(|index| offsets.get(index))
549                .map(|&offset| offset as i64)
550        };
551        let index_for = |(line_number, column): SyntaxPosition| -> Option<i64> {
552            if line_number > count || count < line_number.saturating_add(1) {
553                return None;
554            }
555            let line_index = line_number.saturating_sub(1);
556            let line_length = offset_at(line_index.saturating_add(1))? - offset_at(line_index)? - 1;
557            Some(offset_at(line_index)?.saturating_add(column.min(line_length)))
558        };
559        let length = chars as i64;
560        let byte = |char_index: usize| {
561            plain
562                .char_indices()
563                .nth(char_index)
564                .map_or(plain.len(), |(at, _)| at)
565        };
566        for range in &self.stylized_ranges {
567            let (Some(start), Some(end)) = (index_for(range.start), index_for(range.end)) else {
568                continue;
569            };
570            // `Text.stylize`: negative offsets count from the end, and an
571            // empty or out-of-range span is dropped.
572            let start = if start < 0 {
573                length.saturating_add(start)
574            } else {
575                start
576            };
577            let end = if end < 0 {
578                length.saturating_add(end)
579            } else {
580                end
581            };
582            if start >= length || end <= start {
583                continue;
584            }
585            let (start, end) = (byte(start.max(0) as usize), byte(end.min(length) as usize));
586            if range.style_before {
587                text.stylize_before(range.style.clone(), start, end);
588            } else {
589                text.stylize(range.style.clone(), start, end);
590            }
591        }
592    }
593}
594
595impl Syntax {
596    /// Upstream's `_numbers_column_width`.
597    fn numbers_column_width(&self) -> usize {
598        if !self.line_numbers {
599            return 0;
600        }
601        // Python ints are unbounded: widen so `i64::MAX` does not overflow.
602        let last = i128::from(self.start_line) + self.code.matches('\n').count() as i128;
603        last.to_string().len() + NUMBERS_COLUMN_DEFAULT_PADDING
604    }
605
606    /// `(background, number, highlighted number)` styles for the gutter.
607    /// Port of `Syntax._get_number_styles`.
608    fn number_styles(&self, console: &Console, base_style: &Style) -> (Style, Style, Style) {
609        let dim = |on: bool| Style::parse(if on { "dim" } else { "not dim" }).unwrap_or_default();
610        if base_style.bgcolor().is_none() {
611            return (Style::new(), dim(true), Style::new());
612        }
613        if matches!(
614            console.color_system(),
615            Some(ColorSystem::EightBit | ColorSystem::Truecolor)
616        ) {
617            let text_style = self.token_style(console, "Text");
618            let background = self.background_style();
619            let number = base_style
620                .combine(&text_style)
621                .combine(
622                    &Style::new().with_color(self.line_numbers_color(console, base_style, 0.3)),
623                )
624                .combine(&background);
625            let highlight = base_style
626                .combine(&text_style)
627                .combine(
628                    &Style::parse("bold")
629                        .unwrap_or_default()
630                        .with_color(self.line_numbers_color(console, base_style, 0.9)),
631                )
632                .combine(&background);
633            (base_style.clone(), number, highlight)
634        } else {
635            (
636                base_style.clone(),
637                base_style.combine(&dim(true)),
638                base_style.combine(&dim(false)),
639            )
640        }
641    }
642
643    /// Port of `Syntax._get_line_numbers_color`: the text colour blended
644    /// into the background.
645    fn line_numbers_color(&self, console: &Console, base_style: &Style, blend: f64) -> Color {
646        let system_defined =
647            |color: &Color| !matches!(color.kind, ColorType::EightBit | ColorType::Truecolor);
648        let Some(background) = base_style.bgcolor().filter(|color| !system_defined(color)) else {
649            return Color::default_color();
650        };
651        let text_style = self.token_style(console, "Text");
652        let Some(foreground) = text_style.color().filter(|color| !system_defined(color)) else {
653            return text_style
654                .color()
655                .cloned()
656                .unwrap_or_else(Color::default_color);
657        };
658        let (Some(bg), Some(fg)) = (background.get_truecolor(), foreground.get_truecolor()) else {
659            return Color::default_color();
660        };
661        let mix = |a: u8, b: u8| (f64::from(a) + (f64::from(b) - f64::from(a)) * blend) as u8;
662        let ColorTriplet { red, green, blue } = ColorTriplet::new(
663            mix(bg.red, fg.red),
664            mix(bg.green, fg.green),
665            mix(bg.blue, fg.blue),
666        );
667        Color::from_rgb(red, green, blue)
668    }
669
670    /// Port of `Syntax._get_syntax`: the code's lines, without the padding.
671    fn syntax_lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Vec<Segment>> {
672        let (_, pad_right, _, pad_left) = self.padding;
673        let horizontal_padding = pad_left + pad_right;
674        let numbers_column_width = self.numbers_column_width();
675        let code_width = match self.code_width {
676            Some(code_width) => code_width,
677            None => {
678                let width = if self.line_numbers {
679                    options.max_width.saturating_sub(numbers_column_width + 1)
680                } else {
681                    options.max_width
682                };
683                width.saturating_sub(horizontal_padding)
684            }
685        };
686
687        // `_process_code`: the code always ends with a newline to highlight.
688        let ends_on_nl = self.code.ends_with('\n');
689        let mut code = self.process_code();
690        if !code.ends_with('\n') {
691            code.push('\n');
692        }
693        let highlighted = self.highlighted(&code, Some(console));
694        let background_style = self.background_style();
695        let base_style = highlighted
696            .background
697            .clone()
698            .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
699            .combine(&background_style);
700        let transparent = base_style.bgcolor().is_none();
701        let mut text = self.highlighted_text(&code, &highlighted, self.line_range);
702        text.set_base_style(base_style.clone());
703        text.set_justify(if transparent {
704            Justify::Default
705        } else {
706            Justify::Left
707        });
708        text.set_tab_size(Some(self.tab_size));
709        text.set_no_wrap(Some(!self.word_wrap));
710        let guide_style = || {
711            base_style
712                .combine(&self.token_style(console, "Comment"))
713                .combine(&Style::parse("dim").unwrap_or_default())
714                .combine(&background_style)
715        };
716        let render_text = |text: &Text, width: usize, justify: Justify| {
717            let overflow = text
718                .get_overflow()
719                .or(options.overflow)
720                .unwrap_or(Overflow::Fold);
721            let no_wrap = text.get_no_wrap().or(options.no_wrap).unwrap_or(false);
722            let mut lines = text.render_lines_wrapped_tabs(
723                console.theme(),
724                console.base_style(),
725                Some(width),
726                justify,
727                overflow,
728                no_wrap,
729                self.tab_size.max(1),
730            );
731            if lines.is_empty() {
732                lines.push(Vec::new());
733            }
734            lines
735        };
736
737        if !self.line_numbers && !self.word_wrap && self.line_range.is_none() {
738            if !ends_on_nl && text.plain().ends_with('\n') {
739                text.right_crop(1);
740            }
741            if self.indent_guides && !options.ascii_only() {
742                text = text.with_indent_guides(Some(self.tab_size), "│", guide_style());
743                text.set_overflow(Some(Overflow::Crop));
744            }
745            if code_width == 0 {
746                return Vec::new();
747            }
748            if transparent {
749                // `console.render(text, options.update(width=code_width))`.
750                return render_text(&text, code_width, text.get_justify());
751            }
752            // `render_lines(text, width=code_width, justify="left",
753            // style=background_style, pad=True)`.
754            return render_text(&text, code_width, Justify::Left)
755                .into_iter()
756                .map(|line| {
757                    let line = if background_style.is_null() {
758                        line
759                    } else {
760                        Segment::apply_style(&line, &background_style)
761                    };
762                    Segment::adjust_line_length(&line, code_width, Some(background_style.clone()))
763                })
764                .collect();
765        }
766
767        let (start_line, end_line) = self.line_range.unwrap_or((None, None));
768        // `line_offset = max(0, start_line - 1)` when `start_line` is truthy.
769        let line_offset = start_line.filter(|&start| start != 0).map_or(0, |start| {
770            usize::try_from(start.saturating_sub(1)).unwrap_or(0)
771        });
772        let mut lines = text.split("\n", false, ends_on_nl);
773        if self.line_range.is_some() {
774            if line_offset > lines.len() {
775                return Vec::new();
776            }
777            // `lines[line_offset:end_line]`, with Python's slice bounds.
778            let len = lines.len() as i64;
779            let end = match end_line {
780                None => len,
781                Some(end) if end < 0 => (len + end).max(0),
782                Some(end) => end.min(len),
783            } as usize;
784            lines = if end > line_offset {
785                lines[line_offset..end].to_vec()
786            } else {
787                Vec::new()
788            };
789        }
790        if self.indent_guides && !options.ascii_only() {
791            let style = guide_style().combine(&Style::parse("not italic").unwrap_or_default());
792            lines = Text::new("\n")
793                .join(&lines)
794                .with_indent_guides(Some(self.tab_size), "│", style)
795                .split("\n", false, true);
796        }
797
798        let (background, number_style, highlight_number_style) =
799            self.number_styles(console, &base_style);
800        let line_pointer = if options.legacy_windows { "> " } else { "❱ " };
801        let mut out: Vec<Vec<Segment>> = Vec::new();
802        for (index, line) in lines.iter().enumerate() {
803            // Widened, as Python ints are unbounded (`start_line` may be
804            // `i64::MAX`).
805            let line_no = i128::from(self.start_line) + (line_offset + index) as i128;
806            let wrapped_lines: Vec<Vec<Segment>> = if self.word_wrap {
807                if code_width == 0 {
808                    Vec::new()
809                } else {
810                    render_text(line, code_width, line.get_justify())
811                        .into_iter()
812                        .map(|wrapped| {
813                            let wrapped = if background.is_null() {
814                                wrapped
815                            } else {
816                                Segment::apply_style(&wrapped, &background)
817                            };
818                            if transparent {
819                                wrapped
820                            } else {
821                                Segment::adjust_line_length(
822                                    &wrapped,
823                                    code_width,
824                                    Some(background.clone()),
825                                )
826                            }
827                        })
828                        .collect()
829                }
830            } else {
831                let segments = line.render(console.theme(), console.base_style());
832                if options.no_wrap == Some(true) {
833                    vec![segments]
834                } else if transparent {
835                    vec![crop_line(&segments, code_width)]
836                } else {
837                    vec![Segment::adjust_line_length(
838                        &segments,
839                        code_width,
840                        Some(background.clone()),
841                    )]
842                }
843            };
844            if self.line_numbers {
845                let left_pad = Segment::new(
846                    " ".repeat(numbers_column_width + 1),
847                    Some(background.clone()),
848                );
849                for (first, wrapped) in wrapped_lines.into_iter().enumerate() {
850                    let mut row = Vec::new();
851                    if first == 0 {
852                        let line_column = format!(
853                            "{:>width$} ",
854                            line_no,
855                            width = numbers_column_width.saturating_sub(2)
856                        );
857                        if i64::try_from(line_no)
858                            .is_ok_and(|line_no| self.highlight_lines.contains(&line_no))
859                        {
860                            row.push(Segment::new(
861                                line_pointer,
862                                Some(Style::parse("red").unwrap_or_default()),
863                            ));
864                            row.push(Segment::new(
865                                line_column,
866                                Some(highlight_number_style.clone()),
867                            ));
868                        } else {
869                            row.push(Segment::new("  ", Some(highlight_number_style.clone())));
870                            row.push(Segment::new(line_column, Some(number_style.clone())));
871                        }
872                    } else {
873                        row.push(left_pad.clone());
874                    }
875                    row.extend(wrapped);
876                    out.push(row);
877                }
878            } else {
879                out.extend(wrapped_lines);
880            }
881        }
882        out
883    }
884}
885
886impl Syntax {}
887
888/// `Segment.adjust_line_length(…, pad=False)`: crop a line to `width`, never
889/// pad it.
890fn crop_line(line: &[Segment], width: usize) -> Vec<Segment> {
891    let length: usize = line.iter().map(Segment::cell_length).sum();
892    if length > width {
893        Segment::adjust_line_length(line, width, None)
894    } else {
895        line.to_vec()
896    }
897}
898
899/// Lines already rendered, as upstream's `Segments(…)` under `Padding`.
900struct RenderedLines(Vec<Vec<Segment>>);
901
902impl Renderable for RenderedLines {
903    fn rich_render(&self, _console: &Console, _options: &ConsoleOptions) -> Vec<Segment> {
904        join_lines(self.0.clone())
905    }
906
907    /// No `__rich_measure__`: `Measurement(0, max_width)`.
908    fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
909        Measurement::new(0, options.max_width)
910    }
911}
912
913/// Lines joined by newlines. An empty last line is marked with an empty
914/// segment, so a container splitting the stream keeps it (see
915/// [`Segment::split_lines`]).
916fn join_lines(lines: Vec<Vec<Segment>>) -> Vec<Segment> {
917    let mut segments = Vec::new();
918    let last = lines.len().saturating_sub(1);
919    for (index, line) in lines.into_iter().enumerate() {
920        // Even when it is the only line: `Syntax("")` prints a blank line.
921        if index == last && line.is_empty() {
922            segments.push(Segment::new("", None));
923        }
924        segments.extend(line);
925        if index != last {
926            segments.push(Segment::line());
927        }
928    }
929    segments
930}
931
932impl Renderable for Syntax {
933    /// Port of `Syntax.__rich_measure__`. Like upstream it measures the raw
934    /// source, where a tab counts as zero cells.
935    fn measure(&self, _console: &Console, _options: &ConsoleOptions) -> Measurement {
936        let (_, right, _, left) = self.padding;
937        let padding = left + right;
938        let numbers = self.numbers_column_width();
939        if let Some(code_width) = self.code_width {
940            return Measurement::new(numbers, code_width + numbers + padding + 1);
941        }
942        let widest = python_splitlines(&self.code)
943            .into_iter()
944            .map(cell_len)
945            .max()
946            .unwrap_or(0);
947        let mut width = numbers + padding + widest;
948        if self.line_numbers {
949            width += 1;
950        }
951        Measurement::new(numbers, width)
952    }
953
954    /// Port of `Syntax.__rich_console__`: the lines, inside `Padding` in the
955    /// code's base style when there is any padding.
956    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
957        // The theme's background alone: highlighting no code finds it cheaply.
958        let theme_background = self.highlighted("", Some(console)).background;
959        let lines = self.syntax_lines(console, options);
960        let (top, right, bottom, left) = self.padding;
961        if top == 0 && right == 0 && bottom == 0 && left == 0 {
962            return join_lines(lines);
963        }
964        let base_style = theme_background
965            .map_or_else(Style::new, |bg| Style::new().with_bgcolor(bg))
966            .combine(&self.background_style());
967        crate::padding::Padding::new(Box::new(RenderedLines(lines)), self.padding)
968            .style(base_style)
969            .rich_render(console, options)
970    }
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976    use crate::color::ColorSystem;
977    use std::sync::Arc;
978
979    fn render(code: &str, lang: &str, width: usize) -> String {
980        Console::builder()
981            .force_terminal(true)
982            .color_system(Some(ColorSystem::Truecolor))
983            .width(width)
984            .no_color(false)
985            .build()
986            .render_to_string(&Syntax::new(code, lang))
987    }
988
989    #[test]
990    fn measured_syntax_still_prints_at_full_width() {
991        // Upstream renders a printed Syntax at the console width (its background
992        // pads every row); only str/Text shrink to their measurement.
993        let console = Console::builder().width(30).color_system(None).build();
994        let syntax = Syntax::new("x = 1", "python");
995        assert_eq!(syntax.measure(&console, &console.options()).maximum, 5);
996        let out = console.render_to_string(&syntax);
997        assert!(!out.contains('\x1b'), "{out:?}");
998        assert_eq!(cell_len(out.lines().next().unwrap()), 30, "{out:?}");
999    }
1000
1001    #[test]
1002    fn a_lone_carriage_return_breaks_the_line_like_pygments() {
1003        // Expected output captured from rich 15.0.0 (color_system=None) with
1004        // `Console.print`; `render_to_string` leaves out its final newline.
1005        let render = |renderable: &dyn Renderable, width| {
1006            let console = Console::builder().width(width).color_system(None).build();
1007            console.render_to_string(renderable) + "\n"
1008        };
1009        assert_eq!(
1010            render(&Syntax::new("ab\rcd\r", "python"), 10),
1011            "ab        \ncd        \n"
1012        );
1013        assert_eq!(
1014            render(&Syntax::new("ab\r\ncd", "text"), 10),
1015            "ab        \ncd        \n"
1016        );
1017        assert_eq!(
1018            render(
1019                &crate::panel::Panel::fit(Box::new(Syntax::new("ab\rcd", "python"))),
1020                20
1021            ),
1022            "╭────╮\n│ ab │\n│ cd │\n╰────╯\n"
1023        );
1024        assert_eq!(
1025            render(&crate::markdown::Markdown::new("```python\nab\rcd\n```"), 20),
1026            "                    \n ab                 \n cd                 \n                    \n"
1027        );
1028    }
1029
1030    #[test]
1031    fn splitlines_matches_python() {
1032        assert_eq!(
1033            python_splitlines("a\r\nb\rc\u{2028}d\n"),
1034            ["a", "b", "c", "d"]
1035        );
1036        assert_eq!(python_splitlines("\n\n"), ["", ""]);
1037        assert!(python_splitlines("").is_empty());
1038    }
1039
1040    #[test]
1041    fn highlights_rust_keyword() {
1042        // Functional (not byte-parity): assert the code text survives and the
1043        // output is colored (contains SGR sequences).
1044        let out = render("fn main() {}", "rust", 20);
1045        assert!(out.contains("fn"));
1046        assert!(out.contains("main"));
1047        assert!(out.contains('\x1b'), "expected ANSI color codes");
1048    }
1049
1050    #[test]
1051    fn multiple_lines_are_separated() {
1052        let out = render("let x = 1;\nlet y = 2;", "rust", 20);
1053        assert_eq!(out.matches('\n').count(), 1);
1054        assert!(out.contains("let"));
1055    }
1056
1057    #[test]
1058    fn unknown_language_renders_plain() {
1059        // No panic, code preserved, still padded/colored to a block.
1060        let out = render("just some text", "nonsense-lang", 20);
1061        assert!(out.contains("just some text"));
1062    }
1063
1064    #[test]
1065    fn word_wrap_is_off_by_default_matching_upstream() {
1066        // Measured against upstream: Syntax(word_wrap=False) at width 80 keeps
1067        // 80 of 300 characters. The default must not diverge from that.
1068        let code = "A".repeat(300);
1069        let out = render(&code, "python", 80);
1070        assert_eq!(out.matches('A').count(), 80, "default should crop");
1071    }
1072
1073    #[test]
1074    fn word_wrap_keeps_every_character() {
1075        let code = "A".repeat(300);
1076        let console = Console::builder().width(80).color_system(None).build();
1077        let out = console.render_to_string(&Syntax::new(code.as_str(), "python").word_wrap(true));
1078        assert_eq!(
1079            out.matches('A').count(),
1080            300,
1081            "wrapping must not lose characters:
1082{out}"
1083        );
1084    }
1085
1086    /// Syntax emits segments directly rather than going through `Text`, so the
1087    /// shared `strip_control_codes` never ran and `rich -x` leaked backspaces
1088    /// and BELs that `rich -m` did not.
1089    #[test]
1090    fn control_codes_are_stripped_from_highlighted_code() {
1091        let out = render("let x = 1;\u{7}\u{8}\u{b}\u{c}", "rust", 40);
1092        for code in ['\u{7}', '\u{8}', '\u{b}', '\u{c}'] {
1093            assert!(
1094                !out.contains(code),
1095                "control code {code:?} reached the output"
1096            );
1097        }
1098        assert!(out.contains("let"), "content lost with the control codes");
1099    }
1100
1101    /// A blank source line has no segments, and folding an empty row yielded
1102    /// zero rows rather than one empty one — so wrapping silently deleted every
1103    /// blank line in the file, and the loss was baked into exports.
1104    #[test]
1105    fn word_wrap_keeps_blank_lines() {
1106        let console = Console::builder().width(20).color_system(None).build();
1107        let out =
1108            console.render_to_string(&Syntax::new("a = 1\n\nb = 2\n", "python").word_wrap(true));
1109        let rows: Vec<&str> = out.trim_end_matches('\n').split('\n').collect();
1110        // Four rows, not three: upstream splits with Python's `str.split("\n")`,
1111        // so the trailing newline contributes a final empty row —
1112        // `"a = 1\n\nb = 2\n".split("\n") == ["a = 1", "", "b = 2", ""]`, and
1113        // rich 15.0.0 prints four padded rows for it. This assertion previously
1114        // said three, pinning our own missing-row bug as the expectation.
1115        assert_eq!(rows.len(), 4, "blank line lost: {rows:?}");
1116        assert!(
1117            rows[1].trim().is_empty(),
1118            "middle row should be blank: {rows:?}"
1119        );
1120        assert!(
1121            rows[3].trim().is_empty(),
1122            "trailing row should be blank: {rows:?}"
1123        );
1124    }
1125
1126    /// `Syntax._process_code` runs `code.expandtabs(self.tab_size)` before
1127    /// anything is highlighted. We emitted the raw U+0009 and measured it as one
1128    /// cell, so a tabbed line reached the terminal 31-32 cells wide against a
1129    /// requested 30 and tore the background block.
1130    ///
1131    /// Both expectations captured verbatim from real rich 15.0.0.
1132    #[test]
1133    fn tabs_are_expanded_before_highlighting() {
1134        let console = Console::builder().width(30).color_system(None).build();
1135        let out = console.render_to_string(&Syntax::new(
1136            "def f():\n\tif x:\n\t\treturn 1\n\treturn 0",
1137            "python",
1138        ));
1139        assert_eq!(
1140            out.split('\n').collect::<Vec<_>>(),
1141            [
1142                "def f():                      ",
1143                "    if x:                     ",
1144                "        return 1              ",
1145                "    return 0                  ",
1146            ]
1147        );
1148        assert!(!out.contains('\t'), "a raw tab survived: {out:?}");
1149    }
1150
1151    /// A tab advances to the next multiple of the tab size, so it is *not* a
1152    /// fixed run of spaces — the width of the text before it decides.
1153    #[test]
1154    fn a_tab_advances_to_the_next_tab_stop() {
1155        let console = Console::builder().width(20).color_system(None).build();
1156        let out = console.render_to_string(&Syntax::new(
1157            "a\tb\tc\nab\tcd\tef\nabcd\tefgh\tijkl",
1158            "python",
1159        ));
1160        assert_eq!(
1161            out.split('\n').collect::<Vec<_>>(),
1162            [
1163                "a   b   c           ",
1164                "ab  cd  ef          ",
1165                "abcd    efgh    ijkl",
1166            ]
1167        );
1168    }
1169
1170    /// Every row must occupy exactly the requested width *on screen*.
1171    ///
1172    /// Measuring against [`cell_len`] cannot catch this: it counted a raw tab as
1173    /// one cell and the padding was computed the same way, so the row looked
1174    /// exactly `width` wide to us while the terminal advanced the tab to the
1175    /// next 8-cell stop and the block overran by seven.
1176    #[test]
1177    fn a_tabbed_line_measures_the_requested_width() {
1178        /// Width as the *terminal* renders it: a tab jumps to the next 8-cell
1179        /// stop, which is the only measure that reveals the defect.
1180        fn screen_width(row: &str) -> usize {
1181            let mut column = 0usize;
1182            for ch in row.chars() {
1183                column += if ch == '\t' {
1184                    8 - (column % 8)
1185                } else {
1186                    cell_len(ch.encode_utf8(&mut [0u8; 4]))
1187                };
1188            }
1189            column
1190        }
1191
1192        for width in [10usize, 20, 30, 40] {
1193            let console = Console::builder().width(width).color_system(None).build();
1194            let out = console.render_to_string(&Syntax::new("\tvalue = compute(a, b)", "python"));
1195            for row in out.split('\n') {
1196                assert_eq!(screen_width(row), width, "row {row:?} at width {width}");
1197            }
1198        }
1199    }
1200
1201    /// `str.expandtabs` counts *characters*, not cells, and resets its column at
1202    /// `\n` and `\r`.
1203    #[test]
1204    fn expand_tabs_matches_pythons_str_expandtabs() {
1205        // Left column verified against CPython's `str.expandtabs(4)`.
1206        for (input, expected) in [
1207            ("a\tb", "a   b"),
1208            ("ab\tb", "ab  b"),
1209            ("abc\tb", "abc b"),
1210            ("abcd\tb", "abcd    b"),
1211            ("\t", "    "),
1212            ("a\nbb\tc", "a\nbb  c"),
1213            ("a\rbb\tc", "a\rbb  c"),
1214            // A wide char counts as one column, exactly as in Python.
1215            ("\u{4e2d}\tx", "\u{4e2d}   x"),
1216        ] {
1217            assert_eq!(expand_tabs(input, 4), expected, "input {input:?}");
1218        }
1219        // `tabsize <= 0` deletes the tab (CPython's own branch).
1220        assert_eq!(expand_tabs("a\tb", 0), "ab");
1221    }
1222
1223    /// Upstream's word_wrap breaks at word boundaries; we folded wherever the
1224    /// row filled up, splitting identifiers mid-word.
1225    #[test]
1226    fn word_wrap_breaks_between_words() {
1227        let console = Console::builder().width(30).color_system(None).build();
1228        // This exact line is the one character-folding splits as `z` / `eta`,
1229        // which is what makes the assertion discriminating.
1230        let code = "result = compute_total(alpha, beta, gamma, delta, epsilon, zeta, eta, theta)\n";
1231        let out = console.render_to_string(&Syntax::new(code, "python").word_wrap(true));
1232        // Every identifier must survive on a single row. Folding mid-word split
1233        // `epsilon` across the break as `e` / `psilon`.
1234        for word in [
1235            "compute_total",
1236            "alpha",
1237            "gamma",
1238            "epsilon",
1239            "zeta",
1240            "theta",
1241        ] {
1242            assert!(
1243                out.split('\n').any(|row| row.contains(word)),
1244                "{word:?} was split across rows: {out:?}"
1245            );
1246        }
1247    }
1248
1249    // ---- CodeHighlighter (#522, #523) ---------------------------------------
1250
1251    use crate::protocol::HighlightSpan;
1252
1253    /// A highlighter that returns exactly the lines it was built with.
1254    struct Fixed(Vec<HighlightedLine>);
1255
1256    impl CodeHighlighter for Fixed {
1257        fn highlight(
1258            &self,
1259            _code: &str,
1260            _language: Option<&str>,
1261            _theme: &str,
1262        ) -> Result<HighlightedCode, HighlightError> {
1263            Ok(HighlightedCode {
1264                lines: self.0.clone(),
1265                ..Default::default()
1266            })
1267        }
1268        fn default_theme(&self) -> &str {
1269            "fixed"
1270        }
1271        fn themes(&self) -> Vec<String> {
1272            vec!["fixed".into()]
1273        }
1274        fn languages(&self) -> Vec<String> {
1275            Vec::new()
1276        }
1277    }
1278
1279    /// Styles every line by theme: `bold` (its default) or `underline`.
1280    struct ByTheme;
1281
1282    impl CodeHighlighter for ByTheme {
1283        fn highlight(
1284            &self,
1285            code: &str,
1286            _language: Option<&str>,
1287            theme: &str,
1288        ) -> Result<HighlightedCode, HighlightError> {
1289            let style = match theme {
1290                "bold" | "underline" => Style::parse(theme).unwrap(),
1291                other => return Err(HighlightError::UnknownTheme(other.into())),
1292            };
1293            let lines = code
1294                .split('\n')
1295                .map(|line| HighlightedLine {
1296                    spans: (!line.is_empty())
1297                        .then(|| HighlightSpan {
1298                            range: 0..line.len(),
1299                            style: style.clone(),
1300                        })
1301                        .into_iter()
1302                        .collect(),
1303                    newline_style: None,
1304                })
1305                .collect();
1306            Ok(HighlightedCode {
1307                lines,
1308                ..Default::default()
1309            })
1310        }
1311        fn default_theme(&self) -> &str {
1312            "bold"
1313        }
1314        fn themes(&self) -> Vec<String> {
1315            vec!["bold".into(), "underline".into()]
1316        }
1317        fn languages(&self) -> Vec<String> {
1318            Vec::new()
1319        }
1320    }
1321
1322    /// The console's default highlighter and theme apply to a `Syntax` (and
1323    /// to Markdown code) without its own; the `Syntax`'s own highlighter or
1324    /// theme wins; with no default, output is unchanged.
1325    #[test]
1326    fn a_console_default_highlighter_applies_where_none_is_given() {
1327        use crate::markdown::Markdown;
1328        use crate::protocol::{CodeHighlighting, ConsoleCodeHighlighting};
1329        let plain = || {
1330            Console::builder()
1331                .width(20)
1332                .force_terminal(true)
1333                .color_system(Some(crate::color::ColorSystem::Truecolor))
1334                .build()
1335        };
1336        let with = |theme: Option<&str>| {
1337            let mut console = plain();
1338            console.set_code_highlighting(Some(CodeHighlighting {
1339                highlighter: Arc::new(ByTheme),
1340                theme: theme.map(str::to_string),
1341            }));
1342            console
1343        };
1344        let syntax = || Syntax::new("x = 1", "python");
1345
1346        assert!(with(None)
1347            .render_to_string(&syntax())
1348            .contains("\x1b[1mx = 1"));
1349        assert!(with(Some("underline"))
1350            .render_to_string(&syntax())
1351            .contains("\x1b[4mx = 1"));
1352        // The Syntax's own theme beats the console's.
1353        assert!(with(Some("underline"))
1354            .render_to_string(&syntax().theme("bold"))
1355            .contains("\x1b[1mx = 1"));
1356        // The Syntax's own highlighter beats the console's, and the console's
1357        // theme (a name of another engine) does not follow it.
1358        let own = with(Some("underline"))
1359            .render_to_string(&syntax().highlighter(SyntectHighlighter::shared()));
1360        assert_eq!(own, plain().render_to_string(&syntax()));
1361        // Markdown code blocks follow the console.
1362        let markdown = Markdown::new("```python\nx = 1\n```");
1363        assert!(with(None)
1364            .render_to_string(&markdown)
1365            .contains("\x1b[1mx = 1"));
1366        // `highlight_for` sees the console; `highlight` does not.
1367        assert!(!syntax().highlight_for(&with(None)).spans().is_empty());
1368        assert_eq!(
1369            syntax().highlight_for(&plain()).spans(),
1370            syntax().highlight().spans()
1371        );
1372        // With no default, nothing changes.
1373        let mut cleared = with(None);
1374        cleared.set_code_highlighting(None);
1375        assert_eq!(
1376            cleared.render_to_string(&syntax()),
1377            plain().render_to_string(&syntax())
1378        );
1379    }
1380
1381    /// Always fails with an engine error.
1382    struct Broken;
1383
1384    impl CodeHighlighter for Broken {
1385        fn highlight(
1386            &self,
1387            _code: &str,
1388            _language: Option<&str>,
1389            _theme: &str,
1390        ) -> Result<HighlightedCode, HighlightError> {
1391            Err(HighlightError::Engine("boom".into()))
1392        }
1393        fn default_theme(&self) -> &str {
1394            "x"
1395        }
1396        fn themes(&self) -> Vec<String> {
1397            vec!["x".into()]
1398        }
1399        fn languages(&self) -> Vec<String> {
1400            Vec::new()
1401        }
1402    }
1403
1404    fn span(range: std::ops::Range<usize>, style: &str) -> HighlightSpan {
1405        HighlightSpan {
1406            range,
1407            style: Style::parse(style).unwrap(),
1408        }
1409    }
1410
1411    fn line(spans: Vec<HighlightSpan>) -> HighlightedLine {
1412        HighlightedLine {
1413            spans,
1414            newline_style: None,
1415        }
1416    }
1417
1418    fn render_with(code: &str, highlighter: Arc<dyn CodeHighlighter>, width: usize) -> String {
1419        Console::builder()
1420            .force_terminal(true)
1421            .color_system(Some(ColorSystem::Standard))
1422            .width(width)
1423            .build()
1424            .render_to_string(&Syntax::new(code, "python").highlighter(highlighter))
1425    }
1426
1427    #[test]
1428    fn a_custom_highlighter_drives_the_rendered_styles() {
1429        let fixed = Fixed(vec![line(vec![span(0..1, "red"), span(4..5, "bold")])]);
1430        let out = render_with("x = 1", Arc::new(fixed), 7);
1431        // `x` red, ` = ` in the default (unstyled) gap, `1` bold. A theme
1432        // with no background is transparent: upstream renders it unpadded.
1433        assert_eq!(out, "\u{1b}[31mx\u{1b}[0m = \u{1b}[1m1\u{1b}[0m");
1434    }
1435
1436    #[test]
1437    fn invalid_spans_are_dropped_not_rendered() {
1438        let fixed = Fixed(vec![line(vec![
1439            span(0..3, "red"),
1440            span(1..2, "green"), // overlaps the first
1441            span(3..99, "blue"), // past the end of the line
1442            span(std::ops::Range { start: 2, end: 1 }, "yellow"), // reversed
1443        ])]);
1444        let out = render_with("abcdef", Arc::new(fixed), 6);
1445        assert_eq!(out, "\u{1b}[31mabc\u{1b}[0mdef");
1446        // A span ending inside a multi-byte character is dropped too.
1447        let fixed = Fixed(vec![line(vec![span(0..1, "red")])]);
1448        let out = render_with("é", Arc::new(fixed), 1);
1449        assert_eq!(out, "é");
1450    }
1451
1452    #[test]
1453    fn missing_and_extra_lines_are_reconciled_with_the_source() {
1454        // One line returned for three source lines: the rest render unstyled.
1455        let fixed = Fixed(vec![line(vec![span(0..1, "red")])]);
1456        let out = render_with("a\nb\nc", Arc::new(fixed), 1);
1457        assert_eq!(out, "\u{1b}[31ma\u{1b}[0m\nb\nc");
1458        // Extra lines beyond the source are ignored.
1459        let fixed = Fixed(vec![
1460            line(vec![]),
1461            line(vec![]),
1462            line(vec![span(0..1, "red")]),
1463        ]);
1464        assert_eq!(render_with("a", Arc::new(fixed), 1), "a");
1465    }
1466
1467    #[test]
1468    fn highlighter_styles_cannot_add_links_or_text() {
1469        let style = Style::parse("red")
1470            .unwrap()
1471            .with_link("https://evil.example/\u{1b}]0;x\u{7}");
1472        let fixed = Fixed(vec![line(vec![HighlightSpan { range: 0..1, style }])]);
1473        let out = render_with("a\u{8}", Arc::new(fixed), 1);
1474        assert!(!out.contains("\u{1b}]8"), "hyperlink escaped: {out:?}");
1475        assert!(!out.contains("evil"), "{out:?}");
1476        assert!(!out.contains('\u{8}'), "control code survived: {out:?}");
1477    }
1478
1479    #[test]
1480    fn an_engine_failure_renders_the_source_unstyled() {
1481        let out = render_with("print(1)", Arc::new(Broken), 8);
1482        assert_eq!(out, "print(1)");
1483    }
1484
1485    #[test]
1486    fn an_unknown_theme_falls_back_to_the_default_theme() {
1487        let console = Console::builder()
1488            .force_terminal(true)
1489            .color_system(Some(ColorSystem::Truecolor))
1490            .width(20)
1491            .build();
1492        let default = console.render_to_string(&Syntax::new("x = 1", "python"));
1493        let unknown =
1494            console.render_to_string(&Syntax::new("x = 1", "python").theme("no-such-theme"));
1495        assert_eq!(default, unknown);
1496    }
1497
1498    #[test]
1499    fn highlight_text_uses_the_same_highlighter() {
1500        let fixed = Fixed(vec![line(vec![span(0..1, "red")]), line(vec![])]);
1501        let text = Syntax::new("ab\ncd", "python")
1502            .highlighter(Arc::new(fixed))
1503            .highlight();
1504        assert_eq!(text.plain(), "ab\ncd");
1505        assert_eq!(text.spans()[0].start, 0);
1506        assert_eq!(text.spans()[0].end, 1);
1507    }
1508
1509    /// Upstream's `ANSI_DARK`/`ANSI_LIGHT` colours, by Pygments token class,
1510    /// applied through the TextMate scopes syntect reports.
1511    #[test]
1512    fn ansi_themes_use_upstream_palette_colours() {
1513        let code = "# note\n@wraps\ndef greet(name):\n    return f\"hi {name}\" and 42\n";
1514        let render = |theme: &str, system: ColorSystem| {
1515            Console::builder()
1516                .force_terminal(true)
1517                .color_system(Some(system))
1518                .width(40)
1519                .build()
1520                .render_to_string(&Syntax::new(code, "python").theme(theme))
1521        };
1522        let dark = render("ansi_dark", ColorSystem::Truecolor);
1523        // No RGB colour and no background anywhere: only the 16 palette colours.
1524        assert!(!dark.contains("38;2;") && !dark.contains("48;"), "{dark:?}");
1525        assert!(dark.contains("\u{1b}[2m#"), "comment is dim: {dark:?}");
1526        assert!(
1527            dark.contains("\u{1b}[1;95m@"),
1528            "decorator bold bright_magenta: {dark:?}"
1529        );
1530        assert!(
1531            dark.contains("\u{1b}[33mf"),
1532            "string prefix is part of the string: {dark:?}"
1533        );
1534        assert!(
1535            dark.contains("\u{1b}[94mdef"),
1536            "keyword bright_blue: {dark:?}"
1537        );
1538        assert!(
1539            dark.contains("\u{1b}[92mgreet"),
1540            "function bright_green: {dark:?}"
1541        );
1542        assert!(
1543            dark.contains("\u{1b}[94m42"),
1544            "number bright_blue: {dark:?}"
1545        );
1546        assert!(
1547            dark.contains("\u{1b}[95mand"),
1548            "operator word bright_magenta: {dark:?}"
1549        );
1550        assert!(dark.contains("\u{1b}[33m"), "string yellow: {dark:?}");
1551        let light = render("ansi_light", ColorSystem::Truecolor);
1552        assert!(light.contains("\u{1b}[34mdef"), "keyword blue: {light:?}");
1553        assert!(
1554            light.contains("\u{1b}[32mgreet"),
1555            "function green: {light:?}"
1556        );
1557        // A 16-colour console gets exactly the same codes.
1558        assert_eq!(render("ansi_dark", ColorSystem::Standard), dark);
1559    }
1560}