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