use crate::types::Language;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum AffectedError {
#[error("not inside a git repository: {0}")]
NotARepo(PathBuf),
#[error("git command failed: {0}")]
GitFailed(String),
#[error("io error: {0}")]
Io(String),
}
pub struct AffectedOptions<'a> {
pub cwd: PathBuf,
pub base: String,
pub language: Language,
pub patterns: Vec<String>,
pub extra_changed_files: Vec<String>,
pub git: &'a dyn GitRunner,
pub fs: &'a dyn Fs,
pub import_extractor: Option<&'a dyn ImportExtractor>,
}
pub trait ImportExtractor {
fn extract(&self, src: &str, lang: Language) -> Vec<String>;
}
pub struct RegexImportExtractor;
impl ImportExtractor for RegexImportExtractor {
fn extract(&self, src: &str, lang: Language) -> Vec<String> {
extract_specifiers(src, lang)
}
}
#[derive(Debug, Clone)]
pub struct AffectedSet {
pub base: String,
pub changed_files: Vec<String>,
pub bench_files: Vec<PathBuf>,
pub all_bench_files: Vec<PathBuf>,
}
pub trait GitRunner {
fn run(&self, args: &[&str], cwd: &Path) -> Result<String, AffectedError>;
}
pub trait Fs {
fn read_to_string(&self, path: &Path) -> Result<String, AffectedError>;
fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>, AffectedError>;
fn metadata(&self, path: &Path) -> Result<EntryKind, AffectedError>;
}
#[derive(Debug, Clone)]
pub struct DirEntry {
pub path: PathBuf,
pub kind: EntryKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryKind {
File,
Dir,
Other,
}
pub fn resolve_affected(opts: &AffectedOptions<'_>) -> Result<AffectedSet, AffectedError> {
let all_bench_files = discover_benches(&opts.cwd, &opts.patterns, opts.language, opts.fs)?;
if all_bench_files.is_empty() {
return Ok(AffectedSet {
base: opts.base.clone(),
changed_files: vec![],
bench_files: vec![],
all_bench_files: vec![],
});
}
let repo_root = detect_repo_root(&opts.cwd, opts.git)?;
let mut changed_rel = git_changed_files(&repo_root, &opts.base, opts.git)?;
for extra in &opts.extra_changed_files {
changed_rel.push(extra.clone());
}
let changed_abs: HashSet<PathBuf> = changed_rel
.iter()
.map(|f| normalize_path(&repo_root.join(f)))
.collect();
let default_extractor = RegexImportExtractor;
let extractor: &dyn ImportExtractor = match opts.import_extractor {
Some(e) => e,
None => &default_extractor,
};
let mut bench_files: Vec<PathBuf> = Vec::new();
for bench in &all_bench_files {
let reachable = collect_reachable_with(bench, opts.language, opts.fs, extractor);
if reachable.iter().any(|f| changed_abs.contains(f)) {
bench_files.push(bench.clone());
}
}
Ok(AffectedSet {
base: opts.base.clone(),
changed_files: changed_rel,
bench_files,
all_bench_files,
})
}
fn discover_benches(
cwd: &Path,
patterns: &[String],
lang: Language,
fs: &dyn Fs,
) -> Result<Vec<PathBuf>, AffectedError> {
let mut globs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect();
if globs.is_empty() {
globs.extend(lang.default_globs().iter().copied());
}
let matchers: Vec<GlobMatcher> = globs.iter().map(|g| GlobMatcher::new(g)).collect();
let excludes = &["node_modules", "dist", "build", ".git", "target", "vendor"];
let mut out: Vec<PathBuf> = Vec::new();
walk(cwd, fs, &matchers, excludes, &mut out)?;
out.sort();
out.dedup();
Ok(out)
}
fn walk(
root: &Path,
fs: &dyn Fs,
matchers: &[GlobMatcher],
excludes: &[&str],
out: &mut Vec<PathBuf>,
) -> Result<(), AffectedError> {
let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = match fs.read_dir(&dir) {
Ok(e) => e,
Err(_) => continue,
};
for e in entries {
let name = e.path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if excludes.iter().any(|ex| ex == &name) {
continue;
}
match e.kind {
EntryKind::Dir => stack.push(e.path),
EntryKind::File => {
if matchers.iter().any(|m| m.matches(&e.path)) {
out.push(e.path);
}
}
EntryKind::Other => {}
}
}
}
Ok(())
}
pub fn collect_reachable(entry: &Path, lang: Language, fs: &dyn Fs) -> HashSet<PathBuf> {
collect_reachable_with(entry, lang, fs, &RegexImportExtractor)
}
pub fn collect_reachable_with(
entry: &Path,
lang: Language,
fs: &dyn Fs,
extractor: &dyn ImportExtractor,
) -> HashSet<PathBuf> {
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut stack: Vec<PathBuf> = vec![normalize_path(entry)];
while let Some(file) = stack.pop() {
if !seen.insert(file.clone()) {
continue;
}
let src = match fs.read_to_string(&file) {
Ok(s) => s,
Err(_) => continue,
};
let specifiers = extractor.extract(&src, lang);
let from_dir = file.parent().unwrap_or(Path::new(".")).to_path_buf();
for spec in specifiers {
if !is_relative_spec(&spec, lang) {
continue;
}
for resolved in resolve_import(&from_dir, &spec, lang, fs) {
if !seen.contains(&resolved) {
stack.push(resolved);
}
}
}
}
seen
}
pub fn extract_specifiers(src: &str, lang: Language) -> Vec<String> {
let stripped = strip_comments(src, lang);
let mut out: Vec<String> = Vec::new();
match lang {
Language::Ts => {
for caps in TS_FROM_RE.captures_iter(&stripped) {
out.push(caps[1].to_string());
}
for caps in TS_SIDE_EFFECT_RE.captures_iter(&stripped) {
out.push(caps[1].to_string());
}
for caps in TS_CALL_RE.captures_iter(&stripped) {
out.push(caps[1].to_string());
}
}
Language::Go => {
for caps in GO_SINGLE_IMPORT_RE.captures_iter(&stripped) {
out.push(caps[1].to_string());
}
for caps in GO_BLOCK_IMPORT_RE.captures_iter(&stripped) {
let block = &caps[1];
for sub in GO_BLOCK_PATH_RE.captures_iter(block) {
out.push(sub[1].to_string());
}
}
}
Language::Rust => {
for caps in RUST_MOD_RE.captures_iter(&stripped) {
out.push(format!("./{}", &caps[1]));
}
for caps in RUST_INCLUDE_RE.captures_iter(&stripped) {
out.push(prefix_relative(&caps[1]));
}
for caps in RUST_PATH_ATTR_RE.captures_iter(&stripped) {
out.push(prefix_relative(&caps[1]));
}
}
}
out
}
fn prefix_relative(p: &str) -> String {
if p.starts_with("./") || p.starts_with("../") || p.starts_with('/') {
p.to_string()
} else {
format!("./{p}")
}
}
fn strip_comments(src: &str, lang: Language) -> String {
match lang {
Language::Ts | Language::Rust => {
let no_block = BLOCK_COMMENT_RE.replace_all(src, "").to_string();
LINE_COMMENT_RE.replace_all(&no_block, "$1").to_string()
}
Language::Go => {
let no_block = BLOCK_COMMENT_RE.replace_all(src, "").to_string();
LINE_COMMENT_RE.replace_all(&no_block, "$1").to_string()
}
}
}
fn is_relative_spec(spec: &str, lang: Language) -> bool {
match lang {
Language::Ts => {
spec.starts_with("./")
|| spec.starts_with("../")
|| spec == "."
|| spec == ".."
|| spec.starts_with('/')
}
Language::Go => {
spec.starts_with("./") || spec.starts_with("../")
}
Language::Rust => {
spec.starts_with("./") || spec.starts_with("../")
}
}
}
pub fn resolve_import(from_dir: &Path, spec: &str, lang: Language, fs: &dyn Fs) -> Vec<PathBuf> {
let exts = lang.source_extensions();
let base = normalize_path(&from_dir.join(spec));
for ext in exts {
if spec.ends_with(ext) {
return if file_exists(&base, fs) {
vec![base]
} else {
vec![]
};
}
}
if matches!(lang, Language::Go) {
if let Ok(EntryKind::Dir) = fs.metadata(&base) {
if let Ok(entries) = fs.read_dir(&base) {
let mut out: Vec<PathBuf> = entries
.into_iter()
.filter(|e| matches!(e.kind, EntryKind::File))
.filter(|e| {
e.path
.extension()
.and_then(|s| s.to_str())
.map(|s| s == "go")
.unwrap_or(false)
})
.map(|e| e.path)
.collect();
out.sort();
return out;
}
}
}
for ext in exts {
let cand = path_with_ext(&base, ext);
if file_exists(&cand, fs) {
return vec![cand];
}
}
if let Ok(EntryKind::Dir) = fs.metadata(&base) {
let index_names: &[&str] = match lang {
Language::Ts => &["index"],
Language::Rust => &["mod"],
Language::Go => &[],
};
for stem in index_names {
for ext in exts {
let cand = base.join(format!("{stem}{ext}"));
if file_exists(&cand, fs) {
return vec![cand];
}
}
}
}
vec![]
}
fn normalize_path(p: &Path) -> PathBuf {
use std::path::Component;
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
fn path_with_ext(p: &Path, ext: &str) -> PathBuf {
let mut s = p.to_path_buf().into_os_string();
s.push(ext);
PathBuf::from(s)
}
fn file_exists(p: &Path, fs: &dyn Fs) -> bool {
matches!(fs.metadata(p), Ok(EntryKind::File))
}
fn detect_repo_root(cwd: &Path, git: &dyn GitRunner) -> Result<PathBuf, AffectedError> {
let out = git.run(&["rev-parse", "--show-toplevel"], cwd)?;
let trimmed = out.trim();
if trimmed.is_empty() {
return Err(AffectedError::NotARepo(cwd.to_path_buf()));
}
Ok(PathBuf::from(trimmed))
}
fn git_changed_files(
repo_root: &Path,
base: &str,
git: &dyn GitRunner,
) -> Result<Vec<String>, AffectedError> {
let triple_dot = format!("{}...HEAD", base);
let out = git.run(&["diff", "--name-only", &triple_dot], repo_root)?;
let lines: Vec<String> = out
.split('\n')
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty())
.collect();
Ok(lines)
}
#[derive(Debug)]
pub struct GlobMatcher {
re: regex::Regex,
}
impl GlobMatcher {
pub fn new(pattern: &str) -> Self {
let mut rx = String::with_capacity(pattern.len() * 2);
rx.push('^');
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let next = chars.get(i + 1).copied();
if c == '*' && next == Some('*') {
rx.push_str(".*");
i += 2;
if chars.get(i) == Some(&'/') {
i += 1;
}
} else if c == '*' {
rx.push_str("[^/]*");
i += 1;
} else if c == '?' {
rx.push_str("[^/]");
i += 1;
} else if matches!(
c,
'.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\'
) {
rx.push('\\');
rx.push(c);
i += 1;
} else {
rx.push(c);
i += 1;
}
}
rx.push('$');
let re = regex::Regex::new(&rx).expect("internal: glob regex compile failed");
Self { re }
}
pub fn matches(&self, p: &Path) -> bool {
let s = p.to_string_lossy();
self.re.is_match(&s)
}
}
use once_cell::sync::Lazy;
static BLOCK_COMMENT_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r"(?s)/\*.*?\*/").unwrap());
static LINE_COMMENT_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r"(^|[^:])//[^\n]*").unwrap());
static TS_FROM_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"\bfrom\s+['"]([^'"]+)['"]"#).unwrap());
static TS_SIDE_EFFECT_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"\bimport\s+['"]([^'"]+)['"]"#).unwrap());
static TS_CALL_RE: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new(r#"\b(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap()
});
static GO_SINGLE_IMPORT_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"(?m)^\s*import\s+(?:[A-Za-z_]\w*\s+)?"([^"]+)""#).unwrap());
static GO_BLOCK_IMPORT_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"(?s)import\s*\(\s*(.*?)\s*\)"#).unwrap());
static GO_BLOCK_PATH_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"(?m)^\s*(?:[A-Za-z_]\w*\s+)?"([^"]+)""#).unwrap());
static RUST_MOD_RE: Lazy<regex::Regex> = Lazy::new(|| {
regex::Regex::new(r"(?m)^\s*(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;").unwrap()
});
static RUST_INCLUDE_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"\binclude!\s*\(\s*"([^"]+)"\s*\)"#).unwrap());
static RUST_PATH_ATTR_RE: Lazy<regex::Regex> =
Lazy::new(|| regex::Regex::new(r#"#\[\s*path\s*=\s*"([^"]+)"\s*\]"#).unwrap());