cargo_plot/core/path_matcher/
matcher.rs1use super::sort::SortStrategy;
2use super::stats::{MatchStats, ResultSet, ShowMode};
3use regex::Regex;
4use std::collections::HashSet;
5
6pub struct PathMatcher {
13 regex: Regex,
14 targets_file: bool,
15 requires_sibling: bool,
18 requires_orphan: bool,
21 is_deep: bool,
24 base_name: String,
27 pub is_negated: bool,
30}
31
32impl PathMatcher {
33 pub fn new(pattern: &str, case_sensitive: bool) -> Result<Self, regex::Error> {
34 let is_negated = pattern.starts_with('!');
35 let actual_pattern = if is_negated { &pattern[1..] } else { pattern };
36
37 let is_deep = actual_pattern.ends_with('+');
38 let requires_sibling = actual_pattern.contains('@');
39 let requires_orphan = actual_pattern.contains('$');
40 let clean_pattern_str = actual_pattern.replace(['@', '$', '+'], "");
41
42 let base_name = clean_pattern_str
43 .trim_end_matches('/')
44 .trim_end_matches("**")
45 .split('/')
46 .next_back()
47 .unwrap_or("")
48 .split('.')
49 .next()
50 .unwrap_or("")
51 .to_string();
52
53 let mut re = String::new();
54
55 if !case_sensitive {
56 re.push_str("(?i)");
57 }
58
59 let mut is_anchored = false;
60 let mut p = clean_pattern_str.as_str();
61
62 let targets_file = !p.ends_with('/') && !p.ends_with("**");
63
64 if p.starts_with("./") {
65 is_anchored = true;
66 p = &p[2..];
67 } else if p.starts_with("**/") {
68 is_anchored = true;
69 }
70
71 if is_anchored {
72 re.push('^');
73 } else {
74 re.push_str("(?:^|/)");
75 }
76
77 let chars: Vec<char> = p.chars().collect();
78 let mut i = 0;
79
80 while i < chars.len() {
81 match chars[i] {
82 '\\' => {
83 if i + 1 < chars.len() {
84 i += 1;
85 re.push_str(®ex::escape(&chars[i].to_string()));
86 }
87 }
88 '.' => re.push_str("\\."),
89 '/' => {
90 if is_deep && i == chars.len() - 1 {
91 } else {
94 re.push('/');
95 }
96 }
97 '*' => {
98 if i + 1 < chars.len() && chars[i + 1] == '*' {
99 if i + 2 < chars.len() && chars[i + 2] == '/' {
100 re.push_str("(?:[^/]+/)*");
101 i += 2;
102 } else {
103 re.push_str(".+");
104 i += 1;
105 }
106 } else {
107 re.push_str("[^/]*");
108 }
109 }
110 '?' => re.push_str("[^/]"),
111 '{' => {
112 let mut options = String::new();
113 i += 1;
114 while i < chars.len() && chars[i] != '}' {
115 options.push(chars[i]);
116 i += 1;
117 }
118 let escaped: Vec<String> = options.split(',').map(regex::escape).collect();
119 re.push_str(&format!("(?:{})", escaped.join("|")));
120 }
121 '[' => {
122 re.push('[');
123 if i + 1 < chars.len() && chars[i + 1] == '!' {
124 re.push('^');
125 i += 1;
126 }
127 }
128 ']' | '-' | '^' => re.push(chars[i]),
129 c => re.push_str(®ex::escape(&c.to_string())),
130 }
131 i += 1;
132 }
133
134 if is_deep {
135 re.push_str("(?:/.*)?$");
136 } else {
137 re.push('$');
138 }
139
140 Ok(Self {
141 regex: Regex::new(&re)?,
142 targets_file,
143 requires_sibling,
144 requires_orphan,
145 is_deep,
146 base_name,
147 is_negated,
148 })
149 }
150
151 pub fn is_match(&self, path: &str, env: &HashSet<&str>) -> bool {
154 if self.targets_file && path.ends_with('/') {
155 return false;
156 }
157
158 let clean_path = path.strip_prefix("./").unwrap_or(path);
159
160 if !self.regex.is_match(clean_path) {
161 return false;
162 }
163
164 if (self.requires_sibling || self.requires_orphan) && !path.ends_with('/') {
167 if self.is_deep && self.requires_sibling {
168 if !self.check_authorized_root(path, env) {
169 return false;
170 }
171 return true;
172 }
173 let mut components: Vec<&str> = path.split('/').collect();
174 if let Some(file_name) = components.pop() {
175 let parent_dir = components.join("/");
176 let core_name = file_name.split('.').next().unwrap_or("");
177 let expected_folder = if parent_dir.is_empty() {
178 format!("{}/", core_name)
179 } else {
180 format!("{}/{}/", parent_dir, core_name)
181 };
182
183 if !env.contains(expected_folder.as_str()) {
184 return false;
185 }
186 }
187 }
188
189 if self.requires_sibling && path.ends_with('/') {
192 if self.is_deep {
193 if !self.check_authorized_root(path, env) {
194 return false;
195 }
196 } else {
197 let dir_no_slash = path.trim_end_matches('/');
198 let has_file_sibling = env.iter().any(|&p| {
199 p.starts_with(dir_no_slash)
200 && p[dir_no_slash.len()..].starts_with('.')
201 && !p.ends_with('/')
202 });
203
204 if !has_file_sibling {
205 return false;
206 }
207 }
208 }
209
210 true
211 }
212
213 pub fn evaluate<I, S, OnMatch, OnMismatch>(
217 &self,
218 paths: I,
219 env: &HashSet<&str>,
220 strategy: SortStrategy,
221 show_mode: ShowMode,
222 mut on_match: OnMatch,
223 mut on_mismatch: OnMismatch,
224 ) -> MatchStats
225 where
226 I: IntoIterator<Item = S>,
227 S: AsRef<str>,
228 OnMatch: FnMut(&str),
229 OnMismatch: FnMut(&str),
230 {
231 let mut matched = Vec::new();
232 let mut mismatched = Vec::new();
233
234 for path in paths {
235 if self.is_match(path.as_ref(), env) {
236 matched.push(path);
237 } else {
238 mismatched.push(path);
239 }
240 }
241
242 strategy.apply(&mut matched);
243 strategy.apply(&mut mismatched);
244
245 let stats = MatchStats {
246 m_size_matched: matched.len(),
247 x_size_mismatched: mismatched.len(),
248 total: matched.len() + mismatched.len(),
249 m_matched: ResultSet {
250 paths: matched.iter().map(|s| s.as_ref().to_string()).collect(),
251 tree: None,
252 list: None,
253 grid: None,
254 },
255 x_mismatched: ResultSet {
256 paths: mismatched.iter().map(|s| s.as_ref().to_string()).collect(),
257 tree: None,
258 list: None,
259 grid: None,
260 },
261 };
262
263 if show_mode == ShowMode::Include || show_mode == ShowMode::Context {
264 for path in &matched {
265 on_match(path.as_ref());
266 }
267 }
268
269 if show_mode == ShowMode::Exclude || show_mode == ShowMode::Context {
270 for path in &mismatched {
271 on_mismatch(path.as_ref());
272 }
273 }
274
275 stats
276 }
277
278 fn check_authorized_root(&self, path: &str, env: &HashSet<&str>) -> bool {
281 let clean = path.strip_prefix("./").unwrap_or(path);
282 let components: Vec<&str> = clean.split('/').collect();
283
284 for i in 0..components.len() {
285 let comp_core = components[i].split('.').next().unwrap_or("");
286
287 if comp_core == self.base_name {
288 let base_dir = if i == 0 {
289 self.base_name.clone()
290 } else {
291 format!("{}/{}", components[0..i].join("/"), self.base_name)
292 };
293
294 let full_base_dir = if path.starts_with("./") {
295 format!("./{}", base_dir)
296 } else {
297 base_dir
298 };
299 let dir_path = format!("{}/", full_base_dir);
300
301 let has_dir = env.contains(dir_path.as_str());
302 let has_file = env.iter().any(|&p| {
303 p.starts_with(&full_base_dir)
304 && p[full_base_dir.len()..].starts_with('.')
305 && !p.ends_with('/')
306 });
307
308 if has_dir && has_file {
309 return true;
310 }
311 }
312 }
313 false
314 }
315}
316
317pub struct PathMatchers {
324 matchers: Vec<PathMatcher>,
325}
326
327impl PathMatchers {
328 pub fn new<I, S>(patterns: I, case_sensitive: bool) -> Result<Self, regex::Error>
331 where
332 I: IntoIterator<Item = S>,
333 S: AsRef<str>,
334 {
335 let mut matchers = Vec::new();
336 for pat in patterns {
337 matchers.push(PathMatcher::new(pat.as_ref(), case_sensitive)?);
338 }
339 Ok(Self { matchers })
340 }
341
342 pub fn is_match(&self, path: &str, env: &HashSet<&str>) -> bool {
345 if self.matchers.is_empty() {
346 return false;
347 }
348
349 let mut has_positive = false;
350 let mut matched_positive = false;
351
352 for matcher in &self.matchers {
353 if matcher.is_negated {
354 if matcher.is_match(path, env) {
357 return false;
358 }
359 } else {
360 has_positive = true;
361 if !matched_positive && matcher.is_match(path, env) {
362 matched_positive = true;
363 }
364 }
365 }
366
367 if has_positive { matched_positive } else { true }
370 }
371
372 pub fn evaluate<I, S, OnMatch, OnMismatch>(
375 &self,
376 paths: I,
377 env: &HashSet<&str>,
378 strategy: SortStrategy,
379 show_mode: ShowMode,
380 mut on_match: OnMatch,
381 mut on_mismatch: OnMismatch,
382 ) -> MatchStats
383 where
384 I: IntoIterator<Item = S>,
385 S: AsRef<str>,
386 OnMatch: FnMut(&str),
387 OnMismatch: FnMut(&str),
388 {
389 let mut matched = Vec::new();
390 let mut mismatched = Vec::new();
391
392 for path in paths {
393 if self.is_match(path.as_ref(), env) {
394 matched.push(path);
395 } else {
396 mismatched.push(path);
397 }
398 }
399
400 strategy.apply(&mut matched);
401 strategy.apply(&mut mismatched);
402
403 let stats = MatchStats {
404 m_size_matched: matched.len(),
405 x_size_mismatched: mismatched.len(),
406 total: matched.len() + mismatched.len(),
407 m_matched: ResultSet {
408 paths: matched.iter().map(|s| s.as_ref().to_string()).collect(),
409 tree: None,
410 list: None,
411 grid: None,
412 },
413 x_mismatched: ResultSet {
414 paths: mismatched.iter().map(|s| s.as_ref().to_string()).collect(),
415 tree: None,
416 list: None,
417 grid: None,
418 },
419 };
420
421 if show_mode == ShowMode::Include || show_mode == ShowMode::Context {
422 for path in matched {
423 on_match(path.as_ref());
424 }
425 }
426
427 if show_mode == ShowMode::Exclude || show_mode == ShowMode::Context {
428 for path in mismatched {
429 on_mismatch(path.as_ref());
430 }
431 }
432
433 stats
434 }
435}