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.to_string_lossy().contains("src/utils.ts"),
622 "should map ./dist/utils.js to src/utils.ts"
623 );
624 }
625
626 #[test]
627 fn maps_build_output_to_src() {
628 let dir = tempfile::tempdir().expect("create temp dir");
629 let canonical = dir.path().canonicalize().unwrap();
631 let src = canonical.join("src");
632 std::fs::create_dir_all(&src).unwrap();
633 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
634
635 let result = resolve_entry_path(
636 &canonical,
637 "./build/index.js",
638 &canonical,
639 EntryPointSource::PackageJsonExports,
640 );
641 assert!(result.is_some(), "should map build/ output to src/");
642 let ep = result.unwrap();
643 assert!(
644 ep.path.to_string_lossy().contains("src/index.tsx"),
645 "should map ./build/index.js to src/index.tsx"
646 );
647 }
648
649 #[test]
650 fn preserves_entry_point_source() {
651 let dir = tempfile::tempdir().expect("create temp dir");
652 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
653
654 let canonical = dir.path().canonicalize().unwrap();
655 let result = resolve_entry_path(
656 dir.path(),
657 "index.ts",
658 &canonical,
659 EntryPointSource::PackageJsonScript,
660 );
661 assert!(result.is_some());
662 assert!(
663 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
664 "should preserve the source kind"
665 );
666 }
667 }
668
669 mod output_to_source_tests {
671 use super::*;
672
673 #[test]
674 fn maps_dist_to_src_with_ts_extension() {
675 let dir = tempfile::tempdir().expect("create temp dir");
676 let src = dir.path().join("src");
677 std::fs::create_dir_all(&src).unwrap();
678 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
679
680 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
681 assert!(result.is_some());
682 assert!(result.unwrap().to_string_lossy().contains("src/utils.ts"));
683 }
684
685 #[test]
686 fn returns_none_when_no_source_file_exists() {
687 let dir = tempfile::tempdir().expect("create temp dir");
688 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
690 assert!(result.is_none());
691 }
692
693 #[test]
694 fn ignores_non_output_directories() {
695 let dir = tempfile::tempdir().expect("create temp dir");
696 let src = dir.path().join("src");
697 std::fs::create_dir_all(&src).unwrap();
698 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
699
700 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
702 assert!(result.is_none());
703 }
704
705 #[test]
706 fn maps_nested_output_path_preserving_prefix() {
707 let dir = tempfile::tempdir().expect("create temp dir");
708 let modules_src = dir.path().join("modules").join("src");
709 std::fs::create_dir_all(&modules_src).unwrap();
710 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
711
712 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
713 assert!(result.is_some());
714 assert!(
715 result
716 .unwrap()
717 .to_string_lossy()
718 .contains("modules/src/helper.ts")
719 );
720 }
721 }
722
723 mod default_fallback_tests {
725 use super::*;
726
727 #[test]
728 fn finds_src_index_ts_as_fallback() {
729 let dir = tempfile::tempdir().expect("create temp dir");
730 let src = dir.path().join("src");
731 std::fs::create_dir_all(&src).unwrap();
732 let index_path = src.join("index.ts");
733 std::fs::write(&index_path, "export const a = 1;").unwrap();
734
735 let files = vec![DiscoveredFile {
736 id: FileId(0),
737 path: index_path.clone(),
738 size_bytes: 20,
739 }];
740
741 let entries = apply_default_fallback(&files, dir.path(), None);
742 assert_eq!(entries.len(), 1);
743 assert_eq!(entries[0].path, index_path);
744 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
745 }
746
747 #[test]
748 fn finds_root_index_js_as_fallback() {
749 let dir = tempfile::tempdir().expect("create temp dir");
750 let index_path = dir.path().join("index.js");
751 std::fs::write(&index_path, "module.exports = {};").unwrap();
752
753 let files = vec![DiscoveredFile {
754 id: FileId(0),
755 path: index_path.clone(),
756 size_bytes: 21,
757 }];
758
759 let entries = apply_default_fallback(&files, dir.path(), None);
760 assert_eq!(entries.len(), 1);
761 assert_eq!(entries[0].path, index_path);
762 }
763
764 #[test]
765 fn returns_empty_when_no_index_file() {
766 let dir = tempfile::tempdir().expect("create temp dir");
767 let other_path = dir.path().join("src").join("utils.ts");
768
769 let files = vec![DiscoveredFile {
770 id: FileId(0),
771 path: other_path,
772 size_bytes: 10,
773 }];
774
775 let entries = apply_default_fallback(&files, dir.path(), None);
776 assert!(
777 entries.is_empty(),
778 "non-index files should not match default fallback"
779 );
780 }
781
782 #[test]
783 fn workspace_filter_restricts_scope() {
784 let dir = tempfile::tempdir().expect("create temp dir");
785 let ws_a = dir.path().join("packages").join("a").join("src");
786 std::fs::create_dir_all(&ws_a).unwrap();
787 let ws_b = dir.path().join("packages").join("b").join("src");
788 std::fs::create_dir_all(&ws_b).unwrap();
789
790 let index_a = ws_a.join("index.ts");
791 let index_b = ws_b.join("index.ts");
792
793 let files = vec![
794 DiscoveredFile {
795 id: FileId(0),
796 path: index_a.clone(),
797 size_bytes: 10,
798 },
799 DiscoveredFile {
800 id: FileId(1),
801 path: index_b.clone(),
802 size_bytes: 10,
803 },
804 ];
805
806 let ws_root = dir.path().join("packages").join("a");
808 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
809 assert_eq!(entries.len(), 1);
810 assert_eq!(entries[0].path, index_a);
811 }
812 }
813}