1use 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
18struct 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 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")), 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")), Language::Svelte => ("svelte", Some("html")), Language::Unknown => return None,
58 };
59
60 self.syntax_set
62 .find_syntax_by_extension(extension)
63 .or_else(|| {
64 self.syntax_set.find_syntax_by_token(extension)
66 })
67 .or_else(|| {
68 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
78use 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
86pub struct OutputFormatter {
88 pub use_colors: bool,
90 pub use_syntax_highlighting: bool,
92 terminal_width: u16,
94}
95
96impl OutputFormatter {
97 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 let terminal_width = terminal::size().map(|(w, _)| w).unwrap_or(80);
106
107 Self {
108 use_colors,
109 use_syntax_highlighting: use_colors, terminal_width,
111 }
112 }
113
114 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 let grouped = self.group_by_file(results);
123
124 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 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 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 fn print_file_group(
152 &self,
153 file_path: &str,
154 results: &[&SearchResult],
155 pattern: &str,
156 is_last_file: bool,
157 ) -> Result<()> {
158 self.print_file_header(file_path, results.len())?;
160
161 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 if !is_last_file {
170 println!();
171 }
172
173 Ok(())
174 }
175
176 fn print_file_header(&self, file_path: &str, count: usize) -> Result<()> {
178 if self.use_colors {
179 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 println!(
190 " {} ({} {})",
191 file_path,
192 count,
193 if count == 1 { "match" } else { "matches" }
194 );
195 }
196
197 Ok(())
198 }
199
200 fn print_result(&self, result: &SearchResult, pattern: &str, is_last: bool) -> Result<()> {
202 let line_no = format!("{:>4}", result.span.start_line);
204
205 let symbol_badge = self.format_symbol_badge(&result.kind, result.symbol.as_deref());
207
208 if self.use_colors {
210 println!(
212 " {} {}",
213 line_no.yellow(),
214 symbol_badge
215 );
216
217 let highlighted = self.highlight_code(&result.preview, &result.lang, pattern);
219 println!(" {}", highlighted);
220
221 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 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 println!(" {} {}", line_no, symbol_badge);
238 println!(" {}", result.preview);
239
240 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 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 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 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 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 let syntax = match highlighter.get_syntax(lang) {
328 Some(s) => s,
329 None => {
330 return self.highlight_pattern(code, pattern);
332 }
333 };
334
335 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 output.push_str("\x1b[0m");
352
353 output
354 }
355
356 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 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
380use 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 unsafe {
392 std::env::set_var("NO_COLOR", "1");
393 }
394 let formatter = OutputFormatter::new(false);
395 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}