Skip to main content

weavatrix_scan/
file_types.rs

1use crate::default_file_types::DEFAULT_FILE_TYPES;
2use crate::glob;
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6/// Named, reusable groups of file-name or repository-relative glob patterns.
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct NamedFileTypes {
9    definitions: BTreeMap<String, BTreeSet<FileTypePattern>>,
10    selections: Vec<FileTypeSelection>,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
14enum FileTypePattern {
15    Extension(String),
16    Glob(String),
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20enum FileTypeSelection {
21    Include(String),
22    Exclude(String),
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub(crate) enum FileTypeMatch {
27    Include,
28    Exclude,
29    None,
30}
31
32impl NamedFileTypes {
33    #[must_use]
34    pub fn new() -> Self {
35        Self::default()
36    }
37
38    /// Creates a catalog containing common source, markup, data, and build
39    /// file types. No type is selected until [`Self::select`] is called.
40    #[must_use]
41    pub fn defaults() -> Self {
42        Self::new().with_defaults()
43    }
44
45    /// Adds built-in definitions without replacing caller-defined names.
46    #[must_use]
47    pub fn with_defaults(mut self) -> Self {
48        for &(names, patterns) in DEFAULT_FILE_TYPES {
49            let parsed = patterns
50                .iter()
51                .map(|pattern| pattern_from_glob(pattern))
52                .collect::<BTreeSet<_>>();
53            for name in names {
54                self.definitions
55                    .entry((*name).to_owned())
56                    .or_insert_with(|| parsed.clone());
57            }
58        }
59        self
60    }
61
62    /// Adds or replaces a named extension group.
63    ///
64    /// Extensions retain a specialized allocation-free matching path.
65    #[must_use]
66    pub fn with_type<I, S>(mut self, name: impl Into<String>, extensions: I) -> Self
67    where
68        I: IntoIterator<Item = S>,
69        S: AsRef<str>,
70    {
71        self.definitions.insert(
72            name.into(),
73            extensions
74                .into_iter()
75                .map(|extension| {
76                    FileTypePattern::Extension(normalized_extension(extension.as_ref()))
77                })
78                .collect(),
79        );
80        self
81    }
82
83    /// Adds or replaces a named group of arbitrary glob patterns.
84    ///
85    /// Patterns containing `/` match normalized repository-relative paths.
86    /// Other patterns match only the native file name. Matching is
87    /// case-sensitive; callers can include explicit variants when needed.
88    #[must_use]
89    pub fn with_globs<I, S>(mut self, name: impl Into<String>, patterns: I) -> Self
90    where
91        I: IntoIterator<Item = S>,
92        S: AsRef<str>,
93    {
94        self.definitions.insert(
95            name.into(),
96            patterns
97                .into_iter()
98                .map(|pattern| pattern_from_glob(pattern.as_ref()))
99                .collect(),
100        );
101        self
102    }
103
104    /// Adds or replaces a type composed from existing definitions.
105    ///
106    /// Unknown component names contribute no patterns.
107    #[must_use]
108    pub fn with_composed_type<I, S>(mut self, name: impl Into<String>, components: I) -> Self
109    where
110        I: IntoIterator<Item = S>,
111        S: AsRef<str>,
112    {
113        let mut patterns = BTreeSet::new();
114        for component in components {
115            if let Some(component) = self.definitions.get(component.as_ref()) {
116                patterns.extend(component.iter().cloned());
117            }
118        }
119        self.definitions.insert(name.into(), patterns);
120        self
121    }
122
123    /// Replaces current selections with included named groups.
124    ///
125    /// Unknown names simply match no files.
126    #[must_use]
127    pub fn select<I, S>(mut self, names: I) -> Self
128    where
129        I: IntoIterator<Item = S>,
130        S: AsRef<str>,
131    {
132        self.selections = names
133            .into_iter()
134            .map(|name| FileTypeSelection::Include(name.as_ref().to_owned()))
135            .collect();
136        self
137    }
138
139    /// Appends excluded named groups. Later matching selections win.
140    #[must_use]
141    pub fn negate<I, S>(mut self, names: I) -> Self
142    where
143        I: IntoIterator<Item = S>,
144        S: AsRef<str>,
145    {
146        self.selections.extend(
147            names
148                .into_iter()
149                .map(|name| FileTypeSelection::Exclude(name.as_ref().to_owned())),
150        );
151        self
152    }
153
154    #[must_use]
155    pub fn is_active(&self) -> bool {
156        !self.selections.is_empty()
157    }
158
159    #[must_use]
160    pub fn contains(&self, name: &str) -> bool {
161        self.definitions.contains_key(name)
162    }
163
164    /// Returns the number of available named definitions.
165    #[must_use]
166    pub fn len(&self) -> usize {
167        self.definitions.len()
168    }
169
170    /// Returns whether the catalog contains no named definitions.
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.definitions.is_empty()
174    }
175
176    /// Iterates over definition names in deterministic lexical order.
177    pub fn names(&self) -> impl ExactSizeIterator<Item = &str> + DoubleEndedIterator {
178        self.definitions.keys().map(String::as_str)
179    }
180
181    pub(crate) fn has_includes(&self) -> bool {
182        self.selections
183            .iter()
184            .any(|selection| matches!(selection, FileTypeSelection::Include(_)))
185    }
186
187    pub(crate) fn matched(&self, path: &Path, relative: &str) -> FileTypeMatch {
188        let mut matched = FileTypeMatch::None;
189        for selection in &self.selections {
190            let (name, decision) = match selection {
191                FileTypeSelection::Include(name) => (name, FileTypeMatch::Include),
192                FileTypeSelection::Exclude(name) => (name, FileTypeMatch::Exclude),
193            };
194            if self
195                .definitions
196                .get(name)
197                .is_some_and(|patterns| matches_any(patterns, path, relative))
198            {
199                matched = decision;
200            }
201        }
202        matched
203    }
204}
205
206fn matches_any(patterns: &BTreeSet<FileTypePattern>, path: &Path, relative: &str) -> bool {
207    let file_name = path.file_name().and_then(|value| value.to_str());
208    patterns.iter().any(|pattern| match pattern {
209        FileTypePattern::Extension(expected) => path
210            .extension()
211            .and_then(|value| value.to_str())
212            .is_some_and(|actual| actual == expected || actual.eq_ignore_ascii_case(expected)),
213        FileTypePattern::Glob(pattern) if pattern.contains('/') => glob::matches(pattern, relative),
214        FileTypePattern::Glob(pattern) => {
215            file_name.is_some_and(|file_name| glob::matches(pattern, file_name))
216        }
217    })
218}
219
220fn pattern_from_glob(pattern: &str) -> FileTypePattern {
221    let normalized = pattern.replace('\\', "/");
222    normalized
223        .strip_prefix("*.")
224        .filter(|extension| {
225            !extension.contains('.')
226                && !extension
227                    .bytes()
228                    .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b'{' | b'/'))
229        })
230        .map_or_else(
231            || FileTypePattern::Glob(normalized.clone()),
232            |extension| FileTypePattern::Extension(normalized_extension(extension)),
233        )
234}
235
236fn normalized_extension(extension: &str) -> String {
237    extension.trim_start_matches('.').to_ascii_lowercase()
238}
239
240#[cfg(test)]
241mod tests {
242    use crate::file_types::{NamedFileTypes, pattern_from_glob};
243
244    #[test]
245    fn defaults_include_every_ignore_name_and_pattern() {
246        let ours = NamedFileTypes::defaults();
247        let mut upstream = ignore::types::TypesBuilder::new();
248        upstream.add_defaults();
249        for definition in upstream.definitions() {
250            let patterns = ours
251                .definitions
252                .get(definition.name())
253                .unwrap_or_else(|| panic!("missing ignore type {}", definition.name()));
254            for pattern in definition.globs() {
255                assert!(
256                    patterns.contains(&pattern_from_glob(pattern)),
257                    "missing ignore pattern {}:{pattern}",
258                    definition.name()
259                );
260            }
261        }
262    }
263}