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::fallbacks::{is_declaration_file_name, output_source_stem};
9use super::types::{MISSING_ONLY_OUTPUT_DIRS, OUTPUT_DIRS};
10
11/// Map an output directory entry to its same-stem source file.
12///
13/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries
14/// `/project/packages/ui/src/utils.<ext>` for each extension in
15/// `source_extensions`, then `src/utils/index.<ext>`. A declaration output
16/// such as `dist/utils.d.ts` maps to `src/utils.<ext>`. A path prefix between
17/// the package root and the output directory stays: `./modules/dist/utils.js`
18/// maps to `modules/src/utils.ts`.
19///
20/// `OUTPUT_DIRS` decide first. A `lib/` entry (`MISSING_ONLY_OUTPUT_DIRS`)
21/// maps only when the entry target is absent. The target is present when the
22/// filesystem probe finds a file and `is_discovered` accepts that file. Thus a
23/// hand-written `lib/` file in the analyzed file set stays the entry, and a
24/// `lib/` build output that `.gitignore` or `ignorePatterns` excludes maps to
25/// `src/`.
26///
27/// The probe and `is_discovered` run only for a `lib/` entry that has a
28/// same-stem source file. Other entries never call `is_discovered`, so a
29/// caller can build an expensive lookup on first use.
30pub fn output_entry_to_source_path(
31    base: &Path,
32    entry: &str,
33    source_extensions: &[&str],
34    is_discovered: impl Fn(&Path) -> bool,
35) -> Option<PathBuf> {
36    output_dir_to_source_path(base, entry, OUTPUT_DIRS, source_extensions).or_else(|| {
37        let resolved = base.join(entry);
38        if is_bare_missing_only_dir(&resolved) {
39            return None;
40        }
41        let source =
42            output_dir_to_source_path(base, entry, MISSING_ONLY_OUTPUT_DIRS, source_extensions)?;
43        let target_present = probed_entry_target(&resolved, source_extensions)
44            .is_some_and(|target| is_discovered(&target));
45        (!target_present).then_some(source)
46    })
47}
48
49/// Return the first `index.<ext>` file in the directory `resolved`.
50pub fn directory_index_entry(resolved: &Path, source_extensions: &[&str]) -> Option<PathBuf> {
51    source_extensions
52        .iter()
53        .map(|ext| resolved.join(format!("index.{ext}")))
54        .find(|candidate| candidate.is_file())
55}
56
57/// Map the last `dirs` component of `entry` to `src/` with the same stem.
58fn output_dir_to_source_path(
59    base: &Path,
60    entry: &str,
61    dirs: &[&str],
62    source_extensions: &[&str],
63) -> Option<PathBuf> {
64    let components: Vec<_> = Path::new(entry).components().collect();
65
66    let output_pos = components.iter().rposition(|component| {
67        if let Component::Normal(name) = component
68            && let Some(name) = name.to_str()
69        {
70            return dirs.contains(&name);
71        }
72        false
73    })?;
74
75    let prefix: PathBuf = components[..output_pos]
76        .iter()
77        .filter(|component| !matches!(component, Component::CurDir))
78        .collect();
79    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
80    let file_name = suffix.file_name()?.to_str()?;
81    let stem = output_source_stem(file_name);
82    let source_dir = match suffix.parent() {
83        Some(parent) => base.join(&prefix).join("src").join(parent),
84        None => base.join(&prefix).join("src"),
85    };
86
87    let same_stem = source_extensions
88        .iter()
89        .map(|ext| source_dir.join(format!("{stem}.{ext}")));
90    // A copied hand-written declaration keeps its name in `src/`.
91    let declaration = is_declaration_file_name(file_name).then(|| source_dir.join(file_name));
92    // Bundlers that name an output after its source directory emit
93    // `dist/core.mjs` from `src/core/index.ts`.
94    let directory_index = source_extensions
95        .iter()
96        .map(|ext| source_dir.join(stem).join(format!("index.{ext}")));
97
98    same_stem
99        .chain(declaration)
100        .chain(directory_index)
101        .find(|candidate| candidate.is_file())
102}
103
104/// Return `true` when `path` ends in a missing-only output directory, such as
105/// `./lib`. Such an entry has no file to map to a same-stem source file.
106fn is_bare_missing_only_dir(path: &Path) -> bool {
107    path.file_name()
108        .and_then(|name| name.to_str())
109        .is_some_and(|name| MISSING_ONLY_OUTPUT_DIRS.contains(&name))
110}
111
112/// Return the file that the filesystem probe finds for `resolved`: the exact
113/// file, then an extension variant, then a directory index.
114fn probed_entry_target(resolved: &Path, source_extensions: &[&str]) -> Option<PathBuf> {
115    if resolved.is_file() {
116        return Some(resolved.to_path_buf());
117    }
118    source_extensions
119        .iter()
120        .map(|ext| resolved.with_extension(ext))
121        .find(|candidate| candidate.is_file())
122        .or_else(|| directory_index_entry(resolved, source_extensions))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    const EXTS: &[&str] = &["ts", "tsx", "js"];
130
131    fn write(path: &Path) {
132        std::fs::create_dir_all(path.parent().expect("parent directory")).expect("directory");
133        std::fs::write(path, "export {};\n").expect("file");
134    }
135
136    #[cfg_attr(miri, ignore)]
137    #[test]
138    fn missing_lib_entry_maps_to_source() {
139        let dir = tempfile::tempdir().expect("temporary directory");
140        write(&dir.path().join("src/index.ts"));
141
142        assert_eq!(
143            output_entry_to_source_path(dir.path(), "./lib/index.mjs", EXTS, |_| true),
144            Some(dir.path().join("src/index.ts"))
145        );
146    }
147
148    #[cfg_attr(miri, ignore)]
149    #[test]
150    fn discovered_lib_entry_stays_the_entry() {
151        let dir = tempfile::tempdir().expect("temporary directory");
152        write(&dir.path().join("src/index.ts"));
153        write(&dir.path().join("lib/index.js"));
154
155        assert_eq!(
156            output_entry_to_source_path(dir.path(), "./lib/index.js", EXTS, |_| true),
157            None
158        );
159        assert_eq!(
160            output_entry_to_source_path(dir.path(), "./lib/index", EXTS, |_| true),
161            None
162        );
163    }
164
165    #[cfg_attr(miri, ignore)]
166    #[test]
167    fn undiscovered_lib_entry_on_disk_maps_to_source() {
168        let dir = tempfile::tempdir().expect("temporary directory");
169        write(&dir.path().join("src/index.ts"));
170        write(&dir.path().join("lib/index.js"));
171
172        assert_eq!(
173            output_entry_to_source_path(dir.path(), "./lib/index.js", EXTS, |_| false),
174            Some(dir.path().join("src/index.ts"))
175        );
176    }
177
178    #[cfg_attr(miri, ignore)]
179    #[test]
180    fn discovered_check_runs_only_for_lib_entry_with_source() {
181        let dir = tempfile::tempdir().expect("temporary directory");
182        write(&dir.path().join("index.js"));
183        write(&dir.path().join("lib/other.js"));
184        write(&dir.path().join("lib/index.js"));
185        write(&dir.path().join("src/index.ts"));
186        let calls = std::cell::Cell::new(0);
187        let count = |_: &Path| {
188            calls.set(calls.get() + 1);
189            true
190        };
191
192        assert_eq!(
193            output_entry_to_source_path(dir.path(), "./index.js", EXTS, count),
194            None
195        );
196        assert_eq!(
197            output_entry_to_source_path(dir.path(), "./lib/other.js", EXTS, count),
198            None
199        );
200        assert_eq!(calls.get(), 0, "no lib/ entry with a source file, no check");
201
202        assert_eq!(
203            output_entry_to_source_path(dir.path(), "./lib/index.js", EXTS, count),
204            None
205        );
206        assert_eq!(calls.get(), 1);
207    }
208
209    #[cfg_attr(miri, ignore)]
210    #[test]
211    fn bare_lib_entry_does_not_map() {
212        let dir = tempfile::tempdir().expect("temporary directory");
213        write(&dir.path().join("src/index.ts"));
214
215        assert_eq!(
216            output_entry_to_source_path(dir.path(), "./lib", EXTS, |_| false),
217            None
218        );
219    }
220
221    /// A bundler that names each output after its source directory emits
222    /// `dist/core.mjs` from `src/core/index.ts`.
223    #[cfg_attr(miri, ignore)]
224    #[test]
225    fn output_file_maps_to_the_source_directory_index() {
226        let dir = tempfile::tempdir().expect("temporary directory");
227        write(&dir.path().join("src/core/index.ts"));
228
229        for entry in ["./dist/core.mjs", "./dist/core.cjs", "dist/core.js"] {
230            assert_eq!(
231                output_entry_to_source_path(dir.path(), entry, EXTS, |_| true),
232                Some(dir.path().join("src/core/index.ts")),
233                "{entry}"
234            );
235        }
236    }
237
238    #[cfg_attr(miri, ignore)]
239    #[test]
240    fn same_stem_source_file_wins_over_the_directory_index() {
241        let dir = tempfile::tempdir().expect("temporary directory");
242        write(&dir.path().join("src/core.ts"));
243        write(&dir.path().join("src/core/index.ts"));
244
245        assert_eq!(
246            output_entry_to_source_path(dir.path(), "./dist/core.mjs", EXTS, |_| true),
247            Some(dir.path().join("src/core.ts"))
248        );
249    }
250
251    /// The compiler emits `dist/core/index.d.ts` from `src/core/index.ts`.
252    #[cfg_attr(miri, ignore)]
253    #[test]
254    fn declaration_output_maps_to_the_source_without_the_declaration_suffix() {
255        let dir = tempfile::tempdir().expect("temporary directory");
256        write(&dir.path().join("src/core/index.ts"));
257        write(&dir.path().join("src/feature.port.ts"));
258
259        for (entry, expected) in [
260            ("./dist/core/index.d.ts", "src/core/index.ts"),
261            ("./dist/core/index.d.mts", "src/core/index.ts"),
262            ("./dist/core/index.d.cts", "src/core/index.ts"),
263            ("./dist/feature.port.d.ts", "src/feature.port.ts"),
264        ] {
265            assert_eq!(
266                output_entry_to_source_path(dir.path(), entry, EXTS, |_| true),
267                Some(dir.path().join(expected)),
268                "{entry}"
269            );
270        }
271    }
272
273    /// A package can copy a hand-written declaration file to the output
274    /// directory unchanged.
275    #[cfg_attr(miri, ignore)]
276    #[test]
277    fn declaration_output_keeps_a_hand_written_source_declaration() {
278        let dir = tempfile::tempdir().expect("temporary directory");
279        write(&dir.path().join("src/types.d.ts"));
280
281        assert_eq!(
282            output_entry_to_source_path(dir.path(), "./dist/types.d.ts", EXTS, |_| true),
283            Some(dir.path().join("src/types.d.ts"))
284        );
285    }
286
287    #[cfg_attr(miri, ignore)]
288    #[test]
289    fn output_entry_without_a_source_does_not_map() {
290        let dir = tempfile::tempdir().expect("temporary directory");
291        write(&dir.path().join("src/other/index.ts"));
292
293        for entry in ["./dist/core.mjs", "./dist/core/index.d.ts"] {
294            assert_eq!(
295                output_entry_to_source_path(dir.path(), entry, EXTS, |_| true),
296                None,
297                "{entry}"
298            );
299        }
300    }
301
302    #[cfg_attr(miri, ignore)]
303    #[test]
304    fn output_dirs_map_without_the_discovered_check() {
305        let dir = tempfile::tempdir().expect("temporary directory");
306        write(&dir.path().join("src/utils.ts"));
307        write(&dir.path().join("dist/utils.js"));
308
309        assert_eq!(
310            output_entry_to_source_path(dir.path(), "./dist/utils.js", EXTS, |_| true),
311            Some(dir.path().join("src/utils.ts"))
312        );
313    }
314}