use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use ignore::WalkBuilder;
use crate::loc::language::{LanguageSpec, detect, detect_by_shebang};
pub const TEST_DIRS: &[&str] = &["tests", "test", "__tests__", "spec"];
struct TestPattern {
exts: &'static [&'static str],
suffixes: &'static [&'static str],
prefixes: &'static [&'static str],
}
const TEST_PATTERNS: &[TestPattern] = &[
TestPattern {
exts: &["rs", "go", "exs", "dart"],
suffixes: &["_test"],
prefixes: &[],
},
TestPattern {
exts: &["py"],
suffixes: &["_test"],
prefixes: &["test_"],
},
TestPattern {
exts: &["rb"],
suffixes: &["_test", "_spec"],
prefixes: &[],
},
TestPattern {
exts: &["php"],
suffixes: &["Test", "_test"],
prefixes: &[],
},
TestPattern {
exts: &["js", "jsx", "mjs", "cjs", "ts", "tsx", "mts", "cts"],
suffixes: &[".test", ".spec"],
prefixes: &[],
},
TestPattern {
exts: &["java", "kt", "kts", "cs", "swift"],
suffixes: &["Test", "Tests"],
prefixes: &[],
},
TestPattern {
exts: &["scala"],
suffixes: &["Test", "Spec"],
prefixes: &[],
},
TestPattern {
exts: &["c"],
suffixes: &["_test", "_unittest"],
prefixes: &["test_"],
},
TestPattern {
exts: &["cc", "cpp", "cxx"],
suffixes: &["_test", "_unittest", "Test"],
prefixes: &["test_"],
},
TestPattern {
exts: &["hs"],
suffixes: &["Test", "Spec"],
prefixes: &[],
},
];
pub fn is_test_file(path: &Path) -> bool {
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => return false,
};
let Some(dot) = file_name.rfind('.') else {
return false;
};
let ext = &file_name[dot + 1..];
let base = &file_name[..dot];
for pattern in TEST_PATTERNS {
if !pattern.exts.contains(&ext) {
continue;
}
if pattern.suffixes.iter().any(|s| base.ends_with(s)) {
return true;
}
if pattern.prefixes.iter().any(|p| base.starts_with(p)) {
return true;
}
}
false
}
pub fn try_detect_shebang(path: &Path) -> Option<&'static LanguageSpec> {
let file = File::open(path).ok()?;
let mut reader = BufReader::new(file);
let mut first_line = String::new();
reader.read_line(&mut first_line).ok()?;
detect_by_shebang(&first_line)
}
pub fn source_files(path: &Path, exclude_tests: bool) -> Vec<(PathBuf, &'static LanguageSpec)> {
let mut result = Vec::new();
for entry in walk(path, exclude_tests) {
let entry = match entry {
Ok(e) => e,
Err(err) => {
eprintln!("warning: {err}");
continue;
}
};
if !entry.file_type().is_some_and(|ft| ft.is_file()) {
continue;
}
let file_path = entry.path();
if exclude_tests && is_test_file(file_path) {
continue;
}
let spec = match detect(file_path) {
Some(s) => s,
None => match try_detect_shebang(file_path) {
Some(s) => s,
None => continue,
},
};
result.push((file_path.to_path_buf(), spec));
}
result
}
pub fn collect_analysis<T>(
path: &Path,
exclude_tests: bool,
f: impl Fn(&Path, &LanguageSpec) -> Result<Option<T>, Box<dyn std::error::Error>>,
) -> Vec<T> {
let mut results = Vec::new();
for (file_path, spec) in source_files(path, exclude_tests) {
match f(&file_path, spec) {
Ok(Some(m)) => results.push(m),
Ok(None) => {}
Err(err) => {
eprintln!("warning: {}: {err}", file_path.display());
}
}
}
results
}
pub fn walk(path: &Path, exclude_tests: bool) -> ignore::Walk {
WalkBuilder::new(path)
.hidden(false)
.follow_links(false)
.filter_entry(move |entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
if entry.file_name() == ".git" {
return false;
}
if exclude_tests
&& let Some(name) = entry.file_name().to_str()
&& TEST_DIRS.contains(&name)
{
return false;
}
}
true
})
.build()
}