1use std::ffi::OsStr;
2use std::path::{Path, PathBuf};
3
4use fallow_config::{PackageJson, ResolvedConfig};
5use ignore::WalkBuilder;
6
7pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
9
10pub const SOURCE_EXTENSIONS: &[&str] = &[
11 "ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "vue", "svelte", "astro", "mdx", "css",
12 "scss",
13];
14
15const ALLOWED_HIDDEN_DIRS: &[&str] = &[".storybook", ".well-known", ".changeset", ".github"];
24
25fn is_allowed_hidden_dir(name: &OsStr) -> bool {
27 ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
28}
29
30fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
36 let name = entry.file_name();
37 let name_str = name.to_string_lossy();
38
39 if !name_str.starts_with('.') {
41 return true;
42 }
43
44 if entry.file_type().is_some_and(|ft| ft.is_file()) {
46 return true;
47 }
48
49 is_allowed_hidden_dir(name)
51}
52
53const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
55 "**/*.test.*",
57 "**/*.spec.*",
58 "**/*.e2e.*",
59 "**/*.e2e-spec.*",
60 "**/*.bench.*",
61 "**/*.fixture.*",
62 "**/*.stories.*",
64 "**/*.story.*",
65 "**/__tests__/**",
67 "**/__mocks__/**",
68 "**/__snapshots__/**",
69 "**/__fixtures__/**",
70 "**/test/**",
71 "**/tests/**",
72 "**/*.config.*",
74 "**/.*.js",
75 "**/.*.ts",
76 "**/.*.mjs",
77 "**/.*.cjs",
78];
79
80pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
82 let _span = tracing::info_span!("discover_files").entered();
83
84 let mut types_builder = ignore::types::TypesBuilder::new();
85 for ext in SOURCE_EXTENSIONS {
86 types_builder
87 .add("source", &format!("*.{ext}"))
88 .expect("valid glob");
89 }
90 types_builder.select("source");
91 let types = types_builder.build().expect("valid types");
92
93 let mut walk_builder = WalkBuilder::new(&config.root);
94 walk_builder
95 .hidden(false)
96 .git_ignore(true)
97 .git_global(true)
98 .git_exclude(true)
99 .types(types)
100 .threads(config.threads)
101 .filter_entry(is_allowed_hidden);
102 let walker = walk_builder.build();
103
104 let production_excludes = if config.production {
106 let mut builder = globset::GlobSetBuilder::new();
107 for pattern in PRODUCTION_EXCLUDE_PATTERNS {
108 if let Ok(glob) = globset::Glob::new(pattern) {
109 builder.add(glob);
110 }
111 }
112 builder.build().ok()
113 } else {
114 None
115 };
116
117 let mut files: Vec<DiscoveredFile> = walker
118 .filter_map(|entry| entry.ok())
119 .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_file()))
120 .filter(|entry| !config.ignore_patterns.is_match(entry.path()))
121 .filter(|entry| {
122 production_excludes.as_ref().is_none_or(|excludes| {
124 let relative = entry
125 .path()
126 .strip_prefix(&config.root)
127 .unwrap_or(entry.path());
128 !excludes.is_match(relative)
129 })
130 })
131 .enumerate()
132 .map(|(idx, entry)| {
133 let size_bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
134 DiscoveredFile {
135 id: FileId(idx as u32),
136 path: entry.into_path(),
137 size_bytes,
138 }
139 })
140 .collect();
141
142 files.sort_unstable_by(|a, b| a.path.cmp(&b.path));
147
148 for (idx, file) in files.iter_mut().enumerate() {
150 file.id = FileId(idx as u32);
151 }
152
153 files
154}
155
156const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
160
161fn resolve_entry_path(
168 base: &Path,
169 entry: &str,
170 canonical_root: &Path,
171 source: EntryPointSource,
172) -> Option<EntryPoint> {
173 let resolved = base.join(entry);
174 let canonical_resolved = resolved.canonicalize().unwrap_or(resolved.clone());
176 if !canonical_resolved.starts_with(canonical_root) {
177 tracing::warn!(path = %entry, "Skipping entry point outside project root");
178 return None;
179 }
180
181 if let Some(source_path) = try_output_to_source_path(base, entry) {
186 if let Ok(canonical_source) = source_path.canonicalize()
188 && canonical_source.starts_with(canonical_root)
189 {
190 return Some(EntryPoint {
191 path: source_path,
192 source,
193 });
194 }
195 }
196
197 if resolved.exists() {
198 return Some(EntryPoint {
199 path: resolved,
200 source,
201 });
202 }
203 for ext in SOURCE_EXTENSIONS {
205 let with_ext = resolved.with_extension(ext);
206 if with_ext.exists() {
207 return Some(EntryPoint {
208 path: with_ext,
209 source,
210 });
211 }
212 }
213 None
214}
215
216fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
228 let entry_path = Path::new(entry);
229 let components: Vec<_> = entry_path.components().collect();
230
231 let output_pos = components.iter().rposition(|c| {
233 if let std::path::Component::Normal(s) = c
234 && let Some(name) = s.to_str()
235 {
236 return OUTPUT_DIRS.contains(&name);
237 }
238 false
239 })?;
240
241 let prefix: PathBuf = components[..output_pos]
243 .iter()
244 .filter(|c| !matches!(c, std::path::Component::CurDir))
245 .collect();
246
247 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
249
250 for ext in SOURCE_EXTENSIONS {
252 let source_candidate = base
253 .join(&prefix)
254 .join("src")
255 .join(suffix.with_extension(ext));
256 if source_candidate.exists() {
257 return Some(source_candidate);
258 }
259 }
260
261 None
262}
263
264const DEFAULT_INDEX_PATTERNS: &[&str] = &[
266 "src/index.{ts,tsx,js,jsx}",
267 "src/main.{ts,tsx,js,jsx}",
268 "index.{ts,tsx,js,jsx}",
269 "main.{ts,tsx,js,jsx}",
270];
271
272fn apply_default_fallback(
277 files: &[DiscoveredFile],
278 root: &Path,
279 ws_filter: Option<&Path>,
280) -> Vec<EntryPoint> {
281 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
282 .iter()
283 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
284 .collect();
285
286 let mut entries = Vec::new();
287 for file in files {
288 if let Some(ws_root) = ws_filter
290 && file.path.strip_prefix(ws_root).is_err()
291 {
292 continue;
293 }
294 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
295 let relative_str = relative.to_string_lossy();
296 if default_matchers
297 .iter()
298 .any(|m| m.is_match(relative_str.as_ref()))
299 {
300 entries.push(EntryPoint {
301 path: file.path.clone(),
302 source: EntryPointSource::DefaultIndex,
303 });
304 }
305 }
306 entries
307}
308
309pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
311 let _span = tracing::info_span!("discover_entry_points").entered();
312 let mut entries = Vec::new();
313
314 let relative_paths: Vec<String> = files
316 .iter()
317 .map(|f| {
318 f.path
319 .strip_prefix(&config.root)
320 .unwrap_or(&f.path)
321 .to_string_lossy()
322 .into_owned()
323 })
324 .collect();
325
326 {
329 let mut builder = globset::GlobSetBuilder::new();
330 for pattern in &config.entry_patterns {
331 if let Ok(glob) = globset::Glob::new(pattern) {
332 builder.add(glob);
333 }
334 }
335 if let Ok(glob_set) = builder.build()
336 && !glob_set.is_empty()
337 {
338 for (idx, rel) in relative_paths.iter().enumerate() {
339 if glob_set.is_match(rel) {
340 entries.push(EntryPoint {
341 path: files[idx].path.clone(),
342 source: EntryPointSource::ManualEntry,
343 });
344 }
345 }
346 }
347 }
348
349 let canonical_root = config.root.canonicalize().unwrap_or(config.root.clone());
352 let pkg_path = config.root.join("package.json");
353 if let Ok(pkg) = PackageJson::load(&pkg_path) {
354 for entry_path in pkg.entry_points() {
355 if let Some(ep) = resolve_entry_path(
356 &config.root,
357 &entry_path,
358 &canonical_root,
359 EntryPointSource::PackageJsonMain,
360 ) {
361 entries.push(ep);
362 }
363 }
364
365 if let Some(scripts) = &pkg.scripts {
367 for script_value in scripts.values() {
368 for file_ref in extract_script_file_refs(script_value) {
369 if let Some(ep) = resolve_entry_path(
370 &config.root,
371 &file_ref,
372 &canonical_root,
373 EntryPointSource::PackageJsonScript,
374 ) {
375 entries.push(ep);
376 }
377 }
378 }
379 }
380
381 }
383
384 discover_nested_package_entries(&config.root, files, &mut entries, &canonical_root);
388
389 if entries.is_empty() {
391 entries = apply_default_fallback(files, &config.root, None);
392 }
393
394 entries.sort_by(|a, b| a.path.cmp(&b.path));
396 entries.dedup_by(|a, b| a.path == b.path);
397
398 entries
399}
400
401fn discover_nested_package_entries(
407 root: &Path,
408 _files: &[DiscoveredFile],
409 entries: &mut Vec<EntryPoint>,
410 canonical_root: &Path,
411) {
412 let search_dirs = ["packages", "apps", "libs", "modules", "plugins"];
414 for dir_name in &search_dirs {
415 let search_dir = root.join(dir_name);
416 if !search_dir.is_dir() {
417 continue;
418 }
419 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
420 continue;
421 };
422 for entry in read_dir.flatten() {
423 let pkg_path = entry.path().join("package.json");
424 if !pkg_path.exists() {
425 continue;
426 }
427 let Ok(pkg) = PackageJson::load(&pkg_path) else {
428 continue;
429 };
430 let pkg_dir = entry.path();
431 for entry_path in pkg.entry_points() {
432 if let Some(ep) = resolve_entry_path(
433 &pkg_dir,
434 &entry_path,
435 canonical_root,
436 EntryPointSource::PackageJsonExports,
437 ) {
438 entries.push(ep);
439 }
440 }
441 if let Some(scripts) = &pkg.scripts {
443 for script_value in scripts.values() {
444 for file_ref in extract_script_file_refs(script_value) {
445 if let Some(ep) = resolve_entry_path(
446 &pkg_dir,
447 &file_ref,
448 canonical_root,
449 EntryPointSource::PackageJsonScript,
450 ) {
451 entries.push(ep);
452 }
453 }
454 }
455 }
456 }
457 }
458}
459
460pub fn discover_workspace_entry_points(
462 ws_root: &Path,
463 _config: &ResolvedConfig,
464 all_files: &[DiscoveredFile],
465) -> Vec<EntryPoint> {
466 let mut entries = Vec::new();
467
468 let pkg_path = ws_root.join("package.json");
469 if let Ok(pkg) = PackageJson::load(&pkg_path) {
470 let canonical_ws_root = ws_root.canonicalize().unwrap_or(ws_root.to_path_buf());
471 for entry_path in pkg.entry_points() {
472 if let Some(ep) = resolve_entry_path(
473 ws_root,
474 &entry_path,
475 &canonical_ws_root,
476 EntryPointSource::PackageJsonMain,
477 ) {
478 entries.push(ep);
479 }
480 }
481
482 if let Some(scripts) = &pkg.scripts {
484 for script_value in scripts.values() {
485 for file_ref in extract_script_file_refs(script_value) {
486 if let Some(ep) = resolve_entry_path(
487 ws_root,
488 &file_ref,
489 &canonical_ws_root,
490 EntryPointSource::PackageJsonScript,
491 ) {
492 entries.push(ep);
493 }
494 }
495 }
496 }
497
498 }
500
501 if entries.is_empty() {
503 entries = apply_default_fallback(all_files, ws_root, None);
504 }
505
506 entries.sort_by(|a, b| a.path.cmp(&b.path));
507 entries.dedup_by(|a, b| a.path == b.path);
508 entries
509}
510
511fn extract_script_file_refs(script: &str) -> Vec<String> {
522 let mut refs = Vec::new();
523
524 const RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node"];
526
527 for segment in script.split(&['&', '|', ';'][..]) {
529 let segment = segment.trim();
530 if segment.is_empty() {
531 continue;
532 }
533
534 let tokens: Vec<&str> = segment.split_whitespace().collect();
535 if tokens.is_empty() {
536 continue;
537 }
538
539 let mut start = 0;
541 if matches!(tokens.first(), Some(&"npx" | &"pnpx")) {
542 start = 1;
543 } else if tokens.len() >= 2 && matches!(tokens[0], "yarn" | "pnpm") && tokens[1] == "exec" {
544 start = 2;
545 }
546
547 if start >= tokens.len() {
548 continue;
549 }
550
551 let cmd = tokens[start];
552
553 if RUNNERS.contains(&cmd) {
555 for &token in &tokens[start + 1..] {
558 if token.starts_with('-') {
559 continue;
560 }
561 if looks_like_file_path(token) {
563 refs.push(token.to_string());
564 }
565 }
566 } else {
567 for &token in &tokens[start..] {
569 if token.starts_with('-') {
570 continue;
571 }
572 if looks_like_script_file(token) {
573 refs.push(token.to_string());
574 }
575 }
576 }
577 }
578
579 refs
580}
581
582fn looks_like_file_path(token: &str) -> bool {
585 let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
586 if extensions.iter().any(|ext| token.ends_with(ext)) {
587 return true;
588 }
589 token.starts_with("./")
592 || token.starts_with("../")
593 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
594}
595
596fn looks_like_script_file(token: &str) -> bool {
599 let extensions = [".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx"];
600 if !extensions.iter().any(|ext| token.ends_with(ext)) {
601 return false;
602 }
603 token.contains('/') || token.starts_with("./") || token.starts_with("../")
606}
607
608pub fn discover_plugin_entry_points(
613 plugin_result: &crate::plugins::AggregatedPluginResult,
614 config: &ResolvedConfig,
615 files: &[DiscoveredFile],
616) -> Vec<EntryPoint> {
617 let mut entries = Vec::new();
618
619 let relative_paths: Vec<String> = files
621 .iter()
622 .map(|f| {
623 f.path
624 .strip_prefix(&config.root)
625 .unwrap_or(&f.path)
626 .to_string_lossy()
627 .into_owned()
628 })
629 .collect();
630
631 let mut builder = globset::GlobSetBuilder::new();
634 for pattern in plugin_result
635 .entry_patterns
636 .iter()
637 .chain(plugin_result.discovered_always_used.iter())
638 .chain(plugin_result.always_used.iter())
639 {
640 if let Ok(glob) = globset::Glob::new(pattern) {
641 builder.add(glob);
642 }
643 }
644 if let Ok(glob_set) = builder.build()
645 && !glob_set.is_empty()
646 {
647 for (idx, rel) in relative_paths.iter().enumerate() {
648 if glob_set.is_match(rel) {
649 entries.push(EntryPoint {
650 path: files[idx].path.clone(),
651 source: EntryPointSource::Plugin {
652 name: "plugin".to_string(),
653 },
654 });
655 }
656 }
657 }
658
659 for setup_file in &plugin_result.setup_files {
661 let resolved = if setup_file.is_absolute() {
662 setup_file.clone()
663 } else {
664 config.root.join(setup_file)
665 };
666 if resolved.exists() {
667 entries.push(EntryPoint {
668 path: resolved,
669 source: EntryPointSource::Plugin {
670 name: "plugin-setup".to_string(),
671 },
672 });
673 } else {
674 for ext in SOURCE_EXTENSIONS {
676 let with_ext = resolved.with_extension(ext);
677 if with_ext.exists() {
678 entries.push(EntryPoint {
679 path: with_ext,
680 source: EntryPointSource::Plugin {
681 name: "plugin-setup".to_string(),
682 },
683 });
684 break;
685 }
686 }
687 }
688 }
689
690 entries.sort_by(|a, b| a.path.cmp(&b.path));
692 entries.dedup_by(|a, b| a.path == b.path);
693 entries
694}
695
696pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
698 if patterns.is_empty() {
699 return None;
700 }
701 let mut builder = globset::GlobSetBuilder::new();
702 for pattern in patterns {
703 if let Ok(glob) = globset::Glob::new(pattern) {
704 builder.add(glob);
705 }
706 }
707 builder.build().ok()
708}
709
710#[cfg(test)]
711mod tests {
712 use super::*;
713
714 #[test]
716 fn script_node_runner() {
717 let refs = extract_script_file_refs("node utilities/generate-coverage-badge.js");
718 assert_eq!(refs, vec!["utilities/generate-coverage-badge.js"]);
719 }
720
721 #[test]
722 fn script_ts_node_runner() {
723 let refs = extract_script_file_refs("ts-node scripts/seed.ts");
724 assert_eq!(refs, vec!["scripts/seed.ts"]);
725 }
726
727 #[test]
728 fn script_tsx_runner() {
729 let refs = extract_script_file_refs("tsx scripts/migrate.ts");
730 assert_eq!(refs, vec!["scripts/migrate.ts"]);
731 }
732
733 #[test]
734 fn script_npx_prefix() {
735 let refs = extract_script_file_refs("npx ts-node scripts/generate.ts");
736 assert_eq!(refs, vec!["scripts/generate.ts"]);
737 }
738
739 #[test]
740 fn script_chained_commands() {
741 let refs = extract_script_file_refs("node scripts/build.js && node scripts/post-build.js");
742 assert_eq!(refs, vec!["scripts/build.js", "scripts/post-build.js"]);
743 }
744
745 #[test]
746 fn script_with_flags() {
747 let refs = extract_script_file_refs(
748 "node --experimental-specifier-resolution=node scripts/run.mjs",
749 );
750 assert_eq!(refs, vec!["scripts/run.mjs"]);
751 }
752
753 #[test]
754 fn script_no_file_ref() {
755 let refs = extract_script_file_refs("next build");
756 assert!(refs.is_empty());
757 }
758
759 #[test]
760 fn script_bare_file_path() {
761 let refs = extract_script_file_refs("echo done && node ./scripts/check.js");
762 assert_eq!(refs, vec!["./scripts/check.js"]);
763 }
764
765 #[test]
766 fn script_semicolon_separator() {
767 let refs = extract_script_file_refs("node scripts/a.js; node scripts/b.ts");
768 assert_eq!(refs, vec!["scripts/a.js", "scripts/b.ts"]);
769 }
770
771 #[test]
773 fn file_path_with_extension() {
774 assert!(looks_like_file_path("scripts/build.js"));
775 assert!(looks_like_file_path("scripts/build.ts"));
776 assert!(looks_like_file_path("scripts/build.mjs"));
777 }
778
779 #[test]
780 fn file_path_with_slash() {
781 assert!(looks_like_file_path("scripts/build"));
782 }
783
784 #[test]
785 fn not_file_path() {
786 assert!(!looks_like_file_path("--watch"));
787 assert!(!looks_like_file_path("build"));
788 }
789
790 #[test]
792 fn script_file_with_path() {
793 assert!(looks_like_script_file("scripts/build.js"));
794 assert!(looks_like_script_file("./scripts/build.ts"));
795 assert!(looks_like_script_file("../scripts/build.mjs"));
796 }
797
798 #[test]
799 fn not_script_file_bare_name() {
800 assert!(!looks_like_script_file("webpack.js"));
802 assert!(!looks_like_script_file("build"));
803 }
804
805 #[test]
807 fn allowed_hidden_dirs() {
808 assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
809 assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
810 assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
811 assert!(is_allowed_hidden_dir(OsStr::new(".github")));
812 }
813
814 #[test]
815 fn disallowed_hidden_dirs() {
816 assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
817 assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
818 assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
819 assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
820 assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
821 }
822
823 #[test]
824 fn non_hidden_dirs_not_in_allowlist() {
825 assert!(!is_allowed_hidden_dir(OsStr::new("src")));
828 assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
829 }
830
831 mod proptests {
832 use super::*;
833 use proptest::prelude::*;
834
835 proptest! {
836 #[test]
838 fn glob_patterns_never_panic_on_compile(
839 prefix in "[a-zA-Z0-9_]{1,20}",
840 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
841 ) {
842 let pattern = format!("**/{prefix}*.{ext}");
843 let result = globset::Glob::new(&pattern);
845 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
846 }
847
848 #[test]
850 fn non_source_extensions_not_in_list(
851 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "html", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
852 ) {
853 prop_assert!(
854 !SOURCE_EXTENSIONS.contains(&ext),
855 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
856 );
857 }
858
859 #[test]
861 fn compile_glob_set_no_panic(
862 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
863 ) {
864 let _ = compile_glob_set(&patterns);
866 }
867
868 #[test]
870 fn looks_like_file_path_no_panic(s in "[a-zA-Z0-9_./@-]{1,80}") {
871 let _ = looks_like_file_path(&s);
872 }
873
874 #[test]
876 fn looks_like_script_file_no_panic(s in "[a-zA-Z0-9_./@-]{1,80}") {
877 let _ = looks_like_script_file(&s);
878 }
879
880 #[test]
882 fn extract_script_file_refs_no_panic(s in "[a-zA-Z0-9 _./@&|;-]{1,200}") {
883 let _ = extract_script_file_refs(&s);
884 }
885 }
886 }
887}