use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::ast_grep_lang::AstGrepLang;
use crate::protocol::Response;
pub(crate) struct AstScope {
pub files: Vec<PathBuf>,
pub scope_warnings: Vec<String>,
pub no_files_matched_scope: bool,
}
struct SearchRoot {
path: PathBuf,
label: Option<String>,
}
pub(crate) fn collect_ast_files(
req_id: &str,
command: &str,
project_root: &Path,
lang: &AstGrepLang,
paths: &[String],
globs: &[String],
) -> Result<AstScope, Response> {
let roots = resolve_roots(project_root, paths);
for root in &roots {
if !root.path.exists() {
return Err(Response::error(
req_id,
"path_not_found",
format!(
"{}: search path does not exist: {}",
command,
root.path.display()
),
));
}
}
let files = walk_roots(project_root, &roots, lang, globs);
let scope_warnings = scope_warnings(project_root, &roots, lang, globs, &files);
let no_files_matched_scope = files.is_empty();
Ok(AstScope {
files,
scope_warnings,
no_files_matched_scope,
})
}
fn resolve_roots(project_root: &Path, paths: &[String]) -> Vec<SearchRoot> {
if paths.is_empty() {
return vec![SearchRoot {
path: project_root.to_path_buf(),
label: None,
}];
}
paths
.iter()
.map(|path| {
let candidate = PathBuf::from(path);
let resolved = if candidate.is_absolute() {
candidate
} else {
project_root.join(path)
};
SearchRoot {
path: resolved,
label: Some(path.clone()),
}
})
.collect()
}
fn scope_warnings(
project_root: &Path,
roots: &[SearchRoot],
lang: &AstGrepLang,
globs: &[String],
files: &[PathBuf],
) -> Vec<String> {
let mut warnings = Vec::new();
let has_include_globs = globs.iter().any(|glob| !glob.starts_with('!'));
for root in roots.iter().filter(|root| root.label.is_some()) {
let has_files = if files.iter().any(|file| file.starts_with(&root.path)) {
true
} else if has_include_globs {
!walk_root(project_root, root, lang, &[]).is_empty()
} else {
false
};
if !has_files {
warnings.push(format!(
"{} → no files",
root.label.as_deref().expect("explicit root label")
));
}
}
let matched_relative_paths: HashSet<String> = files
.iter()
.map(|file| relative_path_for_globs(project_root, roots, file))
.collect();
for include_glob in globs.iter().filter(|glob| !glob.starts_with('!')) {
if !matched_relative_paths
.iter()
.any(|path| glob_matches(include_glob, path))
{
warnings.push(format!("{} → no files", include_glob));
}
}
warnings.sort();
warnings.dedup();
warnings
}
fn relative_path_for_globs(project_root: &Path, roots: &[SearchRoot], file: &Path) -> String {
let filter_root = roots
.iter()
.find(|root| file.starts_with(&root.path))
.map(|root| {
if root.path.starts_with(project_root) {
project_root
} else {
root.path.as_path()
}
})
.unwrap_or(project_root);
file.strip_prefix(filter_root)
.unwrap_or(file)
.to_string_lossy()
.replace(std::path::MAIN_SEPARATOR, "/")
}
fn glob_matches(glob: &str, relative_path: &str) -> bool {
let mut builder = ignore::overrides::OverrideBuilder::new("");
if builder.add(glob).is_err() {
return false;
}
builder
.build()
.map(|overrides| overrides.matched(relative_path, false).is_whitelist())
.unwrap_or(false)
}
fn walk_roots(
project_root: &Path,
roots: &[SearchRoot],
lang: &AstGrepLang,
globs: &[String],
) -> Vec<PathBuf> {
roots
.iter()
.flat_map(|root| walk_root(project_root, root, lang, globs))
.collect()
}
fn walk_root(
project_root: &Path,
root: &SearchRoot,
lang: &AstGrepLang,
globs: &[String],
) -> Vec<PathBuf> {
use ignore::WalkBuilder;
let filter_root = if root.path.starts_with(project_root) {
project_root
} else {
root.path.as_path()
};
let overrides = build_overrides(filter_root, globs);
let mut builder = WalkBuilder::new(&root.path);
builder
.hidden(true)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
if entry.file_type().map_or(false, |ft| ft.is_dir()) {
return !matches!(
name.as_ref(),
"node_modules"
| "target"
| "venv"
| ".venv"
| ".git"
| "__pycache__"
| ".tox"
| "dist"
| "build"
);
}
true
});
if let Some(overrides) = overrides {
builder.overrides(overrides);
}
builder
.build()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().map_or(false, |ft| ft.is_file()))
.map(|entry| entry.into_path())
.filter(|path| lang.matches_path(path))
.collect()
}
fn build_overrides(root: &Path, globs: &[String]) -> Option<ignore::overrides::Override> {
if globs.is_empty() {
return None;
}
let mut override_builder = ignore::overrides::OverrideBuilder::new(root);
for glob in globs {
if let Some(exclude) = glob.strip_prefix('!') {
let _ = override_builder.add(&format!("!{}", exclude));
} else {
let _ = override_builder.add(glob);
}
}
override_builder.build().ok()
}