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
49/// Compute public export keys for a retained project graph.
50#[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}