Skip to main content

graphyn_core/
scan.rs

1use std::path::{Path, PathBuf};
2use walkdir::WalkDir;
3use crate::ir::Language;
4
5#[derive(Debug, Clone, Default)]
6pub struct ScanConfig {
7    pub include_patterns: Vec<String>,
8    pub exclude_patterns: Vec<String>,
9    pub respect_gitignore: bool,
10}
11
12impl ScanConfig {
13    pub fn default_enabled() -> Self {
14        Self {
15            include_patterns: Vec::new(),
16            exclude_patterns: Vec::new(),
17            respect_gitignore: true,
18        }
19    }
20}
21
22pub fn parse_csv_patterns(raw: Option<&str>) -> Vec<String> {
23    raw.unwrap_or_default()
24        .split(',')
25        .map(str::trim)
26        .filter(|p| !p.is_empty())
27        .map(|p| p.replace('\\', "/"))
28        .collect()
29}
30
31pub fn path_matches_any(path: &str, patterns: &[String]) -> bool {
32    patterns.iter().any(|p| pattern_matches(path, p))
33}
34
35pub fn pattern_matches(path: &str, pattern: &str) -> bool {
36    let path = normalize(path);
37    let pattern = normalize(pattern);
38
39    if pattern.is_empty() {
40        return false;
41    }
42
43    if !pattern.contains('/') {
44        if wildcard_match(&path, &pattern) {
45            return true;
46        }
47        return path.split('/').any(|seg| wildcard_match(seg, &pattern));
48    }
49
50    if let Some(tail) = pattern.strip_prefix("**/") {
51        return path.split('/').enumerate().any(|(idx, _)| {
52            wildcard_match(
53                &path.split('/').skip(idx).collect::<Vec<_>>().join("/"),
54                tail,
55            )
56        });
57    }
58
59    if wildcard_match(&path, &pattern) {
60        return true;
61    }
62
63    if path.len() >= pattern.len() {
64        return path.ends_with(&pattern);
65    }
66
67    false
68}
69
70fn wildcard_match(text: &str, pattern: &str) -> bool {
71    let t = text.as_bytes();
72    let p = pattern.as_bytes();
73
74    let (mut ti, mut pi) = (0usize, 0usize);
75    let mut star = None::<usize>;
76    let mut match_i = 0usize;
77
78    while ti < t.len() {
79        if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
80            ti += 1;
81            pi += 1;
82        } else if pi < p.len() && p[pi] == b'*' {
83            star = Some(pi);
84            pi += 1;
85            match_i = ti;
86        } else if let Some(star_pos) = star {
87            pi = star_pos + 1;
88            match_i += 1;
89            ti = match_i;
90        } else {
91            return false;
92        }
93    }
94
95    while pi < p.len() && p[pi] == b'*' {
96        pi += 1;
97    }
98
99    pi == p.len()
100}
101
102fn normalize(input: &str) -> String {
103    input
104        .trim()
105        .replace('\\', "/")
106        .trim_start_matches("./")
107        .trim_matches('/')
108        .to_string()
109}
110
111#[derive(Debug, Clone)]
112pub struct GitignoreRule {
113    pub pattern: String,
114    pub negated: bool,
115    pub directory_only: bool,
116}
117
118pub fn load_root_gitignore_rules(root: &Path) -> Vec<GitignoreRule> {
119    let path = root.join(".gitignore");
120    let Ok(content) = std::fs::read_to_string(path) else {
121        return Vec::new();
122    };
123
124    content
125        .lines()
126        .map(str::trim)
127        .filter(|line| !line.is_empty() && !line.starts_with('#'))
128        .filter_map(|line| {
129            let negated = line.starts_with('!');
130            let mut body = if negated { &line[1..] } else { line };
131            body = body.trim();
132            if body.is_empty() {
133                return None;
134            }
135
136            let directory_only = body.ends_with('/');
137            let pattern = body.trim_end_matches('/').replace('\\', "/");
138            Some(GitignoreRule {
139                pattern,
140                negated,
141                directory_only,
142            })
143        })
144        .collect()
145}
146
147pub fn is_ignored_by_rules(rel_path: &str, _is_dir: bool, rules: &[GitignoreRule]) -> bool {
148    let path = normalize(rel_path);
149    if path.is_empty() {
150        return false;
151    }
152
153    let mut ignored = false;
154
155    for rule in rules {
156        let anchored = rule.pattern.starts_with('/');
157        let rule_pattern = rule.pattern.trim_start_matches('/');
158        let dir_prefix = format!("{rule_pattern}/");
159
160        let matches_candidate = |candidate: &str| {
161            if rule.directory_only {
162                candidate == rule_pattern || candidate.starts_with(&dir_prefix)
163            } else {
164                pattern_matches(candidate, rule_pattern)
165            }
166        };
167
168        let matched = if anchored {
169            matches_candidate(&path)
170        } else {
171            matches_candidate(&path)
172                || path.split('/').enumerate().any(|(idx, _)| {
173                    matches_candidate(&path.split('/').skip(idx).collect::<Vec<_>>().join("/"))
174                })
175        };
176
177        if matched {
178            ignored = !rule.negated;
179        }
180    }
181
182    ignored
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use std::path::PathBuf;
189
190    #[test]
191    fn test_parse_csv_patterns() {
192        let patterns = parse_csv_patterns(Some("src/**,  tests/*.ts , ,node_modules/**"));
193        assert_eq!(
194            patterns,
195            vec![
196                "src/**".to_string(),
197                "tests/*.ts".to_string(),
198                "node_modules/**".to_string()
199            ]
200        );
201    }
202
203    #[test]
204    fn test_pattern_matches_globs_and_suffix() {
205        assert!(pattern_matches("src/a/b/file.ts", "src/**"));
206        assert!(pattern_matches("src/a/b/file.ts", "**/*.ts"));
207        assert!(pattern_matches("src/a/b/file.ts", "*.ts"));
208        assert!(pattern_matches("src/a/b/file.ts", "a/b/file.ts"));
209        assert!(!pattern_matches("src/a/b/file.ts", "*.tsx"));
210    }
211
212    #[test]
213    fn test_gitignore_rule_evaluation_with_negation() {
214        let rules = vec![
215            GitignoreRule {
216                pattern: "dist".to_string(),
217                negated: false,
218                directory_only: true,
219            },
220            GitignoreRule {
221                pattern: "dist/keep.ts".to_string(),
222                negated: true,
223                directory_only: false,
224            },
225        ];
226
227        assert!(is_ignored_by_rules("dist", true, &rules));
228        assert!(is_ignored_by_rules("dist/a.ts", false, &rules));
229        assert!(!is_ignored_by_rules("dist/keep.ts", false, &rules));
230    }
231
232    #[test]
233    fn test_include_patterns_descend_correctly_into_subdirectories() {
234        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/scan/monorepo");
235        let config = ScanConfig {
236            include_patterns: vec!["projects/frontend/web-portal/**".to_string()],
237            exclude_patterns: Vec::new(),
238            respect_gitignore: false,
239        };
240
241        let files = walk_source_files_with_config(&root, &config, |_| true)
242            .expect("walk should succeed for monorepo fixture");
243        let rel_files: Vec<String> = files
244            .iter()
245            .filter_map(|p| {
246                p.strip_prefix(&root)
247                    .ok()
248                    .map(|r| r.to_string_lossy().replace('\\', "/"))
249            })
250            .collect();
251
252        assert!(
253            rel_files
254                .iter()
255                .any(|f| f.ends_with("projects/frontend/web-portal/src/App.ts")),
256            "should include App.ts under nested include path"
257        );
258        assert!(
259            rel_files
260                .iter()
261                .any(|f| f.ends_with("projects/frontend/web-portal/src/Component.ts")),
262            "should include Component.ts under nested include path"
263        );
264    }
265
266    #[test]
267    fn test_include_with_double_star_finds_nested_files() {
268        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/scan/monorepo");
269        let config = ScanConfig {
270            include_patterns: vec!["projects/**/*.ts".to_string()],
271            exclude_patterns: Vec::new(),
272            respect_gitignore: false,
273        };
274
275        let files = walk_source_files_with_config(&root, &config, |_| true)
276            .expect("walk should succeed for recursive glob include");
277        let rel_files: Vec<String> = files
278            .iter()
279            .filter_map(|p| {
280                p.strip_prefix(&root)
281                    .ok()
282                    .map(|r| r.to_string_lossy().replace('\\', "/"))
283            })
284            .collect();
285
286        assert_eq!(
287            rel_files.len(),
288            3,
289            "recursive include should find all three fixture TypeScript files"
290        );
291        assert!(
292            rel_files
293                .iter()
294                .any(|f| f.ends_with("projects/api/administration/src/AdminService.ts")),
295            "recursive include should include AdminService.ts"
296        );
297    }
298}
299
300pub fn walk_source_files_with_config<F>(
301    root: &Path,
302    config: &ScanConfig,
303    is_supported: F,
304) -> Result<Vec<PathBuf>, std::io::Error>
305where
306    F: Fn(&Path) -> bool,
307{
308    let mut out = Vec::new();
309    let rules = if config.respect_gitignore {
310        load_root_gitignore_rules(root)
311    } else {
312        Vec::new()
313    };
314
315    for entry in WalkDir::new(root)
316        .into_iter()
317        .filter_entry(|e| should_descend(root, e.path(), config, &rules))
318    {
319        let entry = entry?;
320        let path = entry.path();
321        if !path.is_file() || !is_supported(path) {
322            continue;
323        }
324
325        if should_include_file(root, path, config, &rules) {
326            out.push(path.to_path_buf());
327        }
328    }
329
330    out.sort();
331    Ok(out)
332}
333
334/// True if a file looks machine-generated and should be skipped.
335///
336/// Generated code parses fine, but its symbols are noise in an impact graph:
337/// nobody edits it by hand, and it is regenerated wholesale rather than
338/// refactored. Shared across adapters so every language applies the same rule.
339///
340/// Only the first few lines are inspected — the convention across generators
341/// (protoc, bindgen, `go generate`, OpenAPI) is a banner comment at the top.
342pub fn looks_generated(source: &str, path: &Path) -> bool {
343    const MARKERS: &[&str] = &[
344        "code generated by",
345        "auto-generated",
346        "autogenerated",
347        "automatically generated",
348        "do not edit",
349        "@generated",
350    ];
351
352    let banner: String = source.lines().take(8).collect::<Vec<_>>().join("\n").to_lowercase();
353    if MARKERS.iter().any(|m| banner.contains(m)) {
354        return true;
355    }
356
357    let path_str = path.to_string_lossy().to_ascii_lowercase();
358    // `.pb.` and `_pb2` are the protobuf conventions; `.g.` is common for
359    // codegen in Dart/Freezed-style toolchains.
360    path_str.contains(".pb.") || path_str.contains("_pb2.") || path_str.contains(".g.")
361}
362
363/// What a scan covered, and what it chose to leave out.
364///
365/// Reported so that a smaller-than-expected file count is explainable. A
366/// directory silently missing from the graph is indistinguishable from a
367/// parser bug, and users reasonably assume the latter.
368#[derive(Debug, Default, Clone)]
369pub struct ScanReport {
370    pub files: Vec<PathBuf>,
371    /// Directories pruned by a built-in default rule, which `--include` can override.
372    pub skipped_dirs: std::collections::BTreeSet<String>,
373}
374
375/// Walk for source files, recording which directories the defaults pruned.
376pub fn walk_source_files_reporting<F>(
377    root: &Path,
378    config: &ScanConfig,
379    is_supported: F,
380) -> Result<ScanReport, std::io::Error>
381where
382    F: Fn(&Path) -> bool,
383{
384    let mut report = ScanReport::default();
385    let rules = if config.respect_gitignore {
386        load_root_gitignore_rules(root)
387    } else {
388        Vec::new()
389    };
390
391    let skipped = std::cell::RefCell::new(std::collections::BTreeSet::new());
392
393    for entry in WalkDir::new(root).into_iter().filter_entry(|e| {
394        let descend = should_descend(root, e.path(), config, &rules);
395        if !descend && e.path().is_dir() {
396            if let Ok(relative) = e.path().strip_prefix(root) {
397                let rel = relative.to_string_lossy().replace('\\', "/");
398                // Only report prunes a user could reverse; `.git` and explicit
399                // `--exclude` patterns are not surprises worth reporting.
400                if let Some(name) = rel.rsplit('/').next() {
401                    if DEFAULT_EXCLUDE_DIRS.contains(&name) {
402                        skipped.borrow_mut().insert(name.to_string());
403                    }
404                }
405            }
406        }
407        descend
408    }) {
409        let entry = entry?;
410        let path = entry.path();
411        if !path.is_file() || !is_supported(path) {
412            continue;
413        }
414        if should_include_file(root, path, config, &rules) {
415            report.files.push(path.to_path_buf());
416        }
417    }
418
419    report.files.sort();
420    report.skipped_dirs = skipped.into_inner();
421    Ok(report)
422}
423
424pub fn detect_language_from_extension(ext: &str) -> Option<Language> {
425    match ext.to_ascii_lowercase().as_str() {
426        "py" | "pyi" => Some(Language::Python),
427        "rs" => Some(Language::Rust),
428        "go" => Some(Language::Go),
429        "c" | "h" => Some(Language::C),
430        "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Some(Language::Cpp),
431        "ts" | "tsx" | "mts" | "cts" | "vue" | "svelte" | "astro" => Some(Language::TypeScript),
432        "js" | "jsx" | "mjs" | "cjs" => Some(Language::JavaScript),
433        _ => None,
434    }
435}
436
437pub fn is_any_supported_source_file(path: &Path) -> bool {
438    path.extension()
439        .and_then(|e| e.to_str())
440        .and_then(detect_language_from_extension)
441        .is_some()
442}
443
444/// Directories that never contain source worth indexing, in any language.
445///
446/// Deliberately conservative. An earlier revision also listed `env`, `gen`,
447/// `generated`, `proto` and `vendor`; those are ordinary source directory names
448/// in plenty of projects (`src/env/` for configuration, `src/gen/` for
449/// hand-written generator code), and because this check ran before
450/// `--include` was consulted there was no way to opt back in. Files simply
451/// vanished from the graph with no diagnostic, which reads as a parser bug.
452///
453/// Anything here can still be reached with an explicit `--include` pattern —
454/// see [`should_include_relative_path`] — except `.git`, which is never source.
455const DEFAULT_EXCLUDE_DIRS: &[&str] = &[
456    // Package manager and build output
457    "node_modules",
458    "dist",
459    "build",
460    "out",
461    "target",
462    "coverage",
463    // Framework caches
464    ".next",
465    ".nuxt",
466    ".output",
467    ".cache",
468    ".turbo",
469    ".parcel-cache",
470    // Python environments and caches
471    "__pycache__",
472    ".venv",
473    "venv",
474    ".eggs",
475    ".mypy_cache",
476    ".pytest_cache",
477    ".tox",
478    "site-packages",
479    // C/C++ build trees
480    "CMakeFiles",
481    "_deps",
482    ".cmake",
483    // Ours
484    ".graphyn",
485];
486
487/// Never indexed, and not reachable even with an explicit `--include`.
488const ALWAYS_EXCLUDE_DIRS: &[&str] = &[".git", ".hg", ".svn"];
489
490const DEFAULT_EXCLUDE_SUFFIXES: &[&str] = &[
491    ".d.ts", ".d.mts", ".d.cts", // TypeScript declarations
492    ".min.js", ".min.mjs", // Minified JS
493    ".min.css", // Minified CSS
494    ".map",     // Source maps
495    ".pyc",
496    ".pyo",
497    ".o",
498    ".a",
499    ".so",
500    ".dll",
501    ".obj",
502    ".lib",
503    ".exe",
504    ".pb.c",
505    ".pb.h",
506    "_gen.c",
507    "_gen.h",
508    ".pb.rs",
509];
510
511pub fn should_include_relative_path(
512    relative_path: &str,
513    is_dir: bool,
514    config: &ScanConfig,
515    rules: &[GitignoreRule],
516) -> bool {
517    let rel = relative_path.replace('\\', "/");
518
519    if rel.is_empty() || rel == "." {
520        return true;
521    }
522
523    // Version-control metadata is never source, and no flag reaches it.
524    for segment in rel.split('/') {
525        if ALWAYS_EXCLUDE_DIRS.contains(&segment) {
526            return false;
527        }
528    }
529
530    // An explicit exclude always wins: the user asked for it by name.
531    if !config.exclude_patterns.is_empty() && path_matches_any(&rel, &config.exclude_patterns) {
532        return false;
533    }
534
535    // An explicit include also wins, over the built-in defaults below. Naming a
536    // path on the command line is an unambiguous statement of intent, and
537    // without this a directory matching a default exclude could not be indexed
538    // at all — `--include 'src/env/**'` would silently match nothing.
539    let explicitly_included =
540        !config.include_patterns.is_empty() && path_matches_any(&rel, &config.include_patterns);
541
542    if !explicitly_included {
543        for segment in rel.split('/') {
544            if DEFAULT_EXCLUDE_DIRS.contains(&segment) {
545                return false;
546            }
547        }
548
549        if config.respect_gitignore && is_ignored_by_rules(&rel, is_dir, rules) {
550            return false;
551        }
552    }
553
554    // Compiled output and declaration files carry no symbols a user would edit,
555    // whatever the include patterns say.
556    if !is_dir {
557        for suffix in DEFAULT_EXCLUDE_SUFFIXES {
558            if rel.ends_with(suffix) {
559                return false;
560            }
561        }
562    }
563
564    if config.include_patterns.is_empty() {
565        return true;
566    }
567
568    explicitly_included
569}
570
571fn should_descend(root: &Path, path: &Path, config: &ScanConfig, rules: &[GitignoreRule]) -> bool {
572    if !path.is_dir() {
573        return true;
574    }
575
576    let Ok(relative) = path.strip_prefix(root) else {
577        return true;
578    };
579    let rel = relative.to_string_lossy().replace('\\', "/");
580    if rel.is_empty() {
581        return true;
582    }
583
584    for segment in rel.split('/') {
585        if ALWAYS_EXCLUDE_DIRS.contains(&segment) {
586            return false;
587        }
588    }
589
590    if !config.exclude_patterns.is_empty() && path_matches_any(&rel, &config.exclude_patterns) {
591        return false;
592    }
593
594    // Mirror `should_include_relative_path`: if an include pattern could match
595    // inside this directory, descend even when a default exclude names it.
596    let include_reaches_here = !config.include_patterns.is_empty()
597        && config
598            .include_patterns
599            .iter()
600            .any(|pattern| directory_could_contain_match(&rel, pattern));
601
602    if !include_reaches_here {
603        for segment in rel.split('/') {
604            if DEFAULT_EXCLUDE_DIRS.contains(&segment) {
605                return false;
606            }
607        }
608
609        if config.respect_gitignore && is_ignored_by_rules(&rel, true, rules) {
610            return false;
611        }
612
613        if !config.include_patterns.is_empty() {
614            return false;
615        }
616    }
617
618    true
619}
620
621fn directory_could_contain_match(dir_rel: &str, pattern: &str) -> bool {
622    let dir = normalize(dir_rel);
623    let pat = normalize(pattern);
624
625    if dir.is_empty() || pat.is_empty() {
626        return true;
627    }
628
629    if pat.starts_with("**") {
630        return true;
631    }
632
633    let pat_no_globstar = pat.strip_prefix("**/").unwrap_or(&pat);
634    let fixed_prefix = pat_no_globstar
635        .split('*')
636        .next()
637        .unwrap_or("")
638        .trim_matches('/');
639
640    if fixed_prefix.is_empty() {
641        return true;
642    }
643
644    if fixed_prefix.starts_with(&dir) || dir.starts_with(fixed_prefix) {
645        return true;
646    }
647
648    pattern_matches(&format!("{dir}/dummy"), &pat)
649}
650
651fn should_include_file(
652    root: &Path,
653    path: &Path,
654    config: &ScanConfig,
655    rules: &[GitignoreRule],
656) -> bool {
657    let Ok(relative) = path.strip_prefix(root) else {
658        return false;
659    };
660
661    let rel = relative.to_string_lossy().replace('\\', "/");
662    should_include_relative_path(&rel, false, config, rules)
663}
664
665#[cfg(test)]
666mod default_exclude_tests {
667    use super::*;
668    use std::path::PathBuf;
669
670    fn fixture() -> PathBuf {
671        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/scan/default_excludes")
672    }
673
674    fn scan(config: ScanConfig) -> Vec<String> {
675        let root = fixture();
676        let report = walk_source_files_reporting(&root, &config, |p| {
677            p.extension().and_then(|e| e.to_str()) == Some("ts")
678        })
679        .expect("scan succeeds");
680        report
681            .files
682            .iter()
683            .filter_map(|p| {
684                p.strip_prefix(&root)
685                    .ok()
686                    .map(|r| r.to_string_lossy().replace('\\', "/"))
687            })
688            .collect()
689    }
690
691    #[test]
692    fn ordinary_source_directory_names_are_no_longer_excluded_by_default() {
693        // `env`, `gen` and `proto` were added to the default exclude list, which
694        // silently removed `src/env/` — a common configuration directory — from
695        // every existing TypeScript project's graph.
696        let files = scan(ScanConfig {
697            include_patterns: Vec::new(),
698            exclude_patterns: Vec::new(),
699            respect_gitignore: false,
700        });
701
702        assert!(
703            files.iter().any(|f| f.contains("src/env/")),
704            "src/env is ordinary source; got {files:?}"
705        );
706        assert!(
707            files.iter().any(|f| f.contains("src/gen/")),
708            "src/gen is ordinary source; got {files:?}"
709        );
710    }
711
712    #[test]
713    fn genuine_build_output_is_still_excluded_by_default() {
714        let files = scan(ScanConfig {
715            include_patterns: Vec::new(),
716            exclude_patterns: Vec::new(),
717            respect_gitignore: false,
718        });
719        assert!(
720            !files.iter().any(|f| f.contains("node_modules")),
721            "got {files:?}"
722        );
723    }
724
725    #[test]
726    fn an_explicit_include_overrides_a_default_exclusion() {
727        // Default excludes used to be checked before include patterns, so a
728        // directory the defaults named could not be indexed at all —
729        // `--include 'src/node_modules/**'` matched nothing and reported no
730        // reason why.
731        let files = scan(ScanConfig {
732            include_patterns: vec!["src/node_modules/**".to_string()],
733            exclude_patterns: Vec::new(),
734            respect_gitignore: false,
735        });
736
737        assert!(
738            files.iter().any(|f| f.contains("src/node_modules/")),
739            "naming a path explicitly is an unambiguous request for it; got {files:?}"
740        );
741    }
742
743    #[test]
744    fn an_explicit_exclude_still_wins_over_an_include() {
745        let files = scan(ScanConfig {
746            include_patterns: vec!["src/**".to_string()],
747            exclude_patterns: vec!["src/ok/**".to_string()],
748            respect_gitignore: false,
749        });
750        assert!(!files.iter().any(|f| f.contains("src/ok/")), "got {files:?}");
751        assert!(files.iter().any(|f| f.contains("src/env/")), "got {files:?}");
752    }
753
754    #[test]
755    fn skipped_directories_are_reported_so_a_short_file_count_is_explainable() {
756        let root = fixture();
757        let report = walk_source_files_reporting(
758            &root,
759            &ScanConfig {
760                include_patterns: Vec::new(),
761                exclude_patterns: Vec::new(),
762                respect_gitignore: false,
763            },
764            |p| p.extension().and_then(|e| e.to_str()) == Some("ts"),
765        )
766        .expect("scan succeeds");
767
768        assert!(
769            report.skipped_dirs.contains("node_modules"),
770            "a pruned directory must be reported, got {:?}",
771            report.skipped_dirs
772        );
773    }
774}