Skip to main content

fallow_graph/resolve/
output_entry.rs

1//! Map a package manifest entry in a build output directory to its source file.
2//!
3//! Entry point discovery (`fallow-core`) and the public API entry set
4//! (`fallow-engine`) share these helpers, so both apply the same rule.
5
6use std::path::{Component, Path, PathBuf};
7
8use super::types::{MISSING_ONLY_OUTPUT_DIRS, OUTPUT_DIRS};
9
10/// Map an output directory entry to its same-stem source file.
11///
12/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries
13/// `/project/packages/ui/src/utils.<ext>` for each extension in
14/// `source_extensions`. A path prefix between the package root and the output
15/// directory stays: `./modules/dist/utils.js` maps to `modules/src/utils.ts`.
16///
17/// `OUTPUT_DIRS` decide first. A `lib/` entry (`MISSING_ONLY_OUTPUT_DIRS`)
18/// maps only when the entry target is absent. The target is present when the
19/// filesystem probe finds a file and `is_discovered` accepts that file. Thus a
20/// hand-written `lib/` file in the analyzed file set stays the entry, and a
21/// `lib/` build output that `.gitignore` or `ignorePatterns` excludes maps to
22/// `src/`.
23///
24/// The probe and `is_discovered` run only for a `lib/` entry that has a
25/// same-stem source file. Other entries never call `is_discovered`, so a
26/// caller can build an expensive lookup on first use.
27pub fn output_entry_to_source_path(
28    base: &Path,
29    entry: &str,
30    source_extensions: &[&str],
31    is_discovered: impl Fn(&Path) -> bool,
32) -> Option<PathBuf> {
33    output_dir_to_source_path(base, entry, OUTPUT_DIRS, source_extensions).or_else(|| {
34        let resolved = base.join(entry);
35        if is_bare_missing_only_dir(&resolved) {
36            return None;
37        }
38        let source =
39            output_dir_to_source_path(base, entry, MISSING_ONLY_OUTPUT_DIRS, source_extensions)?;
40        let target_present = probed_entry_target(&resolved, source_extensions)
41            .is_some_and(|target| is_discovered(&target));
42        (!target_present).then_some(source)
43    })
44}
45
46/// Return the first `index.<ext>` file in the directory `resolved`.
47pub fn directory_index_entry(resolved: &Path, source_extensions: &[&str]) -> Option<PathBuf> {
48    source_extensions
49        .iter()
50        .map(|ext| resolved.join(format!("index.{ext}")))
51        .find(|candidate| candidate.is_file())
52}
53
54/// Map the last `dirs` component of `entry` to `src/` with the same stem.
55fn output_dir_to_source_path(
56    base: &Path,
57    entry: &str,
58    dirs: &[&str],
59    source_extensions: &[&str],
60) -> Option<PathBuf> {
61    let components: Vec<_> = Path::new(entry).components().collect();
62
63    let output_pos = components.iter().rposition(|component| {
64        if let Component::Normal(name) = component
65            && let Some(name) = name.to_str()
66        {
67            return dirs.contains(&name);
68        }
69        false
70    })?;
71
72    let prefix: PathBuf = components[..output_pos]
73        .iter()
74        .filter(|component| !matches!(component, Component::CurDir))
75        .collect();
76    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
77
78    source_extensions
79        .iter()
80        .map(|ext| {
81            base.join(&prefix)
82                .join("src")
83                .join(suffix.with_extension(ext))
84        })
85        .find(|candidate| candidate.exists())
86}
87
88/// Return `true` when `path` ends in a missing-only output directory, such as
89/// `./lib`. Such an entry has no file to map to a same-stem source file.
90fn is_bare_missing_only_dir(path: &Path) -> bool {
91    path.file_name()
92        .and_then(|name| name.to_str())
93        .is_some_and(|name| MISSING_ONLY_OUTPUT_DIRS.contains(&name))
94}
95
96/// Return the file that the filesystem probe finds for `resolved`: the exact
97/// file, then an extension variant, then a directory index.
98fn probed_entry_target(resolved: &Path, source_extensions: &[&str]) -> Option<PathBuf> {
99    if resolved.is_file() {
100        return Some(resolved.to_path_buf());
101    }
102    source_extensions
103        .iter()
104        .map(|ext| resolved.with_extension(ext))
105        .find(|candidate| candidate.is_file())
106        .or_else(|| directory_index_entry(resolved, source_extensions))
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    const EXTS: &[&str] = &["ts", "tsx", "js"];
114
115    fn write(path: &Path) {
116        std::fs::create_dir_all(path.parent().expect("parent directory")).expect("directory");
117        std::fs::write(path, "export {};\n").expect("file");
118    }
119
120    #[cfg_attr(miri, ignore)]
121    #[test]
122    fn missing_lib_entry_maps_to_source() {
123        let dir = tempfile::tempdir().expect("temporary directory");
124        write(&dir.path().join("src/index.ts"));
125
126        assert_eq!(
127            output_entry_to_source_path(dir.path(), "./lib/index.mjs", EXTS, |_| true),
128            Some(dir.path().join("src/index.ts"))
129        );
130    }
131
132    #[cfg_attr(miri, ignore)]
133    #[test]
134    fn discovered_lib_entry_stays_the_entry() {
135        let dir = tempfile::tempdir().expect("temporary directory");
136        write(&dir.path().join("src/index.ts"));
137        write(&dir.path().join("lib/index.js"));
138
139        assert_eq!(
140            output_entry_to_source_path(dir.path(), "./lib/index.js", EXTS, |_| true),
141            None
142        );
143        assert_eq!(
144            output_entry_to_source_path(dir.path(), "./lib/index", EXTS, |_| true),
145            None
146        );
147    }
148
149    #[cfg_attr(miri, ignore)]
150    #[test]
151    fn undiscovered_lib_entry_on_disk_maps_to_source() {
152        let dir = tempfile::tempdir().expect("temporary directory");
153        write(&dir.path().join("src/index.ts"));
154        write(&dir.path().join("lib/index.js"));
155
156        assert_eq!(
157            output_entry_to_source_path(dir.path(), "./lib/index.js", EXTS, |_| false),
158            Some(dir.path().join("src/index.ts"))
159        );
160    }
161
162    #[cfg_attr(miri, ignore)]
163    #[test]
164    fn discovered_check_runs_only_for_lib_entry_with_source() {
165        let dir = tempfile::tempdir().expect("temporary directory");
166        write(&dir.path().join("index.js"));
167        write(&dir.path().join("lib/other.js"));
168        write(&dir.path().join("lib/index.js"));
169        write(&dir.path().join("src/index.ts"));
170        let calls = std::cell::Cell::new(0);
171        let count = |_: &Path| {
172            calls.set(calls.get() + 1);
173            true
174        };
175
176        assert_eq!(
177            output_entry_to_source_path(dir.path(), "./index.js", EXTS, count),
178            None
179        );
180        assert_eq!(
181            output_entry_to_source_path(dir.path(), "./lib/other.js", EXTS, count),
182            None
183        );
184        assert_eq!(calls.get(), 0, "no lib/ entry with a source file, no check");
185
186        assert_eq!(
187            output_entry_to_source_path(dir.path(), "./lib/index.js", EXTS, count),
188            None
189        );
190        assert_eq!(calls.get(), 1);
191    }
192
193    #[cfg_attr(miri, ignore)]
194    #[test]
195    fn bare_lib_entry_does_not_map() {
196        let dir = tempfile::tempdir().expect("temporary directory");
197        write(&dir.path().join("src/index.ts"));
198
199        assert_eq!(
200            output_entry_to_source_path(dir.path(), "./lib", EXTS, |_| false),
201            None
202        );
203    }
204
205    #[cfg_attr(miri, ignore)]
206    #[test]
207    fn output_dirs_map_without_the_discovered_check() {
208        let dir = tempfile::tempdir().expect("temporary directory");
209        write(&dir.path().join("src/utils.ts"));
210        write(&dir.path().join("dist/utils.js"));
211
212        assert_eq!(
213            output_entry_to_source_path(dir.path(), "./dist/utils.js", EXTS, |_| true),
214            Some(dir.path().join("src/utils.ts"))
215        );
216    }
217}