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::{
6    PackageJson, ResolvedConfig, TsconfigOutputMap, TsconfigOutputResolution, WorkspaceInfo,
7};
8use fallow_types::discover::FileId;
9use rustc_hash::{FxHashMap, FxHashSet};
10
11use fallow_graph::resolve::OUTPUT_DIRS;
12
13use crate::{
14    discover::{EntryPoint, EntryPointSource, SOURCE_EXTENSIONS},
15    module_graph::RetainedModuleGraph,
16};
17
18/// Compute the exports-aware public API entry-point set for a project graph.
19#[must_use]
20pub fn public_api_package_entry_points(
21    graph: &RetainedModuleGraph,
22    config: &ResolvedConfig,
23    root_pkg: Option<&PackageJson>,
24    workspaces: &[WorkspaceInfo],
25) -> FxHashSet<FileId> {
26    let graph = graph.as_graph();
27    let mut public_api_entry_points = FxHashSet::default();
28    let path_to_file_id = graph_path_to_file_id(graph);
29    let canonical_project_root =
30        dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
31
32    add_root_public_api_entry_points(
33        &mut public_api_entry_points,
34        graph,
35        &path_to_file_id,
36        config,
37        root_pkg,
38        &canonical_project_root,
39    );
40    add_workspace_public_api_entry_points(
41        &mut public_api_entry_points,
42        graph,
43        &path_to_file_id,
44        workspaces,
45        &config.public_packages,
46        &canonical_project_root,
47    );
48
49    public_api_entry_points
50}
51
52/// Compute public export keys for a retained project graph.
53#[must_use]
54pub fn public_export_keys_for_graph(
55    graph: &RetainedModuleGraph,
56    config: &ResolvedConfig,
57    workspaces: &[WorkspaceInfo],
58    root: &Path,
59) -> FxHashSet<String> {
60    let root_pkg = fallow_config::load_dir_package_json(&config.root);
61    let public_entries =
62        public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
63    graph.public_export_keys(&public_entries, root)
64}
65
66/// Resolve exports-aware package entry points to their source paths for
67/// semantic API-surface queries.
68#[must_use]
69pub fn public_api_entry_paths_for_graph(
70    graph: &RetainedModuleGraph,
71    config: &ResolvedConfig,
72    workspaces: &[WorkspaceInfo],
73) -> Vec<PathBuf> {
74    let root_pkg = fallow_config::load_dir_package_json(&config.root);
75    let public_entries =
76        public_api_package_entry_points(graph, config, root_pkg.as_ref(), workspaces);
77    let mut paths = public_entries
78        .into_iter()
79        .filter_map(|file_id| {
80            graph
81                .as_graph()
82                .modules
83                .get(file_id.0 as usize)
84                .map(|module| module.path.clone())
85        })
86        .collect::<Vec<_>>();
87    paths.sort();
88    paths.dedup();
89    paths
90}
91
92fn graph_path_to_file_id(graph: &fallow_graph::graph::ModuleGraph) -> FxHashMap<PathBuf, FileId> {
93    graph
94        .modules
95        .iter()
96        .map(|module| (module.path.clone(), module.file_id))
97        .collect()
98}
99
100fn add_root_public_api_entry_points(
101    public_api_entry_points: &mut FxHashSet<FileId>,
102    graph: &fallow_graph::graph::ModuleGraph,
103    path_to_file_id: &FxHashMap<PathBuf, FileId>,
104    config: &ResolvedConfig,
105    root_pkg: Option<&PackageJson>,
106    canonical_project_root: &Path,
107) {
108    if let Some(pkg) = root_pkg {
109        add_package_public_api_entry_points(
110            public_api_entry_points,
111            graph,
112            path_to_file_id,
113            &config.root,
114            pkg,
115            canonical_project_root,
116        );
117        add_exportless_package_source_indexes(public_api_entry_points, graph, &config.root, pkg);
118    }
119}
120
121fn add_workspace_public_api_entry_points(
122    public_api_entry_points: &mut FxHashSet<FileId>,
123    graph: &fallow_graph::graph::ModuleGraph,
124    path_to_file_id: &FxHashMap<PathBuf, FileId>,
125    workspaces: &[WorkspaceInfo],
126    public_packages: &[String],
127    canonical_project_root: &Path,
128) {
129    for workspace in workspaces
130        .iter()
131        .filter(|workspace| fallow_config::workspace_is_public(&workspace.name, public_packages))
132    {
133        let Some(pkg) = fallow_config::load_dir_package_json(&workspace.root) else {
134            continue;
135        };
136        add_package_public_api_entry_points(
137            public_api_entry_points,
138            graph,
139            path_to_file_id,
140            &workspace.root,
141            &pkg,
142            canonical_project_root,
143        );
144        add_exportless_package_source_indexes(
145            public_api_entry_points,
146            graph,
147            &workspace.root,
148            &pkg,
149        );
150    }
151}
152
153fn add_package_public_api_entry_points(
154    public_api_entry_points: &mut FxHashSet<FileId>,
155    graph: &fallow_graph::graph::ModuleGraph,
156    path_to_file_id: &FxHashMap<PathBuf, FileId>,
157    package_root: &Path,
158    package_json: &PackageJson,
159    canonical_project_root: &Path,
160) {
161    if package_json.private.unwrap_or(false) {
162        return;
163    }
164
165    let output_map = TsconfigOutputMap::from_project(package_root);
166    for entry in package_json.entry_points() {
167        let Some(entry_point) = resolve_public_api_entry_path(
168            package_root,
169            &entry,
170            canonical_project_root,
171            EntryPointSource::PackageJsonExports,
172            &output_map,
173        ) else {
174            continue;
175        };
176
177        if let Some(file_id) = path_to_file_id.get(&entry_point.path).copied().or_else(|| {
178            resolve_entry_via_canonical(graph, path_to_file_id, package_root, &entry_point.path)
179        }) {
180            public_api_entry_points.insert(file_id);
181        }
182    }
183}
184
185fn resolve_public_api_entry_path(
186    base: &Path,
187    entry: &str,
188    canonical_root: &Path,
189    source: EntryPointSource,
190    output_map: &TsconfigOutputMap,
191) -> Option<EntryPoint> {
192    if entry.contains('*') || entry_has_parent_dir(entry) {
193        return None;
194    }
195
196    match output_map.resolve_source_for_entry(entry, SOURCE_EXTENSIONS) {
197        TsconfigOutputResolution::Resolved(source_path) => {
198            return validated_entry_point(&source_path, canonical_root, source);
199        }
200        TsconfigOutputResolution::ConfiguredButUnresolved => {}
201        TsconfigOutputResolution::Unconfigured => {
202            if let Some(source_path) = try_legacy_output_to_source_path(base, entry) {
203                return validated_entry_point(&source_path, canonical_root, source);
204            }
205
206            if is_entry_in_output_dir(entry)
207                && let Some(source_path) = try_source_index_fallback(base)
208            {
209                return validated_entry_point(&source_path, canonical_root, source);
210            }
211        }
212    }
213
214    resolve_entry_via_filesystem_probe(base, entry, canonical_root, source)
215}
216
217fn resolve_entry_via_filesystem_probe(
218    base: &Path,
219    entry: &str,
220    canonical_root: &Path,
221    source: EntryPointSource,
222) -> Option<EntryPoint> {
223    let resolved = base.join(entry);
224
225    if resolved.is_file() {
226        return validated_entry_point(&resolved, canonical_root, source);
227    }
228
229    for ext in SOURCE_EXTENSIONS {
230        let with_ext = resolved.with_extension(ext);
231        if with_ext.is_file() {
232            return validated_entry_point(&with_ext, canonical_root, source);
233        }
234    }
235
236    if let Some(index_entry) = try_directory_index_entry(&resolved) {
237        return validated_entry_point(&index_entry, canonical_root, source);
238    }
239
240    if is_package_root_index_entry(entry)
241        && let Some(source_path) = try_source_index_fallback(base)
242    {
243        return validated_entry_point(&source_path, canonical_root, source);
244    }
245
246    None
247}
248
249fn entry_has_parent_dir(entry: &str) -> bool {
250    Path::new(entry)
251        .components()
252        .any(|component| matches!(component, Component::ParentDir))
253}
254
255fn validated_entry_point(
256    candidate: &Path,
257    canonical_root: &Path,
258    source: EntryPointSource,
259) -> Option<EntryPoint> {
260    let canonical_candidate = dunce::canonicalize(candidate).ok()?;
261    canonical_candidate
262        .starts_with(canonical_root)
263        .then(|| EntryPoint {
264            path: candidate.to_path_buf(),
265            source,
266        })
267}
268
269fn try_directory_index_entry(resolved: &Path) -> Option<PathBuf> {
270    for ext in SOURCE_EXTENSIONS {
271        let candidate = resolved.join(format!("index.{ext}"));
272        if candidate.is_file() {
273            return Some(candidate);
274        }
275    }
276    None
277}
278
279fn is_package_root_index_entry(entry: &str) -> bool {
280    let mut components = Path::new(entry)
281        .components()
282        .filter(|component| !matches!(component, Component::CurDir));
283
284    let Some(Component::Normal(file_name)) = components.next() else {
285        return false;
286    };
287    if components.next().is_some() {
288        return false;
289    }
290
291    file_name
292        .to_str()
293        .is_some_and(|name| name == "index" || name.starts_with("index."))
294}
295
296fn try_legacy_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
297    let entry_path = Path::new(entry);
298    let components: Vec<_> = entry_path.components().collect();
299
300    let output_pos = components.iter().rposition(|component| {
301        if let Component::Normal(name) = component
302            && let Some(name) = name.to_str()
303        {
304            return OUTPUT_DIRS.contains(&name);
305        }
306        false
307    })?;
308
309    let prefix: PathBuf = components[..output_pos]
310        .iter()
311        .filter(|component| !matches!(component, Component::CurDir))
312        .collect();
313    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
314
315    for ext in SOURCE_EXTENSIONS {
316        let source_candidate = base
317            .join(&prefix)
318            .join("src")
319            .join(suffix.with_extension(ext));
320        if source_candidate.exists() {
321            return Some(source_candidate);
322        }
323    }
324
325    None
326}
327
328fn is_entry_in_output_dir(entry: &str) -> bool {
329    Path::new(entry).components().any(|component| {
330        if let Component::Normal(name) = component
331            && let Some(name) = name.to_str()
332        {
333            return OUTPUT_DIRS.contains(&name);
334        }
335        false
336    })
337}
338
339fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
340    for ext in SOURCE_EXTENSIONS {
341        let candidate = base.join("src").join(format!("index.{ext}"));
342        if candidate.is_file() {
343            return Some(candidate);
344        }
345    }
346    None
347}
348
349fn resolve_entry_via_canonical(
350    graph: &fallow_graph::graph::ModuleGraph,
351    path_to_file_id: &FxHashMap<PathBuf, FileId>,
352    package_root: &Path,
353    entry_path: &Path,
354) -> Option<FileId> {
355    dunce::canonicalize(entry_path).ok().and_then(|canonical| {
356        path_to_file_id
357            .get(&canonical)
358            .copied()
359            .or_else(|| resolve_entry_via_scoped_canonical(graph, package_root, &canonical))
360    })
361}
362
363fn resolve_entry_via_scoped_canonical(
364    graph: &fallow_graph::graph::ModuleGraph,
365    package_root: &Path,
366    canonical_entry: &Path,
367) -> Option<FileId> {
368    graph
369        .modules
370        .iter()
371        .filter(|module| module.path.starts_with(package_root))
372        .find_map(|module| {
373            (dunce::canonicalize(&module.path).ok().as_deref() == Some(canonical_entry))
374                .then_some(module.file_id)
375        })
376}
377
378fn add_exportless_package_source_indexes(
379    public_api_entry_points: &mut FxHashSet<FileId>,
380    graph: &fallow_graph::graph::ModuleGraph,
381    package_root: &Path,
382    package_json: &PackageJson,
383) {
384    if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
385        return;
386    }
387
388    let mut roots = vec![package_root.to_path_buf()];
389    if let Ok(canonical) = dunce::canonicalize(package_root) {
390        roots.push(canonical);
391    }
392
393    for module in &graph.modules {
394        if roots
395            .iter()
396            .any(|root| is_source_index_under_package(&module.path, root))
397        {
398            public_api_entry_points.insert(module.file_id);
399        }
400    }
401}
402
403fn is_source_index_under_package(path: &Path, package_root: &Path) -> bool {
404    let Ok(relative) = path.strip_prefix(package_root) else {
405        return false;
406    };
407
408    if !matches!(
409        relative.components().next(),
410        Some(std::path::Component::Normal(segment)) if segment == "src"
411    ) {
412        return false;
413    }
414
415    path.file_stem()
416        .and_then(|stem| stem.to_str())
417        .is_some_and(|stem| stem == "index")
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423    use crate::session::AnalysisSession;
424
425    fn fixture_root() -> PathBuf {
426        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
427            .join("../../tests/fixtures/public-package-members")
428    }
429
430    fn public_entry_paths(session: &AnalysisSession) -> Vec<PathBuf> {
431        let artifacts = session
432            .analyze_dead_code_with_artifacts(false, true)
433            .expect("analysis succeeds");
434        let graph = artifacts.graph.expect("retained graph");
435        public_api_entry_paths_for_graph(&graph, session.config(), session.workspaces())
436    }
437
438    #[test]
439    fn workspace_public_entries_require_public_packages_selection() {
440        let root = fixture_root();
441        let unselected = AnalysisSession::load_with_config(&root, None, |config| {
442            config.public_packages.clear();
443        })
444        .expect("unselected session loads");
445
446        assert!(public_entry_paths(&unselected).is_empty());
447
448        let selected = AnalysisSession::load_with_config(&root, None, |config| {
449            config.public_packages = vec!["@workspace/public-lib".to_string()];
450        })
451        .expect("selected session loads");
452        let selected_paths = public_entry_paths(&selected);
453
454        assert_eq!(selected_paths.len(), 1);
455        assert!(selected_paths[0].ends_with("packages/public-lib/src/index.ts"));
456    }
457
458    #[test]
459    fn package_output_entries_resolve_through_tsconfig_with_outputs_present_or_absent() {
460        let directory = tempfile::tempdir().expect("temporary project directory");
461        let root = directory.path();
462        std::fs::create_dir_all(root.join("source")).expect("source directory");
463        std::fs::create_dir_all(root.join("distribution")).expect("output directory");
464        std::fs::write(
465            root.join("package.json"),
466            r#"{
467                "name": "configured-output-package",
468                "exports": {
469                    ".": {
470                        "types": "./distribution/index.d.ts",
471                        "import": "./distribution/index.js"
472                    }
473                }
474            }"#,
475        )
476        .expect("package manifest");
477        std::fs::write(
478            root.join("tsconfig.build.json"),
479            r#"{
480                "compilerOptions": {
481                    "rootDir": "./source",
482                    "outDir": "./distribution"
483                },
484                "include": ["source"]
485            }"#,
486        )
487        .expect("TypeScript config");
488        std::fs::write(
489            root.join("source/index.ts"),
490            "export const publicValue = 1;\n",
491        )
492        .expect("source entry");
493        std::fs::write(
494            root.join("source/internal.ts"),
495            "export const internalValue = 1;\n",
496        )
497        .expect("unrelated source file");
498        std::fs::write(
499            root.join("distribution/index.js"),
500            "export const publicValue = 1;\n",
501        )
502        .expect("generated JavaScript entry");
503        std::fs::write(
504            root.join("distribution/index.d.ts"),
505            "export declare const publicValue: 1;\n",
506        )
507        .expect("generated declaration entry");
508
509        let with_outputs = AnalysisSession::load_with_config(root, None, |_| {})
510            .expect("project with outputs loads");
511        let expected = root.join("source/index.ts");
512        assert_eq!(
513            public_entry_paths(&with_outputs),
514            vec![expected.clone()],
515            "configured output entries should resolve to their source entry, without exposing unrelated source files"
516        );
517
518        std::fs::remove_dir_all(root.join("distribution")).expect("remove generated outputs");
519        let without_outputs = AnalysisSession::load_with_config(root, None, |_| {})
520            .expect("project without outputs loads");
521        assert_eq!(
522            public_entry_paths(&without_outputs),
523            vec![expected],
524            "configured output entries should still resolve when build artifacts are absent"
525        );
526    }
527
528    #[test]
529    fn out_dir_without_root_dir_resolves_public_subpaths_and_bin() {
530        let directory = tempfile::tempdir().expect("temporary project directory");
531        let root = directory.path();
532        std::fs::create_dir_all(root.join("src")).expect("source directory");
533        std::fs::write(
534            root.join("package.json"),
535            r#"{
536                "name": "out-dir-only-package",
537                "main": "./dist/index.js",
538                "bin": "./dist/cli.js",
539                "exports": {
540                    ".": "./dist/index.js",
541                    "./browser": "./dist/browser.js"
542                }
543            }"#,
544        )
545        .expect("package manifest");
546        std::fs::write(
547            root.join("tsconfig.json"),
548            r#"{"compilerOptions":{"outDir":"./dist"},"include":["src"]}"#,
549        )
550        .expect("TypeScript config");
551        for name in ["index.ts", "browser.ts", "cli.ts", "internal.ts"] {
552            std::fs::write(
553                root.join("src").join(name),
554                format!("export const {} = 1;\n", name.trim_end_matches(".ts")),
555            )
556            .expect("source module");
557        }
558
559        let session = AnalysisSession::load_with_config(root, None, |_| {})
560            .expect("outDir-only package loads");
561        let entries = public_entry_paths(&session);
562        for entry in ["src/index.ts", "src/browser.ts", "src/cli.ts"] {
563            assert!(
564                entries.iter().any(|path| path.ends_with(entry)),
565                "public output entry {entry} should map to its source, public entries: {entries:?}"
566            );
567        }
568        assert!(
569            !entries.iter().any(|path| path.ends_with("src/internal.ts")),
570            "mapping public outputs must not expose unrelated source files, public entries: {entries:?}"
571        );
572    }
573
574    #[test]
575    fn declaration_dir_without_out_dir_resolves_package_types_entries() {
576        for with_output in [true, false] {
577            let directory = tempfile::tempdir().expect("temporary project directory");
578            let root = directory.path();
579            std::fs::create_dir_all(root.join("source")).expect("source directory");
580            std::fs::write(
581                root.join("package.json"),
582                r#"{
583                    "name":"declaration-only-package",
584                    "types":"./types/index.d.ts",
585                    "exports":{".":{"types":"./types/index.d.ts"}}
586                }"#,
587            )
588            .expect("package manifest");
589            std::fs::write(
590                root.join("tsconfig.build.json"),
591                r#"{
592                    "compilerOptions": {
593                        "rootDir":"./source",
594                        "declarationDir":"./types"
595                    }
596                }"#,
597            )
598            .expect("TypeScript config");
599            std::fs::write(
600                root.join("source/index.ts"),
601                "export const publicValue = 1;\n",
602            )
603            .expect("source entry");
604            std::fs::write(
605                root.join("source/internal.ts"),
606                "export const internalValue = 1;\n",
607            )
608            .expect("unrelated source file");
609            if with_output {
610                std::fs::create_dir_all(root.join("types")).expect("types directory");
611                std::fs::write(
612                    root.join("types/index.d.ts"),
613                    "export declare const publicValue: 1;\n",
614                )
615                .expect("generated declaration entry");
616            }
617
618            let session = AnalysisSession::load_with_config(root, None, |_| {})
619                .expect("declaration-only project loads");
620            assert_eq!(
621                public_entry_paths(&session),
622                vec![root.join("source/index.ts")],
623                "declarationDir should resolve with output present={with_output}"
624            );
625        }
626    }
627
628    #[test]
629    fn ambiguous_configured_output_does_not_use_legacy_public_entry_guess() {
630        for with_output in [true, false] {
631            let directory = tempfile::tempdir().expect("temporary project directory");
632            let root = directory.path();
633            for source_dir in ["source", "src"] {
634                std::fs::create_dir_all(root.join(source_dir)).expect("source directory");
635                std::fs::write(
636                    root.join(source_dir).join("index.ts"),
637                    "export const value = 1;\n",
638                )
639                .expect("source entry");
640            }
641            std::fs::write(
642                root.join("package.json"),
643                r#"{"name":"ambiguous-output-package","exports":{".":"./dist/index.js","./runtime":"./runtime.ts"}}"#,
644            )
645            .expect("package manifest");
646            std::fs::write(root.join("runtime.ts"), "export const runtime = 1;\n")
647                .expect("independent runtime export");
648            for (config_name, source_dir) in [
649                ("tsconfig.source.json", "source"),
650                ("tsconfig.src.json", "src"),
651            ] {
652                std::fs::write(
653                    root.join(config_name),
654                    format!(
655                        r#"{{"compilerOptions":{{"rootDir":"./{source_dir}","outDir":"./dist"}}}}"#
656                    ),
657                )
658                .expect("TypeScript config");
659            }
660            if with_output {
661                std::fs::create_dir_all(root.join("dist")).expect("output directory");
662                std::fs::write(root.join("dist/index.js"), "export const value = 1;\n")
663                    .expect("generated output");
664            }
665
666            let session = AnalysisSession::load_with_config(root, None, |_| {})
667                .expect("ambiguous project loads");
668            let entries = public_entry_paths(&session);
669            assert!(
670                !entries.iter().any(|path| path.ends_with("src/index.ts")),
671                "an ambiguous configured map must not expose the legacy src guess with output present={with_output}, entries: {entries:?}"
672            );
673            assert!(
674                !entries.iter().any(|path| path.ends_with("source/index.ts")),
675                "an ambiguous configured map must not expose either source candidate, entries: {entries:?}"
676            );
677        }
678
679        let legacy = tempfile::tempdir().expect("legacy project directory");
680        std::fs::create_dir_all(legacy.path().join("src")).expect("legacy source directory");
681        std::fs::write(
682            legacy.path().join("package.json"),
683            r#"{"name":"legacy-output-package","main":"./dist/index.js"}"#,
684        )
685        .expect("package manifest");
686        std::fs::write(
687            legacy.path().join("src/index.ts"),
688            "export const value = 1;\n",
689        )
690        .expect("legacy source entry");
691        let session = AnalysisSession::load_with_config(legacy.path(), None, |_| {})
692            .expect("legacy project loads");
693        assert!(
694            public_entry_paths(&session)
695                .iter()
696                .any(|path| path.ends_with("src/index.ts")),
697            "the legacy dist/src convention should still resolve without a matching config"
698        );
699    }
700}