Skip to main content

mdsee_render/
highlight.rs

1//! Syntax highlight(design.md §29, §92)。
2//!
3//! Renderer(rendering code path)は `SyntaxHighlighter` traitのみに依存し、
4//! syntectのAPIを直接呼ばない。`SyntectHighlighter` はfeature `syntax`
5//! 有効時のみ提供される。
6
7use crate::style::Rgb;
8
9/// highlight済みの1行。
10#[derive(Debug, Clone, PartialEq, Eq, Default)]
11pub struct HighlightedLine {
12    pub spans: Vec<HighlightedSpan>,
13}
14
15/// highlight済みの1区切り。色はsyntect theme由来。
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct HighlightedSpan {
18    pub text: String,
19    pub fg: Rgb,
20    pub bold: bool,
21    pub italic: bool,
22    pub underline: bool,
23}
24
25/// syntax highlight interface(§29)。
26pub trait SyntaxHighlighter {
27    fn highlight(&self, code: &str, language: Option<&str>) -> Vec<HighlightedLine>;
28}
29
30/// highlightを行わない実装。feature `syntax` 無効時のfallback。
31#[derive(Debug, Clone, Copy, Default)]
32pub struct NoHighlight;
33
34impl SyntaxHighlighter for NoHighlight {
35    fn highlight(&self, code: &str, _language: Option<&str>) -> Vec<HighlightedLine> {
36        code.lines()
37            .map(|_| HighlightedLine { spans: Vec::new() })
38            .collect()
39    }
40}
41
42/// syntect実装(§29)。feature `syntax` で有効化。
43#[cfg(feature = "syntax")]
44pub mod syntect_backend {
45    use super::{HighlightedLine, HighlightedSpan, Rgb, SyntaxHighlighter};
46    use std::sync::OnceLock;
47    use syntect::easy::HighlightLines;
48    use syntect::highlighting::{FontStyle, ThemeSet};
49    use syntect::parsing::SyntaxSet;
50
51    static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
52    static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
53
54    /// syntect backendの `SyntectHighlighter`(§29)。
55    ///
56    /// theme名は `Theme.syntax_theme`(§61)から渡される。
57    /// 未登録のtheme名は `base16-ocean.dark` へfallbackする。
58    #[derive(Debug, Clone)]
59    pub struct SyntectHighlighter {
60        theme_name: String,
61    }
62
63    impl SyntectHighlighter {
64        pub fn new(theme_name: impl Into<String>) -> Self {
65            Self {
66                theme_name: theme_name.into(),
67            }
68        }
69    }
70
71    impl Default for SyntectHighlighter {
72        fn default() -> Self {
73            Self::new("base16-ocean.dark")
74        }
75    }
76
77    impl SyntaxHighlighter for SyntectHighlighter {
78        fn highlight(&self, code: &str, language: Option<&str>) -> Vec<HighlightedLine> {
79            let syntax_set = SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines);
80            let theme_set = THEME_SET.get_or_init(ThemeSet::load_defaults);
81            let fallback = theme_set.themes.get("base16-ocean.dark");
82            let theme = theme_set
83                .themes
84                .get(&self.theme_name)
85                .or(fallback)
86                .expect("built-in theme set always contains base16-ocean.dark");
87
88            let syntax = language
89                .and_then(|token| syntax_set.find_syntax_by_token(token))
90                .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
91
92            let mut highlighter = HighlightLines::new(syntax, theme);
93            let mut lines = Vec::new();
94            for line in code.split_inclusive('\n') {
95                let mut spans: Vec<HighlightedSpan> = Vec::new();
96                let ranges = highlighter.highlight_line(line, syntax_set);
97                match ranges {
98                    Ok(ranges) => {
99                        for (style, text) in ranges {
100                            let text = text.strip_suffix('\n').unwrap_or(text);
101                            let text = text.strip_suffix('\r').unwrap_or(text);
102                            if text.is_empty() {
103                                continue;
104                            }
105                            spans.push(HighlightedSpan {
106                                text: text.to_string(),
107                                fg: Rgb(style.foreground.r, style.foreground.g, style.foreground.b),
108                                bold: style.font_style.contains(FontStyle::BOLD),
109                                italic: style.font_style.contains(FontStyle::ITALIC),
110                                underline: style.font_style.contains(FontStyle::UNDERLINE),
111                            });
112                        }
113                    }
114                    Err(_) => spans.push(HighlightedSpan {
115                        text: line.trim_end_matches(['\n', '\r']).to_string(),
116                        fg: Rgb(166, 173, 186),
117                        bold: false,
118                        italic: false,
119                        underline: false,
120                    }),
121                }
122                lines.push(HighlightedLine { spans });
123            }
124            // 末尾の空行分(code.lines() と長期を合わせるための調整は不要。
125            // split_inclusive('\n') は code.lines() と同一の行数を返す)
126            lines
127        }
128    }
129}
130
131#[cfg(all(test, feature = "syntax"))]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn rust_code_gets_multiple_colored_spans() {
137        let highlighter = syntect_backend::SyntectHighlighter::default();
138        let lines = highlighter.highlight("fn main() {}\n", Some("rust"));
139        assert_eq!(lines.len(), 1);
140        let joined: String = lines[0].spans.iter().map(|s| s.text.as_str()).collect();
141        assert_eq!(joined, "fn main() {}");
142        // keyword等で複数色に分かれる
143        assert!(lines[0].spans.len() >= 2, "spans: {:?}", lines[0].spans);
144        let colors: std::collections::HashSet<_> = lines[0].spans.iter().map(|s| s.fg).collect();
145        assert!(colors.len() >= 2);
146    }
147
148    #[test]
149    fn unknown_language_falls_back_to_plain() {
150        let highlighter = syntect_backend::SyntectHighlighter::default();
151        let lines = highlighter.highlight("just text\n", Some("no-such-lang"));
152        assert_eq!(lines.len(), 1);
153        assert_eq!(lines[0].spans[0].text, "just text");
154    }
155
156    #[test]
157    fn multi_line_code_preserves_line_count() {
158        let highlighter = syntect_backend::SyntectHighlighter::default();
159        let lines = highlighter.highlight("a\nb\nc\n", None);
160        assert_eq!(lines.len(), 3);
161        let texts: Vec<String> = lines
162            .iter()
163            .map(|l| l.spans.iter().map(|s| s.text.as_str()).collect())
164            .collect();
165        assert_eq!(texts, ["a", "b", "c"]);
166    }
167
168    #[test]
169    fn unknown_theme_falls_back_to_default() {
170        let highlighter = syntect_backend::SyntectHighlighter::new("no-such-theme");
171        let lines = highlighter.highlight("x\n", None);
172        assert_eq!(lines.len(), 1);
173    }
174}