1use std::path::{Component, Path, PathBuf};
4
5use fallow_config::{
6 PackageJson, ResolvedConfig, TsconfigOutputMap, TsconfigOutputResolution, WorkspaceInfo,
7};
8use fallow_types::discover::FileId;
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use fallow_graph::resolve::OUTPUT_DIRS;
12
13use crate::{
14 discover::{EntryPoint, EntryPointSource, SOURCE_EXTENSIONS},
15 module_graph::RetainedModuleGraph,
16};
17
18#[must_use]
20pub fn public_api_package_entry_points(
21 graph: &RetainedModuleGraph,
22 config: &ResolvedConfig,
23 root_pkg: Option<&PackageJson>,
24 workspaces: &[WorkspaceInfo],
25) -> FxHashSet<FileId> {
26 let graph = graph.as_graph();
27 let mut public_api_entry_points = FxHashSet::default();
28 let path_to_file_id = graph_path_to_file_id(graph);
29 let canonical_project_root =
30 dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
31
32 add_root_public_api_entry_points(
33 &mut public_api_entry_points,
34 graph,
35 &path_to_file_id,
36 config,
37 root_pkg,
38 &canonical_project_root,
39 );
40 add_workspace_public_api_entry_points(
41 &mut public_api_entry_points,
42 graph,
43 &path_to_file_id,
44 workspaces,
45 &config.public_packages,
46 &canonical_project_root,
47 );
48
49 public_api_entry_points
50}
51
52#[must_use]
54pub fn public_export_keys_for_graph(
55 graph: &RetainedModuleGraph,
56 config: &ResolvedConfig,
57 workspaces: &[WorkspaceInfo],
58 root: &Path,
59) -> FxHashSet<String> {
60 let root_pkg = fallow_config::load_dir_package_json(&config.root);
61 let public_entries =
62 public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
63 graph.public_export_keys(&public_entries, root)
64}
65
66#[must_use]
69pub fn public_api_entry_paths_for_graph(
70 graph: &RetainedModuleGraph,
71 config: &ResolvedConfig,
72 workspaces: &[WorkspaceInfo],
73) -> Vec<PathBuf> {
74 let root_pkg = fallow_config::load_dir_package_json(&config.root);
75 let public_entries =
76 public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
77 let mut paths = public_entries
78 .into_iter()
79 .filter_map(|file_id| {
80 graph
81 .as_graph()
82 .modules
83 .get(file_id.0 as usize)
84 .map(|module| module.path.clone())
85 })
86 .collect::<Vec<_>>();
87 paths.sort();
88 paths.dedup();
89 paths
90}
91
92fn graph_path_to_file_id(graph: &fallow_graph::graph::ModuleGraph) -> FxHashMap<PathBuf, FileId> {
93 graph
94 .modules
95 .iter()
96 .map(|module| (module.path.clone(), module.file_id))
97 .collect()
98}
99
100fn add_root_public_api_entry_points(
101 public_api_entry_points: &mut FxHashSet<FileId>,
102 graph: &fallow_graph::graph::ModuleGraph,
103 path_to_file_id: &FxHashMap<PathBuf, FileId>,
104 config: &ResolvedConfig,
105 root_pkg: Option<&PackageJson>,
106 canonical_project_root: &Path,
107) {
108 if let Some(pkg) = root_pkg {
109 add_package_public_api_entry_points(
110 public_api_entry_points,
111 graph,
112 path_to_file_id,
113 &config.root,
114 pkg,
115 canonical_project_root,
116 );
117 add_exportless_package_source_indexes(public_api_entry_points, graph, &config.root, pkg);
118 }
119}
120
121fn add_workspace_public_api_entry_points(
122 public_api_entry_points: &mut FxHashSet<FileId>,
123 graph: &fallow_graph::graph::ModuleGraph,
124 path_to_file_id: &FxHashMap<PathBuf, FileId>,
125 workspaces: &[WorkspaceInfo],
126 public_packages: &[String],
127 canonical_project_root: &Path,
128) {
129 for workspace in workspaces
130 .iter()
131 .filter(|workspace| fallow_config::workspace_is_public(&workspace.name, public_packages))
132 {
133 let Some(pkg) = fallow_config::load_dir_package_json(&workspace.root) else {
134 continue;
135 };
136 add_package_public_api_entry_points(
137 public_api_entry_points,
138 graph,
139 path_to_file_id,
140 &workspace.root,
141 &pkg,
142 canonical_project_root,
143 );
144 add_exportless_package_source_indexes(
145 public_api_entry_points,
146 graph,
147 &workspace.root,
148 &pkg,
149 );
150 }
151}
152
153fn add_package_public_api_entry_points(
154 public_api_entry_points: &mut FxHashSet<FileId>,
155 graph: &fallow_graph::graph::ModuleGraph,
156 path_to_file_id: &FxHashMap<PathBuf, FileId>,
157 package_root: &Path,
158 package_json: &PackageJson,
159 canonical_project_root: &Path,
160) {
161 if package_json.private.unwrap_or(false) {
162 return;
163 }
164
165 let output_map = TsconfigOutputMap::from_project(package_root);
166 for entry in package_json.entry_points() {
167 let Some(entry_point) = resolve_public_api_entry_path(
168 package_root,
169 &entry,
170 canonical_project_root,
171 EntryPointSource::PackageJsonExports,
172 &output_map,
173 ) else {
174 continue;
175 };
176
177 if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
178 resolve_entry_via_canonical(graph, path_to_file_id, package_root, &entry_point.path)
179 }) {
180 public_api_entry_points.insert(file_id);
181 }
182 }
183}
184
185fn resolve_public_api_entry_path(
186 base: &Path,
187 entry: &str,
188 canonical_root: &Path,
189 source: EntryPointSource,
190 output_map: &TsconfigOutputMap,
191) -> Option<EntryPoint> {
192 if entry.contains('*') || entry_has_parent_dir(entry) {
193 return None;
194 }
195
196 match output_map.resolve_source_for_entry(entry, SOURCE_EXTENSIONS) {
197 TsconfigOutputResolution::Resolved(source_path) => {
198 return validated_entry_point(&source_path, canonical_root, source);
199 }
200 TsconfigOutputResolution::ConfiguredButUnresolved => {}
201 TsconfigOutputResolution::Unconfigured => {
202 if let Some(source_path) = try_legacy_output_to_source_path(base, entry) {
203 return validated_entry_point(&source_path, canonical_root, source);
204 }
205
206 if is_entry_in_output_dir(entry)
207 && let Some(source_path) = try_source_index_fallback(base)
208 {
209 return validated_entry_point(&source_path, canonical_root, source);
210 }
211 }
212 }
213
214 resolve_entry_via_filesystem_probe(base, entry, canonical_root, source)
215}
216
217fn resolve_entry_via_filesystem_probe(
218 base: &Path,
219 entry: &str,
220 canonical_root: &Path,
221 source: EntryPointSource,
222) -> Option<EntryPoint> {
223 let resolved = base.join(entry);
224
225 if resolved.is_file() {
226 return validated_entry_point(&resolved, canonical_root, source);
227 }
228
229 for ext in SOURCE_EXTENSIONS {
230 let with_ext = resolved.with_extension(ext);
231 if with_ext.is_file() {
232 return validated_entry_point(&with_ext, canonical_root, source);
233 }
234 }
235
236 if let Some(index_entry) = try_directory_index_entry(&resolved) {
237 return validated_entry_point(&index_entry, canonical_root, source);
238 }
239
240 if is_package_root_index_entry(entry)
241 && let Some(source_path) = try_source_index_fallback(base)
242 {
243 return validated_entry_point(&source_path, canonical_root, source);
244 }
245
246 None
247}
248
249fn entry_has_parent_dir(entry: &str) -> bool {
250 Path::new(entry)
251 .components()
252 .any(|component| matches!(component, Component::ParentDir))
253}
254
255fn validated_entry_point(
256 candidate: &Path,
257 canonical_root: &Path,
258 source: EntryPointSource,
259) -> Option<EntryPoint> {
260 let canonical_candidate = dunce::canonicalize(candidate).ok()?;
261 canonical_candidate
262 .starts_with(canonical_root)
263 .then(|| EntryPoint {
264 path: candidate.to_path_buf(),
265 source,
266 })
267}
268
269fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
270 for ext in SOURCE_EXTENSIONS {
271 let candidate = resolved.join(format!("index.{ext}"));
272 if candidate.is_file() {
273 return Some(candidate);
274 }
275 }
276 None
277}
278
279fn is_package_root_index_entry(entry: &str) -> bool {
280 let mut components = Path::new(entry)
281 .components()
282 .filter(|component| !matches!(component, Component::CurDir));
283
284 let Some(Component::Normal(file_name)) = components.next() else {
285 return false;
286 };
287 if components.next().is_some() {
288 return false;
289 }
290
291 file_name
292 .to_str()
293 .is_some_and(|name| name == "index" || name.starts_with("index."))
294}
295
296fn try_legacy_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
297 let entry_path = Path::new(entry);
298 let components: Vec<_> = entry_path.components().collect();
299
300 let output_pos = components.iter().rposition(|component| {
301 if let Component::Normal(name) = component
302 && let Some(name) = name.to_str()
303 {
304 return OUTPUT_DIRS.contains(&name);
305 }
306 false
307 })?;
308
309 let prefix: PathBuf = components[..output_pos]
310 .iter()
311 .filter(|component| !matches!(component, Component::CurDir))
312 .collect();
313 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
314
315 for ext in SOURCE_EXTENSIONS {
316 let source_candidate = base
317 .join(&prefix)
318 .join("src")
319 .join(suffix.with_extension(ext));
320 if source_candidate.exists() {
321 return Some(source_candidate);
322 }
323 }
324
325 None
326}
327
328fn is_entry_in_output_dir(entry: &str) -> bool {
329 Path::new(entry).components().any(|component| {
330 if let Component::Normal(name) = component
331 && let Some(name) = name.to_str()
332 {
333 return OUTPUT_DIRS.contains(&name);
334 }
335 false
336 })
337}
338
339fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
340 for ext in SOURCE_EXTENSIONS {
341 let candidate = base.join("src").join(format!("index.{ext}"));
342 if candidate.is_file() {
343 return Some(candidate);
344 }
345 }
346 None
347}
348
349fn resolve_entry_via_canonical(
350 graph: &fallow_graph::graph::ModuleGraph,
351 path_to_file_id: &FxHashMap<PathBuf, FileId>,
352 package_root: &Path,
353 entry_path: &Path,
354) -> Option<FileId> {
355 dunce::canonicalize(entry_path).ok().and_then(|canonical| {
356 path_to_file_id
357 .get(&canonical)
358 .copied()
359 .or_else(|| resolve_entry_via_scoped_canonical(graph, package_root, &canonical))
360 })
361}
362
363fn resolve_entry_via_scoped_canonical(
364 graph: &fallow_graph::graph::ModuleGraph,
365 package_root: &Path,
366 canonical_entry: &Path,
367) -> Option<FileId> {
368 graph
369 .modules
370 .iter()
371 .filter(|module| module.path.starts_with(package_root))
372 .find_map(|module| {
373 (dunce::canonicalize(&module.path).ok().as_deref() == Some(canonical_entry))
374 .then_some(module.file_id)
375 })
376}
377
378fn add_exportless_package_source_indexes(
379 public_api_entry_points: &mut FxHashSet<FileId>,
380 graph: &fallow_graph::graph::ModuleGraph,
381 package_root: &Path,
382 package_json: &PackageJson,
383) {
384 if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
385 return;
386 }
387
388 let mut roots = vec![package_root.to_path_buf()];
389 if let Ok(canonical) = dunce::canonicalize(package_root) {
390 roots.push(canonical);
391 }
392
393 for module in &graph.modules {
394 if roots
395 .iter()
396 .any(|root| is_source_index_under_package(&module.path, root))
397 {
398 public_api_entry_points.insert(module.file_id);
399 }
400 }
401}
402
403fn is_source_index_under_package(path: &Path, package_root: &Path) -> bool {
404 let Ok(relative) = path.strip_prefix(package_root) else {
405 return false;
406 };
407
408 if !matches!(
409 relative.components().next(),
410 Some(std::path::Component::Normal(segment)) if segment == "src"
411 ) {
412 return false;
413 }
414
415 path.file_stem()
416 .and_then(|stem| stem.to_str())
417 .is_some_and(|stem| stem == "index")
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use crate::session::AnalysisSession;
424
425 fn fixture_root() -> PathBuf {
426 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
427 .join("../../tests/fixtures/public-package-members")
428 }
429
430 fn public_entry_paths(session: &AnalysisSession) -> Vec<PathBuf> {
431 let artifacts = session
432 .analyze_dead_code_with_artifacts(false, true)
433 .expect("analysis succeeds");
434 let graph = artifacts.graph.expect("retained graph");
435 public_api_entry_paths_for_graph(&graph, session.config(), session.workspaces())
436 }
437
438 #[test]
439 fn workspace_public_entries_require_public_packages_selection() {
440 let root = fixture_root();
441 let unselected = AnalysisSession::load_with_config(&root, None, |config| {
442 config.public_packages.clear();
443 })
444 .expect("unselected session loads");
445
446 assert!(public_entry_paths(&unselected).is_empty());
447
448 let selected = AnalysisSession::load_with_config(&root, None, |config| {
449 config.public_packages = vec!["@workspace/public-lib".to_string()];
450 })
451 .expect("selected session loads");
452 let selected_paths = public_entry_paths(&selected);
453
454 assert_eq!(selected_paths.len(), 1);
455 assert!(selected_paths[0].ends_with("packages/public-lib/src/index.ts"));
456 }
457
458 #[test]
459 fn package_output_entries_resolve_through_tsconfig_with_outputs_present_or_absent() {
460 let directory = tempfile::tempdir().expect("temporary project directory");
461 let root = directory.path();
462 std::fs::create_dir_all(root.join("source")).expect("source directory");
463 std::fs::create_dir_all(root.join("distribution")).expect("output directory");
464 std::fs::write(
465 root.join("package.json"),
466 r#"{
467 "name": "configured-output-package",
468 "exports": {
469 ".": {
470 "types": "./distribution/index.d.ts",
471 "import": "./distribution/index.js"
472 }
473 }
474 }"#,
475 )
476 .expect("package manifest");
477 std::fs::write(
478 root.join("tsconfig.build.json"),
479 r#"{
480 "compilerOptions": {
481 "rootDir": "./source",
482 "outDir": "./distribution"
483 },
484 "include": ["source"]
485 }"#,
486 )
487 .expect("TypeScript config");
488 std::fs::write(
489 root.join("source/index.ts"),
490 "export const publicValue = 1;\n",
491 )
492 .expect("source entry");
493 std::fs::write(
494 root.join("source/internal.ts"),
495 "export const internalValue = 1;\n",
496 )
497 .expect("unrelated source file");
498 std::fs::write(
499 root.join("distribution/index.js"),
500 "export const publicValue = 1;\n",
501 )
502 .expect("generated JavaScript entry");
503 std::fs::write(
504 root.join("distribution/index.d.ts"),
505 "export declare const publicValue: 1;\n",
506 )
507 .expect("generated declaration entry");
508
509 let with_outputs = AnalysisSession::load_with_config(root, None, |_| {})
510 .expect("project with outputs loads");
511 let expected = root.join("source/index.ts");
512 assert_eq!(
513 public_entry_paths(&with_outputs),
514 vec![expected.clone()],
515 "configured output entries should resolve to their source entry, without exposing unrelated source files"
516 );
517
518 std::fs::remove_dir_all(root.join("distribution")).expect("remove generated outputs");
519 let without_outputs = AnalysisSession::load_with_config(root, None, |_| {})
520 .expect("project without outputs loads");
521 assert_eq!(
522 public_entry_paths(&without_outputs),
523 vec![expected],
524 "configured output entries should still resolve when build artifacts are absent"
525 );
526 }
527
528 #[test]
529 fn out_dir_without_root_dir_resolves_public_subpaths_and_bin() {
530 let directory = tempfile::tempdir().expect("temporary project directory");
531 let root = directory.path();
532 std::fs::create_dir_all(root.join("src")).expect("source directory");
533 std::fs::write(
534 root.join("package.json"),
535 r#"{
536 "name": "out-dir-only-package",
537 "main": "./dist/index.js",
538 "bin": "./dist/cli.js",
539 "exports": {
540 ".": "./dist/index.js",
541 "./browser": "./dist/browser.js"
542 }
543 }"#,
544 )
545 .expect("package manifest");
546 std::fs::write(
547 root.join("tsconfig.json"),
548 r#"{"compilerOptions":{"outDir":"./dist"},"include":["src"]}"#,
549 )
550 .expect("TypeScript config");
551 for name in ["index.ts", "browser.ts", "cli.ts", "internal.ts"] {
552 std::fs::write(
553 root.join("src").join(name),
554 format!("export const {} = 1;\n", name.trim_end_matches(".ts")),
555 )
556 .expect("source module");
557 }
558
559 let session = AnalysisSession::load_with_config(root, None, |_| {})
560 .expect("outDir-only package loads");
561 let entries = public_entry_paths(&session);
562 for entry in ["src/index.ts", "src/browser.ts", "src/cli.ts"] {
563 assert!(
564 entries.iter().any(|path| path.ends_with(entry)),
565 "public output entry {entry} should map to its source, public entries: {entries:?}"
566 );
567 }
568 assert!(
569 !entries.iter().any(|path| path.ends_with("src/internal.ts")),
570 "mapping public outputs must not expose unrelated source files, public entries: {entries:?}"
571 );
572 }
573
574 #[test]
575 fn declaration_dir_without_out_dir_resolves_package_types_entries() {
576 for with_output in [true, false] {
577 let directory = tempfile::tempdir().expect("temporary project directory");
578 let root = directory.path();
579 std::fs::create_dir_all(root.join("source")).expect("source directory");
580 std::fs::write(
581 root.join("package.json"),
582 r#"{
583 "name":"declaration-only-package",
584 "types":"./types/index.d.ts",
585 "exports":{".":{"types":"./types/index.d.ts"}}
586 }"#,
587 )
588 .expect("package manifest");
589 std::fs::write(
590 root.join("tsconfig.build.json"),
591 r#"{
592 "compilerOptions": {
593 "rootDir":"./source",
594 "declarationDir":"./types"
595 }
596 }"#,
597 )
598 .expect("TypeScript config");
599 std::fs::write(
600 root.join("source/index.ts"),
601 "export const publicValue = 1;\n",
602 )
603 .expect("source entry");
604 std::fs::write(
605 root.join("source/internal.ts"),
606 "export const internalValue = 1;\n",
607 )
608 .expect("unrelated source file");
609 if with_output {
610 std::fs::create_dir_all(root.join("types")).expect("types directory");
611 std::fs::write(
612 root.join("types/index.d.ts"),
613 "export declare const publicValue: 1;\n",
614 )
615 .expect("generated declaration entry");
616 }
617
618 let session = AnalysisSession::load_with_config(root, None, |_| {})
619 .expect("declaration-only project loads");
620 assert_eq!(
621 public_entry_paths(&session),
622 vec![root.join("source/index.ts")],
623 "declarationDir should resolve with output present={with_output}"
624 );
625 }
626 }
627
628 #[test]
629 fn ambiguous_configured_output_does_not_use_legacy_public_entry_guess() {
630 for with_output in [true, false] {
631 let directory = tempfile::tempdir().expect("temporary project directory");
632 let root = directory.path();
633 for source_dir in ["source", "src"] {
634 std::fs::create_dir_all(root.join(source_dir)).expect("source directory");
635 std::fs::write(
636 root.join(source_dir).join("index.ts"),
637 "export const value = 1;\n",
638 )
639 .expect("source entry");
640 }
641 std::fs::write(
642 root.join("package.json"),
643 r#"{"name":"ambiguous-output-package","exports":{".":"./dist/index.js","./runtime":"./runtime.ts"}}"#,
644 )
645 .expect("package manifest");
646 std::fs::write(root.join("runtime.ts"), "export const runtime = 1;\n")
647 .expect("independent runtime export");
648 for (config_name, source_dir) in [
649 ("tsconfig.source.json", "source"),
650 ("tsconfig.src.json", "src"),
651 ] {
652 std::fs::write(
653 root.join(config_name),
654 format!(
655 r#"{{"compilerOptions":{{"rootDir":"./{source_dir}","outDir":"./dist"}}}}"#
656 ),
657 )
658 .expect("TypeScript config");
659 }
660 if with_output {
661 std::fs::create_dir_all(root.join("dist")).expect("output directory");
662 std::fs::write(root.join("dist/index.js"), "export const value = 1;\n")
663 .expect("generated output");
664 }
665
666 let session = AnalysisSession::load_with_config(root, None, |_| {})
667 .expect("ambiguous project loads");
668 let entries = public_entry_paths(&session);
669 assert!(
670 !entries.iter().any(|path| path.ends_with("src/index.ts")),
671 "an ambiguous configured map must not expose the legacy src guess with output present={with_output}, entries: {entries:?}"
672 );
673 assert!(
674 !entries.iter().any(|path| path.ends_with("source/index.ts")),
675 "an ambiguous configured map must not expose either source candidate, entries: {entries:?}"
676 );
677 }
678
679 let legacy = tempfile::tempdir().expect("legacy project directory");
680 std::fs::create_dir_all(legacy.path().join("src")).expect("legacy source directory");
681 std::fs::write(
682 legacy.path().join("package.json"),
683 r#"{"name":"legacy-output-package","main":"./dist/index.js"}"#,
684 )
685 .expect("package manifest");
686 std::fs::write(
687 legacy.path().join("src/index.ts"),
688 "export const value = 1;\n",
689 )
690 .expect("legacy source entry");
691 let session = AnalysisSession::load_with_config(legacy.path(), None, |_| {})
692 .expect("legacy project loads");
693 assert!(
694 public_entry_paths(&session)
695 .iter()
696 .any(|path| path.ends_with("src/index.ts")),
697 "the legacy dist/src convention should still resolve without a matching config"
698 );
699 }
700}