use std::collections::BTreeSet;
use std::path::Path;
use std::sync::LazyLock;
pub mod definitions;
pub mod runner;
pub mod spec;
use definitions::ALL_LANGUAGES;
use spec::LanguageSupport;
pub fn detect(path: &Path) -> Option<&'static LanguageSupport> {
detect_index(path).map(|index| ALL_LANGUAGES[index])
}
fn detect_index(path: &Path) -> Option<usize> {
let ext = path.extension()?.to_str()?;
ALL_LANGUAGES.iter().position(|lang| {
lang.extensions
.iter()
.any(|known| known[1..].eq_ignore_ascii_case(ext))
})
}
pub fn group_by_language<'a>(paths: &[&'a Path]) -> Vec<(&'static LanguageSupport, Vec<&'a Path>)> {
let mut buckets: Vec<Vec<&'a Path>> = vec![Vec::new(); ALL_LANGUAGES.len()];
for path in paths {
if let Some(index) = detect_index(path) {
buckets[index].push(path);
}
}
buckets
.into_iter()
.enumerate()
.filter(|(_, files)| !files.is_empty())
.map(|(index, files)| (ALL_LANGUAGES[index], files))
.collect()
}
pub fn all_languages() -> &'static [&'static LanguageSupport] {
ALL_LANGUAGES
}
pub fn source_extensions() -> &'static [&'static str] {
&SOURCE_EXTENSIONS
}
static SOURCE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for lang in ALL_LANGUAGES {
for ext in lang.extensions {
if seen.insert(*ext) {
out.push(*ext);
}
}
}
out
});
pub fn vendored_dirs() -> &'static [&'static str] {
&VENDORED_DIRS
}
static VENDORED_DIRS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
let mut seen = BTreeSet::new();
let mut out = Vec::new();
for lang in ALL_LANGUAGES {
for dir in lang.vendored_dirs {
if seen.insert(*dir) {
out.push(*dir);
}
}
}
out
});
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn detect_python() {
assert_eq!(detect(Path::new("foo.py")).map(|l| l.name), Some("python"));
}
#[test]
fn detect_javascript() {
assert_eq!(
detect(Path::new("foo.js")).map(|l| l.name),
Some("javascript")
);
}
#[test]
fn detect_typescript() {
assert_eq!(
detect(Path::new("foo.ts")).map(|l| l.name),
Some("typescript")
);
}
#[test]
fn detect_go() {
assert_eq!(detect(Path::new("foo.go")).map(|l| l.name), Some("go"));
}
#[test]
fn detect_rust() {
assert_eq!(detect(Path::new("foo.rs")).map(|l| l.name), Some("rust"));
}
#[test]
fn unknown_extension_returns_none() {
assert!(detect(Path::new("foo.xyz")).is_none());
}
#[test]
fn extension_match_is_case_insensitive() {
assert_eq!(detect(Path::new("FOO.PY")).map(|l| l.name), Some("python"));
assert_eq!(detect(Path::new("Mixed.Go")).map(|l| l.name), Some("go"));
}
#[test]
fn tsc_stream_is_stdout_go_vet_stream_is_stderr() {
assert_eq!(definitions::TSC.diagnostics_stream, "stdout");
assert_eq!(definitions::GO_VET.diagnostics_stream, "stderr");
}
#[test]
fn clippy_is_the_only_tool_that_cannot_take_file_arguments() {
assert!(
!definitions::CLIPPY.accepts_files,
"cargo clippy checks a crate; a path argument is rejected outright"
);
for spec in [
&definitions::RUFF,
&definitions::ESLINT,
&definitions::TSC,
&definitions::GOFMT,
&definitions::GO_VET,
] {
assert!(
spec.accepts_files,
"{} is invoked with the files it should check",
spec.name
);
}
}
#[test]
fn all_languages_returns_every_registered_language() {
let langs = all_languages();
let names: Vec<&str> = langs.iter().map(|l| l.name).collect();
assert_eq!(
names,
vec!["python", "javascript", "typescript", "go", "rust"]
);
}
#[test]
fn source_extensions_contains_python_and_tsx_but_not_markdown() {
let exts = source_extensions();
assert!(
exts.contains(&".py"),
"`.py` is owned by python, expected in source_extensions, got {exts:?}"
);
assert!(
exts.contains(&".tsx"),
"`.tsx` is owned by typescript, expected in source_extensions, got {exts:?}"
);
assert!(
!exts.contains(&".md"),
"markdown is documentation, not a registered language: {exts:?}"
);
}
#[test]
fn group_by_language_buckets_in_registration_order() {
let paths = [
Path::new("main.go"),
Path::new("a.py"),
Path::new("lib.rs"),
Path::new("b.py"),
];
let grouped = group_by_language(&paths);
let names: Vec<&str> = grouped.iter().map(|(lang, _)| lang.name).collect();
assert_eq!(
names,
vec!["python", "go", "rust"],
"registration order (python, javascript, typescript, go, rust), \
not alphabetical and not first-seen"
);
assert_eq!(
grouped[0].1,
vec![Path::new("a.py"), Path::new("b.py")],
"files land in their own bucket, in the order given"
);
}
#[test]
fn group_by_language_omits_empty_buckets_and_unknown_extensions() {
let grouped = group_by_language(&[Path::new("notes.md"), Path::new("data.xyz")]);
assert!(
grouped.is_empty(),
"no registered language claims these, so there is nothing to report: {:?}",
grouped.iter().map(|(l, _)| l.name).collect::<Vec<_>>()
);
let grouped = group_by_language(&[Path::new("a.py"), Path::new("notes.md")]);
assert_eq!(grouped.len(), 1, "only python is present");
assert_eq!(grouped[0].0.name, "python");
assert_eq!(
grouped[0].1,
vec![Path::new("a.py")],
"the markdown file is dropped, not attached to python"
);
}
#[test]
fn vendored_dirs_collects_unique_entries_across_languages() {
let dirs = vendored_dirs();
for expected in ["node_modules", "venv", "target"] {
assert!(
dirs.contains(&expected),
"{expected} should be in vendored_dirs, got {dirs:?}"
);
}
let count = dirs.iter().filter(|d| **d == "node_modules").count();
assert_eq!(
count, 1,
"JavaScript and TypeScript both declare node_modules; the set must collapse"
);
}
}