Skip to main content

bynk_driver/
output.rs

1//! Writing a compiled project's output to disk.
2//!
3//! Moved down from `bynk-emit` (#1047, R2.3/T0.7 residue): every caller was
4//! already at driver level (`bynkc`'s CLI/test paths, `bynk dev`'s in-process
5//! build) — `bynk-emit` never called this itself, so relocating it here is a
6//! pure move, not a design change. `bynk-emit` stays a pure, in-memory
7//! library; disk writes are the driver's job, as R2.3 says they should be.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use bynk_emit::project::{Document, ProjectOutput, is_worker_asset_path, sibling_path};
13
14/// Write a [`ProjectOutput`]'s artefacts under `dir`, creating parent
15/// directories as needed. The shared writer behind both `bynkc`'s
16/// `compile`/`test` paths and `bynk dev`'s in-process build (slice 7) — so
17/// the on-disk result is identical however the build was driven.
18///
19/// Reconciles `dir` against `out.artefacts` first: a `.ts`/`.js`/
20/// `.map`/`.json`/`.toml` file already on disk that no longer corresponds to
21/// anything in `out.artefacts.docs` is deleted — as is any file at all under
22/// a Worker's compiler-owned assets directory (#1883, slice 0;
23/// [`bynk_emit::project::WORKER_ASSETS_DIR`]) that is not in
24/// `out.artefacts.assets` — along with any directory
25/// that becomes empty as a result — otherwise a deleted `.bynk` unit's
26/// emitted `.ts` lingers on disk, still type-checked by the emitted
27/// `tsconfig.json`'s `include: **/*.ts`, so `tsc` fails against a module the
28/// current project no longer has. `node_modules` and dotfile directories
29/// (`.git`, an npm-installed tree under the output root) are never
30/// descended into — this reconciles the compiler's own output, not whatever
31/// else happens to live alongside it.
32pub fn write_output(out: &ProjectOutput, dir: &Path) -> std::io::Result<()> {
33    prune_stale_output(out, dir)?;
34    for (path, doc) in &out.artefacts.docs {
35        write_document(path, doc, &out.artefacts.docs, dir)?;
36    }
37    for (path, bytes) in &out.artefacts.assets {
38        // Review of #1902: a path in both maps would silently resolve to the
39        // asset, decided only by loop order. Unreachable while documents never
40        // land in an assets directory; this makes it a test failure if one does.
41        debug_assert!(
42            !out.artefacts.docs.contains_key(path),
43            "`{}` is both a document and a static asset",
44            path.display()
45        );
46        write_asset(path, bytes, dir)?;
47    }
48    write_modules(&out.artefacts.modules, dir)
49}
50
51/// #2321: write the WebAssembly modules adapter bindings include, byte for
52/// byte, beside the bindings that load them.
53pub fn write_modules(modules: &BTreeMap<PathBuf, Vec<u8>>, dir: &Path) -> std::io::Result<()> {
54    for (path, bytes) in modules {
55        let target = dir.join(path);
56        if let Some(parent) = target.parent() {
57            std::fs::create_dir_all(parent)?;
58        }
59        std::fs::write(&target, bytes)?;
60    }
61    Ok(())
62}
63
64/// #2321: for a run on Node (`bynk test`), write the modules and overwrite each
65/// one's Workers loader shim with the Node form
66/// ([`bynk_emit::project::node_wasm_shim`]), which inlines the module. Call it
67/// after every document write, since a later write of `docs` restores the
68/// Workers form.
69pub fn write_modules_for_node(
70    modules: &BTreeMap<PathBuf, Vec<u8>>,
71    dir: &Path,
72) -> std::io::Result<()> {
73    write_modules(modules, dir)?;
74    for (path, bytes) in modules {
75        std::fs::write(
76            dir.join(bynk_emit::project::wasm_shim_path(path)),
77            bynk_emit::project::node_wasm_shim(bytes),
78        )?;
79    }
80    Ok(())
81}
82
83/// #1883, slice 0: write one static asset's bytes under `dir`, verbatim. The
84/// binary-safe counterpart of [`write_document`] — an asset is not a
85/// [`Document`] (see `bynk_emit::project::Artefacts::assets` for why), so it
86/// gets no printer, no source-map trailer and no text conversion.
87///
88/// Every asset must lie under a Worker's compiler-owned assets directory
89/// ([`is_worker_asset_path`]): that is the only place reconciliation prunes
90/// files of any extension, so an asset written anywhere else would linger
91/// after it stopped being produced. Producers build the path through
92/// `bynk_emit::project::worker_assets_dir`; this assertion makes bypassing it
93/// a test failure rather than a silent leak.
94fn write_asset(path: &Path, bytes: &[u8], dir: &Path) -> std::io::Result<()> {
95    debug_assert!(
96        is_worker_asset_path(path),
97        "static asset `{}` is outside every Worker's assets directory",
98        path.display()
99    );
100    let target = dir.join(path);
101    if let Some(parent) = target.parent() {
102        std::fs::create_dir_all(parent)?;
103    }
104    std::fs::write(&target, bytes)
105}
106
107/// Write one [`Document`] under `dir` — the real, typed write boundary
108/// (P7.6, #1309, Decision C: one place derives the sibling-path relationship
109/// instead of two independently-maintained ones). Shared by [`write_output`]
110/// and `bynk-driver::test_runner`'s own output loop (`bynkc test`), so every
111/// disk-writing path emits maps uniformly (slice 2 — `bynkc test --inspect`
112/// runs the emitted `.ts` directly and needs the maps on disk).
113///
114/// A `Ts` document is printed through the one printer that owns a character
115/// (R7.3); its own `Printed::source_map` is discarded, not written — the
116/// real map, when one exists, is already present in `docs` as its own
117/// `SourceMap` entry at this path's `.map` sibling ([`sibling_path`]), split
118/// out at construction (`bynk-emit::project::build_output`'s own tail).
119/// That sibling's presence, not `Printed::source_map`, is what decides
120/// whether the `.ts`/`.js` file gets a `//# sourceMappingURL=` trailer — the
121/// trailer lives only on the on-disk artefact, not on `docs`' own in-memory
122/// text, so golden comparisons are unaffected.
123pub fn write_document(
124    path: &Path,
125    doc: &Document,
126    docs: &BTreeMap<PathBuf, Document>,
127    dir: &Path,
128) -> std::io::Result<()> {
129    let target = dir.join(path);
130    if let Some(parent) = target.parent() {
131        std::fs::create_dir_all(parent)?;
132    }
133    let mapped_text = match doc {
134        Document::Ts(program) => {
135            Some(bynk_ts::print(program, "", "", &path.to_string_lossy()).text)
136        }
137        Document::Js(s) => Some(s.clone()),
138        _ => None,
139    };
140    if let Some(text) = mapped_text {
141        match docs.get(&sibling_path(path, "map")) {
142            Some(_) => {
143                let map_name = match target.file_name() {
144                    Some(n) => format!("{}.map", n.to_string_lossy()),
145                    None => "module.ts.map".to_string(),
146                };
147                let with_trailer = format!("{text}//# sourceMappingURL={map_name}\n");
148                std::fs::write(&target, with_trailer)?;
149            }
150            None => std::fs::write(&target, &text)?,
151        }
152        return Ok(());
153    }
154    match doc {
155        Document::Toml(t) => {
156            std::fs::write(
157                &target,
158                bynk_emit::emitter::toml_doc::print_toml_document(t),
159            )?;
160        }
161        Document::Json(s) | Document::SourceMap(s) | Document::DebugSidecar(s) => {
162            std::fs::write(&target, s)?;
163        }
164        Document::Ts(_) | Document::Js(_) => unreachable!("handled above"),
165    }
166    Ok(())
167}
168
169/// The project-relative paths [`write_output`] will have written once this
170/// `ProjectOutput` lands on disk — exactly `out.artefacts.docs`'s own keys
171/// (`bynk-emit::project::build_output`'s own tail already splits every
172/// `.map`/`.bynkdbg.json` sidecar into its own entry there, Decision C), plus
173/// `out.artefacts.assets`'s (#1883, slice 0) and `out.artefacts.modules`' (#2321).
174fn expected_output_paths(out: &ProjectOutput) -> std::collections::HashSet<PathBuf> {
175    out.artefacts
176        .docs
177        .keys()
178        .chain(out.artefacts.assets.keys())
179        .chain(out.artefacts.modules.keys())
180        .cloned()
181        .collect()
182}
183
184/// Extensions the compiler ever writes under a build-output directory — the
185/// set [`write_output`]'s reconciliation is allowed to prune. Kept narrow so a
186/// directory the caller points `write_output` at can still carry other files
187/// unrelated to a `.bynk` build without those being swept up.
188fn is_prunable_output_extension(ext: &str) -> bool {
189    matches!(ext, "ts" | "js" | "map" | "json" | "toml" | "wasm")
190}
191
192fn prune_stale_output(out: &ProjectOutput, dir: &Path) -> std::io::Result<()> {
193    if !dir.is_dir() {
194        return Ok(());
195    }
196    let expected = expected_output_paths(out);
197    let mut dirs_visited = Vec::new();
198    prune_stale_output_dir(dir, dir, &expected, &mut dirs_visited)?;
199    // Remove directories left empty by the file removals above, deepest first
200    // (a parent only empties out once its children are gone). `remove_dir` is
201    // a no-op error (ignored) on anything still non-empty — e.g. a directory
202    // that held only unrelated files to begin with.
203    dirs_visited.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
204    for d in dirs_visited {
205        let _ = std::fs::remove_dir(&d);
206    }
207    Ok(())
208}
209
210fn prune_stale_output_dir(
211    root: &Path,
212    dir: &Path,
213    expected: &std::collections::HashSet<std::path::PathBuf>,
214    dirs_visited: &mut Vec<std::path::PathBuf>,
215) -> std::io::Result<()> {
216    for entry in std::fs::read_dir(dir)? {
217        let entry = entry?;
218        let path = entry.path();
219        let file_type = entry.file_type()?;
220        let rel = path.strip_prefix(root).unwrap_or(&path);
221        // #1883, slice 0: the compiler owns a Worker's assets directory
222        // outright. Review of #1902: that has to hold for every kind of
223        // entry there, or an asset the build stopped producing lingers and
224        // keeps being served.
225        let owned_asset = is_worker_asset_path(rel);
226        if file_type.is_dir() {
227            let is_own_cache = path
228                .file_name()
229                .and_then(|n| n.to_str())
230                .is_some_and(|n| n == "node_modules" || n.starts_with('.'));
231            // The `.git`/`node_modules` skip protects trees that live beside
232            // the compiler's output. Inside an assets directory there are no
233            // such trees, and a dot-directory (`.well-known/`) holds assets.
234            if is_own_cache && !owned_asset {
235                continue;
236            }
237            prune_stale_output_dir(root, &path, expected, dirs_visited)?;
238            dirs_visited.push(path);
239        } else if file_type.is_file() {
240            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
241            // Inside an assets directory the extension filter does not apply:
242            // an asset keeps its source's extension (`.css`, `.png`, …) and
243            // the platform's `_headers` file has none.
244            let prunable = is_prunable_output_extension(ext) || owned_asset;
245            if prunable && !expected.contains(rel) {
246                std::fs::remove_file(&path)?;
247            }
248        } else if file_type.is_symlink() && owned_asset && !expected.contains(rel) {
249            // `DirEntry::file_type` does not follow links, so a symlink is
250            // neither `is_dir` nor `is_file`. `remove_file` removes the link
251            // itself, never its target.
252            std::fs::remove_file(&path)?;
253        }
254    }
255    Ok(())
256}