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 {
442 let mut builder = globset::GlobSetBuilder::new();
443 for pattern in &config.entry_patterns {
444 builder.add(
445 globset::Glob::new(pattern)
446 .expect("entry pattern was validated at config load time"),
447 );
448 }
449 if let Ok(glob_set) = builder.build()
450 && !glob_set.is_empty()
451 {
452 for (idx, rel) in relative_paths.iter().enumerate() {
453 if glob_set.is_match(rel) {
454 discovery.entries.push(EntryPoint {
455 path: files[idx].path.clone(),
456 source: EntryPointSource::ManualEntry,
457 });
458 }
459 }
460 }
461 }
462
463 let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
466 if let Some(pkg) = root_pkg {
467 for entry_path in pkg.entry_points() {
468 if let Some(ep) = resolve_entry_path_with_tracking(
469 &config.root,
470 &entry_path,
471 &canonical_root,
472 EntryPointSource::PackageJsonMain,
473 Some(&mut discovery.skipped_entries),
474 ) {
475 discovery.entries.push(ep);
476 }
477 }
478
479 if let Some(scripts) = &pkg.scripts {
481 for script_value in scripts.values() {
482 for file_ref in extract_script_file_refs(script_value) {
483 if let Some(ep) = resolve_entry_path_with_tracking(
484 &config.root,
485 &file_ref,
486 &canonical_root,
487 EntryPointSource::PackageJsonScript,
488 Some(&mut discovery.skipped_entries),
489 ) {
490 discovery.entries.push(ep);
491 }
492 }
493 }
494 }
495
496 }
498
499 if include_nested_package_entries {
503 let exports_dirs = root_pkg
504 .map(PackageJson::exports_subdirectories)
505 .unwrap_or_default();
506 discover_nested_package_entries(
507 &config.root,
508 files,
509 &mut discovery.entries,
510 &canonical_root,
511 &exports_dirs,
512 &mut discovery.skipped_entries,
513 );
514 }
515
516 if discovery.entries.is_empty() {
518 discovery.entries = apply_default_fallback(files, &config.root, None);
519 }
520
521 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
523 discovery.entries.dedup_by(|a, b| a.path == b.path);
524
525 discovery
526}
527
528pub fn discover_entry_points_with_warnings_from_pkg(
529 config: &ResolvedConfig,
530 files: &[DiscoveredFile],
531 root_pkg: Option<&PackageJson>,
532 include_nested_package_entries: bool,
533) -> EntryPointDiscovery {
534 discover_entry_points_with_warnings_impl(
535 config,
536 files,
537 root_pkg,
538 include_nested_package_entries,
539 )
540}
541
542pub fn discover_entry_points_with_warnings(
543 config: &ResolvedConfig,
544 files: &[DiscoveredFile],
545) -> EntryPointDiscovery {
546 let pkg_path = config.root.join("package.json");
547 let root_pkg = PackageJson::load(&pkg_path).ok();
548 discover_entry_points_with_warnings_impl(config, files, root_pkg.as_ref(), true)
549}
550
551pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
552 let discovery = discover_entry_points_with_warnings(config, files);
553 warn_skipped_entry_summary(&discovery.skipped_entries);
554 discovery.entries
555}
556
557fn discover_nested_package_entries(
567 root: &Path,
568 _files: &[DiscoveredFile],
569 entries: &mut Vec<EntryPoint>,
570 canonical_root: &Path,
571 exports_subdirectories: &[String],
572 skipped_entries: &mut FxHashMap<String, usize>,
573) {
574 let mut visited = rustc_hash::FxHashSet::default();
575
576 let search_dirs = [
578 "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
579 ];
580 for dir_name in &search_dirs {
581 let search_dir = root.join(dir_name);
582 if !search_dir.is_dir() {
583 continue;
584 }
585 let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
586 continue;
587 };
588 for entry in read_dir.flatten() {
589 let pkg_dir = entry.path();
590 if visited.insert(pkg_dir.clone()) {
591 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
592 }
593 }
594 }
595
596 for dir_name in exports_subdirectories {
598 let pkg_dir = root.join(dir_name);
599 if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
600 collect_nested_package_entries(&pkg_dir, entries, canonical_root, skipped_entries);
601 }
602 }
603}
604
605fn collect_nested_package_entries(
607 pkg_dir: &Path,
608 entries: &mut Vec<EntryPoint>,
609 canonical_root: &Path,
610 skipped_entries: &mut FxHashMap<String, usize>,
611) {
612 let pkg_path = pkg_dir.join("package.json");
613 if !pkg_path.exists() {
614 return;
615 }
616 let Ok(pkg) = PackageJson::load(&pkg_path) else {
617 return;
618 };
619 for entry_path in pkg.entry_points() {
620 if entry_path.contains('*') {
621 expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
622 } else if let Some(ep) = resolve_entry_path_with_tracking(
623 pkg_dir,
624 &entry_path,
625 canonical_root,
626 EntryPointSource::PackageJsonExports,
627 Some(&mut *skipped_entries),
628 ) {
629 entries.push(ep);
630 }
631 }
632 if let Some(scripts) = &pkg.scripts {
633 for script_value in scripts.values() {
634 for file_ref in extract_script_file_refs(script_value) {
635 if let Some(ep) = resolve_entry_path_with_tracking(
636 pkg_dir,
637 &file_ref,
638 canonical_root,
639 EntryPointSource::PackageJsonScript,
640 Some(&mut *skipped_entries),
641 ) {
642 entries.push(ep);
643 }
644 }
645 }
646 }
647}
648
649fn expand_wildcard_entries(
655 base: &Path,
656 pattern: &str,
657 canonical_root: &Path,
658 entries: &mut Vec<EntryPoint>,
659) {
660 let full_pattern = base.join(pattern).to_string_lossy().to_string();
661 let Ok(matches) = glob::glob(&full_pattern) else {
662 return;
663 };
664 for path_result in matches {
665 let Ok(path) = path_result else {
666 continue;
667 };
668 if let Ok(canonical) = dunce::canonicalize(&path)
669 && canonical.starts_with(canonical_root)
670 {
671 entries.push(EntryPoint {
672 path,
673 source: EntryPointSource::PackageJsonExports,
674 });
675 }
676 }
677}
678
679#[must_use]
681fn discover_workspace_entry_points_with_warnings_impl(
682 ws_root: &Path,
683 all_files: &[DiscoveredFile],
684 pkg: Option<&PackageJson>,
685) -> EntryPointDiscovery {
686 let mut discovery = EntryPointDiscovery::default();
687
688 if let Some(pkg) = pkg {
689 let canonical_ws_root =
690 dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
691 for entry_path in pkg.entry_points() {
692 if entry_path.contains('*') {
693 expand_wildcard_entries(
694 ws_root,
695 &entry_path,
696 &canonical_ws_root,
697 &mut discovery.entries,
698 );
699 } else if let Some(ep) = resolve_entry_path_with_tracking(
700 ws_root,
701 &entry_path,
702 &canonical_ws_root,
703 EntryPointSource::PackageJsonMain,
704 Some(&mut discovery.skipped_entries),
705 ) {
706 discovery.entries.push(ep);
707 }
708 }
709
710 if let Some(scripts) = &pkg.scripts {
712 for script_value in scripts.values() {
713 for file_ref in extract_script_file_refs(script_value) {
714 if let Some(ep) = resolve_entry_path_with_tracking(
715 ws_root,
716 &file_ref,
717 &canonical_ws_root,
718 EntryPointSource::PackageJsonScript,
719 Some(&mut discovery.skipped_entries),
720 ) {
721 discovery.entries.push(ep);
722 }
723 }
724 }
725 }
726
727 }
729
730 if discovery.entries.is_empty() {
732 discovery.entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
733 }
734
735 discovery.entries.sort_by(|a, b| a.path.cmp(&b.path));
736 discovery.entries.dedup_by(|a, b| a.path == b.path);
737 discovery
738}
739
740pub fn discover_workspace_entry_points_with_warnings_from_pkg(
741 ws_root: &Path,
742 all_files: &[DiscoveredFile],
743 pkg: Option<&PackageJson>,
744) -> EntryPointDiscovery {
745 discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg)
746}
747
748#[must_use]
749pub fn discover_workspace_entry_points_with_warnings(
750 ws_root: &Path,
751 _config: &ResolvedConfig,
752 all_files: &[DiscoveredFile],
753) -> EntryPointDiscovery {
754 let pkg_path = ws_root.join("package.json");
755 let pkg = PackageJson::load(&pkg_path).ok();
756 discover_workspace_entry_points_with_warnings_impl(ws_root, all_files, pkg.as_ref())
757}
758
759#[must_use]
760pub fn discover_workspace_entry_points(
761 ws_root: &Path,
762 config: &ResolvedConfig,
763 all_files: &[DiscoveredFile],
764) -> Vec<EntryPoint> {
765 let discovery = discover_workspace_entry_points_with_warnings(ws_root, config, all_files);
766 warn_skipped_entry_summary(&discovery.skipped_entries);
767 discovery.entries
768}
769
770#[must_use]
775pub fn discover_plugin_entry_points(
776 plugin_result: &crate::plugins::AggregatedPluginResult,
777 config: &ResolvedConfig,
778 files: &[DiscoveredFile],
779) -> Vec<EntryPoint> {
780 discover_plugin_entry_point_sets(plugin_result, config, files).all
781}
782
783#[must_use]
785pub fn discover_plugin_entry_point_sets(
786 plugin_result: &crate::plugins::AggregatedPluginResult,
787 config: &ResolvedConfig,
788 files: &[DiscoveredFile],
789) -> CategorizedEntryPoints {
790 let mut entries = CategorizedEntryPoints::default();
791
792 let relative_paths: Vec<String> = files
794 .iter()
795 .map(|f| {
796 f.path
797 .strip_prefix(&config.root)
798 .unwrap_or(&f.path)
799 .to_string_lossy()
800 .into_owned()
801 })
802 .collect();
803
804 let mut builder = globset::GlobSetBuilder::new();
807 let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
808 for (rule, pname) in &plugin_result.entry_patterns {
809 if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
810 builder.add(include);
811 glob_meta.push(compiled);
812 }
813 }
814 for (pattern, pname) in plugin_result
815 .discovered_always_used
816 .iter()
817 .chain(plugin_result.always_used.iter())
818 .chain(plugin_result.fixture_patterns.iter())
819 {
820 if let Ok(glob) = globset::GlobBuilder::new(pattern)
821 .literal_separator(true)
822 .build()
823 {
824 builder.add(glob);
825 if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
826 &crate::plugins::PathRule::new(pattern.clone()),
827 "support entry pattern",
828 ) {
829 glob_meta.push(CompiledEntryRule {
830 path,
831 plugin_name: pname,
832 role: EntryPointRole::Support,
833 });
834 }
835 }
836 }
837 if let Ok(glob_set) = builder.build()
838 && !glob_set.is_empty()
839 {
840 for (idx, rel) in relative_paths.iter().enumerate() {
841 let matches: Vec<usize> = glob_set
842 .matches(rel)
843 .into_iter()
844 .filter(|match_idx| glob_meta[*match_idx].matches(rel))
845 .collect();
846 if !matches.is_empty() {
847 let name = glob_meta[matches[0]].plugin_name;
848 let entry = EntryPoint {
849 path: files[idx].path.clone(),
850 source: EntryPointSource::Plugin {
851 name: name.to_string(),
852 },
853 };
854
855 let mut has_runtime = false;
856 let mut has_test = false;
857 let mut has_support = false;
858 for match_idx in matches {
859 match glob_meta[match_idx].role {
860 EntryPointRole::Runtime => has_runtime = true,
861 EntryPointRole::Test => has_test = true,
862 EntryPointRole::Support => has_support = true,
863 }
864 }
865
866 if has_runtime {
867 entries.push_runtime(entry.clone());
868 }
869 if has_test {
870 entries.push_test(entry.clone());
871 }
872 if has_support || (!has_runtime && !has_test) {
873 entries.push_support(entry);
874 }
875 }
876 }
877 }
878
879 for (setup_file, pname) in &plugin_result.setup_files {
881 let resolved = if setup_file.is_absolute() {
882 setup_file.clone()
883 } else {
884 config.root.join(setup_file)
885 };
886 if resolved.exists() {
887 entries.push_support(EntryPoint {
888 path: resolved,
889 source: EntryPointSource::Plugin {
890 name: pname.clone(),
891 },
892 });
893 } else {
894 for ext in SOURCE_EXTENSIONS {
896 let with_ext = resolved.with_extension(ext);
897 if with_ext.exists() {
898 entries.push_support(EntryPoint {
899 path: with_ext,
900 source: EntryPointSource::Plugin {
901 name: pname.clone(),
902 },
903 });
904 break;
905 }
906 }
907 }
908 }
909
910 entries.dedup()
911}
912
913#[must_use]
918pub fn discover_dynamically_loaded_entry_points(
919 config: &ResolvedConfig,
920 files: &[DiscoveredFile],
921) -> Vec<EntryPoint> {
922 if config.dynamically_loaded.is_empty() {
923 return Vec::new();
924 }
925
926 let mut builder = globset::GlobSetBuilder::new();
929 for pattern in &config.dynamically_loaded {
930 builder.add(
931 globset::Glob::new(pattern)
932 .expect("dynamicallyLoaded pattern was validated at config load time"),
933 );
934 }
935 let Ok(glob_set) = builder.build() else {
936 return Vec::new();
937 };
938 if glob_set.is_empty() {
939 return Vec::new();
940 }
941
942 let mut entries = Vec::new();
943 for file in files {
944 let rel = file
945 .path
946 .strip_prefix(&config.root)
947 .unwrap_or(&file.path)
948 .to_string_lossy();
949 if glob_set.is_match(rel.as_ref()) {
950 entries.push(EntryPoint {
951 path: file.path.clone(),
952 source: EntryPointSource::DynamicallyLoaded,
953 });
954 }
955 }
956 entries
957}
958
959struct CompiledEntryRule<'a> {
960 path: crate::plugins::CompiledPathRule,
961 plugin_name: &'a str,
962 role: EntryPointRole,
963}
964
965impl CompiledEntryRule<'_> {
966 fn matches(&self, path: &str) -> bool {
967 self.path.matches(path)
968 }
969}
970
971fn compile_entry_rule<'a>(
972 rule: &'a crate::plugins::PathRule,
973 plugin_name: &'a str,
974 plugin_result: &'a crate::plugins::AggregatedPluginResult,
975) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
976 let include = match globset::GlobBuilder::new(&rule.pattern)
977 .literal_separator(true)
978 .build()
979 {
980 Ok(glob) => glob,
981 Err(err) => {
982 tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
983 return None;
984 }
985 };
986 let role = plugin_result
987 .entry_point_roles
988 .get(plugin_name)
989 .copied()
990 .unwrap_or(EntryPointRole::Support);
991 Some((
992 include,
993 CompiledEntryRule {
994 path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
995 plugin_name,
996 role,
997 },
998 ))
999}
1000
1001#[must_use]
1003pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
1004 if patterns.is_empty() {
1005 return None;
1006 }
1007 let mut builder = globset::GlobSetBuilder::new();
1008 for pattern in patterns {
1009 if let Ok(glob) = globset::GlobBuilder::new(pattern)
1010 .literal_separator(true)
1011 .build()
1012 {
1013 builder.add(glob);
1014 }
1015 }
1016 builder.build().ok()
1017}
1018
1019#[cfg(test)]
1020mod tests {
1021 use super::*;
1022 use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
1023 use fallow_types::discover::FileId;
1024 use proptest::prelude::*;
1025
1026 proptest! {
1027 #[test]
1029 fn glob_patterns_never_panic_on_compile(
1030 prefix in "[a-zA-Z0-9_]{1,20}",
1031 ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
1032 ) {
1033 let pattern = format!("**/{prefix}*.{ext}");
1034 let result = globset::Glob::new(&pattern);
1036 prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
1037 }
1038
1039 #[test]
1041 fn non_source_extensions_not_in_list(
1042 ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
1043 ) {
1044 prop_assert!(
1045 !SOURCE_EXTENSIONS.contains(&ext),
1046 "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
1047 );
1048 }
1049
1050 #[test]
1052 fn compile_glob_set_no_panic(
1053 patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
1054 ) {
1055 let _ = compile_glob_set(&patterns);
1057 }
1058 }
1059
1060 #[test]
1062 fn compile_glob_set_empty_input() {
1063 assert!(
1064 compile_glob_set(&[]).is_none(),
1065 "empty patterns should return None"
1066 );
1067 }
1068
1069 #[test]
1070 fn compile_glob_set_valid_patterns() {
1071 let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
1072 let set = compile_glob_set(&patterns);
1073 assert!(set.is_some(), "valid patterns should compile");
1074 let set = set.unwrap();
1075 assert!(set.is_match("src/foo.ts"));
1076 assert!(set.is_match("src/bar.js"));
1077 assert!(!set.is_match("src/bar.py"));
1078 }
1079
1080 #[test]
1081 fn compile_glob_set_keeps_star_within_a_single_path_segment() {
1082 let patterns = vec!["composables/*.{ts,js}".to_string()];
1083 let set = compile_glob_set(&patterns).expect("pattern should compile");
1084
1085 assert!(set.is_match("composables/useFoo.ts"));
1086 assert!(!set.is_match("composables/nested/useFoo.ts"));
1087 }
1088
1089 #[test]
1090 fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
1091 let dir = tempfile::tempdir().expect("create temp dir");
1092 let root = dir.path();
1093 std::fs::create_dir_all(root.join("src")).unwrap();
1094 std::fs::create_dir_all(root.join("tests")).unwrap();
1095 std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
1096 std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
1097 std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
1098
1099 let config = FallowConfig {
1100 schema: None,
1101 extends: vec![],
1102 entry: vec![],
1103 ignore_patterns: vec![],
1104 framework: vec![],
1105 workspaces: None,
1106 ignore_dependencies: vec![],
1107 ignore_exports: vec![],
1108 ignore_catalog_references: vec![],
1109 ignore_dependency_overrides: vec![],
1110 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1111 used_class_members: vec![],
1112 ignore_decorators: vec![],
1113 duplicates: fallow_config::DuplicatesConfig::default(),
1114 health: fallow_config::HealthConfig::default(),
1115 rules: RulesConfig::default(),
1116 boundaries: fallow_config::BoundaryConfig::default(),
1117 production: false.into(),
1118 plugins: vec![],
1119 dynamically_loaded: vec![],
1120 overrides: vec![],
1121 regression: None,
1122 audit: fallow_config::AuditConfig::default(),
1123 codeowners: None,
1124 public_packages: vec![],
1125 flags: fallow_config::FlagsConfig::default(),
1126 fix: fallow_config::FixConfig::default(),
1127 resolve: fallow_config::ResolveConfig::default(),
1128 sealed: false,
1129 include_entry_exports: false,
1130 cache: fallow_config::CacheConfig::default(),
1131 }
1132 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1133
1134 let files = vec![
1135 DiscoveredFile {
1136 id: FileId(0),
1137 path: root.join("src/runtime.ts"),
1138 size_bytes: 1,
1139 },
1140 DiscoveredFile {
1141 id: FileId(1),
1142 path: root.join("src/setup.ts"),
1143 size_bytes: 1,
1144 },
1145 DiscoveredFile {
1146 id: FileId(2),
1147 path: root.join("tests/app.test.ts"),
1148 size_bytes: 1,
1149 },
1150 ];
1151
1152 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1153 plugin_result.entry_patterns.push((
1154 crate::plugins::PathRule::new("src/runtime.ts"),
1155 "runtime-plugin".to_string(),
1156 ));
1157 plugin_result.entry_patterns.push((
1158 crate::plugins::PathRule::new("tests/app.test.ts"),
1159 "test-plugin".to_string(),
1160 ));
1161 plugin_result
1162 .always_used
1163 .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
1164 plugin_result
1165 .entry_point_roles
1166 .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
1167 plugin_result
1168 .entry_point_roles
1169 .insert("test-plugin".to_string(), EntryPointRole::Test);
1170 plugin_result
1171 .entry_point_roles
1172 .insert("support-plugin".to_string(), EntryPointRole::Support);
1173
1174 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1175
1176 assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
1177 assert!(
1178 entries.runtime[0].path.ends_with("src/runtime.ts"),
1179 "runtime entry should stay runtime-only"
1180 );
1181 assert_eq!(entries.test.len(), 1, "expected one test entry");
1182 assert!(
1183 entries.test[0].path.ends_with("tests/app.test.ts"),
1184 "test entry should stay test-only"
1185 );
1186 assert_eq!(
1187 entries.all.len(),
1188 3,
1189 "support entries should stay in all entries"
1190 );
1191 assert!(
1192 entries
1193 .all
1194 .iter()
1195 .any(|entry| entry.path.ends_with("src/setup.ts")),
1196 "support entries should remain in the overall entry-point set"
1197 );
1198 assert!(
1199 !entries
1200 .runtime
1201 .iter()
1202 .any(|entry| entry.path.ends_with("src/setup.ts")),
1203 "support entries should not bleed into runtime reachability"
1204 );
1205 assert!(
1206 !entries
1207 .test
1208 .iter()
1209 .any(|entry| entry.path.ends_with("src/setup.ts")),
1210 "support entries should not bleed into test reachability"
1211 );
1212 }
1213
1214 #[test]
1215 fn plugin_entry_point_rules_respect_exclusions() {
1216 let dir = tempfile::tempdir().expect("create temp dir");
1217 let root = dir.path();
1218 std::fs::create_dir_all(root.join("app/pages")).unwrap();
1219 std::fs::write(
1220 root.join("app/pages/index.tsx"),
1221 "export default function Page() { return null; }",
1222 )
1223 .unwrap();
1224 std::fs::write(
1225 root.join("app/pages/-helper.ts"),
1226 "export const helper = 1;",
1227 )
1228 .unwrap();
1229
1230 let config = FallowConfig {
1231 schema: None,
1232 extends: vec![],
1233 entry: vec![],
1234 ignore_patterns: vec![],
1235 framework: vec![],
1236 workspaces: None,
1237 ignore_dependencies: vec![],
1238 ignore_exports: vec![],
1239 ignore_catalog_references: vec![],
1240 ignore_dependency_overrides: vec![],
1241 ignore_exports_used_in_file: fallow_config::IgnoreExportsUsedInFileConfig::default(),
1242 used_class_members: vec![],
1243 ignore_decorators: vec![],
1244 duplicates: fallow_config::DuplicatesConfig::default(),
1245 health: fallow_config::HealthConfig::default(),
1246 rules: RulesConfig::default(),
1247 boundaries: fallow_config::BoundaryConfig::default(),
1248 production: false.into(),
1249 plugins: vec![],
1250 dynamically_loaded: vec![],
1251 overrides: vec![],
1252 regression: None,
1253 audit: fallow_config::AuditConfig::default(),
1254 codeowners: None,
1255 public_packages: vec![],
1256 flags: fallow_config::FlagsConfig::default(),
1257 fix: fallow_config::FixConfig::default(),
1258 resolve: fallow_config::ResolveConfig::default(),
1259 sealed: false,
1260 include_entry_exports: false,
1261 cache: fallow_config::CacheConfig::default(),
1262 }
1263 .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true, None);
1264
1265 let files = vec![
1266 DiscoveredFile {
1267 id: FileId(0),
1268 path: root.join("app/pages/index.tsx"),
1269 size_bytes: 1,
1270 },
1271 DiscoveredFile {
1272 id: FileId(1),
1273 path: root.join("app/pages/-helper.ts"),
1274 size_bytes: 1,
1275 },
1276 ];
1277
1278 let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
1279 plugin_result.entry_patterns.push((
1280 crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
1281 .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
1282 "tanstack-router".to_string(),
1283 ));
1284 plugin_result
1285 .entry_point_roles
1286 .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1287
1288 let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1289 let entry_paths: Vec<_> = entries
1290 .all
1291 .iter()
1292 .map(|entry| {
1293 entry
1294 .path
1295 .strip_prefix(root)
1296 .unwrap()
1297 .to_string_lossy()
1298 .into_owned()
1299 })
1300 .collect();
1301
1302 assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1303 assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1304 }
1305
1306 mod resolve_entry_path_tests {
1308 use super::*;
1309
1310 #[test]
1311 fn resolves_existing_file() {
1312 let dir = tempfile::tempdir().expect("create temp dir");
1313 let src = dir.path().join("src");
1314 std::fs::create_dir_all(&src).unwrap();
1315 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1316
1317 let canonical = dunce::canonicalize(dir.path()).unwrap();
1318 let result = resolve_entry_path(
1319 dir.path(),
1320 "src/index.ts",
1321 &canonical,
1322 EntryPointSource::PackageJsonMain,
1323 );
1324 assert!(result.is_some(), "should resolve an existing file");
1325 assert!(result.unwrap().path.ends_with("src/index.ts"));
1326 }
1327
1328 #[test]
1329 fn resolves_with_extension_fallback() {
1330 let dir = tempfile::tempdir().expect("create temp dir");
1331 let canonical = dunce::canonicalize(dir.path()).unwrap();
1333 let src = canonical.join("src");
1334 std::fs::create_dir_all(&src).unwrap();
1335 std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1336
1337 let result = resolve_entry_path(
1339 &canonical,
1340 "src/index",
1341 &canonical,
1342 EntryPointSource::PackageJsonMain,
1343 );
1344 assert!(
1345 result.is_some(),
1346 "should resolve via extension fallback when exact path doesn't exist"
1347 );
1348 let ep = result.unwrap();
1349 assert!(
1350 ep.path.to_string_lossy().contains("index.ts"),
1351 "should find index.ts via extension fallback"
1352 );
1353 }
1354
1355 #[test]
1356 fn returns_none_for_nonexistent_file() {
1357 let dir = tempfile::tempdir().expect("create temp dir");
1358 let canonical = dunce::canonicalize(dir.path()).unwrap();
1359 let result = resolve_entry_path(
1360 dir.path(),
1361 "does/not/exist.ts",
1362 &canonical,
1363 EntryPointSource::PackageJsonMain,
1364 );
1365 assert!(result.is_none(), "should return None for nonexistent files");
1366 }
1367
1368 #[test]
1369 fn maps_dist_output_to_src() {
1370 let dir = tempfile::tempdir().expect("create temp dir");
1371 let src = dir.path().join("src");
1372 std::fs::create_dir_all(&src).unwrap();
1373 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1374
1375 let dist = dir.path().join("dist");
1377 std::fs::create_dir_all(&dist).unwrap();
1378 std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1379
1380 let canonical = dunce::canonicalize(dir.path()).unwrap();
1381 let result = resolve_entry_path(
1382 dir.path(),
1383 "./dist/utils.js",
1384 &canonical,
1385 EntryPointSource::PackageJsonExports,
1386 );
1387 assert!(result.is_some(), "should resolve dist/ path to src/");
1388 let ep = result.unwrap();
1389 assert!(
1390 ep.path
1391 .to_string_lossy()
1392 .replace('\\', "/")
1393 .contains("src/utils.ts"),
1394 "should map ./dist/utils.js to src/utils.ts"
1395 );
1396 }
1397
1398 #[test]
1399 fn maps_build_output_to_src() {
1400 let dir = tempfile::tempdir().expect("create temp dir");
1401 let canonical = dunce::canonicalize(dir.path()).unwrap();
1403 let src = canonical.join("src");
1404 std::fs::create_dir_all(&src).unwrap();
1405 std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1406
1407 let result = resolve_entry_path(
1408 &canonical,
1409 "./build/index.js",
1410 &canonical,
1411 EntryPointSource::PackageJsonExports,
1412 );
1413 assert!(result.is_some(), "should map build/ output to src/");
1414 let ep = result.unwrap();
1415 assert!(
1416 ep.path
1417 .to_string_lossy()
1418 .replace('\\', "/")
1419 .contains("src/index.tsx"),
1420 "should map ./build/index.js to src/index.tsx"
1421 );
1422 }
1423
1424 #[test]
1425 fn preserves_entry_point_source() {
1426 let dir = tempfile::tempdir().expect("create temp dir");
1427 std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1428
1429 let canonical = dunce::canonicalize(dir.path()).unwrap();
1430 let result = resolve_entry_path(
1431 dir.path(),
1432 "index.ts",
1433 &canonical,
1434 EntryPointSource::PackageJsonScript,
1435 );
1436 assert!(result.is_some());
1437 assert!(
1438 matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1439 "should preserve the source kind"
1440 );
1441 }
1442 #[test]
1443 fn tracks_skipped_entries_without_logging_each_repeat() {
1444 let dir = tempfile::tempdir().expect("create temp dir");
1445 let canonical = dunce::canonicalize(dir.path()).unwrap();
1446 let mut skipped_entries = FxHashMap::default();
1447
1448 let result = resolve_entry_path_with_tracking(
1449 dir.path(),
1450 "../scripts/build.js",
1451 &canonical,
1452 EntryPointSource::PackageJsonScript,
1453 Some(&mut skipped_entries),
1454 );
1455
1456 assert!(result.is_none(), "unsafe entry should be skipped");
1457 assert_eq!(
1458 skipped_entries.get("../scripts/build.js"),
1459 Some(&1),
1460 "warning tracker should count the skipped path"
1461 );
1462 }
1463
1464 #[test]
1465 fn formats_skipped_entry_warning_with_counts() {
1466 let mut skipped_entries = FxHashMap::default();
1467 skipped_entries.insert("../../scripts/rm.mjs".to_owned(), 8);
1468 skipped_entries.insert("../utils/bar.js".to_owned(), 2);
1469
1470 let warning =
1471 format_skipped_entry_warning(&skipped_entries).expect("warning should be rendered");
1472
1473 assert_eq!(
1474 warning,
1475 "Skipped 10 package.json entry points outside project root or containing parent directory traversal: ../../scripts/rm.mjs (8x), ../utils/bar.js (2x)"
1476 );
1477 }
1478
1479 #[test]
1480 fn rejects_parent_dir_escape_for_exact_file() {
1481 let sandbox = tempfile::tempdir().expect("create sandbox");
1482 let root = sandbox.path().join("project");
1483 std::fs::create_dir_all(&root).unwrap();
1484 std::fs::write(
1485 sandbox.path().join("escape.ts"),
1486 "export const escape = true;",
1487 )
1488 .unwrap();
1489
1490 let canonical = dunce::canonicalize(&root).unwrap();
1491 let result = resolve_entry_path(
1492 &root,
1493 "../escape.ts",
1494 &canonical,
1495 EntryPointSource::PackageJsonMain,
1496 );
1497
1498 assert!(
1499 result.is_none(),
1500 "should reject exact paths that escape the root"
1501 );
1502 }
1503
1504 #[test]
1505 fn rejects_parent_dir_escape_via_extension_fallback() {
1506 let sandbox = tempfile::tempdir().expect("create sandbox");
1507 let root = sandbox.path().join("project");
1508 std::fs::create_dir_all(&root).unwrap();
1509 std::fs::write(
1510 sandbox.path().join("escape.ts"),
1511 "export const escape = true;",
1512 )
1513 .unwrap();
1514
1515 let canonical = dunce::canonicalize(&root).unwrap();
1516 let result = resolve_entry_path(
1517 &root,
1518 "../escape",
1519 &canonical,
1520 EntryPointSource::PackageJsonMain,
1521 );
1522
1523 assert!(
1524 result.is_none(),
1525 "should reject extension fallback paths that escape the root"
1526 );
1527 }
1528 }
1529
1530 mod output_to_source_tests {
1532 use super::*;
1533
1534 #[test]
1535 fn maps_dist_to_src_with_ts_extension() {
1536 let dir = tempfile::tempdir().expect("create temp dir");
1537 let src = dir.path().join("src");
1538 std::fs::create_dir_all(&src).unwrap();
1539 std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1540
1541 let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1542 assert!(result.is_some());
1543 assert!(
1544 result
1545 .unwrap()
1546 .to_string_lossy()
1547 .replace('\\', "/")
1548 .contains("src/utils.ts")
1549 );
1550 }
1551
1552 #[test]
1553 fn returns_none_when_no_source_file_exists() {
1554 let dir = tempfile::tempdir().expect("create temp dir");
1555 let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1557 assert!(result.is_none());
1558 }
1559
1560 #[test]
1561 fn ignores_non_output_directories() {
1562 let dir = tempfile::tempdir().expect("create temp dir");
1563 let src = dir.path().join("src");
1564 std::fs::create_dir_all(&src).unwrap();
1565 std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1566
1567 let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1569 assert!(result.is_none());
1570 }
1571
1572 #[test]
1573 fn maps_nested_output_path_preserving_prefix() {
1574 let dir = tempfile::tempdir().expect("create temp dir");
1575 let modules_src = dir.path().join("modules").join("src");
1576 std::fs::create_dir_all(&modules_src).unwrap();
1577 std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1578
1579 let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1580 assert!(result.is_some());
1581 assert!(
1582 result
1583 .unwrap()
1584 .to_string_lossy()
1585 .replace('\\', "/")
1586 .contains("modules/src/helper.ts")
1587 );
1588 }
1589 }
1590
1591 mod source_index_fallback_tests {
1593 use super::*;
1594
1595 #[test]
1596 fn detects_dist_entry_in_output_dir() {
1597 assert!(is_entry_in_output_dir("./dist/esm2022/index.js"));
1598 assert!(is_entry_in_output_dir("dist/index.js"));
1599 assert!(is_entry_in_output_dir("./build/index.js"));
1600 assert!(is_entry_in_output_dir("./out/main.js"));
1601 assert!(is_entry_in_output_dir("./esm/index.js"));
1602 assert!(is_entry_in_output_dir("./cjs/index.js"));
1603 }
1604
1605 #[test]
1606 fn rejects_non_output_entry_paths() {
1607 assert!(!is_entry_in_output_dir("./src/index.ts"));
1608 assert!(!is_entry_in_output_dir("src/main.ts"));
1609 assert!(!is_entry_in_output_dir("./index.js"));
1610 assert!(!is_entry_in_output_dir(""));
1611 }
1612
1613 #[test]
1614 fn rejects_substring_match_for_output_dir() {
1615 assert!(!is_entry_in_output_dir("./distro/index.js"));
1617 assert!(!is_entry_in_output_dir("./build-scripts/run.js"));
1618 }
1619
1620 #[test]
1621 fn finds_src_index_ts() {
1622 let dir = tempfile::tempdir().expect("create temp dir");
1623 let src = dir.path().join("src");
1624 std::fs::create_dir_all(&src).unwrap();
1625 let index_path = src.join("index.ts");
1626 std::fs::write(&index_path, "export const a = 1;").unwrap();
1627
1628 let result = try_source_index_fallback(dir.path());
1629 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1630 }
1631
1632 #[test]
1633 fn finds_src_index_tsx_when_ts_missing() {
1634 let dir = tempfile::tempdir().expect("create temp dir");
1635 let src = dir.path().join("src");
1636 std::fs::create_dir_all(&src).unwrap();
1637 let index_path = src.join("index.tsx");
1638 std::fs::write(&index_path, "export default 1;").unwrap();
1639
1640 let result = try_source_index_fallback(dir.path());
1641 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1642 }
1643
1644 #[test]
1645 fn prefers_src_index_over_root_index() {
1646 let dir = tempfile::tempdir().expect("create temp dir");
1649 let src = dir.path().join("src");
1650 std::fs::create_dir_all(&src).unwrap();
1651 let src_index = src.join("index.ts");
1652 std::fs::write(&src_index, "export const a = 1;").unwrap();
1653 let root_index = dir.path().join("index.ts");
1654 std::fs::write(&root_index, "export const b = 2;").unwrap();
1655
1656 let result = try_source_index_fallback(dir.path());
1657 assert_eq!(result.as_deref(), Some(src_index.as_path()));
1658 }
1659
1660 #[test]
1661 fn falls_back_to_src_main() {
1662 let dir = tempfile::tempdir().expect("create temp dir");
1663 let src = dir.path().join("src");
1664 std::fs::create_dir_all(&src).unwrap();
1665 let main_path = src.join("main.ts");
1666 std::fs::write(&main_path, "export const a = 1;").unwrap();
1667
1668 let result = try_source_index_fallback(dir.path());
1669 assert_eq!(result.as_deref(), Some(main_path.as_path()));
1670 }
1671
1672 #[test]
1673 fn falls_back_to_root_index_when_no_src() {
1674 let dir = tempfile::tempdir().expect("create temp dir");
1675 let index_path = dir.path().join("index.js");
1676 std::fs::write(&index_path, "module.exports = {};").unwrap();
1677
1678 let result = try_source_index_fallback(dir.path());
1679 assert_eq!(result.as_deref(), Some(index_path.as_path()));
1680 }
1681
1682 #[test]
1683 fn returns_none_when_nothing_matches() {
1684 let dir = tempfile::tempdir().expect("create temp dir");
1685 let result = try_source_index_fallback(dir.path());
1686 assert!(result.is_none());
1687 }
1688
1689 #[test]
1690 fn resolve_entry_path_falls_back_to_src_index_for_dist_entry() {
1691 let dir = tempfile::tempdir().expect("create temp dir");
1692 let canonical = dunce::canonicalize(dir.path()).unwrap();
1693
1694 let dist_dir = canonical.join("dist").join("esm2022");
1699 std::fs::create_dir_all(&dist_dir).unwrap();
1700 std::fs::write(dist_dir.join("index.js"), "export const x = 1;").unwrap();
1701
1702 let src = canonical.join("src");
1703 std::fs::create_dir_all(&src).unwrap();
1704 let src_index = src.join("index.ts");
1705 std::fs::write(&src_index, "export const x = 1;").unwrap();
1706
1707 let result = resolve_entry_path(
1708 &canonical,
1709 "./dist/esm2022/index.js",
1710 &canonical,
1711 EntryPointSource::PackageJsonMain,
1712 );
1713 assert!(result.is_some());
1714 let entry = result.unwrap();
1715 assert_eq!(entry.path, src_index);
1716 }
1717
1718 #[test]
1719 fn resolve_entry_path_uses_direct_src_mirror_when_available() {
1720 let dir = tempfile::tempdir().expect("create temp dir");
1723 let canonical = dunce::canonicalize(dir.path()).unwrap();
1724
1725 let src_mirror = canonical.join("src").join("esm2022");
1726 std::fs::create_dir_all(&src_mirror).unwrap();
1727 let mirror_index = src_mirror.join("index.ts");
1728 std::fs::write(&mirror_index, "export const x = 1;").unwrap();
1729
1730 let src_index = canonical.join("src").join("index.ts");
1732 std::fs::write(&src_index, "export const y = 2;").unwrap();
1733
1734 let result = resolve_entry_path(
1735 &canonical,
1736 "./dist/esm2022/index.js",
1737 &canonical,
1738 EntryPointSource::PackageJsonMain,
1739 );
1740 assert_eq!(result.map(|e| e.path), Some(mirror_index));
1741 }
1742 }
1743
1744 mod default_fallback_tests {
1746 use super::*;
1747
1748 #[test]
1749 fn finds_src_index_ts_as_fallback() {
1750 let dir = tempfile::tempdir().expect("create temp dir");
1751 let src = dir.path().join("src");
1752 std::fs::create_dir_all(&src).unwrap();
1753 let index_path = src.join("index.ts");
1754 std::fs::write(&index_path, "export const a = 1;").unwrap();
1755
1756 let files = vec![DiscoveredFile {
1757 id: FileId(0),
1758 path: index_path.clone(),
1759 size_bytes: 20,
1760 }];
1761
1762 let entries = apply_default_fallback(&files, dir.path(), None);
1763 assert_eq!(entries.len(), 1);
1764 assert_eq!(entries[0].path, index_path);
1765 assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1766 }
1767
1768 #[test]
1769 fn finds_root_index_js_as_fallback() {
1770 let dir = tempfile::tempdir().expect("create temp dir");
1771 let index_path = dir.path().join("index.js");
1772 std::fs::write(&index_path, "module.exports = {};").unwrap();
1773
1774 let files = vec![DiscoveredFile {
1775 id: FileId(0),
1776 path: index_path.clone(),
1777 size_bytes: 21,
1778 }];
1779
1780 let entries = apply_default_fallback(&files, dir.path(), None);
1781 assert_eq!(entries.len(), 1);
1782 assert_eq!(entries[0].path, index_path);
1783 }
1784
1785 #[test]
1786 fn returns_empty_when_no_index_file() {
1787 let dir = tempfile::tempdir().expect("create temp dir");
1788 let other_path = dir.path().join("src").join("utils.ts");
1789
1790 let files = vec![DiscoveredFile {
1791 id: FileId(0),
1792 path: other_path,
1793 size_bytes: 10,
1794 }];
1795
1796 let entries = apply_default_fallback(&files, dir.path(), None);
1797 assert!(
1798 entries.is_empty(),
1799 "non-index files should not match default fallback"
1800 );
1801 }
1802
1803 #[test]
1804 fn workspace_filter_restricts_scope() {
1805 let dir = tempfile::tempdir().expect("create temp dir");
1806 let ws_a = dir.path().join("packages").join("a").join("src");
1807 std::fs::create_dir_all(&ws_a).unwrap();
1808 let ws_b = dir.path().join("packages").join("b").join("src");
1809 std::fs::create_dir_all(&ws_b).unwrap();
1810
1811 let index_a = ws_a.join("index.ts");
1812 let index_b = ws_b.join("index.ts");
1813
1814 let files = vec![
1815 DiscoveredFile {
1816 id: FileId(0),
1817 path: index_a.clone(),
1818 size_bytes: 10,
1819 },
1820 DiscoveredFile {
1821 id: FileId(1),
1822 path: index_b,
1823 size_bytes: 10,
1824 },
1825 ];
1826
1827 let ws_root = dir.path().join("packages").join("a");
1829 let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1830 assert_eq!(entries.len(), 1);
1831 assert_eq!(entries[0].path, index_a);
1832 }
1833 }
1834
1835 mod wildcard_entry_tests {
1837 use super::*;
1838
1839 #[test]
1840 fn expands_wildcard_css_entries() {
1841 let dir = tempfile::tempdir().expect("create temp dir");
1844 let themes = dir.path().join("src").join("themes");
1845 std::fs::create_dir_all(&themes).unwrap();
1846 std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1847 std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1848
1849 let canonical = dunce::canonicalize(dir.path()).unwrap();
1850 let mut entries = Vec::new();
1851 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1852
1853 assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1854 let paths: Vec<String> = entries
1855 .iter()
1856 .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1857 .collect();
1858 assert!(paths.contains(&"dark.css".to_string()));
1859 assert!(paths.contains(&"light.css".to_string()));
1860 assert!(
1861 entries
1862 .iter()
1863 .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1864 );
1865 }
1866
1867 #[test]
1868 fn wildcard_does_not_match_nonexistent_files() {
1869 let dir = tempfile::tempdir().expect("create temp dir");
1870 std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1872
1873 let canonical = dunce::canonicalize(dir.path()).unwrap();
1874 let mut entries = Vec::new();
1875 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1876
1877 assert!(
1878 entries.is_empty(),
1879 "should return empty when no files match the wildcard"
1880 );
1881 }
1882
1883 #[test]
1884 fn wildcard_only_matches_specified_extension() {
1885 let dir = tempfile::tempdir().expect("create temp dir");
1887 let themes = dir.path().join("src").join("themes");
1888 std::fs::create_dir_all(&themes).unwrap();
1889 std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1890 std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1891
1892 let canonical = dunce::canonicalize(dir.path()).unwrap();
1893 let mut entries = Vec::new();
1894 expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1895
1896 assert_eq!(entries.len(), 1, "should only match CSS files");
1897 assert!(
1898 entries[0]
1899 .path
1900 .file_name()
1901 .unwrap()
1902 .to_string_lossy()
1903 .ends_with(".css")
1904 );
1905 }
1906 }
1907}