Skip to main content

limit_tui/
syntax.rs

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/// Syntax highlighting errors
12#[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/// Syntax highlighter for TUI with ratatui support
25#[derive(Debug, Clone)]
26pub struct SyntaxHighlighter {
27    syntax_set: SyntaxSet,
28    theme: Theme,
29}
30
31impl SyntaxHighlighter {
32    /// Load default syntax set and theme
33    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    /// Load with a custom theme from the built-in theme set
42    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    /// List available built-in themes
56    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    /// Detect and return syntax reference for a language identifier
69    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            // Note: Default syntect syntax set doesn't have TypeScript, use JavaScript
76            "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                // Try to find by token directly
148                if let Some(syntax) = self.syntax_set.find_syntax_by_token(lang) {
149                    return syntax.clone();
150                }
151                // Fallback to plain text
152                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    /// Convert syntect color to ratatui Color
163    fn syntect_color_to_ratatui(color: &syntect::highlighting::Color) -> Color {
164        Color::Rgb(color.r, color.g, color.b)
165    }
166
167    /// Highlight code and return spans for TUI rendering
168    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    /// Get theme name
199    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); // 4 lines of code
281    }
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        // Note: Default syntect doesn't have TypeScript, so "ts" falls back to JavaScript
291        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}