1use std::path::{Component, Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
7use rustc_hash::FxHashMap;
8
9const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
13const SKIPPED_ENTRY_WARNING_PREVIEW: usize = 5;
14
15fn format_skipped_entry_warning(skipped_entries: &FxHashMap<String, usize>) -> Option<String> {
16 if skipped_entries.is_empty() {
17 return None;
18 }
19
20 let mut entries = skipped_entries
21 .iter()
22 .map(|(path, count)| (path.as_str(), *count))
23 .collect::<Vec<_>>();
24 entries.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
25
26 let preview = entries
27 .iter()
28 .take(SKIPPED_ENTRY_WARNING_PREVIEW)
29 .map(|(path, count)| {
30 if *count > 1 {
31 format!("{path} ({count}x)")
32 } else {
33 (*path).to_owned()
34 }
35 })
36 .collect::<Vec<_>>();
37
38 let omitted = entries.len().saturating_sub(SKIPPED_ENTRY_WARNING_PREVIEW);
39 let tail = if omitted > 0 {
40 format!(" (and {omitted} more)")
41 } else {
42 String::new()
43 };
44 let total = entries.iter().map(|(_, count)| *count).sum::<usize>();
45 let noun = if total == 1 {
46 "package.json entry point"
47 } else {
48 "package.json entry points"
49 };
50
51 Some(format!(
52 "Skipped {total} {noun} outside project root or containing parent directory traversal: {}{tail}",
53 preview.join(", ")
54 ))
55}
56
57pub fn warn_skipped_entry_summary(skipped_entries: &FxHashMap<String, usize>) {
58 if let Some(message) = format_skipped_entry_warning(skipped_entries) {
59 tracing::warn!("{message}");
60 }
61}
62
63#[derive(Debug, Clone, Default)]
65pub struct CategorizedEntryPoints {
66 pub all: Vec<EntryPoint>,
67 pub runtime: Vec<EntryPoint>,
68 pub test: Vec<EntryPoint>,
69}
70
71impl CategorizedEntryPoints {
72 pub fn push_runtime(&mut self, entry: EntryPoint) {
73 self.runtime.push(entry.clone());
74 self.all.push(entry);
75 }
76
77 pub fn push_test(&mut self, entry: EntryPoint) {
78 self.test.push(entry.clone());
79 self.all.push(entry);
80 }
81
82 pub fn push_support(&mut self, entry: EntryPoint) {
83 self.all.push(entry);
84 }
85
86 pub fn extend_runtime<I>(&mut self, entries: I)
87 where
88 I: IntoIterator<Item = EntryPoint>,
89 {
90 for entry in entries {
91 self.push_runtime(entry);
92 }
93 }
94
95 pub fn extend_test<I>(&mut self, entries: I)
96 where
97 I: IntoIterator<Item = EntryPoint>,
98 {
99 for entry in entries {
100 self.push_test(entry);
101 }
102 }
103
104 pub fn extend_support<I>(&mut self, entries: I)
105 where
106 I: IntoIterator<Item = EntryPoint>,
107 {
108 for entry in entries {
109 self.push_support(entry);
110 }
111 }
112
113 pub fn extend(&mut self, other: Self) {
114 self.all.extend(other.all);
115 self.runtime.extend(other.runtime);
116 self.test.extend(other.test);
117 }
118
119 #[must_use]
120 pub fn dedup(mut self) -> Self {
121 dedup_entry_paths(&mut self.all);
122 dedup_entry_paths(&mut self.runtime);
123 dedup_entry_paths(&mut self.test);
124 self
125 }
126}
127
128fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
129 entries.sort_by(|a, b| a.path.cmp(&b.path));
130 entries.dedup_by(|a, b| a.path == b.path);
131}
132
133#[derive(Debug, Default)]
134pub struct EntryPointDiscovery {
135 pub entries: Vec<EntryPoint>,
136 pub skipped_entries: FxHashMap<String, usize>,
137}
138
139fn resolve_entry_path_with_tracking(
146 base: &Path,
147 entry: &str,
148 canonical_root: &Path,
149 source: EntryPointSource,
150 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
151) -> Option<EntryPoint> {
152 if entry.contains('*') {
155 return None;
156 }
157
158 if entry_has_parent_dir(entry) {
159 if let Some(skipped_entries) = skipped_entries.as_mut() {
160 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
161 } else {
162 tracing::warn!(path = %entry, "Skipping entry point containing parent directory traversal");
163 }
164 return None;
165 }
166
167 let resolved = base.join(entry);
168
169 if let Some(source_path) = try_output_to_source_path(base, entry) {
174 return validated_entry_point(
175 &source_path,
176 canonical_root,
177 entry,
178 source,
179 skipped_entries.as_deref_mut(),
180 );
181 }
182
183 if is_entry_in_output_dir(entry)
191 && let Some(source_path) = try_source_index_fallback(base)
192 {
193 tracing::info!(
194 entry = %entry,
195 fallback = %source_path.display(),
196 "package.json entry resolves to an ignored output directory; falling back to source index"
197 );
198 return validated_entry_point(
199 &source_path,
200 canonical_root,
201 entry,
202 source,
203 skipped_entries.as_deref_mut(),
204 );
205 }
206
207 if resolved.is_file() {
208 return validated_entry_point(
209 &resolved,
210 canonical_root,
211 entry,
212 source,
213 skipped_entries.as_deref_mut(),
214 );
215 }
216
217 for ext in SOURCE_EXTENSIONS {
219 let with_ext = resolved.with_extension(ext);
220 if with_ext.is_file() {
221 return validated_entry_point(
222 &with_ext,
223 canonical_root,
224 entry,
225 source,
226 skipped_entries.as_deref_mut(),
227 );
228 }
229 }
230 None
231}
232
233fn entry_has_parent_dir(entry: &str) -> bool {
234 Path::new(entry)
235 .components()
236 .any(|component| matches!(component, Component::ParentDir))
237}
238
239fn validated_entry_point(
240 candidate: &Path,
241 canonical_root: &Path,
242 entry: &str,
243 source: EntryPointSource,
244 mut skipped_entries: Option<&mut FxHashMap<String, usize>>,
245) -> Option<EntryPoint> {
246 let canonical_candidate = match dunce::canonicalize(candidate) {
247 Ok(path) => path,
248 Err(err) => {
249 tracing::warn!(
250 path = %candidate.display(),
251 %entry,
252 error = %err,
253 "Skipping entry point that could not be canonicalized"
254 );
255 return None;
256 }
257 };
258
259 if !canonical_candidate.starts_with(canonical_root) {
260 if let Some(skipped_entries) = skipped_entries.as_mut() {
261 *skipped_entries.entry(entry.to_owned()).or_default() += 1;
262 } else {
263 tracing::warn!(
264 path = %candidate.display(),
265 %entry,
266 "Skipping entry point outside project root"
267 );
268 }
269 return None;
270 }
271
272 Some(EntryPoint {
273 path: candidate.to_path_buf(),
274 source,
275 })
276}
277
278pub fn resolve_entry_path(
279 base: &Path,
280 entry: &str,
281 canonical_root: &Path,
282 source: EntryPointSource,
283) -> Option<EntryPoint> {
284 resolve_entry_path_with_tracking(base, entry, canonical_root, source, None)
285}
286fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
298 let entry_path = Path::new(entry);
299 let components: Vec<_> = entry_path.components().collect();
300
301 let output_pos = components.iter().rposition(|c| {
303 if let std::path::Component::Normal(s) = c
304 && let Some(name) = s.to_str()
305 {
306 return OUTPUT_DIRS.contains(&name);
307 }
308 false
309 })?;
310
311 let prefix: PathBuf = components[..output_pos]
313 .iter()
314 .filter(|c| !matches!(c, std::path::Component::CurDir))
315 .collect();
316
317 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
319
320 for ext in SOURCE_EXTENSIONS {
322 let source_candidate = base
323 .join(&prefix)
324 .join("src")
325 .join(suffix.with_extension(ext));
326 if source_candidate.exists() {
327 return Some(source_candidate);
328 }
329 }
330
331 None
332}
333
334const SOURCE_INDEX_FALLBACK_STEMS: &[&str] = &["src/index", "src/main", "index", "main"];
337
338fn is_entry_in_output_dir(entry: &str) -> bool {
343 Path::new(entry).components().any(|c| {
344 matches!(
345 c,
346 std::path::Component::Normal(s)
347 if s.to_str().is_some_and(|name| OUTPUT_DIRS.contains(&name))
348 )
349 })
350}
351
352fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
359 for stem in SOURCE_INDEX_FALLBACK_STEMS {
360 for ext in SOURCE_EXTENSIONS {
361 let candidate = base.join(format!("{stem}.{ext}"));
362 if candidate.is_file() {
363 return Some(candidate);
364 }
365 }
366 }
367 None
368}
369
370const DEFAULT_INDEX_PATTERNS: &[&str] = &[
372 "src/index.{ts,tsx,js,jsx}",
373 "src/main.{ts,tsx,js,jsx}",
374 "index.{ts,tsx,js,jsx}",
375 "main.{ts,tsx,js,jsx}",
376];
377
378fn apply_default_fallback(
383 files: &[DiscoveredFile],
384 root: &Path,
385 ws_filter: Option<&Path>,
386) -> Vec<EntryPoint> {
387 let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
388 .iter()
389 .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
390 .collect();
391
392 let mut entries = Vec::new();
393 for file in files {
394 if let Some(ws_root) = ws_filter
396 && file.path.strip_prefix(ws_root).is_err()
397 {
398 continue;
399 }
400 let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
401 let relative_str = relative.to_string_lossy();
402 if default_matchers
403 .iter()
404 .any(|m| m.is_match(relative_str.as_ref()))
405 {
406 entries.push(EntryPoint {
407 path: file.path.clone(),
408 source: EntryPointSource::DefaultIndex,
409 });
410 }
411 }
412 entries
413}
414
415fn discover_entry_points_with_warnings_impl(
417 config: &ResolvedConfig,
418 files: &[DiscoveredFile],
419 root_pkg: Option<&PackageJson>,
420 include_nested_package_entries: bool,
421) -> EntryPointDiscovery {
422 let _span = tracing::info_span!("discover_entry_points").entered();
423 let mut discovery = EntryPointDiscovery::default();
424
425 let relative_paths: Vec<String> = files
427 .iter()
428 .map(|f| {
429 f.path
430 .strip_prefix(&config.root)
431 .unwrap_or(&f.path)
432 .to_string_lossy()
433 .into_owned()
434 })
435 .collect();
436
437 {
440 let mut builder = globset::GlobSetBuilder::new();
441 for pattern in &config.entry_patterns {
442 if let Ok(glob) = globset::Glob::new(pattern) {
443 builder.add(glob);
444 }
445 }
446 if let Ok(glob_set) = builder.build()
447 && !glob_set.is_empty()
448 {
449 for (idx, rel) in relative_paths.iter().enumerate() {
450 if glob_set.is_match(rel) {
451 discovery.entries.push(EntryPoint {
452 path: files[idx].path.clone(),
453 source: EntryPointSource::ManualEntry,
454 });
455 }
456 }
457 }
458 }
459
460 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
463 if let Some(pkg) = root_pkg {
464 for entry_path in pkg.entry_points() {
465 if let Some(ep) = resolve_entry_path_with_tracking(
466 &config.root,
467 &entry_path,
468 &canonical_root,
469 EntryPointSource::PackageJsonMain,
470 Some(&mut discovery.skipped_entries),
471 ) {
472 discovery.entries.push(ep);
473 }
474 }
475
476 if let Some(scripts) = &pkg.scripts {
478 for script_value in scripts.values() {
479 for file_ref in extract_script_file_refs(script_value) {
480 if let Some(ep) = resolve_entry_path_with_tracking(
481 &config.root,
482 &file_ref,
483 &canonical_root,
484 EntryPointSource::PackageJsonScript,
485 Some(&mut discovery.skipped_entries),
486 ) {
487 discovery.entries.push(ep);
488 }
489 }
490 }
491 }
492
493 }
495
496 if include_nested_package_entries {
500 let exports_dirs = root_pkg
501 .map(PackageJson::exports_subdirectories)
502 .unwrap_or_default();
503 discover_nested_package_entries(
504 &config.root,
505 files,
506 &mut discovery.entries,
507 &canonical_root,
508 &exports_dirs,
509 &mut discovery.skipped_entries,
510 );
511 }
512
513 if discovery.entries.is_empty() {
515 discovery.entries = apply_default_fallback(files, &config.root, None);
516 }
517
518 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
520 discovery.entries.dedup_by(|a, b| a.path == b.path);
521
522 discovery
523}
524
525pub fn discover_entry_points_with_warnings_from_pkg(
526 config: &ResolvedConfig,
527 files: &[DiscoveredFile],
528 root_pkg: Option<&PackageJson>,
529 include_nested_package_entries: bool,
530) -> EntryPointDiscovery {
531 discover_entry_points_with_warnings_impl(
532 config,
533 files,
534 root_pkg,
535 include_nested_package_entries,
536 )
537}
538
539pub fn discover_entry_points_with_warnings(
540 config: &ResolvedConfig,
541 files: &[DiscoveredFile],
542) -> EntryPointDiscovery {
543 let pkg_path = config.root.join("package.json");
544 let root_pkg = PackageJson::load(&pkg_path).ok();
545 discover_entry_points_with_warnings_impl(config, files, root_pkg.as_ref(), true)
546}
547
548pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
549 let discovery = discover_entry_points_with_warnings(config, files);
550 warn_skipped_entry_summary(&discovery.skipped_entries);
551 discovery.entries
552}
553
554fn discover_nested_package_entries(
564 root: &Path,
565 _files: &[DiscoveredFile],
566 entries: &mut Vec<EntryPoint>,
567 canonical_root: &Path,
568 exports_subdirectories: &[String],
569 skipped_entries: &mut FxHashMap<String, usize>,
570) {
571 let mut visited = rustc_hash::FxHashSet::default();
572
573 let search_dirs = [
575 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
576 ];
577 for dir_name in &search_dirs {
578 let search_dir = root.join(dir_name);
579 if !search_dir.is_dir() {
580 continue;
581 }
582 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
583 continue;
584 };
585 for entry in read_dir.flatten() {
586 let pkg_dir = entry.path();
587 if visited.insert(pkg_dir.clone()) {
588 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
589 }
590 }
591 }
592
593 for dir_name in exports_subdirectories {
595 let pkg_dir = root.join(dir_name);
596 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
597 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
598 }
599 }
600}
601
602fn collect_nested_package_entries(
604 pkg_dir: &Path,
605 entries: &mut Vec<EntryPoint>,
606 canonical_root: &Path,
607 skipped_entries: &mut FxHashMap<String, usize>,
608) {
609 let pkg_path = pkg_dir.join("package.json");
610 if !pkg_path.exists() {
611 return;
612 }
613 let Ok(pkg) = PackageJson::load(&pkg_path) else {
614 return;
615 };
616 for entry_path in pkg.entry_points() {
617 if entry_path.contains('*') {
618 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
619 } else if let Some(ep) = resolve_entry_path_with_tracking(
620 pkg_dir,
621 &entry_path,
622 canonical_root,
623 EntryPointSource::PackageJsonExports,
624 Some(&mut *skipped_entries),
625 ) {
626 entries.push(ep);
627 }
628 }
629 if let Some(scripts) = &pkg.scripts {
630 for script_value in scripts.values() {
631 for file_ref in extract_script_file_refs(script_value) {
632 if let Some(ep) = resolve_entry_path_with_tracking(
633 pkg_dir,
634 &file_ref,
635 canonical_root,
636 EntryPointSource::PackageJsonScript,
637 Some(&mut *skipped_entries),
638 ) {
639 entries.push(ep);
640 }
641 }
642 }
643 }
644}
645
646fn expand_wildcard_entries(
652 base: &Path,
653 pattern: &str,
654 canonical_root: &Path,
655 entries: &mut Vec<EntryPoint>,
656) {
657 let full_pattern = base.join(pattern).to_string_lossy().to_string();
658 let Ok(matches) = glob::glob(&full_pattern) else {
659 return;
660 };
661 for path_result in matches {
662 let Ok(path) = path_result else {
663 continue;
664 };
665 if let Ok(canonical) = dunce::canonicalize(&path)
666 && canonical.starts_with(canonical_root)
667 {
668 entries.push(EntryPoint {
669 path,
670 source: EntryPointSource::PackageJsonExports,
671 });
672 }
673 }
674}
675
676#[must_use]
678fn discover_workspace_entry_points_with_warnings_impl(
679 ws_root: &Path,
680 all_files: &[DiscoveredFile],
681 pkg: Option<&PackageJson>,
682) -> EntryPointDiscovery {
683 let mut discovery = EntryPointDiscovery::default();
684
685 if let Some(pkg) = pkg {
686 let canonical_ws_root =
687 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
688 for entry_path in pkg.entry_points() {
689 if entry_path.contains('*') {
690 expand_wildcard_entries(
691 ws_root,
692 &entry_path,
693 &canonical_ws_root,
694 &mut discovery.entries,
695 );
696 } else if let Some(ep) = resolve_entry_path_with_tracking(
697 ws_root,
698 &entry_path,
699 &canonical_ws_root,
700 EntryPointSource::PackageJsonMain,
701 Some(&mut discovery.skipped_entries),
702 ) {
703 discovery.entries.push(ep);
704 }
705 }
706
707 if let Some(scripts) = &pkg.scripts {
709 for script_value in scripts.values() {
710 for file_ref in extract_script_file_refs(script_value) {
711 if let Some(ep) = resolve_entry_path_with_tracking(
712 ws_root,
713 &file_ref,
714 &canonical_ws_root,
715 EntryPointSource::PackageJsonScript,
716 Some(&mut discovery.skipped_entries),
717 ) {
718 discovery.entries.push(ep);
719 }
720 }
721 }
722 }
723
724 }
726
727 if discovery.entries.is_empty() {
729 discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
730 }
731
732 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
733 discovery.entries.dedup_by(|a, b| a.path == b.path);
734 discovery
735}
736
737pub fn discover_workspace_entry_points_with_warnings_from_pkg(
738 ws_root: &Path,
739 all_files: &[DiscoveredFile],
740 pkg: Option<&PackageJson>,
741) -> EntryPointDiscovery {
742 discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg)
743}
744
745#[must_use]
746pub fn discover_workspace_entry_points_with_warnings(
747 ws_root: &Path,
748 _config: &ResolvedConfig,
749 all_files: &[DiscoveredFile],
750) -> EntryPointDiscovery {
751 let pkg_path = ws_root.join("package.json");
752 let pkg = PackageJson::load(&pkg_path).ok();
753 discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg.as_ref())
754}
755
756#[must_use]
757pub fn discover_workspace_entry_points(
758 ws_root: &Path,
759 config: &ResolvedConfig,
760 all_files: &[DiscoveredFile],
761) -> Vec<EntryPoint> {
762 let discovery = discover_workspace_entry_points_with_warnings(ws_root, config, all_files);
763 warn_skipped_entry_summary(&discovery.skipped_entries);
764 discovery.entries
765}
766
767#[must_use]
772pub fn discover_plugin_entry_points(
773 plugin_result: &crate::plugins::AggregatedPluginResult,
774 config: &ResolvedConfig,
775 files: &[DiscoveredFile],
776) -> Vec<EntryPoint> {
777 discover_plugin_entry_point_sets(plugin_result, config, files).all
778}
779
780#[must_use]
782pub fn discover_plugin_entry_point_sets(
783 plugin_result: &crate::plugins::AggregatedPluginResult,
784 config: &ResolvedConfig,
785 files: &[DiscoveredFile],
786) -> CategorizedEntryPoints {
787 let mut entries = CategorizedEntryPoints::default();
788
789 let relative_paths: Vec<String> = files
791 .iter()
792 .map(|f| {
793 f.path
794 .strip_prefix(&config.root)
795 .unwrap_or(&f.path)
796 .to_string_lossy()
797 .into_owned()
798 })
799 .collect();
800
801 let mut builder = globset::GlobSetBuilder::new();
804 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
805 for (rule, pname) in &plugin_result.entry_patterns {
806 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
807 builder.add(include);
808 glob_meta.push(compiled);
809 }
810 }
811 for (pattern, pname) in plugin_result
812 .discovered_always_used
813 .iter()
814 .chain(plugin_result.always_used.iter())
815 .chain(plugin_result.fixture_patterns.iter())
816 {
817 if let Ok(glob) = globset::GlobBuilder::new(pattern)
818 .literal_separator(true)
819 .build()
820 {
821 builder.add(glob);
822 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
823 &crate::plugins::PathRule::new(pattern.clone()),
824 "support entry pattern",
825 ) {
826 glob_meta.push(CompiledEntryRule {
827 path,
828 plugin_name: pname,
829 role: EntryPointRole::Support,
830 });
831 }
832 }
833 }
834 if let Ok(glob_set) = builder.build()
835 && !glob_set.is_empty()
836 {
837 for (idx, rel) in relative_paths.iter().enumerate() {
838 let matches: Vec<usize> = glob_set
839 .matches(rel)
840 .into_iter()
841 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
842 .collect();
843 if !matches.is_empty() {
844 let name = glob_meta[matches[0]].plugin_name;
845 let entry = EntryPoint {
846 path: files[idx].path.clone(),
847 source: EntryPointSource::Plugin {
848 name: name.to_string(),
849 },
850 };
851
852 let mut has_runtime = false;
853 let mut has_test = false;
854 let mut has_support = false;
855 for match_idx in matches {
856 match glob_meta[match_idx].role {
857 EntryPointRole::Runtime => has_runtime = true,
858 EntryPointRole::Test => has_test = true,
859 EntryPointRole::Support => has_support = true,
860 }
861 }
862
863 if has_runtime {
864 entries.push_runtime(entry.clone());
865 }
866 if has_test {
867 entries.push_test(entry.clone());
868 }
869 if has_support || (!has_runtime && !has_test) {
870 entries.push_support(entry);
871 }
872 }
873 }
874 }
875
876 for (setup_file, pname) in &plugin_result.setup_files {
878 let resolved = if setup_file.is_absolute() {
879 setup_file.clone()
880 } else {
881 config.root.join(setup_file)
882 };
883 if resolved.exists() {
884 entries.push_support(EntryPoint {
885 path: resolved,
886 source: EntryPointSource::Plugin {
887 name: pname.clone(),
888 },
889 });
890 } else {
891 for ext in SOURCE_EXTENSIONS {
893 let with_ext = resolved.with_extension(ext);
894 if with_ext.exists() {
895 entries.push_support(EntryPoint {
896 path: with_ext,
897 source: EntryPointSource::Plugin {
898 name: pname.clone(),
899 },
900 });
901 break;
902 }
903 }
904 }
905 }
906
907 entries.dedup()
908}
909
910#[must_use]
915pub fn discover_dynamically_loaded_entry_points(
916 config: &ResolvedConfig,
917 files: &[DiscoveredFile],
918) -> Vec<EntryPoint> {
919 if config.dynamically_loaded.is_empty() {
920 return Vec::new();
921 }
922
923 let mut builder = globset::GlobSetBuilder::new();
924 for pattern in &config.dynamically_loaded {
925 if let Ok(glob) = globset::Glob::new(pattern) {
926 builder.add(glob);
927 }
928 }
929 let Ok(glob_set) = builder.build() else {
930 return Vec::new();
931 };
932 if glob_set.is_empty() {
933 return Vec::new();
934 }
935
936 let mut entries = Vec::new();
937 for file in files {
938 let rel = file
939 .path
940 .strip_prefix(&config.root)
941 .unwrap_or(&file.path)
942 .to_string_lossy();
943 if glob_set.is_match(rel.as_ref()) {
944 entries.push(EntryPoint {
945 path: file.path.clone(),
946 source: EntryPointSource::DynamicallyLoaded,
947 });
948 }
949 }
950 entries
951}
952
953struct CompiledEntryRule<'a> {
954 path: crate::plugins::CompiledPathRule,
955 plugin_name: &'a str,
956 role: EntryPointRole,
957}
958
959impl CompiledEntryRule<'_> {
960 fn matches(&self, path: &str) -> bool {
961 self.path.matches(path)
962 }
963}
964
965fn compile_entry_rule<'a>(
966 rule: &'a crate::plugins::PathRule,
967 plugin_name: &'a str,
968 plugin_result: &'a crate::plugins::AggregatedPluginResult,
969) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
970 let include = match globset::GlobBuilder::new(&rule.pattern)
971 .literal_separator(true)
972 .build()
973 {
974 Ok(glob) => glob,
975 Err(err) => {
976 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
977 return None;
978 }
979 };
980 let role = plugin_result
981 .entry_point_roles
982 .get(plugin_name)
983 .copied()
984 .unwrap_or(EntryPointRole::Support);
985 Some((
986 include,
987 CompiledEntryRule {
988 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
989 plugin_name,
990 role,
991 },
992 ))
993}
994
995#[must_use]
997pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
998 if patterns.is_empty() {
999 return None;
1000 }
1001 let mut builder = globset::GlobSetBuilder::new();
1002 for pattern in patterns {
1003 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1004 .literal_separator(true)
1005 .build()
1006 {
1007 builder.add(glob);
1008 }
1009 }
1010 builder.build().ok()
1011}
1012
1013#[cfg(test)]
1014mod tests {
1015 use super::*;
1016 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1017 use fallow_types::discover::FileId;
1018 use proptest::prelude::*;
1019
1020 proptest! {
1021 #[test]
1023 fn glob_patterns_never_panic_on_compile(
1024 prefix in "[a-zA-Z0-9_]{1,20}",
1025 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1026 ) {
1027 let pattern = format!("**/{prefix}*.{ext}");
1028 let result = globset::Glob::new(&pattern);
1030 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1031 }
1032
1033 #[test]
1035 fn non_source_extensions_not_in_list(
1036 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1037 ) {
1038 prop_assert!(
1039 !SOURCE_EXTENSIONS.contains(&ext),
1040 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1041 );
1042 }
1043
1044 #[test]
1046 fn compile_glob_set_no_panic(
1047 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1048 ) {
1049 let _ = compile_glob_set(&patterns);
1051 }
1052 }
1053
1054 #[test]
1056 fn compile_glob_set_empty_input() {
1057 assert!(
1058 compile_glob_set(&[]).is_none(),
1059 "empty patterns should return None"
1060 );
1061 }
1062
1063 #[test]
1064 fn compile_glob_set_valid_patterns() {
1065 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1066 let set = compile_glob_set(&patterns);
1067 assert!(set.is_some(), "valid patterns should compile");
1068 let set = set.unwrap();
1069 assert!(set.is_match("src/foo.ts"));
1070 assert!(set.is_match("src/bar.js"));
1071 assert!(!set.is_match("src/bar.py"));
1072 }
1073
1074 #[test]
1075 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1076 let patterns = vec!["composables/*.{ts,js}".to_string()];
1077 let set = compile_glob_set(&patterns).expect("pattern should compile");
1078
1079 assert!(set.is_match("composables/useFoo.ts"));
1080 assert!(!set.is_match("composables/nested/useFoo.ts"));
1081 }
1082
1083 #[test]
1084 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1085 let dir = tempfile::tempdir().expect("create temp dir");
1086 let root = dir.path();
1087 std::fs::create_dir_all(root.join("src")).unwrap();
1088 std::fs::create_dir_all(root.join("tests")).unwrap();
1089 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1090 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1091 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1092
1093 let config = FallowConfig {
1094 schema: None,
1095 extends: vec![],
1096 entry: vec![],
1097 ignore_patterns: vec![],
1098 framework: vec![],
1099 workspaces: None,
1100 ignore_dependencies: vec![],
1101 ignore_exports: vec![],
1102 ignore_catalog_references: vec![],
1103 ignore_dependency_overrides: vec![],
1104 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1105 used_class_members: vec![],
1106 duplicates: fallow_config::DuplicatesConfig::default(),
1107 health: fallow_config::HealthConfig::default(),
1108 rules: RulesConfig::default(),
1109 boundaries: fallow_config::BoundaryConfig::default(),
1110 production: false.into(),
1111 plugins: vec![],
1112 dynamically_loaded: vec![],
1113 overrides: vec![],
1114 regression: None,
1115 audit: fallow_config::AuditConfig::default(),
1116 codeowners: None,
1117 public_packages: vec![],
1118 flags: fallow_config::FlagsConfig::default(),
1119 fix: fallow_config::FixConfig::default(),
1120 resolve: fallow_config::ResolveConfig::default(),
1121 sealed: false,
1122 include_entry_exports: false,
1123 }
1124 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1125
1126 let files = vec![
1127 DiscoveredFile {
1128 id: FileId(0),
1129 path: root.join("src/runtime.ts"),
1130 size_bytes: 1,
1131 },
1132 DiscoveredFile {
1133 id: FileId(1),
1134 path: root.join("src/setup.ts"),
1135 size_bytes: 1,
1136 },
1137 DiscoveredFile {
1138 id: FileId(2),
1139 path: root.join("tests/app.test.ts"),
1140 size_bytes: 1,
1141 },
1142 ];
1143
1144 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1145 plugin_result.entry_patterns.push((
1146 crate::plugins::PathRule::new("src/runtime.ts"),
1147 "runtime-plugin".to_string(),
1148 ));
1149 plugin_result.entry_patterns.push((
1150 crate::plugins::PathRule::new("tests/app.test.ts"),
1151 "test-plugin".to_string(),
1152 ));
1153 plugin_result
1154 .always_used
1155 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1156 plugin_result
1157 .entry_point_roles
1158 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1159 plugin_result
1160 .entry_point_roles
1161 .insert("test-plugin".to_string(), EntryPointRole::Test);
1162 plugin_result
1163 .entry_point_roles
1164 .insert("support-plugin".to_string(), EntryPointRole::Support);
1165
1166 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1167
1168 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1169 assert!(
1170 entries.runtime[0].path.ends_with("src/runtime.ts"),
1171 "runtime entry should stay runtime-only"
1172 );
1173 assert_eq!(entries.test.len(), 1, "expected one test entry");
1174 assert!(
1175 entries.test[0].path.ends_with("tests/app.test.ts"),
1176 "test entry should stay test-only"
1177 );
1178 assert_eq!(
1179 entries.all.len(),
1180 3,
1181 "support entries should stay in all entries"
1182 );
1183 assert!(
1184 entries
1185 .all
1186 .iter()
1187 .any(|entry| entry.path.ends_with("src/setup.ts")),
1188 "support entries should remain in the overall entry-point set"
1189 );
1190 assert!(
1191 !entries
1192 .runtime
1193 .iter()
1194 .any(|entry| entry.path.ends_with("src/setup.ts")),
1195 "support entries should not bleed into runtime reachability"
1196 );
1197 assert!(
1198 !entries
1199 .test
1200 .iter()
1201 .any(|entry| entry.path.ends_with("src/setup.ts")),
1202 "support entries should not bleed into test reachability"
1203 );
1204 }
1205
1206 #[test]
1207 fn plugin_entry_point_rules_respect_exclusions() {
1208 let dir = tempfile::tempdir().expect("create temp dir");
1209 let root = dir.path();
1210 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1211 std::fs::write(
1212 root.join("app/pages/index.tsx"),
1213 "export default function Page() { return null; }",
1214 )
1215 .unwrap();
1216 std::fs::write(
1217 root.join("app/pages/-helper.ts"),
1218 "export const helper = 1;",
1219 )
1220 .unwrap();
1221
1222 let config = FallowConfig {
1223 schema: None,
1224 extends: vec![],
1225 entry: vec![],
1226 ignore_patterns: vec![],
1227 framework: vec![],
1228 workspaces: None,
1229 ignore_dependencies: vec![],
1230 ignore_exports: vec![],
1231 ignore_catalog_references: vec![],
1232 ignore_dependency_overrides: vec![],
1233 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1234 used_class_members: vec![],
1235 duplicates: fallow_config::DuplicatesConfig::default(),
1236 health: fallow_config::HealthConfig::default(),
1237 rules: RulesConfig::default(),
1238 boundaries: fallow_config::BoundaryConfig::default(),
1239 production: false.into(),
1240 plugins: vec![],
1241 dynamically_loaded: vec![],
1242 overrides: vec![],
1243 regression: None,
1244 audit: fallow_config::AuditConfig::default(),
1245 codeowners: None,
1246 public_packages: vec![],
1247 flags: fallow_config::FlagsConfig::default(),
1248 fix: fallow_config::FixConfig::default(),
1249 resolve: fallow_config::ResolveConfig::default(),
1250 sealed: false,
1251 include_entry_exports: false,
1252 }
1253 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
1254
1255 let files = vec![
1256 DiscoveredFile {
1257 id: FileId(0),
1258 path: root.join("app/pages/index.tsx"),
1259 size_bytes: 1,
1260 },
1261 DiscoveredFile {
1262 id: FileId(1),
1263 path: root.join("app/pages/-helper.ts"),
1264 size_bytes: 1,
1265 },
1266 ];
1267
1268 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1269 plugin_result.entry_patterns.push((
1270 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1271 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1272 "tanstack-router".to_string(),
1273 ));
1274 plugin_result
1275 .entry_point_roles
1276 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1277
1278 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1279 let entry_paths: Vec<_> = entries
1280 .all
1281 .iter()
1282 .map(|entry| {
1283 entry
1284 .path
1285 .strip_prefix(root)
1286 .unwrap()
1287 .to_string_lossy()
1288 .into_owned()
1289 })
1290 .collect();
1291
1292 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1293 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1294 }
1295
1296 mod resolve_entry_path_tests {
1298 use super::*;
1299
1300 #[test]
1301 fn resolves_existing_file() {
1302 let dir = tempfile::tempdir().expect("create temp dir");
1303 let src = dir.path().join("src");
1304 std::fs::create_dir_all(&src).unwrap();
1305 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1306
1307 let canonical = dunce::canonicalize(dir.path()).unwrap();
1308 let result = resolve_entry_path(
1309 dir.path(),
1310 "src/index.ts",
1311 &canonical,
1312 EntryPointSource::PackageJsonMain,
1313 );
1314 assert!(result.is_some(), "should resolve an existing file");
1315 assert!(result.unwrap().path.ends_with("src/index.ts"));
1316 }
1317
1318 #[test]
1319 fn resolves_with_extension_fallback() {
1320 let dir = tempfile::tempdir().expect("create temp dir");
1321 let canonical = dunce::canonicalize(dir.path()).unwrap();
1323 let src = canonical.join("src");
1324 std::fs::create_dir_all(&src).unwrap();
1325 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1326
1327 let result = resolve_entry_path(
1329 &canonical,
1330 "src/index",
1331 &canonical,
1332 EntryPointSource::PackageJsonMain,
1333 );
1334 assert!(
1335 result.is_some(),
1336 "should resolve via extension fallback when exact path doesn't exist"
1337 );
1338 let ep = result.unwrap();
1339 assert!(
1340 ep.path.to_string_lossy().contains("index.ts"),
1341 "should find index.ts via extension fallback"
1342 );
1343 }
1344
1345 #[test]
1346 fn returns_none_for_nonexistent_file() {
1347 let dir = tempfile::tempdir().expect("create temp dir");
1348 let canonical = dunce::canonicalize(dir.path()).unwrap();
1349 let result = resolve_entry_path(
1350 dir.path(),
1351 "does/not/exist.ts",
1352 &canonical,
1353 EntryPointSource::PackageJsonMain,
1354 );
1355 assert!(result.is_none(), "should return None for nonexistent files");
1356 }
1357
1358 #[test]
1359 fn maps_dist_output_to_src() {
1360 let dir = tempfile::tempdir().expect("create temp dir");
1361 let src = dir.path().join("src");
1362 std::fs::create_dir_all(&src).unwrap();
1363 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1364
1365 let dist = dir.path().join("dist");
1367 std::fs::create_dir_all(&dist).unwrap();
1368 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1369
1370 let canonical = dunce::canonicalize(dir.path()).unwrap();
1371 let result = resolve_entry_path(
1372 dir.path(),
1373 "./dist/utils.js",
1374 &canonical,
1375 EntryPointSource::PackageJsonExports,
1376 );
1377 assert!(result.is_some(), "should resolve dist/ path to src/");
1378 let ep = result.unwrap();
1379 assert!(
1380 ep.path
1381 .to_string_lossy()
1382 .replace('\\', "/")
1383 .contains("src/utils.ts"),
1384 "should map ./dist/utils.js to src/utils.ts"
1385 );
1386 }
1387
1388 #[test]
1389 fn maps_build_output_to_src() {
1390 let dir = tempfile::tempdir().expect("create temp dir");
1391 let canonical = dunce::canonicalize(dir.path()).unwrap();
1393 let src = canonical.join("src");
1394 std::fs::create_dir_all(&src).unwrap();
1395 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1396
1397 let result = resolve_entry_path(
1398 &canonical,
1399 "./build/index.js",
1400 &canonical,
1401 EntryPointSource::PackageJsonExports,
1402 );
1403 assert!(result.is_some(), "should map build/ output to src/");
1404 let ep = result.unwrap();
1405 assert!(
1406 ep.path
1407 .to_string_lossy()
1408 .replace('\\', "/")
1409 .contains("src/index.tsx"),
1410 "should map ./build/index.js to src/index.tsx"
1411 );
1412 }
1413
1414 #[test]
1415 fn preserves_entry_point_source() {
1416 let dir = tempfile::tempdir().expect("create temp dir");
1417 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1418
1419 let canonical = dunce::canonicalize(dir.path()).unwrap();
1420 let result = resolve_entry_path(
1421 dir.path(),
1422 "index.ts",
1423 &canonical,
1424 EntryPointSource::PackageJsonScript,
1425 );
1426 assert!(result.is_some());
1427 assert!(
1428 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1429 "should preserve the source kind"
1430 );
1431 }
1432 #[test]
1433 fn tracks_skipped_entries_without_logging_each_repeat() {
1434 let dir = tempfile::tempdir().expect("create temp dir");
1435 let canonical = dunce::canonicalize(dir.path()).unwrap();
1436 let mut skipped_entries = FxHashMap::default();
1437
1438 let result = resolve_entry_path_with_tracking(
1439 dir.path(),
1440 "../scripts/build.js",
1441 &canonical,
1442 EntryPointSource::PackageJsonScript,
1443 Some(&mut skipped_entries),
1444 );
1445
1446 assert!(result.is_none(), "unsafe entry should be skipped");
1447 assert_eq!(
1448 skipped_entries.get("../scripts/build.js"),
1449 Some(&1),
1450 "warning tracker should count the skipped path"
1451 );
1452 }
1453
1454 #[test]
1455 fn formats_skipped_entry_warning_with_counts() {
1456 let mut skipped_entries = FxHashMap::default();
1457 skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1458 skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1459
1460 let warning =
1461 format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1462
1463 assert_eq!(
1464 warning,
1465 "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1466 );
1467 }
1468
1469 #[test]
1470 fn rejects_parent_dir_escape_for_exact_file() {
1471 let sandbox = tempfile::tempdir().expect("create sandbox");
1472 let root = sandbox.path().join("project");
1473 std::fs::create_dir_all(&root).unwrap();
1474 std::fs::write(
1475 sandbox.path().join("escape.ts"),
1476 "export const escape = true;",
1477 )
1478 .unwrap();
1479
1480 let canonical = dunce::canonicalize(&root).unwrap();
1481 let result = resolve_entry_path(
1482 &root,
1483 "../escape.ts",
1484 &canonical,
1485 EntryPointSource::PackageJsonMain,
1486 );
1487
1488 assert!(
1489 result.is_none(),
1490 "should reject exact paths that escape the root"
1491 );
1492 }
1493
1494 #[test]
1495 fn rejects_parent_dir_escape_via_extension_fallback() {
1496 let sandbox = tempfile::tempdir().expect("create sandbox");
1497 let root = sandbox.path().join("project");
1498 std::fs::create_dir_all(&root).unwrap();
1499 std::fs::write(
1500 sandbox.path().join("escape.ts"),
1501 "export const escape = true;",
1502 )
1503 .unwrap();
1504
1505 let canonical = dunce::canonicalize(&root).unwrap();
1506 let result = resolve_entry_path(
1507 &root,
1508 "../escape",
1509 &canonical,
1510 EntryPointSource::PackageJsonMain,
1511 );
1512
1513 assert!(
1514 result.is_none(),
1515 "should reject extension fallback paths that escape the root"
1516 );
1517 }
1518 }
1519
1520 mod output_to_source_tests {
1522 use super::*;
1523
1524 #[test]
1525 fn maps_dist_to_src_with_ts_extension() {
1526 let dir = tempfile::tempdir().expect("create temp dir");
1527 let src = dir.path().join("src");
1528 std::fs::create_dir_all(&src).unwrap();
1529 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1530
1531 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1532 assert!(result.is_some());
1533 assert!(
1534 result
1535 .unwrap()
1536 .to_string_lossy()
1537 .replace('\\', "/")
1538 .contains("src/utils.ts")
1539 );
1540 }
1541
1542 #[test]
1543 fn returns_none_when_no_source_file_exists() {
1544 let dir = tempfile::tempdir().expect("create temp dir");
1545 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1547 assert!(result.is_none());
1548 }
1549
1550 #[test]
1551 fn ignores_non_output_directories() {
1552 let dir = tempfile::tempdir().expect("create temp dir");
1553 let src = dir.path().join("src");
1554 std::fs::create_dir_all(&src).unwrap();
1555 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1556
1557 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1559 assert!(result.is_none());
1560 }
1561
1562 #[test]
1563 fn maps_nested_output_path_preserving_prefix() {
1564 let dir = tempfile::tempdir().expect("create temp dir");
1565 let modules_src = dir.path().join("modules").join("src");
1566 std::fs::create_dir_all(&modules_src).unwrap();
1567 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1568
1569 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1570 assert!(result.is_some());
1571 assert!(
1572 result
1573 .unwrap()
1574 .to_string_lossy()
1575 .replace('\\', "/")
1576 .contains("modules/src/helper.ts")
1577 );
1578 }
1579 }
1580
1581 mod source_index_fallback_tests {
1583 use super::*;
1584
1585 #[test]
1586 fn detects_dist_entry_in_output_dir() {
1587 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1588 assert!(is_entry_in_output_dir("dist/index.js"));
1589 assert!(is_entry_in_output_dir("./build/index.js"));
1590 assert!(is_entry_in_output_dir("./out/main.js"));
1591 assert!(is_entry_in_output_dir("./esm/index.js"));
1592 assert!(is_entry_in_output_dir("./cjs/index.js"));
1593 }
1594
1595 #[test]
1596 fn rejects_non_output_entry_paths() {
1597 assert!(!is_entry_in_output_dir("./src/index.ts"));
1598 assert!(!is_entry_in_output_dir("src/main.ts"));
1599 assert!(!is_entry_in_output_dir("./index.js"));
1600 assert!(!is_entry_in_output_dir(""));
1601 }
1602
1603 #[test]
1604 fn rejects_substring_match_for_output_dir() {
1605 assert!(!is_entry_in_output_dir("./distro/index.js"));
1607 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1608 }
1609
1610 #[test]
1611 fn finds_src_index_ts() {
1612 let dir = tempfile::tempdir().expect("create temp dir");
1613 let src = dir.path().join("src");
1614 std::fs::create_dir_all(&src).unwrap();
1615 let index_path = src.join("index.ts");
1616 std::fs::write(&index_path, "export const a = 1;").unwrap();
1617
1618 let result = try_source_index_fallback(dir.path());
1619 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1620 }
1621
1622 #[test]
1623 fn finds_src_index_tsx_when_ts_missing() {
1624 let dir = tempfile::tempdir().expect("create temp dir");
1625 let src = dir.path().join("src");
1626 std::fs::create_dir_all(&src).unwrap();
1627 let index_path = src.join("index.tsx");
1628 std::fs::write(&index_path, "export default 1;").unwrap();
1629
1630 let result = try_source_index_fallback(dir.path());
1631 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1632 }
1633
1634 #[test]
1635 fn prefers_src_index_over_root_index() {
1636 let dir = tempfile::tempdir().expect("create temp dir");
1639 let src = dir.path().join("src");
1640 std::fs::create_dir_all(&src).unwrap();
1641 let src_index = src.join("index.ts");
1642 std::fs::write(&src_index, "export const a = 1;").unwrap();
1643 let root_index = dir.path().join("index.ts");
1644 std::fs::write(&root_index, "export const b = 2;").unwrap();
1645
1646 let result = try_source_index_fallback(dir.path());
1647 assert_eq!(result.as_deref(), Some(src_index.as_path()));
1648 }
1649
1650 #[test]
1651 fn falls_back_to_src_main() {
1652 let dir = tempfile::tempdir().expect("create temp dir");
1653 let src = dir.path().join("src");
1654 std::fs::create_dir_all(&src).unwrap();
1655 let main_path = src.join("main.ts");
1656 std::fs::write(&main_path, "export const a = 1;").unwrap();
1657
1658 let result = try_source_index_fallback(dir.path());
1659 assert_eq!(result.as_deref(), Some(main_path.as_path()));
1660 }
1661
1662 #[test]
1663 fn falls_back_to_root_index_when_no_src() {
1664 let dir = tempfile::tempdir().expect("create temp dir");
1665 let index_path = dir.path().join("index.js");
1666 std::fs::write(&index_path, "module.exports = {};").unwrap();
1667
1668 let result = try_source_index_fallback(dir.path());
1669 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1670 }
1671
1672 #[test]
1673 fn returns_none_when_nothing_matches() {
1674 let dir = tempfile::tempdir().expect("create temp dir");
1675 let result = try_source_index_fallback(dir.path());
1676 assert!(result.is_none());
1677 }
1678
1679 #[test]
1680 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1681 let dir = tempfile::tempdir().expect("create temp dir");
1682 let canonical = dunce::canonicalize(dir.path()).unwrap();
1683
1684 let dist_dir = canonical.join("dist").join("esm2022");
1689 std::fs::create_dir_all(&dist_dir).unwrap();
1690 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1691
1692 let src = canonical.join("src");
1693 std::fs::create_dir_all(&src).unwrap();
1694 let src_index = src.join("index.ts");
1695 std::fs::write(&src_index, "export const x = 1;").unwrap();
1696
1697 let result = resolve_entry_path(
1698 &canonical,
1699 "./dist/esm2022/index.js",
1700 &canonical,
1701 EntryPointSource::PackageJsonMain,
1702 );
1703 assert!(result.is_some());
1704 let entry = result.unwrap();
1705 assert_eq!(entry.path, src_index);
1706 }
1707
1708 #[test]
1709 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1710 let dir = tempfile::tempdir().expect("create temp dir");
1713 let canonical = dunce::canonicalize(dir.path()).unwrap();
1714
1715 let src_mirror = canonical.join("src").join("esm2022");
1716 std::fs::create_dir_all(&src_mirror).unwrap();
1717 let mirror_index = src_mirror.join("index.ts");
1718 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1719
1720 let src_index = canonical.join("src").join("index.ts");
1722 std::fs::write(&src_index, "export const y = 2;").unwrap();
1723
1724 let result = resolve_entry_path(
1725 &canonical,
1726 "./dist/esm2022/index.js",
1727 &canonical,
1728 EntryPointSource::PackageJsonMain,
1729 );
1730 assert_eq!(result.map(|e| e.path), Some(mirror_index));
1731 }
1732 }
1733
1734 mod default_fallback_tests {
1736 use super::*;
1737
1738 #[test]
1739 fn finds_src_index_ts_as_fallback() {
1740 let dir = tempfile::tempdir().expect("create temp dir");
1741 let src = dir.path().join("src");
1742 std::fs::create_dir_all(&src).unwrap();
1743 let index_path = src.join("index.ts");
1744 std::fs::write(&index_path, "export const a = 1;").unwrap();
1745
1746 let files = vec![DiscoveredFile {
1747 id: FileId(0),
1748 path: index_path.clone(),
1749 size_bytes: 20,
1750 }];
1751
1752 let entries = apply_default_fallback(&files, dir.path(), None);
1753 assert_eq!(entries.len(), 1);
1754 assert_eq!(entries[0].path, index_path);
1755 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1756 }
1757
1758 #[test]
1759 fn finds_root_index_js_as_fallback() {
1760 let dir = tempfile::tempdir().expect("create temp dir");
1761 let index_path = dir.path().join("index.js");
1762 std::fs::write(&index_path, "module.exports = {};").unwrap();
1763
1764 let files = vec![DiscoveredFile {
1765 id: FileId(0),
1766 path: index_path.clone(),
1767 size_bytes: 21,
1768 }];
1769
1770 let entries = apply_default_fallback(&files, dir.path(), None);
1771 assert_eq!(entries.len(), 1);
1772 assert_eq!(entries[0].path, index_path);
1773 }
1774
1775 #[test]
1776 fn returns_empty_when_no_index_file() {
1777 let dir = tempfile::tempdir().expect("create temp dir");
1778 let other_path = dir.path().join("src").join("utils.ts");
1779
1780 let files = vec![DiscoveredFile {
1781 id: FileId(0),
1782 path: other_path,
1783 size_bytes: 10,
1784 }];
1785
1786 let entries = apply_default_fallback(&files, dir.path(), None);
1787 assert!(
1788 entries.is_empty(),
1789 "non-index files should not match default fallback"
1790 );
1791 }
1792
1793 #[test]
1794 fn workspace_filter_restricts_scope() {
1795 let dir = tempfile::tempdir().expect("create temp dir");
1796 let ws_a = dir.path().join("packages").join("a").join("src");
1797 std::fs::create_dir_all(&ws_a).unwrap();
1798 let ws_b = dir.path().join("packages").join("b").join("src");
1799 std::fs::create_dir_all(&ws_b).unwrap();
1800
1801 let index_a = ws_a.join("index.ts");
1802 let index_b = ws_b.join("index.ts");
1803
1804 let files = vec![
1805 DiscoveredFile {
1806 id: FileId(0),
1807 path: index_a.clone(),
1808 size_bytes: 10,
1809 },
1810 DiscoveredFile {
1811 id: FileId(1),
1812 path: index_b,
1813 size_bytes: 10,
1814 },
1815 ];
1816
1817 let ws_root = dir.path().join("packages").join("a");
1819 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1820 assert_eq!(entries.len(), 1);
1821 assert_eq!(entries[0].path, index_a);
1822 }
1823 }
1824
1825 mod wildcard_entry_tests {
1827 use super::*;
1828
1829 #[test]
1830 fn expands_wildcard_css_entries() {
1831 let dir = tempfile::tempdir().expect("create temp dir");
1834 let themes = dir.path().join("src").join("themes");
1835 std::fs::create_dir_all(&themes).unwrap();
1836 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1837 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1838
1839 let canonical = dunce::canonicalize(dir.path()).unwrap();
1840 let mut entries = Vec::new();
1841 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1842
1843 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1844 let paths: Vec<String> = entries
1845 .iter()
1846 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1847 .collect();
1848 assert!(paths.contains(&"dark.css".to_string()));
1849 assert!(paths.contains(&"light.css".to_string()));
1850 assert!(
1851 entries
1852 .iter()
1853 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1854 );
1855 }
1856
1857 #[test]
1858 fn wildcard_does_not_match_nonexistent_files() {
1859 let dir = tempfile::tempdir().expect("create temp dir");
1860 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1862
1863 let canonical = dunce::canonicalize(dir.path()).unwrap();
1864 let mut entries = Vec::new();
1865 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1866
1867 assert!(
1868 entries.is_empty(),
1869 "should return empty when no files match the wildcard"
1870 );
1871 }
1872
1873 #[test]
1874 fn wildcard_only_matches_specified_extension() {
1875 let dir = tempfile::tempdir().expect("create temp dir");
1877 let themes = dir.path().join("src").join("themes");
1878 std::fs::create_dir_all(&themes).unwrap();
1879 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1880 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1881
1882 let canonical = dunce::canonicalize(dir.path()).unwrap();
1883 let mut entries = Vec::new();
1884 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1885
1886 assert_eq!(entries.len(), 1, "should only match CSS files");
1887 assert!(
1888 entries[0]
1889 .path
1890 .file_name()
1891 .unwrap()
1892 .to_string_lossy()
1893 .ends_with(".css")
1894 );
1895 }
1896 }
1897}