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::path::Path;
10
11use bynk_emit::project::{CompiledFile, ProjectOutput};
12
13/// Write a [`ProjectOutput`]'s files under `dir`, creating parent directories as
14/// needed. The shared writer behind both `bynkc`'s `compile`/`test` paths and
15/// `bynk dev`'s in-process build (slice 7) — so the on-disk result is identical
16/// however the build was driven.
17///
18/// Reconciles `dir` against `out.files` first: a `.ts`/`.js`/`.map`/`.json`/
19/// `.toml` file already on disk that no longer corresponds to anything in
20/// `out.files` (or one of its `.map`/`.bynkdbg.json` sidecars) is deleted,
21/// along with any directory that becomes empty as a result — otherwise a
22/// deleted `.bynk` unit's emitted `.ts` lingers on disk, still type-checked by
23/// the emitted `tsconfig.json`'s `include: **/*.ts`, so `tsc` fails against a
24/// module the current project no longer has. `node_modules` and dotfile
25/// directories (`.git`, an npm-installed tree under the output root) are
26/// never descended into — this reconciles the compiler's own output, not
27/// whatever else happens to live alongside it.
28pub fn write_output(out: &ProjectOutput, dir: &Path) -> std::io::Result<()> {
29    prune_stale_output(out, dir)?;
30    for file in &out.files {
31        write_compiled_file(file, dir)?;
32    }
33    Ok(())
34}
35
36/// The project-relative paths [`write_output`] will have written once this
37/// `ProjectOutput` lands on disk — each `CompiledFile::output_path` plus its
38/// `.map` / `.bynkdbg.json` sidecars, named exactly as [`write_compiled_file`]
39/// names them.
40fn expected_output_paths(out: &ProjectOutput) -> std::collections::HashSet<std::path::PathBuf> {
41    let mut expected = std::collections::HashSet::new();
42    for file in &out.files {
43        expected.insert(file.output_path.clone());
44        let Some(name) = file.output_path.file_name() else {
45            continue;
46        };
47        if file.source_map.is_some() {
48            let map_name = format!("{}.map", name.to_string_lossy());
49            expected.insert(file.output_path.with_file_name(map_name));
50        }
51        if file.debug_metadata.is_some() {
52            let meta_name = format!("{}.bynkdbg.json", name.to_string_lossy());
53            expected.insert(file.output_path.with_file_name(meta_name));
54        }
55    }
56    expected
57}
58
59/// Extensions the compiler ever writes under a build-output directory — the
60/// set [`write_output`]'s reconciliation is allowed to prune. Kept narrow so a
61/// directory the caller points `write_output` at can still carry other files
62/// unrelated to a `.bynk` build without those being swept up.
63fn is_prunable_output_extension(ext: &str) -> bool {
64    matches!(ext, "ts" | "js" | "map" | "json" | "toml")
65}
66
67fn prune_stale_output(out: &ProjectOutput, dir: &Path) -> std::io::Result<()> {
68    if !dir.is_dir() {
69        return Ok(());
70    }
71    let expected = expected_output_paths(out);
72    let mut dirs_visited = Vec::new();
73    prune_stale_output_dir(dir, dir, &expected, &mut dirs_visited)?;
74    // Remove directories left empty by the file removals above, deepest first
75    // (a parent only empties out once its children are gone). `remove_dir` is
76    // a no-op error (ignored) on anything still non-empty — e.g. a directory
77    // that held only unrelated files to begin with.
78    dirs_visited.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
79    for d in dirs_visited {
80        let _ = std::fs::remove_dir(&d);
81    }
82    Ok(())
83}
84
85fn prune_stale_output_dir(
86    root: &Path,
87    dir: &Path,
88    expected: &std::collections::HashSet<std::path::PathBuf>,
89    dirs_visited: &mut Vec<std::path::PathBuf>,
90) -> std::io::Result<()> {
91    for entry in std::fs::read_dir(dir)? {
92        let entry = entry?;
93        let path = entry.path();
94        let file_type = entry.file_type()?;
95        if file_type.is_dir() {
96            let is_own_cache = path
97                .file_name()
98                .and_then(|n| n.to_str())
99                .is_some_and(|n| n == "node_modules" || n.starts_with('.'));
100            if is_own_cache {
101                continue;
102            }
103            prune_stale_output_dir(root, &path, expected, dirs_visited)?;
104            dirs_visited.push(path);
105        } else if file_type.is_file() {
106            let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
107            let rel = path.strip_prefix(root).unwrap_or(&path);
108            if is_prunable_output_extension(ext) && !expected.contains(rel) {
109                std::fs::remove_file(&path)?;
110            }
111        }
112    }
113    Ok(())
114}
115
116/// Write a single [`CompiledFile`] under `dir`, map-aware: a `.bynk`-sourced file
117/// gets a sibling `.ts.map` and a `//# sourceMappingURL` trailer (slice 1, ADR
118/// 0103); a file with no map is written verbatim. Shared by [`write_output`] and
119/// `bynkc test`'s output loops, so every disk-writing path emits maps uniformly
120/// (slice 2 — `bynkc test --inspect` runs the emitted `.ts` directly and needs
121/// the maps on disk). The trailer lives only on the on-disk artefact; the
122/// in-memory `file.typescript` stays trailer-free, so golden comparisons are
123/// unaffected. The map name appends `.map` to the output file name.
124pub fn write_compiled_file(file: &CompiledFile, dir: &Path) -> std::io::Result<()> {
125    let target = dir.join(&file.output_path);
126    if let Some(parent) = target.parent() {
127        std::fs::create_dir_all(parent)?;
128    }
129    match &file.source_map {
130        Some(map) => {
131            let map_name = match target.file_name() {
132                Some(n) => format!("{}.map", n.to_string_lossy()),
133                None => "module.ts.map".to_string(),
134            };
135            let map_path = target.with_file_name(&map_name);
136            std::fs::write(&map_path, map)?;
137            let with_trailer = format!("{}//# sourceMappingURL={map_name}\n", file.typescript);
138            std::fs::write(&target, with_trailer)?;
139        }
140        None => std::fs::write(&target, &file.typescript)?,
141    }
142    // Slice 3 (ADR 0105): the debug-metadata sidecar — a `<file>.bynkdbg.json` next
143    // to the `.ts`, mapping each emitted handler to its Bynk operation label so the
144    // debugger names stack frames in Bynk. A sibling like the `.ts.map`; not bundled
145    // into a deployed Worker.
146    if let Some(meta) = &file.debug_metadata {
147        let meta_name = match target.file_name() {
148            Some(n) => format!("{}.bynkdbg.json", n.to_string_lossy()),
149            None => "module.ts.bynkdbg.json".to_string(),
150        };
151        std::fs::write(target.with_file_name(meta_name), meta)?;
152    }
153    Ok(())
154}