Skip to main content

reflex/
formatter.rs

1//! Terminal output formatting for query results
2//!
3//! This module provides beautiful, syntax-highlighted terminal output using ratatui.
4//! It supports both static output (print and exit) and prepares for future interactive mode.
5
6use anyhow::Result;
7use crossterm::tty::IsTty;
8use crossterm::terminal;
9use std::collections::HashMap;
10use std::io;
11use syntect::easy::HighlightLines;
12use syntect::highlighting::{Style as SyntectStyle, ThemeSet};
13use syntect::parsing::SyntaxSet;
14use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
15
16use crate::models::{DependencyInfo, Language, SearchResult, SymbolKind};
17
18/// Lazy-loaded syntax highlighting resources
19struct SyntaxHighlighter {
20    syntax_set: SyntaxSet,
21    theme_set: ThemeSet,
22}
23
24impl SyntaxHighlighter {
25    fn new() -> Self {
26        Self {
27            syntax_set: SyntaxSet::load_defaults_newlines(),
28            theme_set: ThemeSet::load_defaults(),
29        }
30    }
31
32    /// Get syntax reference for a language using extension-based lookup (most reliable)
33    ///
34    /// This uses file extensions to find syntaxes, which is more reliable than name-based
35    /// lookup because syntect (based on Sublime Text) primarily uses extension matching.
36    ///
37    /// For languages not in the default syntect set (TypeScript, Vue, Svelte), we fall back
38    /// to related syntaxes (JavaScript for TypeScript, HTML for Vue/Svelte).
39    fn get_syntax(&self, lang: &Language) -> Option<&syntect::parsing::SyntaxReference> {
40        let (extension, fallback_extension) = match lang {
41            Language::Rust => ("rs", None),
42            Language::Python => ("py", None),
43            Language::JavaScript => ("js", None),
44            Language::TypeScript => ("ts", Some("js")),  // Fallback to JavaScript
45            Language::Go => ("go", None),
46            Language::Java => ("java", None),
47            Language::C => ("c", None),
48            Language::Cpp => ("cpp", None),
49            Language::CSharp => ("cs", None),
50            Language::PHP => ("php", None),
51            Language::Ruby => ("rb", None),
52            Language::Kotlin => ("kt", None),
53            Language::Swift => ("swift", None),
54            Language::Zig => ("zig", None),
55            Language::Vue => ("vue", Some("html")),      // Fallback to HTML
56            Language::Svelte => ("svelte", Some("html")), // Fallback to HTML
57            Language::Unknown => return None,
58        };
59
60        // Try extension-based lookup first (most reliable)
61        self.syntax_set
62            .find_syntax_by_extension(extension)
63            .or_else(|| {
64                // Try token-based search (searches by extension then name)
65                self.syntax_set.find_syntax_by_token(extension)
66            })
67            .or_else(|| {
68                // If we have a fallback extension (for TypeScript, Vue, Svelte), try it
69                fallback_extension.and_then(|fallback| {
70                    self.syntax_set
71                        .find_syntax_by_extension(fallback)
72                        .or_else(|| self.syntax_set.find_syntax_by_token(fallback))
73                })
74            })
75    }
76}
77
78// Global syntax highlighter (initialized on first use)
79use std::sync::OnceLock;
80static SYNTAX_HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
81
82fn get_syntax_highlighter() -> &'static SyntaxHighlighter {
83    SYNTAX_HIGHLIGHTER.get_or_init(SyntaxHighlighter::new)
84}
85
86/// Output formatter configuration
87pub struct OutputFormatter {
88    /// Whether to use colors and formatting
89    pub use_colors: bool,
90    /// Whether to use syntax highlighting
91    pub use_syntax_highlighting: bool,
92    /// Terminal width for full-width separators
93    terminal_width: u16,
94}
95
96impl OutputFormatter {
97    /// Create a new formatter with automatic TTY detection
98    pub fn new(plain: bool) -> Self {
99        let is_tty = io::stdout().is_tty();
100        let no_color = std::env::var("NO_COLOR").is_ok();
101
102        let use_colors = !plain && !no_color && is_tty;
103
104        // Get terminal width, default to 80 if detection fails
105        let terminal_width = terminal::size().map(|(w, _)| w).unwrap_or(80);
106
107        Self {
108            use_colors,
109            use_syntax_highlighting: use_colors, // Enable syntax highlighting if colors enabled
110            terminal_width,
111        }
112    }
113
114    /// Format and print search results to stdout
115    pub fn format_results(&self, results: &[SearchResult], pattern: &str) -> Result<()> {
116        if results.is_empty() {
117            println!("No results found.");
118            return Ok(());
119        }
120
121        // Group results by file
122        let grouped = self.group_by_file(results);
123
124        // Print each file group
125        for (idx, (file_path, file_results)) in grouped.iter().enumerate() {
126            self.print_file_group(file_path, file_results, pattern, idx == grouped.len() - 1)?;
127        }
128
129        Ok(())
130    }
131
132    /// Group results by file path
133    fn group_by_file<'a>(&self, results: &'a [SearchResult]) -> Vec<(String, Vec<&'a SearchResult>)> {
134        let mut grouped: HashMap<String, Vec<&'a SearchResult>> = HashMap::new();
135
136        for result in results {
137            grouped
138                .entry(result.path.clone())
139                .or_default()
140                .push(result);
141        }
142
143        // Convert to sorted vec (by file path)
144        let mut grouped_vec: Vec<_> = grouped.into_iter().collect();
145        grouped_vec.sort_by(|a, b| a.0.cmp(&b.0));
146
147        grouped_vec
148    }
149
150    /// Print a group of results for a single file
151    fn print_file_group(
152        &self,
153        file_path: &str,
154        results: &[&SearchResult],
155        pattern: &str,
156        is_last_file: bool,
157    ) -> Result<()> {
158        // Print file header
159        self.print_file_header(file_path, results.len())?;
160
161        // Print each result
162        for (idx, result) in results.iter().enumerate() {
163            let is_last_in_file = idx == results.len() - 1;
164            let is_last_overall = is_last_file && is_last_in_file;
165            self.print_result(result, pattern, is_last_overall)?;
166        }
167
168        // Add spacing between file groups (but not after the last file)
169        if !is_last_file {
170            println!();
171        }
172
173        Ok(())
174    }
175
176    /// Print file header with match count
177    fn print_file_header(&self, file_path: &str, count: usize) -> Result<()> {
178        if self.use_colors {
179            // Colorized header with file icon
180            println!(
181                "  {} {} {}",
182                "📁".bright_blue(),
183                file_path.bright_cyan().bold(),
184                format!("({} {})", count, if count == 1 { "match" } else { "matches" })
185                    .dimmed()
186            );
187        } else {
188            // Plain text header
189            println!(
190                "  {} ({} {})",
191                file_path,
192                count,
193                if count == 1 { "match" } else { "matches" }
194            );
195        }
196
197        Ok(())
198    }
199
200    /// Print a single search result
201    fn print_result(&self, result: &SearchResult, pattern: &str, is_last: bool) -> Result<()> {
202        // Format line number (right-aligned to 4 digits)
203        let line_no = format!("{:>4}", result.span.start_line);
204
205        // Get symbol badge if available
206        let symbol_badge = self.format_symbol_badge(&result.kind, result.symbol.as_deref());
207
208        // Print the line with result
209        if self.use_colors {
210            // Line number and symbol badge
211            println!(
212                "    {} {}",
213                line_no.yellow(),
214                symbol_badge
215            );
216
217            // Print code preview with syntax highlighting (indented)
218            let highlighted = self.highlight_code(&result.preview, &result.lang, pattern);
219            println!("        {}", highlighted);
220
221            // Print internal dependencies if available
222            if let Some(deps_formatted) = self.format_internal_dependencies(&result.dependencies) {
223                println!();
224                println!("        {}", "Dependencies:".dimmed());
225                for dep in deps_formatted {
226                    println!("          {}", dep.bright_magenta());
227                }
228            }
229
230            // Add separator line between results (except for the very last one)
231            if !is_last {
232                let separator_width = self.terminal_width.saturating_sub(2) as usize;
233                println!("  {}", "─".repeat(separator_width).truecolor(60, 60, 60));
234            }
235        } else {
236            // Plain text output
237            println!("    {} {}", line_no, symbol_badge);
238            println!("        {}", result.preview);
239
240            // Print internal dependencies if available
241            if let Some(deps_formatted) = self.format_internal_dependencies(&result.dependencies) {
242                println!();
243                println!("        Dependencies:");
244                for dep in deps_formatted {
245                    println!("          {}", dep);
246                }
247            }
248
249            // Add separator line between results (except for the very last one)
250            if !is_last {
251                let separator_width = self.terminal_width.saturating_sub(2) as usize;
252                println!("  {}", "─".repeat(separator_width));
253            }
254        }
255
256        Ok(())
257    }
258
259    /// Format dependencies for display
260    /// Returns None if no dependencies exist
261    /// Note: Database only contains internal dependencies (external/stdlib filtered during indexing)
262    fn format_internal_dependencies(&self, dependencies: &Option<Vec<DependencyInfo>>) -> Option<Vec<String>> {
263        dependencies.as_ref().and_then(|deps| {
264            let dep_paths: Vec<String> = deps
265                .iter()
266                .map(|dep| dep.path.clone())
267                .collect();
268
269            if dep_paths.is_empty() {
270                None
271            } else {
272                Some(dep_paths)
273            }
274        })
275    }
276
277    /// Format symbol kind badge
278    fn format_symbol_badge(&self, kind: &SymbolKind, symbol: Option<&str>) -> String {
279        let (kind_str, color_fn): (&str, fn(&str) -> String) = match kind {
280            SymbolKind::Function => ("fn", |s| s.green().to_string()),
281            SymbolKind::Class => ("class", |s| s.blue().to_string()),
282            SymbolKind::Struct => ("struct", |s| s.cyan().to_string()),
283            SymbolKind::Enum => ("enum", |s| s.magenta().to_string()),
284            SymbolKind::Trait => ("trait", |s| s.yellow().to_string()),
285            SymbolKind::Interface => ("interface", |s| s.blue().to_string()),
286            SymbolKind::Method => ("method", |s| s.green().to_string()),
287            SymbolKind::Constant => ("const", |s| s.red().to_string()),
288            SymbolKind::Variable => ("var", |s| s.white().to_string()),
289            SymbolKind::Module => ("mod", |s| s.bright_magenta().to_string()),
290            SymbolKind::Namespace => ("namespace", |s| s.bright_magenta().to_string()),
291            SymbolKind::Type => ("type", |s| s.cyan().to_string()),
292            SymbolKind::Macro => ("macro", |s| s.bright_yellow().to_string()),
293            SymbolKind::Property => ("property", |s| s.bright_green().to_string()),
294            SymbolKind::Event => ("event", |s| s.bright_red().to_string()),
295            SymbolKind::Import => ("import", |s| s.bright_blue().to_string()),
296            SymbolKind::Export => ("export", |s| s.bright_blue().to_string()),
297            SymbolKind::Attribute => ("attribute", |s| s.bright_yellow().to_string()),
298            SymbolKind::Unknown(_) => ("", |s| s.white().to_string()),
299        };
300
301        if self.use_colors && !kind_str.is_empty() {
302            if let Some(sym) = symbol {
303                format!("{} {}", color_fn(&format!("[{}]", kind_str)), sym.bold())
304            } else {
305                color_fn(&format!("[{}]", kind_str))
306            }
307        } else if !kind_str.is_empty() {
308            if let Some(sym) = symbol {
309                format!("[{}] {}", kind_str, sym)
310            } else {
311                format!("[{}]", kind_str)
312            }
313        } else {
314            symbol.unwrap_or("").to_string()
315        }
316    }
317
318    /// Highlight code with syntax highlighting
319    fn highlight_code(&self, code: &str, lang: &Language, pattern: &str) -> String {
320        if !self.use_syntax_highlighting {
321            return code.to_string();
322        }
323
324        let highlighter = get_syntax_highlighter();
325
326        // Try to get syntax for the language
327        let syntax = match highlighter.get_syntax(lang) {
328            Some(s) => s,
329            None => {
330                // Fallback: highlight the pattern match manually
331                return self.highlight_pattern(code, pattern);
332            }
333        };
334
335        // Get theme - try Monokai Extended, fall back to base16-ocean.dark or first available
336        let theme = highlighter.theme_set.themes.get("Monokai Extended")
337            .or_else(|| highlighter.theme_set.themes.get("base16-ocean.dark"))
338            .or_else(|| highlighter.theme_set.themes.values().next())
339            .expect("No themes available in syntect");
340
341        let mut output = String::new();
342        let mut h = HighlightLines::new(syntax, theme);
343
344        for line in LinesWithEndings::from(code) {
345            let ranges: Vec<(SyntectStyle, &str)> = h.highlight_line(line, &highlighter.syntax_set).unwrap_or_default();
346            let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
347            output.push_str(&escaped);
348        }
349
350        // Reset colors to prevent bleeding into subsequent output
351        output.push_str("\x1b[0m");
352
353        output
354    }
355
356    /// Fallback: manually highlight pattern matches in code
357    fn highlight_pattern(&self, code: &str, pattern: &str) -> String {
358        if pattern.is_empty() || !self.use_colors {
359            return code.to_string();
360        }
361
362        // Simple substring highlighting (case-sensitive)
363        if let Some(pos) = code.find(pattern) {
364            let before = &code[..pos];
365            let matched = &code[pos..pos + pattern.len()];
366            let after = &code[pos + pattern.len()..];
367
368            format!(
369                "{}{}{}",
370                before,
371                matched.black().on_yellow().bold(),
372                after
373            )
374        } else {
375            code.to_string()
376        }
377    }
378}
379
380// Import color trait extensions
381use owo_colors::OwoColorize;
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386    use crate::models::Span;
387
388    #[test]
389    fn test_formatter_creation() {
390        // Set NO_COLOR to ensure deterministic test behavior regardless of TTY
391        unsafe {
392            std::env::set_var("NO_COLOR", "1");
393        }
394        let formatter = OutputFormatter::new(false);
395        // With NO_COLOR set, colors should always be disabled
396        assert!(!formatter.use_colors);
397        unsafe {
398            std::env::remove_var("NO_COLOR");
399        }
400    }
401
402    #[test]
403    fn test_plain_mode() {
404        let formatter = OutputFormatter::new(true);
405        assert!(!formatter.use_colors);
406        assert!(!formatter.use_syntax_highlighting);
407    }
408
409    #[test]
410    fn test_group_by_file() {
411        let formatter = OutputFormatter::new(true);
412
413        let results = vec![
414            SearchResult {
415                path: "a.rs".to_string(),
416                lang: Language::Rust,
417                kind: SymbolKind::Function,
418                symbol: Some("foo".to_string()),
419                span: Span {
420                    start_line: 1,
421                    end_line: 1,
422                },
423                preview: "fn foo() {}".to_string(),
424                dependencies: None,
425            },
426            SearchResult {
427                path: "a.rs".to_string(),
428                lang: Language::Rust,
429                kind: SymbolKind::Function,
430                symbol: Some("bar".to_string()),
431                span: Span {
432                    start_line: 2,
433                    end_line: 2,
434                },
435                preview: "fn bar() {}".to_string(),
436                dependencies: None,
437            },
438            SearchResult {
439                path: "b.rs".to_string(),
440                lang: Language::Rust,
441                kind: SymbolKind::Function,
442                symbol: Some("baz".to_string()),
443                span: Span {
444                    start_line: 1,
445                    end_line: 1,
446                },
447                preview: "fn baz() {}".to_string(),
448                dependencies: None,
449            },
450        ];
451
452        let grouped = formatter.group_by_file(&results);
453
454        assert_eq!(grouped.len(), 2);
455        assert_eq!(grouped[0].0, "a.rs");
456        assert_eq!(grouped[0].1.len(), 2);
457        assert_eq!(grouped[1].0, "b.rs");
458        assert_eq!(grouped[1].1.len(), 1);
459    }
460
461    #[test]
462    fn test_symbol_badge_formatting() {
463        let formatter = OutputFormatter::new(true);
464
465        let badge = formatter.format_symbol_badge(&SymbolKind::Function, Some("test"));
466        assert_eq!(badge, "[fn] test");
467
468        let badge = formatter.format_symbol_badge(&SymbolKind::Class, None);
469        assert_eq!(badge, "[class]");
470    }
471}