use anyhow::Result;
use crossterm::tty::IsTty;
use crossterm::terminal;
use std::collections::HashMap;
use std::io;
use syntect::easy::HighlightLines;
use syntect::highlighting::{Style as SyntectStyle, ThemeSet};
use syntect::parsing::SyntaxSet;
use syntect::util::{as_24_bit_terminal_escaped, LinesWithEndings};
use crate::models::{DependencyInfo, Language, SearchResult, SymbolKind};
struct SyntaxHighlighter {
syntax_set: SyntaxSet,
theme_set: ThemeSet,
}
impl SyntaxHighlighter {
fn new() -> Self {
Self {
syntax_set: SyntaxSet::load_defaults_newlines(),
theme_set: ThemeSet::load_defaults(),
}
}
fn get_syntax(&self, lang: &Language) -> Option<&syntect::parsing::SyntaxReference> {
let (extension, fallback_extension) = match lang {
Language::Rust => ("rs", None),
Language::Python => ("py", None),
Language::JavaScript => ("js", None),
Language::TypeScript => ("ts", Some("js")), Language::Go => ("go", None),
Language::Java => ("java", None),
Language::C => ("c", None),
Language::Cpp => ("cpp", None),
Language::CSharp => ("cs", None),
Language::PHP => ("php", None),
Language::Ruby => ("rb", None),
Language::Kotlin => ("kt", None),
Language::Swift => ("swift", None),
Language::Zig => ("zig", None),
Language::Vue => ("vue", Some("html")), Language::Svelte => ("svelte", Some("html")), Language::Unknown => return None,
};
self.syntax_set
.find_syntax_by_extension(extension)
.or_else(|| {
self.syntax_set.find_syntax_by_token(extension)
})
.or_else(|| {
fallback_extension.and_then(|fallback| {
self.syntax_set
.find_syntax_by_extension(fallback)
.or_else(|| self.syntax_set.find_syntax_by_token(fallback))
})
})
}
}
use std::sync::OnceLock;
static SYNTAX_HIGHLIGHTER: OnceLock<SyntaxHighlighter> = OnceLock::new();
fn get_syntax_highlighter() -> &'static SyntaxHighlighter {
SYNTAX_HIGHLIGHTER.get_or_init(SyntaxHighlighter::new)
}
pub struct OutputFormatter {
pub use_colors: bool,
pub use_syntax_highlighting: bool,
terminal_width: u16,
}
impl OutputFormatter {
pub fn new(plain: bool) -> Self {
let is_tty = io::stdout().is_tty();
let no_color = std::env::var("NO_COLOR").is_ok();
let use_colors = !plain && !no_color && is_tty;
let terminal_width = terminal::size().map(|(w, _)| w).unwrap_or(80);
Self {
use_colors,
use_syntax_highlighting: use_colors, terminal_width,
}
}
pub fn format_results(&self, results: &[SearchResult], pattern: &str) -> Result<()> {
if results.is_empty() {
println!("No results found.");
return Ok(());
}
let grouped = self.group_by_file(results);
for (idx, (file_path, file_results)) in grouped.iter().enumerate() {
self.print_file_group(file_path, file_results, pattern, idx == grouped.len() - 1)?;
}
Ok(())
}
fn group_by_file<'a>(&self, results: &'a [SearchResult]) -> Vec<(String, Vec<&'a SearchResult>)> {
let mut grouped: HashMap<String, Vec<&'a SearchResult>> = HashMap::new();
for result in results {
grouped
.entry(result.path.clone())
.or_default()
.push(result);
}
let mut grouped_vec: Vec<_> = grouped.into_iter().collect();
grouped_vec.sort_by(|a, b| a.0.cmp(&b.0));
grouped_vec
}
fn print_file_group(
&self,
file_path: &str,
results: &[&SearchResult],
pattern: &str,
is_last_file: bool,
) -> Result<()> {
self.print_file_header(file_path, results.len())?;
for (idx, result) in results.iter().enumerate() {
let is_last_in_file = idx == results.len() - 1;
let is_last_overall = is_last_file && is_last_in_file;
self.print_result(result, pattern, is_last_overall)?;
}
if !is_last_file {
println!();
}
Ok(())
}
fn print_file_header(&self, file_path: &str, count: usize) -> Result<()> {
if self.use_colors {
println!(
" {} {} {}",
"📁".bright_blue(),
file_path.bright_cyan().bold(),
format!("({} {})", count, if count == 1 { "match" } else { "matches" })
.dimmed()
);
} else {
println!(
" {} ({} {})",
file_path,
count,
if count == 1 { "match" } else { "matches" }
);
}
Ok(())
}
fn print_result(&self, result: &SearchResult, pattern: &str, is_last: bool) -> Result<()> {
let line_no = format!("{:>4}", result.span.start_line);
let symbol_badge = self.format_symbol_badge(&result.kind, result.symbol.as_deref());
if self.use_colors {
println!(
" {} {}",
line_no.yellow(),
symbol_badge
);
let highlighted = self.highlight_code(&result.preview, &result.lang, pattern);
println!(" {}", highlighted);
if let Some(deps_formatted) = self.format_internal_dependencies(&result.dependencies) {
println!();
println!(" {}", "Dependencies:".dimmed());
for dep in deps_formatted {
println!(" {}", dep.bright_magenta());
}
}
if !is_last {
let separator_width = self.terminal_width.saturating_sub(2) as usize;
println!(" {}", "─".repeat(separator_width).truecolor(60, 60, 60));
}
} else {
println!(" {} {}", line_no, symbol_badge);
println!(" {}", result.preview);
if let Some(deps_formatted) = self.format_internal_dependencies(&result.dependencies) {
println!();
println!(" Dependencies:");
for dep in deps_formatted {
println!(" {}", dep);
}
}
if !is_last {
let separator_width = self.terminal_width.saturating_sub(2) as usize;
println!(" {}", "─".repeat(separator_width));
}
}
Ok(())
}
fn format_internal_dependencies(&self, dependencies: &Option<Vec<DependencyInfo>>) -> Option<Vec<String>> {
dependencies.as_ref().and_then(|deps| {
let dep_paths: Vec<String> = deps
.iter()
.map(|dep| dep.path.clone())
.collect();
if dep_paths.is_empty() {
None
} else {
Some(dep_paths)
}
})
}
fn format_symbol_badge(&self, kind: &SymbolKind, symbol: Option<&str>) -> String {
let (kind_str, color_fn): (&str, fn(&str) -> String) = match kind {
SymbolKind::Function => ("fn", |s| s.green().to_string()),
SymbolKind::Class => ("class", |s| s.blue().to_string()),
SymbolKind::Struct => ("struct", |s| s.cyan().to_string()),
SymbolKind::Enum => ("enum", |s| s.magenta().to_string()),
SymbolKind::Trait => ("trait", |s| s.yellow().to_string()),
SymbolKind::Interface => ("interface", |s| s.blue().to_string()),
SymbolKind::Method => ("method", |s| s.green().to_string()),
SymbolKind::Constant => ("const", |s| s.red().to_string()),
SymbolKind::Variable => ("var", |s| s.white().to_string()),
SymbolKind::Module => ("mod", |s| s.bright_magenta().to_string()),
SymbolKind::Namespace => ("namespace", |s| s.bright_magenta().to_string()),
SymbolKind::Type => ("type", |s| s.cyan().to_string()),
SymbolKind::Macro => ("macro", |s| s.bright_yellow().to_string()),
SymbolKind::Property => ("property", |s| s.bright_green().to_string()),
SymbolKind::Event => ("event", |s| s.bright_red().to_string()),
SymbolKind::Import => ("import", |s| s.bright_blue().to_string()),
SymbolKind::Export => ("export", |s| s.bright_blue().to_string()),
SymbolKind::Attribute => ("attribute", |s| s.bright_yellow().to_string()),
SymbolKind::Unknown(_) => ("", |s| s.white().to_string()),
};
if self.use_colors && !kind_str.is_empty() {
if let Some(sym) = symbol {
format!("{} {}", color_fn(&format!("[{}]", kind_str)), sym.bold())
} else {
color_fn(&format!("[{}]", kind_str))
}
} else if !kind_str.is_empty() {
if let Some(sym) = symbol {
format!("[{}] {}", kind_str, sym)
} else {
format!("[{}]", kind_str)
}
} else {
symbol.unwrap_or("").to_string()
}
}
fn highlight_code(&self, code: &str, lang: &Language, pattern: &str) -> String {
if !self.use_syntax_highlighting {
return code.to_string();
}
let highlighter = get_syntax_highlighter();
let syntax = match highlighter.get_syntax(lang) {
Some(s) => s,
None => {
return self.highlight_pattern(code, pattern);
}
};
let theme = highlighter.theme_set.themes.get("Monokai Extended")
.or_else(|| highlighter.theme_set.themes.get("base16-ocean.dark"))
.or_else(|| highlighter.theme_set.themes.values().next())
.expect("No themes available in syntect");
let mut output = String::new();
let mut h = HighlightLines::new(syntax, theme);
for line in LinesWithEndings::from(code) {
let ranges: Vec<(SyntectStyle, &str)> = h.highlight_line(line, &highlighter.syntax_set).unwrap_or_default();
let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
output.push_str(&escaped);
}
output.push_str("\x1b[0m");
output
}
fn highlight_pattern(&self, code: &str, pattern: &str) -> String {
if pattern.is_empty() || !self.use_colors {
return code.to_string();
}
if let Some(pos) = code.find(pattern) {
let before = &code[..pos];
let matched = &code[pos..pos + pattern.len()];
let after = &code[pos + pattern.len()..];
format!(
"{}{}{}",
before,
matched.black().on_yellow().bold(),
after
)
} else {
code.to_string()
}
}
}
use owo_colors::OwoColorize;
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Span;
#[test]
fn test_formatter_creation() {
unsafe {
std::env::set_var("NO_COLOR", "1");
}
let formatter = OutputFormatter::new(false);
assert!(!formatter.use_colors);
unsafe {
std::env::remove_var("NO_COLOR");
}
}
#[test]
fn test_plain_mode() {
let formatter = OutputFormatter::new(true);
assert!(!formatter.use_colors);
assert!(!formatter.use_syntax_highlighting);
}
#[test]
fn test_group_by_file() {
let formatter = OutputFormatter::new(true);
let results = vec![
SearchResult {
path: "a.rs".to_string(),
lang: Language::Rust,
kind: SymbolKind::Function,
symbol: Some("foo".to_string()),
span: Span {
start_line: 1,
end_line: 1,
},
preview: "fn foo() {}".to_string(),
dependencies: None,
},
SearchResult {
path: "a.rs".to_string(),
lang: Language::Rust,
kind: SymbolKind::Function,
symbol: Some("bar".to_string()),
span: Span {
start_line: 2,
end_line: 2,
},
preview: "fn bar() {}".to_string(),
dependencies: None,
},
SearchResult {
path: "b.rs".to_string(),
lang: Language::Rust,
kind: SymbolKind::Function,
symbol: Some("baz".to_string()),
span: Span {
start_line: 1,
end_line: 1,
},
preview: "fn baz() {}".to_string(),
dependencies: None,
},
];
let grouped = formatter.group_by_file(&results);
assert_eq!(grouped.len(), 2);
assert_eq!(grouped[0].0, "a.rs");
assert_eq!(grouped[0].1.len(), 2);
assert_eq!(grouped[1].0, "b.rs");
assert_eq!(grouped[1].1.len(), 1);
}
#[test]
fn test_symbol_badge_formatting() {
let formatter = OutputFormatter::new(true);
let badge = formatter.format_symbol_badge(&SymbolKind::Function, Some("test"));
assert_eq!(badge, "[fn] test");
let badge = formatter.format_symbol_badge(&SymbolKind::Class, None);
assert_eq!(badge, "[class]");
}
}