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]
193 fn clippy_is_the_only_tool_that_cannot_take_file_arguments() {
194 assert!(
195 !definitions::CLIPPY.accepts_files,
196 "cargo clippy checks a crate; a path argument is rejected outright"
197 );
198 for spec in [
199 &definitions::RUFF,
200 &definitions::ESLINT,
201 &definitions::TSC,
202 &definitions::GOFMT,
203 &definitions::GO_VET,
204 ] {
205 assert!(
206 spec.accepts_files,
207 "{} is invoked with the files it should check",
208 spec.name
209 );
210 }
211 }
212
213 #[test]
214 fn all_languages_returns_every_registered_language() {
215 let langs = all_languages();
216 let names: Vec<&str> = langs.iter().map(|l| l.name).collect();
217 assert_eq!(
218 names,
219 vec!["python", "javascript", "typescript", "go", "rust"]
220 );
221 }
222
223 #[test]
224 fn source_extensions_contains_python_and_tsx_but_not_markdown() {
225 let exts = source_extensions();
226 assert!(
227 exts.contains(&".py"),
228 "`.py` is owned by python, expected in source_extensions, got {exts:?}"
229 );
230 assert!(
231 exts.contains(&".tsx"),
232 "`.tsx` is owned by typescript, expected in source_extensions, got {exts:?}"
233 );
234 assert!(
235 !exts.contains(&".md"),
236 "markdown is documentation, not a registered language: {exts:?}"
237 );
238 }
239
240 #[test]
247 fn group_by_language_buckets_in_registration_order() {
248 let paths = [
249 Path::new("main.go"),
250 Path::new("a.py"),
251 Path::new("lib.rs"),
252 Path::new("b.py"),
253 ];
254 let grouped = group_by_language(&paths);
255 let names: Vec<&str> = grouped.iter().map(|(lang, _)| lang.name).collect();
256 assert_eq!(
257 names,
258 vec!["python", "go", "rust"],
259 "registration order (python, javascript, typescript, go, rust), \
260 not alphabetical and not first-seen"
261 );
262 assert_eq!(
263 grouped[0].1,
264 vec![Path::new("a.py"), Path::new("b.py")],
265 "files land in their own bucket, in the order given"
266 );
267 }
268
269 #[test]
272 fn group_by_language_omits_empty_buckets_and_unknown_extensions() {
273 let grouped = group_by_language(&[Path::new("notes.md"), Path::new("data.xyz")]);
274 assert!(
275 grouped.is_empty(),
276 "no registered language claims these, so there is nothing to report: {:?}",
277 grouped.iter().map(|(l, _)| l.name).collect::<Vec<_>>()
278 );
279
280 let grouped = group_by_language(&[Path::new("a.py"), Path::new("notes.md")]);
281 assert_eq!(grouped.len(), 1, "only python is present");
282 assert_eq!(grouped[0].0.name, "python");
283 assert_eq!(
284 grouped[0].1,
285 vec![Path::new("a.py")],
286 "the markdown file is dropped, not attached to python"
287 );
288 }
289
290 #[test]
291 fn vendored_dirs_collects_unique_entries_across_languages() {
292 let dirs = vendored_dirs();
293 for expected in ["node_modules", "venv", "target"] {
294 assert!(
295 dirs.contains(&expected),
296 "{expected} should be in vendored_dirs, got {dirs:?}"
297 );
298 }
299 let count = dirs.iter().filter(|d| **d == "node_modules").count();
300 assert_eq!(
301 count, 1,
302 "JavaScript and TypeScript both declare node_modules; the set must collapse"
303 );
304 }
305}