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 = 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/// Resolve exports-aware package entry points to their source paths for
64/// semantic API-surface queries.
65#[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}