Skip to main content

rich/
syntax_syntect.rs

1//! The default [`CodeHighlighter`]: `syntect`, with its bundled grammars and
2//! themes, plus upstream's two ANSI themes.
3//!
4//! Upstream highlights with Pygments, so the token colours are functional, not
5//! byte-identical (DIVERGENCES #18). This module is the whole of the port's
6//! dependency on `syntect`; everything else talks to the [`CodeHighlighter`]
7//! trait.
8
9use std::path::Path;
10use std::sync::{Arc, OnceLock};
11
12use syntect::highlighting::{
13    Color as SynColor, FontStyle, ScopeSelectors, Style as SynStyle, StyleModifier, Theme,
14    ThemeItem, ThemeSet, ThemeSettings,
15};
16use syntect::parsing::{SyntaxReference, SyntaxSet};
17use syntect::util::LinesWithEndings;
18
19#[cfg(not(feature = "syntax-cache"))]
20use syntect::easy::HighlightLines;
21
22use crate::color::Color;
23use crate::protocol::{
24    CodeHighlighter, HighlightError, HighlightSpan, HighlightedCode, HighlightedLine,
25};
26use crate::style::Style;
27
28/// The default theme (a dark base16 palette shipped with `syntect`).
29pub(crate) const DEFAULT_THEME: &str = "base16-ocean.dark";
30
31/// Highlights with `syntect`: its default grammars, its default themes, and
32/// upstream's `ansi_dark` and `ansi_light`, which use the terminal's own
33/// palette. This is the highlighter `Syntax` and Markdown use unless given
34/// another one.
35#[derive(Clone, Copy, Debug, Default)]
36pub struct SyntectHighlighter;
37
38impl SyntectHighlighter {
39    pub fn new() -> Self {
40        SyntectHighlighter
41    }
42
43    /// The shared instance every `Syntax` uses by default.
44    pub fn shared() -> Arc<dyn CodeHighlighter> {
45        static SHARED: OnceLock<Arc<dyn CodeHighlighter>> = OnceLock::new();
46        SHARED.get_or_init(|| Arc::new(SyntectHighlighter)).clone()
47    }
48}
49
50pub(crate) fn syntax_set() -> &'static SyntaxSet {
51    static SET: OnceLock<SyntaxSet> = OnceLock::new();
52    SET.get_or_init(SyntaxSet::load_defaults_newlines)
53}
54
55pub(crate) fn theme_set() -> &'static ThemeSet {
56    static SET: OnceLock<ThemeSet> = OnceLock::new();
57    SET.get_or_init(ThemeSet::load_defaults)
58}
59
60/// A language by token (name) or extension; plain text otherwise.
61fn resolve(language: Option<&str>) -> &'static SyntaxReference {
62    let syntaxes = syntax_set();
63    language
64        .and_then(|lang| {
65            syntaxes
66                .find_syntax_by_token(lang)
67                .or_else(|| syntaxes.find_syntax_by_extension(lang))
68        })
69        .unwrap_or_else(|| syntaxes.find_syntax_plain_text())
70}
71
72/// Convert a `syntect` RGBA color to a truecolor [`Color`] (alpha dropped).
73fn to_color(c: SynColor) -> Color {
74    Color::from_rgb(c.r, c.g, c.b)
75}
76
77/// Convert a `syntect` style (fg/bg + font flags) to a rich [`Style`].
78fn to_style(s: SynStyle) -> Style {
79    let mut style = Style::new()
80        .with_color(to_color(s.foreground))
81        .with_bgcolor(to_color(s.background));
82    if s.font_style.contains(FontStyle::BOLD) {
83        style = style.combine(&Style::parse("bold").expect("valid style"));
84    }
85    if s.font_style.contains(FontStyle::ITALIC) {
86        style = style.combine(&Style::parse("italic").expect("valid style"));
87    }
88    if s.font_style.contains(FontStyle::UNDERLINE) {
89        style = style.combine(&Style::parse("underline").expect("valid style"));
90    }
91    style
92}
93
94// ---- ANSI themes ------------------------------------------------------------
95//
96// Upstream's `ansi_dark`/`ansi_light` (`rich/syntax.py`, `ANSI_DARK`/`ANSI_LIGHT`)
97// style Pygments token types with the terminal's 16 colours. syntect matches
98// TextMate scopes instead, so each token class below lists the scopes that
99// correspond to it (DIVERGENCES #18). syntect does the scope matching against
100// an internal theme whose "colours" are only class markers; the marker is then
101// mapped to upstream's style for that class. Pygments' `Whitespace` and
102// `Generic.Prompt` have no TextMate equivalent and are not mapped.
103
104/// A Pygments token type, in upstream's table order.
105#[derive(Clone, Copy)]
106enum TokenType {
107    Token,
108    Comment,
109    CommentPreproc,
110    Keyword,
111    KeywordType,
112    Operator,
113    OperatorWord,
114    NameBuiltin,
115    NameFunction,
116    NameNamespace,
117    NameClass,
118    NameException,
119    NameDecorator,
120    NameVariable,
121    NameConstant,
122    NameAttribute,
123    NameTag,
124    String,
125    Number,
126    GenericDeleted,
127    GenericInserted,
128    GenericHeading,
129    GenericSubheading,
130    Error,
131}
132
133/// TextMate scope selectors for each class. syntect picks the most specific
134/// match, so `keyword.operator` (plain operators) beats `keyword`.
135const SCOPES: &[(TokenType, &str)] = &[
136    (
137        TokenType::Comment,
138        "comment, punctuation.definition.comment",
139    ),
140    (
141        TokenType::CommentPreproc,
142        "meta.preprocessor, keyword.control.import.include, meta.annotation",
143    ),
144    (
145        TokenType::Keyword,
146        "keyword, storage.type, storage.modifier, constant.language",
147    ),
148    (
149        TokenType::KeywordType,
150        "support.type, storage.type.primitive, storage.type.numeric, storage.type.builtin",
151    ),
152    (TokenType::Operator, "keyword.operator"),
153    (
154        TokenType::OperatorWord,
155        "keyword.operator.logical, keyword.operator.word",
156    ),
157    (
158        TokenType::NameBuiltin,
159        "support.function.builtin, variable.language",
160    ),
161    (TokenType::NameFunction, "entity.name.function"),
162    (
163        TokenType::NameNamespace,
164        "entity.name.namespace, entity.name.module",
165    ),
166    (
167        TokenType::NameClass,
168        "entity.name.class, entity.name.struct, entity.name.enum, entity.name.union, \
169         entity.name.trait, entity.name.type",
170    ),
171    (TokenType::NameException, "support.type.exception"),
172    (
173        TokenType::NameDecorator,
174        "meta.decorator, meta.annotation.python, entity.name.function.decorator",
175    ),
176    (
177        TokenType::NameVariable,
178        "variable.other.readwrite.instance, variable.other.readwrite.class, \
179         variable.other.readwrite.global, variable.other.readwrite.shell, \
180         variable.other.normal.shell, variable.other.php",
181    ),
182    (
183        TokenType::NameConstant,
184        "variable.other.constant, constant.other, entity.name.constant, support.constant",
185    ),
186    (TokenType::NameAttribute, "entity.other.attribute-name"),
187    (TokenType::NameTag, "entity.name.tag"),
188    // `storage.type.string` is a string prefix (`f"…"`), Pygments' `String.Affix`.
189    (
190        TokenType::String,
191        "string, punctuation.definition.string, storage.type.string",
192    ),
193    (TokenType::Number, "constant.numeric"),
194    (TokenType::GenericDeleted, "markup.deleted"),
195    (TokenType::GenericInserted, "markup.inserted"),
196    (
197        TokenType::GenericHeading,
198        "markup.heading, meta.diff.header",
199    ),
200    (
201        TokenType::GenericSubheading,
202        "markup.heading.2, markup.heading.3, markup.heading.4, markup.heading.5, \
203         markup.heading.6, meta.diff.range",
204    ),
205    (TokenType::Error, "invalid"),
206];
207
208/// Upstream's `ANSI_DARK` and `ANSI_LIGHT` styles for a class, as markup.
209fn ansi_style_markup(class: TokenType, dark: bool) -> &'static str {
210    use TokenType::*;
211    match (class, dark) {
212        (Token | Operator, _) => "",
213        (Comment, _) => "dim",
214        (CommentPreproc, true) => "bright_cyan",
215        (CommentPreproc, false) => "cyan",
216        (Keyword, true) => "bright_blue",
217        (Keyword, false) => "blue",
218        (KeywordType, true) => "bright_cyan",
219        (KeywordType, false) => "cyan",
220        (OperatorWord, true) => "bright_magenta",
221        (OperatorWord, false) => "magenta",
222        (NameBuiltin, true) => "bright_cyan",
223        (NameBuiltin, false) => "cyan",
224        (NameFunction, true) => "bright_green",
225        (NameFunction, false) => "green",
226        (NameNamespace, true) => "bright_cyan underline",
227        (NameNamespace, false) => "cyan underline",
228        (NameClass, true) => "bright_green underline",
229        (NameClass, false) => "green underline",
230        (NameException, true) => "bright_cyan",
231        (NameException, false) => "cyan",
232        (NameDecorator, true) => "bright_magenta bold",
233        (NameDecorator, false) => "magenta bold",
234        (NameVariable, true) => "bright_red",
235        (NameVariable, false) => "red",
236        (NameConstant, true) => "bright_red",
237        (NameConstant, false) => "red",
238        (NameAttribute, true) => "bright_cyan",
239        (NameAttribute, false) => "cyan",
240        (NameTag, _) => "bright_blue",
241        (String, _) => "yellow",
242        (Number, true) => "bright_blue",
243        (Number, false) => "blue",
244        (GenericDeleted, _) => "bright_red",
245        (GenericInserted, true) => "bright_green",
246        (GenericInserted, false) => "green",
247        (GenericHeading, _) => "bold",
248        (GenericSubheading, true) => "bright_magenta bold",
249        (GenericSubheading, false) => "magenta bold",
250        (Error, _) => "red underline",
251    }
252}
253
254/// Classes in marker order: marker `n` is `CLASSES[n]`.
255const CLASSES: [TokenType; 24] = [
256    TokenType::Token,
257    TokenType::Comment,
258    TokenType::CommentPreproc,
259    TokenType::Keyword,
260    TokenType::KeywordType,
261    TokenType::Operator,
262    TokenType::OperatorWord,
263    TokenType::NameBuiltin,
264    TokenType::NameFunction,
265    TokenType::NameNamespace,
266    TokenType::NameClass,
267    TokenType::NameException,
268    TokenType::NameDecorator,
269    TokenType::NameVariable,
270    TokenType::NameConstant,
271    TokenType::NameAttribute,
272    TokenType::NameTag,
273    TokenType::String,
274    TokenType::Number,
275    TokenType::GenericDeleted,
276    TokenType::GenericInserted,
277    TokenType::GenericHeading,
278    TokenType::GenericSubheading,
279    TokenType::Error,
280];
281
282fn marker(class: TokenType) -> SynColor {
283    SynColor {
284        r: class as u8,
285        g: 0x5a,
286        b: 0xa5,
287        a: 0xff,
288    }
289}
290
291/// The internal marker theme both ANSI themes share.
292fn ansi_marker_theme() -> &'static Theme {
293    static THEME: OnceLock<Theme> = OnceLock::new();
294    THEME.get_or_init(|| Theme {
295        name: Some("rich-ansi-markers".into()),
296        settings: ThemeSettings {
297            foreground: Some(marker(TokenType::Token)),
298            background: Some(marker(TokenType::Token)),
299            ..ThemeSettings::default()
300        },
301        scopes: SCOPES
302            .iter()
303            .map(|&(class, selectors)| ThemeItem {
304                scope: selectors
305                    .parse::<ScopeSelectors>()
306                    .expect("valid scope selectors"),
307                style: StyleModifier {
308                    foreground: Some(marker(class)),
309                    background: None,
310                    font_style: None,
311                },
312            })
313            .collect(),
314        ..Theme::default()
315    })
316}
317
318/// Upstream's style for a marker colour.
319fn ansi_style(foreground: SynColor, dark: bool) -> Style {
320    static DARK: OnceLock<Vec<Style>> = OnceLock::new();
321    static LIGHT: OnceLock<Vec<Style>> = OnceLock::new();
322    let table = if dark { &DARK } else { &LIGHT };
323    let styles = table.get_or_init(|| {
324        CLASSES
325            .iter()
326            .map(|&class| {
327                let markup = ansi_style_markup(class, dark);
328                if markup.is_empty() {
329                    Style::new()
330                } else {
331                    Style::parse(markup).expect("valid ANSI theme style")
332                }
333            })
334            .collect()
335    });
336    styles
337        .get(foreground.r as usize)
338        .cloned()
339        .unwrap_or_default()
340}
341
342/// A theme resolved for one highlight call.
343enum Resolved {
344    Syntect(&'static Theme),
345    Ansi { dark: bool },
346}
347
348fn resolve_theme(name: &str) -> Result<Resolved, HighlightError> {
349    match name {
350        "ansi_dark" => Ok(Resolved::Ansi { dark: true }),
351        "ansi_light" => Ok(Resolved::Ansi { dark: false }),
352        _ => theme_set()
353            .themes
354            .get(name)
355            .map(Resolved::Syntect)
356            .ok_or_else(|| HighlightError::UnknownTheme(name.to_string())),
357    }
358}
359
360impl CodeHighlighter for SyntectHighlighter {
361    fn highlight(
362        &self,
363        code: &str,
364        language: Option<&str>,
365        theme: &str,
366    ) -> Result<HighlightedCode, HighlightError> {
367        let resolved = resolve_theme(theme)?;
368        let (syn_theme, background, default_style) = match &resolved {
369            Resolved::Syntect(theme) => {
370                let background = theme.settings.background.map(to_color);
371                let mut default_style = Style::new();
372                if let Some(bg) = &background {
373                    default_style = default_style.with_bgcolor(bg.clone());
374                }
375                (*theme, background, default_style)
376            }
377            Resolved::Ansi { .. } => (ansi_marker_theme(), None, Style::new()),
378        };
379        let convert = |style: SynStyle| match resolved {
380            Resolved::Syntect(_) => to_style(style),
381            Resolved::Ansi { dark } => ansi_style(style.foreground, dark),
382        };
383
384        let syntaxes = syntax_set();
385        let syntax = resolve(language);
386        #[cfg(not(feature = "syntax-cache"))]
387        let mut highlighter = HighlightLines::new(syntax, syn_theme);
388        #[cfg(feature = "syntax-cache")]
389        let mut highlighter = super::cache::CachedHighlighter::new(syntax, syn_theme);
390
391        let mut lines = Vec::new();
392        for line in LinesWithEndings::from(code) {
393            let body = line.strip_suffix('\n').map_or(line.len(), str::len);
394            let mut highlighted = HighlightedLine::default();
395            let mut position = 0usize;
396            // A line syntect fails on keeps no spans and renders unstyled.
397            for (style, token) in highlighter
398                .highlight_line(line, syntaxes)
399                .unwrap_or_default()
400            {
401                let style = convert(style);
402                let start = position;
403                position += token.len();
404                if token.ends_with('\n') {
405                    highlighted.newline_style = Some(style.clone());
406                }
407                let end = position.min(body);
408                if end > start {
409                    highlighted.spans.push(HighlightSpan {
410                        range: start..end,
411                        style,
412                    });
413                }
414            }
415            lines.push(highlighted);
416        }
417        // `code.split('\n')` has one more element than `LinesWithEndings` yields
418        // when the code is empty or ends with a newline.
419        if code.is_empty() || code.ends_with('\n') {
420            lines.push(HighlightedLine::default());
421        }
422        Ok(HighlightedCode {
423            lines,
424            background,
425            default_style,
426        })
427    }
428
429    fn default_theme(&self) -> &str {
430        DEFAULT_THEME
431    }
432
433    /// Upstream's `ANSI_DARK`/`ANSI_LIGHT` entry for the token; for a syntect
434    /// theme, its foreground (`Text`) or its `comment` scope style.
435    fn token_style(&self, theme: &str, token: &str) -> Option<Style> {
436        match (resolve_theme(theme).ok()?, token) {
437            (Resolved::Ansi { .. }, "Comment") => Some(Style::parse("dim").expect("valid style")),
438            (Resolved::Ansi { .. }, _) => None,
439            (Resolved::Syntect(theme), "Text") => theme
440                .settings
441                .foreground
442                .map(|color| Style::new().with_color(to_color(color))),
443            (Resolved::Syntect(theme), "Comment") => {
444                let scope = syntect::parsing::Scope::new("comment").ok()?;
445                let style =
446                    syntect::highlighting::Highlighter::new(theme).style_for_stack(&[scope]);
447                Some(
448                    to_style(style)
449                        .without_color()
450                        .combine(&Style::new().with_color(to_color(style.foreground))),
451                )
452            }
453            _ => None,
454        }
455    }
456
457    fn themes(&self) -> Vec<String> {
458        let mut names: Vec<String> = theme_set().themes.keys().cloned().collect();
459        names.extend(["ansi_dark".to_string(), "ansi_light".to_string()]);
460        names.sort();
461        names
462    }
463
464    fn languages(&self) -> Vec<String> {
465        let mut names: Vec<String> = syntax_set()
466            .syntaxes()
467            .iter()
468            .map(|syntax| syntax.name.clone())
469            .collect();
470        names.sort();
471        names.dedup();
472        names
473    }
474
475    fn language_for_path(&self, path: &Path) -> Option<String> {
476        let syntaxes = syntax_set();
477        let by_name = path
478            .file_name()
479            .and_then(|name| name.to_str())
480            .and_then(|name| syntaxes.find_syntax_by_extension(name));
481        let by_extension = path
482            .extension()
483            .and_then(|ext| ext.to_str())
484            .and_then(|ext| syntaxes.find_syntax_by_extension(ext));
485        by_name.or(by_extension).map(|syntax| syntax.name.clone())
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    #[test]
494    fn every_ansi_scope_selector_parses_and_every_style_is_valid() {
495        let _ = ansi_marker_theme();
496        for dark in [true, false] {
497            for class in CLASSES {
498                let _ = ansi_style(marker(class), dark);
499            }
500        }
501    }
502
503    #[test]
504    fn markers_are_in_class_order() {
505        for (index, class) in CLASSES.iter().enumerate() {
506            assert_eq!(*class as usize, index);
507        }
508    }
509
510    #[test]
511    fn lines_follow_split_semantics() {
512        let highlighter = SyntectHighlighter;
513        for code in ["", "a", "a\n", "a\nb", "a\n\nb\n", "\n"] {
514            let out = highlighter
515                .highlight(code, Some("python"), DEFAULT_THEME)
516                .unwrap();
517            assert_eq!(out.lines.len(), code.split('\n').count(), "{code:?}");
518        }
519    }
520
521    #[test]
522    fn unknown_theme_is_an_error_and_unknown_language_is_plain() {
523        let highlighter = SyntectHighlighter;
524        assert_eq!(
525            highlighter.highlight("x", None, "no-such-theme"),
526            Err(HighlightError::UnknownTheme("no-such-theme".into()))
527        );
528        let plain = highlighter
529            .highlight("x = 1", Some("no-such-language"), DEFAULT_THEME)
530            .unwrap();
531        assert_eq!(plain.lines.len(), 1);
532    }
533
534    #[test]
535    fn themes_include_the_ansi_pair_and_the_default() {
536        let themes = SyntectHighlighter.themes();
537        for name in ["ansi_dark", "ansi_light", DEFAULT_THEME] {
538            assert!(
539                themes.iter().any(|t| t == name),
540                "{name} missing: {themes:?}"
541            );
542        }
543    }
544
545    #[test]
546    fn language_for_path_uses_extensions() {
547        let highlighter = SyntectHighlighter;
548        assert_eq!(
549            highlighter
550                .language_for_path(Path::new("src/main.rs"))
551                .as_deref(),
552            Some("Rust")
553        );
554        assert_eq!(
555            highlighter.language_for_path(Path::new("notes.unknownext")),
556            None
557        );
558    }
559}