1use crate::types::Language;
32use std::collections::HashSet;
33use std::path::{Path, PathBuf};
34
35#[derive(Debug, thiserror::Error)]
37pub enum AffectedError {
38 #[error("not inside a git repository: {0}")]
39 NotARepo(PathBuf),
40 #[error("git command failed: {0}")]
41 GitFailed(String),
42 #[error("io error: {0}")]
43 Io(String),
44}
45
46pub struct AffectedOptions<'a> {
48 pub cwd: PathBuf,
50 pub base: String,
52 pub language: Language,
53 pub patterns: Vec<String>,
55 pub extra_changed_files: Vec<String>,
58 pub git: &'a dyn GitRunner,
59 pub fs: &'a dyn Fs,
60 pub import_extractor: Option<&'a dyn ImportExtractor>,
66}
67
68pub trait ImportExtractor {
77 fn extract(&self, src: &str, lang: Language) -> Vec<String>;
78}
79
80pub struct RegexImportExtractor;
85
86impl ImportExtractor for RegexImportExtractor {
87 fn extract(&self, src: &str, lang: Language) -> Vec<String> {
88 extract_specifiers(src, lang)
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct AffectedSet {
95 pub base: String,
96 pub changed_files: Vec<String>,
97 pub bench_files: Vec<PathBuf>,
99 pub all_bench_files: Vec<PathBuf>,
102}
103
104pub trait GitRunner {
105 fn run(&self, args: &[&str], cwd: &Path) -> Result<String, AffectedError>;
106}
107
108pub trait Fs {
109 fn read_to_string(&self, path: &Path) -> Result<String, AffectedError>;
110 fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>, AffectedError>;
111 fn metadata(&self, path: &Path) -> Result<EntryKind, AffectedError>;
112}
113
114#[derive(Debug, Clone)]
115pub struct DirEntry {
116 pub path: PathBuf,
117 pub kind: EntryKind,
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum EntryKind {
122 File,
123 Dir,
124 Other,
125}
126
127pub fn resolve_affected(opts: &AffectedOptions<'_>) -> Result<AffectedSet, AffectedError> {
129 let all_bench_files = discover_benches(&opts.cwd, &opts.patterns, opts.language, opts.fs)?;
130 if all_bench_files.is_empty() {
131 return Ok(AffectedSet {
132 base: opts.base.clone(),
133 changed_files: vec![],
134 bench_files: vec![],
135 all_bench_files: vec![],
136 });
137 }
138
139 let repo_root = detect_repo_root(&opts.cwd, opts.git)?;
140 let mut changed_rel = git_changed_files(&repo_root, &opts.base, opts.git)?;
141 for extra in &opts.extra_changed_files {
142 changed_rel.push(extra.clone());
143 }
144 let changed_abs: HashSet<PathBuf> = changed_rel
145 .iter()
146 .map(|f| normalize_path(&repo_root.join(f)))
147 .collect();
148
149 let default_extractor = RegexImportExtractor;
150 let extractor: &dyn ImportExtractor = match opts.import_extractor {
151 Some(e) => e,
152 None => &default_extractor,
153 };
154
155 let mut bench_files: Vec<PathBuf> = Vec::new();
156 for bench in &all_bench_files {
157 let reachable = collect_reachable_with(bench, opts.language, opts.fs, extractor);
158 if reachable.iter().any(|f| changed_abs.contains(f)) {
159 bench_files.push(bench.clone());
160 }
161 }
162
163 Ok(AffectedSet {
164 base: opts.base.clone(),
165 changed_files: changed_rel,
166 bench_files,
167 all_bench_files,
168 })
169}
170
171fn discover_benches(
174 cwd: &Path,
175 patterns: &[String],
176 lang: Language,
177 fs: &dyn Fs,
178) -> Result<Vec<PathBuf>, AffectedError> {
179 let mut globs: Vec<&str> = patterns.iter().map(|s| s.as_str()).collect();
180 if globs.is_empty() {
181 globs.extend(lang.default_globs().iter().copied());
182 }
183 let matchers: Vec<GlobMatcher> = globs.iter().map(|g| GlobMatcher::new(g)).collect();
184 let excludes = &["node_modules", "dist", "build", ".git", "target", "vendor"];
185 let mut out: Vec<PathBuf> = Vec::new();
186 walk(cwd, fs, &matchers, excludes, &mut out)?;
187 out.sort();
188 out.dedup();
189 Ok(out)
190}
191
192fn walk(
193 root: &Path,
194 fs: &dyn Fs,
195 matchers: &[GlobMatcher],
196 excludes: &[&str],
197 out: &mut Vec<PathBuf>,
198) -> Result<(), AffectedError> {
199 let mut stack: Vec<PathBuf> = vec![root.to_path_buf()];
200 while let Some(dir) = stack.pop() {
201 let entries = match fs.read_dir(&dir) {
202 Ok(e) => e,
203 Err(_) => continue,
204 };
205 for e in entries {
206 let name = e.path.file_name().and_then(|s| s.to_str()).unwrap_or("");
207 if excludes.iter().any(|ex| ex == &name) {
208 continue;
209 }
210 match e.kind {
211 EntryKind::Dir => stack.push(e.path),
212 EntryKind::File => {
213 if matchers.iter().any(|m| m.matches(&e.path)) {
214 out.push(e.path);
215 }
216 }
217 EntryKind::Other => {}
218 }
219 }
220 }
221 Ok(())
222}
223
224pub fn collect_reachable(entry: &Path, lang: Language, fs: &dyn Fs) -> HashSet<PathBuf> {
234 collect_reachable_with(entry, lang, fs, &RegexImportExtractor)
235}
236
237pub fn collect_reachable_with(
242 entry: &Path,
243 lang: Language,
244 fs: &dyn Fs,
245 extractor: &dyn ImportExtractor,
246) -> HashSet<PathBuf> {
247 let mut seen: HashSet<PathBuf> = HashSet::new();
248 let mut stack: Vec<PathBuf> = vec![normalize_path(entry)];
249 while let Some(file) = stack.pop() {
250 if !seen.insert(file.clone()) {
251 continue;
252 }
253 let src = match fs.read_to_string(&file) {
254 Ok(s) => s,
255 Err(_) => continue,
256 };
257 let specifiers = extractor.extract(&src, lang);
258 let from_dir = file.parent().unwrap_or(Path::new(".")).to_path_buf();
259 for spec in specifiers {
260 if !is_relative_spec(&spec, lang) {
261 continue;
262 }
263 for resolved in resolve_import(&from_dir, &spec, lang, fs) {
264 if !seen.contains(&resolved) {
265 stack.push(resolved);
266 }
267 }
268 }
269 }
270 seen
271}
272
273pub fn extract_specifiers(src: &str, lang: Language) -> Vec<String> {
279 let stripped = strip_comments(src, lang);
280 let mut out: Vec<String> = Vec::new();
281 match lang {
282 Language::Ts => {
283 for caps in TS_FROM_RE.captures_iter(&stripped) {
284 out.push(caps[1].to_string());
285 }
286 for caps in TS_SIDE_EFFECT_RE.captures_iter(&stripped) {
287 out.push(caps[1].to_string());
288 }
289 for caps in TS_CALL_RE.captures_iter(&stripped) {
290 out.push(caps[1].to_string());
291 }
292 }
293 Language::Go => {
294 for caps in GO_SINGLE_IMPORT_RE.captures_iter(&stripped) {
296 out.push(caps[1].to_string());
297 }
298 for caps in GO_BLOCK_IMPORT_RE.captures_iter(&stripped) {
299 let block = &caps[1];
300 for sub in GO_BLOCK_PATH_RE.captures_iter(block) {
301 out.push(sub[1].to_string());
302 }
303 }
304 }
305 Language::Rust => {
306 for caps in RUST_MOD_RE.captures_iter(&stripped) {
308 out.push(format!("./{}", &caps[1]));
309 }
310 for caps in RUST_INCLUDE_RE.captures_iter(&stripped) {
312 out.push(prefix_relative(&caps[1]));
313 }
314 for caps in RUST_PATH_ATTR_RE.captures_iter(&stripped) {
318 out.push(prefix_relative(&caps[1]));
319 }
320 }
321 }
322 out
323}
324
325fn prefix_relative(p: &str) -> String {
327 if p.starts_with("./") || p.starts_with("../") || p.starts_with('/') {
328 p.to_string()
329 } else {
330 format!("./{p}")
331 }
332}
333
334fn strip_comments(src: &str, lang: Language) -> String {
335 match lang {
336 Language::Ts | Language::Rust => {
337 let no_block = BLOCK_COMMENT_RE.replace_all(src, "").to_string();
341 LINE_COMMENT_RE.replace_all(&no_block, "$1").to_string()
342 }
343 Language::Go => {
344 let no_block = BLOCK_COMMENT_RE.replace_all(src, "").to_string();
346 LINE_COMMENT_RE.replace_all(&no_block, "$1").to_string()
347 }
348 }
349}
350
351fn is_relative_spec(spec: &str, lang: Language) -> bool {
352 match lang {
353 Language::Ts => {
354 spec.starts_with("./")
355 || spec.starts_with("../")
356 || spec == "."
357 || spec == ".."
358 || spec.starts_with('/')
359 }
360 Language::Go => {
361 spec.starts_with("./") || spec.starts_with("../")
367 }
368 Language::Rust => {
369 spec.starts_with("./") || spec.starts_with("../")
373 }
374 }
375}
376
377pub fn resolve_import(from_dir: &Path, spec: &str, lang: Language, fs: &dyn Fs) -> Vec<PathBuf> {
385 let exts = lang.source_extensions();
386 let base = normalize_path(&from_dir.join(spec));
387
388 for ext in exts {
390 if spec.ends_with(ext) {
391 return if file_exists(&base, fs) {
392 vec![base]
393 } else {
394 vec![]
395 };
396 }
397 }
398
399 if matches!(lang, Language::Go) {
401 if let Ok(EntryKind::Dir) = fs.metadata(&base) {
402 if let Ok(entries) = fs.read_dir(&base) {
403 let mut out: Vec<PathBuf> = entries
404 .into_iter()
405 .filter(|e| matches!(e.kind, EntryKind::File))
406 .filter(|e| {
407 e.path
408 .extension()
409 .and_then(|s| s.to_str())
410 .map(|s| s == "go")
411 .unwrap_or(false)
412 })
413 .map(|e| e.path)
414 .collect();
415 out.sort();
416 return out;
417 }
418 }
419 }
422
423 for ext in exts {
425 let cand = path_with_ext(&base, ext);
426 if file_exists(&cand, fs) {
427 return vec![cand];
428 }
429 }
430 if let Ok(EntryKind::Dir) = fs.metadata(&base) {
432 let index_names: &[&str] = match lang {
433 Language::Ts => &["index"],
434 Language::Rust => &["mod"],
435 Language::Go => &[],
436 };
437 for stem in index_names {
438 for ext in exts {
439 let cand = base.join(format!("{stem}{ext}"));
440 if file_exists(&cand, fs) {
441 return vec![cand];
442 }
443 }
444 }
445 }
446 vec![]
447}
448
449fn normalize_path(p: &Path) -> PathBuf {
453 use std::path::Component;
454 let mut out = PathBuf::new();
455 for c in p.components() {
456 match c {
457 Component::CurDir => {}
458 Component::ParentDir => {
459 out.pop();
460 }
461 other => out.push(other.as_os_str()),
462 }
463 }
464 out
465}
466
467fn path_with_ext(p: &Path, ext: &str) -> PathBuf {
468 let mut s = p.to_path_buf().into_os_string();
469 s.push(ext);
470 PathBuf::from(s)
471}
472
473fn file_exists(p: &Path, fs: &dyn Fs) -> bool {
474 matches!(fs.metadata(p), Ok(EntryKind::File))
475}
476
477fn detect_repo_root(cwd: &Path, git: &dyn GitRunner) -> Result<PathBuf, AffectedError> {
480 let out = git.run(&["rev-parse", "--show-toplevel"], cwd)?;
481 let trimmed = out.trim();
482 if trimmed.is_empty() {
483 return Err(AffectedError::NotARepo(cwd.to_path_buf()));
484 }
485 Ok(PathBuf::from(trimmed))
486}
487
488fn git_changed_files(
489 repo_root: &Path,
490 base: &str,
491 git: &dyn GitRunner,
492) -> Result<Vec<String>, AffectedError> {
493 let triple_dot = format!("{}...HEAD", base);
494 let out = git.run(&["diff", "--name-only", &triple_dot], repo_root)?;
495 let lines: Vec<String> = out
496 .split('\n')
497 .map(|l| l.trim().to_string())
498 .filter(|l| !l.is_empty())
499 .collect();
500 Ok(lines)
501}
502
503#[derive(Debug)]
508pub struct GlobMatcher {
509 re: regex::Regex,
510}
511
512impl GlobMatcher {
513 pub fn new(pattern: &str) -> Self {
514 let mut rx = String::with_capacity(pattern.len() * 2);
515 rx.push('^');
516 let chars: Vec<char> = pattern.chars().collect();
517 let mut i = 0;
518 while i < chars.len() {
519 let c = chars[i];
520 let next = chars.get(i + 1).copied();
521 if c == '*' && next == Some('*') {
522 rx.push_str(".*");
523 i += 2;
524 if chars.get(i) == Some(&'/') {
525 i += 1;
526 }
527 } else if c == '*' {
528 rx.push_str("[^/]*");
529 i += 1;
530 } else if c == '?' {
531 rx.push_str("[^/]");
532 i += 1;
533 } else if matches!(
534 c,
535 '.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\'
536 ) {
537 rx.push('\\');
538 rx.push(c);
539 i += 1;
540 } else {
541 rx.push(c);
542 i += 1;
543 }
544 }
545 rx.push('$');
546 let re = regex::Regex::new(&rx).expect("internal: glob regex compile failed");
547 Self { re }
548 }
549 pub fn matches(&self, p: &Path) -> bool {
550 let s = p.to_string_lossy();
551 self.re.is_match(&s)
552 }
553}
554
555use once_cell::sync::Lazy;
558
559static BLOCK_COMMENT_RE: Lazy<regex::Regex> =
560 Lazy::new(|| regex::Regex::new(r"(?s)/\*.*?\*/").unwrap());
561static LINE_COMMENT_RE: Lazy<regex::Regex> =
562 Lazy::new(|| regex::Regex::new(r"(^|[^:])//[^\n]*").unwrap());
563
564static TS_FROM_RE: Lazy<regex::Regex> =
565 Lazy::new(|| regex::Regex::new(r#"\bfrom\s+['"]([^'"]+)['"]"#).unwrap());
566static TS_SIDE_EFFECT_RE: Lazy<regex::Regex> =
567 Lazy::new(|| regex::Regex::new(r#"\bimport\s+['"]([^'"]+)['"]"#).unwrap());
568static TS_CALL_RE: Lazy<regex::Regex> = Lazy::new(|| {
569 regex::Regex::new(r#"\b(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)"#).unwrap()
570});
571
572static GO_SINGLE_IMPORT_RE: Lazy<regex::Regex> =
573 Lazy::new(|| regex::Regex::new(r#"(?m)^\s*import\s+(?:[A-Za-z_]\w*\s+)?"([^"]+)""#).unwrap());
574static GO_BLOCK_IMPORT_RE: Lazy<regex::Regex> =
575 Lazy::new(|| regex::Regex::new(r#"(?s)import\s*\(\s*(.*?)\s*\)"#).unwrap());
576static GO_BLOCK_PATH_RE: Lazy<regex::Regex> =
577 Lazy::new(|| regex::Regex::new(r#"(?m)^\s*(?:[A-Za-z_]\w*\s+)?"([^"]+)""#).unwrap());
578
579static RUST_MOD_RE: Lazy<regex::Regex> = Lazy::new(|| {
580 regex::Regex::new(r"(?m)^\s*(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+([A-Za-z_]\w*)\s*;").unwrap()
582});
583static RUST_INCLUDE_RE: Lazy<regex::Regex> =
584 Lazy::new(|| regex::Regex::new(r#"\binclude!\s*\(\s*"([^"]+)"\s*\)"#).unwrap());
585static RUST_PATH_ATTR_RE: Lazy<regex::Regex> =
586 Lazy::new(|| regex::Regex::new(r#"#\[\s*path\s*=\s*"([^"]+)"\s*\]"#).unwrap());