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}