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::{
13 self, CompileOptions, ProjectPathsError, read_project_paths, try_read_project_paths,
14};
15use bynk_fmt::{FormatOptions, format_source};
16
17pub mod coverage;
18pub mod probe;
19pub mod test_json;
20pub mod test_runner;
21
22/// Root a directory project the way every project command should (#46): a
23/// `bynk.toml` or a `src/` subdir selects **project** mode, whose flat
24/// `[paths] include`/`exclude` layout (v0.113, DECISION S) defaults to the
25/// conventional roots that exist (`src`, `tests`) or the project root itself;
26/// otherwise the legacy **single-tree** where `<dir>` is itself the root.
27/// `check`, `compile`, `test`, and `dev` all route through this so the
28/// conventional layout works the same from any of them.
29pub fn project_options(input: &Path) -> CompileOptions {
30 if input.join("bynk.toml").exists() || input.join("src").is_dir() {
31 CompileOptions::split(input.to_path_buf(), read_project_paths(input))
32 } else {
33 CompileOptions::single(input.to_path_buf())
34 }
35}
36
37/// [`project_options`], but a malformed `bynk.toml` is an error rather than a
38/// silent fall-back to the conventional layout — the one input a user
39/// hand-edits that the compiler otherwise reads without checking, after which
40/// a cascade of `bynk.uses.unknown_target` errors points at units that
41/// plainly exist on disk.
42pub fn try_project_options(input: &Path) -> Result<CompileOptions, ProjectPathsError> {
43 if input.join("bynk.toml").exists() || input.join("src").is_dir() {
44 Ok(CompileOptions::split(
45 input.to_path_buf(),
46 try_read_project_paths(input)?,
47 ))
48 } else {
49 Ok(CompileOptions::single(input.to_path_buf()))
50 }
51}
52
53/// Render a project build failure with per-file ariadne context, exactly as
54/// single-file mode had rich rendering. Unattributed (project-level) errors
55/// keep the plain form.
56///
57/// This is the **flattening layer** (ADR 0100): it attributes each
58/// `AttributedError` to its file snapshot and delegates the actual rendering to
59/// [`bynk_render::print_errors`]. The `ProjectFailure → CompileError` flattening
60/// stays here, above `bynk-render`, so there is no `render → emit` edge.
61pub fn print_project_failure(failure: &project::ProjectFailure) {
62 for ae in &failure.errors {
63 match attributed_snapshot(ae, &failure.snapshots) {
64 Some((label, text)) => {
65 bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
66 }
67 None => {
68 eprintln!("[{}] {}", ae.error.category, ae.error.message);
69 for note in &ae.error.notes {
70 eprintln!(" note: {note}");
71 }
72 // Finding #47: a label's text still surfaces even with no
73 // file to underline it against.
74 for (_, label) in &ae.error.labels {
75 eprintln!(" label: {label}");
76 }
77 }
78 }
79 }
80}
81
82/// v0.89 (ADR 0117): print a successful build's non-failing warnings, with
83/// real per-file ariadne context now that a successful build's `snapshots`
84/// (mirroring `ProjectFailure::snapshots`) make that possible. A warning whose
85/// source isn't attributable (or doesn't fit the snapshot) falls back to the
86/// plain `warning[<category>]: <message>` form.
87pub fn print_project_warnings(
88 warnings: &[project::AttributedError],
89 snapshots: &[(PathBuf, String)],
90) {
91 for w in warnings {
92 match attributed_snapshot(w, snapshots) {
93 Some((label, text)) => {
94 bynk_render::print_errors(std::slice::from_ref(&w.error), text, &label)
95 }
96 None => {
97 let where_ = w
98 .source_path
99 .as_deref()
100 .map(|p| format!("{}: ", p.to_string_lossy().replace('\\', "/")))
101 .unwrap_or_default();
102 eprintln!("{where_}warning[{}]: {}", w.error.category, w.error.message);
103 for note in &w.error.notes {
104 eprintln!(" note: {note}");
105 }
106 for (_, label) in &w.error.labels {
107 eprintln!(" label: {label}");
108 }
109 }
110 }
111 }
112}
113
114/// [`print_project_warnings`]'s `--format short` analogue: one
115/// `path:line:col: warning[category]: message` line per warning, falling
116/// back to `warning[category]: message` when unattributed. Strictly one
117/// line per warning throughout (like [`bynk_render::render_errors_short`],
118/// this mirrors the VS Code problem-matcher's contract), so — unlike
119/// [`print_project_warnings`] — finding #47 doesn't reach this one.
120pub fn print_project_warnings_short(
121 warnings: &[project::AttributedError],
122 snapshots: &[(PathBuf, String)],
123) {
124 for w in warnings {
125 match attributed_snapshot(w, snapshots) {
126 Some((label, text)) => eprintln!("{}", bynk_render::short_line(&label, text, &w.error)),
127 // Every entry in `warnings` is warning-severity by construction
128 // (ADR 0117's own split), so `severity_word` here is always
129 // "warning" — read off the shared helper (finding #48) rather
130 // than hardcoding the string a second time.
131 None => eprintln!(
132 "{}[{}]: {}",
133 bynk_render::severity_word(&w.error),
134 w.error.category,
135 w.error.message
136 ),
137 }
138 }
139}
140
141/// The `(label, source text)` an `AttributedError`'s `source_path` resolves
142/// to in `snapshots`, if any — the one attribution lookup every renderer in
143/// this file shares (finding #48; previously `print_project_failure` and
144/// [`project_failure_short_lines`] each hand-rolled their own copy).
145fn attributed_snapshot<'a>(
146 ae: &project::AttributedError,
147 snapshots: &'a [(PathBuf, String)],
148) -> Option<(String, &'a str)> {
149 let path = ae.source_path.as_deref()?;
150 let text = snapshots
151 .iter()
152 .find(|(p, _)| p.as_path() == path)
153 .map(|(_, t)| t.as_str())?;
154 Some((path.to_string_lossy().replace('\\', "/"), text))
155}
156
157/// The project-failure analogue of [`bynk_render::print_errors_short`]: each
158/// attributed error is positioned against its file's snapshot; an unattributed
159/// (project-level) error falls back to `<severity>[<category>]: <message>`.
160pub fn print_project_failure_short(failure: &project::ProjectFailure) {
161 for line in project_failure_short_lines(failure) {
162 eprintln!("{line}");
163 }
164}
165
166/// The string form of [`print_project_failure_short`]: one `path:line:col:
167/// severity[category]: message` line per attributed error (an unattributed
168/// project-level error falls back to `severity[category]: message`). Backs both
169/// the printer above and the `bynkc test --format json` compile-error document,
170/// whose `diagnostics` the VS Code `bynkc` problem-matcher re-parses — each
171/// `Vec` entry is exactly one line by that contract, so unlike the other
172/// renderers in this file this one deliberately does *not* grow note/label
173/// continuation lines (finding #47): doing so would break a machine consumer
174/// that re-parses every entry as a single diagnostic line.
175///
176/// The flattening layer (ADR 0100): it delegates the per-error formatting to
177/// [`bynk_render::short_line`] / [`bynk_render::severity_word`], and the
178/// attribution lookup to the crate-private `attributed_snapshot` (finding #48).
179pub fn project_failure_short_lines(failure: &project::ProjectFailure) -> Vec<String> {
180 failure
181 .errors
182 .iter()
183 .map(|ae| match attributed_snapshot(ae, &failure.snapshots) {
184 Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
185 None => format!(
186 "{}[{}]: {}",
187 bynk_render::severity_word(&ae.error),
188 ae.error.category,
189 ae.error.message
190 ),
191 })
192 .collect()
193}
194
195/// Render every diagnostic from a [`project::ProjectCheck`] (finding #64) with
196/// the same per-file ariadne context [`print_project_failure`] gives its own,
197/// errors-only list. Unlike that renderer, a `ProjectCheck`'s list can
198/// legitimately mix both severities — the unattributed fallback line names its
199/// actual severity ([`bynk_render::severity_word`]) rather than
200/// `print_project_failure`'s bare `[category]: message` (silently correct only
201/// because that list is errors-only by construction).
202pub fn print_project_check(check: &project::ProjectCheck) {
203 for ae in &check.errors {
204 match attributed_snapshot(ae, &check.snapshots) {
205 Some((label, text)) => {
206 bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
207 }
208 None => {
209 eprintln!(
210 "{}[{}]: {}",
211 bynk_render::severity_word(&ae.error),
212 ae.error.category,
213 ae.error.message
214 );
215 for note in &ae.error.notes {
216 eprintln!(" note: {note}");
217 }
218 for (_, label) in &ae.error.labels {
219 eprintln!(" label: {label}");
220 }
221 }
222 }
223 }
224}
225
226/// [`print_project_check`]'s `--format short` analogue, mirroring
227/// [`project_failure_short_lines`].
228pub fn project_check_short_lines(check: &project::ProjectCheck) -> Vec<String> {
229 check
230 .errors
231 .iter()
232 .map(|ae| match attributed_snapshot(ae, &check.snapshots) {
233 Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
234 None => format!(
235 "{}[{}]: {}",
236 bynk_render::severity_word(&ae.error),
237 ae.error.category,
238 ae.error.message
239 ),
240 })
241 .collect()
242}
243
244/// [`print_project_check`] via [`project_check_short_lines`].
245pub fn print_project_check_short(check: &project::ProjectCheck) {
246 for line in project_check_short_lines(check) {
247 eprintln!("{line}");
248 }
249}
250
251/// The `fmt` command body shared by `bynkc fmt` and `bynk fmt`: each input is
252/// formatted and rewritten only when it changes; `--check` reports
253/// non-canonical files without writing; `-` reads stdin and writes the
254/// formatted result to stdout. `prog` prefixes messages (`bynk fmt: …`).
255pub fn run_fmt(prog: &str, inputs: &[PathBuf], check: bool) -> ExitCode {
256 let opts = FormatOptions::default();
257 if inputs.is_empty() {
258 eprintln!("{prog} fmt: no input files (pass file paths or `-` for stdin)");
259 return ExitCode::FAILURE;
260 }
261 let mut had_diff = false;
262 let mut had_error = false;
263 for input in inputs {
264 if input.as_os_str() == "-" {
265 use std::io::Read;
266 let mut source = String::new();
267 if let Err(e) = std::io::stdin().read_to_string(&mut source) {
268 eprintln!("{prog} fmt: read from stdin: {e}");
269 return ExitCode::FAILURE;
270 }
271 match format_source(&source, &opts) {
272 Ok(formatted) => {
273 if check {
274 // `--check` on stdin must not print the formatted text
275 // (it would pollute a CI log) and must report a diff the
276 // same way the file path does — a `generator | bynk fmt
277 // --check -` gate is otherwise dead, passing green on
278 // non-canonical input.
279 if formatted != source {
280 eprintln!("{prog} fmt: <stdin> is not canonically formatted");
281 had_diff = true;
282 }
283 } else {
284 print!("{formatted}");
285 }
286 }
287 Err(e) => {
288 bynk_render::print_errors(&e.errors, &source, "<stdin>");
289 return ExitCode::FAILURE;
290 }
291 }
292 continue;
293 }
294 let source = match std::fs::read_to_string(input) {
295 Ok(s) => s,
296 Err(e) => {
297 eprintln!("{prog} fmt: read `{}`: {e}", input.display());
298 had_error = true;
299 continue;
300 }
301 };
302 let filename = input.display().to_string();
303 match format_source(&source, &opts) {
304 Ok(formatted) => {
305 if check {
306 if formatted != source {
307 eprintln!(
308 "{prog} fmt: {} is not canonically formatted",
309 input.display()
310 );
311 had_diff = true;
312 }
313 } else if formatted != source
314 && let Err(e) = atomic_write(input, &formatted)
315 {
316 eprintln!("{prog} fmt: write `{}`: {e}", input.display());
317 had_error = true;
318 }
319 }
320 Err(e) => {
321 bynk_render::print_errors(&e.errors, &source, &filename);
322 had_error = true;
323 }
324 }
325 }
326 if had_error || (check && had_diff) {
327 ExitCode::FAILURE
328 } else {
329 ExitCode::SUCCESS
330 }
331}
332
333/// Write `contents` to `path` atomically: the bytes land in a sibling temp
334/// file that is then `rename`d over `path`. A plain `std::fs::write` truncates
335/// the destination *before* writing, so an ENOSPC, a signal, or a crash
336/// mid-write leaves the file truncated or empty — and for `fmt`, whose only
337/// copy of the original is the in-memory `source`, that original is then gone.
338/// The rename is atomic on POSIX and Windows, so a reader sees either the whole
339/// old file or the whole new one, never a half-written mix.
340///
341/// The temp file is a sibling (same directory) so the rename stays within one
342/// filesystem — a cross-device rename would fail with `EXDEV`. Its name carries
343/// the PID and a per-process counter so concurrent `fmt` runs, or two files in
344/// one run, never collide, and it is opened with `create_new` (`O_EXCL`): a
345/// pre-existing path — a stale temp from an earlier crashed run, or a symlink a
346/// local actor pre-planted to redirect the formatted bytes — is refused rather
347/// than opened, and we bump the counter and retry. On any failure the temp file
348/// is removed so a botched write leaves no litter beside the untouched original.
349///
350/// The `rename` swaps in a fresh inode, so if `path` was a symlink or a
351/// hardlink the formatted file replaces the link rather than being written
352/// through it (the old `std::fs::write` wrote through). Uncommon for source
353/// files, and the atomicity is worth it.
354fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
355 use std::io::Write as _;
356 use std::sync::atomic::{AtomicU64, Ordering};
357
358 static COUNTER: AtomicU64 = AtomicU64::new(0);
359
360 let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
361 let file_name = path
362 .file_name()
363 .map(|n| n.to_string_lossy().into_owned())
364 .unwrap_or_default();
365
366 // Open a fresh sibling temp file exclusively, bumping the counter past any
367 // name that is already taken (stale temp or planted symlink).
368 let (mut file, tmp) = loop {
369 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
370 let tmp_name = format!(".{file_name}.bynk-fmt.{}.{n}.tmp", std::process::id());
371 let tmp = match dir {
372 Some(d) => d.join(tmp_name),
373 None => PathBuf::from(tmp_name),
374 };
375 match std::fs::OpenOptions::new()
376 .write(true)
377 .create_new(true)
378 .open(&tmp)
379 {
380 Ok(f) => break (f, tmp),
381 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
382 Err(e) => return Err(e),
383 }
384 };
385
386 // Scope the write so the handle is flushed and closed before the rename.
387 // The rename replaces the destination inode, so carry the original file's
388 // permissions onto the temp file first — otherwise a formatted file would
389 // silently pick up the process umask's default mode (e.g. an executable or
390 // group-restricted source would lose its bits).
391 let write_result = (|| {
392 // Best-effort: a filesystem that cannot honour the mode must not fail
393 // the whole write.
394 if let Ok(meta) = std::fs::metadata(path) {
395 let _ = file.set_permissions(meta.permissions());
396 }
397 file.write_all(contents.as_bytes())?;
398 file.sync_all()
399 })();
400 if let Err(e) = write_result {
401 let _ = std::fs::remove_file(&tmp);
402 return Err(e);
403 }
404 if let Err(e) = std::fs::rename(&tmp, path) {
405 let _ = std::fs::remove_file(&tmp);
406 return Err(e);
407 }
408 Ok(())
409}
410
411/// The `check` command body shared by `bynkc check` and `bynk check`: a
412/// directory routes through [`project::check_project`] (finding #64 —
413/// non-bailing, so a structural error anywhere does not hide diagnostics
414/// elsewhere the way `compile_project`'s bail-fast `Mode::Build` would), a
415/// single file through [`bynk_emit::compile_with_warnings`]. `short` selects
416/// the one-line `--format short` rendering. `prog` prefixes messages
417/// (`bynk: …`).
418pub fn run_check(prog: &str, input: &Path, short: bool) -> ExitCode {
419 if input.is_dir() {
420 let options = match try_project_options(input) {
421 Ok(o) => o,
422 Err(e) => {
423 eprintln!("{prog}: {e}");
424 return ExitCode::FAILURE;
425 }
426 };
427 let check = project::check_project(&options);
428 let has_errors = check.has_errors();
429 if short {
430 print_project_check_short(&check);
431 } else {
432 print_project_check(&check);
433 }
434 if has_errors {
435 ExitCode::FAILURE
436 } else {
437 ExitCode::SUCCESS
438 }
439 } else {
440 let source = match std::fs::read_to_string(input) {
441 Ok(s) => s,
442 Err(e) => {
443 eprintln!("{prog}: could not read `{}`: {e}", input.display());
444 return ExitCode::FAILURE;
445 }
446 };
447 let filename = input.display().to_string();
448 match bynk_emit::compile_with_warnings(&source, &filename) {
449 Ok(compiled) => {
450 if !compiled.warnings.is_empty() {
451 if short {
452 bynk_render::print_errors_short(&compiled.warnings, &source, &filename);
453 } else {
454 bynk_render::print_errors(&compiled.warnings, &source, &filename);
455 }
456 }
457 ExitCode::SUCCESS
458 }
459 Err(errors) => {
460 if short {
461 bynk_render::print_errors_short(&errors, &source, &filename);
462 } else {
463 bynk_render::print_errors(&errors, &source, &filename);
464 }
465 ExitCode::FAILURE
466 }
467 }
468 }
469}