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