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, 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.docs` 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, along with any directory
22/// that becomes empty as a result — otherwise a deleted `.bynk` unit's
23/// emitted `.ts` lingers on disk, still type-checked by the emitted
24/// `tsconfig.json`'s `include: **/*.ts`, so `tsc` fails against a module the
25/// current project no longer has. `node_modules` and dotfile directories
26/// (`.git`, an npm-installed tree under the output root) are never
27/// descended into — this reconciles the compiler's own output, not whatever
28/// else happens to live alongside it.
29pub fn write_output(out: &ProjectOutput, dir: &Path) -> std::io::Result<()> {
30 prune_stale_output(out, dir)?;
31 for (path, doc) in &out.artefacts.docs {
32 write_document(path, doc, &out.artefacts.docs, dir)?;
33 }
34 Ok(())
35}
36
37/// Write one [`Document`] under `dir` — the real, typed write boundary
38/// (P7.6, #1309, Decision C: one place derives the sibling-path relationship
39/// instead of two independently-maintained ones). Shared by [`write_output`]
40/// and `bynk-driver::test_runner`'s own output loop (`bynkc test`), so every
41/// disk-writing path emits maps uniformly (slice 2 — `bynkc test --inspect`
42/// runs the emitted `.ts` directly and needs the maps on disk).
43///
44/// A `Ts` document is printed through the one printer that owns a character
45/// (R7.3); its own `Printed::source_map` is discarded, not written — the
46/// real map, when one exists, is already present in `docs` as its own
47/// `SourceMap` entry at this path's `.map` sibling ([`sibling_path`]), split
48/// out at construction (`bynk-emit::project::build_output`'s own tail).
49/// That sibling's presence, not `Printed::source_map`, is what decides
50/// whether the `.ts`/`.js` file gets a `//# sourceMappingURL=` trailer — the
51/// trailer lives only on the on-disk artefact, not on `docs`' own in-memory
52/// text, so golden comparisons are unaffected.
53pub fn write_document(
54 path: &Path,
55 doc: &Document,
56 docs: &BTreeMap<PathBuf, Document>,
57 dir: &Path,
58) -> std::io::Result<()> {
59 let target = dir.join(path);
60 if let Some(parent) = target.parent() {
61 std::fs::create_dir_all(parent)?;
62 }
63 let mapped_text = match doc {
64 Document::Ts(program) => {
65 Some(bynk_ts::print(program, "", "", &path.to_string_lossy()).text)
66 }
67 Document::Js(s) => Some(s.clone()),
68 _ => None,
69 };
70 if let Some(text) = mapped_text {
71 match docs.get(&sibling_path(path, "map")) {
72 Some(_) => {
73 let map_name = match target.file_name() {
74 Some(n) => format!("{}.map", n.to_string_lossy()),
75 None => "module.ts.map".to_string(),
76 };
77 let with_trailer = format!("{text}//# sourceMappingURL={map_name}\n");
78 std::fs::write(&target, with_trailer)?;
79 }
80 None => std::fs::write(&target, &text)?,
81 }
82 return Ok(());
83 }
84 match doc {
85 Document::Toml(t) => {
86 std::fs::write(
87 &target,
88 bynk_emit::emitter::toml_doc::print_toml_document(t),
89 )?;
90 }
91 Document::Json(s) | Document::SourceMap(s) | Document::DebugSidecar(s) => {
92 std::fs::write(&target, s)?;
93 }
94 Document::Ts(_) | Document::Js(_) => unreachable!("handled above"),
95 }
96 Ok(())
97}
98
99/// The project-relative paths [`write_output`] will have written once this
100/// `ProjectOutput` lands on disk — exactly `out.artefacts.docs`'s own keys,
101/// since `bynk-emit::project::build_output`'s own tail already splits every
102/// `.map`/`.bynkdbg.json` sidecar into its own entry there (Decision C).
103fn expected_output_paths(out: &ProjectOutput) -> std::collections::HashSet<PathBuf> {
104 out.artefacts.docs.keys().cloned().collect()
105}
106
107/// Extensions the compiler ever writes under a build-output directory — the
108/// set [`write_output`]'s reconciliation is allowed to prune. Kept narrow so a
109/// directory the caller points `write_output` at can still carry other files
110/// unrelated to a `.bynk` build without those being swept up.
111fn is_prunable_output_extension(ext: &str) -> bool {
112 matches!(ext, "ts" | "js" | "map" | "json" | "toml")
113}
114
115fn prune_stale_output(out: &ProjectOutput, dir: &Path) -> std::io::Result<()> {
116 if !dir.is_dir() {
117 return Ok(());
118 }
119 let expected = expected_output_paths(out);
120 let mut dirs_visited = Vec::new();
121 prune_stale_output_dir(dir, dir, &expected, &mut dirs_visited)?;
122 // Remove directories left empty by the file removals above, deepest first
123 // (a parent only empties out once its children are gone). `remove_dir` is
124 // a no-op error (ignored) on anything still non-empty — e.g. a directory
125 // that held only unrelated files to begin with.
126 dirs_visited.sort_by_key(|p| std::cmp::Reverse(p.components().count()));
127 for d in dirs_visited {
128 let _ = std::fs::remove_dir(&d);
129 }
130 Ok(())
131}
132
133fn prune_stale_output_dir(
134 root: &Path,
135 dir: &Path,
136 expected: &std::collections::HashSet<std::path::PathBuf>,
137 dirs_visited: &mut Vec<std::path::PathBuf>,
138) -> std::io::Result<()> {
139 for entry in std::fs::read_dir(dir)? {
140 let entry = entry?;
141 let path = entry.path();
142 let file_type = entry.file_type()?;
143 if file_type.is_dir() {
144 let is_own_cache = path
145 .file_name()
146 .and_then(|n| n.to_str())
147 .is_some_and(|n| n == "node_modules" || n.starts_with('.'));
148 if is_own_cache {
149 continue;
150 }
151 prune_stale_output_dir(root, &path, expected, dirs_visited)?;
152 dirs_visited.push(path);
153 } else if file_type.is_file() {
154 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
155 let rel = path.strip_prefix(root).unwrap_or(&path);
156 if is_prunable_output_extension(ext) && !expected.contains(rel) {
157 std::fs::remove_file(&path)?;
158 }
159 }
160 }
161 Ok(())
162}