1use std::path::{Path, PathBuf};
2
3use fallow_config::{PackageJson, ResolvedConfig};
4use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
5
6use super::parse_scripts::extract_script_file_refs;
7use super::walk::SOURCE_EXTENSIONS;
8
9const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
13
14pub fn resolve_entry_path(
21 base: &Path,
22 entry: &str,
23 canonical_root: &Path,
24 source: EntryPointSource,
25) -> Option<EntryPoint> {
26 let resolved = base.join(entry);
27 let canonical_resolved = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
29 if !canonical_resolved.starts_with(canonical_root) {
30 tracing::warn!(path = %entry, "Skipping entry point outside project root");
31 return None;
32 }
33
34 if let Some(source_path) = try_output_to_source_path(base, entry) {
39 if let Ok(canonical_source) = source_path.canonicalize()
41 && canonical_source.starts_with(canonical_root)
42 {
43 return Some(EntryPoint {
44 path: source_path,
45 source,
46 });
47 }
48 }
49
50 if resolved.exists() {
51 return Some(EntryPoint {
52 path: resolved,
53 source,
54 });
55 }
56 for ext in SOURCE_EXTENSIONS {
58 let with_ext = resolved.with_extension(ext);
59 if with_ext.exists() {
60 return Some(EntryPoint {
61 path: with_ext,
62 source,
63 });
64 }
65 }
66 None
67}
68
69fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
81 let entry_path = Path::new(entry);
82 let components: Vec<_> = entry_path.components().collect();
83
84 let output_pos = components.iter().rposition(|c| {
86 if let std::path::Component::Normal(s) = c
87 && let Some(name) = s.to_str()
88 {
89 return OUTPUT_DIRS.contains(&name);
90 }
91 false
92 })?;
93
94 let prefix: PathBuf = components[..output_pos]
96 .iter()
97 .filter(|c| !matches!(c, std::path::Component::CurDir))
98 .collect();
99
100 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
102
103 for ext in SOURCE_EXTENSIONS {
105 let source_candidate = base
106 .join(&prefix)
107 .join("src")
108 .join(suffix.with_extension(ext));
109 if source_candidate.exists() {
110 return Some(source_candidate);
111 }
112 }
113
114 None
115}
116
117const DEFAULT_INDEX_PATTERNS: &[&str] = &[
119 "src/index.{ts,tsx,js,jsx}",
120 "src/main.{ts,tsx,js,jsx}",
121 "index.{ts,tsx,js,jsx}",
122 "main.{ts,tsx,js,jsx}",
123];
124
125fn apply_default_fallback(
130 files: &[DiscoveredFile],
131 root: &Path,
132 ws_filter: Option<&Path>,
133) -> Vec<EntryPoint> {
134 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
135 .iter()
136 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
137 .collect();
138
139 let mut entries = Vec::new();
140 for file in files {
141 if let Some(ws_root) = ws_filter
143 && file.path.strip_prefix(ws_root).is_err()
144 {
145 continue;
146 }
147 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
148 let relative_str = relative.to_string_lossy();
149 if default_matchers
150 .iter()
151 .any(|m| m.is_match(relative_str.as_ref()))
152 {
153 entries.push(EntryPoint {
154 path: file.path.clone(),
155 source: EntryPointSource::DefaultIndex,
156 });
157 }
158 }
159 entries
160}
161
162pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
164 let _span = tracing::info_span!("discover_entry_points").entered();
165 let mut entries = Vec::new();
166
167 let relative_paths: Vec<String> = files
169 .iter()
170 .map(|f| {
171 f.path
172 .strip_prefix(&config.root)
173 .unwrap_or(&f.path)
174 .to_string_lossy()
175 .into_owned()
176 })
177 .collect();
178
179 {
182 let mut builder = globset::GlobSetBuilder::new();
183 for pattern in &config.entry_patterns {
184 if let Ok(glob) = globset::Glob::new(pattern) {
185 builder.add(glob);
186 }
187 }
188 if let Ok(glob_set) = builder.build()
189 && !glob_set.is_empty()
190 {
191 for (idx, rel) in relative_paths.iter().enumerate() {
192 if glob_set.is_match(rel) {
193 entries.push(EntryPoint {
194 path: files[idx].path.clone(),
195 source: EntryPointSource::ManualEntry,
196 });
197 }
198 }
199 }
200 }
201
202 let canonical_root = config
205 .root
206 .canonicalize()
207 .unwrap_or_else(|_| config.root.clone());
208 let pkg_path = config.root.join("package.json");
209 if let Ok(pkg) = PackageJson::load(&pkg_path) {
210 for entry_path in pkg.entry_points() {
211 if let Some(ep) = resolve_entry_path(
212 &config.root,
213 &entry_path,
214 &canonical_root,
215 EntryPointSource::PackageJsonMain,
216 ) {
217 entries.push(ep);
218 }
219 }
220
221 if let Some(scripts) = &pkg.scripts {
223 for script_value in scripts.values() {
224 for file_ref in extract_script_file_refs(script_value) {
225 if let Some(ep) = resolve_entry_path(
226 &config.root,
227 &file_ref,
228 &canonical_root,
229 EntryPointSource::PackageJsonScript,
230 ) {
231 entries.push(ep);
232 }
233 }
234 }
235 }
236
237 }
239
240 discover_nested_package_entries(&config.root, files, &mut entries, &canonical_root);
244
245 if entries.is_empty() {
247 entries = apply_default_fallback(files, &config.root, None);
248 }
249
250 entries.sort_by(|a, b| a.path.cmp(&b.path));
252 entries.dedup_by(|a, b| a.path == b.path);
253
254 entries
255}
256
257fn discover_nested_package_entries(
263 root: &Path,
264 _files: &[DiscoveredFile],
265 entries: &mut Vec<EntryPoint>,
266 canonical_root: &Path,
267) {
268 let search_dirs = [
270 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
271 ];
272 for dir_name in &search_dirs {
273 let search_dir = root.join(dir_name);
274 if !search_dir.is_dir() {
275 continue;
276 }
277 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
278 continue;
279 };
280 for entry in read_dir.flatten() {
281 let pkg_path = entry.path().join("package.json");
282 if !pkg_path.exists() {
283 continue;
284 }
285 let Ok(pkg) = PackageJson::load(&pkg_path) else {
286 continue;
287 };
288 let pkg_dir = entry.path();
289 for entry_path in pkg.entry_points() {
290 if let Some(ep) = resolve_entry_path(
291 &pkg_dir,
292 &entry_path,
293 canonical_root,
294 EntryPointSource::PackageJsonExports,
295 ) {
296 entries.push(ep);
297 }
298 }
299 if let Some(scripts) = &pkg.scripts {
301 for script_value in scripts.values() {
302 for file_ref in extract_script_file_refs(script_value) {
303 if let Some(ep) = resolve_entry_path(
304 &pkg_dir,
305 &file_ref,
306 canonical_root,
307 EntryPointSource::PackageJsonScript,
308 ) {
309 entries.push(ep);
310 }
311 }
312 }
313 }
314 }
315 }
316}
317
318pub fn discover_workspace_entry_points(
320 ws_root: &Path,
321 _config: &ResolvedConfig,
322 all_files: &[DiscoveredFile],
323) -> Vec<EntryPoint> {
324 let mut entries = Vec::new();
325
326 let pkg_path = ws_root.join("package.json");
327 if let Ok(pkg) = PackageJson::load(&pkg_path) {
328 let canonical_ws_root = ws_root
329 .canonicalize()
330 .unwrap_or_else(|_| ws_root.to_path_buf());
331 for entry_path in pkg.entry_points() {
332 if let Some(ep) = resolve_entry_path(
333 ws_root,
334 &entry_path,
335 &canonical_ws_root,
336 EntryPointSource::PackageJsonMain,
337 ) {
338 entries.push(ep);
339 }
340 }
341
342 if let Some(scripts) = &pkg.scripts {
344 for script_value in scripts.values() {
345 for file_ref in extract_script_file_refs(script_value) {
346 if let Some(ep) = resolve_entry_path(
347 ws_root,
348 &file_ref,
349 &canonical_ws_root,
350 EntryPointSource::PackageJsonScript,
351 ) {
352 entries.push(ep);
353 }
354 }
355 }
356 }
357
358 }
360
361 if entries.is_empty() {
363 entries = apply_default_fallback(all_files, ws_root, None);
364 }
365
366 entries.sort_by(|a, b| a.path.cmp(&b.path));
367 entries.dedup_by(|a, b| a.path == b.path);
368 entries
369}
370
371pub fn discover_plugin_entry_points(
376 plugin_result: &crate::plugins::AggregatedPluginResult,
377 config: &ResolvedConfig,
378 files: &[DiscoveredFile],
379) -> Vec<EntryPoint> {
380 let mut entries = Vec::new();
381
382 let relative_paths: Vec<String> = files
384 .iter()
385 .map(|f| {
386 f.path
387 .strip_prefix(&config.root)
388 .unwrap_or(&f.path)
389 .to_string_lossy()
390 .into_owned()
391 })
392 .collect();
393
394 let mut builder = globset::GlobSetBuilder::new();
398 let mut glob_plugin_names: Vec<&str> = Vec::new();
399 for (pattern, pname) in plugin_result
400 .entry_patterns
401 .iter()
402 .chain(plugin_result.discovered_always_used.iter())
403 .chain(plugin_result.always_used.iter())
404 {
405 if let Ok(glob) = globset::Glob::new(pattern) {
406 builder.add(glob);
407 glob_plugin_names.push(pname);
408 }
409 }
410 if let Ok(glob_set) = builder.build()
411 && !glob_set.is_empty()
412 {
413 for (idx, rel) in relative_paths.iter().enumerate() {
414 let matches = glob_set.matches(rel);
415 if !matches.is_empty() {
416 let name = glob_plugin_names[matches[0]].to_string();
418 entries.push(EntryPoint {
419 path: files[idx].path.clone(),
420 source: EntryPointSource::Plugin { name },
421 });
422 }
423 }
424 }
425
426 for (setup_file, pname) in &plugin_result.setup_files {
428 let resolved = if setup_file.is_absolute() {
429 setup_file.clone()
430 } else {
431 config.root.join(setup_file)
432 };
433 if resolved.exists() {
434 entries.push(EntryPoint {
435 path: resolved,
436 source: EntryPointSource::Plugin {
437 name: pname.clone(),
438 },
439 });
440 } else {
441 for ext in SOURCE_EXTENSIONS {
443 let with_ext = resolved.with_extension(ext);
444 if with_ext.exists() {
445 entries.push(EntryPoint {
446 path: with_ext,
447 source: EntryPointSource::Plugin {
448 name: pname.clone(),
449 },
450 });
451 break;
452 }
453 }
454 }
455 }
456
457 entries.sort_by(|a, b| a.path.cmp(&b.path));
459 entries.dedup_by(|a, b| a.path == b.path);
460 entries
461}
462
463pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
465 if patterns.is_empty() {
466 return None;
467 }
468 let mut builder = globset::GlobSetBuilder::new();
469 for pattern in patterns {
470 if let Ok(glob) = globset::Glob::new(pattern) {
471 builder.add(glob);
472 }
473 }
474 builder.build().ok()
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480 use fallow_types::discover::FileId;
481 use proptest::prelude::*;
482
483 proptest! {
484 #[test]
486 fn glob_patterns_never_panic_on_compile(
487 prefix in "[a-zA-Z0-9_]{1,20}",
488 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
489 ) {
490 let pattern = format!("**/{prefix}*.{ext}");
491 let result = globset::Glob::new(&pattern);
493 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
494 }
495
496 #[test]
498 fn non_source_extensions_not_in_list(
499 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "html", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
500 ) {
501 prop_assert!(
502 !SOURCE_EXTENSIONS.contains(&ext),
503 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
504 );
505 }
506
507 #[test]
509 fn compile_glob_set_no_panic(
510 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
511 ) {
512 let _ = compile_glob_set(&patterns);
514 }
515 }
516
517 #[test]
519 fn compile_glob_set_empty_input() {
520 assert!(
521 compile_glob_set(&[]).is_none(),
522 "empty patterns should return None"
523 );
524 }
525
526 #[test]
527 fn compile_glob_set_valid_patterns() {
528 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
529 let set = compile_glob_set(&patterns);
530 assert!(set.is_some(), "valid patterns should compile");
531 let set = set.unwrap();
532 assert!(set.is_match("src/foo.ts"));
533 assert!(set.is_match("src/bar.js"));
534 assert!(!set.is_match("src/bar.py"));
535 }
536
537 mod resolve_entry_path_tests {
539 use super::*;
540
541 #[test]
542 fn resolves_existing_file() {
543 let dir = tempfile::tempdir().expect("create temp dir");
544 let src = dir.path().join("src");
545 std::fs::create_dir_all(&src).unwrap();
546 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
547
548 let canonical = dir.path().canonicalize().unwrap();
549 let result = resolve_entry_path(
550 dir.path(),
551 "src/index.ts",
552 &canonical,
553 EntryPointSource::PackageJsonMain,
554 );
555 assert!(result.is_some(), "should resolve an existing file");
556 assert!(result.unwrap().path.ends_with("src/index.ts"));
557 }
558
559 #[test]
560 fn resolves_with_extension_fallback() {
561 let dir = tempfile::tempdir().expect("create temp dir");
562 let canonical = dir.path().canonicalize().unwrap();
564 let src = canonical.join("src");
565 std::fs::create_dir_all(&src).unwrap();
566 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
567
568 let result = resolve_entry_path(
570 &canonical,
571 "src/index",
572 &canonical,
573 EntryPointSource::PackageJsonMain,
574 );
575 assert!(
576 result.is_some(),
577 "should resolve via extension fallback when exact path doesn't exist"
578 );
579 let ep = result.unwrap();
580 assert!(
581 ep.path.to_string_lossy().contains("index.ts"),
582 "should find index.ts via extension fallback"
583 );
584 }
585
586 #[test]
587 fn returns_none_for_nonexistent_file() {
588 let dir = tempfile::tempdir().expect("create temp dir");
589 let canonical = dir.path().canonicalize().unwrap();
590 let result = resolve_entry_path(
591 dir.path(),
592 "does/not/exist.ts",
593 &canonical,
594 EntryPointSource::PackageJsonMain,
595 );
596 assert!(result.is_none(), "should return None for nonexistent files");
597 }
598
599 #[test]
600 fn maps_dist_output_to_src() {
601 let dir = tempfile::tempdir().expect("create temp dir");
602 let src = dir.path().join("src");
603 std::fs::create_dir_all(&src).unwrap();
604 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
605
606 let dist = dir.path().join("dist");
608 std::fs::create_dir_all(&dist).unwrap();
609 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
610
611 let canonical = dir.path().canonicalize().unwrap();
612 let result = resolve_entry_path(
613 dir.path(),
614 "./dist/utils.js",
615 &canonical,
616 EntryPointSource::PackageJsonExports,
617 );
618 assert!(result.is_some(), "should resolve dist/ path to src/");
619 let ep = result.unwrap();
620 assert!(
621 ep.path
622 .to_string_lossy()
623 .replace('\\', "/")
624 .contains("src/utils.ts"),
625 "should map ./dist/utils.js to src/utils.ts"
626 );
627 }
628
629 #[test]
630 fn maps_build_output_to_src() {
631 let dir = tempfile::tempdir().expect("create temp dir");
632 let canonical = dir.path().canonicalize().unwrap();
634 let src = canonical.join("src");
635 std::fs::create_dir_all(&src).unwrap();
636 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
637
638 let result = resolve_entry_path(
639 &canonical,
640 "./build/index.js",
641 &canonical,
642 EntryPointSource::PackageJsonExports,
643 );
644 assert!(result.is_some(), "should map build/ output to src/");
645 let ep = result.unwrap();
646 assert!(
647 ep.path
648 .to_string_lossy()
649 .replace('\\', "/")
650 .contains("src/index.tsx"),
651 "should map ./build/index.js to src/index.tsx"
652 );
653 }
654
655 #[test]
656 fn preserves_entry_point_source() {
657 let dir = tempfile::tempdir().expect("create temp dir");
658 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
659
660 let canonical = dir.path().canonicalize().unwrap();
661 let result = resolve_entry_path(
662 dir.path(),
663 "index.ts",
664 &canonical,
665 EntryPointSource::PackageJsonScript,
666 );
667 assert!(result.is_some());
668 assert!(
669 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
670 "should preserve the source kind"
671 );
672 }
673 }
674
675 mod output_to_source_tests {
677 use super::*;
678
679 #[test]
680 fn maps_dist_to_src_with_ts_extension() {
681 let dir = tempfile::tempdir().expect("create temp dir");
682 let src = dir.path().join("src");
683 std::fs::create_dir_all(&src).unwrap();
684 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
685
686 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
687 assert!(result.is_some());
688 assert!(
689 result
690 .unwrap()
691 .to_string_lossy()
692 .replace('\\', "/")
693 .contains("src/utils.ts")
694 );
695 }
696
697 #[test]
698 fn returns_none_when_no_source_file_exists() {
699 let dir = tempfile::tempdir().expect("create temp dir");
700 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
702 assert!(result.is_none());
703 }
704
705 #[test]
706 fn ignores_non_output_directories() {
707 let dir = tempfile::tempdir().expect("create temp dir");
708 let src = dir.path().join("src");
709 std::fs::create_dir_all(&src).unwrap();
710 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
711
712 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
714 assert!(result.is_none());
715 }
716
717 #[test]
718 fn maps_nested_output_path_preserving_prefix() {
719 let dir = tempfile::tempdir().expect("create temp dir");
720 let modules_src = dir.path().join("modules").join("src");
721 std::fs::create_dir_all(&modules_src).unwrap();
722 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
723
724 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
725 assert!(result.is_some());
726 assert!(
727 result
728 .unwrap()
729 .to_string_lossy()
730 .replace('\\', "/")
731 .contains("modules/src/helper.ts")
732 );
733 }
734 }
735
736 mod default_fallback_tests {
738 use super::*;
739
740 #[test]
741 fn finds_src_index_ts_as_fallback() {
742 let dir = tempfile::tempdir().expect("create temp dir");
743 let src = dir.path().join("src");
744 std::fs::create_dir_all(&src).unwrap();
745 let index_path = src.join("index.ts");
746 std::fs::write(&index_path, "export const a = 1;").unwrap();
747
748 let files = vec![DiscoveredFile {
749 id: FileId(0),
750 path: index_path.clone(),
751 size_bytes: 20,
752 }];
753
754 let entries = apply_default_fallback(&files, dir.path(), None);
755 assert_eq!(entries.len(), 1);
756 assert_eq!(entries[0].path, index_path);
757 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
758 }
759
760 #[test]
761 fn finds_root_index_js_as_fallback() {
762 let dir = tempfile::tempdir().expect("create temp dir");
763 let index_path = dir.path().join("index.js");
764 std::fs::write(&index_path, "module.exports = {};").unwrap();
765
766 let files = vec![DiscoveredFile {
767 id: FileId(0),
768 path: index_path.clone(),
769 size_bytes: 21,
770 }];
771
772 let entries = apply_default_fallback(&files, dir.path(), None);
773 assert_eq!(entries.len(), 1);
774 assert_eq!(entries[0].path, index_path);
775 }
776
777 #[test]
778 fn returns_empty_when_no_index_file() {
779 let dir = tempfile::tempdir().expect("create temp dir");
780 let other_path = dir.path().join("src").join("utils.ts");
781
782 let files = vec![DiscoveredFile {
783 id: FileId(0),
784 path: other_path,
785 size_bytes: 10,
786 }];
787
788 let entries = apply_default_fallback(&files, dir.path(), None);
789 assert!(
790 entries.is_empty(),
791 "non-index files should not match default fallback"
792 );
793 }
794
795 #[test]
796 fn workspace_filter_restricts_scope() {
797 let dir = tempfile::tempdir().expect("create temp dir");
798 let ws_a = dir.path().join("packages").join("a").join("src");
799 std::fs::create_dir_all(&ws_a).unwrap();
800 let ws_b = dir.path().join("packages").join("b").join("src");
801 std::fs::create_dir_all(&ws_b).unwrap();
802
803 let index_a = ws_a.join("index.ts");
804 let index_b = ws_b.join("index.ts");
805
806 let files = vec![
807 DiscoveredFile {
808 id: FileId(0),
809 path: index_a.clone(),
810 size_bytes: 10,
811 },
812 DiscoveredFile {
813 id: FileId(1),
814 path: index_b,
815 size_bytes: 10,
816 },
817 ];
818
819 let ws_root = dir.path().join("packages").join("a");
821 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
822 assert_eq!(entries.len(), 1);
823 assert_eq!(entries[0].path, index_a);
824 }
825 }
826}