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