use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use globset::{Glob, GlobSet, GlobSetBuilder}; use grep_regex::RegexMatcherBuilder; use grep_searcher::sinks::UTF8; use grep_searcher::{BinaryDetection, SearcherBuilder}; use ignore::{WalkBuilder, WalkState}; use serde::Serialize; use super::to_canon; const FILE_SIZE_CAP: u64 = 5 * 1024 * 1024; const DEFAULT_MAX_RESULTS: usize = 200; const HARD_MAX_RESULTS: usize = 2000; #[derive(Serialize)] pub struct GrepHit { pub path: String, pub rel: String, pub line: u64, pub text: String, } #[derive(Serialize)] pub struct GrepResponse { pub hits: Vec, pub truncated: bool, pub files_scanned: usize, } fn build_globset(patterns: &[String]) -> Result, String> { if patterns.is_empty() { return Ok(None); } let mut b = GlobSetBuilder::new(); for p in patterns { let g = Glob::new(p).map_err(|e| format!("bad glob {p:?}: {e}"))?; b.add(g); } let set = b.build().map_err(|e| format!("globset build: {e}"))?; Ok(Some(set)) } #[tauri::command] pub fn fs_grep( pattern: String, root: String, glob: Option>, case_insensitive: Option, max_results: Option, ) -> Result { if pattern.is_empty() { return Err("empty pattern".into()); } let root_path = PathBuf::from(&root); if !root_path.is_dir() { return Err(format!("not a directory: {root}")); } let cap = max_results .unwrap_or(DEFAULT_MAX_RESULTS) .clamp(1, HARD_MAX_RESULTS); let matcher = RegexMatcherBuilder::new() .case_insensitive(case_insensitive.unwrap_or(false)) .line_terminator(Some(b'\n')) .build(&pattern) .map_err(|e| format!("bad regex: {e}"))?; let globs = build_globset(glob.as_deref().unwrap_or(&[]))?; let walker = WalkBuilder::new(&root_path) .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .ignore(true) .parents(true) .follow_links(false) .build_parallel(); let hits: Arc>> = Arc::new(Mutex::new(Vec::new())); let scanned = Arc::new(AtomicUsize::new(0)); let truncated = Arc::new(AtomicBool::new(false)); walker.run(|| { let matcher = matcher.clone(); let globs = globs.clone(); let hits = hits.clone(); let scanned = scanned.clone(); let truncated = truncated.clone(); let root_path = root_path.clone(); Box::new(move |dent_res| { if truncated.load(Ordering::Relaxed) { return WalkState::Quit; } let dent = match dent_res { Ok(d) => d, Err(_) => return WalkState::Continue, }; if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) { return WalkState::Continue; } let path = dent.path(); let rel = match path.strip_prefix(&root_path) { Ok(r) => to_canon(r), Err(_) => return WalkState::Continue, }; if let Some(set) = globs.as_ref() { if !set.is_match(&rel) { return WalkState::Continue; } } if let Ok(meta) = std::fs::metadata(path) { if meta.len() > FILE_SIZE_CAP { return WalkState::Continue; } } scanned.fetch_add(1, Ordering::Relaxed); let abs = to_canon(path); let rel_clone = rel.clone(); let mut searcher = SearcherBuilder::new() .binary_detection(BinaryDetection::quit(b'\x00')) .line_number(true) .build(); let _ = searcher.search_path( &matcher, path, UTF8(|line_num, text| { let line_text = text.trim_end_matches('\n').to_string(); let mut guard = hits.lock().unwrap(); if guard.len() >= cap { truncated.store(true, Ordering::Relaxed); return Ok(false); } guard.push(GrepHit { path: abs.clone(), rel: rel_clone.clone(), line: line_num, text: line_text, }); Ok(true) }), ); WalkState::Continue }) }); let final_hits = Arc::try_unwrap(hits) .map(|m| m.into_inner().unwrap()) .unwrap_or_default(); Ok(GrepResponse { hits: final_hits, truncated: truncated.load(Ordering::Relaxed), files_scanned: scanned.load(Ordering::Relaxed), }) } #[derive(Serialize)] pub struct GlobHit { pub path: String, pub rel: String, } #[derive(Serialize)] pub struct GlobResponse { pub hits: Vec, pub truncated: bool, } #[tauri::command] pub fn fs_glob( pattern: String, root: String, max_results: Option, ) -> Result { if pattern.is_empty() { return Err("empty pattern".into()); } let root_path = PathBuf::from(&root); if !root_path.is_dir() { return Err(format!("not a directory: {root}")); } let cap = max_results.unwrap_or(500).clamp(1, HARD_MAX_RESULTS); let glob = Glob::new(&pattern).map_err(|e| format!("bad glob: {e}"))?; let mut gb = GlobSetBuilder::new(); gb.add(glob); let set = gb.build().map_err(|e| format!("globset build: {e}"))?; let walker = WalkBuilder::new(&root_path) .hidden(true) .git_ignore(true) .git_global(true) .git_exclude(true) .ignore(true) .parents(true) .follow_links(false) .build(); let mut hits: Vec = Vec::new(); let mut truncated = false; for dent in walker.flatten() { if hits.len() >= cap { truncated = true; break; } if !dent.file_type().map(|t| t.is_file()).unwrap_or(false) { continue; } let path = dent.path(); let rel = match path.strip_prefix(&root_path) { Ok(r) => to_canon(r), Err(_) => continue, }; if !set.is_match(&rel) { continue; } hits.push(GlobHit { path: to_canon(path), rel, }); } Ok(GlobResponse { hits, truncated }) }