weavatrix_scan/
file_types.rs1use crate::default_file_types::DEFAULT_FILE_TYPES;
2use crate::glob;
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::Path;
5
6#[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 #[must_use]
41 pub fn defaults() -> Self {
42 Self::new().with_defaults()
43 }
44
45 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
166 pub fn len(&self) -> usize {
167 self.definitions.len()
168 }
169
170 #[must_use]
172 pub fn is_empty(&self) -> bool {
173 self.definitions.is_empty()
174 }
175
176 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}