1use std::path::{Path, PathBuf};
2
3use fallow_config::{FrameworkDetection, PackageJson, ResolvedConfig};
4use ignore::WalkBuilder;
5
6#[derive(Debug, Clone)]
8pub struct DiscoveredFile {
9 pub id: FileId,
11 pub path: PathBuf,
13 pub size_bytes: u64,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub struct FileId(pub u32);
20
21#[derive(Debug, Clone)]
23pub struct EntryPoint {
24 pub path: PathBuf,
25 pub source: EntryPointSource,
26}
27
28#[derive(Debug, Clone)]
30pub enum EntryPointSource {
31 PackageJsonMain,
32 PackageJsonModule,
33 PackageJsonExports,
34 PackageJsonBin,
35 PackageJsonScript,
36 FrameworkRule { name: String },
37 TestFile,
38 DefaultIndex,
39 ManualEntry,
40}
41
42const SOURCE_EXTENSIONS: &[&str] = &[
43 "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "vue", "svelte",
44];
45
46pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
48 let _span = tracing::info_span!("discover_files").entered();
49
50 let mut types_builder = ignore::types::TypesBuilder::new();
51 for ext in SOURCE_EXTENSIONS {
52 types_builder
53 .add("source", &format!("*.{ext}"))
54 .expect("valid glob");
55 }
56 types_builder.select("source");
57 let types = types_builder.build().expect("valid types");
58
59 let walker = WalkBuilder::new(&config.root)
60 .hidden(true)
61 .git_ignore(true)
62 .git_global(true)
63 .git_exclude(true)
64 .types(types)
65 .threads(config.threads)
66 .build();
67
68 let mut files: Vec<DiscoveredFile> = walker
69 .filter_map(|entry| entry.ok())
70 .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
71 .filter(|entry| !config.ignore_patterns.is_match(entry.path()))
72 .enumerate()
73 .map(|(idx, entry)| {
74 let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
75 DiscoveredFile {
76 id: FileId(idx as u32),
77 path: entry.into_path(),
78 size_bytes,
79 }
80 })
81 .collect();
82
83 files.sort_unstable_by(|a, b| {
85 b.size_bytes
86 .cmp(&a.size_bytes)
87 .then_with(|| a.path.cmp(&b.path))
88 });
89
90 for (idx, file) in files.iter_mut().enumerate() {
92 file.id = FileId(idx as u32);
93 }
94
95 files
96}
97
98fn resolve_entry_path(
103 base: &Path,
104 entry: &str,
105 canonical_root: &Path,
106 source: EntryPointSource,
107) -> Option<EntryPoint> {
108 let resolved = base.join(entry);
109 let canonical_resolved = resolved.canonicalize().unwrap_or(resolved.clone());
111 if !canonical_resolved.starts_with(canonical_root) {
112 tracing::warn!(path = %entry, "Skipping entry point outside project root");
113 return None;
114 }
115 if resolved.exists() {
116 return Some(EntryPoint {
117 path: resolved,
118 source,
119 });
120 }
121 for ext in SOURCE_EXTENSIONS {
123 let with_ext = resolved.with_extension(ext);
124 if with_ext.exists() {
125 return Some(EntryPoint {
126 path: with_ext,
127 source,
128 });
129 }
130 }
131 None
132}
133
134fn compile_rule_matchers(
136 rule: &fallow_config::FrameworkRule,
137) -> (Vec<globset::GlobMatcher>, Vec<globset::GlobMatcher>) {
138 let entry_matchers: Vec<globset::GlobMatcher> = rule
139 .entry_points
140 .iter()
141 .filter_map(|ep| {
142 globset::Glob::new(&ep.pattern)
143 .ok()
144 .map(|g| g.compile_matcher())
145 })
146 .collect();
147
148 let always_matchers: Vec<globset::GlobMatcher> = rule
149 .always_used
150 .iter()
151 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
152 .collect();
153
154 (entry_matchers, always_matchers)
155}
156
157const DEFAULT_INDEX_PATTERNS: &[&str] = &[
159 "src/index.{ts,tsx,js,jsx}",
160 "src/main.{ts,tsx,js,jsx}",
161 "index.{ts,tsx,js,jsx}",
162 "main.{ts,tsx,js,jsx}",
163];
164
165fn apply_default_fallback(
170 files: &[DiscoveredFile],
171 root: &Path,
172 ws_filter: Option<&Path>,
173) -> Vec<EntryPoint> {
174 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
175 .iter()
176 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
177 .collect();
178
179 let mut entries = Vec::new();
180 for file in files {
181 if let Some(canonical_ws) = ws_filter {
182 let canonical_file = file.path.canonicalize().unwrap_or(file.path.clone());
183 if !canonical_file.starts_with(canonical_ws) {
184 continue;
185 }
186 }
187 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
188 let relative_str = relative.to_string_lossy();
189 if default_matchers
190 .iter()
191 .any(|m| m.is_match(relative_str.as_ref()))
192 {
193 entries.push(EntryPoint {
194 path: file.path.clone(),
195 source: EntryPointSource::DefaultIndex,
196 });
197 }
198 }
199 entries
200}
201
202pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
204 let _span = tracing::info_span!("discover_entry_points").entered();
205 let mut entries = Vec::new();
206
207 let relative_paths: Vec<String> = files
209 .iter()
210 .map(|f| {
211 f.path
212 .strip_prefix(&config.root)
213 .unwrap_or(&f.path)
214 .to_string_lossy()
215 .into_owned()
216 })
217 .collect();
218
219 for pattern in &config.entry_patterns {
221 if let Ok(glob) = globset::Glob::new(pattern) {
222 let matcher = glob.compile_matcher();
223 for (idx, rel) in relative_paths.iter().enumerate() {
224 if matcher.is_match(rel) {
225 entries.push(EntryPoint {
226 path: files[idx].path.clone(),
227 source: EntryPointSource::ManualEntry,
228 });
229 }
230 }
231 }
232 }
233
234 let pkg_path = config.root.join("package.json");
236 if let Ok(pkg) = PackageJson::load(&pkg_path) {
237 let canonical_root = config.root.canonicalize().unwrap_or(config.root.clone());
238 for entry_path in pkg.entry_points() {
239 if let Some(ep) = resolve_entry_path(
240 &config.root,
241 &entry_path,
242 &canonical_root,
243 EntryPointSource::PackageJsonMain,
244 ) {
245 entries.push(ep);
246 }
247 }
248
249 if let Some(scripts) = &pkg.scripts {
251 for script_value in scripts.values() {
252 for file_ref in extract_script_file_refs(script_value) {
253 if let Some(ep) = resolve_entry_path(
254 &config.root,
255 &file_ref,
256 &canonical_root,
257 EntryPointSource::PackageJsonScript,
258 ) {
259 entries.push(ep);
260 }
261 }
262 }
263 }
264
265 let active_rules: Vec<&fallow_config::FrameworkRule> = config
267 .framework_rules
268 .iter()
269 .filter(|rule| is_framework_active(rule, &pkg, &config.root))
270 .collect();
271
272 for rule in &active_rules {
273 let (entry_matchers, always_matchers) = compile_rule_matchers(rule);
274
275 for (idx, rel) in relative_paths.iter().enumerate() {
277 let matched = entry_matchers.iter().any(|m| m.is_match(rel))
278 || always_matchers.iter().any(|m| m.is_match(rel));
279 if matched {
280 entries.push(EntryPoint {
281 path: files[idx].path.clone(),
282 source: EntryPointSource::FrameworkRule {
283 name: rule.name.clone(),
284 },
285 });
286 }
287 }
288 }
289 }
290
291 discover_nested_package_entries(&config.root, files, &mut entries);
295
296 if entries.is_empty() {
298 entries = apply_default_fallback(files, &config.root, None);
299 }
300
301 entries.sort_by(|a, b| a.path.cmp(&b.path));
303 entries.dedup_by(|a, b| a.path == b.path);
304
305 entries
306}
307
308fn discover_nested_package_entries(
314 root: &Path,
315 _files: &[DiscoveredFile],
316 entries: &mut Vec<EntryPoint>,
317) {
318 let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
319
320 let search_dirs = ["packages", "apps", "libs", "modules", "plugins"];
322 for dir_name in &search_dirs {
323 let search_dir = root.join(dir_name);
324 if !search_dir.is_dir() {
325 continue;
326 }
327 let read_dir = match std::fs::read_dir(&search_dir) {
328 Ok(rd) => rd,
329 Err(_) => continue,
330 };
331 for entry in read_dir.flatten() {
332 let pkg_path = entry.path().join("package.json");
333 if !pkg_path.exists() {
334 continue;
335 }
336 let Ok(pkg) = PackageJson::load(&pkg_path) else {
337 continue;
338 };
339 let pkg_dir = entry.path();
340 for entry_path in pkg.entry_points() {
341 if let Some(ep) = resolve_entry_path(
342 &pkg_dir,
343 &entry_path,
344 &canonical_root,
345 EntryPointSource::PackageJsonExports,
346 ) {
347 entries.push(ep);
348 }
349 }
350 if let Some(scripts) = &pkg.scripts {
352 for script_value in scripts.values() {
353 for file_ref in extract_script_file_refs(script_value) {
354 if let Some(ep) = resolve_entry_path(
355 &pkg_dir,
356 &file_ref,
357 &canonical_root,
358 EntryPointSource::PackageJsonScript,
359 ) {
360 entries.push(ep);
361 }
362 }
363 }
364 }
365 }
366 }
367}
368
369fn is_framework_active(
371 rule: &fallow_config::FrameworkRule,
372 pkg: &PackageJson,
373 root: &Path,
374) -> bool {
375 match &rule.detection {
376 None => true, Some(detection) => check_detection(detection, pkg, root),
378 }
379}
380
381fn check_detection(detection: &FrameworkDetection, pkg: &PackageJson, root: &Path) -> bool {
382 match detection {
383 FrameworkDetection::Dependency { package } => {
384 pkg.all_dependency_names().iter().any(|d| d == package)
385 }
386 FrameworkDetection::FileExists { pattern } => file_exists_glob(pattern, root),
387 FrameworkDetection::All { conditions } => {
388 conditions.iter().all(|c| check_detection(c, pkg, root))
389 }
390 FrameworkDetection::Any { conditions } => {
391 conditions.iter().any(|c| check_detection(c, pkg, root))
392 }
393 }
394}
395
396pub fn discover_workspace_entry_points(
398 ws_root: &Path,
399 config: &ResolvedConfig,
400 all_files: &[DiscoveredFile],
401) -> Vec<EntryPoint> {
402 let mut entries = Vec::new();
403
404 let root_pkg = PackageJson::load(&config.root.join("package.json")).ok();
406
407 let pkg_path = ws_root.join("package.json");
408 if let Ok(pkg) = PackageJson::load(&pkg_path) {
409 let canonical_ws_root = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
410 for entry_path in pkg.entry_points() {
411 if let Some(ep) = resolve_entry_path(
412 ws_root,
413 &entry_path,
414 &canonical_ws_root,
415 EntryPointSource::PackageJsonMain,
416 ) {
417 entries.push(ep);
418 }
419 }
420
421 if let Some(scripts) = &pkg.scripts {
423 for script_value in scripts.values() {
424 for file_ref in extract_script_file_refs(script_value) {
425 if let Some(ep) = resolve_entry_path(
426 ws_root,
427 &file_ref,
428 &canonical_ws_root,
429 EntryPointSource::PackageJsonScript,
430 ) {
431 entries.push(ep);
432 }
433 }
434 }
435 }
436
437 let canonical_ws = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
440 for rule in &config.framework_rules {
441 let ws_active = is_framework_active(rule, &pkg, ws_root);
442 let root_active = root_pkg
443 .as_ref()
444 .map(|rpkg| is_framework_active(rule, rpkg, &config.root))
445 .unwrap_or(false);
446
447 if !ws_active && !root_active {
448 continue;
449 }
450
451 let (entry_matchers, always_matchers) = compile_rule_matchers(rule);
452
453 for file in all_files {
455 let canonical_file = file.path.canonicalize().unwrap_or(file.path.clone());
456 if !canonical_file.starts_with(&canonical_ws) {
457 continue;
458 }
459 let relative = file.path.strip_prefix(ws_root).unwrap_or(&file.path);
460 let relative_str = relative.to_string_lossy();
461 let matched = entry_matchers
462 .iter()
463 .any(|m| m.is_match(relative_str.as_ref()))
464 || always_matchers
465 .iter()
466 .any(|m| m.is_match(relative_str.as_ref()));
467 if matched {
468 entries.push(EntryPoint {
469 path: file.path.clone(),
470 source: EntryPointSource::FrameworkRule {
471 name: rule.name.clone(),
472 },
473 });
474 }
475 }
476 }
477 }
478
479 if entries.is_empty() {
481 let canonical_ws = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
482 entries = apply_default_fallback(all_files, ws_root, Some(&canonical_ws));
483 }
484
485 entries.sort_by(|a, b| a.path.cmp(&b.path));
486 entries.dedup_by(|a, b| a.path == b.path);
487 entries
488}
489
490fn extract_script_file_refs(script: &str) -> Vec<String> {
501 let mut refs = Vec::new();
502
503 const RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node"];
505
506 for segment in script.split(&['&', '|', ';'][..]) {
508 let segment = segment.trim();
509 if segment.is_empty() {
510 continue;
511 }
512
513 let tokens: Vec<&str> = segment.split_whitespace().collect();
514 if tokens.is_empty() {
515 continue;
516 }
517
518 let mut start = 0;
520 if matches!(tokens.first(), Some(&"npx" | &"pnpx")) {
521 start = 1;
522 } else if tokens.len() >= 2 && matches!(tokens[0], "yarn" | "pnpm") && tokens[1] == "exec" {
523 start = 2;
524 }
525
526 if start >= tokens.len() {
527 continue;
528 }
529
530 let cmd = tokens[start];
531
532 if RUNNERS.contains(&cmd) {
534 for &token in &tokens[start + 1..] {
537 if token.starts_with('-') {
538 continue;
539 }
540 if looks_like_file_path(token) {
542 refs.push(token.to_string());
543 }
544 }
545 } else {
546 for &token in &tokens[start..] {
548 if token.starts_with('-') {
549 continue;
550 }
551 if looks_like_script_file(token) {
552 refs.push(token.to_string());
553 }
554 }
555 }
556 }
557
558 refs
559}
560
561fn looks_like_file_path(token: &str) -> bool {
564 let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
565 if extensions.iter().any(|ext| token.ends_with(ext)) {
566 return true;
567 }
568 token.starts_with("./")
571 || token.starts_with("../")
572 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
573}
574
575fn looks_like_script_file(token: &str) -> bool {
578 let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
579 if !extensions.iter().any(|ext| token.ends_with(ext)) {
580 return false;
581 }
582 token.contains('/') || token.starts_with("./") || token.starts_with("../")
585}
586
587fn file_exists_glob(pattern: &str, root: &Path) -> bool {
592 let matcher = match globset::Glob::new(pattern) {
593 Ok(g) => g.compile_matcher(),
594 Err(_) => return false,
595 };
596
597 let prefix: PathBuf = Path::new(pattern)
600 .components()
601 .take_while(|c| {
602 let s = c.as_os_str().to_string_lossy();
603 !s.contains('*') && !s.contains('?') && !s.contains('{') && !s.contains('[')
604 })
605 .collect();
606
607 let search_dir = if prefix.as_os_str().is_empty() {
608 root.to_path_buf()
609 } else {
610 let joined = root.join(&prefix);
612 if joined.is_dir() {
613 joined
614 } else if let Some(parent) = joined.parent() {
615 if parent != root && parent.is_dir() {
617 parent.to_path_buf()
618 } else {
619 return false;
621 }
622 } else {
623 return false;
624 }
625 };
626
627 if !search_dir.is_dir() {
628 return false;
629 }
630
631 walk_dir_recursive(&search_dir, root, &matcher)
632}
633
634const MAX_WALK_DEPTH: usize = 20;
636
637fn walk_dir_recursive(dir: &Path, root: &Path, matcher: &globset::GlobMatcher) -> bool {
639 walk_dir_recursive_depth(dir, root, matcher, 0)
640}
641
642fn walk_dir_recursive_depth(
644 dir: &Path,
645 root: &Path,
646 matcher: &globset::GlobMatcher,
647 depth: usize,
648) -> bool {
649 if depth >= MAX_WALK_DEPTH {
650 tracing::warn!(
651 dir = %dir.display(),
652 "Maximum directory walk depth reached, possible symlink cycle"
653 );
654 return false;
655 }
656
657 let entries = match std::fs::read_dir(dir) {
658 Ok(rd) => rd,
659 Err(_) => return false,
660 };
661
662 for entry in entries.flatten() {
663 let is_real_dir = entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
665 if is_real_dir {
666 if walk_dir_recursive_depth(&entry.path(), root, matcher, depth + 1) {
667 return true;
668 }
669 } else {
670 let path = entry.path();
671 let relative = path.strip_prefix(root).unwrap_or(&path);
672 if matcher.is_match(relative) {
673 return true;
674 }
675 }
676 }
677
678 false
679}
680
681pub fn discover_plugin_entry_points(
686 plugin_result: &crate::plugins::AggregatedPluginResult,
687 config: &ResolvedConfig,
688 files: &[DiscoveredFile],
689) -> Vec<EntryPoint> {
690 let mut entries = Vec::new();
691
692 let relative_paths: Vec<String> = files
694 .iter()
695 .map(|f| {
696 f.path
697 .strip_prefix(&config.root)
698 .unwrap_or(&f.path)
699 .to_string_lossy()
700 .into_owned()
701 })
702 .collect();
703
704 let all_patterns: Vec<&str> = plugin_result
706 .entry_patterns
707 .iter()
708 .chain(plugin_result.discovered_always_used.iter())
709 .chain(plugin_result.always_used.iter())
710 .map(|s| s.as_str())
711 .collect();
712
713 let matchers: Vec<globset::GlobMatcher> = all_patterns
714 .iter()
715 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
716 .collect();
717
718 for (idx, rel) in relative_paths.iter().enumerate() {
719 if matchers.iter().any(|m| m.is_match(rel)) {
720 entries.push(EntryPoint {
721 path: files[idx].path.clone(),
722 source: EntryPointSource::FrameworkRule {
723 name: "plugin".to_string(),
724 },
725 });
726 }
727 }
728
729 for setup_file in &plugin_result.setup_files {
731 let resolved = if setup_file.is_absolute() {
732 setup_file.clone()
733 } else {
734 config.root.join(setup_file)
735 };
736 if resolved.exists() {
737 entries.push(EntryPoint {
738 path: resolved,
739 source: EntryPointSource::FrameworkRule {
740 name: "plugin-setup".to_string(),
741 },
742 });
743 } else {
744 for ext in SOURCE_EXTENSIONS {
746 let with_ext = resolved.with_extension(ext);
747 if with_ext.exists() {
748 entries.push(EntryPoint {
749 path: with_ext,
750 source: EntryPointSource::FrameworkRule {
751 name: "plugin-setup".to_string(),
752 },
753 });
754 break;
755 }
756 }
757 }
758 }
759
760 entries.sort_by(|a, b| a.path.cmp(&b.path));
762 entries.dedup_by(|a, b| a.path == b.path);
763 entries
764}
765
766pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
768 if patterns.is_empty() {
769 return None;
770 }
771 let mut builder = globset::GlobSetBuilder::new();
772 for pattern in patterns {
773 if let Ok(glob) = globset::Glob::new(pattern) {
774 builder.add(glob);
775 }
776 }
777 builder.build().ok()
778}
779
780#[cfg(test)]
781mod tests {
782 use super::*;
783
784 #[test]
786 fn script_node_runner() {
787 let refs = extract_script_file_refs("node utilities/generate-coverage-badge.js");
788 assert_eq!(refs, vec!["utilities/generate-coverage-badge.js"]);
789 }
790
791 #[test]
792 fn script_ts_node_runner() {
793 let refs = extract_script_file_refs("ts-node scripts/seed.ts");
794 assert_eq!(refs, vec!["scripts/seed.ts"]);
795 }
796
797 #[test]
798 fn script_tsx_runner() {
799 let refs = extract_script_file_refs("tsx scripts/migrate.ts");
800 assert_eq!(refs, vec!["scripts/migrate.ts"]);
801 }
802
803 #[test]
804 fn script_npx_prefix() {
805 let refs = extract_script_file_refs("npx ts-node scripts/generate.ts");
806 assert_eq!(refs, vec!["scripts/generate.ts"]);
807 }
808
809 #[test]
810 fn script_chained_commands() {
811 let refs = extract_script_file_refs("node scripts/build.js && node scripts/post-build.js");
812 assert_eq!(refs, vec!["scripts/build.js", "scripts/post-build.js"]);
813 }
814
815 #[test]
816 fn script_with_flags() {
817 let refs = extract_script_file_refs(
818 "node --experimental-specifier-resolution=node scripts/run.mjs",
819 );
820 assert_eq!(refs, vec!["scripts/run.mjs"]);
821 }
822
823 #[test]
824 fn script_no_file_ref() {
825 let refs = extract_script_file_refs("next build");
826 assert!(refs.is_empty());
827 }
828
829 #[test]
830 fn script_bare_file_path() {
831 let refs = extract_script_file_refs("echo done && node ./scripts/check.js");
832 assert_eq!(refs, vec!["./scripts/check.js"]);
833 }
834
835 #[test]
836 fn script_semicolon_separator() {
837 let refs = extract_script_file_refs("node scripts/a.js; node scripts/b.ts");
838 assert_eq!(refs, vec!["scripts/a.js", "scripts/b.ts"]);
839 }
840
841 #[test]
843 fn file_path_with_extension() {
844 assert!(looks_like_file_path("scripts/build.js"));
845 assert!(looks_like_file_path("scripts/build.ts"));
846 assert!(looks_like_file_path("scripts/build.mjs"));
847 }
848
849 #[test]
850 fn file_path_with_slash() {
851 assert!(looks_like_file_path("scripts/build"));
852 }
853
854 #[test]
855 fn not_file_path() {
856 assert!(!looks_like_file_path("--watch"));
857 assert!(!looks_like_file_path("build"));
858 }
859
860 #[test]
862 fn script_file_with_path() {
863 assert!(looks_like_script_file("scripts/build.js"));
864 assert!(looks_like_script_file("./scripts/build.ts"));
865 assert!(looks_like_script_file("../scripts/build.mjs"));
866 }
867
868 #[test]
869 fn not_script_file_bare_name() {
870 assert!(!looks_like_script_file("webpack.js"));
872 assert!(!looks_like_script_file("build"));
873 }
874}