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, IndentStyle, 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/// How `--indent` spells the two [`IndentStyle`] variants. The words match the
252/// `[fmt] indent` key in `bynk.toml`, which the language server already reads,
253/// so a project states the same choice the same way in either place.
254#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
255pub enum IndentKind {
256    /// One tab per nesting level. The default — a reader sets their own tab
257    /// width in the editor, which space indentation takes away from them.
258    Tab,
259    /// `--indent-width` spaces per nesting level.
260    Spaces,
261}
262
263/// The `fmt` subcommand's arguments, flattened by both `bynkc::cli` and
264/// `bynk::cli` so the two spell one contract rather than two copies of it (the
265/// [`test_runner::TestArgs`] pattern, findings #40/#72). Field docs here are
266/// the CLI help text for both commands' flags.
267///
268/// Every style field is an `Option`, and deliberately carries no clap
269/// `default_value` (#972). The three sources are layered — spec default, then
270/// the project's `bynk.toml` `[fmt]`, then the flag — and a clap default would
271/// make "the user asked for 100" indistinguishable from "the user said
272/// nothing", so a manifest's `max_line_width = 120` would be overwritten by a
273/// flag nobody passed. `None` means *defer to the layer below*.
274#[derive(clap::Args, Debug)]
275pub struct FmtArgs {
276    /// Files to format. Use `-` for stdin → stdout.
277    pub inputs: Vec<PathBuf>,
278    /// Check formatting without writing changes. Exits non-zero if any
279    /// file is not already canonical.
280    #[arg(long)]
281    pub check: bool,
282    /// Indent with tabs or spaces. Defaults to the project's `[fmt] indent`,
283    /// or tabs.
284    #[arg(long, value_enum)]
285    pub indent: Option<IndentKind>,
286    /// Spaces per nesting level, with spaces indentation. Defaults to the
287    /// project's `[fmt] indent_width`, or 2. Rejected when the effective
288    /// indentation is tabs, where it would have no effect.
289    #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(0..=64))]
290    pub indent_width: Option<u8>,
291    /// Soft target line width in columns. A construct wider than this wraps
292    /// across lines where the grammar allows; one with no break point in it
293    /// (a long string literal) is left long. Defaults to the project's
294    /// `[fmt] max_line_width`, or 100.
295    #[arg(long, value_name = "COLUMNS", value_parser = clap::value_parser!(u32).range(1..))]
296    pub max_line_width: Option<u32>,
297    /// Emit a trailing comma in multi-line records, sums, list literals and
298    /// `exports` clauses. Overrides a project's `trailing_comma = false`, and
299    /// overrides an earlier `--no-trailing-comma`.
300    #[arg(long, overrides_with = "no_trailing_comma")]
301    pub trailing_comma: bool,
302    /// Omit the trailing comma in multi-line records, sums, list literals and
303    /// `exports` clauses. (Parameter and argument lists never carry one — the
304    /// grammar rejects it — regardless of this flag.)
305    #[arg(long, overrides_with = "trailing_comma")]
306    pub no_trailing_comma: bool,
307    /// Ignore the project's `bynk.toml` `[fmt]` section and format to the
308    /// canonical style, plus whatever flags this run passes. For a script that
309    /// wants one fixed rendering whatever project it is pointed at.
310    #[arg(long)]
311    pub no_config: bool,
312}
313
314impl FmtArgs {
315    /// Layer these arguments over `base` — the manifest-resolved options for
316    /// the file about to be formatted — or report why they describe nothing
317    /// usable. A field the run did not state leaves `base` untouched.
318    pub fn apply_to(&self, base: FormatOptions) -> Result<FormatOptions, String> {
319        // The width already in `base` (from `[fmt] indent_width`, or the spec
320        // default), so `--indent spaces` alone over a manifest's `indent_width
321        // = 4` lands on four spaces rather than resetting to two.
322        let base_width = match base.indent {
323            IndentStyle::Spaces(n) => Some(n),
324            IndentStyle::Tab => None,
325        };
326        let kind = self.indent.unwrap_or(match base.indent {
327            IndentStyle::Tab => IndentKind::Tab,
328            IndentStyle::Spaces(_) => IndentKind::Spaces,
329        });
330        let indent = match (kind, self.indent_width) {
331            (IndentKind::Tab, None) => IndentStyle::Tab,
332            // A width alongside tab indentation is silently meaningless, which
333            // is exactly the kind of ignored flag that costs an hour to
334            // notice. Say so instead — naming the *effective* indentation,
335            // since it may have come from the manifest rather than this run.
336            (IndentKind::Tab, Some(_)) => {
337                return Err(
338                    "`--indent-width` applies only to spaces indentation, and this run resolves \
339                     to tabs (pass `--indent spaces`, or set `[fmt] indent` in bynk.toml)"
340                        .to_string(),
341                );
342            }
343            // 2 matches the `bynk.toml` `[fmt] indent_width` fallback, so the
344            // CLI and the editor agree from the same words.
345            (IndentKind::Spaces, width) => IndentStyle::Spaces(width.or(base_width).unwrap_or(2)),
346        };
347        Ok(FormatOptions {
348            indent,
349            max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
350            // Neither flag set defers to `base`; clap's `overrides_with` pair
351            // makes the last one given win.
352            trailing_comma: if self.no_trailing_comma {
353                false
354            } else if self.trailing_comma {
355                true
356            } else {
357                base.trailing_comma
358            },
359        })
360    }
361}
362
363/// Why a run could not settle on the options to format an input with.
364enum FmtOptionsError {
365    /// The project's `bynk.toml` `[fmt]` section is unusable.
366    Manifest(PathBuf, bynk_fmt::ConfigError),
367    /// The flags this run passed contradict each other or the manifest.
368    Args(String),
369}
370
371impl std::fmt::Display for FmtOptionsError {
372    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
373        match self {
374            // Named, because the manifest governing a file is not necessarily
375            // the one in the working directory.
376            Self::Manifest(path, e) => write!(f, "{}: {e}", path.display()),
377            Self::Args(e) => write!(f, "{e}"),
378        }
379    }
380}
381
382/// Per-directory memo of the `[fmt]` section governing an input.
383///
384/// A run typically formats many files under one project (`fmt src/*.bynk`), so
385/// the upward walk for `bynk.toml` and its parse happen once per starting
386/// directory rather than once per file.
387struct ManifestCache {
388    /// `--no-config`: skip discovery entirely and hand back the spec defaults.
389    disabled: bool,
390    by_dir: std::collections::HashMap<PathBuf, FormatOptions>,
391}
392
393impl ManifestCache {
394    fn new(disabled: bool) -> Self {
395        Self {
396            disabled,
397            by_dir: std::collections::HashMap::new(),
398        }
399    }
400
401    /// The options `input` inherits from its project, before this run's flags.
402    fn options_for(&mut self, input: &Path) -> Result<FormatOptions, FmtOptionsError> {
403        if self.disabled {
404            return Ok(FormatOptions::default());
405        }
406        // Stdin carries no path to search from; the working directory is the
407        // only project context a pipe has.
408        let start: PathBuf = if input.as_os_str() == "-" {
409            PathBuf::from(".")
410        } else {
411            match input.parent() {
412                // A bare `x.bynk` has an empty parent, which is the cwd.
413                Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
414                _ => PathBuf::from("."),
415            }
416        };
417        // Absolutise before walking. A relative start has no ancestors to walk
418        // *through*: `Path::new("src").parent()` is `""` and `""`'s parent is
419        // `None`, so the search stops at the working directory and never
420        // reaches the project root above it. Run from `src/`, `fmt calc.bynk`
421        // therefore missed the very manifest `fmt src/calc.bynk` from the root
422        // found — silently formatting to the canonical style, and (under
423        // `--check`) gating CI on a style the editor never produces. Joining
424        // onto the cwd also collapses `src` and `/abs/src` to one cache key.
425        //
426        // `current_dir()` rather than `std::path::absolute`: same result here,
427        // and it does not raise the crate's MSRV.
428        let start = std::env::current_dir()
429            .map(|cwd| cwd.join(&start))
430            .unwrap_or(start);
431        if let Some(hit) = self.by_dir.get(&start) {
432            return Ok(*hit);
433        }
434        let opts = match bynk_fmt::find_manifest(&start) {
435            None => FormatOptions::default(),
436            Some(manifest) => bynk_fmt::FmtConfig::from_manifest(&manifest)
437                .map_err(|e| FmtOptionsError::Manifest(manifest, e))?
438                .apply(FormatOptions::default()),
439        };
440        self.by_dir.insert(start, opts);
441        Ok(opts)
442    }
443}
444
445/// The `fmt` command body shared by `bynkc fmt` and `bynk fmt`: each input is
446/// formatted and rewritten only when it changes; `--check` reports
447/// non-canonical files without writing; `-` reads stdin and writes the
448/// formatted result to stdout. `prog` prefixes messages (`bynk fmt: …`).
449pub fn run_fmt(prog: &str, args: &FmtArgs) -> ExitCode {
450    let (inputs, check) = (&args.inputs, args.check);
451    if inputs.is_empty() {
452        eprintln!("{prog} fmt: no input files (pass file paths or `-` for stdin)");
453        return ExitCode::FAILURE;
454    }
455    // Resolve *every* input's options before formatting any of them. Options
456    // are per-input — `[fmt]` belongs to the project the file sits in, so a
457    // path outside the current project obeys that project's style — but a
458    // manifest error found on the third input must not land after the first two
459    // have already been rewritten. Configuration is a whole-run precondition:
460    // it fails before a byte is written, or not at all.
461    let mut manifests = ManifestCache::new(args.no_config);
462    let mut resolved: Vec<FormatOptions> = Vec::with_capacity(inputs.len());
463    for input in inputs {
464        match manifests
465            .options_for(input)
466            .and_then(|base| args.apply_to(base).map_err(FmtOptionsError::Args))
467        {
468            Ok(opts) => resolved.push(opts),
469            Err(e) => {
470                eprintln!("{prog} fmt: {e}");
471                return ExitCode::FAILURE;
472            }
473        }
474    }
475
476    let mut had_diff = false;
477    let mut had_error = false;
478    for (input, opts) in inputs.iter().zip(resolved) {
479        if input.as_os_str() == "-" {
480            use std::io::Read;
481            let mut source = String::new();
482            if let Err(e) = std::io::stdin().read_to_string(&mut source) {
483                eprintln!("{prog} fmt: read from stdin: {e}");
484                return ExitCode::FAILURE;
485            }
486            match format_source(&source, &opts) {
487                Ok(formatted) => {
488                    if check {
489                        // `--check` on stdin must not print the formatted text
490                        // (it would pollute a CI log) and must report a diff the
491                        // same way the file path does — a `generator | bynk fmt
492                        // --check -` gate is otherwise dead, passing green on
493                        // non-canonical input.
494                        if formatted != source {
495                            eprintln!("{prog} fmt: <stdin> is not canonically formatted");
496                            had_diff = true;
497                        }
498                    } else {
499                        print!("{formatted}");
500                    }
501                }
502                Err(e) => {
503                    bynk_render::print_errors(&e.errors, &source, "<stdin>");
504                    return ExitCode::FAILURE;
505                }
506            }
507            continue;
508        }
509        let source = match std::fs::read_to_string(input) {
510            Ok(s) => s,
511            Err(e) => {
512                eprintln!("{prog} fmt: read `{}`: {e}", input.display());
513                had_error = true;
514                continue;
515            }
516        };
517        let filename = input.display().to_string();
518        match format_source(&source, &opts) {
519            Ok(formatted) => {
520                if check {
521                    if formatted != source {
522                        eprintln!(
523                            "{prog} fmt: {} is not canonically formatted",
524                            input.display()
525                        );
526                        had_diff = true;
527                    }
528                } else if formatted != source
529                    && let Err(e) = atomic_write(input, &formatted)
530                {
531                    eprintln!("{prog} fmt: write `{}`: {e}", input.display());
532                    had_error = true;
533                }
534            }
535            Err(e) => {
536                bynk_render::print_errors(&e.errors, &source, &filename);
537                had_error = true;
538            }
539        }
540    }
541    if had_error || (check && had_diff) {
542        ExitCode::FAILURE
543    } else {
544        ExitCode::SUCCESS
545    }
546}
547
548/// Write `contents` to `path` atomically: the bytes land in a sibling temp
549/// file that is then `rename`d over `path`. A plain `std::fs::write` truncates
550/// the destination *before* writing, so an ENOSPC, a signal, or a crash
551/// mid-write leaves the file truncated or empty — and for `fmt`, whose only
552/// copy of the original is the in-memory `source`, that original is then gone.
553/// The rename is atomic on POSIX and Windows, so a reader sees either the whole
554/// old file or the whole new one, never a half-written mix.
555///
556/// The temp file is a sibling (same directory) so the rename stays within one
557/// filesystem — a cross-device rename would fail with `EXDEV`. Its name carries
558/// the PID and a per-process counter so concurrent `fmt` runs, or two files in
559/// one run, never collide, and it is opened with `create_new` (`O_EXCL`): a
560/// pre-existing path — a stale temp from an earlier crashed run, or a symlink a
561/// local actor pre-planted to redirect the formatted bytes — is refused rather
562/// than opened, and we bump the counter and retry. On any failure the temp file
563/// is removed so a botched write leaves no litter beside the untouched original.
564///
565/// The `rename` swaps in a fresh inode, so if `path` was a symlink or a
566/// hardlink the formatted file replaces the link rather than being written
567/// through it (the old `std::fs::write` wrote through). Uncommon for source
568/// files, and the atomicity is worth it.
569fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
570    use std::io::Write as _;
571    use std::sync::atomic::{AtomicU64, Ordering};
572
573    static COUNTER: AtomicU64 = AtomicU64::new(0);
574
575    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
576    let file_name = path
577        .file_name()
578        .map(|n| n.to_string_lossy().into_owned())
579        .unwrap_or_default();
580
581    // Open a fresh sibling temp file exclusively, bumping the counter past any
582    // name that is already taken (stale temp or planted symlink).
583    let (mut file, tmp) = loop {
584        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
585        let tmp_name = format!(".{file_name}.bynk-fmt.{}.{n}.tmp", std::process::id());
586        let tmp = match dir {
587            Some(d) => d.join(tmp_name),
588            None => PathBuf::from(tmp_name),
589        };
590        match std::fs::OpenOptions::new()
591            .write(true)
592            .create_new(true)
593            .open(&tmp)
594        {
595            Ok(f) => break (f, tmp),
596            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
597            Err(e) => return Err(e),
598        }
599    };
600
601    // Scope the write so the handle is flushed and closed before the rename.
602    // The rename replaces the destination inode, so carry the original file's
603    // permissions onto the temp file first — otherwise a formatted file would
604    // silently pick up the process umask's default mode (e.g. an executable or
605    // group-restricted source would lose its bits).
606    let write_result = (|| {
607        // Best-effort: a filesystem that cannot honour the mode must not fail
608        // the whole write.
609        if let Ok(meta) = std::fs::metadata(path) {
610            let _ = file.set_permissions(meta.permissions());
611        }
612        file.write_all(contents.as_bytes())?;
613        file.sync_all()
614    })();
615    if let Err(e) = write_result {
616        let _ = std::fs::remove_file(&tmp);
617        return Err(e);
618    }
619    if let Err(e) = std::fs::rename(&tmp, path) {
620        let _ = std::fs::remove_file(&tmp);
621        return Err(e);
622    }
623    Ok(())
624}
625
626/// The `check` command body shared by `bynkc check` and `bynk check`: a
627/// directory routes through [`project::check_project`] (finding #64 —
628/// non-bailing, so a structural error anywhere does not hide diagnostics
629/// elsewhere the way `compile_project`'s bail-fast `Mode::Build` would), a
630/// single file through [`bynk_emit::compile_with_warnings`]. `short` selects
631/// the one-line `--format short` rendering. `prog` prefixes messages
632/// (`bynk: …`).
633pub fn run_check(prog: &str, input: &Path, short: bool) -> ExitCode {
634    if input.is_dir() {
635        let options = match try_project_options(input) {
636            Ok(o) => o,
637            Err(e) => {
638                eprintln!("{prog}: {e}");
639                return ExitCode::FAILURE;
640            }
641        };
642        let check = project::check_project(&options);
643        let has_errors = check.has_errors();
644        if short {
645            print_project_check_short(&check);
646        } else {
647            print_project_check(&check);
648        }
649        if has_errors {
650            ExitCode::FAILURE
651        } else {
652            ExitCode::SUCCESS
653        }
654    } else {
655        let source = match std::fs::read_to_string(input) {
656            Ok(s) => s,
657            Err(e) => {
658                eprintln!("{prog}: could not read `{}`: {e}", input.display());
659                return ExitCode::FAILURE;
660            }
661        };
662        let filename = input.display().to_string();
663        match bynk_emit::compile_with_warnings(&source, &filename) {
664            Ok(compiled) => {
665                if !compiled.warnings.is_empty() {
666                    if short {
667                        bynk_render::print_errors_short(&compiled.warnings, &source, &filename);
668                    } else {
669                        bynk_render::print_errors(&compiled.warnings, &source, &filename);
670                    }
671                }
672                ExitCode::SUCCESS
673            }
674            Err(errors) => {
675                if short {
676                    bynk_render::print_errors_short(&errors, &source, &filename);
677                } else {
678                    bynk_render::print_errors(&errors, &source, &filename);
679                }
680                ExitCode::FAILURE
681            }
682        }
683    }
684}
685
686#[cfg(test)]
687mod tests {
688    use super::*;
689    use clap::Parser;
690
691    /// A minimal parser around [`FmtArgs`], so these assert what the real CLIs
692    /// parse rather than what a hand-built struct claims.
693    #[derive(clap::Parser, Debug)]
694    struct Harness {
695        #[command(flatten)]
696        args: FmtArgs,
697    }
698
699    fn parse(argv: &[&str]) -> FmtArgs {
700        let mut full = vec!["fmt"];
701        full.extend_from_slice(argv);
702        Harness::parse_from(full).args
703    }
704
705    /// The options a run resolves with no manifest in play.
706    fn resolve(argv: &[&str]) -> Result<FormatOptions, String> {
707        parse(argv).apply_to(FormatOptions::default())
708    }
709
710    /// A stand-in for what `bynk.toml` `[fmt]` resolved to.
711    fn manifest(toml: &str) -> FormatOptions {
712        bynk_fmt::FmtConfig::from_manifest_str(toml)
713            .expect("manifest parses")
714            .apply(FormatOptions::default())
715    }
716
717    #[test]
718    fn no_flags_is_the_canonical_style() {
719        let opts = resolve(&["a.bynk"]).expect("valid");
720        assert_eq!(opts, FormatOptions::default());
721    }
722
723    #[test]
724    fn spaces_without_a_width_falls_back_to_two() {
725        // The same fallback `bynk.toml`'s `[fmt] indent_width` uses, so the CLI
726        // and the language server land on the same style from the same words.
727        let opts = resolve(&["--indent", "spaces", "a.bynk"]).expect("valid");
728        assert_eq!(opts.indent, IndentStyle::Spaces(2));
729    }
730
731    #[test]
732    fn spaces_takes_the_given_width() {
733        let opts =
734            resolve(&["--indent", "spaces", "--indent-width", "4", "a.bynk"]).expect("valid");
735        assert_eq!(opts.indent, IndentStyle::Spaces(4));
736    }
737
738    #[test]
739    fn a_width_with_tabs_is_an_error_not_a_silent_no_op() {
740        let err = resolve(&["--indent", "tab", "--indent-width", "4", "a.bynk"])
741            .expect_err("a meaningless width must be reported");
742        assert!(err.contains("--indent-width"), "{err}");
743        assert!(err.contains("spaces"), "{err}");
744    }
745
746    #[test]
747    fn the_trailing_comma_pair_is_last_one_wins() {
748        // `overrides_with` in both directions: a script may append either flag
749        // to a shared argument list and have it win rather than conflict-error.
750        assert!(
751            resolve(&["--no-trailing-comma", "--trailing-comma", "a.bynk"])
752                .expect("valid")
753                .trailing_comma
754        );
755        assert!(
756            !resolve(&["--trailing-comma", "--no-trailing-comma", "a.bynk"])
757                .expect("valid")
758                .trailing_comma
759        );
760    }
761
762    #[test]
763    fn max_line_width_is_taken_verbatim_and_zero_is_refused() {
764        assert_eq!(
765            resolve(&["--max-line-width", "60", "a.bynk"])
766                .expect("valid")
767                .max_line_width,
768            60
769        );
770        // A zero-column budget is a nonsense input; clap rejects it at parse
771        // time rather than the formatter wrapping every construct maximally.
772        assert!(
773            Harness::try_parse_from(["fmt", "--max-line-width", "0", "a.bynk"]).is_err(),
774            "`--max-line-width 0` must not parse"
775        );
776    }
777
778    // -- #972: the `bynk.toml` `[fmt]` layer beneath the flags --
779
780    #[test]
781    fn an_unflagged_run_takes_the_manifest_whole() {
782        let base = manifest(
783            "[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\ntrailing_comma = false\n",
784        );
785        let opts = parse(&["a.bynk"]).apply_to(base).expect("valid");
786        assert_eq!(opts.indent, IndentStyle::Spaces(4));
787        assert_eq!(opts.max_line_width, 120);
788        assert!(!opts.trailing_comma);
789    }
790
791    #[test]
792    fn a_flag_beats_the_manifest_field_it_names_and_no_other() {
793        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\n");
794        let opts = parse(&["--max-line-width", "80", "a.bynk"])
795            .apply_to(base)
796            .expect("valid");
797        assert_eq!(opts.max_line_width, 80, "the flag wins where it speaks");
798        assert_eq!(
799            opts.indent,
800            IndentStyle::Spaces(4),
801            "and stays silent everywhere else"
802        );
803    }
804
805    #[test]
806    fn an_absent_flag_does_not_reset_the_manifest_to_the_default() {
807        // The regression a clap `default_value` would have caused: "the user
808        // said 100" is indistinguishable from "the user said nothing", so a
809        // project's 120 would be overwritten by a flag nobody passed.
810        let base = manifest("[fmt]\nmax_line_width = 120\n");
811        assert_eq!(
812            parse(&["a.bynk"])
813                .apply_to(base)
814                .expect("valid")
815                .max_line_width,
816            120
817        );
818    }
819
820    #[test]
821    fn indent_spaces_alone_keeps_the_manifest_width() {
822        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
823        let opts = parse(&["--indent", "spaces", "a.bynk"])
824            .apply_to(base)
825            .expect("valid");
826        assert_eq!(opts.indent, IndentStyle::Spaces(4), "not reset to 2");
827    }
828
829    #[test]
830    fn indent_width_alone_applies_to_a_manifest_that_chose_spaces() {
831        let base = manifest("[fmt]\nindent = \"spaces\"\n");
832        let opts = parse(&["--indent-width", "8", "a.bynk"])
833            .apply_to(base)
834            .expect("valid");
835        assert_eq!(opts.indent, IndentStyle::Spaces(8));
836    }
837
838    #[test]
839    fn indent_width_alone_is_refused_when_the_run_resolves_to_tabs() {
840        // No `--indent`, and a manifest that says tabs (or none at all): the
841        // width has nothing to apply to, and the message says so rather than
842        // the flag vanishing.
843        let err = parse(&["--indent-width", "8", "a.bynk"])
844            .apply_to(manifest("[fmt]\nindent = \"tab\"\n"))
845            .expect_err("refused");
846        assert!(err.contains("resolves"), "{err}");
847    }
848
849    #[test]
850    fn an_explicit_tab_flag_overrides_a_manifest_choosing_spaces() {
851        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
852        let opts = parse(&["--indent", "tab", "a.bynk"])
853            .apply_to(base)
854            .expect("valid");
855        assert_eq!(opts.indent, IndentStyle::Tab);
856    }
857
858    #[test]
859    fn trailing_comma_flag_overrides_a_manifest_that_turned_it_off() {
860        let base = manifest("[fmt]\ntrailing_comma = false\n");
861        assert!(
862            parse(&["--trailing-comma", "a.bynk"])
863                .apply_to(base)
864                .expect("valid")
865                .trailing_comma
866        );
867        // …and an unflagged run still honours the manifest.
868        assert!(
869            !parse(&["a.bynk"])
870                .apply_to(base)
871                .expect("valid")
872                .trailing_comma
873        );
874    }
875
876    #[test]
877    fn no_config_is_parsed_and_defaults_are_used_in_its_presence() {
878        // `--no-config` is honoured by the manifest *lookup* (ManifestCache),
879        // so here it is enough that it parses and leaves the flag layer alone.
880        let args = parse(&["--no-config", "a.bynk"]);
881        assert!(args.no_config);
882        assert_eq!(
883            args.apply_to(FormatOptions::default()).expect("valid"),
884            FormatOptions::default()
885        );
886    }
887}