1use std::collections::BTreeSet;
4use std::path::Path;
5use std::sync::LazyLock;
6
7pub mod definitions;
8pub mod runner;
9pub mod spec;
10
11use definitions::ALL_LANGUAGES;
15use spec::LanguageSupport;
16
17pub fn detect(path: &Path) -> Option<&'static LanguageSupport> {
21 detect_index(path).map(|index| ALL_LANGUAGES[index])
22}
23
24fn detect_index(path: &Path) -> Option<usize> {
30 let ext = path.extension()?.to_str()?;
31 ALL_LANGUAGES.iter().position(|lang| {
36 lang.extensions
37 .iter()
38 .any(|known| known[1..].eq_ignore_ascii_case(ext))
39 })
40}
41
42pub fn group_by_language<'a>(paths: &[&'a Path]) -> Vec<(&'static LanguageSupport, Vec<&'a Path>)> {
61 let mut buckets: Vec<Vec<&'a Path>> = vec![Vec::new(); ALL_LANGUAGES.len()];
62 for path in paths {
63 if let Some(index) = detect_index(path) {
64 buckets[index].push(path);
65 }
66 }
67 buckets
68 .into_iter()
69 .enumerate()
70 .filter(|(_, files)| !files.is_empty())
71 .map(|(index, files)| (ALL_LANGUAGES[index], files))
72 .collect()
73}
74
75pub fn all_languages() -> &'static [&'static LanguageSupport] {
77 ALL_LANGUAGES
78}
79
80pub fn source_extensions() -> &'static [&'static str] {
87 &SOURCE_EXTENSIONS
88}
89
90static SOURCE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
93 let mut seen = BTreeSet::new();
94 let mut out = Vec::new();
95 for lang in ALL_LANGUAGES {
96 for ext in lang.extensions {
97 if seen.insert(*ext) {
98 out.push(*ext);
99 }
100 }
101 }
102 out
103});
104
105pub fn vendored_dirs() -> &'static [&'static str] {
110 &VENDORED_DIRS
111}
112
113static VENDORED_DIRS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
115 let mut seen = BTreeSet::new();
116 let mut out = Vec::new();
117 for lang in ALL_LANGUAGES {
118 for dir in lang.vendored_dirs {
119 if seen.insert(*dir) {
120 out.push(*dir);
121 }
122 }
123 }
124 out
125});
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use std::path::Path;
131
132 #[test]
133 fn detect_python() {
134 assert_eq!(detect(Path::new("foo.py")).map(|l| l.name), Some("python"));
135 }
136
137 #[test]
138 fn detect_javascript() {
139 assert_eq!(
140 detect(Path::new("foo.js")).map(|l| l.name),
141 Some("javascript")
142 );
143 }
144
145 #[test]
146 fn detect_typescript() {
147 assert_eq!(
148 detect(Path::new("foo.ts")).map(|l| l.name),
149 Some("typescript")
150 );
151 }
152
153 #[test]
154 fn detect_go() {
155 assert_eq!(detect(Path::new("foo.go")).map(|l| l.name), Some("go"));
156 }
157
158 #[test]
159 fn detect_rust() {
160 assert_eq!(detect(Path::new("foo.rs")).map(|l| l.name), Some("rust"));
161 }
162
163 #[test]
164 fn unknown_extension_returns_none() {
165 assert!(detect(Path::new("foo.xyz")).is_none());
166 }
167
168 #[test]
169 fn extension_match_is_case_insensitive() {
170 assert_eq!(detect(Path::new("FOO.PY")).map(|l| l.name), Some("python"));
171 assert_eq!(detect(Path::new("Mixed.Go")).map(|l| l.name), Some("go"));
172 }
173
174 #[test]
175 fn tsc_stream_is_stdout_go_vet_stream_is_stderr() {
176 assert_eq!(definitions::TSC.diagnostics_stream, "stdout");
177 assert_eq!(definitions::GO_VET.diagnostics_stream, "stderr");
178 }
179
180 #[test]
189 fn project_compilers_do_not_take_file_arguments() {
190 assert!(
191 !definitions::CLIPPY.accepts_files,
192 "cargo clippy checks a crate; a path argument is rejected outright"
193 );
194 assert!(
195 !definitions::TSC.accepts_files,
196 "passing paths makes tsc ignore tsconfig.json"
197 );
198 for spec in [
199 &definitions::RUFF,
200 &definitions::ESLINT,
201 &definitions::GOFMT,
202 &definitions::GO_VET,
203 ] {
204 assert!(
205 spec.accepts_files,
206 "{} is invoked with the files it should check",
207 spec.name
208 );
209 }
210 }
211
212 #[test]
213 fn only_clippy_is_serialized_within_a_repository() {
214 assert!(
215 definitions::CLIPPY.serial_in_repository,
216 "parallel cargo processes contend for the same build lock"
217 );
218 for spec in [
219 &definitions::RUFF,
220 &definitions::ESLINT,
221 &definitions::TSC,
222 &definitions::GOFMT,
223 &definitions::GO_VET,
224 ] {
225 assert!(
226 !spec.serial_in_repository,
227 "{} should remain eligible for bounded parallel execution",
228 spec.name
229 );
230 }
231 }
232
233 #[test]
234 fn all_languages_returns_every_registered_language() {
235 let langs = all_languages();
236 let names: Vec<&str> = langs.iter().map(|l| l.name).collect();
237 assert_eq!(
238 names,
239 vec![
240 "python",
241 "javascript",
242 "typescript",
243 "go",
244 "rust",
245 "java",
246 "kotlin",
247 "scala",
248 "groovy"
249 ]
250 );
251 }
252
253 #[test]
254 fn source_extensions_contains_python_and_tsx_but_not_markdown() {
255 let exts = source_extensions();
256 assert!(
257 exts.contains(&".py"),
258 "`.py` is owned by python, expected in source_extensions, got {exts:?}"
259 );
260 assert!(
261 exts.contains(&".tsx"),
262 "`.tsx` is owned by typescript, expected in source_extensions, got {exts:?}"
263 );
264 assert!(
265 !exts.contains(&".md"),
266 "markdown is documentation, not a registered language: {exts:?}"
267 );
268 }
269
270 #[test]
277 fn group_by_language_buckets_in_registration_order() {
278 let paths = [
279 Path::new("main.go"),
280 Path::new("a.py"),
281 Path::new("lib.rs"),
282 Path::new("b.py"),
283 ];
284 let grouped = group_by_language(&paths);
285 let names: Vec<&str> = grouped.iter().map(|(lang, _)| lang.name).collect();
286 assert_eq!(
287 names,
288 vec!["python", "go", "rust"],
289 "registration order (python, javascript, typescript, go, rust), \
290 not alphabetical and not first-seen"
291 );
292 assert_eq!(
293 grouped[0].1,
294 vec![Path::new("a.py"), Path::new("b.py")],
295 "files land in their own bucket, in the order given"
296 );
297 }
298
299 #[test]
302 fn group_by_language_omits_empty_buckets_and_unknown_extensions() {
303 let grouped = group_by_language(&[Path::new("notes.md"), Path::new("data.xyz")]);
304 assert!(
305 grouped.is_empty(),
306 "no registered language claims these, so there is nothing to report: {:?}",
307 grouped.iter().map(|(l, _)| l.name).collect::<Vec<_>>()
308 );
309
310 let grouped = group_by_language(&[Path::new("a.py"), Path::new("notes.md")]);
311 assert_eq!(grouped.len(), 1, "only python is present");
312 assert_eq!(grouped[0].0.name, "python");
313 assert_eq!(
314 grouped[0].1,
315 vec![Path::new("a.py")],
316 "the markdown file is dropped, not attached to python"
317 );
318 }
319
320 #[test]
325 fn jvm_extensions_resolve_to_their_languages() {
326 for (path, expected) in [
327 ("Main.java", "java"),
328 ("Main.kt", "kotlin"),
329 ("Main.kts", "kotlin"),
330 ("Main.scala", "scala"),
331 ("Main.sc", "scala"),
332 ("Main.groovy", "groovy"),
333 ("build.gradle", "groovy"),
334 ] {
335 let detected = detect(Path::new(path));
336 assert_eq!(
337 detected.map(|lang| lang.name),
338 Some(expected),
339 "{path} should resolve to {expected}"
340 );
341 }
342 }
343
344 #[test]
348 fn gradle_kts_is_kotlin_not_groovy() {
349 assert_eq!(
350 detect(Path::new("build.gradle.kts")).map(|lang| lang.name),
351 Some("kotlin")
352 );
353 }
354
355 #[test]
356 fn jvm_build_directories_are_vendored() {
357 let dirs = vendored_dirs();
358 for expected in ["build", ".gradle", "target"] {
359 assert!(
360 dirs.contains(&expected),
361 "{expected} is a JVM build output and should never be descended into, got {dirs:?}"
362 );
363 }
364 }
365
366 #[test]
367 fn vendored_dirs_collects_unique_entries_across_languages() {
368 let dirs = vendored_dirs();
369 for expected in ["node_modules", "venv", "target"] {
370 assert!(
371 dirs.contains(&expected),
372 "{expected} should be in vendored_dirs, got {dirs:?}"
373 );
374 }
375 let count = dirs.iter().filter(|d| **d == "node_modules").count();
376 assert_eq!(
377 count, 1,
378 "JavaScript and TypeScript both declare node_modules; the set must collapse"
379 );
380 }
381}