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 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;