Skip to main content

stylex_compiler_rs/utils/
path_filter.rs

1use crate::enums::PathFilterUnion;
2use fancy_regex::Regex;
3use glob::Pattern as GlobPattern;
4use log::warn;
5use std::env;
6use std::path::Path;
7
8/// Determines whether a file should be transformed based on include/exclude patterns
9pub(crate) fn should_transform_file(
10  file_path: &str,
11  include: &Option<Vec<PathFilterUnion>>,
12  exclude: &Option<Vec<PathFilterUnion>>,
13) -> bool {
14  let cwd = env::current_dir().unwrap_or_default();
15  let file_path_buf = Path::new(file_path);
16
17  // Get relative path
18  let relative_path = file_path_buf
19    .strip_prefix(&cwd)
20    .unwrap_or(file_path_buf)
21    .to_string_lossy();
22
23  // Normalize path separators to forward slashes for consistent matching
24  let relative_path = relative_path.replace('\\', "/");
25
26  // Check include patterns
27  if let Some(include_patterns) = include
28    && !include_patterns.is_empty()
29  {
30    let included = include_patterns
31      .iter()
32      .any(|pattern| match_pattern(&relative_path, pattern));
33    if !included {
34      return false;
35    }
36  }
37
38  // Check exclude patterns
39  if let Some(exclude_patterns) = exclude {
40    let excluded = exclude_patterns
41      .iter()
42      .any(|pattern| match_pattern(&relative_path, pattern));
43    if excluded {
44      return false;
45    }
46  }
47
48  true
49}
50
51/// Matches a file path against a pattern (glob or regex)
52fn match_pattern(file_path: &str, pattern: &PathFilterUnion) -> bool {
53  match pattern {
54    PathFilterUnion::Glob(glob) => GlobPattern::new(glob)
55      .map(|p| p.matches(file_path))
56      .unwrap_or_else(|e| {
57        warn!(
58          "Invalid glob pattern '{}': {}. Skipping pattern match.",
59          glob, e
60        );
61        false
62      }),
63    PathFilterUnion::Regex(regex_str) => match Regex::new(regex_str) {
64      Ok(r) => match r.is_match(file_path) {
65        Ok(matched) => matched,
66        Err(e) => {
67          warn!(
68            "Error matching regex pattern '{}' against '{}': {}. Skipping pattern match.",
69            regex_str, file_path, e
70          );
71          false
72        },
73      },
74      Err(e) => {
75        warn!(
76          "Invalid regex pattern '{}': {}. Skipping pattern match.",
77          regex_str, e
78        );
79        false
80      },
81    },
82  }
83}
84
85#[cfg(test)]
86mod tests {
87  use super::*;
88
89  #[test]
90  fn test_match_pattern_glob() {
91    let pattern = PathFilterUnion::Glob("src/**/*.rs".to_string());
92
93    assert!(match_pattern("src/main.rs", &pattern));
94    assert!(match_pattern("src/lib/utils.rs", &pattern));
95    assert!(match_pattern("src/deep/nested/file.rs", &pattern));
96    assert!(!match_pattern("lib/main.rs", &pattern));
97    assert!(!match_pattern("src/main.ts", &pattern));
98  }
99
100  #[test]
101  fn test_match_pattern_regex() {
102    let pattern = PathFilterUnion::Regex(r"\.test\.rs$".to_string());
103
104    assert!(match_pattern("src/button.test.rs", &pattern));
105    assert!(match_pattern("lib/component.test.rs", &pattern));
106    assert!(!match_pattern("src/button.rs", &pattern));
107    assert!(!match_pattern("src/button.test.ts", &pattern));
108  }
109
110  #[test]
111  fn test_match_pattern_regex_negative_lookbehind() {
112    // Test negative lookbehind: match .tsx files NOT preceded by src/
113    // The lookbehind checks the position before the entire match
114    let pattern = PathFilterUnion::Regex(r"(?<!src/).*\.tsx$".to_string());
115
116    // Both should match because the lookbehind checks position before .*,
117    // which is start-of-string for both, and start-of-string is not "src/"
118    assert!(match_pattern("lib/components/Button.tsx", &pattern));
119    assert!(match_pattern("src/components/Button.tsx", &pattern));
120
121    // Test a pattern that actually checks if string starts with src/
122    // Use negative lookahead instead: don't start with src/
123    let pattern2 = PathFilterUnion::Regex(r"^(?!src/).*\.tsx$".to_string());
124    assert!(match_pattern("lib/components/Button.tsx", &pattern2));
125    assert!(!match_pattern("src/components/Button.tsx", &pattern2));
126  }
127
128  #[test]
129  fn test_match_pattern_regex_negative_lookahead() {
130    // Test negative lookahead: match node_modules but not @stylexjs
131    let pattern = PathFilterUnion::Regex(r"node_modules(?!/@stylexjs)".to_string());
132
133    // Should match - not followed by /@stylexjs
134    assert!(match_pattern(
135      "node_modules/some-package/index.js",
136      &pattern
137    ));
138
139    // Should NOT match - followed by /@stylexjs
140    assert!(!match_pattern(
141      "node_modules/@stylexjs/stylex/index.js",
142      &pattern
143    ));
144  }
145
146  #[test]
147  fn test_match_pattern_complex_glob() {
148    // Note: glob crate doesn't support brace expansion like {rs,toml}
149    // Users should specify separate patterns instead
150    let pattern_rs = PathFilterUnion::Glob("**/*.rs".to_string());
151    let pattern_toml = PathFilterUnion::Glob("**/*.toml".to_string());
152
153    assert!(match_pattern("src/main.rs", &pattern_rs));
154    assert!(match_pattern("Cargo.toml", &pattern_toml));
155
156    // Brace expansion doesn't work - this is expected behavior
157    let pattern_braces = PathFilterUnion::Glob("**/*.{rs,toml}".to_string());
158    assert!(!match_pattern("Cargo.toml", &pattern_braces));
159  }
160
161  #[test]
162  fn test_should_transform_file_no_patterns() {
163    let result = should_transform_file("src/main.rs", &None, &None);
164    assert!(result);
165  }
166
167  #[test]
168  fn test_should_transform_file_empty_patterns() {
169    let result = should_transform_file("src/main.rs", &Some(vec![]), &Some(vec![]));
170    assert!(result);
171  }
172
173  #[test]
174  fn test_should_transform_file_include_glob() {
175    let include = Some(vec![PathFilterUnion::Glob("src/**/*.rs".to_string())]);
176
177    // These paths should be relative to cwd in actual use
178    assert!(should_transform_file("src/main.rs", &include, &None));
179    assert!(should_transform_file("src/lib/utils.rs", &include, &None));
180    assert!(!should_transform_file("lib/main.rs", &include, &None));
181  }
182
183  #[test]
184  fn test_should_transform_file_include_regex() {
185    let include = Some(vec![PathFilterUnion::Regex(r"^src/.*\.rs$".to_string())]);
186
187    assert!(should_transform_file("src/main.rs", &include, &None));
188    assert!(should_transform_file("src/utils.rs", &include, &None));
189    assert!(!should_transform_file("lib/main.rs", &include, &None));
190  }
191
192  #[test]
193  fn test_should_transform_file_exclude_glob() {
194    let exclude = Some(vec![PathFilterUnion::Glob("**/*.test.rs".to_string())]);
195
196    assert!(should_transform_file("src/main.rs", &None, &exclude));
197    assert!(!should_transform_file("src/main.test.rs", &None, &exclude));
198    assert!(!should_transform_file("lib/utils.test.rs", &None, &exclude));
199  }
200
201  #[test]
202  fn test_should_transform_file_exclude_regex() {
203    let exclude = Some(vec![PathFilterUnion::Regex(r"\.test\.rs$".to_string())]);
204
205    assert!(should_transform_file("src/main.rs", &None, &exclude));
206    assert!(!should_transform_file("src/main.test.rs", &None, &exclude));
207  }
208
209  #[test]
210  fn test_should_transform_file_multiple_include_patterns() {
211    let include = Some(vec![
212      PathFilterUnion::Glob("src/**/*.rs".to_string()),
213      PathFilterUnion::Glob("app/**/*.rs".to_string()),
214    ]);
215
216    assert!(should_transform_file("src/main.rs", &include, &None));
217    assert!(should_transform_file("app/main.rs", &include, &None));
218    assert!(!should_transform_file("lib/main.rs", &include, &None));
219  }
220
221  #[test]
222  fn test_should_transform_file_multiple_exclude_patterns() {
223    let exclude = Some(vec![
224      PathFilterUnion::Glob("**/*.test.rs".to_string()),
225      PathFilterUnion::Glob("**/*.spec.rs".to_string()),
226    ]);
227
228    assert!(should_transform_file("src/main.rs", &None, &exclude));
229    assert!(!should_transform_file("src/main.test.rs", &None, &exclude));
230    assert!(!should_transform_file("src/main.spec.rs", &None, &exclude));
231  }
232
233  #[test]
234  fn test_should_transform_file_combined_include_exclude() {
235    let include = Some(vec![PathFilterUnion::Glob("src/**/*.rs".to_string())]);
236    let exclude = Some(vec![PathFilterUnion::Glob("**/*.test.rs".to_string())]);
237
238    assert!(should_transform_file("src/main.rs", &include, &exclude));
239    assert!(!should_transform_file(
240      "src/main.test.rs",
241      &include,
242      &exclude
243    ));
244    assert!(!should_transform_file("lib/main.rs", &include, &exclude));
245  }
246
247  #[test]
248  fn test_should_transform_file_mixed_patterns() {
249    let include = Some(vec![
250      PathFilterUnion::Glob("src/**/*.rs".to_string()),
251      PathFilterUnion::Regex(r"^app/.*\.rs$".to_string()),
252    ]);
253    let exclude = Some(vec![
254      PathFilterUnion::Glob("**/__tests__/**".to_string()),
255      PathFilterUnion::Regex(r"\.test\.rs$".to_string()),
256    ]);
257
258    assert!(should_transform_file("src/main.rs", &include, &exclude));
259    assert!(should_transform_file("app/main.rs", &include, &exclude));
260    assert!(!should_transform_file(
261      "src/__tests__/main.rs",
262      &include,
263      &exclude
264    ));
265    assert!(!should_transform_file(
266      "src/main.test.rs",
267      &include,
268      &exclude
269    ));
270    assert!(!should_transform_file("lib/main.rs", &include, &exclude));
271  }
272
273  #[test]
274  fn test_should_transform_file_exclude_takes_precedence() {
275    // Even if file matches include, exclude should filter it out
276    let include = Some(vec![PathFilterUnion::Glob("src/**/*.rs".to_string())]);
277    let exclude = Some(vec![PathFilterUnion::Glob("src/__tests__/**".to_string())]);
278
279    assert!(should_transform_file("src/main.rs", &include, &exclude));
280    assert!(!should_transform_file(
281      "src/__tests__/main.rs",
282      &include,
283      &exclude
284    ));
285  }
286
287  #[test]
288  fn test_match_pattern_edge_cases() {
289    // Test with dotfiles
290    let pattern = PathFilterUnion::Glob("**/.*.rs".to_string());
291    assert!(match_pattern(".hidden.rs", &pattern));
292
293    // Test with multiple dots
294    let pattern2 = PathFilterUnion::Glob("**/*.test.rs".to_string());
295    assert!(match_pattern("component.test.rs", &pattern2));
296  }
297
298  #[test]
299  fn test_invalid_regex_pattern() {
300    // Invalid regex should not panic, should return false
301    let pattern = PathFilterUnion::Regex("[invalid(".to_string());
302    assert!(!match_pattern("src/main.rs", &pattern));
303  }
304
305  #[test]
306  fn test_invalid_glob_pattern() {
307    // Invalid glob should not panic, should return false
308    let pattern = PathFilterUnion::Glob("[invalid".to_string());
309    assert!(!match_pattern("src/main.rs", &pattern));
310  }
311}