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. `files::is_scan_target` calls `source_extensions()` for
91/// **every entry the tree walk touches**, not just the matches, and this used
92/// to build a fresh `BTreeSet` plus a fresh `Vec` on each of those calls - tens
93/// of thousands of allocations per run to answer a question whose answer is
94/// fixed at compile time.
95static 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
108/// Every dependency/build directory any registered language creates.
109///
110/// Same deduplication discipline as `source_extensions`: each `LanguageSupport`
111/// declares its own vendored directories, and the set is built once.
112pub fn vendored_dirs() -> &'static [&'static str] {
113    &VENDORED_DIRS
114}
115
116/// Computed once, for the same reason as [`SOURCE_EXTENSIONS`].
117static 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    /// Project compilers run from configuration and cannot take file args.
184    ///
185    /// Pinned explicitly because the failure is invisible in unit tests: with
186    /// `accepts_files: true`, clippy is invoked as `cargo clippy ... a.rs` and
187    /// exits 1 with "unexpected argument", so drep reports every Rust file
188    /// `Unavailable` and exits 2 on any Rust repository. Nothing in the suite
189    /// noticed - it took running drep against its own source to find it, and a
190    /// well-meant "why is this field false?" edit would put it straight back.
191    #[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    /// Buckets come back in registration order, not alphabetical order, and
264    /// each holds exactly its own files.
265    ///
266    /// Order is asserted because it is what makes `doctor`'s output stable
267    /// across runs; a `BTreeMap` keyed on name would sort go before python and
268    /// silently change the report.
269    #[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    /// A language with no matching files never appears, and an unrecognised
293    /// extension is dropped rather than bucketed anywhere.
294    #[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}