1use std::path::{Component, Path, PathBuf};
4
5use fallow_config::{PackageJson, ResolvedConfig, WorkspaceInfo};
6use fallow_types::discover::FileId;
7use rustc_hash::{FxHashMap, FxHashSet};
8
9use fallow_graph::resolve::OUTPUT_DIRS;
10
11use crate::{
12 discover::{EntryPoint, EntryPointSource, SOURCE_EXTENSIONS},
13 module_graph::RetainedModuleGraph,
14};
15
16#[must_use]
18pub fn public_api_package_entry_points(
19 graph: &RetainedModuleGraph,
20 config: &ResolvedConfig,
21 root_pkg: Option<&PackageJson>,
22 workspaces: &[WorkspaceInfo],
23) -> FxHashSet<FileId> {
24 let graph = graph.as_graph();
25 let mut public_api_entry_points = FxHashSet::default();
26 let path_to_file_id = graph_path_to_file_id(graph);
27 let canonical_project_root =
28 dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
29
30 add_root_public_api_entry_points(
31 &mut public_api_entry_points,
32 graph,
33 &path_to_file_id,
34 config,
35 root_pkg,
36 &canonical_project_root,
37 );
38 add_workspace_public_api_entry_points(
39 &mut public_api_entry_points,
40 graph,
41 &path_to_file_id,
42 workspaces,
43 &config.public_packages,
44 &canonical_project_root,
45 );
46
47 public_api_entry_points
48}
49
50#[must_use]
52pub fn public_export_keys_for_graph(
53 graph: &RetainedModuleGraph,
54 config: &ResolvedConfig,
55 workspaces: &[WorkspaceInfo],
56 root: &Path,
57) -> FxHashSet<String> {
58 let root_pkg = fallow_config::load_dir_package_json(&config.root);
59 let public_entries =
60 public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
61 graph.public_export_keys(&public_entries, root)
62}
63
64#[must_use]
67pub fn public_api_entry_paths_for_graph(
68 graph: &RetainedModuleGraph,
69 config: &ResolvedConfig,
70 workspaces: &[WorkspaceInfo],
71) -> Vec<PathBuf> {
72 let root_pkg = fallow_config::load_dir_package_json(&config.root);
73 let public_entries =
74 public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
75 let mut paths = public_entries
76 .into_iter()
77 .filter_map(|file_id| {
78 graph
79 .as_graph()
80 .modules
81 .get(file_id.0 as usize)
82 .map(|module| module.path.clone())
83 })
84 .collect::<Vec<_>>();
85 paths.sort();
86 paths.dedup();
87 paths
88}
89
90fn graph_path_to_file_id(graph: &fallow_graph::graph::ModuleGraph) -> FxHashMap<PathBuf, FileId> {
91 graph
92 .modules
93 .iter()
94 .map(|module| (module.path.clone(), module.file_id))
95 .collect()
96}
97
98fn add_root_public_api_entry_points(
99 public_api_entry_points: &mut FxHashSet<FileId>,
100 graph: &fallow_graph::graph::ModuleGraph,
101 path_to_file_id: &FxHashMap<PathBuf, FileId>,
102 config: &ResolvedConfig,
103 root_pkg: Option<&PackageJson>,
104 canonical_project_root: &Path,
105) {
106 if let Some(pkg) = root_pkg {
107 add_package_public_api_entry_points(
108 public_api_entry_points,
109 graph,
110 path_to_file_id,
111 &config.root,
112 pkg,
113 canonical_project_root,
114 );
115 add_exportless_package_source_indexes(public_api_entry_points, graph, &config.root, pkg);
116 }
117}
118
119fn add_workspace_public_api_entry_points(
120 public_api_entry_points: &mut FxHashSet<FileId>,
121 graph: &fallow_graph::graph::ModuleGraph,
122 path_to_file_id: &FxHashMap<PathBuf, FileId>,
123 workspaces: &[WorkspaceInfo],
124 public_packages: &[String],
125 canonical_project_root: &Path,
126) {
127 for workspace in workspaces
128 .iter()
129 .filter(|workspace| fallow_config::workspace_is_public(&workspace.name, public_packages))
130 {
131 let Some(pkg) = fallow_config::load_dir_package_json(&workspace.root) else {
132 continue;
133 };
134 add_package_public_api_entry_points(
135 public_api_entry_points,
136 graph,
137 path_to_file_id,
138 &workspace.root,
139 &pkg,
140 canonical_project_root,
141 );
142 add_exportless_package_source_indexes(
143 public_api_entry_points,
144 graph,
145 &workspace.root,
146 &pkg,
147 );
148 }
149}
150
151fn add_package_public_api_entry_points(
152 public_api_entry_points: &mut FxHashSet<FileId>,
153 graph: &fallow_graph::graph::ModuleGraph,
154 path_to_file_id: &FxHashMap<PathBuf, FileId>,
155 package_root: &Path,
156 package_json: &PackageJson,
157 canonical_project_root: &Path,
158) {
159 if package_json.private.unwrap_or(false) {
160 return;
161 }
162
163 for entry in package_json.entry_points() {
164 let Some(entry_point) = resolve_public_api_entry_path(
165 package_root,
166 &entry,
167 canonical_project_root,
168 EntryPointSource::PackageJsonExports,
169 ) else {
170 continue;
171 };
172
173 if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
174 resolve_entry_via_canonical(graph, path_to_file_id, package_root, &entry_point.path)
175 }) {
176 public_api_entry_points.insert(file_id);
177 }
178 }
179}
180
181fn resolve_public_api_entry_path(
182 base: &Path,
183 entry: &str,
184 canonical_root: &Path,
185 source: EntryPointSource,
186) -> Option<EntryPoint> {
187 if entry.contains('*') || entry_has_parent_dir(entry) {
188 return None;
189 }
190
191 if let Some(source_path) = try_output_to_source_path(base, entry) {
192 return validated_entry_point(&source_path, canonical_root, source);
193 }
194
195 if is_entry_in_output_dir(entry)
196 && let Some(source_path) = try_source_index_fallback(base)
197 {
198 return validated_entry_point(&source_path, canonical_root, source);
199 }
200
201 resolve_entry_via_filesystem_probe(base, entry, canonical_root, source)
202}
203
204fn resolve_entry_via_filesystem_probe(
205 base: &Path,
206 entry: &str,
207 canonical_root: &Path,
208 source: EntryPointSource,
209) -> Option<EntryPoint> {
210 let resolved = base.join(entry);
211
212 if resolved.is_file() {
213 return validated_entry_point(&resolved, canonical_root, source);
214 }
215
216 for ext in SOURCE_EXTENSIONS {
217 let with_ext = resolved.with_extension(ext);
218 if with_ext.is_file() {
219 return validated_entry_point(&with_ext, canonical_root, source);
220 }
221 }
222
223 if let Some(index_entry) = try_directory_index_entry(&resolved) {
224 return validated_entry_point(&index_entry, canonical_root, source);
225 }
226
227 if is_package_root_index_entry(entry)
228 && let Some(source_path) = try_source_index_fallback(base)
229 {
230 return validated_entry_point(&source_path, canonical_root, source);
231 }
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 validated_entry_point(
243 candidate: &Path,
244 canonical_root: &Path,
245 source: EntryPointSource,
246) -> Option<EntryPoint> {
247 let canonical_candidate = dunce::canonicalize(candidate).ok()?;
248 canonical_candidate
249 .starts_with(canonical_root)
250 .then(|| EntryPoint {
251 path: candidate.to_path_buf(),
252 source,
253 })
254}
255
256fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
257 for ext in SOURCE_EXTENSIONS {
258 let candidate = resolved.join(format!("index.{ext}"));
259 if candidate.is_file() {
260 return Some(candidate);
261 }
262 }
263 None
264}
265
266fn is_package_root_index_entry(entry: &str) -> bool {
267 let mut components = Path::new(entry)
268 .components()
269 .filter(|component| !matches!(component, Component::CurDir));
270
271 let Some(Component::Normal(file_name)) = components.next() else {
272 return false;
273 };
274 if components.next().is_some() {
275 return false;
276 }
277
278 file_name
279 .to_str()
280 .is_some_and(|name| name == "index" || name.starts_with("index."))
281}
282
283fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
284 let entry_path = Path::new(entry);
285 let components: Vec<_> = entry_path.components().collect();
286
287 let output_pos = components.iter().rposition(|component| {
288 if let Component::Normal(name) = component
289 && let Some(name) = name.to_str()
290 {
291 return OUTPUT_DIRS.contains(&name);
292 }
293 false
294 })?;
295
296 let prefix: PathBuf = components[..output_pos]
297 .iter()
298 .filter(|component| !matches!(component, Component::CurDir))
299 .collect();
300 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
301
302 for ext in SOURCE_EXTENSIONS {
303 let source_candidate = base
304 .join(&prefix)
305 .join("src")
306 .join(suffix.with_extension(ext));
307 if source_candidate.exists() {
308 return Some(source_candidate);
309 }
310 }
311
312 None
313}
314
315fn is_entry_in_output_dir(entry: &str) -> bool {
316 Path::new(entry).components().any(|component| {
317 if let Component::Normal(name) = component
318 && let Some(name) = name.to_str()
319 {
320 return OUTPUT_DIRS.contains(&name);
321 }
322 false
323 })
324}
325
326fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
327 for ext in SOURCE_EXTENSIONS {
328 let candidate = base.join("src").join(format!("index.{ext}"));
329 if candidate.is_file() {
330 return Some(candidate);
331 }
332 }
333 None
334}
335
336fn resolve_entry_via_canonical(
337 graph: &fallow_graph::graph::ModuleGraph,
338 path_to_file_id: &FxHashMap<PathBuf, FileId>,
339 package_root: &Path,
340 entry_path: &Path,
341) -> Option<FileId> {
342 dunce::canonicalize(entry_path).ok().and_then(|canonical| {
343 path_to_file_id
344 .get(&canonical)
345 .copied()
346 .or_else(|| resolve_entry_via_scoped_canonical(graph, package_root, &canonical))
347 })
348}
349
350fn resolve_entry_via_scoped_canonical(
351 graph: &fallow_graph::graph::ModuleGraph,
352 package_root: &Path,
353 canonical_entry: &Path,
354) -> Option<FileId> {
355 graph
356 .modules
357 .iter()
358 .filter(|module| module.path.starts_with(package_root))
359 .find_map(|module| {
360 (dunce::canonicalize(&module.path).ok().as_deref() == Some(canonical_entry))
361 .then_some(module.file_id)
362 })
363}
364
365fn add_exportless_package_source_indexes(
366 public_api_entry_points: &mut FxHashSet<FileId>,
367 graph: &fallow_graph::graph::ModuleGraph,
368 package_root: &Path,
369 package_json: &PackageJson,
370) {
371 if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
372 return;
373 }
374
375 let mut roots = vec![package_root.to_path_buf()];
376 if let Ok(canonical) = dunce::canonicalize(package_root) {
377 roots.push(canonical);
378 }
379
380 for module in &graph.modules {
381 if roots
382 .iter()
383 .any(|root| is_source_index_under_package(&module.path, root))
384 {
385 public_api_entry_points.insert(module.file_id);
386 }
387 }
388}
389
390fn is_source_index_under_package(path: &Path, package_root: &Path) -> bool {
391 let Ok(relative) = path.strip_prefix(package_root) else {
392 return false;
393 };
394
395 if !matches!(
396 relative.components().next(),
397 Some(std::path::Component::Normal(segment)) if segment == "src"
398 ) {
399 return false;
400 }
401
402 path.file_stem()
403 .and_then(|stem| stem.to_str())
404 .is_some_and(|stem| stem == "index")
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::session::AnalysisSession;
411
412 fn fixture_root() -> PathBuf {
413 PathBuf::from(env!("CARGO_MANIFEST_DIR"))
414 .join("../../tests/fixtures/public-package-members")
415 }
416
417 fn public_entry_paths(session: &AnalysisSession) -> Vec<PathBuf> {
418 let artifacts = session
419 .analyze_dead_code_with_artifacts(false, true)
420 .expect("analysis succeeds");
421 let graph = artifacts.graph.expect("retained graph");
422 public_api_entry_paths_for_graph(&graph, session.config(), session.workspaces())
423 }
424
425 #[test]
426 fn workspace_public_entries_require_public_packages_selection() {
427 let root = fixture_root();
428 let unselected = AnalysisSession::load_with_config(&root, None, |config| {
429 config.public_packages.clear();
430 })
431 .expect("unselected session loads");
432
433 assert!(public_entry_paths(&unselected).is_empty());
434
435 let selected = AnalysisSession::load_with_config(&root, None, |config| {
436 config.public_packages = vec!["@workspace/public-lib".to_string()];
437 })
438 .expect("selected session loads");
439 let selected_paths = public_entry_paths(&selected);
440
441 assert_eq!(selected_paths.len(), 1);
442 assert!(selected_paths[0].ends_with("packages/public-lib/src/index.ts"));
443 }
444}