use std::path::Path;
use crate::language::{LangId, spec_for};
use std::collections::HashMap;
use std::sync::LazyLock;
static EXT_MAP: LazyLock<HashMap<&'static str, LangId>> = LazyLock::new(|| {
LangId::all()
.iter()
.flat_map(|&id| {
let spec = spec_for(id);
spec.extensions.iter().map(move |&ext| (ext, id))
})
.collect()
});
pub fn detect_language(path: &Path) -> Option<LangId> {
let ext = path.extension()?.to_str()?.to_ascii_lowercase();
EXT_MAP.get(ext.as_str()).copied()
}
pub fn simplified_path(path: &Path) -> std::path::PathBuf {
dunce::simplified(path).to_path_buf()
}
pub fn portable_path(path: &Path) -> String {
let simplified = dunce::simplified(path);
let value = simplified.to_string_lossy();
#[cfg(windows)]
{
value.replace('\\', "/")
}
#[cfg(not(windows))]
{
value.into_owned()
}
}
pub fn discover_files(
root: &Path,
languages: Option<&[LangId]>,
) -> Result<Vec<(std::path::PathBuf, LangId)>, std::io::Error> {
let mut results = Vec::new();
if root.is_file() {
if let Some(lang_id) = detect_language(root)
&& languages.is_none_or(|langs| langs.contains(&lang_id))
{
results.push((root.to_path_buf(), lang_id));
}
return Ok(results);
}
if !root.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("path does not exist: {}", root.display()),
));
}
for entry in ignore::WalkBuilder::new(root)
.build()
.filter_map(|e| e.ok())
{
let path = simplified_path(&entry.into_path());
if !path.is_file() {
continue;
}
if let Some(lang_id) = detect_language(&path) {
results.push((path, lang_id));
}
}
if let Some(langs) = languages {
results.retain(|(_, id)| langs.contains(id));
}
results.sort_by(|a, b| a.0.cmp(&b.0));
Ok(results)
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::language::LangId;
#[test]
fn detect_python_extensions() {
assert_eq!(
detect_language(&PathBuf::from("foo.py")),
Some(LangId::Python)
);
assert_eq!(
detect_language(&PathBuf::from("foo.pyi")),
Some(LangId::Python)
);
}
#[test]
fn detect_javascript_extensions() {
assert_eq!(
detect_language(&PathBuf::from("foo.js")),
Some(LangId::JavaScript)
);
assert_eq!(
detect_language(&PathBuf::from("foo.mjs")),
Some(LangId::JavaScript)
);
assert_eq!(
detect_language(&PathBuf::from("foo.cjs")),
Some(LangId::JavaScript)
);
}
#[test]
fn detect_typescript_extensions() {
assert_eq!(
detect_language(&PathBuf::from("foo.ts")),
Some(LangId::TypeScript)
);
assert_eq!(
detect_language(&PathBuf::from("foo.cts")),
Some(LangId::TypeScript)
);
assert_eq!(
detect_language(&PathBuf::from("foo.mts")),
Some(LangId::TypeScript)
);
}
#[test]
fn detect_tsx_extension() {
assert_eq!(
detect_language(&PathBuf::from("foo.tsx")),
Some(LangId::Tsx)
);
assert_ne!(
detect_language(&PathBuf::from("foo.tsx")),
Some(LangId::TypeScript)
);
}
#[test]
fn detect_c_cpp_extensions() {
assert_eq!(detect_language(&PathBuf::from("foo.c")), Some(LangId::C));
assert_eq!(detect_language(&PathBuf::from("foo.cc")), Some(LangId::Cpp));
assert_eq!(
detect_language(&PathBuf::from("foo.cpp")),
Some(LangId::Cpp)
);
assert_eq!(
detect_language(&PathBuf::from("foo.cxx")),
Some(LangId::Cpp)
);
}
#[test]
fn detect_rust_go() {
assert_eq!(
detect_language(&PathBuf::from("foo.rs")),
Some(LangId::Rust)
);
assert_eq!(detect_language(&PathBuf::from("foo.go")), Some(LangId::Go));
}
#[test]
fn detect_unknown_returns_none() {
assert_eq!(detect_language(&PathBuf::from("foo.md")), None);
assert_eq!(detect_language(&PathBuf::from("foo.txt")), None);
assert_eq!(detect_language(&PathBuf::from("README")), None);
}
#[test]
fn detect_case_insensitive() {
assert_eq!(
detect_language(&PathBuf::from("foo.PY")),
Some(LangId::Python)
);
assert_eq!(
detect_language(&PathBuf::from("foo.Rs")),
Some(LangId::Rust)
);
}
#[test]
fn discover_files_finds_fixtures() {
let root = PathBuf::from("tests/fixtures/python");
let files = discover_files(&root, None).unwrap();
assert!(!files.is_empty());
assert!(files.iter().all(|(_, lang)| *lang == LangId::Python));
}
#[test]
fn discover_files_filters_by_language() {
let root = PathBuf::from("tests/fixtures/mixed");
let files = discover_files(&root, Some(&[LangId::Python])).unwrap();
assert!(files.iter().all(|(_, lang)| *lang == LangId::Python));
assert!(!files.is_empty());
}
#[test]
fn discover_files_empty_dir() {
let tmp = std::env::temp_dir().join("meta_ast_test_empty");
std::fs::create_dir_all(&tmp).unwrap();
let files = discover_files(&tmp, None).unwrap();
assert!(files.is_empty());
std::fs::remove_dir(&tmp).unwrap();
}
#[test]
fn discover_single_file() {
let path = PathBuf::from("tests/fixtures/python/simple_functions.py");
let files = discover_files(&path, None).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].1, LangId::Python);
}
#[test]
fn discover_single_file_honors_language_filter() {
let path = PathBuf::from("tests/fixtures/mixed/app.py");
let matching = discover_files(&path, Some(&[LangId::Python])).unwrap();
assert_eq!(matching.len(), 1);
assert_eq!(matching[0].1, LangId::Python);
let excluded = discover_files(&path, Some(&[LangId::Go])).unwrap();
assert!(excluded.is_empty());
}
#[test]
fn discover_files_respects_gitignore() {
let root = PathBuf::from("tests/fixtures/mixed");
let files = discover_files(&root, None).unwrap();
let paths: Vec<_> = files
.iter()
.map(|(p, _)| p.file_name().unwrap().to_str().unwrap())
.collect();
assert!(
!paths.contains(&"test.generated.py"),
"gitignored file should be excluded"
);
}
#[test]
fn discover_files_sorted() {
let root = PathBuf::from("tests/fixtures/mixed");
let files = discover_files(&root, None).unwrap();
let paths: Vec<_> = files.iter().map(|(p, _)| p).collect();
let mut sorted = paths.clone();
sorted.sort();
assert_eq!(paths, sorted);
}
#[test]
fn ext_map_covers_all_catalog_extensions() {
for id in LangId::all() {
let spec = spec_for(id);
for &ext in spec.extensions {
assert_eq!(
detect_language(&PathBuf::from(format!("foo.{ext}"))),
Some(id),
"extension {ext:?} should map to {id:?}"
);
}
}
}
#[test]
fn portable_path_uses_dunce_and_preserves_unix_backslash() {
use std::path::Path;
let slash = portable_path(Path::new("a/b.py"));
let backslash = portable_path(Path::new("a\\b.py"));
assert_eq!(slash, "a/b.py");
#[cfg(not(windows))]
assert_ne!(backslash, slash);
#[cfg(windows)]
assert_eq!(backslash, slash);
}
#[test]
fn simplified_path_returns_owned_path() {
use std::path::Path;
let out = simplified_path(Path::new("a/b.py"));
assert_eq!(out, PathBuf::from("a/b.py"));
}
}