watchdiff_tui/
highlight.rs

1use std::path::Path;
2use syntect::easy::HighlightLines;
3use syntect::highlighting::{ThemeSet, Style};
4use syntect::parsing::SyntaxSet;
5use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
6use ratatui::style::{Color, Modifier};
7
8pub struct SyntaxHighlighter {
9    syntax_set: SyntaxSet,
10    theme_set: ThemeSet,
11}
12
13impl Default for SyntaxHighlighter {
14    fn default() -> Self {
15        Self::new()
16    }
17}
18
19impl SyntaxHighlighter {
20    pub fn new() -> Self {
21        Self {
22            syntax_set: SyntaxSet::load_defaults_newlines(),
23            theme_set: ThemeSet::load_defaults(),
24        }
25    }
26
27    pub fn get_language_from_path<P: AsRef<Path>>(&self, path: P) -> Option<String> {
28        let path = path.as_ref();
29        
30        // First try by file extension
31        if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
32            if let Some(syntax) = self.syntax_set.find_syntax_by_extension(ext) {
33                return Some(syntax.name.clone());
34            }
35        }
36        
37        // Then try by filename
38        if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
39            if let Some(syntax) = self.syntax_set.find_syntax_by_name(filename) {
40                return Some(syntax.name.clone());
41            }
42            
43            // Handle special cases
44            match filename.to_lowercase().as_str() {
45                "dockerfile" => return Some("Dockerfile".to_string()),
46                "makefile" => return Some("Makefile".to_string()),
47                "cargo.toml" | "pyproject.toml" => return Some("TOML".to_string()),
48                "package.json" => return Some("JSON".to_string()),
49                _ => {}
50            }
51        }
52        
53        // Try by first line (for shebangs)
54        None
55    }
56
57    pub fn highlight_line(&self, line: &str, language: &str, _line_number: usize) -> Vec<(ratatui::style::Style, String)> {
58        let syntax = match self.syntax_set.find_syntax_by_name(language) {
59            Some(syntax) => syntax,
60            None => return vec![(ratatui::style::Style::default(), line.to_string())],
61        };
62
63        let theme = match self.theme_set.themes.get("base16-ocean.dark") {
64            Some(theme) => theme,
65            None => &self.theme_set.themes["InspiredGitHub"],
66        };
67
68        let mut highlighter = HighlightLines::new(syntax, theme);
69        
70        match highlighter.highlight_line(line, &self.syntax_set) {
71            Ok(ranges) => {
72                let mut result = Vec::new();
73                for (style, text) in ranges {
74                    let ratatui_style = self.convert_syntect_style_to_ratatui(style);
75                    result.push((ratatui_style, text.to_string()));
76                }
77                result
78            }
79            Err(_) => vec![(ratatui::style::Style::default(), line.to_string())],
80        }
81    }
82
83    pub fn highlight_code(&self, code: &str, language: &str) -> Vec<Vec<(ratatui::style::Style, String)>> {
84        let syntax = match self.syntax_set.find_syntax_by_name(language) {
85            Some(syntax) => syntax,
86            None => return code.lines().map(|line| vec![(ratatui::style::Style::default(), line.to_string())]).collect(),
87        };
88
89        let theme = match self.theme_set.themes.get("base16-ocean.dark") {
90            Some(theme) => theme,
91            None => &self.theme_set.themes["InspiredGitHub"],
92        };
93
94        let mut highlighter = HighlightLines::new(syntax, theme);
95        let mut result = Vec::new();
96
97        for line in LinesWithEndings::from(code) {
98            match highlighter.highlight_line(line, &self.syntax_set) {
99                Ok(ranges) => {
100                    let mut line_result = Vec::new();
101                    for (style, text) in ranges {
102                        let ratatui_style = self.convert_syntect_style_to_ratatui(style);
103                        line_result.push((ratatui_style, text.to_string()));
104                    }
105                    result.push(line_result);
106                }
107                Err(_) => {
108                    result.push(vec![(ratatui::style::Style::default(), line.to_string())]);
109                }
110            }
111        }
112
113        result
114    }
115
116    pub fn get_terminal_highlighted(&self, code: &str, language: &str) -> String {
117        let syntax = match self.syntax_set.find_syntax_by_name(language) {
118            Some(syntax) => syntax,
119            None => return code.to_string(),
120        };
121
122        let theme = match self.theme_set.themes.get("base16-ocean.dark") {
123            Some(theme) => theme,
124            None => &self.theme_set.themes["InspiredGitHub"],
125        };
126
127        let mut highlighter = HighlightLines::new(syntax, theme);
128        let mut result = String::new();
129
130        for line in LinesWithEndings::from(code) {
131            match highlighter.highlight_line(line, &self.syntax_set) {
132                Ok(ranges) => {
133                    let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
134                    result.push_str(&escaped);
135                }
136                Err(_) => {
137                    result.push_str(line);
138                }
139            }
140        }
141
142        result
143    }
144
145    fn convert_syntect_style_to_ratatui(&self, style: Style) -> ratatui::style::Style {
146        let mut ratatui_style = ratatui::style::Style::default();
147
148        // Convert foreground color
149        if style.foreground.a > 0 {
150            ratatui_style = ratatui_style.fg(Color::Rgb(
151                style.foreground.r,
152                style.foreground.g,
153                style.foreground.b,
154            ));
155        }
156
157        // Convert background color
158        if style.background.a > 0 {
159            ratatui_style = ratatui_style.bg(Color::Rgb(
160                style.background.r,
161                style.background.g,
162                style.background.b,
163            ));
164        }
165
166        // Convert font styles
167        if style.font_style.contains(syntect::highlighting::FontStyle::BOLD) {
168            ratatui_style = ratatui_style.add_modifier(Modifier::BOLD);
169        }
170        if style.font_style.contains(syntect::highlighting::FontStyle::ITALIC) {
171            ratatui_style = ratatui_style.add_modifier(Modifier::ITALIC);
172        }
173        if style.font_style.contains(syntect::highlighting::FontStyle::UNDERLINE) {
174            ratatui_style = ratatui_style.add_modifier(Modifier::UNDERLINED);
175        }
176
177        ratatui_style
178    }
179
180    pub fn get_common_languages() -> Vec<&'static str> {
181        vec![
182            "Rust",
183            "Python",
184            "JavaScript",
185            "TypeScript",
186            "Java",
187            "C",
188            "C++",
189            "C#",
190            "Go",
191            "Swift",
192            "Kotlin",
193            "PHP",
194            "Ruby",
195            "HTML",
196            "CSS",
197            "SCSS",
198            "JSON",
199            "YAML",
200            "TOML",
201            "XML",
202            "Markdown",
203            "Bash",
204            "Fish",
205            "Zsh",
206            "PowerShell",
207            "Dockerfile",
208            "Makefile",
209            "SQL",
210            "GraphQL",
211        ]
212    }
213}
214
215// Helper function to detect if a file is likely to be binary
216pub fn is_likely_text_file<P: AsRef<Path>>(path: P) -> bool {
217    let path = path.as_ref();
218    
219    if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
220        let ext = ext.to_lowercase();
221        matches!(ext.as_str(),
222            // Source code
223            "rs" | "py" | "js" | "ts" | "jsx" | "tsx" | "java" | "kt" | "swift" |
224            "go" | "c" | "cpp" | "cc" | "cxx" | "h" | "hpp" | "cs" | "php" | "rb" |
225            
226            // Web
227            "html" | "htm" | "css" | "scss" | "sass" | "less" | "vue" | "svelte" |
228            
229            // Config and data
230            "json" | "yaml" | "yml" | "toml" | "xml" | "ini" | "conf" | "config" |
231            "env" | "properties" | "cfg" | "plist" |
232            
233            // Documentation
234            "md" | "txt" | "rst" | "adoc" | "tex" | "rtf" |
235            
236            // Scripts
237            "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "cmd" |
238            
239            // Other
240            "sql" | "graphql" | "dockerfile" | "makefile" | "cmake" | "log"
241        )
242    } else {
243        // Files without extensions that are typically text
244        if let Some(filename) = path.file_name().and_then(|s| s.to_str()) {
245            matches!(filename.to_lowercase().as_str(),
246                "dockerfile" | "makefile" | "cmake" | "readme" | "license" | 
247                "changelog" | "authors" | "contributors" | "todo" | "news"
248            )
249        } else {
250            false
251        }
252    }
253}