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
334pub 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 path_str.contains(".pb.") || path_str.contains("_pb2.") || path_str.contains(".g.")
361}
362
363#[derive(Debug, Default, Clone)]
369pub struct ScanReport {
370 pub files: Vec<PathBuf>,
371 pub skipped_dirs: std::collections::BTreeSet<String>,
373}
374
375pub 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 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
444const DEFAULT_EXCLUDE_DIRS: &[&str] = &[
456 "node_modules",
458 "dist",
459 "build",
460 "out",
461 "target",
462 "coverage",
463 ".next",
465 ".nuxt",
466 ".output",
467 ".cache",
468 ".turbo",
469 ".parcel-cache",
470 "__pycache__",
472 ".venv",
473 "venv",
474 ".eggs",
475 ".mypy_cache",
476 ".pytest_cache",
477 ".tox",
478 "site-packages",
479 "CMakeFiles",
481 "_deps",
482 ".cmake",
483 ".graphyn",
485];
486
487const ALWAYS_EXCLUDE_DIRS: &[&str] = &[".git", ".hg", ".svn"];
489
490const DEFAULT_EXCLUDE_SUFFIXES: &[&str] = &[
491 ".d.ts", ".d.mts", ".d.cts", ".min.js", ".min.mjs", ".min.css", ".map", ".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 for segment in rel.split('/') {
525 if ALWAYS_EXCLUDE_DIRS.contains(&segment) {
526 return false;
527 }
528 }
529
530 if !config.exclude_patterns.is_empty() && path_matches_any(&rel, &config.exclude_patterns) {
532 return false;
533 }
534
535 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 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 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 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 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}