use crate::commands::args::{Args, OutputMode, SearchPipelineSpec};
use crate::config::Config;
use crate::error::{LlaError, Result};
use crate::plugin::PluginManager;
use crate::theme::is_no_color;
use crate::utils::color::colorize_file_name;
use colored::*;
use ignore::WalkBuilder;
use lla_plugin_utils::syntax::CodeHighlighter;
use serde::Deserialize;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use unicode_width::UnicodeWidthChar;
fn build_exclude_args(config: &Config, root: &Path) -> Vec<String> {
let mut args = Vec::new();
for ex in &config.exclude_paths {
let pattern = if let Ok(rel) = ex.strip_prefix(root) {
format!("!{}/**", rel.to_string_lossy())
} else {
format!("!{}/**", ex.to_string_lossy())
};
args.push("--glob".to_string());
args.push(pattern);
}
args
}
fn build_name_filter_args(args_cfg: &Args) -> Vec<String> {
let mut args = Vec::new();
if let Some(filter_str) = &args_cfg.filter {
if filter_str.starts_with("glob:") {
let g = &filter_str[5..];
args.push("--glob".to_string());
args.push(g.to_string());
} else if filter_str.starts_with('.') {
let ext = &filter_str[1..];
args.push("--glob".to_string());
args.push(format!("**/*.{}", ext));
}
}
args
}
pub fn run_search(args: &Args, config: &Config, plugin_manager: &mut PluginManager) -> Result<()> {
crate::commands::jump::record_visit(&args.directory, config);
let pattern = match &args.search {
Some(p) => p,
None => return Err(LlaError::Other("--search requires a pattern".into())),
};
let root = Path::new(&args.directory);
if !root.exists() {
return Err(LlaError::Other(format!(
"Path not found: {}",
root.display()
)));
}
let mut cmd = Command::new("rg");
cmd.current_dir(root);
cmd.arg("--line-number");
cmd.arg("--column");
cmd.arg("--color");
cmd.arg("never");
cmd.arg("--context");
cmd.arg(args.search_context.to_string());
let (search_pattern, use_fixed_string) = if pattern.starts_with("regex:") {
(&pattern[6..], false)
} else {
(pattern.as_str(), true)
};
if use_fixed_string {
cmd.arg("--fixed-strings");
}
if !args.case_sensitive {
cmd.arg("-i");
}
if args.no_dotfiles && !args.almost_all && !args.dotfiles_only {
cmd.arg("--hidden"); cmd.arg("--glob");
cmd.arg("!**/.*");
} else {
cmd.arg("--hidden");
}
if args.dirs_only {
println!("No results (dirs-only with content search)");
return Ok(());
}
if args.no_files {
println!("No results (--no-files with content search)");
return Ok(());
}
for g in build_exclude_args(config, root) {
cmd.arg(g);
}
for g in build_name_filter_args(args) {
cmd.arg(g);
}
let _machine_output = matches!(
args.output_mode,
OutputMode::Json { .. } | OutputMode::Ndjson | OutputMode::Csv
);
cmd.arg("--json");
cmd.arg(search_pattern);
let mut paths: Vec<PathBuf> = Vec::new();
let mut walker = WalkBuilder::new(root);
walker.hidden(!args.almost_all && (args.no_dotfiles || config.filter.no_dotfiles));
walker.git_ignore(true).git_exclude(true).parents(true);
let walker = walker.build();
for dent in walker {
if let Ok(d) = dent {
let p = d.path();
if !config.exclude_paths.is_empty() {
let abs = p;
if config.exclude_paths.iter().any(|ex| abs.starts_with(ex)) {
continue;
}
}
if p.is_file() {
if args.files_only || (!args.no_files) {
paths.push(p.to_path_buf());
}
}
}
}
if paths.is_empty() {
cmd.arg(".");
} else {
for p in paths {
cmd.arg(p);
}
}
let output = cmd
.output()
.map_err(|e| LlaError::Other(format!("Failed to run ripgrep: {}", e)))?;
if !output.status.success() && output.stdout.is_empty() {
let err = String::from_utf8_lossy(&output.stderr);
return Err(LlaError::Other(format!("ripgrep error: {}", err.trim())));
}
let stdout_data = output.stdout;
let matches = collect_matches(&stdout_data);
match args.output_mode {
OutputMode::Human => render_pretty(&matches, args),
OutputMode::Json { .. } | OutputMode::Ndjson => {
let s = String::from_utf8_lossy(&stdout_data);
print!("{}", s);
Ok(())
}
OutputMode::Csv => render_csv(&matches),
}?;
if !args.search_pipelines.is_empty() {
run_search_pipelines(&matches, plugin_manager, &args.search_pipelines)?;
}
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "lowercase")]
enum RipgrepEvent {
Match {
data: RgMatch,
},
#[serde(other)]
Other,
}
#[derive(Debug, Deserialize, Clone)]
struct RgMatch {
path: RgText,
lines: RgLines,
line_number: usize,
submatches: Vec<RgSubMatch>,
}
#[derive(Debug, Deserialize, Clone)]
struct RgText {
text: String,
}
#[derive(Debug, Deserialize, Clone)]
struct RgLines {
text: String,
}
#[derive(Debug, Deserialize, Clone)]
struct RgSubMatch {
#[serde(rename = "match")]
_m: RgText,
start: usize,
end: usize,
}
fn render_pretty(matches: &[RgMatch], args: &Args) -> Result<()> {
use std::collections::BTreeMap;
let mut matches_by_file: BTreeMap<String, Vec<RgMatch>> = BTreeMap::new();
for data in matches.iter().cloned() {
matches_by_file
.entry(data.path.text.clone())
.or_default()
.push(data);
}
const TABSTOP: usize = 4;
for (file, list) in matches_by_file.iter_mut() {
list.sort_by_key(|m| m.line_number);
let path = std::path::Path::new(file);
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
let full_path = path.display();
if is_no_color() {
println!("\nFile: {} [{}]", file_name, full_path);
} else {
println!(
"\n{} {} {}",
"File:".bright_black().bold(),
colorize_file_name(path).bold(),
format!("[{}]", full_path).bright_black()
);
}
for m in list.iter() {
let start_line = m.line_number.saturating_sub(args.search_context);
let total_lines = args.search_context * 2 + 1;
let snippet = read_snippet(path, start_line, total_lines)?;
if snippet.is_empty() {
continue;
}
let expanded: Vec<String> = snippet.iter().map(|s| expand_tabs(s, TABSTOP)).collect();
let language = path.extension().and_then(|e| e.to_str()).unwrap_or("txt");
for (idx, line) in expanded.iter().enumerate() {
let ln = start_line + idx;
let prefix = format!("{:4} │ ", ln);
let mut to_highlight = line.clone();
to_highlight.push('\n');
let highlighted = CodeHighlighter::highlight(&to_highlight, language);
print!("{}{}", prefix, highlighted);
if ln == m.line_number {
let center_text = expand_tabs(&m.lines.text.replace('\n', ""), TABSTOP);
if !center_text.is_empty() && !m.submatches.is_empty() {
let width = display_col_at(¢er_text, center_text.len(), TABSTOP);
let mut markers = vec![' '; width.max(1)];
for sm in &m.submatches {
let s = display_col_at(¢er_text, sm.start, TABSTOP);
let e = display_col_at(¢er_text, sm.end, TABSTOP);
let end = e.min(markers.len());
let start = s.min(end);
for i in start..end {
markers[i] = '^';
}
}
let marker_line: String = markers.into_iter().collect();
if is_no_color() {
println!("{}{}", prefix, marker_line);
} else {
let colored: String = marker_line
.chars()
.map(|c| {
if c == '^' {
"^".bright_yellow().bold().to_string()
} else {
" ".to_string()
}
})
.collect();
println!("{}{}", prefix, colored);
}
}
}
}
println!();
}
}
Ok(())
}
fn render_csv(matches: &[RgMatch]) -> Result<()> {
println!("file,line,column,kind,text");
for data in matches {
let file = data.path.text.clone();
let line_no = data.line_number;
let col = data.submatches.get(0).map(|sm| sm.start + 1).unwrap_or(1);
let text = data.lines.text.replace('\n', "");
let text_escaped = text.replace('"', "\"\"");
println!(
"{} ,{} ,{} ,match ,\"{}\"",
file, line_no, col, text_escaped
);
}
Ok(())
}
fn collect_matches(stdout: &[u8]) -> Vec<RgMatch> {
let mut matches = Vec::new();
for line in String::from_utf8_lossy(stdout).lines() {
if let Ok(ev) = serde_json::from_str::<RipgrepEvent>(line) {
if let RipgrepEvent::Match { data } = ev {
matches.push(data);
}
}
}
matches
}
fn run_search_pipelines(
matches: &[RgMatch],
plugin_manager: &mut PluginManager,
pipelines: &[SearchPipelineSpec],
) -> Result<()> {
if matches.is_empty() {
println!("No matches to feed into search pipelines.");
return Ok(());
}
let mut seen = HashSet::new();
let mut files = Vec::new();
for m in matches {
let path = m.path.text.clone();
if seen.insert(path.clone()) {
files.push(path);
}
}
if files.is_empty() {
println!("No files to feed into search pipelines.");
return Ok(());
}
for pipeline in pipelines {
let mut args = pipeline.args.clone();
args.extend(files.clone());
println!(
"↪ Running {}:{} on {} matched files",
pipeline.plugin,
pipeline.action,
files.len()
);
plugin_manager.perform_plugin_action(&pipeline.plugin, &pipeline.action, &args)?;
}
Ok(())
}
fn read_snippet(path: &std::path::Path, start_line: usize, lines: usize) -> Result<Vec<String>> {
use std::fs::File;
use std::io::{BufRead, BufReader};
let file = File::open(path)
.map_err(|e| LlaError::Other(format!("Failed to read {}: {}", path.display(), e)))?;
let reader = BufReader::new(file);
let mut result = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let ln = idx + 1;
if ln < start_line {
continue;
}
if ln >= start_line + lines {
break;
}
result.push(line.unwrap_or_default());
}
Ok(result)
}
fn expand_tabs(s: &str, tabstop: usize) -> String {
let mut out = String::with_capacity(s.len());
let mut col = 0usize;
for ch in s.chars() {
if ch == '\t' {
let spaces = tabstop - (col % tabstop);
for _ in 0..spaces {
out.push(' ');
}
col += spaces;
} else {
out.push(ch);
col += UnicodeWidthChar::width(ch).unwrap_or(1);
}
}
out
}
fn display_col_at(s: &str, byte_offset: usize, tabstop: usize) -> usize {
let mut col = 0usize;
for (i, ch) in s.char_indices() {
if i >= byte_offset {
break;
}
if ch == '\t' {
let spaces = tabstop - (col % tabstop);
col += spaces;
} else {
col += UnicodeWidthChar::width(ch).unwrap_or(1);
}
}
col
}