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