Skip to main content

iced_markdown_editor/
syntect.rs

1//! Code highlighter based on [syntect](https://github.com/trishume/syntect)
2//! (Sublime Text grammars). Available with the `syntect` feature.
3//!
4//! ```ignore
5//! use iced_markdown_editor::{EditorState, SyntectHighlighter};
6//!
7//! let editor = EditorState::with_markdown(text).highlighter(SyntectHighlighter::new());
8//! ```
9//!
10//! The light theme is used when the editor's code background is light,
11//! the dark theme otherwise.
12
13use crate::highlight::{CodeHighlighter, Color, HighlightRequest, HighlightSpan};
14use std::collections::BTreeSet;
15
16use ::syntect::easy::HighlightLines;
17use ::syntect::util::LinesWithEndings;
18
19/// syntect types used by [`SyntectHighlighter::from_parts`], re-exported so
20/// you do not need a direct dependency on syntect.
21pub use ::syntect::highlighting::{Theme, ThemeSet};
22pub use ::syntect::parsing::{SyntaxReference, SyntaxSet};
23
24/// Default theme for light code backgrounds.
25pub const DEFAULT_LIGHT_THEME: &str = "InspiredGitHub";
26/// Default theme for dark code backgrounds.
27pub const DEFAULT_DARK_THEME: &str = "base16-ocean.dark";
28
29/// Fence names that syntect does not know directly, mapped to a token it
30/// does know (an extension or syntax name).
31const ALIASES: &[(&str, &str)] = &[
32    ("ts", "js"),
33    ("tsx", "js"),
34    ("typescript", "js"),
35    ("jsx", "js"),
36    ("jsonc", "json"),
37    ("json5", "json"),
38    ("shell", "sh"),
39    ("console", "sh"),
40    ("zsh", "sh"),
41    ("powershell", "sh"),
42    ("ps1", "sh"),
43    ("csharp", "cs"),
44    ("c#", "cs"),
45    ("c++", "cpp"),
46    ("golang", "go"),
47    ("py3", "py"),
48    ("python3", "py"),
49    ("yml", "yaml"),
50    ("objc", "m"),
51    ("html5", "html"),
52    ("svg", "xml"),
53];
54
55/// Highlighter based on syntect's bundled grammars and themes.
56pub struct SyntectHighlighter {
57    syntaxes: SyntaxSet,
58    light: Theme,
59    dark: Theme,
60}
61
62impl Default for SyntectHighlighter {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68impl SyntectHighlighter {
69    /// Bundled grammars, `InspiredGitHub` (light) and `base16-ocean.dark` (dark).
70    pub fn new() -> Self {
71        Self::with_themes(DEFAULT_LIGHT_THEME, DEFAULT_DARK_THEME)
72    }
73
74    /// Bundled grammars with the named bundled themes. Unknown names fall
75    /// back to the defaults.
76    pub fn with_themes(light: &str, dark: &str) -> Self {
77        let themes = ThemeSet::load_defaults();
78        let pick = |name: &str, fallback: &str| {
79            themes
80                .themes
81                .get(name)
82                .or_else(|| themes.themes.get(fallback))
83                .cloned()
84                .unwrap_or_default()
85        };
86        Self {
87            syntaxes: SyntaxSet::load_defaults_newlines(),
88            light: pick(light, DEFAULT_LIGHT_THEME),
89            dark: pick(dark, DEFAULT_DARK_THEME),
90        }
91    }
92
93    /// Your own grammars and themes. The syntax set must be built for lines
94    /// **with** newlines.
95    pub fn from_parts(syntaxes: SyntaxSet, light: Theme, dark: Theme) -> Self {
96        Self {
97            syntaxes,
98            light,
99            dark,
100        }
101    }
102
103    /// Names of the bundled themes.
104    pub fn theme_names() -> Vec<String> {
105        ThemeSet::load_defaults().themes.keys().cloned().collect()
106    }
107
108    fn find(&self, language: &str) -> Option<&SyntaxReference> {
109        let language = language.trim().to_lowercase();
110        let token = ALIASES
111            .iter()
112            .find(|(alias, _)| *alias == language)
113            .map(|(_, token)| *token)
114            .unwrap_or(language.as_str());
115
116        self.syntaxes
117            .find_syntax_by_token(token)
118            .or_else(|| self.syntaxes.find_syntax_by_extension(token))
119            .filter(|syntax| syntax.name != "Plain Text")
120    }
121}
122
123fn to_color(color: ::syntect::highlighting::Color) -> Color {
124    Color::from_rgba8(color.r, color.g, color.b, color.a as f32 / 255.0)
125}
126
127impl CodeHighlighter for SyntectHighlighter {
128    fn highlight(&self, request: &HighlightRequest<'_>) -> Vec<HighlightSpan> {
129        let Some(syntax) = self.find(request.language) else {
130            return Vec::new();
131        };
132        let theme = if request.dark {
133            &self.dark
134        } else {
135            &self.light
136        };
137        let mut lines = HighlightLines::new(syntax, theme);
138        let mut spans = Vec::new();
139        let mut offset = 0usize;
140
141        for line in LinesWithEndings::from(request.code) {
142            let Ok(ranges) = lines.highlight_line(line, &self.syntaxes) else {
143                break;
144            };
145            for (style, piece) in ranges {
146                let len = piece.chars().count();
147                spans.push(HighlightSpan {
148                    range: offset..offset + len,
149                    color: to_color(style.foreground),
150                });
151                offset += len;
152            }
153        }
154
155        spans
156    }
157
158    fn languages(&self) -> Vec<String> {
159        let mut names = BTreeSet::new();
160        for syntax in self.syntaxes.syntaxes() {
161            if syntax.hidden || syntax.name == "Plain Text" {
162                continue;
163            }
164            let name = syntax.name.to_lowercase();
165            let short = if name.contains(' ') || name.contains('(') {
166                match syntax.file_extensions.first() {
167                    Some(extension) => extension.to_lowercase(),
168                    None => continue,
169                }
170            } else {
171                name
172            };
173            names.insert(short);
174        }
175        names.into_iter().collect()
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn highlights_rust_and_ignores_unknown_languages() {
185        let highlighter = SyntectHighlighter::new();
186        let request = |language| HighlightRequest {
187            language,
188            code: "fn main() {}\n",
189            background: Color::WHITE,
190            dark: false,
191        };
192        assert!(!highlighter.highlight(&request("rust")).is_empty());
193        assert!(!highlighter.highlight(&request("rs")).is_empty());
194        assert!(
195            highlighter
196                .highlight(&request("no-such-language"))
197                .is_empty()
198        );
199        assert!(highlighter.languages().contains(&"rust".to_string()));
200    }
201}