Skip to main content

drep/languages/
mod.rs

1//! Language registry: resolve a `Path` to the language that owns its extension.
2
3use std::collections::BTreeSet;
4use std::path::Path;
5use std::sync::LazyLock;
6
7pub mod definitions;
8pub mod runner;
9pub mod spec;
10
11// One public path per type, matching `analysis`: no facade re-exports, so
12// consumers cannot drift between `languages::ToolSpec` and
13// `languages::spec::ToolSpec`.
14use definitions::ALL_LANGUAGES;
15use spec::LanguageSupport;
16
17/// The language owning `path`, or `None` if drep does not analyze it.
18///
19/// Case-insensitive, matching the rest of drep's file-target policy.
20pub fn detect(path: &Path) -> Option<&'static LanguageSupport> {
21    detect_index(path).map(|index| ALL_LANGUAGES[index])
22}
23
24/// The index of the language owning `path` within [`ALL_LANGUAGES`].
25///
26/// The index *is* the language's identity, which is what [`group_by_language`]
27/// needs; recovering it afterwards by comparing pointers meant scanning the
28/// table twice for an answer the first scan already had.
29fn detect_index(path: &Path) -> Option<usize> {
30    let ext = path.extension()?.to_str()?;
31    // No allocation: the table's extensions are ASCII literals that all begin
32    // with a dot, so `[1..]` is the bare suffix and `eq_ignore_ascii_case` does
33    // the case folding that `to_lowercase()` used to do into two throwaway
34    // `String`s per call - on a function called once per walked file.
35    ALL_LANGUAGES.iter().position(|lang| {
36        lang.extensions
37            .iter()
38            .any(|known| known[1..].eq_ignore_ascii_case(ext))
39    })
40}
41
42/// Bucket `paths` by the language that owns them.
43///
44/// The single answer to "which languages are present here, and with which
45/// files". Both `drep check`'s deterministic layer (which needs the batch per
46/// tool) and `drep doctor` (which needs the counts) ask it, so neither
47/// re-derives language identity from a path — and neither can disagree with
48/// the other about what this repository contains, which is the specific thing
49/// `doctor` exists to report truthfully.
50///
51/// Paths that no registered language claims are dropped: drep has no opinion
52/// on a file type it does not analyze. The result is ordered by
53/// `ALL_LANGUAGES`' registration order rather than by name, so output is
54/// stable across runs and reads in the order the language table is written.
55///
56/// Borrows rather than owns. The caller already holds the paths, and every
57/// consumer converts them again for its own use - to argv strings in the tool
58/// runner, to counts in `doctor` - so cloning into the buckets was a copy that
59/// nothing read.
60pub 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
75/// Every registered language, in registration order.
76pub fn all_languages() -> &'static [&'static LanguageSupport] {
77    ALL_LANGUAGES
78}
79
80/// Every extension any registered language claims, lowercased, with the dot.
81///
82/// Derived from `ALL_LANGUAGES` so adding a language automatically widens the
83/// scan target set. Duplicates collapse: JavaScript and TypeScript both own
84/// `.ts`-adjacent extensions, so a hand-written list here would silently need
85/// to track them.
86pub fn source_extensions() -> &'static [&'static str] {
87    &SOURCE_EXTENSIONS
88}
89
90/// Computed once so repeated registry introspection does not rebuild a set
91/// whose answer is fixed at compile time.
92static 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
105/// Every dependency/build directory any registered language creates.
106///
107/// Same deduplication discipline as `source_extensions`: each `LanguageSupport`
108/// declares its own vendored directories, and the set is built once.
109pub fn vendored_dirs() -> &'static [&'static str] {
110    &VENDORED_DIRS
111}
112
113/// Computed once, for the same reason as [`SOURCE_EXTENSIONS`].
114static 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    /// Project compilers run from configuration and cannot take file args.
181    ///
182    /// Pinned explicitly because the failure is invisible in unit tests: with
183    /// `accepts_files: true`, clippy is invoked as `cargo clippy ... a.rs` and
184    /// exits 1 with "unexpected argument", so drep reports every Rust file
185    /// `Unavailable` and exits 2 on any Rust repository. Nothing in the suite
186    /// noticed - it took running drep against its own source to find it, and a
187    /// well-meant "why is this field false?" edit would put it straight back.
188    #[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    /// Buckets come back in registration order, not alphabetical order, and
271    /// each holds exactly its own files.
272    ///
273    /// Order is asserted because it is what makes `doctor`'s output stable
274    /// across runs; a `BTreeMap` keyed on name would sort go before python and
275    /// silently change the report.
276    #[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    /// A language with no matching files never appears, and an unrecognised
300    /// extension is dropped rather than bucketed anywhere.
301    #[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    /// The JVM family. Java is the one that motivated it: a repository of
321    /// `.java` files reported "No source files drep recognises were found
322    /// here" and `drep check` exited 0 having analyzed nothing, which is the
323    /// silent pass the deterministic half exists to refuse.
324    #[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    /// `.gradle` is Groovy source that drep should read, but `.gradle.kts` is
345    /// Kotlin and `Path::extension` returns `kts` for it, so the two do not
346    /// collide.
347    #[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}