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