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 spec::LanguageSupport;
15
16/// The language owning `path`, or `None` if drep does not analyze it.
17///
18/// Case-insensitive, matching the rest of drep's file-target policy.
19pub fn detect(path: &Path) -> Option<&'static LanguageSupport> {
20 detect_index(path).map(|index| all_languages()[index])
21}
22
23/// The index of the language owning `path` within [`all_languages`].
24///
25/// The index *is* the language's identity, which is what [`group_by_language`]
26/// needs; recovering it afterwards by comparing pointers meant scanning the
27/// table twice for an answer the first scan already had.
28fn detect_index(path: &Path) -> Option<usize> {
29 // Extension first, whole name second, and never the other way round: a
30 // name match must not shadow a language that claims the suffix, or a file
31 // called `Dockerfile.ts` would stop being TypeScript.
32 if let Some(ext) = path.extension().and_then(|e| e.to_str())
33 && let Some(index) = by_extension(ext)
34 {
35 return Some(index);
36 }
37 let name = path.file_name()?.to_str()?;
38 by_filename(name).or_else(|| by_filename_stem(name))
39}
40
41/// The language claiming `ext`, which carries no leading dot.
42///
43/// No allocation: the table's extensions are ASCII literals that all begin
44/// with a dot, so `[1..]` is the bare suffix and `eq_ignore_ascii_case` does
45/// the case folding that `to_lowercase()` used to do into two throwaway
46/// `String`s per call - on a function called once per walked file.
47fn by_extension(ext: &str) -> Option<usize> {
48 all_languages().iter().position(|lang| {
49 lang.extensions
50 .iter()
51 .any(|known| known[1..].eq_ignore_ascii_case(ext))
52 })
53}
54
55/// The language claiming the whole file name `name`.
56///
57/// `Path::extension` answers `None` for `Dockerfile`, `Makefile`, `Gemfile`
58/// and `Jenkinsfile`, so without this they are dropped at language grouping
59/// and reported as a clean run - the silent pass drep exists to refuse.
60/// Case-insensitive, matching the extension lookup and the rest of drep's
61/// file-target policy.
62fn by_filename(name: &str) -> Option<usize> {
63 all_languages().iter().position(|lang| {
64 lang.filenames
65 .iter()
66 .any(|known| known.eq_ignore_ascii_case(name))
67 })
68}
69
70/// The language claiming `name` as a dotted variant of a claimed stem, e.g.
71/// `Dockerfile.dev`.
72///
73/// Exact whole-name claims cover the canonical file; this covers the
74/// unbounded family of per-environment variants multi-image layouts produce.
75/// Runs after the exact lookup, and the extension lookup runs before both,
76/// so `Dockerfile.ts` stays TypeScript. The variant must carry a non-empty
77/// suffix after the dot: `Dockerfile.` itself is claimed by neither rule.
78fn by_filename_stem(name: &str) -> Option<usize> {
79 // Split once, rather than re-slicing `name` per registered stem. Every
80 // stem is dot-free ASCII, asserted by
81 // `every_registered_entry_is_well_formed`, so the text before the first
82 // dot is the only thing any of them can match.
83 let (head, variant) = name.split_once('.')?;
84 if variant.is_empty() {
85 return None;
86 }
87 all_languages().iter().position(|lang| {
88 lang.filename_prefixes
89 .iter()
90 .any(|stem| stem.eq_ignore_ascii_case(head))
91 })
92}
93
94/// Bucket `paths` by the language that owns them.
95///
96/// The single answer to "which languages are present here, and with which
97/// files". Both `drep check`'s deterministic layer (which needs the batch per
98/// tool) and `drep doctor` (which needs the counts) ask it, so neither
99/// re-derives language identity from a path — and neither can disagree with
100/// the other about what this repository contains, which is the specific thing
101/// `doctor` exists to report truthfully.
102///
103/// Paths that no registered language claims are dropped: drep has no opinion
104/// on a file type it does not analyze. The result is ordered by
105/// registration order rather than by name, so output is
106/// stable across runs and reads in the order the language table is written.
107///
108/// Borrows rather than owns. The caller already holds the paths, and every
109/// consumer converts them again for its own use - to argv strings in the tool
110/// runner, to counts in `doctor` - so cloning into the buckets was a copy that
111/// nothing read.
112pub fn group_by_language<'a>(paths: &[&'a Path]) -> Vec<(&'static LanguageSupport, Vec<&'a Path>)> {
113 let mut buckets: Vec<Vec<&'a Path>> = vec![Vec::new(); all_languages().len()];
114 for path in paths {
115 if let Some(index) = detect_index(path) {
116 buckets[index].push(path);
117 }
118 }
119 buckets
120 .into_iter()
121 .enumerate()
122 .filter(|(_, files)| !files.is_empty())
123 .map(|(index, files)| (all_languages()[index], files))
124 .collect()
125}
126
127/// Every registered language, in registration order.
128pub fn all_languages() -> &'static [&'static LanguageSupport] {
129 &definitions::ALL_LANGUAGES[..]
130}
131
132/// Every extension any registered language claims, lowercased, with the dot.
133///
134/// Derived from the registry so adding a language automatically widens the
135/// scan target set. Duplicates collapse: JavaScript and TypeScript both own
136/// `.ts`-adjacent extensions, so a hand-written list here would silently need
137/// to track them.
138pub fn source_extensions() -> &'static [&'static str] {
139 &SOURCE_EXTENSIONS
140}
141
142/// Computed once so repeated registry introspection does not rebuild a set
143/// whose answer is fixed at compile time.
144static SOURCE_EXTENSIONS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
145 let mut seen = BTreeSet::new();
146 let mut out = Vec::new();
147 for lang in all_languages() {
148 for ext in lang.extensions {
149 if seen.insert(*ext) {
150 out.push(*ext);
151 }
152 }
153 }
154 out
155});
156
157/// Every dependency/build directory any registered language creates.
158///
159/// Same deduplication discipline as `source_extensions`: each `LanguageSupport`
160/// declares its own vendored directories, and the set is built once.
161pub fn vendored_dirs() -> &'static [&'static str] {
162 &VENDORED_DIRS
163}
164
165/// Computed once, for the same reason as [`SOURCE_EXTENSIONS`].
166static VENDORED_DIRS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
167 let mut seen = BTreeSet::new();
168 let mut out = Vec::new();
169 for lang in all_languages() {
170 for dir in lang.vendored_dirs {
171 if seen.insert(*dir) {
172 out.push(*dir);
173 }
174 }
175 }
176 out
177});
178
179#[cfg(test)]
180mod tests;