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    /// `cargo clippy` is the one registered tool that cannot take file
184    /// arguments, and every other one can.
185    ///
186    /// Pinned explicitly because the failure is invisible in unit tests: with
187    /// `accepts_files: true`, clippy is invoked as `cargo clippy ... a.rs` and
188    /// exits 1 with "unexpected argument", so drep reports every Rust file
189    /// `Unavailable` and exits 2 on any Rust repository. Nothing in the suite
190    /// noticed - it took running drep against its own source to find it, and a
191    /// well-meant "why is this field false?" edit would put it straight back.
192    #[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    /// Buckets come back in registration order, not alphabetical order, and
241    /// each holds exactly its own files.
242    ///
243    /// Order is asserted because it is what makes `doctor`'s output stable
244    /// across runs; a `BTreeMap` keyed on name would sort go before python and
245    /// silently change the report.
246    #[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    /// A language with no matching files never appears, and an unrecognised
270    /// extension is dropped rather than bucketed anywhere.
271    #[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}