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!["python", "javascript", "typescript", "go", "rust"]
240 );
241 }
242
243 #[test]
244 fn source_extensions_contains_python_and_tsx_but_not_markdown() {
245 let exts = source_extensions();
246 assert!(
247 exts.contains(&".py"),
248 "`.py` is owned by python, expected in source_extensions, got {exts:?}"
249 );
250 assert!(
251 exts.contains(&".tsx"),
252 "`.tsx` is owned by typescript, expected in source_extensions, got {exts:?}"
253 );
254 assert!(
255 !exts.contains(&".md"),
256 "markdown is documentation, not a registered language: {exts:?}"
257 );
258 }
259
260 #[test]
267 fn group_by_language_buckets_in_registration_order() {
268 let paths = [
269 Path::new("main.go"),
270 Path::new("a.py"),
271 Path::new("lib.rs"),
272 Path::new("b.py"),
273 ];
274 let grouped = group_by_language(&paths);
275 let names: Vec<&str> = grouped.iter().map(|(lang, _)| lang.name).collect();
276 assert_eq!(
277 names,
278 vec!["python", "go", "rust"],
279 "registration order (python, javascript, typescript, go, rust), \
280 not alphabetical and not first-seen"
281 );
282 assert_eq!(
283 grouped[0].1,
284 vec![Path::new("a.py"), Path::new("b.py")],
285 "files land in their own bucket, in the order given"
286 );
287 }
288
289 #[test]
292 fn group_by_language_omits_empty_buckets_and_unknown_extensions() {
293 let grouped = group_by_language(&[Path::new("notes.md"), Path::new("data.xyz")]);
294 assert!(
295 grouped.is_empty(),
296 "no registered language claims these, so there is nothing to report: {:?}",
297 grouped.iter().map(|(l, _)| l.name).collect::<Vec<_>>()
298 );
299
300 let grouped = group_by_language(&[Path::new("a.py"), Path::new("notes.md")]);
301 assert_eq!(grouped.len(), 1, "only python is present");
302 assert_eq!(grouped[0].0.name, "python");
303 assert_eq!(
304 grouped[0].1,
305 vec![Path::new("a.py")],
306 "the markdown file is dropped, not attached to python"
307 );
308 }
309
310 #[test]
311 fn vendored_dirs_collects_unique_entries_across_languages() {
312 let dirs = vendored_dirs();
313 for expected in ["node_modules", "venv", "target"] {
314 assert!(
315 dirs.contains(&expected),
316 "{expected} should be in vendored_dirs, got {dirs:?}"
317 );
318 }
319 let count = dirs.iter().filter(|d| **d == "node_modules").count();
320 assert_eq!(
321 count, 1,
322 "JavaScript and TypeScript both declare node_modules; the set must collapse"
323 );
324 }
325}