bynk_driver/lib.rs
1//! bynk-driver — the shared front-end of the `bynkc` and `bynk` CLIs (#521).
2//!
3//! Both binaries expose `fmt` and `check` with identical semantics; before
4//! this crate each re-implemented the command bodies (and the project-failure
5//! flattening layer, and the project-rooting rule) as by-hand copies pinned
6//! only by comments and a skip-able parity test. The single implementation
7//! lives here, parameterised by the program name that prefixes messages.
8
9use std::path::{Path, PathBuf};
10use std::process::ExitCode;
11
12use bynk_emit::project::{self, CompileOptions, read_project_paths};
13use bynk_fmt::{FormatOptions, format_source};
14
15/// Root a directory project the way every project command should (#46): a
16/// `bynk.toml` or a `src/` subdir selects **project** mode, whose flat
17/// `[paths] include`/`exclude` layout (v0.113, DECISION S) defaults to the
18/// conventional roots that exist (`src`, `tests`) or the project root itself;
19/// otherwise the legacy **single-tree** where `<dir>` is itself the root.
20/// `check`, `compile`, `test`, and `dev` all route through this so the
21/// conventional layout works the same from any of them.
22pub fn project_options(input: &Path) -> CompileOptions {
23 if input.join("bynk.toml").exists() || input.join("src").is_dir() {
24 CompileOptions::split(input.to_path_buf(), read_project_paths(input))
25 } else {
26 CompileOptions::single(input.to_path_buf())
27 }
28}
29
30/// Render a project build failure with per-file ariadne context, exactly as
31/// single-file mode had rich rendering. Unattributed (project-level) errors
32/// keep the plain form.
33///
34/// This is the **flattening layer** (ADR 0100): it attributes each
35/// `AttributedError` to its file snapshot and delegates the actual rendering to
36/// [`bynk_render::print_errors`]. The `ProjectFailure → CompileError` flattening
37/// stays here, above `bynk-render`, so there is no `render → emit` edge.
38pub fn print_project_failure(failure: &project::ProjectFailure) {
39 let texts: std::collections::HashMap<&Path, &str> = failure
40 .snapshots
41 .iter()
42 .map(|(p, t)| (p.as_path(), t.as_str()))
43 .collect();
44 for ae in &failure.errors {
45 match ae
46 .source_path
47 .as_deref()
48 .and_then(|p| texts.get(p).map(|t| (p, *t)))
49 {
50 Some((path, text)) => {
51 let label = path.to_string_lossy().replace('\\', "/");
52 bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
53 }
54 None => {
55 eprintln!("[{}] {}", ae.error.category, ae.error.message);
56 for note in &ae.error.notes {
57 eprintln!(" note: {note}");
58 }
59 }
60 }
61 }
62}
63
64/// v0.89 (ADR 0117): print a successful build's non-failing warnings. A
65/// successful build keeps no per-file snapshots, so warnings render in the
66/// plain `warning[<category>]: <message>` form (with the owning file, when
67/// known) rather than ariadne source context.
68pub fn print_project_warnings(warnings: &[project::AttributedError]) {
69 for w in warnings {
70 let where_ = w
71 .source_path
72 .as_deref()
73 .map(|p| format!("{}: ", p.to_string_lossy().replace('\\', "/")))
74 .unwrap_or_default();
75 eprintln!("{where_}warning[{}]: {}", w.error.category, w.error.message);
76 for note in &w.error.notes {
77 eprintln!(" note: {note}");
78 }
79 }
80}
81
82/// The project-failure analogue of [`bynk_render::print_errors_short`]: each
83/// attributed error is positioned against its file's snapshot; an unattributed
84/// (project-level) error falls back to `<severity>[<category>]: <message>`.
85pub fn print_project_failure_short(failure: &project::ProjectFailure) {
86 for line in project_failure_short_lines(failure) {
87 eprintln!("{line}");
88 }
89}
90
91/// The string form of [`print_project_failure_short`]: one `path:line:col:
92/// severity[category]: message` line per attributed error (an unattributed
93/// project-level error falls back to `severity[category]: message`). Backs both
94/// the printer above and the `bynkc test --format json` compile-error document,
95/// whose `diagnostics` the VS Code `bynkc` problem-matcher re-parses.
96///
97/// The flattening layer (ADR 0100): it delegates the per-error formatting to
98/// [`bynk_render::short_line`] / [`bynk_render::severity_word`].
99pub fn project_failure_short_lines(failure: &project::ProjectFailure) -> Vec<String> {
100 let texts: std::collections::HashMap<&Path, &str> = failure
101 .snapshots
102 .iter()
103 .map(|(p, t)| (p.as_path(), t.as_str()))
104 .collect();
105 failure
106 .errors
107 .iter()
108 .map(|ae| {
109 match ae
110 .source_path
111 .as_deref()
112 .and_then(|p| texts.get(p).map(|t| (p, *t)))
113 {
114 Some((path, text)) => {
115 let label = path.to_string_lossy().replace('\\', "/");
116 bynk_render::short_line(&label, text, &ae.error)
117 }
118 None => format!(
119 "{}[{}]: {}",
120 bynk_render::severity_word(&ae.error),
121 ae.error.category,
122 ae.error.message
123 ),
124 }
125 })
126 .collect()
127}
128
129/// The `fmt` command body shared by `bynkc fmt` and `bynk fmt`: each input is
130/// formatted and rewritten only when it changes; `--check` reports
131/// non-canonical files without writing; `-` reads stdin and writes the
132/// formatted result to stdout. `prog` prefixes messages (`bynk fmt: …`).
133pub fn run_fmt(prog: &str, inputs: &[PathBuf], check: bool) -> ExitCode {
134 let opts = FormatOptions::default();
135 if inputs.is_empty() {
136 eprintln!("{prog} fmt: no input files (pass file paths or `-` for stdin)");
137 return ExitCode::FAILURE;
138 }
139 let mut had_diff = false;
140 let mut had_error = false;
141 for input in inputs {
142 if input.as_os_str() == "-" {
143 use std::io::Read;
144 let mut source = String::new();
145 if let Err(e) = std::io::stdin().read_to_string(&mut source) {
146 eprintln!("{prog} fmt: read from stdin: {e}");
147 return ExitCode::FAILURE;
148 }
149 match format_source(&source, &opts) {
150 Ok(formatted) => {
151 if check {
152 // `--check` on stdin must not print the formatted text
153 // (it would pollute a CI log) and must report a diff the
154 // same way the file path does — a `generator | bynk fmt
155 // --check -` gate is otherwise dead, passing green on
156 // non-canonical input.
157 if formatted != source {
158 eprintln!("{prog} fmt: <stdin> is not canonically formatted");
159 had_diff = true;
160 }
161 } else {
162 print!("{formatted}");
163 }
164 }
165 Err(e) => {
166 bynk_render::print_errors(&e.errors, &source, "<stdin>");
167 return ExitCode::FAILURE;
168 }
169 }
170 continue;
171 }
172 let source = match std::fs::read_to_string(input) {
173 Ok(s) => s,
174 Err(e) => {
175 eprintln!("{prog} fmt: read `{}`: {e}", input.display());
176 had_error = true;
177 continue;
178 }
179 };
180 let filename = input.display().to_string();
181 match format_source(&source, &opts) {
182 Ok(formatted) => {
183 if check {
184 if formatted != source {
185 eprintln!(
186 "{prog} fmt: {} is not canonically formatted",
187 input.display()
188 );
189 had_diff = true;
190 }
191 } else if formatted != source
192 && let Err(e) = atomic_write(input, &formatted)
193 {
194 eprintln!("{prog} fmt: write `{}`: {e}", input.display());
195 had_error = true;
196 }
197 }
198 Err(e) => {
199 bynk_render::print_errors(&e.errors, &source, &filename);
200 had_error = true;
201 }
202 }
203 }
204 if had_error || (check && had_diff) {
205 ExitCode::FAILURE
206 } else {
207 ExitCode::SUCCESS
208 }
209}
210
211/// Write `contents` to `path` atomically: the bytes land in a sibling temp
212/// file that is then `rename`d over `path`. A plain `std::fs::write` truncates
213/// the destination *before* writing, so an ENOSPC, a signal, or a crash
214/// mid-write leaves the file truncated or empty — and for `fmt`, whose only
215/// copy of the original is the in-memory `source`, that original is then gone.
216/// The rename is atomic on POSIX and Windows, so a reader sees either the whole
217/// old file or the whole new one, never a half-written mix.
218///
219/// The temp file is a sibling (same directory) so the rename stays within one
220/// filesystem — a cross-device rename would fail with `EXDEV`. Its name carries
221/// the PID and a per-process counter so concurrent `fmt` runs, or two files in
222/// one run, never collide, and it is opened with `create_new` (`O_EXCL`): a
223/// pre-existing path — a stale temp from an earlier crashed run, or a symlink a
224/// local actor pre-planted to redirect the formatted bytes — is refused rather
225/// than opened, and we bump the counter and retry. On any failure the temp file
226/// is removed so a botched write leaves no litter beside the untouched original.
227///
228/// The `rename` swaps in a fresh inode, so if `path` was a symlink or a
229/// hardlink the formatted file replaces the link rather than being written
230/// through it (the old `std::fs::write` wrote through). Uncommon for source
231/// files, and the atomicity is worth it.
232fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
233 use std::io::Write as _;
234 use std::sync::atomic::{AtomicU64, Ordering};
235
236 static COUNTER: AtomicU64 = AtomicU64::new(0);
237
238 let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
239 let file_name = path
240 .file_name()
241 .map(|n| n.to_string_lossy().into_owned())
242 .unwrap_or_default();
243
244 // Open a fresh sibling temp file exclusively, bumping the counter past any
245 // name that is already taken (stale temp or planted symlink).
246 let (mut file, tmp) = loop {
247 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
248 let tmp_name = format!(".{file_name}.bynk-fmt.{}.{n}.tmp", std::process::id());
249 let tmp = match dir {
250 Some(d) => d.join(tmp_name),
251 None => PathBuf::from(tmp_name),
252 };
253 match std::fs::OpenOptions::new()
254 .write(true)
255 .create_new(true)
256 .open(&tmp)
257 {
258 Ok(f) => break (f, tmp),
259 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
260 Err(e) => return Err(e),
261 }
262 };
263
264 // Scope the write so the handle is flushed and closed before the rename.
265 // The rename replaces the destination inode, so carry the original file's
266 // permissions onto the temp file first — otherwise a formatted file would
267 // silently pick up the process umask's default mode (e.g. an executable or
268 // group-restricted source would lose its bits).
269 let write_result = (|| {
270 // Best-effort: a filesystem that cannot honour the mode must not fail
271 // the whole write.
272 if let Ok(meta) = std::fs::metadata(path) {
273 let _ = file.set_permissions(meta.permissions());
274 }
275 file.write_all(contents.as_bytes())?;
276 file.sync_all()
277 })();
278 if let Err(e) = write_result {
279 let _ = std::fs::remove_file(&tmp);
280 return Err(e);
281 }
282 if let Err(e) = std::fs::rename(&tmp, path) {
283 let _ = std::fs::remove_file(&tmp);
284 return Err(e);
285 }
286 Ok(())
287}
288
289/// The `check` command body shared by `bynkc check` and `bynk check`: a
290/// directory routes through [`project::compile_project`], a single file
291/// through [`bynk_emit::compile_with_warnings`]. `short` selects the one-line
292/// `--format short` rendering. `prog` prefixes messages (`bynk: …`).
293pub fn run_check(prog: &str, input: &Path, short: bool) -> ExitCode {
294 if input.is_dir() {
295 match project::compile_project(&project_options(input)) {
296 Ok(out) => {
297 print_project_warnings(&out.warnings);
298 ExitCode::SUCCESS
299 }
300 Err(failure) => {
301 if short {
302 print_project_failure_short(&failure);
303 } else {
304 print_project_failure(&failure);
305 }
306 ExitCode::FAILURE
307 }
308 }
309 } else {
310 let source = match std::fs::read_to_string(input) {
311 Ok(s) => s,
312 Err(e) => {
313 eprintln!("{prog}: could not read `{}`: {e}", input.display());
314 return ExitCode::FAILURE;
315 }
316 };
317 let filename = input.display().to_string();
318 match bynk_emit::compile_with_warnings(&source, &filename) {
319 Ok(compiled) => {
320 if !compiled.warnings.is_empty() {
321 if short {
322 bynk_render::print_errors_short(&compiled.warnings, &source, &filename);
323 } else {
324 bynk_render::print_errors(&compiled.warnings, &source, &filename);
325 }
326 }
327 ExitCode::SUCCESS
328 }
329 Err(errors) => {
330 if short {
331 bynk_render::print_errors_short(&errors, &source, &filename);
332 } else {
333 bynk_render::print_errors(&errors, &source, &filename);
334 }
335 ExitCode::FAILURE
336 }
337 }
338 }
339}