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 crate::{
10 discover::{EntryPoint, EntryPointSource, SOURCE_EXTENSIONS},
11 module_graph::RetainedModuleGraph,
12};
13
14const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
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 &canonical_project_root,
44 );
45
46 public_api_entry_points
47}
48
49#[must_use]
51pub fn public_export_keys_for_graph(
52 graph: &RetainedModuleGraph,
53 config: &ResolvedConfig,
54 workspaces: &[WorkspaceInfo],
55 root: &Path,
56) -> FxHashSet<String> {
57 let root_pkg = fallow_config::load_dir_package_json(&config.root);
58 let public_entries =
59 public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
60 graph.public_export_keys(&public_entries, root)
61}
62
63#[must_use]
66pub fn public_api_entry_paths_for_graph(
67 graph: &RetainedModuleGraph,
68 config: &ResolvedConfig,
69 workspaces: &[WorkspaceInfo],
70) -> Vec<PathBuf> {
71 let root_pkg = fallow_config::load_dir_package_json(&config.root);
72 let public_entries =
73 public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
74 let mut paths = public_entries
75 .into_iter()
76 .filter_map(|file_id| {
77 graph
78 .as_graph()
79 .modules
80 .get(file_id.0 as usize)
81 .map(|module| module.path.clone())
82 })
83 .collect::<Vec<_>>();
84 paths.sort();
85 paths.dedup();
86 paths
87}
88
89fn graph_path_to_file_id(graph: &fallow_graph::graph::ModuleGraph) -> FxHashMap<PathBuf, FileId> {
90 graph
91 .modules
92 .iter()
93 .map(|module| (module.path.clone(), module.file_id))
94 .collect()
95}
96
97fn add_root_public_api_entry_points(
98 public_api_entry_points: &mut FxHashSet<FileId>,
99 graph: &fallow_graph::graph::ModuleGraph,
100 path_to_file_id: &FxHashMap<PathBuf, FileId>,
101 config: &ResolvedConfig,
102 root_pkg: Option<&PackageJson>,
103 canonical_project_root: &Path,
104) {
105 if let Some(pkg) = root_pkg {
106 add_package_public_api_entry_points(
107 public_api_entry_points,
108 graph,
109 path_to_file_id,
110 &config.root,
111 pkg,
112 canonical_project_root,
113 );
114 add_exportless_package_source_indexes(public_api_entry_points, graph, &config.root, pkg);
115 }
116}
117
118fn add_workspace_public_api_entry_points(
119 public_api_entry_points: &mut FxHashSet<FileId>,
120 graph: &fallow_graph::graph::ModuleGraph,
121 path_to_file_id: &FxHashMap<PathBuf, FileId>,
122 workspaces: &[WorkspaceInfo],
123 canonical_project_root: &Path,
124) {
125 for workspace in workspaces {
126 let Some(pkg) = fallow_config::load_dir_package_json(&workspace.root) else {
127 continue;
128 };
129 add_package_public_api_entry_points(
130 public_api_entry_points,
131 graph,
132 path_to_file_id,
133 &workspace.root,
134 &pkg,
135 canonical_project_root,
136 );
137 add_exportless_package_source_indexes(
138 public_api_entry_points,
139 graph,
140 &workspace.root,
141 &pkg,
142 );
143 }
144}
145
146fn add_package_public_api_entry_points(
147 public_api_entry_points: &mut FxHashSet<FileId>,
148 graph: &fallow_graph::graph::ModuleGraph,
149 path_to_file_id: &FxHashMap<PathBuf, FileId>,
150 package_root: &Path,
151 package_json: &PackageJson,
152 canonical_project_root: &Path,
153) {
154 if package_json.private.unwrap_or(false) {
155 return;
156 }
157
158 for entry in package_json.entry_points() {
159 let Some(entry_point) = resolve_public_api_entry_path(
160 package_root,
161 &entry,
162 canonical_project_root,
163 EntryPointSource::PackageJsonExports,
164 ) else {
165 continue;
166 };
167
168 if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
169 resolve_entry_via_canonical(graph, path_to_file_id, package_root, &entry_point.path)
170 }) {
171 public_api_entry_points.insert(file_id);
172 }
173 }
174}
175
176fn resolve_public_api_entry_path(
177 base: &Path,
178 entry: &str,
179 canonical_root: &Path,
180 source: EntryPointSource,
181) -> Option<EntryPoint> {
182 if entry.contains('*') || entry_has_parent_dir(entry) {
183 return None;
184 }
185
186 if let Some(source_path) = try_output_to_source_path(base, entry) {
187 return validated_entry_point(&source_path, canonical_root, source);
188 }
189
190 if is_entry_in_output_dir(entry)
191 && let Some(source_path) = try_source_index_fallback(base)
192 {
193 return validated_entry_point(&source_path, canonical_root, source);
194 }
195
196 resolve_entry_via_filesystem_probe(base, entry, canonical_root, source)
197}
198
199fn resolve_entry_via_filesystem_probe(
200 base: &Path,
201 entry: &str,
202 canonical_root: &Path,
203 source: EntryPointSource,
204) -> Option<EntryPoint> {
205 let resolved = base.join(entry);
206
207 if resolved.is_file() {
208 return validated_entry_point(&resolved, canonical_root, source);
209 }
210
211 for ext in SOURCE_EXTENSIONS {
212 let with_ext = resolved.with_extension(ext);
213 if with_ext.is_file() {
214 return validated_entry_point(&with_ext, canonical_root, source);
215 }
216 }
217
218 if let Some(index_entry) = try_directory_index_entry(&resolved) {
219 return validated_entry_point(&index_entry, canonical_root, source);
220 }
221
222 if is_package_root_index_entry(entry)
223 && let Some(source_path) = try_source_index_fallback(base)
224 {
225 return validated_entry_point(&source_path, canonical_root, source);
226 }
227
228 None
229}
230
231fn entry_has_parent_dir(entry: &str) -> bool {
232 Path::new(entry)
233 .components()
234 .any(|component| matches!(component, Component::ParentDir))
235}
236
237fn validated_entry_point(
238 candidate: &Path,
239 canonical_root: &Path,
240 source: EntryPointSource,
241) -> Option<EntryPoint> {
242 let canonical_candidate = dunce::canonicalize(candidate).ok()?;
243 canonical_candidate
244 .starts_with(canonical_root)
245 .then(|| EntryPoint {
246 path: candidate.to_path_buf(),
247 source,
248 })
249}
250
251fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
252 for ext in SOURCE_EXTENSIONS {
253 let candidate = resolved.join(format!("index.{ext}"));
254 if candidate.is_file() {
255 return Some(candidate);
256 }
257 }
258 None
259}
260
261fn is_package_root_index_entry(entry: &str) -> bool {
262 let mut components = Path::new(entry)
263 .components()
264 .filter(|component| !matches!(component, Component::CurDir));
265
266 let Some(Component::Normal(file_name)) = components.next() else {
267 return false;
268 };
269 if components.next().is_some() {
270 return false;
271 }
272
273 file_name
274 .to_str()
275 .is_some_and(|name| name == "index" || name.starts_with("index."))
276}
277
278fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
279 let entry_path = Path::new(entry);
280 let components: Vec<_> = entry_path.components().collect();
281
282 let output_pos = components.iter().rposition(|component| {
283 if let Component::Normal(name) = component
284 && let Some(name) = name.to_str()
285 {
286 return OUTPUT_DIRS.contains(&name);
287 }
288 false
289 })?;
290
291 let prefix: PathBuf = components[..output_pos]
292 .iter()
293 .filter(|component| !matches!(component, Component::CurDir))
294 .collect();
295 let suffix: PathBuf = components[output_pos + 1..].iter().collect();
296
297 for ext in SOURCE_EXTENSIONS {
298 let source_candidate = base
299 .join(&prefix)
300 .join("src")
301 .join(suffix.with_extension(ext));
302 if source_candidate.exists() {
303 return Some(source_candidate);
304 }
305 }
306
307 None
308}
309
310fn is_entry_in_output_dir(entry: &str) -> bool {
311 Path::new(entry).components().any(|component| {
312 if let Component::Normal(name) = component
313 && let Some(name) = name.to_str()
314 {
315 return OUTPUT_DIRS.contains(&name);
316 }
317 false
318 })
319}
320
321fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
322 for ext in SOURCE_EXTENSIONS {
323 let candidate = base.join("src").join(format!("index.{ext}"));
324 if candidate.is_file() {
325 return Some(candidate);
326 }
327 }
328 None
329}
330
331fn resolve_entry_via_canonical(
332 graph: &fallow_graph::graph::ModuleGraph,
333 path_to_file_id: &FxHashMap<PathBuf, FileId>,
334 package_root: &Path,
335 entry_path: &Path,
336) -> Option<FileId> {
337 dunce::canonicalize(entry_path).ok().and_then(|canonical| {
338 path_to_file_id
339 .get(&canonical)
340 .copied()
341 .or_else(|| resolve_entry_via_scoped_canonical(graph, package_root, &canonical))
342 })
343}
344
345fn resolve_entry_via_scoped_canonical(
346 graph: &fallow_graph::graph::ModuleGraph,
347 package_root: &Path,
348 canonical_entry: &Path,
349) -> Option<FileId> {
350 graph
351 .modules
352 .iter()
353 .filter(|module| module.path.starts_with(package_root))
354 .find_map(|module| {
355 (dunce::canonicalize(&module.path).ok().as_deref() == Some(canonical_entry))
356 .then_some(module.file_id)
357 })
358}
359
360fn add_exportless_package_source_indexes(
361 public_api_entry_points: &mut FxHashSet<FileId>,
362 graph: &fallow_graph::graph::ModuleGraph,
363 package_root: &Path,
364 package_json: &PackageJson,
365) {
366 if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
367 return;
368 }
369
370 let mut roots = vec![package_root.to_path_buf()];
371 if let Ok(canonical) = dunce::canonicalize(package_root) {
372 roots.push(canonical);
373 }
374
375 for module in &graph.modules {
376 if roots
377 .iter()
378 .any(|root| is_source_index_under_package(&module.path, root))
379 {
380 public_api_entry_points.insert(module.file_id);
381 }
382 }
383}
384
385fn is_source_index_under_package(path: &Path, package_root: &Path) -> bool {
386 let Ok(relative) = path.strip_prefix(package_root) else {
387 return false;
388 };
389
390 if !matches!(
391 relative.components().next(),
392 Some(std::path::Component::Normal(segment)) if segment == "src"
393 ) {
394 return false;
395 }
396
397 path.file_stem()
398 .and_then(|stem| stem.to_str())
399 .is_some_and(|stem| stem == "index")
400}