Skip to main content

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}