Skip to main content

fallow_engine/
public_api.rs

1//! Public API graph helpers owned by the engine boundary.
2
3use 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/// Compute the exports-aware public API entry-point set for a project graph.
17#[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}