Skip to main content

rich_lumis/
lib.rs

1//! The [lumis](https://lumis.sh) syntax highlighter for the `rich` Rust port.
2//!
3//! [`LumisHighlighter`] is a [`CodeHighlighter`]: tree-sitter grammars for
4//! parsing, and lumis's Neovim themes (`monokai` by default, `dracula`,
5//! `github_light`, `onedark` and over 250 more) for colour. Like the
6//! built-in syntect adapter it also has `ansi_dark` and `ansi_light`, upstream
7//! rich's themes in the terminal's own 16 colours.
8//!
9//! Give it to [`Syntax`](rich::syntax::Syntax) or
10//! [`Markdown`](rich::markdown::Markdown) directly, or register [`LumisPlugin`]
11//! with a plugin host so it can be chosen by name (`"lumis"`).
12//!
13//! ```
14//! use rich::syntax::Syntax;
15//! use rich::Console;
16//! use rich_lumis::LumisHighlighter;
17//!
18//! let console = Console::builder().width(40).force_terminal(true).build();
19//! let code = Syntax::new("fn main() {}", "rust")
20//!     .highlighter(LumisHighlighter::shared())
21//!     .theme("dracula");
22//! assert!(console.render_to_string(&code).contains("main"));
23//! ```
24//!
25//! lumis compiles every language it ships by default. For a smaller build,
26//! turn default features off and pick a bundle (`bundle-web`, `bundle-system`,
27//! `bundle-backend`, `bundle-web-extra`).
28
29// A builder method that returns `Self` is `#[must_use]`: dropping its
30// result drops the value (CONTRIBUTING.md, "API conventions").
31#![warn(clippy::return_self_not_must_use)]
32
33use std::ops::Range;
34use std::path::Path;
35use std::sync::{Arc, OnceLock};
36
37use lumis::highlight::highlight_iter;
38use lumis::languages::{Language, available_languages, language_id_for_filename};
39use lumis::themes::{self, Theme, UnderlineStyle};
40use rich::color::Color;
41use rich::protocol::{
42    CodeHighlighter, HighlightError, HighlightSpan, HighlightedCode, HighlightedLine,
43};
44use rich::style::Style;
45use rich_plugin_api::{Plugin, PluginError, PluginMetadata, PluginRegistrar};
46
47/// The theme used when none is chosen: upstream rich's default, `monokai`.
48pub const DEFAULT_THEME: &str = "monokai";
49
50/// Syntax highlighting by lumis.
51#[derive(Clone, Copy, Debug, Default)]
52pub struct LumisHighlighter;
53
54impl LumisHighlighter {
55    pub fn new() -> Self {
56        LumisHighlighter
57    }
58
59    /// One shared instance.
60    pub fn shared() -> Arc<dyn CodeHighlighter> {
61        static SHARED: OnceLock<Arc<LumisHighlighter>> = OnceLock::new();
62        SHARED.get_or_init(|| Arc::new(LumisHighlighter)).clone()
63    }
64}
65
66/// Where span colours come from.
67enum Palette {
68    Theme(Box<Theme>),
69    /// Upstream's `ANSI_DARK` (`true`) or `ANSI_LIGHT`.
70    Ansi(bool),
71}
72
73fn palette(name: &str) -> Result<Palette, HighlightError> {
74    match name {
75        "ansi_dark" => Ok(Palette::Ansi(true)),
76        "ansi_light" => Ok(Palette::Ansi(false)),
77        _ => themes::get(name)
78            .map(|theme| Palette::Theme(Box::new(theme)))
79            .map_err(|_| HighlightError::UnknownTheme(name.to_string())),
80    }
81}
82
83/// A lumis hex colour as a rich colour; `None` for anything else.
84fn color(value: Option<&str>) -> Option<Color> {
85    value.and_then(|value| Color::parse(value).ok())
86}
87
88/// A lumis token style as a rich style, over the theme's own colours.
89fn theme_style(style: &themes::Style, base: &Style) -> Style {
90    let mut out = base.clone();
91    if let Some(fg) = color(style.fg.as_deref()) {
92        out = out.with_color(fg);
93    }
94    if let Some(bg) = color(style.bg.as_deref()) {
95        out = out.with_bgcolor(bg);
96    }
97    let mut flags = Vec::new();
98    if style.bold {
99        flags.push("bold");
100    }
101    if style.italic {
102        flags.push("italic");
103    }
104    if style.text_decoration.underline != UnderlineStyle::None {
105        flags.push("underline");
106    }
107    if style.text_decoration.strikethrough {
108        flags.push("strike");
109    }
110    if !flags.is_empty() {
111        out = out.combine(&Style::parse(&flags.join(" ")).expect("valid style"));
112    }
113    out
114}
115
116/// Pygments token types in upstream's ANSI themes.
117#[derive(Clone, Copy, Debug, PartialEq, Eq)]
118enum TokenType {
119    Token,
120    Comment,
121    CommentPreproc,
122    Keyword,
123    KeywordType,
124    OperatorWord,
125    NameBuiltin,
126    NameFunction,
127    NameNamespace,
128    NameClass,
129    NameDecorator,
130    NameVariable,
131    NameConstant,
132    NameAttribute,
133    NameTag,
134    String,
135    Number,
136    GenericDeleted,
137    GenericInserted,
138    GenericHeading,
139    Error,
140}
141
142/// Tree-sitter capture names (as lumis reports them) to Pygments token types.
143/// The longest matching prefix wins, so `keyword.operator` beats `keyword`.
144const SCOPES: &[(&str, TokenType)] = &[
145    ("comment", TokenType::Comment),
146    ("keyword.directive", TokenType::CommentPreproc),
147    ("keyword.type", TokenType::KeywordType),
148    ("keyword.operator", TokenType::OperatorWord),
149    ("keyword", TokenType::Keyword),
150    ("boolean", TokenType::Keyword),
151    ("constant.builtin", TokenType::Keyword),
152    ("type.builtin", TokenType::KeywordType),
153    ("type", TokenType::NameClass),
154    ("constructor", TokenType::NameClass),
155    ("function.builtin", TokenType::NameBuiltin),
156    ("variable.builtin", TokenType::NameBuiltin),
157    ("module.builtin", TokenType::NameBuiltin),
158    ("function", TokenType::NameFunction),
159    ("module", TokenType::NameNamespace),
160    ("namespace", TokenType::NameNamespace),
161    ("attribute", TokenType::NameDecorator),
162    ("variable.parameter", TokenType::NameVariable),
163    ("constant", TokenType::NameConstant),
164    ("property", TokenType::NameAttribute),
165    ("variable.member", TokenType::NameAttribute),
166    ("tag.attribute", TokenType::NameAttribute),
167    ("tag", TokenType::NameTag),
168    ("string", TokenType::String),
169    ("character", TokenType::String),
170    ("number", TokenType::Number),
171    ("diff.minus", TokenType::GenericDeleted),
172    ("diff.plus", TokenType::GenericInserted),
173    ("markup.heading", TokenType::GenericHeading),
174    ("error", TokenType::Error),
175];
176
177fn token_type(scope: &str) -> TokenType {
178    SCOPES
179        .iter()
180        .filter(|(prefix, _)| {
181            scope == *prefix
182                || scope
183                    .strip_prefix(prefix)
184                    .is_some_and(|rest| rest.starts_with('.'))
185        })
186        .max_by_key(|(prefix, _)| prefix.len())
187        .map_or(TokenType::Token, |(_, token)| *token)
188}
189
190/// Upstream rich's `ANSI_DARK` / `ANSI_LIGHT`, as the syntect adapter uses them.
191fn ansi_markup(token: TokenType, dark: bool) -> &'static str {
192    use TokenType::*;
193    match (token, dark) {
194        (Token, _) => "",
195        (Comment, _) => "dim",
196        (CommentPreproc, true) => "bright_cyan",
197        (CommentPreproc, false) => "cyan",
198        (Keyword, true) => "bright_blue",
199        (Keyword, false) => "blue",
200        (KeywordType | NameBuiltin | NameAttribute, true) => "bright_cyan",
201        (KeywordType | NameBuiltin | NameAttribute, false) => "cyan",
202        (OperatorWord, true) => "bright_magenta",
203        (OperatorWord, false) => "magenta",
204        (NameFunction, true) => "bright_green",
205        (NameFunction, false) => "green",
206        (NameNamespace, true) => "bright_cyan underline",
207        (NameNamespace, false) => "cyan underline",
208        (NameClass, true) => "bright_green underline",
209        (NameClass, false) => "green underline",
210        (NameDecorator, true) => "bright_magenta bold",
211        (NameDecorator, false) => "magenta bold",
212        (NameVariable | NameConstant, true) => "bright_red",
213        (NameVariable | NameConstant, false) => "red",
214        (NameTag, _) => "bright_blue",
215        (String, _) => "yellow",
216        (Number, true) => "bright_blue",
217        (Number, false) => "blue",
218        (GenericDeleted, _) => "bright_red",
219        (GenericInserted, true) => "bright_green",
220        (GenericInserted, false) => "green",
221        (GenericHeading, _) => "bold",
222        (Error, _) => "red underline",
223    }
224}
225
226fn ansi_style(scope: &str, dark: bool) -> Style {
227    Style::parse(ansi_markup(token_type(scope), dark)).expect("valid style")
228}
229
230/// Collects token styles into per-line spans.
231struct Lines<'a> {
232    code: &'a str,
233    /// Byte offset where each line of `code.split('\n')` starts.
234    starts: Vec<usize>,
235    lines: Vec<HighlightedLine>,
236}
237
238impl<'a> Lines<'a> {
239    fn new(code: &'a str) -> Self {
240        let mut starts = vec![0];
241        starts.extend(code.match_indices('\n').map(|(at, _)| at + 1));
242        let lines = vec![HighlightedLine::default(); starts.len()];
243        Lines {
244            code,
245            starts,
246            lines,
247        }
248    }
249
250    /// The line holding byte `at`.
251    fn line_of(&self, at: usize) -> usize {
252        self.starts.partition_point(|&start| start <= at) - 1
253    }
254
255    /// Style `range` of the code, splitting it at line breaks.
256    fn push(&mut self, range: Range<usize>, style: &Style) {
257        if range.is_empty() || style.is_null() || range.end > self.code.len() {
258            return;
259        }
260        for line in self.line_of(range.start)..=self.line_of(range.end - 1) {
261            let begin = self.starts[line];
262            // The end of the line's text, before its '\n'.
263            let end = self
264                .starts
265                .get(line + 1)
266                .map_or(self.code.len(), |next| next - 1);
267            let (from, to) = (range.start.max(begin), range.end.min(end));
268            if from < to {
269                self.lines[line].spans.push(HighlightSpan {
270                    range: from - begin..to - begin,
271                    style: style.clone(),
272                });
273            }
274            if end < self.code.len() && range.start <= end && end < range.end {
275                self.lines[line].newline_style = Some(style.clone());
276            }
277        }
278    }
279}
280
281/// Pygments names that lumis does not know, mapped to the lumis language that
282/// highlights them. Code fences and `Syntax` use Pygments names (`shell`,
283/// `console`, `python3`), and the default syntect highlighter accepts them.
284fn lumis_language(name: &str) -> &str {
285    const ALIASES: &[(&str, &str)] = &[
286        ("shell", "bash"),
287        ("sh", "bash"),
288        ("zsh", "bash"),
289        ("ksh", "bash"),
290        ("console", "bash"),
291        ("shell-session", "bash"),
292        ("shellsession", "bash"),
293        ("python3", "python"),
294        ("py3", "python"),
295        ("golang", "go"),
296        ("patch", "diff"),
297        ("udiff", "diff"),
298        ("jsonc", "json"),
299        ("json5", "json"),
300    ];
301    ALIASES
302        .iter()
303        .find(|(alias, _)| alias.eq_ignore_ascii_case(name))
304        .map_or(name, |(_, target)| target)
305}
306
307impl CodeHighlighter for LumisHighlighter {
308    fn highlight(
309        &self,
310        code: &str,
311        language: Option<&str>,
312        theme: &str,
313    ) -> Result<HighlightedCode, HighlightError> {
314        let palette = palette(theme)?;
315        let language = match language {
316            // An unknown name highlights as plain text rather than guessing
317            // from the content.
318            Some(name) => Language::guess(Some(lumis_language(name)), ""),
319            None => Language::PlainText,
320        };
321        let (theme, background, base) = match palette {
322            Palette::Theme(theme) => {
323                let background = color(theme.bg());
324                let mut base = Style::new();
325                if let Some(fg) = color(theme.fg()) {
326                    base = base.with_color(fg);
327                }
328                if let Some(bg) = &background {
329                    base = base.with_bgcolor(bg.clone());
330                }
331                (Some(*theme), background, Some(base))
332            }
333            Palette::Ansi(dark) => {
334                let mut lines = Lines::new(code);
335                highlight_iter(code, language, None, |_, _, range, scope, _| {
336                    lines.push(range, &ansi_style(scope, dark));
337                    Ok::<(), std::convert::Infallible>(())
338                })
339                .map_err(|error| HighlightError::Engine(error.to_string()))?;
340                return Ok(HighlightedCode {
341                    lines: lines.lines,
342                    background: None,
343                    default_style: Style::new(),
344                });
345            }
346        };
347        let base = base.unwrap_or_default();
348        let mut lines = Lines::new(code);
349        highlight_iter(code, language, theme, |_, _, range, scope, style| {
350            // Unscoped text takes the default style, which already carries the
351            // theme's colours.
352            if !scope.is_empty() {
353                lines.push(range, &theme_style(style, &base));
354            }
355            Ok::<(), std::convert::Infallible>(())
356        })
357        .map_err(|error| HighlightError::Engine(error.to_string()))?;
358        Ok(HighlightedCode {
359            lines: lines.lines,
360            background,
361            default_style: base,
362        })
363    }
364
365    fn default_theme(&self) -> &str {
366        DEFAULT_THEME
367    }
368
369    fn themes(&self) -> Vec<String> {
370        let mut names: Vec<String> = themes::available_themes()
371            .map(|theme| theme.name.clone())
372            .collect();
373        names.extend(["ansi_dark".to_string(), "ansi_light".to_string()]);
374        names.sort();
375        names
376    }
377
378    fn languages(&self) -> Vec<String> {
379        available_languages()
380            .into_iter()
381            .map(|language| language.id.to_string())
382            .collect()
383    }
384
385    fn language_for_path(&self, path: &Path) -> Option<String> {
386        language_id_for_filename(&path.to_string_lossy())
387            .filter(|id| *id != "plaintext")
388            .map(str::to_string)
389    }
390}
391
392/// Registers [`LumisHighlighter`] as the code highlighter `"lumis"`.
393#[derive(Clone, Copy, Debug, Default)]
394pub struct LumisPlugin;
395
396impl Plugin for LumisPlugin {
397    fn metadata(&self) -> PluginMetadata {
398        PluginMetadata::new(
399            "lumis",
400            "lumis syntax highlighter",
401            env!("CARGO_PKG_VERSION"),
402        )
403        .description("tree-sitter highlighting with Neovim themes")
404    }
405
406    fn register(&self, registrar: &mut dyn PluginRegistrar) -> Result<(), PluginError> {
407        registrar.code_highlighter("lumis", LumisHighlighter::shared());
408        Ok(())
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415
416    #[test]
417    fn scopes_map_to_upstream_token_types() {
418        assert_eq!(token_type("keyword"), TokenType::Keyword);
419        assert_eq!(token_type("keyword.function"), TokenType::Keyword);
420        assert_eq!(token_type("keyword.operator"), TokenType::OperatorWord);
421        assert_eq!(
422            token_type("keyword.directive.define"),
423            TokenType::CommentPreproc
424        );
425        assert_eq!(token_type("comment.documentation"), TokenType::Comment);
426        assert_eq!(token_type("function.builtin"), TokenType::NameBuiltin);
427        assert_eq!(token_type("function.method.call"), TokenType::NameFunction);
428        assert_eq!(token_type("string.escape"), TokenType::String);
429        assert_eq!(token_type("punctuation.bracket"), TokenType::Token);
430        assert_eq!(token_type("variable"), TokenType::Token);
431        // A prefix only matches whole components.
432        assert_eq!(token_type("keywordish"), TokenType::Token);
433        assert_eq!(token_type(""), TokenType::Token);
434    }
435
436    #[test]
437    fn spans_split_at_line_breaks() {
438        let code = "ab\ncd\n";
439        let mut lines = Lines::new(code);
440        let bold = Style::parse("bold").unwrap();
441        lines.push(1..5, &bold);
442        assert_eq!(lines.lines.len(), 3);
443        assert_eq!(lines.lines[0].spans[0].range, 1..2);
444        assert_eq!(lines.lines[0].newline_style, Some(bold.clone()));
445        assert_eq!(lines.lines[1].spans[0].range, 0..2);
446        assert_eq!(lines.lines[1].newline_style, None);
447        assert!(lines.lines[2].spans.is_empty());
448        // Out-of-range and empty ranges are ignored.
449        lines.push(4..40, &bold);
450        lines.push(2..2, &bold);
451        assert_eq!(lines.lines[1].spans.len(), 1);
452    }
453}