1use std::io;
2use syntect::easy::HighlightLines;
3use syntect::highlighting::{Theme, ThemeSet};
4use syntect::parsing::{SyntaxReference, SyntaxSet};
5use syntect::util::LinesWithEndings;
6use thiserror::Error;
7
8use ratatui::style::{Color, Style as RatatuiStyle};
9use ratatui::text::Span;
10
11#[derive(Debug, Error)]
13pub enum HighlightError {
14 #[error("Syntax not found: {0}")]
15 SyntaxNotFound(String),
16
17 #[error("IO error: {0}")]
18 Io(#[from] io::Error),
19
20 #[error("Parsing error: {0}")]
21 Parse(String),
22}
23
24#[derive(Debug, Clone)]
26pub struct SyntaxHighlighter {
27 syntax_set: SyntaxSet,
28 theme: Theme,
29}
30
31impl SyntaxHighlighter {
32 pub fn new() -> Result<Self, HighlightError> {
34 let syntax_set = SyntaxSet::load_defaults_newlines();
35 let theme_set = ThemeSet::load_defaults();
36 let theme = theme_set.themes["base16-ocean.dark"].clone();
37
38 Ok(Self { syntax_set, theme })
39 }
40
41 pub fn with_theme(theme_name: &str) -> Result<Self, HighlightError> {
43 let syntax_set = SyntaxSet::load_defaults_newlines();
44 let theme_set = ThemeSet::load_defaults();
45
46 let theme = theme_set
47 .themes
48 .get(theme_name)
49 .ok_or_else(|| HighlightError::SyntaxNotFound(theme_name.to_string()))?
50 .clone();
51
52 Ok(Self { syntax_set, theme })
53 }
54
55 pub fn list_builtin_themes() -> Vec<&'static str> {
57 vec![
58 "base16-ocean.dark",
59 "base16-ocean.light",
60 "Solarized (dark)",
61 "Solarized (light)",
62 "InspiredGitHub",
63 "Monokai Extended",
64 "Nord",
65 ]
66 }
67
68 pub fn detect_language(&self, lang: &str) -> SyntaxReference {
70 let lang_lower = lang.to_lowercase();
71
72 let token = match lang_lower.as_str() {
73 "rust" | "rs" => "Rust",
74 "python" | "py" => "Python",
75 "typescript" | "ts" => "TypeScript",
76 "tsx" => "TypeScript JSX",
77 "javascript" | "js" => "JavaScript",
78 "javascript react" | "jsx" => "JavaScript (Babel)",
79 "go" | "golang" => "Go",
80 "java" => "Java",
81 "c" => "C",
82 "cpp" | "c++" | "cxx" => "C++",
83 "csharp" | "c#" | "cs" => "C#",
84 "ruby" | "rb" => "Ruby",
85 "php" => "PHP",
86 "html" | "htm" => "HTML",
87 "xml" => "XML",
88 "css" => "CSS",
89 "scss" | "sass" => "SCSS",
90 "sql" => "SQL",
91 "bash" | "sh" | "shell" => "Bash",
92 "zsh" => "Shell Script (zsh)",
93 "fish" => "Fish",
94 "json" => "JSON",
95 "yaml" | "yml" => "YAML",
96 "toml" => "TOML",
97 "ini" => "INI",
98 "markdown" | "md" => "Markdown",
99
100 "lua" => "Lua",
101 "r" => "R",
102 "scala" => "Scala",
103 "kotlin" | "kt" => "Kotlin",
104 "swift" => "Swift",
105 "dart" => "Dart",
106 "elixir" | "ex" => "Elixir",
107 "erlang" | "erl" => "Erlang",
108 "haskell" | "hs" => "Haskell",
109 "clojure" | "clj" => "Clojure",
110 "fsharp" | "fs" => "F#",
111 "ocaml" | "ml" => "OCaml",
112 "elm" => "Elm",
113 "purescript" | "purs" => "PureScript",
114 "reason" | "re" => "Reason",
115 "nix" => "Nix",
116 "dockerfile" => "Dockerfile",
117 "makefile" => "Makefile",
118 "cmake" => "CMake",
119 "gradle" => "Gradle",
120 "groovy" => "Groovy",
121 "powershell" | "ps1" => "PowerShell",
122 "vue" => "Vue",
123 "svelte" => "Svelte",
124 "solidity" | "sol" => "Solidity",
125 "asm" | "assembly" | "nasm" => "Assembly",
126 "verilog" => "Verilog",
127 "vhdl" => "VHDL",
128 "matlab" => "MATLAB",
129 "julia" => "Julia",
130 "nim" => "Nim",
131 "racket" => "Racket",
132 "scheme" => "Scheme",
133 "lisp" | "cl" => "Lisp",
134 "commonlisp" => "Common Lisp",
135 "cobol" => "COBOL",
136 "fortran" => "Fortran",
137 "pascal" => "Pascal",
138 "ada" => "Ada",
139 "crystal" => "Crystal",
140 "wren" => "Wren",
141 "zig" => "Zig",
142 "v" => "V",
143 "odin" => "Odin",
144 "gleam" => "Gleam",
145 _ => {
146 if let Some(syntax) = self.syntax_set.find_syntax_by_token(lang) {
148 return syntax.clone();
149 }
150 return self.syntax_set.find_syntax_plain_text().clone();
152 }
153 };
154
155 self.syntax_set
156 .find_syntax_by_token(token)
157 .unwrap_or_else(|| self.syntax_set.find_syntax_plain_text())
158 .clone()
159 }
160
161 fn syntect_color_to_ratatui(color: &syntect::highlighting::Color) -> Color {
163 Color::Rgb(color.r, color.g, color.b)
164 }
165
166 pub fn highlight_to_spans(
168 &self,
169 code: &str,
170 lang: &str,
171 ) -> Result<Vec<Vec<Span<'static>>>, HighlightError> {
172 let syntax = self.detect_language(lang);
173 let mut highlighter = HighlightLines::new(&syntax, &self.theme);
174
175 let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
176
177 for line in LinesWithEndings::from(code) {
178 let ranges = highlighter
179 .highlight_line(line, &self.syntax_set)
180 .map_err(|e| HighlightError::Parse(e.to_string()))?;
181
182 let spans: Vec<Span<'static>> = ranges
183 .iter()
184 .map(|(style, text)| -> Span<'static> {
185 let fg = Self::syntect_color_to_ratatui(&style.foreground);
186 let content: String = text.to_string();
187 Span::styled(content, RatatuiStyle::default().fg(fg))
188 })
189 .collect();
190
191 lines.push(spans);
192 }
193
194 Ok(lines)
195 }
196
197 pub fn theme_name(&self) -> &str {
199 "base16-ocean.dark"
200 }
201}
202
203impl Default for SyntaxHighlighter {
204 fn default() -> Self {
205 Self::new().expect("Failed to initialize syntax highlighter")
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 #[test]
214 fn test_highlighter_new() {
215 let highlighter = SyntaxHighlighter::new();
216 assert!(highlighter.is_ok());
217 }
218
219 #[test]
220 fn test_highlight_rust_code() {
221 let highlighter = SyntaxHighlighter::new().unwrap();
222 let code = r#"fn main() {
223 println!("Hello, world!");
224}"#;
225
226 let result = highlighter.highlight_to_spans(code, "rust");
227 assert!(result.is_ok());
228 let lines = result.unwrap();
229 assert!(!lines.is_empty());
230 }
231
232 #[test]
233 fn test_highlight_python_code() {
234 let highlighter = SyntaxHighlighter::new().unwrap();
235 let code = "def hello():\n print('world')";
236
237 let result = highlighter.highlight_to_spans(code, "python");
238 assert!(result.is_ok());
239 }
240
241 #[test]
242 fn test_detect_languages() {
243 let highlighter = SyntaxHighlighter::new().unwrap();
244
245 let rust_syntax = highlighter.detect_language("rust");
246 assert_eq!(rust_syntax.name, "Rust");
247
248 let py_syntax = highlighter.detect_language("python");
249 assert_eq!(py_syntax.name, "Python");
250
251 let js_syntax = highlighter.detect_language("javascript");
252 assert_eq!(js_syntax.name, "JavaScript");
253
254 let plain = highlighter.detect_language("unknown");
255 assert_eq!(plain.name, "Plain Text");
256 }
257
258 #[test]
259 fn test_empty_code() {
260 let highlighter = SyntaxHighlighter::new().unwrap();
261 let empty_code = "";
262
263 let result = highlighter.highlight_to_spans(empty_code, "rust");
264 assert!(result.is_ok());
265 assert!(result.unwrap().is_empty());
266 }
267
268 #[test]
269 fn test_multiline_code() {
270 let highlighter = SyntaxHighlighter::new().unwrap();
271 let code = r#"fn main() {
272 let x = 42;
273 println!("{}", x);
274}"#;
275
276 let result = highlighter.highlight_to_spans(code, "rust");
277 assert!(result.is_ok());
278 let lines = result.unwrap();
279 assert_eq!(lines.len(), 4); }
281
282 #[test]
283 fn test_language_aliases() {
284 let highlighter = SyntaxHighlighter::new().unwrap();
285
286 assert_eq!(highlighter.detect_language("rs").name, "Rust");
287 assert_eq!(highlighter.detect_language("py").name, "Python");
288 assert_eq!(highlighter.detect_language("js").name, "JavaScript");
289 assert_eq!(highlighter.detect_language("ts").name, "TypeScript");
290 }
291
292 #[test]
293 fn test_with_theme_valid() {
294 let highlighter = SyntaxHighlighter::with_theme("Solarized (dark)");
295 assert!(highlighter.is_ok());
296 }
297
298 #[test]
299 fn test_with_theme_invalid() {
300 let highlighter = SyntaxHighlighter::with_theme("invalid-theme");
301 assert!(highlighter.is_err());
302 }
303}