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(|| {
96 let mut seen = BTreeSet::new();
97 let mut out = Vec::new();
98 for lang in ALL_LANGUAGES {
99 for ext in lang.extensions {
100 if seen.insert(*ext) {
101 out.push(*ext);
102 }
103 }
104 }
105 out
106});
107
108pub fn vendored_dirs() -> &'static [&'static str] {
113 &VENDORED_DIRS
114}
115
116static VENDORED_DIRS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
118 let mut seen = BTreeSet::new();
119 let mut out = Vec::new();
120 for lang in ALL_LANGUAGES {
121 for dir in lang.vendored_dirs {
122 if seen.insert(*dir) {
123 out.push(*dir);
124 }
125 }
126 }
127 out
128});
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133 use std::path::Path;
134
135 #[test]
136 fn detect_python() {
137 assert_eq!(detect(Path::new("foo.py")).map(|l| l.name), Some("python"));
138 }
139
140 #[test]
141 fn detect_javascript() {
142 assert_eq!(
143 detect(Path::new("foo.js")).map(|l| l.name),
144 Some("javascript")
145 );
146 }
147
148 #[test]
149 fn detect_typescript() {
150 assert_eq!(
151 detect(Path::new("foo.ts")).map(|l| l.name),
152 Some("typescript")
153 );
154 }
155
156 #[test]
157 fn detect_go() {
158 assert_eq!(detect(Path::new("foo.go")).map(|l| l.name), Some("go"));
159 }
160
161 #[test]
162 fn detect_rust() {
163 assert_eq!(detect(Path::new("foo.rs")).map(|l| l.name), Some("rust"));
164 }
165
166 #[test]
167 fn unknown_extension_returns_none() {
168 assert!(detect(Path::new("foo.xyz")).is_none());
169 }
170
171 #[test]
172 fn extension_match_is_case_insensitive() {
173 assert_eq!(detect(Path::new("FOO.PY")).map(|l| l.name), Some("python"));
174 assert_eq!(detect(Path::new("Mixed.Go")).map(|l| l.name), Some("go"));
175 }
176
177 #[test]
178 fn tsc_stream_is_stdout_go_vet_stream_is_stderr() {
179 assert_eq!(definitions::TSC.diagnostics_stream, "stdout");
180 assert_eq!(definitions::GO_VET.diagnostics_stream, "stderr");
181 }
182
183 #[test]
192 fn project_compilers_do_not_take_file_arguments() {
193 assert!(
194 !definitions::CLIPPY.accepts_files,
195 "cargo clippy checks a crate; a path argument is rejected outright"
196 );
197 assert!(
198 !definitions::TSC.accepts_files,
199 "passing paths makes tsc ignore tsconfig.json"
200 );
201 for spec in [
202 &definitions::RUFF,
203 &definitions::ESLINT,
204 &definitions::GOFMT,
205 &definitions::GO_VET,
206 ] {
207 assert!(
208 spec.accepts_files,
209 "{} is invoked with the files it should check",
210 spec.name
211 );
212 }
213 }
214
215 #[test]
216 fn only_clippy_is_serialized_within_a_repository() {
217 assert!(
218 definitions::CLIPPY.serial_in_repository,
219 "parallel cargo processes contend for the same build lock"
220 );
221 for spec in [
222 &definitions::RUFF,
223 &definitions::ESLINT,
224 &definitions::TSC,
225 &definitions::GOFMT,
226 &definitions::GO_VET,
227 ] {
228 assert!(
229 !spec.serial_in_repository,
230 "{} should remain eligible for bounded parallel execution",
231 spec.name
232 );
233 }
234 }
235
236 #[test]
237 fn all_languages_returns_every_registered_language() {
238 let langs = all_languages();
239 let names: Vec<&str> = langs.iter().map(|l| l.name).collect();
240 assert_eq!(
241 names,
242 vec!["python", "javascript", "typescript", "go", "rust"]
243 );
244 }
245
246 #[test]
247 fn source_extensions_contains_python_and_tsx_but_not_markdown() {
248 let exts = source_extensions();
249 assert!(
250 exts.contains(&".py"),
251 "`.py` is owned by python, expected in source_extensions, got {exts:?}"
252 );
253 assert!(
254 exts.contains(&".tsx"),
255 "`.tsx` is owned by typescript, expected in source_extensions, got {exts:?}"
256 );
257 assert!(
258 !exts.contains(&".md"),
259 "markdown is documentation, not a registered language: {exts:?}"
260 );
261 }
262
263 #[test]
270 fn group_by_language_buckets_in_registration_order() {
271 let paths = [
272 Path::new("main.go"),
273 Path::new("a.py"),
274 Path::new("lib.rs"),
275 Path::new("b.py"),
276 ];
277 let grouped = group_by_language(&paths);
278 let names: Vec<&str> = grouped.iter().map(|(lang, _)| lang.name).collect();
279 assert_eq!(
280 names,
281 vec!["python", "go", "rust"],
282 "registration order (python, javascript, typescript, go, rust), \
283 not alphabetical and not first-seen"
284 );
285 assert_eq!(
286 grouped[0].1,
287 vec![Path::new("a.py"), Path::new("b.py")],
288 "files land in their own bucket, in the order given"
289 );
290 }
291
292 #[test]
295 fn group_by_language_omits_empty_buckets_and_unknown_extensions() {
296 let grouped = group_by_language(&[Path::new("notes.md"), Path::new("data.xyz")]);
297 assert!(
298 grouped.is_empty(),
299 "no registered language claims these, so there is nothing to report: {:?}",
300 grouped.iter().map(|(l, _)| l.name).collect::<Vec<_>>()
301 );
302
303 let grouped = group_by_language(&[Path::new("a.py"), Path::new("notes.md")]);
304 assert_eq!(grouped.len(), 1, "only python is present");
305 assert_eq!(grouped[0].0.name, "python");
306 assert_eq!(
307 grouped[0].1,
308 vec![Path::new("a.py")],
309 "the markdown file is dropped, not attached to python"
310 );
311 }
312
313 #[test]
314 fn vendored_dirs_collects_unique_entries_across_languages() {
315 let dirs = vendored_dirs();
316 for expected in ["node_modules", "venv", "target"] {
317 assert!(
318 dirs.contains(&expected),
319 "{expected} should be in vendored_dirs, got {dirs:?}"
320 );
321 }
322 let count = dirs.iter().filter(|d| **d == "node_modules").count();
323 assert_eq!(
324 count, 1,
325 "JavaScript and TypeScript both declare node_modules; the set must collapse"
326 );
327 }
328}