Skip to main content

graphyn_core/
scan.rs

1use std::path::{Path, PathBuf};
2use walkdir::WalkDir;
3
4#[derive(Debug, Clone, Default)]
5pub struct ScanConfig {
6    pub include_patterns: Vec<String>,
7    pub exclude_patterns: Vec<String>,
8    pub respect_gitignore: bool,
9}
10
11impl ScanConfig {
12    pub fn default_enabled() -> Self {
13        Self {
14            include_patterns: Vec::new(),
15            exclude_patterns: Vec::new(),
16            respect_gitignore: true,
17        }
18    }
19}
20
21pub fn parse_csv_patterns(raw: Option<&str>) -> Vec<String> {
22    raw.unwrap_or_default()
23        .split(',')
24        .map(str::trim)
25        .filter(|p| !p.is_empty())
26        .map(|p| p.replace('\\', "/"))
27        .collect()
28}
29
30pub fn path_matches_any(path: &str, patterns: &[String]) -> bool {
31    patterns.iter().any(|p| pattern_matches(path, p))
32}
33
34pub fn pattern_matches(path: &str, pattern: &str) -> bool {
35    let path = normalize(path);
36    let pattern = normalize(pattern);
37
38    if pattern.is_empty() {
39        return false;
40    }
41
42    if !pattern.contains('/') {
43        if wildcard_match(&path, &pattern) {
44            return true;
45        }
46        return path.split('/').any(|seg| wildcard_match(seg, &pattern));
47    }
48
49    if let Some(tail) = pattern.strip_prefix("**/") {
50        return path.split('/').enumerate().any(|(idx, _)| {
51            wildcard_match(
52                &path.split('/').skip(idx).collect::<Vec<_>>().join("/"),
53                tail,
54            )
55        });
56    }
57
58    if wildcard_match(&path, &pattern) {
59        return true;
60    }
61
62    if path.len() >= pattern.len() {
63        return path.ends_with(&pattern);
64    }
65
66    false
67}
68
69fn wildcard_match(text: &str, pattern: &str) -> bool {
70    let t = text.as_bytes();
71    let p = pattern.as_bytes();
72
73    let (mut ti, mut pi) = (0usize, 0usize);
74    let mut star = None::<usize>;
75    let mut match_i = 0usize;
76
77    while ti < t.len() {
78        if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
79            ti += 1;
80            pi += 1;
81        } else if pi < p.len() && p[pi] == b'*' {
82            star = Some(pi);
83            pi += 1;
84            match_i = ti;
85        } else if let Some(star_pos) = star {
86            pi = star_pos + 1;
87            match_i += 1;
88            ti = match_i;
89        } else {
90            return false;
91        }
92    }
93
94    while pi < p.len() && p[pi] == b'*' {
95        pi += 1;
96    }
97
98    pi == p.len()
99}
100
101fn normalize(input: &str) -> String {
102    input
103        .trim()
104        .replace('\\', "/")
105        .trim_start_matches("./")
106        .trim_matches('/')
107        .to_string()
108}
109
110#[derive(Debug, Clone)]
111pub struct GitignoreRule {
112    pub pattern: String,
113    pub negated: bool,
114    pub directory_only: bool,
115}
116
117pub fn load_root_gitignore_rules(root: &Path) -> Vec<GitignoreRule> {
118    let path = root.join(".gitignore");
119    let Ok(content) = std::fs::read_to_string(path) else {
120        return Vec::new();
121    };
122
123    content
124        .lines()
125        .map(str::trim)
126        .filter(|line| !line.is_empty() && !line.starts_with('#'))
127        .filter_map(|line| {
128            let negated = line.starts_with('!');
129            let mut body = if negated { &line[1..] } else { line };
130            body = body.trim();
131            if body.is_empty() {
132                return None;
133            }
134
135            let directory_only = body.ends_with('/');
136            let pattern = body.trim_end_matches('/').replace('\\', "/");
137            Some(GitignoreRule {
138                pattern,
139                negated,
140                directory_only,
141            })
142        })
143        .collect()
144}
145
146pub fn is_ignored_by_rules(rel_path: &str, _is_dir: bool, rules: &[GitignoreRule]) -> bool {
147    let path = normalize(rel_path);
148    if path.is_empty() {
149        return false;
150    }
151
152    let mut ignored = false;
153
154    for rule in rules {
155        let anchored = rule.pattern.starts_with('/');
156        let rule_pattern = rule.pattern.trim_start_matches('/');
157        let dir_prefix = format!("{rule_pattern}/");
158
159        let matches_candidate = |candidate: &str| {
160            if rule.directory_only {
161                candidate == rule_pattern || candidate.starts_with(&dir_prefix)
162            } else {
163                pattern_matches(candidate, rule_pattern)
164            }
165        };
166
167        let matched = if anchored {
168            matches_candidate(&path)
169        } else {
170            matches_candidate(&path)
171                || path.split('/').enumerate().any(|(idx, _)| {
172                    matches_candidate(&path.split('/').skip(idx).collect::<Vec<_>>().join("/"))
173                })
174        };
175
176        if matched {
177            ignored = !rule.negated;
178        }
179    }
180
181    ignored
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use std::path::PathBuf;
188
189    #[test]
190    fn test_parse_csv_patterns() {
191        let patterns = parse_csv_patterns(Some("src/**,  tests/*.ts , ,node_modules/**"));
192        assert_eq!(
193            patterns,
194            vec![
195                "src/**".to_string(),
196                "tests/*.ts".to_string(),
197                "node_modules/**".to_string()
198            ]
199        );
200    }
201
202    #[test]
203    fn test_pattern_matches_globs_and_suffix() {
204        assert!(pattern_matches("src/a/b/file.ts", "src/**"));
205        assert!(pattern_matches("src/a/b/file.ts", "**/*.ts"));
206        assert!(pattern_matches("src/a/b/file.ts", "*.ts"));
207        assert!(pattern_matches("src/a/b/file.ts", "a/b/file.ts"));
208        assert!(!pattern_matches("src/a/b/file.ts", "*.tsx"));
209    }
210
211    #[test]
212    fn test_gitignore_rule_evaluation_with_negation() {
213        let rules = vec![
214            GitignoreRule {
215                pattern: "dist".to_string(),
216                negated: false,
217                directory_only: true,
218            },
219            GitignoreRule {
220                pattern: "dist/keep.ts".to_string(),
221                negated: true,
222                directory_only: false,
223            },
224        ];
225
226        assert!(is_ignored_by_rules("dist", true, &rules));
227        assert!(is_ignored_by_rules("dist/a.ts", false, &rules));
228        assert!(!is_ignored_by_rules("dist/keep.ts", false, &rules));
229    }
230
231    #[test]
232    fn test_include_patterns_descend_correctly_into_subdirectories() {
233        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/scan/monorepo");
234        let config = ScanConfig {
235            include_patterns: vec!["projects/frontend/web-portal/**".to_string()],
236            exclude_patterns: Vec::new(),
237            respect_gitignore: false,
238        };
239
240        let files = walk_source_files_with_config(&root, &config, |_| true)
241            .expect("walk should succeed for monorepo fixture");
242        let rel_files: Vec<String> = files
243            .iter()
244            .filter_map(|p| {
245                p.strip_prefix(&root)
246                    .ok()
247                    .map(|r| r.to_string_lossy().replace('\\', "/"))
248            })
249            .collect();
250
251        assert!(
252            rel_files
253                .iter()
254                .any(|f| f.ends_with("projects/frontend/web-portal/src/App.ts")),
255            "should include App.ts under nested include path"
256        );
257        assert!(
258            rel_files
259                .iter()
260                .any(|f| f.ends_with("projects/frontend/web-portal/src/Component.ts")),
261            "should include Component.ts under nested include path"
262        );
263    }
264
265    #[test]
266    fn test_include_with_double_star_finds_nested_files() {
267        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/scan/monorepo");
268        let config = ScanConfig {
269            include_patterns: vec!["projects/**/*.ts".to_string()],
270            exclude_patterns: Vec::new(),
271            respect_gitignore: false,
272        };
273
274        let files = walk_source_files_with_config(&root, &config, |_| true)
275            .expect("walk should succeed for recursive glob include");
276        let rel_files: Vec<String> = files
277            .iter()
278            .filter_map(|p| {
279                p.strip_prefix(&root)
280                    .ok()
281                    .map(|r| r.to_string_lossy().replace('\\', "/"))
282            })
283            .collect();
284
285        assert_eq!(
286            rel_files.len(),
287            3,
288            "recursive include should find all three fixture TypeScript files"
289        );
290        assert!(
291            rel_files
292                .iter()
293                .any(|f| f.ends_with("projects/api/administration/src/AdminService.ts")),
294            "recursive include should include AdminService.ts"
295        );
296    }
297}
298
299pub fn walk_source_files_with_config<F>(
300    root: &Path,
301    config: &ScanConfig,
302    is_supported: F,
303) -> Result<Vec<PathBuf>, std::io::Error>
304where
305    F: Fn(&Path) -> bool,
306{
307    let mut out = Vec::new();
308    let rules = if config.respect_gitignore {
309        load_root_gitignore_rules(root)
310    } else {
311        Vec::new()
312    };
313
314    for entry in WalkDir::new(root)
315        .into_iter()
316        .filter_entry(|e| should_descend(root, e.path(), config, &rules))
317    {
318        let entry = entry?;
319        let path = entry.path();
320        if !path.is_file() || !is_supported(path) {
321            continue;
322        }
323
324        if should_include_file(root, path, config, &rules) {
325            out.push(path.to_path_buf());
326        }
327    }
328
329    out.sort();
330    Ok(out)
331}
332
333const DEFAULT_EXCLUDE_DIRS: &[&str] = &[
334    "node_modules",
335    "dist",
336    "build",
337    "out",
338    ".next",
339    ".nuxt",
340    ".output",
341    "coverage",
342    ".cache",
343    ".turbo",
344    ".parcel-cache",
345    ".graphyn",
346    ".git",
347    "target",
348];
349
350const DEFAULT_EXCLUDE_SUFFIXES: &[&str] = &[
351    ".d.ts", ".d.mts", ".d.cts", // TypeScript declarations
352    ".min.js", ".min.mjs", // Minified JS
353    ".min.css", // Minified CSS
354    ".map",     // Source maps
355];
356
357pub fn should_include_relative_path(
358    relative_path: &str,
359    is_dir: bool,
360    config: &ScanConfig,
361    rules: &[GitignoreRule],
362) -> bool {
363    let rel = relative_path.replace('\\', "/");
364
365    if rel.is_empty() || rel == "." {
366        return true;
367    }
368
369    // Check directory segments against default excludes
370    for segment in rel.split('/') {
371        if DEFAULT_EXCLUDE_DIRS.contains(&segment) {
372            return false;
373        }
374    }
375
376    // Check file suffixes (compound extensions like .d.ts)
377    if !is_dir {
378        for suffix in DEFAULT_EXCLUDE_SUFFIXES {
379            if rel.ends_with(suffix) {
380                return false;
381            }
382        }
383    }
384
385    if config.respect_gitignore && is_ignored_by_rules(&rel, is_dir, rules) {
386        return false;
387    }
388
389    if !config.exclude_patterns.is_empty() && path_matches_any(&rel, &config.exclude_patterns) {
390        return false;
391    }
392
393    if config.include_patterns.is_empty() {
394        return true;
395    }
396
397    path_matches_any(&rel, &config.include_patterns)
398}
399
400fn should_descend(root: &Path, path: &Path, config: &ScanConfig, rules: &[GitignoreRule]) -> bool {
401    if !path.is_dir() {
402        return true;
403    }
404
405    let Ok(relative) = path.strip_prefix(root) else {
406        return true;
407    };
408    let rel = relative.to_string_lossy().replace('\\', "/");
409    if rel.is_empty() {
410        return true;
411    }
412
413    for segment in rel.split('/') {
414        if DEFAULT_EXCLUDE_DIRS.contains(&segment) {
415            return false;
416        }
417    }
418
419    if config.respect_gitignore && is_ignored_by_rules(&rel, true, rules) {
420        return false;
421    }
422
423    if !config.exclude_patterns.is_empty() && path_matches_any(&rel, &config.exclude_patterns) {
424        return false;
425    }
426
427    if !config.include_patterns.is_empty() {
428        return config
429            .include_patterns
430            .iter()
431            .any(|pattern| directory_could_contain_match(&rel, pattern));
432    }
433
434    true
435}
436
437fn directory_could_contain_match(dir_rel: &str, pattern: &str) -> bool {
438    let dir = normalize(dir_rel);
439    let pat = normalize(pattern);
440
441    if dir.is_empty() || pat.is_empty() {
442        return true;
443    }
444
445    if pat.starts_with("**") {
446        return true;
447    }
448
449    let pat_no_globstar = pat.strip_prefix("**/").unwrap_or(&pat);
450    let fixed_prefix = pat_no_globstar
451        .split('*')
452        .next()
453        .unwrap_or("")
454        .trim_matches('/');
455
456    if fixed_prefix.is_empty() {
457        return true;
458    }
459
460    if fixed_prefix.starts_with(&dir) || dir.starts_with(fixed_prefix) {
461        return true;
462    }
463
464    pattern_matches(&format!("{dir}/dummy"), &pat)
465}
466
467fn should_include_file(
468    root: &Path,
469    path: &Path,
470    config: &ScanConfig,
471    rules: &[GitignoreRule],
472) -> bool {
473    let Ok(relative) = path.strip_prefix(root) else {
474        return false;
475    };
476
477    let rel = relative.to_string_lossy().replace('\\', "/");
478    should_include_relative_path(&rel, false, config, rules)
479}