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::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::process::ExitCode;
13
14use bynk_emit::project::{self, CompileOptions, ProjectPathsError, try_read_project_paths_with};
15use bynk_fmt::{FormatOptions, IndentStyle, format_source};
16
17pub mod coverage;
18pub mod discovery;
19pub mod output;
20pub mod probe;
21pub mod schema_lock;
22pub mod test_json;
23pub mod test_runner;
24
25pub use output::{write_compiled_file, write_output};
26
27/// Root a directory project the way every project command should (#46): a
28/// `bynk.toml` or a `src/` subdir selects **project** mode, whose flat
29/// `[paths] include`/`exclude` layout (v0.113, DECISION S) defaults to the
30/// conventional roots that exist (`src`, `tests`) or the project root itself;
31/// otherwise the legacy **single-tree** where `<dir>` is itself the root.
32/// `check`, `compile`, `test`, and `dev` all route through this so the
33/// conventional layout works the same from any of them.
34///
35/// #1077 (R2.3/T0.7 residue): reads and populates `.sources(...)` itself —
36/// `bynk-emit` no longer discovers or reads project files on disk, so this is
37/// now the one real place that walk happens for the live CLI path.
38///
39/// #1081 review: returns `Result` because that walk is real I/O against a
40/// user-controlled `bynk.toml` (a missing `include` root, an unreadable
41/// directory) — [`discovery::DiscoveryError`], not a panic.
42pub fn project_options(input: &Path) -> Result<CompileOptions, discovery::DiscoveryError> {
43    if input.join("bynk.toml").exists() || input.join("src").is_dir() {
44        let paths = try_read_project_paths_with(input, &manifest_overlay(input))
45            .unwrap_or_else(|_| project::ProjectPaths::conventional(input));
46        options_for_split(input, paths)
47    } else {
48        let sources = discovery::read_bynk_tree_single(input)?;
49        Ok(CompileOptions::single(input.to_path_buf()).sources(sources))
50    }
51}
52
53/// [`project_options`], but a malformed `bynk.toml` is an error rather than a
54/// silent fall-back to the conventional layout — the one input a user
55/// hand-edits that the compiler otherwise reads without checking, after which
56/// a cascade of `bynk.uses.unknown_target` errors points at units that
57/// plainly exist on disk.
58pub fn try_project_options(input: &Path) -> Result<CompileOptions, ProjectOptionsError> {
59    if input.join("bynk.toml").exists() || input.join("src").is_dir() {
60        let paths = try_read_project_paths_with(input, &manifest_overlay(input))?;
61        Ok(options_for_split(input, paths)?)
62    } else {
63        let sources = discovery::read_bynk_tree_single(input)?;
64        Ok(CompileOptions::single(input.to_path_buf()).sources(sources))
65    }
66}
67
68/// `bynk.toml`'s own content, keyed exactly as [`try_read_project_paths_with`]
69/// looks it up (`project_root.join("bynk.toml")`, unmodified — the literal-path
70/// branch of `discovery::read_source`'s overlay lookup, so this never needs to
71/// match a canonicalised key).
72///
73/// #1077 review: without this, both entry points above read `bynk.toml`
74/// through `bynk-emit`'s own disk-fallback (`read_source`'s `fs::read_to_string`
75/// on an overlay miss) — the one on-disk read #1081 left the CLI path still
76/// implicitly depending on `bynk-emit` for, despite that PR's claim of a fully
77/// fallback-free CLI path. A missing/unreadable `bynk.toml` yields an empty
78/// overlay, which `try_read_project_paths_with` already treats as "no
79/// manifest" (falls back to the conventional layout) — the same degrade
80/// `try_read_project_paths` itself provides.
81fn manifest_overlay(input: &Path) -> HashMap<PathBuf, String> {
82    let toml_path = input.join("bynk.toml");
83    match fs::read_to_string(&toml_path) {
84        Ok(text) => HashMap::from([(toml_path, text)]),
85        Err(_) => HashMap::new(),
86    }
87}
88
89/// The split-layout half of `project_options`/`try_project_options`: build the
90/// one `Roots` value the project resolves to, walk exactly that (via
91/// [`discovery::sources_for_roots`] — #1081 review, so the CLI's walk can't
92/// drift from what `Roots::trees`/`Roots::excludes` themselves say), and
93/// hand the result to `CompileOptions::split` alongside it.
94fn options_for_split(
95    input: &Path,
96    paths: project::ProjectPaths,
97) -> Result<CompileOptions, discovery::DiscoveryError> {
98    let roots = project::Roots::Split {
99        project_root: input.to_path_buf(),
100        paths: paths.clone(),
101    };
102    let sources = discovery::sources_for_roots(&roots)?;
103    Ok(CompileOptions::split(input.to_path_buf(), paths).sources(sources))
104}
105
106/// Why [`try_project_options`] could not produce a usable [`CompileOptions`]:
107/// either the manifest itself is unreadable ([`ProjectPathsError`]), or a
108/// well-formed manifest names a project tree that can't be walked
109/// ([`discovery::DiscoveryError`]) — #1081 review.
110#[derive(Debug)]
111pub enum ProjectOptionsError {
112    Paths(ProjectPathsError),
113    Discovery(discovery::DiscoveryError),
114}
115
116impl std::fmt::Display for ProjectOptionsError {
117    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118        match self {
119            Self::Paths(e) => write!(f, "{e}"),
120            Self::Discovery(e) => write!(f, "{e}"),
121        }
122    }
123}
124
125impl std::error::Error for ProjectOptionsError {
126    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
127        match self {
128            // `ProjectPathsError` doesn't itself implement `Error` (no
129            // further cause to chain to — it's already a leaf).
130            Self::Paths(_) => None,
131            Self::Discovery(e) => Some(e),
132        }
133    }
134}
135
136impl From<ProjectPathsError> for ProjectOptionsError {
137    fn from(e: ProjectPathsError) -> Self {
138        Self::Paths(e)
139    }
140}
141
142impl From<discovery::DiscoveryError> for ProjectOptionsError {
143    fn from(e: discovery::DiscoveryError) -> Self {
144        Self::Discovery(e)
145    }
146}
147
148/// Render a project build failure with per-file ariadne context, exactly as
149/// single-file mode had rich rendering. Unattributed (project-level) errors
150/// keep the plain form.
151///
152/// This is the **flattening layer** (ADR 0100): it attributes each
153/// `AttributedError` to its file snapshot and delegates the actual rendering to
154/// [`bynk_render::print_errors`]. The `ProjectFailure → CompileError` flattening
155/// stays here, above `bynk-render`, so there is no `render → emit` edge.
156pub fn print_project_failure(failure: &project::ProjectFailure) {
157    for ae in &failure.errors {
158        match attributed_snapshot(ae, &failure.snapshots) {
159            Some((label, text)) => {
160                bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
161            }
162            None => {
163                eprintln!("[{}] {}", ae.error.category, ae.error.message);
164                for note in &ae.error.notes {
165                    eprintln!("  note: {note}");
166                }
167                // Finding #47: a label's text still surfaces even with no
168                // file to underline it against.
169                for (_, label) in &ae.error.labels {
170                    eprintln!("  label: {label}");
171                }
172            }
173        }
174    }
175}
176
177/// v0.89 (ADR 0117): print a successful build's non-failing warnings, with
178/// real per-file ariadne context now that a successful build's `snapshots`
179/// (mirroring `ProjectFailure::snapshots`) make that possible. A warning whose
180/// source isn't attributable (or doesn't fit the snapshot) falls back to the
181/// plain `warning[<category>]: <message>` form.
182pub fn print_project_warnings(
183    warnings: &[project::AttributedError],
184    snapshots: &[(PathBuf, String)],
185) {
186    for w in warnings {
187        match attributed_snapshot(w, snapshots) {
188            Some((label, text)) => {
189                bynk_render::print_errors(std::slice::from_ref(&w.error), text, &label)
190            }
191            None => {
192                let where_ = w
193                    .source_path
194                    .as_deref()
195                    .map(|p| format!("{}: ", p.to_string_lossy().replace('\\', "/")))
196                    .unwrap_or_default();
197                eprintln!("{where_}warning[{}]: {}", w.error.category, w.error.message);
198                for note in &w.error.notes {
199                    eprintln!("  note: {note}");
200                }
201                for (_, label) in &w.error.labels {
202                    eprintln!("  label: {label}");
203                }
204            }
205        }
206    }
207}
208
209/// [`print_project_warnings`]'s `--format short` analogue: one
210/// `path:line:col: warning[category]: message` line per warning, falling
211/// back to `warning[category]: message` when unattributed. Strictly one
212/// line per warning throughout (like [`bynk_render::render_errors_short`],
213/// this mirrors the VS Code problem-matcher's contract), so — unlike
214/// [`print_project_warnings`] — finding #47 doesn't reach this one.
215pub fn print_project_warnings_short(
216    warnings: &[project::AttributedError],
217    snapshots: &[(PathBuf, String)],
218) {
219    for w in warnings {
220        match attributed_snapshot(w, snapshots) {
221            Some((label, text)) => eprintln!("{}", bynk_render::short_line(&label, text, &w.error)),
222            // Every entry in `warnings` is warning-severity by construction
223            // (ADR 0117's own split), so `severity_word` here is always
224            // "warning" — read off the shared helper (finding #48) rather
225            // than hardcoding the string a second time.
226            None => eprintln!(
227                "{}[{}]: {}",
228                bynk_render::severity_word(&w.error),
229                w.error.category,
230                w.error.message
231            ),
232        }
233    }
234}
235
236/// The `(label, source text)` an `AttributedError`'s `source_path` resolves
237/// to in `snapshots`, if any — the one attribution lookup every renderer in
238/// this file shares (finding #48; previously `print_project_failure` and
239/// [`project_failure_short_lines`] each hand-rolled their own copy).
240fn attributed_snapshot<'a>(
241    ae: &project::AttributedError,
242    snapshots: &'a [(PathBuf, String)],
243) -> Option<(String, &'a str)> {
244    let path = ae.source_path.as_deref()?;
245    let text = snapshots
246        .iter()
247        .find(|(p, _)| p.as_path() == path)
248        .map(|(_, t)| t.as_str())?;
249    Some((path.to_string_lossy().replace('\\', "/"), text))
250}
251
252/// The project-failure analogue of [`bynk_render::print_errors_short`]: each
253/// attributed error is positioned against its file's snapshot; an unattributed
254/// (project-level) error falls back to `<severity>[<category>]: <message>`.
255pub fn print_project_failure_short(failure: &project::ProjectFailure) {
256    for line in project_failure_short_lines(failure) {
257        eprintln!("{line}");
258    }
259}
260
261/// The string form of [`print_project_failure_short`]: one `path:line:col:
262/// severity[category]: message` line per attributed error (an unattributed
263/// project-level error falls back to `severity[category]: message`). Backs both
264/// the printer above and the `bynkc test --format json` compile-error document,
265/// whose `diagnostics` the VS Code `bynkc` problem-matcher re-parses — each
266/// `Vec` entry is exactly one line by that contract, so unlike the other
267/// renderers in this file this one deliberately does *not* grow note/label
268/// continuation lines (finding #47): doing so would break a machine consumer
269/// that re-parses every entry as a single diagnostic line.
270///
271/// The flattening layer (ADR 0100): it delegates the per-error formatting to
272/// [`bynk_render::short_line`] / [`bynk_render::severity_word`], and the
273/// attribution lookup to the crate-private `attributed_snapshot` (finding #48).
274pub fn project_failure_short_lines(failure: &project::ProjectFailure) -> Vec<String> {
275    failure
276        .errors
277        .iter()
278        .map(|ae| match attributed_snapshot(ae, &failure.snapshots) {
279            Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
280            None => format!(
281                "{}[{}]: {}",
282                bynk_render::severity_word(&ae.error),
283                ae.error.category,
284                ae.error.message
285            ),
286        })
287        .collect()
288}
289
290/// Render every diagnostic from a [`project::ProjectCheck`] (finding #64) with
291/// the same per-file ariadne context [`print_project_failure`] gives its own,
292/// errors-only list. Unlike that renderer, a `ProjectCheck`'s list can
293/// legitimately mix both severities — the unattributed fallback line names its
294/// actual severity ([`bynk_render::severity_word`]) rather than
295/// `print_project_failure`'s bare `[category]: message` (silently correct only
296/// because that list is errors-only by construction).
297pub fn print_project_check(check: &project::ProjectCheck) {
298    for ae in &check.errors {
299        match attributed_snapshot(ae, &check.snapshots) {
300            Some((label, text)) => {
301                bynk_render::print_errors(std::slice::from_ref(&ae.error), text, &label);
302            }
303            None => {
304                eprintln!(
305                    "{}[{}]: {}",
306                    bynk_render::severity_word(&ae.error),
307                    ae.error.category,
308                    ae.error.message
309                );
310                for note in &ae.error.notes {
311                    eprintln!("  note: {note}");
312                }
313                for (_, label) in &ae.error.labels {
314                    eprintln!("  label: {label}");
315                }
316            }
317        }
318    }
319}
320
321/// [`print_project_check`]'s `--format short` analogue, mirroring
322/// [`project_failure_short_lines`].
323pub fn project_check_short_lines(check: &project::ProjectCheck) -> Vec<String> {
324    check
325        .errors
326        .iter()
327        .map(|ae| match attributed_snapshot(ae, &check.snapshots) {
328            Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
329            None => format!(
330                "{}[{}]: {}",
331                bynk_render::severity_word(&ae.error),
332                ae.error.category,
333                ae.error.message
334            ),
335        })
336        .collect()
337}
338
339/// [`print_project_check`] via [`project_check_short_lines`].
340pub fn print_project_check_short(check: &project::ProjectCheck) {
341    for line in project_check_short_lines(check) {
342        eprintln!("{line}");
343    }
344}
345
346/// How `--indent` spells the two [`IndentStyle`] variants. The words match the
347/// `[fmt] indent` key in `bynk.toml`, which the language server already reads,
348/// so a project states the same choice the same way in either place.
349#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
350pub enum IndentKind {
351    /// One tab per nesting level. The default — a reader sets their own tab
352    /// width in the editor, which space indentation takes away from them.
353    Tab,
354    /// `--indent-width` spaces per nesting level.
355    Spaces,
356}
357
358/// The `fmt` subcommand's arguments, flattened by both `bynkc::cli` and
359/// `bynk::cli` so the two spell one contract rather than two copies of it (the
360/// [`test_runner::TestArgs`] pattern, findings #40/#72). Field docs here are
361/// the CLI help text for both commands' flags.
362///
363/// Every style field is an `Option`, and deliberately carries no clap
364/// `default_value` (#972). The three sources are layered — spec default, then
365/// the project's `bynk.toml` `[fmt]`, then the flag — and a clap default would
366/// make "the user asked for 100" indistinguishable from "the user said
367/// nothing", so a manifest's `max_line_width = 120` would be overwritten by a
368/// flag nobody passed. `None` means *defer to the layer below*.
369#[derive(clap::Args, Debug)]
370pub struct FmtArgs {
371    /// Files to format. Use `-` for stdin → stdout.
372    pub inputs: Vec<PathBuf>,
373    /// Check formatting without writing changes. Exits non-zero if any
374    /// file is not already canonical.
375    #[arg(long)]
376    pub check: bool,
377    /// Indent with tabs or spaces. Defaults to the project's `[fmt] indent`,
378    /// or tabs.
379    #[arg(long, value_enum)]
380    pub indent: Option<IndentKind>,
381    /// Spaces per nesting level, with spaces indentation. Defaults to the
382    /// project's `[fmt] indent_width`, or 2. Rejected when the effective
383    /// indentation is tabs, where it would have no effect.
384    #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(0..=64))]
385    pub indent_width: Option<u8>,
386    /// Soft target line width in columns. A construct wider than this wraps
387    /// across lines where the grammar allows; one with no break point in it
388    /// (a long string literal) is left long. Defaults to the project's
389    /// `[fmt] max_line_width`, or 100.
390    #[arg(long, value_name = "COLUMNS", value_parser = clap::value_parser!(u32).range(1..))]
391    pub max_line_width: Option<u32>,
392    /// Emit a trailing comma in multi-line records, sums, list literals and
393    /// `exports` clauses. Overrides a project's `trailing_comma = false`, and
394    /// overrides an earlier `--no-trailing-comma`.
395    #[arg(long, overrides_with = "no_trailing_comma")]
396    pub trailing_comma: bool,
397    /// Omit the trailing comma in multi-line records, sums, list literals and
398    /// `exports` clauses. (Parameter and argument lists never carry one — the
399    /// grammar rejects it — regardless of this flag.)
400    #[arg(long, overrides_with = "trailing_comma")]
401    pub no_trailing_comma: bool,
402    /// Ignore the project's `bynk.toml` `[fmt]` section and format to the
403    /// canonical style, plus whatever flags this run passes. For a script that
404    /// wants one fixed rendering whatever project it is pointed at.
405    #[arg(long)]
406    pub no_config: bool,
407}
408
409impl FmtArgs {
410    /// Layer these arguments over `base` — the manifest-resolved options for
411    /// the file about to be formatted — or report why they describe nothing
412    /// usable. A field the run did not state leaves `base` untouched.
413    pub fn apply_to(&self, base: FormatOptions) -> Result<FormatOptions, String> {
414        // The width already in `base` (from `[fmt] indent_width`, or the spec
415        // default), so `--indent spaces` alone over a manifest's `indent_width
416        // = 4` lands on four spaces rather than resetting to two.
417        let base_width = match base.indent {
418            IndentStyle::Spaces(n) => Some(n),
419            IndentStyle::Tab => None,
420        };
421        let kind = self.indent.unwrap_or(match base.indent {
422            IndentStyle::Tab => IndentKind::Tab,
423            IndentStyle::Spaces(_) => IndentKind::Spaces,
424        });
425        let indent = match (kind, self.indent_width) {
426            (IndentKind::Tab, None) => IndentStyle::Tab,
427            // A width alongside tab indentation is silently meaningless, which
428            // is exactly the kind of ignored flag that costs an hour to
429            // notice. Say so instead — naming the *effective* indentation,
430            // since it may have come from the manifest rather than this run.
431            (IndentKind::Tab, Some(_)) => {
432                return Err(
433                    "`--indent-width` applies only to spaces indentation, and this run resolves \
434                     to tabs (pass `--indent spaces`, or set `[fmt] indent` in bynk.toml)"
435                        .to_string(),
436                );
437            }
438            // 2 matches the `bynk.toml` `[fmt] indent_width` fallback, so the
439            // CLI and the editor agree from the same words.
440            (IndentKind::Spaces, width) => IndentStyle::Spaces(width.or(base_width).unwrap_or(2)),
441        };
442        Ok(FormatOptions {
443            indent,
444            max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
445            // Neither flag set defers to `base`; clap's `overrides_with` pair
446            // makes the last one given win.
447            trailing_comma: if self.no_trailing_comma {
448                false
449            } else if self.trailing_comma {
450                true
451            } else {
452                base.trailing_comma
453            },
454        })
455    }
456}
457
458/// Why a run could not settle on the options to format an input with.
459enum FmtOptionsError {
460    /// The project's `bynk.toml` `[fmt]` section is unusable.
461    Manifest(PathBuf, bynk_fmt::ConfigError),
462    /// The flags this run passed contradict each other or the manifest.
463    Args(String),
464}
465
466impl std::fmt::Display for FmtOptionsError {
467    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
468        match self {
469            // Named, because the manifest governing a file is not necessarily
470            // the one in the working directory.
471            Self::Manifest(path, e) => write!(f, "{}: {e}", path.display()),
472            Self::Args(e) => write!(f, "{e}"),
473        }
474    }
475}
476
477/// Per-directory memo of the `[fmt]` section governing an input.
478///
479/// A run typically formats many files under one project (`fmt src/*.bynk`), so
480/// the upward walk for `bynk.toml` and its parse happen once per starting
481/// directory rather than once per file.
482struct ManifestCache {
483    /// `--no-config`: skip discovery entirely and hand back the spec defaults.
484    disabled: bool,
485    by_dir: std::collections::HashMap<PathBuf, FormatOptions>,
486}
487
488impl ManifestCache {
489    fn new(disabled: bool) -> Self {
490        Self {
491            disabled,
492            by_dir: std::collections::HashMap::new(),
493        }
494    }
495
496    /// The options `input` inherits from its project, before this run's flags.
497    fn options_for(&mut self, input: &Path) -> Result<FormatOptions, FmtOptionsError> {
498        if self.disabled {
499            return Ok(FormatOptions::default());
500        }
501        // Stdin carries no path to search from; the working directory is the
502        // only project context a pipe has.
503        let start: PathBuf = if input.as_os_str() == "-" {
504            PathBuf::from(".")
505        } else {
506            match input.parent() {
507                // A bare `x.bynk` has an empty parent, which is the cwd.
508                Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
509                _ => PathBuf::from("."),
510            }
511        };
512        // Absolutise before walking. A relative start has no ancestors to walk
513        // *through*: `Path::new("src").parent()` is `""` and `""`'s parent is
514        // `None`, so the search stops at the working directory and never
515        // reaches the project root above it. Run from `src/`, `fmt calc.bynk`
516        // therefore missed the very manifest `fmt src/calc.bynk` from the root
517        // found — silently formatting to the canonical style, and (under
518        // `--check`) gating CI on a style the editor never produces. Joining
519        // onto the cwd also collapses `src` and `/abs/src` to one cache key.
520        //
521        // `current_dir()` rather than `std::path::absolute`: same result here,
522        // and it does not raise the crate's MSRV.
523        let start = std::env::current_dir()
524            .map(|cwd| cwd.join(&start))
525            .unwrap_or(start);
526        if let Some(hit) = self.by_dir.get(&start) {
527            return Ok(*hit);
528        }
529        let opts = match bynk_fmt::find_manifest(&start) {
530            None => FormatOptions::default(),
531            Some(manifest) => {
532                let text = std::fs::read_to_string(&manifest).map_err(|e| {
533                    FmtOptionsError::Manifest(
534                        manifest.clone(),
535                        bynk_fmt::ConfigError::Read(e.to_string()),
536                    )
537                })?;
538                bynk_fmt::FmtConfig::from_manifest_str(&text)
539                    .map_err(|e| FmtOptionsError::Manifest(manifest, e))?
540                    .apply(FormatOptions::default())
541            }
542        };
543        self.by_dir.insert(start, opts);
544        Ok(opts)
545    }
546}
547
548/// The `fmt` command body shared by `bynkc fmt` and `bynk fmt`: each input is
549/// formatted and rewritten only when it changes; `--check` reports
550/// non-canonical files without writing; `-` reads stdin and writes the
551/// formatted result to stdout. `prog` prefixes messages (`bynk fmt: …`).
552pub fn run_fmt(prog: &str, args: &FmtArgs) -> ExitCode {
553    let (inputs, check) = (&args.inputs, args.check);
554    if inputs.is_empty() {
555        eprintln!("{prog} fmt: no input files (pass file paths or `-` for stdin)");
556        return ExitCode::FAILURE;
557    }
558    // Resolve *every* input's options before formatting any of them. Options
559    // are per-input — `[fmt]` belongs to the project the file sits in, so a
560    // path outside the current project obeys that project's style — but a
561    // manifest error found on the third input must not land after the first two
562    // have already been rewritten. Configuration is a whole-run precondition:
563    // it fails before a byte is written, or not at all.
564    let mut manifests = ManifestCache::new(args.no_config);
565    let mut resolved: Vec<FormatOptions> = Vec::with_capacity(inputs.len());
566    for input in inputs {
567        match manifests
568            .options_for(input)
569            .and_then(|base| args.apply_to(base).map_err(FmtOptionsError::Args))
570        {
571            Ok(opts) => resolved.push(opts),
572            Err(e) => {
573                eprintln!("{prog} fmt: {e}");
574                return ExitCode::FAILURE;
575            }
576        }
577    }
578
579    let mut had_diff = false;
580    let mut had_error = false;
581    for (input, opts) in inputs.iter().zip(resolved) {
582        if input.as_os_str() == "-" {
583            use std::io::Read;
584            let mut source = String::new();
585            if let Err(e) = std::io::stdin().read_to_string(&mut source) {
586                eprintln!("{prog} fmt: read from stdin: {e}");
587                return ExitCode::FAILURE;
588            }
589            match format_source(&source, &opts) {
590                Ok(formatted) => {
591                    if check {
592                        // `--check` on stdin must not print the formatted text
593                        // (it would pollute a CI log) and must report a diff the
594                        // same way the file path does — a `generator | bynk fmt
595                        // --check -` gate is otherwise dead, passing green on
596                        // non-canonical input.
597                        if formatted != source {
598                            eprintln!("{prog} fmt: <stdin> is not canonically formatted");
599                            had_diff = true;
600                        }
601                    } else {
602                        print!("{formatted}");
603                    }
604                }
605                Err(e) => {
606                    bynk_render::print_errors(&e.errors, &source, "<stdin>");
607                    return ExitCode::FAILURE;
608                }
609            }
610            continue;
611        }
612        let source = match std::fs::read_to_string(input) {
613            Ok(s) => s,
614            Err(e) => {
615                eprintln!("{prog} fmt: read `{}`: {e}", input.display());
616                had_error = true;
617                continue;
618            }
619        };
620        let filename = input.display().to_string();
621        match format_source(&source, &opts) {
622            Ok(formatted) => {
623                if check {
624                    if formatted != source {
625                        eprintln!(
626                            "{prog} fmt: {} is not canonically formatted",
627                            input.display()
628                        );
629                        had_diff = true;
630                    }
631                } else if formatted != source
632                    && let Err(e) = atomic_write(input, &formatted)
633                {
634                    eprintln!("{prog} fmt: write `{}`: {e}", input.display());
635                    had_error = true;
636                }
637            }
638            Err(e) => {
639                bynk_render::print_errors(&e.errors, &source, &filename);
640                had_error = true;
641            }
642        }
643    }
644    if had_error || (check && had_diff) {
645        ExitCode::FAILURE
646    } else {
647        ExitCode::SUCCESS
648    }
649}
650
651/// Write `contents` to `path` atomically: the bytes land in a sibling temp
652/// file that is then `rename`d over `path`. A plain `std::fs::write` truncates
653/// the destination *before* writing, so an ENOSPC, a signal, or a crash
654/// mid-write leaves the file truncated or empty — and for `fmt`, whose only
655/// copy of the original is the in-memory `source`, that original is then gone.
656/// The rename is atomic on POSIX and Windows, so a reader sees either the whole
657/// old file or the whole new one, never a half-written mix.
658///
659/// The temp file is a sibling (same directory) so the rename stays within one
660/// filesystem — a cross-device rename would fail with `EXDEV`. Its name carries
661/// the PID and a per-process counter so concurrent `fmt` runs, or two files in
662/// one run, never collide, and it is opened with `create_new` (`O_EXCL`): a
663/// pre-existing path — a stale temp from an earlier crashed run, or a symlink a
664/// local actor pre-planted to redirect the formatted bytes — is refused rather
665/// than opened, and we bump the counter and retry. On any failure the temp file
666/// is removed so a botched write leaves no litter beside the untouched original.
667///
668/// The `rename` swaps in a fresh inode, so if `path` was a symlink or a
669/// hardlink the formatted file replaces the link rather than being written
670/// through it (the old `std::fs::write` wrote through). Uncommon for source
671/// files, and the atomicity is worth it.
672fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
673    use std::io::Write as _;
674    use std::sync::atomic::{AtomicU64, Ordering};
675
676    static COUNTER: AtomicU64 = AtomicU64::new(0);
677
678    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
679    let file_name = path
680        .file_name()
681        .map(|n| n.to_string_lossy().into_owned())
682        .unwrap_or_default();
683
684    // Open a fresh sibling temp file exclusively, bumping the counter past any
685    // name that is already taken (stale temp or planted symlink).
686    let (mut file, tmp) = loop {
687        let n = COUNTER.fetch_add(1, Ordering::Relaxed);
688        let tmp_name = format!(".{file_name}.bynk-fmt.{}.{n}.tmp", std::process::id());
689        let tmp = match dir {
690            Some(d) => d.join(tmp_name),
691            None => PathBuf::from(tmp_name),
692        };
693        match std::fs::OpenOptions::new()
694            .write(true)
695            .create_new(true)
696            .open(&tmp)
697        {
698            Ok(f) => break (f, tmp),
699            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
700            Err(e) => return Err(e),
701        }
702    };
703
704    // Scope the write so the handle is flushed and closed before the rename.
705    // The rename replaces the destination inode, so carry the original file's
706    // permissions onto the temp file first — otherwise a formatted file would
707    // silently pick up the process umask's default mode (e.g. an executable or
708    // group-restricted source would lose its bits).
709    let write_result = (|| {
710        // Best-effort: a filesystem that cannot honour the mode must not fail
711        // the whole write.
712        if let Ok(meta) = std::fs::metadata(path) {
713            let _ = file.set_permissions(meta.permissions());
714        }
715        file.write_all(contents.as_bytes())?;
716        file.sync_all()
717    })();
718    if let Err(e) = write_result {
719        let _ = std::fs::remove_file(&tmp);
720        return Err(e);
721    }
722    if let Err(e) = std::fs::rename(&tmp, path) {
723        let _ = std::fs::remove_file(&tmp);
724        return Err(e);
725    }
726    Ok(())
727}
728
729/// The `check` command body shared by `bynkc check` and `bynk check`: a
730/// directory routes through [`project::check_project`] (finding #64 —
731/// non-bailing, so a structural error anywhere does not hide diagnostics
732/// elsewhere the way `compile_project`'s bail-fast `Mode::Build` would), a
733/// single file through [`bynk_emit::compile_with_warnings`]. `short` selects
734/// the one-line `--format short` rendering. `prog` prefixes messages
735/// (`bynk: …`).
736pub fn run_check(prog: &str, input: &Path, short: bool) -> ExitCode {
737    if input.is_dir() {
738        let options = match try_project_options(input) {
739            Ok(o) => o,
740            Err(e) => {
741                eprintln!("{prog}: {e}");
742                return ExitCode::FAILURE;
743            }
744        };
745        let check = project::check_project(&options);
746        let has_errors = check.has_errors();
747        if short {
748            print_project_check_short(&check);
749        } else {
750            print_project_check(&check);
751        }
752        if has_errors {
753            ExitCode::FAILURE
754        } else {
755            ExitCode::SUCCESS
756        }
757    } else {
758        let source = match std::fs::read_to_string(input) {
759            Ok(s) => s,
760            Err(e) => {
761                eprintln!("{prog}: could not read `{}`: {e}", input.display());
762                return ExitCode::FAILURE;
763            }
764        };
765        let filename = input.display().to_string();
766        match bynk_emit::compile_with_warnings(&source, &filename) {
767            Ok(compiled) => {
768                if !compiled.warnings.is_empty() {
769                    if short {
770                        bynk_render::print_errors_short(&compiled.warnings, &source, &filename);
771                    } else {
772                        bynk_render::print_errors(&compiled.warnings, &source, &filename);
773                    }
774                }
775                ExitCode::SUCCESS
776            }
777            Err(errors) => {
778                if short {
779                    bynk_render::print_errors_short(&errors, &source, &filename);
780                } else {
781                    bynk_render::print_errors(&errors, &source, &filename);
782                }
783                ExitCode::FAILURE
784            }
785        }
786    }
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use clap::Parser;
793
794    /// A minimal parser around [`FmtArgs`], so these assert what the real CLIs
795    /// parse rather than what a hand-built struct claims.
796    #[derive(clap::Parser, Debug)]
797    struct Harness {
798        #[command(flatten)]
799        args: FmtArgs,
800    }
801
802    fn parse(argv: &[&str]) -> FmtArgs {
803        let mut full = vec!["fmt"];
804        full.extend_from_slice(argv);
805        Harness::parse_from(full).args
806    }
807
808    /// The options a run resolves with no manifest in play.
809    fn resolve(argv: &[&str]) -> Result<FormatOptions, String> {
810        parse(argv).apply_to(FormatOptions::default())
811    }
812
813    /// A stand-in for what `bynk.toml` `[fmt]` resolved to.
814    fn manifest(toml: &str) -> FormatOptions {
815        bynk_fmt::FmtConfig::from_manifest_str(toml)
816            .expect("manifest parses")
817            .apply(FormatOptions::default())
818    }
819
820    #[test]
821    fn no_flags_is_the_canonical_style() {
822        let opts = resolve(&["a.bynk"]).expect("valid");
823        assert_eq!(opts, FormatOptions::default());
824    }
825
826    #[test]
827    fn spaces_without_a_width_falls_back_to_two() {
828        // The same fallback `bynk.toml`'s `[fmt] indent_width` uses, so the CLI
829        // and the language server land on the same style from the same words.
830        let opts = resolve(&["--indent", "spaces", "a.bynk"]).expect("valid");
831        assert_eq!(opts.indent, IndentStyle::Spaces(2));
832    }
833
834    #[test]
835    fn spaces_takes_the_given_width() {
836        let opts =
837            resolve(&["--indent", "spaces", "--indent-width", "4", "a.bynk"]).expect("valid");
838        assert_eq!(opts.indent, IndentStyle::Spaces(4));
839    }
840
841    #[test]
842    fn a_width_with_tabs_is_an_error_not_a_silent_no_op() {
843        let err = resolve(&["--indent", "tab", "--indent-width", "4", "a.bynk"])
844            .expect_err("a meaningless width must be reported");
845        assert!(err.contains("--indent-width"), "{err}");
846        assert!(err.contains("spaces"), "{err}");
847    }
848
849    #[test]
850    fn the_trailing_comma_pair_is_last_one_wins() {
851        // `overrides_with` in both directions: a script may append either flag
852        // to a shared argument list and have it win rather than conflict-error.
853        assert!(
854            resolve(&["--no-trailing-comma", "--trailing-comma", "a.bynk"])
855                .expect("valid")
856                .trailing_comma
857        );
858        assert!(
859            !resolve(&["--trailing-comma", "--no-trailing-comma", "a.bynk"])
860                .expect("valid")
861                .trailing_comma
862        );
863    }
864
865    #[test]
866    fn max_line_width_is_taken_verbatim_and_zero_is_refused() {
867        assert_eq!(
868            resolve(&["--max-line-width", "60", "a.bynk"])
869                .expect("valid")
870                .max_line_width,
871            60
872        );
873        // A zero-column budget is a nonsense input; clap rejects it at parse
874        // time rather than the formatter wrapping every construct maximally.
875        assert!(
876            Harness::try_parse_from(["fmt", "--max-line-width", "0", "a.bynk"]).is_err(),
877            "`--max-line-width 0` must not parse"
878        );
879    }
880
881    // -- #972: the `bynk.toml` `[fmt]` layer beneath the flags --
882
883    #[test]
884    fn an_unflagged_run_takes_the_manifest_whole() {
885        let base = manifest(
886            "[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\ntrailing_comma = false\n",
887        );
888        let opts = parse(&["a.bynk"]).apply_to(base).expect("valid");
889        assert_eq!(opts.indent, IndentStyle::Spaces(4));
890        assert_eq!(opts.max_line_width, 120);
891        assert!(!opts.trailing_comma);
892    }
893
894    #[test]
895    fn a_flag_beats_the_manifest_field_it_names_and_no_other() {
896        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\n");
897        let opts = parse(&["--max-line-width", "80", "a.bynk"])
898            .apply_to(base)
899            .expect("valid");
900        assert_eq!(opts.max_line_width, 80, "the flag wins where it speaks");
901        assert_eq!(
902            opts.indent,
903            IndentStyle::Spaces(4),
904            "and stays silent everywhere else"
905        );
906    }
907
908    #[test]
909    fn an_absent_flag_does_not_reset_the_manifest_to_the_default() {
910        // The regression a clap `default_value` would have caused: "the user
911        // said 100" is indistinguishable from "the user said nothing", so a
912        // project's 120 would be overwritten by a flag nobody passed.
913        let base = manifest("[fmt]\nmax_line_width = 120\n");
914        assert_eq!(
915            parse(&["a.bynk"])
916                .apply_to(base)
917                .expect("valid")
918                .max_line_width,
919            120
920        );
921    }
922
923    #[test]
924    fn indent_spaces_alone_keeps_the_manifest_width() {
925        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
926        let opts = parse(&["--indent", "spaces", "a.bynk"])
927            .apply_to(base)
928            .expect("valid");
929        assert_eq!(opts.indent, IndentStyle::Spaces(4), "not reset to 2");
930    }
931
932    #[test]
933    fn indent_width_alone_applies_to_a_manifest_that_chose_spaces() {
934        let base = manifest("[fmt]\nindent = \"spaces\"\n");
935        let opts = parse(&["--indent-width", "8", "a.bynk"])
936            .apply_to(base)
937            .expect("valid");
938        assert_eq!(opts.indent, IndentStyle::Spaces(8));
939    }
940
941    #[test]
942    fn indent_width_alone_is_refused_when_the_run_resolves_to_tabs() {
943        // No `--indent`, and a manifest that says tabs (or none at all): the
944        // width has nothing to apply to, and the message says so rather than
945        // the flag vanishing.
946        let err = parse(&["--indent-width", "8", "a.bynk"])
947            .apply_to(manifest("[fmt]\nindent = \"tab\"\n"))
948            .expect_err("refused");
949        assert!(err.contains("resolves"), "{err}");
950    }
951
952    #[test]
953    fn an_explicit_tab_flag_overrides_a_manifest_choosing_spaces() {
954        let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
955        let opts = parse(&["--indent", "tab", "a.bynk"])
956            .apply_to(base)
957            .expect("valid");
958        assert_eq!(opts.indent, IndentStyle::Tab);
959    }
960
961    #[test]
962    fn trailing_comma_flag_overrides_a_manifest_that_turned_it_off() {
963        let base = manifest("[fmt]\ntrailing_comma = false\n");
964        assert!(
965            parse(&["--trailing-comma", "a.bynk"])
966                .apply_to(base)
967                .expect("valid")
968                .trailing_comma
969        );
970        // …and an unflagged run still honours the manifest.
971        assert!(
972            !parse(&["a.bynk"])
973                .apply_to(base)
974                .expect("valid")
975                .trailing_comma
976        );
977    }
978
979    #[test]
980    fn no_config_is_parsed_and_defaults_are_used_in_its_presence() {
981        // `--no-config` is honoured by the manifest *lookup* (ManifestCache),
982        // so here it is enough that it parses and leaves the flag layer alone.
983        let args = parse(&["--no-config", "a.bynk"]);
984        assert!(args.no_config);
985        assert_eq!(
986            args.apply_to(FormatOptions::default()).expect("valid"),
987            FormatOptions::default()
988        );
989    }
990
991    /// A throwaway on-disk directory, removed on drop (including on panic) —
992    /// mirrors `bynk-driver/tests/project_diagnostics.rs`'s own `Scratch`.
993    struct Scratch(PathBuf);
994    impl Drop for Scratch {
995        fn drop(&mut self) {
996            let _ = fs::remove_dir_all(&self.0);
997        }
998    }
999
1000    fn scratch_dir(tag: &str) -> Scratch {
1001        let dir = std::env::temp_dir().join(format!(
1002            "bynk_1077_{tag}_{}_{:?}",
1003            std::process::id(),
1004            std::thread::current().id()
1005        ));
1006        let _ = fs::remove_dir_all(&dir);
1007        fs::create_dir_all(&dir).unwrap();
1008        Scratch(dir)
1009    }
1010
1011    /// #1077 review: `manifest_overlay` keys its entry exactly as
1012    /// `try_read_project_paths_with` looks it up — `root.join("bynk.toml")`,
1013    /// literal, no canonicalisation — and reads `bynk.toml`'s real content.
1014    /// This is what stops that lookup from falling through to `bynk-emit`'s
1015    /// own disk fallback; a mismatched key would silently degrade to the
1016    /// conventional layout instead of surfacing as a test failure here, so
1017    /// this asserts the map entry directly rather than only the end-to-end
1018    /// behaviour (which the integration test in `project_diagnostics.rs`
1019    /// covers).
1020    #[test]
1021    fn manifest_overlay_keys_and_reads_a_real_bynk_toml() {
1022        let dir = scratch_dir("manifest_overlay");
1023        let toml = "[paths]\ninclude = [\"lib\"]\n";
1024        fs::write(dir.0.join("bynk.toml"), toml).unwrap();
1025
1026        let overlay = manifest_overlay(&dir.0);
1027
1028        assert_eq!(
1029            overlay.get(&dir.0.join("bynk.toml")).map(String::as_str),
1030            Some(toml)
1031        );
1032    }
1033
1034    #[test]
1035    fn manifest_overlay_is_empty_with_no_bynk_toml() {
1036        let dir = scratch_dir("manifest_overlay_missing");
1037        assert!(manifest_overlay(&dir.0).is_empty());
1038    }
1039
1040    /// Review of #1084: `ProjectOptionsError::Paths` had no test anywhere in
1041    /// the repo, despite being the one arm where an overlay/disk divergence
1042    /// in the manifest read would actually be observable — everywhere else,
1043    /// `read_source`'s still-present disk fallback quietly reproduces the
1044    /// same result either way. This also re-pins that `?`'s automatic
1045    /// `From<ProjectPathsError>` conversion (not an explicit `map_err`) still
1046    /// reaches the caller correctly.
1047    #[test]
1048    fn try_project_options_surfaces_an_unknown_paths_key() {
1049        let dir = scratch_dir("try_project_options_unknown_key");
1050        fs::write(dir.0.join("bynk.toml"), "[paths]\ninculde = [\"src\"]\n").unwrap();
1051        fs::create_dir_all(dir.0.join("src")).unwrap();
1052        fs::write(dir.0.join("src/thing.bynk"), "context thing\n").unwrap();
1053
1054        let err = match try_project_options(&dir.0) {
1055            Err(e) => e,
1056            Ok(_) => panic!("an unrecognised [paths] key must be reported, not silently ignored"),
1057        };
1058        assert!(
1059            matches!(
1060                &err,
1061                ProjectOptionsError::Paths(ProjectPathsError::UnknownKey(k)) if k == "inculde"
1062            ),
1063            "expected Paths(UnknownKey(\"inculde\")), got: {err:?}"
1064        );
1065    }
1066}