use std::path::{Path, PathBuf};
use std::result::Result;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchMode {
Literal,
Regex,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CaseSensitivity {
Sensitive,
Insensitive,
Smart,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineMatch {
pub path: PathBuf,
pub line: usize,
pub column: usize,
pub line_start: usize,
pub byte_start: usize,
pub byte_end: usize,
pub line_text: String,
pub matched_text: String,
pub word_bounded: bool,
}
#[derive(Debug, Clone)]
pub struct SearchRequest {
pub pattern: String,
pub root: PathBuf,
pub mode: MatchMode,
pub case: CaseSensitivity,
pub include_globs: Vec<String>,
pub language_globs: Vec<String>,
pub exclude_globs: Vec<String>,
pub max_results: usize,
pub max_per_file: usize,
pub max_file_size: Option<u64>,
pub include_hidden: bool,
pub respect_ignore_files: bool,
pub files_with_matches: bool,
}
impl SearchRequest {
#[must_use]
pub fn literal(pattern: impl Into<String>, root: impl Into<PathBuf>) -> Self {
Self {
pattern: pattern.into(),
root: root.into(),
mode: MatchMode::Literal,
case: CaseSensitivity::Smart,
include_globs: Vec::new(),
language_globs: Vec::new(),
exclude_globs: Vec::new(),
max_results: 100,
max_per_file: usize::MAX,
max_file_size: None,
include_hidden: false,
respect_ignore_files: true,
files_with_matches: false,
}
}
#[must_use]
pub fn regex(pattern: impl Into<String>, root: impl Into<PathBuf>) -> Self {
Self {
mode: MatchMode::Regex,
..Self::literal(pattern, root)
}
}
#[must_use]
pub fn with_case(mut self, case: CaseSensitivity) -> Self {
self.case = case;
self
}
#[must_use]
pub fn with_max_results(mut self, max_results: usize) -> Self {
self.max_results = max_results;
self
}
#[must_use]
pub fn with_max_per_file(mut self, max_per_file: usize) -> Self {
self.max_per_file = max_per_file;
self
}
#[must_use]
pub fn with_include_glob(mut self, glob: impl Into<String>) -> Self {
self.include_globs.push(glob.into());
self
}
#[must_use]
pub fn with_language_glob(mut self, glob: impl Into<String>) -> Self {
self.language_globs.push(glob.into());
self
}
#[must_use]
pub fn with_hidden(mut self) -> Self {
self.include_hidden = true;
self
}
#[must_use]
pub fn with_max_file_size(mut self, max_file_size: u64) -> Self {
self.max_file_size = Some(max_file_size);
self
}
#[must_use]
pub fn with_files_with_matches(mut self) -> Self {
self.files_with_matches = true;
self
}
#[must_use]
pub fn with_exclude_glob(mut self, glob: impl Into<String>) -> Self {
self.exclude_globs.push(glob.into());
self
}
#[must_use]
pub fn without_ignore_files(mut self) -> Self {
self.respect_ignore_files = false;
self
}
pub fn compile(&self) -> Result<Regex, regex::Error> {
let pattern = match self.mode {
MatchMode::Literal => regex::escape(&self.pattern),
MatchMode::Regex => self.pattern.clone(),
};
let pattern = match self.case {
CaseSensitivity::Sensitive => pattern,
CaseSensitivity::Insensitive => format!("(?i){pattern}"),
CaseSensitivity::Smart => {
if self.pattern.chars().any(char::is_uppercase) {
pattern
} else {
format!("(?i){pattern}")
}
}
};
Regex::new(&pattern)
}
}
#[derive(Debug)]
pub enum SearchError {
InvalidPattern(regex::Error),
}
impl std::fmt::Display for SearchError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidPattern(error) => write!(formatter, "invalid search pattern: {error}"),
}
}
}
impl std::error::Error for SearchError {}
impl From<regex::Error> for SearchError {
fn from(error: regex::Error) -> Self {
Self::InvalidPattern(error)
}
}
pub fn search(request: &SearchRequest) -> Result<Vec<LineMatch>, SearchError> {
let matcher = request.compile()?;
let mut matches = Vec::new();
let mut candidates = candidate_files(request);
candidates.sort();
for path in candidates {
let remaining = request.max_results.saturating_sub(matches.len());
if remaining == 0 {
break;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let per_file_cap = if request.files_with_matches {
1
} else {
request.max_per_file.min(remaining)
};
let file_matches = scan_file(&path, &content, &matcher, per_file_cap);
if file_matches.is_empty() {
continue;
}
if request.files_with_matches {
matches.extend(file_matches.into_iter().next());
} else {
matches.extend(file_matches);
}
}
matches.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then_with(|| left.line.cmp(&right.line))
.then_with(|| left.column.cmp(&right.column))
});
Ok(matches)
}
fn candidate_files(request: &SearchRequest) -> Vec<PathBuf> {
if request.root.is_file() {
return vec![request.root.clone()];
}
let mut builder = crate::walk::build_default_walker(&request.root);
let _ = builder.hidden(!request.include_hidden);
let _ = builder.filter_entry(|entry| !crate::walk::is_excluded_dir(entry));
if !request.respect_ignore_files {
let _ = builder.git_ignore(false);
let _ = builder.git_global(false);
let _ = builder.git_exclude(false);
let _ = builder.ignore(false);
let _ = builder.parents(false);
}
let language_globs = lowercase_globs(&request.language_globs);
builder
.build()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
.map(|entry| entry.into_path())
.filter(|path| {
let relative = path.strip_prefix(&request.root).unwrap_or(path);
let relative = if relative.as_os_str().is_empty() {
path
} else {
relative
};
path_matches_include_globs(relative, &request.include_globs)
&& path_matches_language_globs(relative, &language_globs)
&& !path_matches_exclude_globs(relative, &request.exclude_globs)
})
.filter(|path| {
request
.max_file_size
.is_none_or(|limit| std::fs::metadata(path).map(|meta| meta.len() <= limit).unwrap_or(false))
})
.collect()
}
fn path_matches_include_globs(relative_path: &Path, globs: &[String]) -> bool {
if globs.is_empty() {
return true;
}
globs.iter().any(|glob| glob_matches_path(relative_path, glob))
}
fn lowercase_globs(globs: &[String]) -> Vec<String> {
globs.iter().map(|glob| glob.to_ascii_lowercase()).collect()
}
fn path_matches_language_globs(relative_path: &Path, globs: &[String]) -> bool {
if globs.is_empty() {
return true;
}
let Some(lowered) = lowercase_relative_path(relative_path) else {
return false;
};
let lowered = Path::new(&lowered);
globs.iter().any(|glob| {
glob_matches_path(lowered, glob)
|| lowered.file_name().is_some_and(|name| glob_matches_path(Path::new(name), glob))
})
}
fn lowercase_relative_path(relative_path: &Path) -> Option<String> {
let mut lowered = String::new();
for component in relative_path.components() {
let std::path::Component::Normal(name) = component else {
return None;
};
if !lowered.is_empty() {
lowered.push('/');
}
lowered.push_str(&name.to_string_lossy().to_ascii_lowercase());
}
Some(lowered)
}
fn path_matches_exclude_globs(relative_path: &Path, globs: &[String]) -> bool {
globs.iter().any(|glob| glob_matches_path(relative_path, glob))
}
fn is_identifier_char(character: char) -> bool {
character.is_alphanumeric() || character == '_'
}
fn glob_matches_path(relative_path: &Path, glob: &str) -> bool {
let glob = glob.trim_start_matches('/');
let pattern_components: Vec<&str> = glob.split('/').collect();
let path_components: Vec<String> = relative_path
.components()
.filter_map(|component| match component {
std::path::Component::Normal(name) => name.to_str().map(str::to_owned),
_ => None,
})
.collect();
if !glob.contains('/') {
let Some(name) = path_components.last() else {
return false;
};
return match_components(&pattern_components, &[name.as_str()]);
}
let path_components: Vec<&str> = path_components.iter().map(String::as_str).collect();
match_components(&pattern_components, &path_components)
}
fn match_components(pattern: &[&str], path: &[&str]) -> bool {
match pattern.split_first() {
None => path.is_empty(),
Some((&"**", rest)) => {
(0..=path.len()).any(|skip| match_components(rest, path.get(skip..).unwrap_or_default()))
}
Some((&head, rest)) => match path.split_first() {
Some((&first, tail)) if component_matches(head, first) => match_components(rest, tail),
_ => false,
},
}
}
fn component_matches(pattern: &str, component: &str) -> bool {
let pattern: Vec<char> = pattern.chars().collect();
let component: Vec<char> = component.chars().collect();
component_glob_matches(&pattern, &component)
}
fn component_glob_matches(pattern: &[char], component: &[char]) -> bool {
match pattern.split_first() {
None => component.is_empty(),
Some((&'*', rest)) => {
(0..=component.len()).any(|skip| component_glob_matches(rest, component.get(skip..).unwrap_or_default()))
}
Some((&'?', rest)) => match component.split_first() {
Some((_, tail)) => component_glob_matches(rest, tail),
None => false,
},
Some((&head, rest)) => match component.split_first() {
Some((&first, tail)) if head == first => component_glob_matches(rest, tail),
_ => false,
},
}
}
fn scan_file(path: &Path, content: &str, matcher: &Regex, max_per_file: usize) -> Vec<LineMatch> {
let mut matches = Vec::new();
let mut line_start = 0usize;
for (index, line) in content.split_inclusive('\n').enumerate() {
let line_without_newline = line.trim_end_matches(['\n', '\r']);
for found in matcher.find_iter(line_without_newline) {
let matched_text = found.as_str();
let column = found.start() + 1;
let before = line_without_newline
.get(..found.start())
.and_then(|text| text.chars().next_back());
let after = line_without_newline.get(found.end()..).and_then(|text| text.chars().next());
let word_bounded = !before.is_some_and(is_identifier_char) && !after.is_some_and(is_identifier_char);
matches.push(LineMatch {
path: path.to_path_buf(),
line: index + 1,
column,
line_start,
byte_start: line_start + found.start(),
byte_end: line_start + found.end(),
line_text: line_without_newline.to_string(),
matched_text: matched_text.to_string(),
word_bounded,
});
if matches.len() >= max_per_file {
return matches;
}
}
line_start += line.len();
}
matches
}
#[cfg(test)]
mod tests {
use super::{CaseSensitivity, MatchMode, SearchRequest, glob_matches_path, search};
use std::fs;
use std::path::Path;
use tempfile::tempdir;
fn fixture() -> tempfile::TempDir {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.rs"), "fn main() {}\nlet needle = 1;\n").expect("write a.rs");
fs::write(dir.path().join("b.txt"), "needle in a text file\n").expect("write b.txt");
dir
}
#[test]
fn literal_mode_matches_metacharacters_verbatim() {
let dir = fixture();
let request = SearchRequest::literal("needle =", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1, "only the literal occurrence matches");
assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
assert_eq!(matches[0].line, 2);
assert_eq!(matches[0].matched_text, "needle =");
}
#[test]
fn regex_mode_treats_pattern_as_a_regular_expression() {
let dir = fixture();
let request = SearchRequest::regex(r"needle\s+in", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("b.txt"));
}
#[test]
fn smart_case_relaxes_lowercase_patterns_only() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("case.txt"), "Needle needle\n").expect("write");
let lower = SearchRequest::literal("needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Smart);
assert_eq!(search(&lower).expect("search runs").len(), 2, "lower-case pattern matches both cases");
let upper = SearchRequest::literal("Needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Smart);
assert_eq!(search(&upper).expect("search runs").len(), 1, "upper-case pattern stays case-sensitive");
}
#[test]
fn explicit_case_sensitivity_overrides_smart_case() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("case.txt"), "Needle needle\n").expect("write");
let request =
SearchRequest::literal("Needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Insensitive);
assert_eq!(search(&request).expect("search runs").len(), 2);
}
#[test]
fn language_globs_match_case_insensitively_in_both_directions() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("main.rs"), "needle\n").expect("write lower-case file");
fs::write(dir.path().join("OTHER.RS"), "needle\n").expect("write upper-case file");
let upper_glob = SearchRequest::literal("needle", dir.path().to_path_buf()).with_language_glob("*.RS");
assert_eq!(search(&upper_glob).expect("search runs").len(), 2, "an upper-case glob matches both files");
let lower_glob = SearchRequest::literal("needle", dir.path().to_path_buf()).with_language_glob("*.rs");
assert_eq!(search(&lower_glob).expect("search runs").len(), 2, "a lower-case glob matches both files");
}
#[test]
fn include_globs_restrict_the_scanned_files() {
let dir = fixture();
let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_include_glob("*.rs");
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
}
#[test]
fn max_results_stops_the_search_early() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_results(2);
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 2);
}
#[test]
fn max_per_file_caps_matches_within_one_file() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_per_file(1);
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1);
}
#[test]
fn files_with_matches_reports_each_file_once() {
let dir = fixture();
let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
request.files_with_matches = true;
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 2, "one entry per matching file");
}
#[test]
fn files_with_matches_honours_the_result_cap() {
let dir = tempdir().expect("tempdir");
for index in 0..4 {
fs::write(dir.path().join(format!("file{index}.txt")), "needle\n").expect("write");
}
let request = SearchRequest::literal("needle", dir.path().to_path_buf())
.with_max_results(2)
.with_files_with_matches();
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 2, "the cap bounds the number of reported files");
}
#[test]
fn files_with_matches_reports_a_real_position_rather_than_a_placeholder() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("a.txt"), "first\nsecond needle\n").expect("write");
let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
request.files_with_matches = true;
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1);
let hit = &matches[0];
assert_eq!(hit.line, 2, "the record points at the line that matched");
assert_eq!(hit.column, 8, "and at the byte column of the match");
assert_eq!(hit.line_text, "second needle");
assert_eq!(hit.matched_text, "needle");
assert_eq!(
"first\nsecond needle\n".get(hit.byte_start..hit.byte_end),
Some("needle"),
"the offsets still index into the file"
);
}
#[test]
fn files_with_matches_reports_each_file_once_when_a_file_has_several_hits() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
fs::write(dir.path().join("one.txt"), "needle\n").expect("write");
let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
request.files_with_matches = true;
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 2, "one entry per matching file, not per hit");
}
#[test]
fn word_bounded_marks_exact_identifier_occurrences() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("words.txt"), "Widget WidgetExtra\n").expect("write");
let request = SearchRequest::literal("Widget", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 2);
assert!(matches[0].word_bounded, "the standalone occurrence is word-bounded");
assert!(!matches[1].word_bounded, "the occurrence inside a longer word is not");
}
#[test]
fn byte_offsets_are_relative_to_the_file() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("offsets.txt"), "first\nsecond needle\n").expect("write");
let request = SearchRequest::literal("needle", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].line, 2);
assert_eq!(
"first\nsecond needle\n"
.get(matches[0].byte_start..matches[0].byte_end)
.expect("the offsets index into the file"),
"needle",
"the offsets must index into the file, not the line"
);
}
#[test]
fn column_is_counted_in_bytes() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("unicode.txt"), "éé needle\n").expect("write");
let request = SearchRequest::literal("needle", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches[0].column, 6, "column counts bytes, not characters");
assert_eq!(
matches[0].line_text.as_bytes().get(matches[0].column - 1..),
Some(b"needle".as_slice()),
"the column must index into the line text"
);
}
#[test]
fn multibyte_column_matches_ripgrep() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("cjk.txt"), "䏿–‡needle\n").expect("write");
let request = SearchRequest::literal("needle", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches[0].column, 7, "column counts bytes, matching `rg --column`");
}
#[test]
fn line_start_marks_the_start_of_the_matching_line() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("offsets.txt"), "first\nsecond needle\n").expect("write");
let request = SearchRequest::literal("needle", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches[0].line_start, 6, "the second line starts after \"first\\n\"");
assert_eq!(
matches[0].byte_start - matches[0].line_start,
matches[0].column - 1,
"the file offset and the line offset must agree"
);
}
#[test]
fn invalid_regex_is_reported_as_an_error() {
let dir = fixture();
let request = SearchRequest::regex("(unclosed", dir.path().to_path_buf());
assert!(search(&request).is_err(), "an invalid regex must not silently match nothing");
}
#[test]
fn glob_matching_handles_wildcards_and_prefixes() {
assert!(glob_matches_path(Path::new("main.rs"), "*.rs"));
assert!(!glob_matches_path(Path::new("main.txt"), "*.rs"));
assert!(glob_matches_path(Path::new("main.rs"), "**/*.rs"));
assert!(glob_matches_path(Path::new("main.rs"), "main.rs"));
assert!(glob_matches_path(Path::new("a/b/main.rs"), "**/*.rs"));
assert!(!glob_matches_path(Path::new("a/b/main.txt"), "**/*.rs"));
assert!(glob_matches_path(Path::new("target/x/y.rs"), "**/target/**"));
}
#[test]
fn exclude_globs_remove_matching_files() {
let dir = fixture();
let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_exclude_glob("*.txt");
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1, "the excluded text file must not be scanned");
assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
}
#[test]
fn exclude_globs_match_directory_patterns_at_any_depth() {
let dir = tempdir().expect("tempdir");
fs::create_dir_all(dir.path().join("target/nested")).expect("nested directory");
fs::write(dir.path().join("target/nested/build.rs"), "needle\n").expect("write nested");
fs::write(dir.path().join("kept.rs"), "needle\n").expect("write kept");
let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_exclude_glob("**/target/**");
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1, "the excluded directory must not be scanned");
assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("kept.rs"));
}
#[test]
fn gitignored_files_are_skipped_by_default() {
let dir = tempdir().expect("tempdir");
fs::create_dir(dir.path().join(".git")).expect("git marker");
fs::write(dir.path().join(".gitignore"), "skipped.rs\n").expect("write ignore file");
fs::write(dir.path().join("skipped.rs"), "needle\n").expect("write skipped");
fs::write(dir.path().join("kept.rs"), "needle\n").expect("write kept");
let request = SearchRequest::literal("needle", dir.path().to_path_buf());
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1, "the ignored file must not be scanned");
assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("kept.rs"));
}
#[test]
fn ignore_files_can_be_disabled() {
let dir = tempdir().expect("tempdir");
fs::create_dir(dir.path().join(".git")).expect("git marker");
fs::write(dir.path().join(".gitignore"), "skipped.rs\n").expect("write ignore file");
fs::write(dir.path().join("skipped.rs"), "needle\n").expect("write skipped");
let request = SearchRequest::literal("needle", dir.path().to_path_buf()).without_ignore_files();
let matches = search(&request).expect("search runs");
assert_eq!(matches.len(), 1, "disabling ignore files scans the ignored file");
}
#[test]
fn max_file_size_skips_large_files() {
let dir = tempdir().expect("tempdir");
fs::write(dir.path().join("big.txt"), "needle\n".repeat(100)).expect("write big");
let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_file_size(16);
let matches = search(&request).expect("search runs");
assert!(matches.is_empty(), "a file over the size limit must not be scanned");
}
#[test]
fn request_defaults_to_literal_mode() {
let request = SearchRequest::literal("x", "/tmp");
assert_eq!(request.mode, MatchMode::Literal);
}
}