use std::path::{Component, Path, PathBuf};
use crate::index::Index;
use crate::path::filter::matches_path_filter;
use crate::{Config, SearchOptions};
use super::search::SearchArgs;
pub(super) fn path_depth(path: &Path) -> usize {
path.components().count().saturating_sub(1)
}
pub(super) fn truncate_matches_per_file(
matches: Vec<crate::SearchMatch>,
limit: usize,
) -> Vec<crate::SearchMatch> {
let mut kept = Vec::with_capacity(matches.len().min(limit));
let mut current_path: Option<PathBuf> = None;
let mut kept_in_file = 0usize;
for m in matches {
if current_path.as_ref() != Some(&m.path) {
current_path = Some(m.path.clone());
kept_in_file = 0;
}
if kept_in_file < limit {
kept.push(m);
kept_in_file += 1;
}
}
kept
}
#[derive(Clone)]
pub(super) struct ExplicitPathSpec {
pub(super) rel_path: PathBuf,
is_dir: bool,
}
impl ExplicitPathSpec {
pub(super) fn path_filter(&self) -> String {
let rel = self.rel_path.to_string_lossy();
if self.is_dir {
format!("{rel}/")
} else {
rel.into_owned()
}
}
}
pub(super) fn explicit_path_specs(repo_root: &Path, paths: &[PathBuf]) -> Vec<ExplicitPathSpec> {
let cwd = std::env::current_dir().unwrap_or_else(|_| repo_root.to_path_buf());
paths
.iter()
.map(|path| ExplicitPathSpec {
rel_path: relativize_cli_path(repo_root, &cwd, path),
is_dir: path_is_directory(repo_root, &cwd, path),
})
.filter(|spec| !spec.rel_path.as_os_str().is_empty())
.collect()
}
pub(super) fn matches_any_explicit_path(path: &Path, specs: &[ExplicitPathSpec]) -> bool {
specs.is_empty() || specs.iter().any(|spec| explicit_path_matches(path, spec))
}
fn explicit_path_matches(path: &Path, spec: &ExplicitPathSpec) -> bool {
if spec.rel_path.as_os_str().is_empty() {
return true;
}
if spec.is_dir {
path.starts_with(&spec.rel_path)
} else {
path == spec.rel_path
}
}
pub(super) fn shows_filename_by_default(config: &Config, paths: &[PathBuf]) -> bool {
match explicit_path_specs(config.repo_root.as_path(), paths).as_slice() {
[] => true,
[spec] => spec.is_dir,
_ => true,
}
}
fn path_is_directory(repo_root: &Path, cwd: &Path, path: &Path) -> bool {
cli_path_on_disk(repo_root, cwd, path)
.metadata()
.map(|meta| meta.is_dir())
.unwrap_or(false)
}
fn relativize_cli_path(repo_root: &Path, cwd: &Path, path: &Path) -> PathBuf {
let base = if path.is_absolute() {
path.to_path_buf()
} else {
resolve_relative_base(repo_root, cwd, path)
};
let rel = match base.strip_prefix(repo_root) {
Ok(rel) => rel,
Err(_) => base.as_path(),
};
crate::path_util::normalize_to_forward_slashes(normalize_relative_path(rel))
}
fn cli_path_on_disk(repo_root: &Path, cwd: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
resolve_relative_base(repo_root, cwd, path)
}
}
fn resolve_relative_base(repo_root: &Path, cwd: &Path, path: &Path) -> PathBuf {
let via_cwd = cwd.join(path);
if via_cwd.starts_with(repo_root) {
via_cwd
} else {
repo_root.join(path)
}
}
fn normalize_relative_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => normalized.push(part),
Component::ParentDir => {
if matches!(
normalized.components().next_back(),
Some(Component::Normal(_))
) {
normalized.pop();
} else {
normalized.push(component.as_os_str());
}
}
Component::RootDir | Component::Prefix(_) => normalized.push(component.as_os_str()),
}
}
normalized
}
pub(super) fn search_options(args: &SearchArgs, path_filter: Option<String>) -> SearchOptions {
SearchOptions {
case_insensitive: args.ignore_case,
file_type: None,
exclude_type: None,
file_types: args.file_types.clone(),
exclude_types: args.type_nots.clone(),
max_results: None,
path_filter,
verify_pattern: None,
skip_line_content: args.files_with_matches || args.files_without_match,
deterministic: false,
#[cfg(any(test, feature = "oracle"))]
force_full_scan: false,
}
}
fn compile_glob(pattern: &str) -> Result<globset::Glob, globset::Error> {
use globset::GlobBuilder;
if pattern.contains('/') {
GlobBuilder::new(pattern).literal_separator(true).build()
} else {
GlobBuilder::new(pattern).build()
}
}
pub(super) struct CompiledGlobs {
entries: Vec<GlobEntry>,
has_positive: bool,
}
struct GlobEntry {
is_exclude: bool,
basename_only: bool,
set: globset::GlobSet,
}
impl CompiledGlobs {
pub(super) fn build(path_globs: &[String]) -> Self {
let mut entries = Vec::new();
let mut has_positive = false;
for glob_str in path_globs {
let (is_exclude, pattern) = match glob_str.strip_prefix('!') {
Some(excl) => (true, excl),
None => (false, glob_str.as_str()),
};
if pattern.is_empty() {
continue;
}
if !is_exclude {
has_positive = true;
}
let Ok(glob) = compile_glob(pattern) else {
continue;
};
let mut builder = globset::GlobSetBuilder::new();
builder.add(glob);
let Ok(set) = builder.build() else {
continue;
};
entries.push(GlobEntry {
is_exclude,
basename_only: !pattern.contains('/'),
set,
});
}
Self {
entries,
has_positive,
}
}
fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
pub(super) fn matches_optional_glob(
path: &Path,
file_types: &[String],
exclude_types: &[String],
globs: &CompiledGlobs,
) -> bool {
if !file_types.is_empty()
&& !file_types
.iter()
.any(|file_type| matches_path_filter(path, &[file_type.as_str()], &[], None))
{
return false;
}
if exclude_types
.iter()
.any(|exclude_type| matches_path_filter(path, &[exclude_type.as_str()], &[], None))
{
return false;
}
if globs.is_empty() {
return true;
}
let basename = path.file_name().map(Path::new);
let mut state: Option<bool> = None; for entry in &globs.entries {
let matches = if entry.basename_only {
basename.is_some_and(|b| entry.set.is_match(b))
} else {
entry.set.is_match(path)
};
if matches {
state = Some(!entry.is_exclude);
}
}
match state {
Some(included) => included,
None => !globs.has_positive,
}
}
pub(super) fn validate_globs(path_globs: &[String]) -> Result<(), (String, String)> {
for glob_str in path_globs {
let pattern = glob_str.strip_prefix('!').unwrap_or(glob_str.as_str());
if pattern.is_empty() {
continue;
}
if let Err(e) = compile_glob(pattern) {
return Err((glob_str.clone(), e.to_string()));
}
}
Ok(())
}
pub(super) fn collect_scoped_paths(
index: &Index,
config: &Config,
args: &SearchArgs,
) -> Vec<PathBuf> {
let snapshot = index.snapshot();
let explicit_specs = explicit_path_specs(config.repo_root.as_path(), &args.paths);
let compiled_globs = CompiledGlobs::build(&args.globs);
let mut paths: Vec<PathBuf> = snapshot
.path_index
.visible_paths()
.filter(|(_, path)| {
matches_any_explicit_path(path, &explicit_specs)
&& matches_optional_glob(path, &args.file_types, &args.type_nots, &compiled_globs)
})
.map(|(_, path)| path.to_path_buf())
.collect();
paths.sort_unstable();
paths
}
mod files;
pub(super) use files::cmd_files;
pub(super) fn sort_and_dedup_matches(
mut matches: Vec<crate::SearchMatch>,
) -> Vec<crate::SearchMatch> {
matches.sort_by(|a, b| {
crate::path_util::cmp_path_bytes(&a.path, &b.path)
.then_with(|| a.line_number.cmp(&b.line_number))
.then_with(|| a.byte_offset.cmp(&b.byte_offset))
.then_with(|| a.submatch_start.cmp(&b.submatch_start))
.then_with(|| a.submatch_end.cmp(&b.submatch_end))
});
matches.dedup_by(|a, b| {
a.path == b.path
&& a.line_number == b.line_number
&& a.byte_offset == b.byte_offset
&& a.submatch_start == b.submatch_start
&& a.submatch_end == b.submatch_end
});
matches
}
#[cfg(test)]
#[path = "../scope_tests.rs"]
mod tests;