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!["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    /// Buckets come back in registration order, not alphabetical order, and
261    /// each holds exactly its own files.
262    ///
263    /// Order is asserted because it is what makes `doctor`'s output stable
264    /// across runs; a `BTreeMap` keyed on name would sort go before python and
265    /// silently change the report.
266    #[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    /// A language with no matching files never appears, and an unrecognised
290    /// extension is dropped rather than bucketed anywhere.
291    #[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}