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            "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                // Try to find by token directly
147                if let Some(syntax) = self.syntax_set.find_syntax_by_token(lang) {
148                    return syntax.clone();
149                }
150                // Fallback to plain text
151                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    /// Convert syntect color to ratatui Color
162    fn syntect_color_to_ratatui(color: &syntect::highlighting::Color) -> Color {
163        Color::Rgb(color.r, color.g, color.b)
164    }
165
166    /// Highlight code and return spans for TUI rendering
167    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    /// Get theme name
198    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); // 4 lines of code
280    }
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}