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, directory_index_entry, output_entry_to_source_path};
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    let file_id_for = |path: &Path| {
167        graph.package_entry_file_id(package_root, path, |candidate| {
168            path_to_file_id.get(candidate).copied()
169        })
170    };
171    for entry in package_json.entry_points() {
172        let Some(entry_point) = resolve_public_api_entry_path(
173            package_root,
174            &entry,
175            canonical_project_root,
176            EntryPointSource::PackageJsonExports,
177            &output_map,
178            &|path| file_id_for(path).is_some(),
179        ) else {
180            continue;
181        };
182
183        if let Some(file_id) = file_id_for(&entry_point.path) {
184            public_api_entry_points.insert(file_id);
185        }
186    }
187}
188
189/// Resolve a package entry to a source path.
190///
191/// `is_discovered` returns `true` for a path in the graph. A `lib/` entry
192/// outside the graph maps to `src/`, the same as a missing `lib/` entry.
193fn resolve_public_api_entry_path(
194    base: &Path,
195    entry: &str,
196    canonical_root: &Path,
197    source: EntryPointSource,
198    output_map: &TsconfigOutputMap,
199    is_discovered: &dyn Fn(&Path) -> bool,
200) -> Option<EntryPoint> {
201    if entry.contains('*') || entry_has_parent_dir(entry) {
202        return None;
203    }
204
205    match output_map.resolve_source_for_entry(entry, SOURCE_EXTENSIONS) {
206        TsconfigOutputResolution::Resolved(source_path) => {
207            return validated_entry_point(&source_path, canonical_root, source);
208        }
209        TsconfigOutputResolution::ConfiguredButUnresolved => {}
210        TsconfigOutputResolution::Unconfigured => {
211            if let Some(source_path) =
212                output_entry_to_source_path(base, entry, SOURCE_EXTENSIONS, is_discovered)
213            {
214                return validated_entry_point(&source_path, canonical_root, source);
215            }
216
217            if is_entry_in_output_dir(entry)
218                && let Some(source_path) = try_source_index_fallback(base)
219            {
220                return validated_entry_point(&source_path, canonical_root, source);
221            }
222        }
223    }
224
225    resolve_entry_via_filesystem_probe(base, entry, canonical_root, source)
226}
227
228fn resolve_entry_via_filesystem_probe(
229    base: &Path,
230    entry: &str,
231    canonical_root: &Path,
232    source: EntryPointSource,
233) -> Option<EntryPoint> {
234    let resolved = base.join(entry);
235
236    if resolved.is_file() {
237        return validated_entry_point(&resolved, canonical_root, source);
238    }
239
240    for ext in SOURCE_EXTENSIONS {
241        let with_ext = resolved.with_extension(ext);
242        if with_ext.is_file() {
243            return validated_entry_point(&with_ext, canonical_root, source);
244        }
245    }
246
247    if let Some(index_entry) = directory_index_entry(&resolved, SOURCE_EXTENSIONS) {
248        return validated_entry_point(&index_entry, canonical_root, source);
249    }
250
251    if is_package_root_index_entry(entry)
252        && let Some(source_path) = try_source_index_fallback(base)
253    {
254        return validated_entry_point(&source_path, canonical_root, source);
255    }
256
257    None
258}
259
260fn entry_has_parent_dir(entry: &str) -> bool {
261    Path::new(entry)
262        .components()
263        .any(|component| matches!(component, Component::ParentDir))
264}
265
266fn validated_entry_point(
267    candidate: &Path,
268    canonical_root: &Path,
269    source: EntryPointSource,
270) -> Option<EntryPoint> {
271    let canonical_candidate = dunce::canonicalize(candidate).ok()?;
272    canonical_candidate
273        .starts_with(canonical_root)
274        .then(|| EntryPoint {
275            path: candidate.to_path_buf(),
276            source,
277        })
278}
279
280fn is_package_root_index_entry(entry: &str) -> bool {
281    let mut components = Path::new(entry)
282        .components()
283        .filter(|component| !matches!(component, Component::CurDir));
284
285    let Some(Component::Normal(file_name)) = components.next() else {
286        return false;
287    };
288    if components.next().is_some() {
289        return false;
290    }
291
292    file_name
293        .to_str()
294        .is_some_and(|name| name == "index" || name.starts_with("index."))
295}
296
297fn is_entry_in_output_dir(entry: &str) -> bool {
298    Path::new(entry).components().any(|component| {
299        if let Component::Normal(name) = component
300            && let Some(name) = name.to_str()
301        {
302            return OUTPUT_DIRS.contains(&name);
303        }
304        false
305    })
306}
307
308fn try_source_index_fallback(base: &Path) -> Option<PathBuf> {
309    for ext in SOURCE_EXTENSIONS {
310        let candidate = base.join("src").join(format!("index.{ext}"));
311        if candidate.is_file() {
312            return Some(candidate);
313        }
314    }
315    None
316}
317
318fn add_exportless_package_source_indexes(
319    public_api_entry_points: &mut FxHashSet<FileId>,
320    graph: &fallow_graph::graph::ModuleGraph,
321    package_root: &Path,
322    package_json: &PackageJson,
323) {
324    if package_json.private.unwrap_or(false) || package_json.exports.is_some() {
325        return;
326    }
327
328    let mut roots = vec![package_root.to_path_buf()];
329    if let Ok(canonical) = dunce::canonicalize(package_root) {
330        roots.push(canonical);
331    }
332
333    for module in &graph.modules {
334        if roots
335            .iter()
336            .any(|root| is_source_index_under_package(&module.path, root))
337        {
338            public_api_entry_points.insert(module.file_id);
339        }
340    }
341}
342
343fn is_source_index_under_package(path: &Path, package_root: &Path) -> bool {
344    let Ok(relative) = path.strip_prefix(package_root) else {
345        return false;
346    };
347
348    if !matches!(
349        relative.components().next(),
350        Some(std::path::Component::Normal(segment)) if segment == "src"
351    ) {
352        return false;
353    }
354
355    path.file_stem()
356        .and_then(|stem| stem.to_str())
357        .is_some_and(|stem| stem == "index")
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::session::AnalysisSession;
364
365    fn fixture_root() -> PathBuf {
366        std::path::PathBuf::from(
367            std::env::var_os("CARGO_MANIFEST_DIR").expect("cargo sets CARGO_MANIFEST_DIR"),
368        )
369        .join("../../tests/fixtures/public-package-members")
370    }
371
372    fn public_entry_paths(session: &AnalysisSession) -> Vec<PathBuf> {
373        let artifacts = session
374            .analyze_dead_code_with_artifacts(false, true)
375            .expect("analysis succeeds");
376        let graph = artifacts.graph.expect("retained graph");
377        public_api_entry_paths_for_graph(&graph, session.config(), session.workspaces())
378    }
379
380    #[test]
381    fn workspace_public_entries_require_public_packages_selection() {
382        let root = fixture_root();
383        let unselected = AnalysisSession::load_with_config(&root, None, |config| {
384            config.public_packages.clear();
385        })
386        .expect("unselected session loads");
387
388        assert!(public_entry_paths(&unselected).is_empty());
389
390        let selected = AnalysisSession::load_with_config(&root, None, |config| {
391            config.public_packages = vec!["@workspace/public-lib".to_string()];
392        })
393        .expect("selected session loads");
394        let selected_paths = public_entry_paths(&selected);
395
396        assert_eq!(selected_paths.len(), 1);
397        assert!(selected_paths[0].ends_with("packages/public-lib/src/index.ts"));
398    }
399
400    #[test]
401    fn package_output_entries_resolve_through_tsconfig_with_outputs_present_or_absent() {
402        let directory = tempfile::tempdir().expect("temporary project directory");
403        let root = directory.path();
404        std::fs::create_dir_all(root.join("source")).expect("source directory");
405        std::fs::create_dir_all(root.join("distribution")).expect("output directory");
406        std::fs::write(
407            root.join("package.json"),
408            r#"{
409                "name": "configured-output-package",
410                "exports": {
411                    ".": {
412                        "types": "./distribution/index.d.ts",
413                        "import": "./distribution/index.js"
414                    }
415                }
416            }"#,
417        )
418        .expect("package manifest");
419        std::fs::write(
420            root.join("tsconfig.build.json"),
421            r#"{
422                "compilerOptions": {
423                    "rootDir": "./source",
424                    "outDir": "./distribution"
425                },
426                "include": ["source"]
427            }"#,
428        )
429        .expect("TypeScript config");
430        std::fs::write(
431            root.join("source/index.ts"),
432            "export const publicValue = 1;\n",
433        )
434        .expect("source entry");
435        std::fs::write(
436            root.join("source/internal.ts"),
437            "export const internalValue = 1;\n",
438        )
439        .expect("unrelated source file");
440        std::fs::write(
441            root.join("distribution/index.js"),
442            "export const publicValue = 1;\n",
443        )
444        .expect("generated JavaScript entry");
445        std::fs::write(
446            root.join("distribution/index.d.ts"),
447            "export declare const publicValue: 1;\n",
448        )
449        .expect("generated declaration entry");
450
451        let with_outputs = AnalysisSession::load_with_config(root, None, |_| {})
452            .expect("project with outputs loads");
453        let expected = root.join("source/index.ts");
454        assert_eq!(
455            public_entry_paths(&with_outputs),
456            vec![expected.clone()],
457            "configured output entries should resolve to their source entry, without exposing unrelated source files"
458        );
459
460        std::fs::remove_dir_all(root.join("distribution")).expect("remove generated outputs");
461        let without_outputs = AnalysisSession::load_with_config(root, None, |_| {})
462            .expect("project without outputs loads");
463        assert_eq!(
464            public_entry_paths(&without_outputs),
465            vec![expected],
466            "configured output entries should still resolve when build artifacts are absent"
467        );
468    }
469
470    #[test]
471    fn out_dir_without_root_dir_resolves_public_subpaths_and_bin() {
472        let directory = tempfile::tempdir().expect("temporary project directory");
473        let root = directory.path();
474        std::fs::create_dir_all(root.join("src")).expect("source directory");
475        std::fs::write(
476            root.join("package.json"),
477            r#"{
478                "name": "out-dir-only-package",
479                "main": "./dist/index.js",
480                "bin": "./dist/cli.js",
481                "exports": {
482                    ".": "./dist/index.js",
483                    "./browser": "./dist/browser.js"
484                }
485            }"#,
486        )
487        .expect("package manifest");
488        std::fs::write(
489            root.join("tsconfig.json"),
490            r#"{"compilerOptions":{"outDir":"./dist"},"include":["src"]}"#,
491        )
492        .expect("TypeScript config");
493        for name in ["index.ts", "browser.ts", "cli.ts", "internal.ts"] {
494            std::fs::write(
495                root.join("src").join(name),
496                format!("export const {} = 1;\n", name.trim_end_matches(".ts")),
497            )
498            .expect("source module");
499        }
500
501        let session = AnalysisSession::load_with_config(root, None, |_| {})
502            .expect("outDir-only package loads");
503        let entries = public_entry_paths(&session);
504        for entry in ["src/index.ts", "src/browser.ts", "src/cli.ts"] {
505            assert!(
506                entries.iter().any(|path| path.ends_with(entry)),
507                "public output entry {entry} should map to its source, public entries: {entries:?}"
508            );
509        }
510        assert!(
511            !entries.iter().any(|path| path.ends_with("src/internal.ts")),
512            "mapping public outputs must not expose unrelated source files, public entries: {entries:?}"
513        );
514    }
515
516    #[test]
517    fn declaration_dir_without_out_dir_resolves_package_types_entries() {
518        for with_output in [true, false] {
519            let directory = tempfile::tempdir().expect("temporary project directory");
520            let root = directory.path();
521            std::fs::create_dir_all(root.join("source")).expect("source directory");
522            std::fs::write(
523                root.join("package.json"),
524                r#"{
525                    "name":"declaration-only-package",
526                    "types":"./types/index.d.ts",
527                    "exports":{".":{"types":"./types/index.d.ts"}}
528                }"#,
529            )
530            .expect("package manifest");
531            std::fs::write(
532                root.join("tsconfig.build.json"),
533                r#"{
534                    "compilerOptions": {
535                        "rootDir":"./source",
536                        "declarationDir":"./types"
537                    }
538                }"#,
539            )
540            .expect("TypeScript config");
541            std::fs::write(
542                root.join("source/index.ts"),
543                "export const publicValue = 1;\n",
544            )
545            .expect("source entry");
546            std::fs::write(
547                root.join("source/internal.ts"),
548                "export const internalValue = 1;\n",
549            )
550            .expect("unrelated source file");
551            if with_output {
552                std::fs::create_dir_all(root.join("types")).expect("types directory");
553                std::fs::write(
554                    root.join("types/index.d.ts"),
555                    "export declare const publicValue: 1;\n",
556                )
557                .expect("generated declaration entry");
558            }
559
560            let session = AnalysisSession::load_with_config(root, None, |_| {})
561                .expect("declaration-only project loads");
562            assert_eq!(
563                public_entry_paths(&session),
564                vec![root.join("source/index.ts")],
565                "declarationDir should resolve with output present={with_output}"
566            );
567        }
568    }
569
570    #[test]
571    fn lib_public_entry_maps_to_source_only_when_lib_is_missing() {
572        for with_lib in [false, true] {
573            let directory = tempfile::tempdir().expect("temporary project directory");
574            let root = directory.path();
575            std::fs::create_dir_all(root.join("src")).expect("source directory");
576            std::fs::write(
577                root.join("package.json"),
578                r#"{"name":"lib-output-package","exports":{".":"./lib/index.mjs","./package.json":"./package.json"}}"#,
579            )
580            .expect("package manifest");
581            std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
582                .expect("source entry");
583            if with_lib {
584                std::fs::create_dir_all(root.join("lib")).expect("lib directory");
585                std::fs::write(root.join("lib/index.mjs"), "export const value = 1;\n")
586                    .expect("hand-written lib entry");
587            }
588
589            let session =
590                AnalysisSession::load_with_config(root, None, |_| {}).expect("project loads");
591            let entries = public_entry_paths(&session);
592            assert_eq!(
593                entries.iter().any(|path| path.ends_with("src/index.ts")),
594                !with_lib,
595                "src/index.ts is a public entry only when lib/ is missing (with_lib={with_lib}), entries: {entries:?}"
596            );
597            assert_eq!(
598                entries.iter().any(|path| path.ends_with("lib/index.mjs")),
599                with_lib,
600                "a lib/ file on disk stays the public entry (with_lib={with_lib}), entries: {entries:?}"
601            );
602        }
603    }
604
605    #[test]
606    fn ignored_lib_public_entry_on_disk_maps_to_source() {
607        let directory = tempfile::tempdir().expect("temporary project directory");
608        let root = directory.path();
609        std::fs::create_dir_all(root.join("src")).expect("source directory");
610        std::fs::create_dir_all(root.join("lib")).expect("lib directory");
611        std::fs::write(
612            root.join("package.json"),
613            r#"{"name":"lib-output-package","exports":{".":"./lib/index.mjs"}}"#,
614        )
615        .expect("package manifest");
616        std::fs::write(
617            root.join(".fallowrc.json"),
618            r#"{"ignorePatterns":["lib/**"]}"#,
619        )
620        .expect("fallow config");
621        std::fs::write(root.join("src/index.ts"), "export const value = 1;\n")
622            .expect("source entry");
623        std::fs::write(root.join("lib/index.mjs"), "export const value = 1;\n")
624            .expect("ignored build output");
625
626        let session = AnalysisSession::load_with_config(root, None, |_| {}).expect("project loads");
627        let entries = public_entry_paths(&session);
628        assert!(
629            entries.iter().any(|path| path.ends_with("src/index.ts")),
630            "a lib/ entry outside the discovered file set maps to src/, entries: {entries:?}"
631        );
632    }
633
634    #[test]
635    fn ambiguous_configured_output_does_not_use_legacy_public_entry_guess() {
636        for with_output in [true, false] {
637            let directory = tempfile::tempdir().expect("temporary project directory");
638            let root = directory.path();
639            for source_dir in ["source", "src"] {
640                std::fs::create_dir_all(root.join(source_dir)).expect("source directory");
641                std::fs::write(
642                    root.join(source_dir).join("index.ts"),
643                    "export const value = 1;\n",
644                )
645                .expect("source entry");
646            }
647            std::fs::write(
648                root.join("package.json"),
649                r#"{"name":"ambiguous-output-package","exports":{".":"./dist/index.js","./runtime":"./runtime.ts"}}"#,
650            )
651            .expect("package manifest");
652            std::fs::write(root.join("runtime.ts"), "export const runtime = 1;\n")
653                .expect("independent runtime export");
654            for (config_name, source_dir) in [
655                ("tsconfig.source.json", "source"),
656                ("tsconfig.src.json", "src"),
657            ] {
658                std::fs::write(
659                    root.join(config_name),
660                    format!(
661                        r#"{{"compilerOptions":{{"rootDir":"./{source_dir}","outDir":"./dist"}}}}"#
662                    ),
663                )
664                .expect("TypeScript config");
665            }
666            if with_output {
667                std::fs::create_dir_all(root.join("dist")).expect("output directory");
668                std::fs::write(root.join("dist/index.js"), "export const value = 1;\n")
669                    .expect("generated output");
670            }
671
672            let session = AnalysisSession::load_with_config(root, None, |_| {})
673                .expect("ambiguous project loads");
674            let entries = public_entry_paths(&session);
675            assert!(
676                !entries.iter().any(|path| path.ends_with("src/index.ts")),
677                "an ambiguous configured map must not expose the legacy src guess with output present={with_output}, entries: {entries:?}"
678            );
679            assert!(
680                !entries.iter().any(|path| path.ends_with("source/index.ts")),
681                "an ambiguous configured map must not expose either source candidate, entries: {entries:?}"
682            );
683        }
684
685        let legacy = tempfile::tempdir().expect("legacy project directory");
686        std::fs::create_dir_all(legacy.path().join("src")).expect("legacy source directory");
687        std::fs::write(
688            legacy.path().join("package.json"),
689            r#"{"name":"legacy-output-package","main":"./dist/index.js"}"#,
690        )
691        .expect("package manifest");
692        std::fs::write(
693            legacy.path().join("src/index.ts"),
694            "export const value = 1;\n",
695        )
696        .expect("legacy source entry");
697        let session = AnalysisSession::load_with_config(legacy.path(), None, |_| {})
698            .expect("legacy project loads");
699        assert!(
700            public_entry_paths(&session)
701                .iter()
702                .any(|path| path.ends_with("src/index.ts")),
703            "the legacy dist/src convention should still resolve without a matching config"
704        );
705    }
706}