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 fallow_graph::resolve::OUTPUT_DIRS;
10
11use crate::{
12    discover::{EntryPoint, EntryPointSource, SOURCE_EXTENSIONS},
13    module_graph::RetainedModuleGraph,
14};
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        &config.public_packages,
44        &canonical_project_root,
45    );
46
47    public_api_entry_points
48}
49
50/// Compute public export keys for a retained project graph.
51#[must_use]
52pub fn public_export_keys_for_graph(
53    graph: &RetainedModuleGraph,
54    config: &ResolvedConfig,
55    workspaces: &[WorkspaceInfo],
56    root: &Path,
57) -> FxHashSet<String> {
58    let root_pkg = fallow_config::load_dir_package_json(&config.root);
59    let public_entries =
60        public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
61    graph.public_export_keys(&public_entries, root)
62}
63
64/// Resolve exports-aware package entry points to their source paths for
65/// semantic API-surface queries.
66#[must_use]
67pub fn public_api_entry_paths_for_graph(
68    graph: &RetainedModuleGraph,
69    config: &ResolvedConfig,
70    workspaces: &[WorkspaceInfo],
71) -> Vec<PathBuf> {
72    let root_pkg = fallow_config::load_dir_package_json(&config.root);
73    let public_entries =
74        public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
75    let mut paths = public_entries
76        .into_iter()
77        .filter_map(|file_id| {
78            graph
79                .as_graph()
80                .modules
81                .get(file_id.0 as usize)
82                .map(|module| module.path.clone())
83        })
84        .collect::<Vec<_>>();
85    paths.sort();
86    paths.dedup();
87    paths
88}
89
90fn graph_path_to_file_id(graph: &fallow_graph::graph::ModuleGraph) -> FxHashMap<PathBuf, FileId> {
91    graph
92        .modules
93        .iter()
94        .map(|module| (module.path.clone(), module.file_id))
95        .collect()
96}
97
98fn add_root_public_api_entry_points(
99    public_api_entry_points: &mut FxHashSet<FileId>,
100    graph: &fallow_graph::graph::ModuleGraph,
101    path_to_file_id: &FxHashMap<PathBuf, FileId>,
102    config: &ResolvedConfig,
103    root_pkg: Option<&PackageJson>,
104    canonical_project_root: &Path,
105) {
106    if let Some(pkg) = root_pkg {
107        add_package_public_api_entry_points(
108            public_api_entry_points,
109            graph,
110            path_to_file_id,
111            &config.root,
112            pkg,
113            canonical_project_root,
114        );
115        add_exportless_package_source_indexes(public_api_entry_points, graph, &config.root, pkg);
116    }
117}
118
119fn add_workspace_public_api_entry_points(
120    public_api_entry_points: &mut FxHashSet<FileId>,
121    graph: &fallow_graph::graph::ModuleGraph,
122    path_to_file_id: &FxHashMap<PathBuf, FileId>,
123    workspaces: &[WorkspaceInfo],
124    public_packages: &[String],
125    canonical_project_root: &Path,
126) {
127    for workspace in workspaces
128        .iter()
129        .filter(|workspace| fallow_config::workspace_is_public(&workspace.name, public_packages))
130    {
131        let Some(pkg) = fallow_config::load_dir_package_json(&workspace.root) else {
132            continue;
133        };
134        add_package_public_api_entry_points(
135            public_api_entry_points,
136            graph,
137            path_to_file_id,
138            &workspace.root,
139            &pkg,
140            canonical_project_root,
141        );
142        add_exportless_package_source_indexes(
143            public_api_entry_points,
144            graph,
145            &workspace.root,
146            &pkg,
147        );
148    }
149}
150
151fn add_package_public_api_entry_points(
152    public_api_entry_points: &mut FxHashSet<FileId>,
153    graph: &fallow_graph::graph::ModuleGraph,
154    path_to_file_id: &FxHashMap<PathBuf, FileId>,
155    package_root: &Path,
156    package_json: &PackageJson,
157    canonical_project_root: &Path,
158) {
159    if package_json.private.unwrap_or(false) {
160        return;
161    }
162
163    for entry in package_json.entry_points() {
164        let Some(entry_point) = resolve_public_api_entry_path(
165            package_root,
166            &entry,
167            canonical_project_root,
168            EntryPointSource::PackageJsonExports,
169        ) else {
170            continue;
171        };
172
173        if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
174            resolve_entry_via_canonical(graph, path_to_file_id, package_root, &entry_point.path)
175        }) {
176            public_api_entry_points.insert(file_id);
177        }
178    }
179}
180
181fn resolve_public_api_entry_path(
182    base: &Path,
183    entry: &str,
184    canonical_root: &Path,
185    source: EntryPointSource,
186) -> Option<EntryPoint> {
187    if entry.contains('*') || entry_has_parent_dir(entry) {
188        return None;
189    }
190
191    if let Some(source_path) = try_output_to_source_path(base, entry) {
192        return validated_entry_point(&source_path, canonical_root, source);
193    }
194
195    if is_entry_in_output_dir(entry)
196        && let Some(source_path) = try_source_index_fallback(base)
197    {
198        return validated_entry_point(&source_path, canonical_root, source);
199    }
200
201    resolve_entry_via_filesystem_probe(base, entry, canonical_root, source)
202}
203
204fn resolve_entry_via_filesystem_probe(
205    base: &Path,
206    entry: &str,
207    canonical_root: &Path,
208    source: EntryPointSource,
209) -> Option<EntryPoint> {
210    let resolved = base.join(entry);
211
212    if resolved.is_file() {
213        return validated_entry_point(&resolved, canonical_root, source);
214    }
215
216    for ext in SOURCE_EXTENSIONS {
217        let with_ext = resolved.with_extension(ext);
218        if with_ext.is_file() {
219            return validated_entry_point(&with_ext, canonical_root, source);
220        }
221    }
222
223    if let Some(index_entry) = try_directory_index_entry(&resolved) {
224        return validated_entry_point(&index_entry, canonical_root, source);
225    }
226
227    if is_package_root_index_entry(entry)
228        && let Some(source_path) = try_source_index_fallback(base)
229    {
230        return validated_entry_point(&source_path, canonical_root, source);
231    }
232
233    None
234}
235
236fn entry_has_parent_dir(entry: &str) -> bool {
237    Path::new(entry)
238        .components()
239        .any(|component| matches!(component, Component::ParentDir))
240}
241
242fn validated_entry_point(
243    candidate: &Path,
244    canonical_root: &Path,
245    source: EntryPointSource,
246) -> Option<EntryPoint> {
247    let canonical_candidate = dunce::canonicalize(candidate).ok()?;
248    canonical_candidate
249        .starts_with(canonical_root)
250        .then(|| EntryPoint {
251            path: candidate.to_path_buf(),
252            source,
253        })
254}
255
256fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
257    for ext in SOURCE_EXTENSIONS {
258        let candidate = resolved.join(format!("index.{ext}"));
259        if candidate.is_file() {
260            return Some(candidate);
261        }
262    }
263    None
264}
265
266fn is_package_root_index_entry(entry: &str) -> bool {
267    let mut components = Path::new(entry)
268        .components()
269        .filter(|component| !matches!(component, Component::CurDir));
270
271    let Some(Component::Normal(file_name)) = components.next() else {
272        return false;
273    };
274    if components.next().is_some() {
275        return false;
276    }
277
278    file_name
279        .to_str()
280        .is_some_and(|name| name == "index" || name.starts_with("index."))
281}
282
283fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
284    let entry_path = Path::new(entry);
285    let components: Vec<_> = entry_path.components().collect();
286
287    let output_pos = components.iter().rposition(|component| {
288        if let Component::Normal(name) = component
289            && let Some(name) = name.to_str()
290        {
291            return OUTPUT_DIRS.contains(&name);
292        }
293        false
294    })?;
295
296    let prefix: PathBuf = components[..output_pos]
297        .iter()
298        .filter(|component| !matches!(component, Component::CurDir))
299        .collect();
300    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
301
302    for ext in SOURCE_EXTENSIONS {
303        let source_candidate = base
304            .join(&prefix)
305            .join("src")
306            .join(suffix.with_extension(ext));
307        if source_candidate.exists() {
308            return Some(source_candidate);
309        }
310    }
311
312    None
313}
314
315fn is_entry_in_output_dir(entry: &str) -> bool {
316    Path::new(entry).components().any(|component| {
317        if let Component::Normal(name) = component
318            && let Some(name) = name.to_str()
319        {
320            return OUTPUT_DIRS.contains(&name);
321        }
322        false
323    })
324}
325
326fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
327    for ext in SOURCE_EXTENSIONS {
328        let candidate = base.join("src").join(format!("index.{ext}"));
329        if candidate.is_file() {
330            return Some(candidate);
331        }
332    }
333    None
334}
335
336fn resolve_entry_via_canonical(
337    graph: &fallow_graph::graph::ModuleGraph,
338    path_to_file_id: &FxHashMap<PathBuf, FileId>,
339    package_root: &Path,
340    entry_path: &Path,
341) -> Option<FileId> {
342    dunce::canonicalize(entry_path).ok().and_then(|canonical| {
343        path_to_file_id
344            .get(&canonical)
345            .copied()
346            .or_else(|| resolve_entry_via_scoped_canonical(graph, package_root, &canonical))
347    })
348}
349
350fn resolve_entry_via_scoped_canonical(
351    graph: &fallow_graph::graph::ModuleGraph,
352    package_root: &Path,
353    canonical_entry: &Path,
354) -> Option<FileId> {
355    graph
356        .modules
357        .iter()
358        .filter(|module| module.path.starts_with(package_root))
359        .find_map(|module| {
360            (dunce::canonicalize(&module.path).ok().as_deref() == Some(canonical_entry))
361                .then_some(module.file_id)
362        })
363}
364
365fn add_exportless_package_source_indexes(
366    public_api_entry_points: &mut FxHashSet<FileId>,
367    graph: &fallow_graph::graph::ModuleGraph,
368    package_root: &Path,
369    package_json: &PackageJson,
370) {
371    if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
372        return;
373    }
374
375    let mut roots = vec![package_root.to_path_buf()];
376    if let Ok(canonical) = dunce::canonicalize(package_root) {
377        roots.push(canonical);
378    }
379
380    for module in &graph.modules {
381        if roots
382            .iter()
383            .any(|root| is_source_index_under_package(&module.path, root))
384        {
385            public_api_entry_points.insert(module.file_id);
386        }
387    }
388}
389
390fn is_source_index_under_package(path: &Path, package_root: &Path) -> bool {
391    let Ok(relative) = path.strip_prefix(package_root) else {
392        return false;
393    };
394
395    if !matches!(
396        relative.components().next(),
397        Some(std::path::Component::Normal(segment)) if segment == "src"
398    ) {
399        return false;
400    }
401
402    path.file_stem()
403        .and_then(|stem| stem.to_str())
404        .is_some_and(|stem| stem == "index")
405}
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::session::AnalysisSession;
411
412    fn fixture_root() -> PathBuf {
413        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
414            .join("../../tests/fixtures/public-package-members")
415    }
416
417    fn public_entry_paths(session: &AnalysisSession) -> Vec<PathBuf> {
418        let artifacts = session
419            .analyze_dead_code_with_artifacts(false, true)
420            .expect("analysis succeeds");
421        let graph = artifacts.graph.expect("retained graph");
422        public_api_entry_paths_for_graph(&graph, session.config(), session.workspaces())
423    }
424
425    #[test]
426    fn workspace_public_entries_require_public_packages_selection() {
427        let root = fixture_root();
428        let unselected = AnalysisSession::load_with_config(&root, None, |config| {
429            config.public_packages.clear();
430        })
431        .expect("unselected session loads");
432
433        assert!(public_entry_paths(&unselected).is_empty());
434
435        let selected = AnalysisSession::load_with_config(&root, None, |config| {
436            config.public_packages = vec!["@workspace/public-lib".to_string()];
437        })
438        .expect("selected session loads");
439        let selected_paths = public_entry_paths(&selected);
440
441        assert_eq!(selected_paths.len(), 1);
442        assert!(selected_paths[0].ends_with("packages/public-lib/src/index.ts"));
443    }
444}