plot/lib/logic/
path_patterns.rs1use regex::Regex;
2
3use crate::lib::logic::PathContext;
4
5pub trait PattEnvIndex {
10 fn has_dir(&self, dir: &str) -> bool;
11 fn has_file_with_prefix(&self, prefix: &str) -> bool;
12 fn any_file_in_dir(&self, dir: &str, check: &mut dyn FnMut(&str) -> bool) -> bool;
13}
14
15#[derive(Debug, Clone)]
21pub struct PattRaw(pub Vec<String>);
22
23#[derive(Debug, Clone)]
25pub struct PattExp(pub Vec<String>);
26
27#[derive(Debug, Clone)]
28pub struct PatternsQueries {
29 pub patterns: PattRaw,
30 pub expanded: PattExp,
31 compiled: Vec<PatternCompiled>,
32}
33
34#[derive(Debug, Clone)]
39struct PatternCompiled {
40 regex: Regex,
41 targets_file: bool,
42 requires_sibling: bool,
43 requires_orphan: bool,
44 is_deep: bool,
45 include_parents: bool,
46 base_name: String,
47 pub is_negated: bool,
48 #[allow(dead_code)]
49 pub original: String,
50}
51
52impl PatternsQueries {
57 pub fn new<I, S>(patterns: I, ignore_case_sensitive: bool) -> Self
58 where
59 I: IntoIterator<Item = S>,
60 S: AsRef<str>, {
61 let mut patterns_vec = Vec::new();
62 let mut expanded_vec = Vec::new();
63
64 for p in patterns {
65 let s = p.as_ref();
66 patterns_vec.push(s.to_string());
67 expanded_vec.extend(Self::expand_braces(s));
68 }
69
70 let compiled = expanded_vec.iter().map(|p| PatternCompiled::new(p, ignore_case_sensitive).unwrap()).collect();
71
72 Self {
73 patterns: PattRaw(patterns_vec),
75 expanded: PattExp(expanded_vec),
76 compiled,
77 }
78 }
79
80 pub fn is_match<E: PattEnvIndex>(&self, path: &str, env: &E) -> bool {
82 let mut has_positive = false;
83 let mut matched = false;
84
85 for c in &self.compiled {
86 if c.is_negated {
87 if c.is_match(path, env) {
88 return false;
89 }
90 } else {
91 has_positive = true;
92 if !matched && c.is_match(path, env) {
93 matched = true;
94 }
95 }
96 }
97
98 if has_positive { matched } else { true }
99 }
100
101 pub fn rules_count(&self) -> usize { self.compiled.len() }
102
103 fn expand_braces(input: &str) -> Vec<String> {
108 if let Some(end) = input.find('}') {
110 if let Some(start) = input[..end].rfind('{') {
112 let prefix = &input[..start];
113 let suffix = &input[end + 1..];
114 let options = &input[start + 1..end];
115
116 let mut result = Vec::new();
117 for opt in options.split(',') {
118 let merged = format!("{}{}{}", prefix, opt, suffix);
119 result.extend(Self::expand_braces(&merged));
121 }
122 return result;
123 }
124 }
125 vec![input.to_string()]
126 }
127}
128
129impl PatternCompiled {
134 pub fn new(pattern: &str, ignore_case_sensitive: bool) -> Result<Self, regex::Error> {
135 let is_negated = pattern.starts_with('!');
136 let p = if is_negated { &pattern[1..] } else { pattern };
137
138 let is_deep = p.ends_with('+');
139 let include_parents = p.ends_with("&/") || p.ends_with("&\\");
140 let requires_sibling = p.contains('@');
141 let requires_orphan = p.contains('$');
142
143 let mut clean = p.replace(['@', '$', '+'], "");
144
145 if include_parents {
146 clean = clean.strip_suffix("&/").or_else(|| clean.strip_suffix("&\\")).unwrap_or(&clean).to_string();
147 }
148
149 let base_name = clean
158 .trim_end_matches('/')
159 .split('/')
160 .next_back()
161 .unwrap_or("")
162 .split('.')
163 .next()
164 .unwrap_or("")
165 .to_string();
166
167 let mut re = String::new();
168
169 if ignore_case_sensitive {
170 re.push_str("(?i)");
171 }
172
173 let mut p_str = clean.as_str();
174 let mut anchored = false;
175
176 let targets_file = !p_str.ends_with('/') && !p_str.ends_with("**");
177
178 if p_str.starts_with("./") {
179 anchored = true;
180 p_str = &p_str[2..];
181 } else if p_str.starts_with("**/") {
182 anchored = true;
183 }
184
185 if anchored {
186 re.push('^');
187 } else {
188 re.push_str("(?:^|/)");
189 }
190
191 let chars: Vec<char> = p_str.chars().collect();
192 let mut i = 0;
193
194 while i < chars.len() {
195 match chars[i] {
196 '\\' => {
197 i += 1;
198 if i < chars.len() {
199 re.push_str(®ex::escape(&chars[i].to_string()));
200 }
201 }
202 '.' => re.push_str("\\."),
203 '/' => re.push('/'),
204 '*' => {
205 if i + 1 < chars.len() && chars[i + 1] == '*' {
206 re.push_str(".+");
207 i += 1;
208 } else {
209 re.push_str("[^/]*");
210 }
211 }
212 '?' => re.push_str("[^/]"),
213 '[' => {
214 re.push('[');
215 if i + 1 < chars.len() && chars[i + 1] == '!' {
216 re.push('^');
217 i += 1;
218 }
219 }
220 ']' | '-' | '^' => re.push(chars[i]),
221 _ => re.push_str(®ex::escape(&chars[i].to_string())),
223 }
224 i += 1;
225 }
226
227 if is_deep {
228 re.push_str("(?:/.*)?$");
229 } else {
230 re.push('$');
231 }
232
233 Ok(Self {
234 regex: Regex::new(&re)?,
235 targets_file,
236 requires_sibling,
237 requires_orphan,
238 is_deep,
239 include_parents,
240 base_name,
241 is_negated,
242 original: pattern.to_string(),
243 })
244 }
245
246 pub fn is_match<E: PattEnvIndex>(&self, path: &str, env: &E) -> bool {
247 let is_dir = path.ends_with('/');
248 let clean = path.strip_prefix("./").unwrap_or(path);
249
250 if self.targets_file && is_dir {
252 if self.include_parents {
253 return env.any_file_in_dir(path, &mut |p| self.regex.is_match(p.strip_prefix("./").unwrap_or(p)));
254 }
255 return false;
256 }
257
258 if !self.regex.is_match(clean) {
259 if is_dir && self.include_parents {
260 return env.any_file_in_dir(path, &mut |p| self.regex.is_match(p.strip_prefix("./").unwrap_or(p)));
261 }
262 return false;
263 }
264
265 if (self.requires_sibling || self.requires_orphan) && !is_dir {
267 if self.is_deep && self.requires_sibling {
268 if !self.check_authorized_root(path, env) {
269 return false;
270 }
271 return true;
272 }
273
274 let ctx = PathContext::from(path);
275 let expected_folder = if ctx.parent.is_empty() {
276 format!("{}/", self.base_name)
277 } else {
278 format!("{}/{}/", ctx.parent, self.base_name)
279 };
280
281 let exists = env.has_dir(&expected_folder);
282
283 if self.requires_sibling && !exists {
284 return false;
285 }
286 if self.requires_orphan && exists {
287 return false;
288 }
289 }
290
291 if (self.requires_sibling || self.requires_orphan) && is_dir {
293 if self.is_deep && self.requires_sibling {
294 if !self.check_authorized_root(path, env) {
295 return false;
296 }
297 } else {
298 let dir_no_slash = path.trim_end_matches('/');
299 let search_prefix = format!("{}.", dir_no_slash);
300 let has_file_sibling = env.has_file_with_prefix(&search_prefix);
301
302 if self.requires_sibling && !has_file_sibling {
303 return false;
304 }
305 if self.requires_orphan && has_file_sibling {
306 return false;
307 }
308 }
309 }
310
311 true
312 }
313
314 fn check_authorized_root<E: PattEnvIndex>(&self, path: &str, env: &E) -> bool {
315 let clean = path.strip_prefix("./").unwrap_or(path);
316 let components: Vec<&str> = clean.split('/').collect();
317
318 for i in 0..components.len() {
319 let comp_core = components[i].split('.').next().unwrap_or("");
320
321 if comp_core == self.base_name {
322 let base_dir = if i == 0 {
323 self.base_name.clone()
324 } else {
325 format!("{}/{}", components[0..i].join("/"), self.base_name)
326 };
327
328 let full_base_dir = if path.starts_with("./") { format!("./{}", base_dir) } else { base_dir };
329 let dir_path = format!("{}/", full_base_dir);
330
331 let has_dir = env.has_dir(&dir_path);
332 let search_prefix = format!("{}.", full_base_dir);
333 let has_file = env.has_file_with_prefix(&search_prefix);
334
335 if has_dir && has_file {
336 return true;
337 }
338 }
339 }
340 false
341 }
342}