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::{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}