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
23pub trait Highlighter: Send + Sync {
25 fn highlight<'a>(&mut self, language: Language, text: &'a str) -> Vec<HighlightChunk<'a>>;
27}
28
29pub fn highlight_text(
31 language: Language,
32 text: &str,
33 highlighter: &mut impl Highlighter,
34) -> StyledStr {
35 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 #[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 #[must_use]
63 pub const fn extension(&self) -> &'static str {
64 match self {
65 $(Self::$ident => $ext,)*
66 }
67 }
68
69 #[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 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#[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
169impl 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#[cfg(feature = "highlight")]
181pub struct DefaultHighlighter {
182 syntax_set: SyntaxSet,
183 theme: Theme,
184 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 #[must_use]
204 pub fn new(scheme: ColorScheme) -> Self {
205 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 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 if text.contains('\n') {
262 chunks.push(HighlightChunk {
263 text: "\n",
264 color: self.foreground,
265 });
266 }
267 }
268
269 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#[derive(Debug)]
283pub struct HighlightChunk<'a> {
284 pub text: &'a str,
286 pub color: Srgb,
288}
289
290impl HighlightChunk<'_> {
291 #[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 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}