Skip to main content

waterui_text/
highlight.rs

1use core::fmt;
2use core::{
3    error::Error,
4    fmt::{Debug, Display},
5    str::FromStr,
6};
7
8use alloc::{string::ToString, vec::Vec};
9use nami::impl_constant;
10#[cfg(feature = "highlight")]
11use syntect::{
12    highlighting::{Theme, ThemeSet},
13    parsing::{SyntaxReference, SyntaxSet},
14};
15#[cfg(feature = "highlight")]
16use two_face::syntax::extra_newlines;
17#[cfg(feature = "highlight")]
18use waterui_graphics::color::ColorScheme;
19use waterui_graphics::color::Srgb;
20
21use crate::styled::{Style, StyledStr};
22
23/// A trait for syntax highlighting implementations.
24pub trait Highlighter: Send + Sync {
25    /// Highlights the given text and returns a vector of chunks with colors.
26    fn highlight<'a>(&mut self, language: Language, text: &'a str) -> Vec<HighlightChunk<'a>>;
27}
28
29/// Highlights text using the given highlighter.
30pub fn highlight_text(
31    language: Language,
32    text: &str,
33    highlighter: &mut impl Highlighter,
34) -> StyledStr {
35    // TODO: use async thread pool
36    highlighter
37        .highlight(language, text)
38        .into_iter()
39        .fold(StyledStr::empty(), |mut s, chunk| {
40            s.push(
41                chunk.text.to_string(),
42                Style::default().foreground(chunk.color),
43            );
44            s
45        })
46}
47
48macro_rules! languages {
49    ($($ident:ident => $ext:literal),* $(,)?) => {
50        /// Supported programming languages for syntax highlighting.
51        #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
52        #[non_exhaustive]
53        pub enum Language {
54            $(
55                #[doc = stringify!($ident)]
56                $ident,
57            )*
58        }
59
60        impl Language {
61            /// Returns the file extension associated with this language.
62            #[must_use]
63            pub const fn extension(&self) -> &'static str {
64                match self {
65                    $(Self::$ident => $ext,)*
66                }
67            }
68
69            /// Returns the token name for this language (lowercase).
70            #[must_use]
71            pub const fn token(&self) -> &'static str {
72                match self {
73                    $(Self::$ident => const {
74                        const fn to_lower(s: &str) -> &str { s }
75                        to_lower(stringify!($ident))
76                    },)*
77                }
78            }
79        }
80
81        impl fmt::Display for Language {
82            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83                match self {
84                    $(Self::$ident => write!(f, stringify!($ident)),)*
85                }
86            }
87        }
88
89        impl FromStr for Language {
90            type Err = ParseLanguageError;
91            fn from_str(s: &str) -> Result<Self, Self::Err> {
92                let s_lower = s.to_lowercase();
93                $(
94                    if s_lower == stringify!($ident).to_lowercase() || s_lower == $ext {
95                        return Ok(Self::$ident);
96                    }
97                )*
98                // Additional aliases
99                match s_lower.as_str() {
100                    "c++" | "cxx" => Ok(Self::Cpp),
101                    "c#" => Ok(Self::CSharp),
102                    "obj-c" | "objc" => Ok(Self::ObjectiveC),
103                    "shell" => Ok(Self::Bash),
104                    "yml" => Ok(Self::Yaml),
105                    "text" => Ok(Self::Plaintext),
106                    _ => Err(ParseLanguageError),
107                }
108            }
109        }
110    };
111}
112
113languages!(
114    Plaintext => "txt",
115    Bash => "sh",
116    C => "c",
117    Cpp => "cpp",
118    CSharp => "cs",
119    Css => "css",
120    Clojure => "clj",
121    D => "d",
122    Diff => "diff",
123    Erlang => "erl",
124    Go => "go",
125    Haskell => "hs",
126    Html => "html",
127    Java => "java",
128    Javascript => "js",
129    Json => "json",
130    Kotlin => "kt",
131    Latex => "tex",
132    Lisp => "lisp",
133    Lua => "lua",
134    Makefile => "makefile",
135    Markdown => "md",
136    ObjectiveC => "m",
137    OCaml => "ml",
138    Pascal => "pas",
139    Perl => "pl",
140    Php => "php",
141    Python => "py",
142    R => "r",
143    Ruby => "rb",
144    Rust => "rs",
145    Scala => "scala",
146    Sql => "sql",
147    Swift => "swift",
148    Toml => "toml",
149    Typescript => "ts",
150    Xml => "xml",
151    Yaml => "yaml",
152    Zig => "zig",
153);
154
155impl_constant!(Language);
156
157/// Error returned when a language token cannot be parsed.
158#[derive(Debug)]
159pub struct ParseLanguageError;
160
161impl Display for ParseLanguageError {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        write!(f, "Failed to parse language")
164    }
165}
166
167impl Error for ParseLanguageError {}
168
169/// Lets [`code`](crate::code) take the fence's token as written — `"rust"`,
170/// `"c++"`, `"yml"` — through its `TryInto<Language>` argument.
171impl TryFrom<&str> for Language {
172    type Error = ParseLanguageError;
173
174    fn try_from(token: &str) -> Result<Self, Self::Error> {
175        token.parse()
176    }
177}
178
179/// Default syntax highlighter implementation using the syntect library.
180#[cfg(feature = "highlight")]
181pub struct DefaultHighlighter {
182    syntax_set: SyntaxSet,
183    theme: Theme,
184    /// The theme's own text colour, for chunks the grammar assigns no scope.
185    foreground: Srgb,
186}
187
188#[cfg(feature = "highlight")]
189impl Debug for DefaultHighlighter {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        f.debug_struct("DefaultHighlighter").finish()
192    }
193}
194
195#[cfg(feature = "highlight")]
196impl DefaultHighlighter {
197    /// Creates a highlighter backed by syntect with extended syntax support,
198    /// using the palette that reads against a light or a dark surface.
199    ///
200    /// # Panics
201    ///
202    /// Panics if the bundled syntect theme declares no default foreground.
203    #[must_use]
204    pub fn new(scheme: ColorScheme) -> Self {
205        // Use two-face's extended syntax set which includes Swift and many more languages
206        let syntax_set = extra_newlines();
207        let theme_set = ThemeSet::load_defaults();
208        let name = match scheme {
209            ColorScheme::Light => "base16-ocean.light",
210            ColorScheme::Dark => "base16-ocean.dark",
211        };
212        let theme = theme_set.themes[name].clone();
213        let foreground = theme
214            .settings
215            .foreground
216            .map(|color| Srgb::new_u8(color.r, color.g, color.b))
217            .expect("syntect theme declares no default foreground");
218        Self {
219            syntax_set,
220            theme,
221            foreground,
222        }
223    }
224
225    fn find_syntax(&self, language: &Language) -> &SyntaxReference {
226        self.syntax_set
227            .find_syntax_by_extension(language.extension())
228            .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text())
229    }
230}
231
232#[cfg(feature = "highlight")]
233impl Highlighter for DefaultHighlighter {
234    fn highlight<'a>(&mut self, language: Language, text: &'a str) -> Vec<HighlightChunk<'a>> {
235        use syntect::easy::HighlightLines;
236
237        let syntax = self.find_syntax(&language);
238        let mut h = HighlightLines::new(syntax, &self.theme);
239        let mut chunks = Vec::new();
240
241        for line in text.lines() {
242            let Ok(ranges) = h.highlight_line(line, &self.syntax_set) else {
243                // Fallback: return the whole line with default color
244                chunks.push(HighlightChunk {
245                    text: line,
246                    color: self.foreground,
247                });
248                continue;
249            };
250
251            for (style, text_slice) in ranges {
252                let color =
253                    Srgb::new_u8(style.foreground.r, style.foreground.g, style.foreground.b);
254                chunks.push(HighlightChunk {
255                    text: text_slice,
256                    color,
257                });
258            }
259
260            // Add newline back (syntect strips it)
261            if text.contains('\n') {
262                chunks.push(HighlightChunk {
263                    text: "\n",
264                    color: self.foreground,
265                });
266            }
267        }
268
269        // Handle trailing content without newline
270        if !text.ends_with('\n')
271            && let Some(last) = chunks.last_mut()
272            && last.text == "\n"
273        {
274            chunks.pop();
275        }
276
277        chunks
278    }
279}
280
281/// A chunk of highlighted text with an associated color.
282#[derive(Debug)]
283pub struct HighlightChunk<'a> {
284    /// The text content.
285    pub text: &'a str,
286    /// The color for this chunk.
287    pub color: Srgb,
288}
289
290impl HighlightChunk<'_> {
291    /// Converts this chunk into a styled string.
292    #[must_use]
293    pub fn attributed(self) -> StyledStr {
294        StyledStr::from(self.text.to_string()).foreground(self.color)
295    }
296}
297
298#[cfg(all(test, feature = "highlight"))]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_swift_syntax_exists() {
304        let syntax_set = extra_newlines();
305        let swift_syntax = syntax_set.find_syntax_by_extension("swift");
306        assert!(
307            swift_syntax.is_some(),
308            "Swift syntax should exist in two-face"
309        );
310    }
311
312    #[test]
313    fn test_swift_highlighting() {
314        let mut highlighter = DefaultHighlighter::new(ColorScheme::Dark);
315        let code = "import SwiftUI\nstruct ContentView: View { }";
316        let chunks = highlighter.highlight(Language::Swift, code);
317        // Should have multiple chunks with different colors (not all plain text)
318        assert!(chunks.len() > 1, "Swift code should be tokenized");
319    }
320
321    #[test]
322    fn palette_follows_the_colour_scheme() {
323        let code = "import SwiftUI\nstruct ContentView: View { }";
324        let colours = |scheme| {
325            DefaultHighlighter::new(scheme)
326                .highlight(Language::Swift, code)
327                .into_iter()
328                .map(|chunk| chunk.color)
329                .collect::<Vec<_>>()
330        };
331        assert_ne!(
332            colours(ColorScheme::Light),
333            colours(ColorScheme::Dark),
334            "a light surface and a dark surface need different ink"
335        );
336    }
337}