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