Skip to main content

cargo_plot/core/path_matcher/
matcher.rs

1use super::sort::SortStrategy;
2use super::stats::{MatchStats, ResultSet, ShowMode};
3use regex::Regex;
4use std::collections::HashSet;
5
6// ==============================================================================
7// ⚡ POJEDYNCZY WZORZEC (PathMatcher)
8// ==============================================================================
9
10/// [POL]: Struktura odpowiedzialna za dopasowanie pojedynczego wzorca z uwzględnieniem zależności strukturalnych.
11/// [ENG]: Structure responsible for matching a single pattern considering structural dependencies.
12pub struct PathMatcher {
13    regex: Regex,
14    targets_file: bool,
15    // [POL]: Flaga @ (para plik-folder)
16    // [ENG]: Flag @ (file-directory pair)
17    requires_sibling: bool,
18    // [POL]: Flaga $ (jednostronna relacja)
19    // [ENG]: Flag $ (one-way relation)
20    requires_orphan: bool,
21    // [POL]: Flaga + (rekurencyjne zacienianie)
22    // [ENG]: Flag + (recursive shadowing)
23    is_deep: bool,
24    // [POL]: Nazwa bazowa modułu do weryfikacji relacji
25    // [ENG]: Base name of the module for relation verification
26    base_name: String,
27    // [POL]: Flaga negacji (!).
28    // [ENG]: Negation flag (!).
29    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(&regex::escape(&chars[i].to_string()));
86                    }
87                }
88                '.' => re.push_str("\\."),
89                '/' => {
90                    if is_deep && i == chars.len() - 1 {
91                        // [POL]: Pominięcie końcowego ukośnika dla flagi '+'.
92                        // [ENG]: Omission of trailing slash for the '+' flag.
93                    } 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(&regex::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    /// [POL]: Sprawdza dopasowanie ścieżki, uwzględniając relacje rodzeństwa w strukturze plików.
152    /// [ENG]: Validates path matching, considering sibling relations within the file structure.
153    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        // [POL]: Relacja rodzeństwa (@) lub sieroty ($) dla plików.
165        // [ENG]: Sibling relation (@) or orphan relation ($) for files.
166        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        // [POL]: Dodatkowa weryfikacja rodzeństwa (@) dla katalogów.
190        // [ENG]: Additional sibling verification (@) for directories.
191        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    /// [POL]: Ewaluuje kolekcję ścieżek, sortuje wyniki i wywołuje odpowiednie akcje.
214    /// [ENG]: Evaluates a path collection, sorts the results, and triggers respective actions.
215    // #[allow(clippy::too_many_arguments)]
216    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    /// [POL]: Weryfikuje autoryzację korzenia modułu w relacji plik-folder dla trybu 'deep'.
279    /// [ENG]: Verifies module root authorisation in the file-directory relation for 'deep' mode.
280    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
317// ==============================================================================
318// ⚡ KONTENER WIELU WZORCÓW (PathMatchers)
319// ==============================================================================
320
321/// [POL]: Kontener przechowujący kolekcję silników dopasowujących ścieżki.
322/// [ENG]: A container holding a collection of path matching engines.
323pub struct PathMatchers {
324    matchers: Vec<PathMatcher>,
325}
326
327impl PathMatchers {
328    /// [POL]: Tworzy nową instancję, kompilując listę wzorców po uprzednim rozwinięciu klamer.
329    /// [ENG]: Creates a new instance by compiling a list of patterns after performing brace expansion.
330    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    /// [POL]: Weryfikuje, czy ścieżka spełnia warunki narzucone przez zbiór wzorców (w tym negacje).
343    /// [ENG]: Verifies if the path meets the conditions imposed by the pattern set (including negations).
344    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                // [POL]: Twarde WETO. Dopasowanie negatywne bezwzględnie odrzuca ścieżkę.
355                // [ENG]: Hard VETO. A negative match unconditionally rejects the path.
356                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        // [POL]: Ostateczna decyzja na podstawie zebranych danych.
368        // [ENG]: Final decision based on collected data.
369        if has_positive { matched_positive } else { true }
370    }
371
372    /// [POL]: Ewaluuje zbiór ścieżek, sortuje je i wykonuje odpowiednie domknięcia.
373    /// [ENG]: Evaluates a set of paths, sorts them, and executes respective closures.
374    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}