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};
16use bynk_render::LabelHome;
17
18pub mod coverage;
19pub mod dev_run;
20pub mod discovery;
21pub mod doc_examples;
22pub mod output;
23pub mod probe;
24pub mod remote;
25pub mod schema_lock;
26pub mod source_overlay;
27pub mod test_filter;
28pub mod test_json;
29pub mod test_runner;
30pub mod test_stream;
31
32pub use output::{write_document, write_modules, write_modules_for_node, write_output};
33
34/// Root a directory project the way every project command should (#46): a
35/// `bynk.toml` or a `src/` subdir selects **project** mode, whose flat
36/// `[paths] include`/`exclude` layout (v0.113, DECISION S) defaults to the
37/// conventional roots that exist (`src`, `tests`) or the project root itself;
38/// otherwise the legacy **single-tree** where `<dir>` is itself the root.
39/// `check`, `compile`, `test`, and `dev` all route through this so the
40/// conventional layout works the same from any of them.
41///
42/// #1077 (R2.3/T0.7 residue): reads and populates `.sources(...)` itself —
43/// `bynk-emit` no longer discovers or reads project files on disk, so this is
44/// now the one real place that walk happens for the live CLI path.
45///
46/// #1081 review: returns `Result` because that walk is real I/O against a
47/// user-controlled `bynk.toml` (a missing `include` root, an unreadable
48/// directory) — [`discovery::DiscoveryError`], not a panic.
49pub fn project_options(input: &Path) -> Result<CompileOptions, discovery::DiscoveryError> {
50 if input.join("bynk.toml").exists() || input.join("src").is_dir() {
51 let overlay = manifest_overlay(input);
52 let paths = try_read_project_paths_with(input, &overlay)
53 .unwrap_or_else(|_| project::ProjectPaths::conventional(input));
54 // #1890: degrades like `[paths]` above; `try_project_options` refuses.
55 let workers = project::try_read_workers_config_with(input, &overlay).unwrap_or_default();
56 Ok(options_for_split(input, paths)?
57 .compatibility_flags(workers.compatibility_flags)
58 .workers_dev(workers.workers_dev)
59 .asset_files(discovery::list_asset_folder(input)?)
60 .embed_files(discovery::list_embed_folder(input)?))
61 } else {
62 let sources = discovery::read_bynk_tree_single(input)?;
63 Ok(CompileOptions::single(input.to_path_buf())
64 .sources(sources)
65 .asset_files(discovery::list_asset_folder(input)?)
66 .embed_files(discovery::list_embed_folder(input)?))
67 }
68}
69
70/// [`project_options`], but a malformed `bynk.toml` is an error rather than a
71/// silent fall-back to the conventional layout — the one input a user
72/// hand-edits that the compiler otherwise reads without checking, after which
73/// a cascade of `bynk.uses.unknown_target` errors points at units that
74/// plainly exist on disk.
75///
76/// #1890: project mode also reads `[workers] compatibility_flags` into the
77/// options, so every CLI build (`bynkc compile`, `bynk dev`/`deploy`, which
78/// all come through here) writes the project's flags into `wrangler.toml`.
79pub fn try_project_options(input: &Path) -> Result<CompileOptions, ProjectOptionsError> {
80 // #1883, slice 1b: the `assets/` folder is listed once here, beside the
81 // sources; the build reads only the declared files through it, so
82 // `bynk-emit` never touches the disk.
83 let asset_files = discovery::list_asset_folder(input)?;
84 // #2387, slice 2: the `embeds/` folder, listed the same way.
85 let embed_files = discovery::list_embed_folder(input)?;
86 Ok(match project_sources(input)? {
87 (Some(paths), sources) => {
88 let workers = project::try_read_workers_config_with(input, &manifest_overlay(input))?;
89 CompileOptions::split(input.to_path_buf(), paths)
90 .sources(sources)
91 .compatibility_flags(workers.compatibility_flags)
92 .workers_dev(workers.workers_dev)
93 .asset_files(asset_files)
94 .embed_files(embed_files)
95 }
96 (None, sources) => CompileOptions::single(input.to_path_buf())
97 .sources(sources)
98 .asset_files(asset_files)
99 .embed_files(embed_files),
100 })
101}
102
103/// The `.bynk` files [`try_project_options`] reads for the directory `input`,
104/// sorted: what `check`, `test` and `compile` see. #1753: `fmt` expands a
105/// directory argument through this, so `fmt --check <dir>` and
106/// `check <dir>` cover the same files.
107pub fn project_source_files(input: &Path) -> Result<Vec<PathBuf>, ProjectOptionsError> {
108 let (_, sources) = project_sources(input)?;
109 let mut files: Vec<PathBuf> = sources.into_keys().collect();
110 files.sort();
111 Ok(files)
112}
113
114/// [`try_project_options`]' rooting and walk, shared with
115/// [`project_source_files`] so the two can't disagree on which files a
116/// directory holds. A `bynk.toml` or a `src/` subdir selects project mode,
117/// returning its `[paths]` layout alongside the sources; otherwise `input` is
118/// a single tree and the layout is `None`.
119fn project_sources(
120 input: &Path,
121) -> Result<(Option<project::ProjectPaths>, HashMap<PathBuf, String>), ProjectOptionsError> {
122 if input.join("bynk.toml").exists() || input.join("src").is_dir() {
123 let overlay = manifest_overlay(input);
124 // #1665: an unknown table is refused here, on the CLIs' path, before
125 // `[paths]` is read. (The language server reads `[paths]` alone.)
126 project::check_manifest(input, &overlay)?;
127 // #2207 (decision C): a workspace root is not a project. Walking it as
128 // one would read every member's sources as a single tree.
129 if project::is_workspace_root(input) {
130 return Err(ProjectPathsError::WorkspaceRoot.into());
131 }
132 let paths = try_read_project_paths_with(input, &overlay)?;
133 let sources = split_sources(input, &paths)?;
134 Ok((Some(paths), sources))
135 } else {
136 Ok((None, discovery::read_bynk_tree_single(input)?))
137 }
138}
139
140/// `bynk.toml`'s own content, keyed exactly as [`try_read_project_paths_with`]
141/// looks it up (`project_root.join("bynk.toml")`, unmodified — the literal-path
142/// branch of `discovery::read_source`'s overlay lookup, so this never needs to
143/// match a canonicalised key).
144///
145/// #1077 review: without this, both entry points above read `bynk.toml`
146/// through `bynk-emit`'s own disk-fallback (`read_source`'s `fs::read_to_string`
147/// on an overlay miss) — the one on-disk read #1081 left the CLI path still
148/// implicitly depending on `bynk-emit` for, despite that PR's claim of a fully
149/// fallback-free CLI path. A missing/unreadable `bynk.toml` yields an empty
150/// overlay, which `try_read_project_paths_with` already treats as "no
151/// manifest" (falls back to the conventional layout) — the same degrade
152/// `try_read_project_paths` itself provides.
153fn manifest_overlay(input: &Path) -> HashMap<PathBuf, String> {
154 let toml_path = input.join("bynk.toml");
155 match fs::read_to_string(&toml_path) {
156 Ok(text) => HashMap::from([(toml_path, text)]),
157 Err(_) => HashMap::new(),
158 }
159}
160
161/// The split-layout half of `project_options`: walk the project's sources
162/// ([`split_sources`]) and hand them to `CompileOptions::split` alongside the
163/// layout.
164fn options_for_split(
165 input: &Path,
166 paths: project::ProjectPaths,
167) -> Result<CompileOptions, discovery::DiscoveryError> {
168 let sources = split_sources(input, &paths)?;
169 Ok(CompileOptions::split(input.to_path_buf(), paths).sources(sources))
170}
171
172/// The sources of a project rooted at `input` with layout `paths`: build the
173/// one `Roots` value the project resolves to and walk exactly that (via
174/// [`discovery::sources_for_roots`] — #1081 review, so the CLI's walk can't
175/// drift from what `Roots::trees`/`Roots::excludes` themselves say). The one
176/// copy of the split-layout walk, shared by [`project_options`] and
177/// [`try_project_options`]/[`project_source_files`] (#1755 review).
178fn split_sources(
179 input: &Path,
180 paths: &project::ProjectPaths,
181) -> Result<HashMap<PathBuf, String>, discovery::DiscoveryError> {
182 let roots = project::Roots::Split {
183 project_root: input.to_path_buf(),
184 paths: paths.clone(),
185 };
186 discovery::sources_for_roots(&roots)
187}
188
189/// Why [`try_project_options`] could not produce a usable [`CompileOptions`]:
190/// either the manifest itself is unreadable ([`ProjectPathsError`]), or a
191/// well-formed manifest names a project tree that can't be walked
192/// ([`discovery::DiscoveryError`]) — #1081 review.
193#[derive(Debug)]
194pub enum ProjectOptionsError {
195 Paths(ProjectPathsError),
196 Discovery(discovery::DiscoveryError),
197}
198
199impl std::fmt::Display for ProjectOptionsError {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 match self {
202 Self::Paths(e) => write!(f, "{e}"),
203 Self::Discovery(e) => write!(f, "{e}"),
204 }
205 }
206}
207
208impl std::error::Error for ProjectOptionsError {
209 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
210 match self {
211 // `ProjectPathsError` doesn't itself implement `Error` (no
212 // further cause to chain to — it's already a leaf).
213 Self::Paths(_) => None,
214 Self::Discovery(e) => Some(e),
215 }
216 }
217}
218
219impl From<ProjectPathsError> for ProjectOptionsError {
220 fn from(e: ProjectPathsError) -> Self {
221 Self::Paths(e)
222 }
223}
224
225impl From<discovery::DiscoveryError> for ProjectOptionsError {
226 fn from(e: discovery::DiscoveryError) -> Self {
227 Self::Discovery(e)
228 }
229}
230
231/// Render a project build failure with per-file ariadne context, exactly as
232/// single-file mode had rich rendering. Unattributed (project-level) errors
233/// keep the plain form.
234///
235/// This is the **flattening layer** (ADR 0100): it attributes each
236/// `AttributedError` to its file snapshot and delegates the actual rendering to
237/// [`bynk_render::print_errors`]. The `ProjectFailure → CompileError` flattening
238/// stays here, above `bynk-render`, so there is no `render → emit` edge.
239pub fn print_project_failure(failure: &project::ProjectFailure) {
240 for ae in &failure.errors {
241 match attributed_snapshot(ae, &failure.snapshots, &failure.display_root) {
242 Some((label, text)) => {
243 print_attributed(ae, &label, text, &failure.snapshots, &failure.display_root);
244 }
245 None => {
246 eprintln!("[{}] {}", ae.error.category, ae.error.message);
247 for note in &ae.error.notes {
248 eprintln!(" note: {note}");
249 }
250 // Finding #47: a label's text still surfaces even with no
251 // file to underline it against.
252 for (_, label) in &ae.error.labels {
253 eprintln!(" label: {label}");
254 }
255 }
256 }
257 }
258}
259
260/// v0.89 (ADR 0117): print a successful build's non-failing warnings, with
261/// real per-file ariadne context now that a successful build's `snapshots`
262/// (mirroring `ProjectFailure::snapshots`) make that possible. A warning whose
263/// source isn't attributable (or doesn't fit the snapshot) falls back to the
264/// plain `warning[<category>]: <message>` form.
265pub fn print_project_warnings(
266 warnings: &[project::AttributedError],
267 snapshots: &[(PathBuf, String)],
268 display_root: &Path,
269) {
270 for w in warnings {
271 match attributed_snapshot(w, snapshots, display_root) {
272 Some((label, text)) => print_attributed(w, &label, text, snapshots, display_root),
273 None => {
274 let where_ = w
275 .source_path
276 .as_deref()
277 .map(|p| format!("{}: ", p.to_string_lossy().replace('\\', "/")))
278 .unwrap_or_default();
279 eprintln!("{where_}warning[{}]: {}", w.error.category, w.error.message);
280 for note in &w.error.notes {
281 eprintln!(" note: {note}");
282 }
283 for (_, label) in &w.error.labels {
284 eprintln!(" label: {label}");
285 }
286 }
287 }
288 }
289}
290
291/// [`print_project_warnings`]'s `--format short` analogue: one
292/// `path:line:col: warning[category]: message` line per warning, falling
293/// back to `warning[category]: message` when unattributed. Strictly one
294/// line per warning throughout (like [`bynk_render::render_errors_short`],
295/// this mirrors the VS Code problem-matcher's contract), so — unlike
296/// [`print_project_warnings`] — finding #47 doesn't reach this one.
297pub fn print_project_warnings_short(
298 warnings: &[project::AttributedError],
299 snapshots: &[(PathBuf, String)],
300 display_root: &Path,
301) {
302 for w in warnings {
303 match attributed_snapshot(w, snapshots, display_root) {
304 Some((label, text)) => eprintln!("{}", bynk_render::short_line(&label, text, &w.error)),
305 // Every entry in `warnings` is warning-severity by construction
306 // (ADR 0117's own split), so `severity_word` here is always
307 // "warning" — read off the shared helper (finding #48) rather
308 // than hardcoding the string a second time.
309 None => eprintln!(
310 "{}[{}]: {}",
311 bynk_render::severity_word(&w.error),
312 w.error.category,
313 w.error.message
314 ),
315 }
316 }
317}
318
319/// The `(label, source text)` an `AttributedError`'s `source_path` resolves
320/// to in `snapshots`, if any — the one attribution lookup every renderer in
321/// this file shares (finding #48; previously `print_project_failure` and
322/// [`project_failure_short_lines`] each hand-rolled their own copy).
323///
324/// #1772: the label is the file's path as typed from the working directory
325/// ([`display_path`]), not its identity path. `snapshots` stay keyed by
326/// identity.
327fn attributed_snapshot<'a>(
328 ae: &project::AttributedError,
329 snapshots: &'a [(PathBuf, String)],
330 display_root: &Path,
331) -> Option<(String, &'a str)> {
332 let path = ae.source_path.as_deref()?;
333 let text = snapshots
334 .iter()
335 .find(|(p, _)| p.as_path() == path)
336 .map(|(_, t)| t.as_str())?;
337 Some((display_path(display_root, path), text))
338}
339
340/// Render `ae` with ariadne against its own file's `text` (shown as `label`),
341/// each secondary label against the file it lies in: a "previously declared
342/// here" in a sibling file of a multi-file unit is drawn in a snippet of that
343/// file, from `snapshots`, not at the same offsets in this one. A label into a
344/// first-party unit (#1956) is drawn against that unit's own source, named by
345/// its bare identity path (`bynk.bynk`) — it is not under the project root, so
346/// [`display_path`] would name a file that doesn't exist. A label whose file
347/// has no text becomes a note.
348fn print_attributed(
349 ae: &project::AttributedError,
350 label: &str,
351 text: &str,
352 snapshots: &[(PathBuf, String)],
353 display_root: &Path,
354) {
355 let others: Vec<Option<(String, &str)>> = (0..ae.error.labels.len())
356 .map(|i| match ae.label_file(i) {
357 project::LabelFile::Other(path) => snapshots
358 .iter()
359 .find(|(p, _)| p == path)
360 .map(|(_, t)| (display_path(display_root, path), t.as_str())),
361 project::LabelFile::FirstParty {
362 identity_path,
363 source,
364 } => Some(((*identity_path).to_string(), *source)),
365 _ => None,
366 })
367 .collect();
368 let homes: Vec<LabelHome<'_>> = others
369 .iter()
370 .enumerate()
371 .map(|(i, other)| match (ae.label_file(i), other) {
372 (project::LabelFile::Primary, _) => LabelHome::Primary,
373 (_, Some((filename, source))) => LabelHome::File { filename, source },
374 (_, None) => LabelHome::Unavailable,
375 })
376 .collect();
377 bynk_render::print_error_in(&ae.error, text, label, &homes);
378}
379
380/// #1772: the path a diagnostic names, as a user would type it from the
381/// working directory: the build's root as the caller spelled it, joined with
382/// the file's identity path (relative to that root). A relative root is
383/// already relative to the working directory; an absolute one (`bynk dev`
384/// resolves the project root) is shown relative to the working directory when
385/// it lies inside it. This matches the path `fmt` reports for the same file,
386/// so a problem matcher resolving against the working directory finds it.
387/// Separators print as `/` on every platform.
388///
389/// #1774 review: a `.` component is dropped (`bynk check`'s default input is
390/// `.`, and `Roots::trees` already keeps `./` out of identity paths for the
391/// same reason), and an absolute path is compared against the cwd both as
392/// given and canonicalised, since a canonical root on Windows is a verbatim
393/// `\\?\C:\…` path that a plain cwd never prefixes. One that still isn't
394/// under the cwd is shown absolute, without a verbatim prefix.
395pub fn display_path(display_root: &Path, identity: &Path) -> String {
396 use std::path::Component;
397 let joined: PathBuf = display_root
398 .join(identity)
399 .components()
400 .filter(|c| !matches!(c, Component::CurDir))
401 .collect();
402 let shown = if joined.is_absolute() {
403 let cwd = std::env::current_dir().ok();
404 let canonical_cwd = cwd.as_ref().and_then(|c| c.canonicalize().ok());
405 cwd.iter()
406 .chain(canonical_cwd.iter())
407 .find_map(|base| joined.strip_prefix(base).ok().map(Path::to_path_buf))
408 .unwrap_or(joined)
409 } else {
410 joined
411 };
412 let text = shown.to_string_lossy().replace('\\', "/");
413 // A verbatim prefix (`//?/C:/…`, `//?/UNC/server/…`) is Windows-internal.
414 if let Some(unc) = text.strip_prefix("//?/UNC/") {
415 format!("//{unc}")
416 } else if let Some(rest) = text.strip_prefix("//?/") {
417 rest.to_string()
418 } else {
419 text
420 }
421}
422
423/// #1774: a path the user named directly (a `fmt` input, a single-file
424/// `check`), shown by the same rule as [`display_path`], so `fmt` and `check`
425/// print one file identically on every platform. On Windows a directory's
426/// expansion joined `\` onto a `/`-typed input, giving mixed separators.
427fn shown(path: &Path) -> String {
428 display_path(Path::new(""), path)
429}
430
431/// The project-failure analogue of [`bynk_render::print_errors_short`]: each
432/// attributed error is positioned against its file's snapshot; an unattributed
433/// (project-level) error falls back to `<severity>[<category>]: <message>`.
434pub fn print_project_failure_short(failure: &project::ProjectFailure) {
435 for line in project_failure_short_lines(failure) {
436 eprintln!("{line}");
437 }
438}
439
440/// The string form of [`print_project_failure_short`]: one `path:line:col:
441/// severity[category]: message` line per attributed error (an unattributed
442/// project-level error falls back to `severity[category]: message`). Backs both
443/// the printer above and the `bynkc test --format json` compile-error document,
444/// whose `diagnostics` the VS Code `bynkc` problem-matcher re-parses — each
445/// `Vec` entry is exactly one line by that contract, so unlike the other
446/// renderers in this file this one deliberately does *not* grow note/label
447/// continuation lines (finding #47): doing so would break a machine consumer
448/// that re-parses every entry as a single diagnostic line.
449///
450/// The flattening layer (ADR 0100): it delegates the per-error formatting to
451/// [`bynk_render::short_line`] / [`bynk_render::severity_word`], and the
452/// attribution lookup to the crate-private `attributed_snapshot` (finding #48).
453pub fn project_failure_short_lines(failure: &project::ProjectFailure) -> Vec<String> {
454 failure
455 .errors
456 .iter()
457 .map(
458 |ae| match attributed_snapshot(ae, &failure.snapshots, &failure.display_root) {
459 Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
460 None => format!(
461 "{}[{}]: {}",
462 bynk_render::severity_word(&ae.error),
463 ae.error.category,
464 ae.error.message
465 ),
466 },
467 )
468 .collect()
469}
470
471/// Render every diagnostic from a [`project::ProjectCheck`] (finding #64) with
472/// the same per-file ariadne context [`print_project_failure`] gives its own,
473/// errors-only list. Unlike that renderer, a `ProjectCheck`'s list can
474/// legitimately mix both severities — the unattributed fallback line names its
475/// actual severity ([`bynk_render::severity_word`]) rather than
476/// `print_project_failure`'s bare `[category]: message` (silently correct only
477/// because that list is errors-only by construction).
478pub fn print_project_check(check: &project::ProjectCheck) {
479 for ae in &check.errors {
480 match attributed_snapshot(ae, &check.snapshots, &check.display_root) {
481 Some((label, text)) => {
482 print_attributed(ae, &label, text, &check.snapshots, &check.display_root);
483 }
484 None => {
485 eprintln!(
486 "{}[{}]: {}",
487 bynk_render::severity_word(&ae.error),
488 ae.error.category,
489 ae.error.message
490 );
491 for note in &ae.error.notes {
492 eprintln!(" note: {note}");
493 }
494 for (_, label) in &ae.error.labels {
495 eprintln!(" label: {label}");
496 }
497 }
498 }
499 }
500}
501
502/// [`print_project_check`]'s `--format short` analogue, mirroring
503/// [`project_failure_short_lines`].
504pub fn project_check_short_lines(check: &project::ProjectCheck) -> Vec<String> {
505 check
506 .errors
507 .iter()
508 .map(
509 |ae| match attributed_snapshot(ae, &check.snapshots, &check.display_root) {
510 Some((label, text)) => bynk_render::short_line(&label, text, &ae.error),
511 None => format!(
512 "{}[{}]: {}",
513 bynk_render::severity_word(&ae.error),
514 ae.error.category,
515 ae.error.message
516 ),
517 },
518 )
519 .collect()
520}
521
522/// [`print_project_check`] via [`project_check_short_lines`].
523pub fn print_project_check_short(check: &project::ProjectCheck) {
524 for line in project_check_short_lines(check) {
525 eprintln!("{line}");
526 }
527}
528
529/// How `--indent` spells the two [`IndentStyle`] variants. The words match the
530/// `[fmt] indent` key in `bynk.toml`, which the language server already reads,
531/// so a project states the same choice the same way in either place.
532#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
533pub enum IndentKind {
534 /// One tab per nesting level. The default — a reader sets their own tab
535 /// width in the editor, which space indentation takes away from them.
536 Tab,
537 /// `--indent-width` spaces per nesting level.
538 Spaces,
539}
540
541/// The `fmt` subcommand's arguments, flattened by both `bynkc::cli` and
542/// `bynk::cli` so the two spell one contract rather than two copies of it (the
543/// [`test_runner::TestArgs`] pattern, findings #40/#72). Field docs here are
544/// the CLI help text for both commands' flags.
545///
546/// Every style field is an `Option`, and deliberately carries no clap
547/// `default_value` (#972). The three sources are layered — spec default, then
548/// the project's `bynk.toml` `[fmt]`, then the flag — and a clap default would
549/// make "the user asked for 100" indistinguishable from "the user said
550/// nothing", so a manifest's `max_line_width = 120` would be overwritten by a
551/// flag nobody passed. `None` means *defer to the layer below*.
552#[derive(clap::Args, Debug)]
553pub struct FmtArgs {
554 /// Files or directories to format. A directory formats the `.bynk` files
555 /// `check` would read in it: a project root's `[paths] include` trees minus
556 /// `exclude`, a workspace root's members, or any other directory walked
557 /// recursively. Use `-` for stdin → stdout.
558 pub inputs: Vec<PathBuf>,
559 /// Check formatting without writing changes. Exits non-zero if any
560 /// file is not already canonical.
561 #[arg(long)]
562 pub check: bool,
563 /// Indent with tabs or spaces. Defaults to the project's `[fmt] indent`,
564 /// or tabs.
565 #[arg(long, value_enum)]
566 pub indent: Option<IndentKind>,
567 /// Spaces per nesting level, with spaces indentation. Defaults to the
568 /// project's `[fmt] indent_width`, or 2. Rejected when the effective
569 /// indentation is tabs, where it would have no effect.
570 #[arg(long, value_name = "N", value_parser = clap::value_parser!(u8).range(0..=64))]
571 pub indent_width: Option<u8>,
572 /// Soft target line width in columns. A construct wider than this wraps
573 /// across lines where the grammar allows; one with no break point in it
574 /// (a long string literal) is left long. Defaults to the project's
575 /// `[fmt] max_line_width`, or 100.
576 #[arg(long, value_name = "COLUMNS", value_parser = clap::value_parser!(u32).range(1..))]
577 pub max_line_width: Option<u32>,
578 /// Emit a trailing comma in multi-line records, sums, list literals and
579 /// `exports` clauses. Overrides a project's `trailing_comma = false`, and
580 /// overrides an earlier `--no-trailing-comma`.
581 #[arg(long, overrides_with = "no_trailing_comma")]
582 pub trailing_comma: bool,
583 /// Omit the trailing comma in multi-line records, sums, list literals and
584 /// `exports` clauses. (Parameter and argument lists never carry one — the
585 /// grammar rejects it — regardless of this flag.)
586 #[arg(long, overrides_with = "trailing_comma")]
587 pub no_trailing_comma: bool,
588 /// Ignore the project's `bynk.toml` `[fmt]` section and format to the
589 /// canonical style, plus whatever flags this run passes. For a script that
590 /// wants one fixed rendering whatever project it is pointed at.
591 #[arg(long)]
592 pub no_config: bool,
593}
594
595impl FmtArgs {
596 /// Layer these arguments over `base` — the manifest-resolved options for
597 /// the file about to be formatted — or report why they describe nothing
598 /// usable. A field the run did not state leaves `base` untouched.
599 pub fn apply_to(&self, base: FormatOptions) -> Result<FormatOptions, String> {
600 // The width already in `base` (from `[fmt] indent_width`, or the spec
601 // default), so `--indent spaces` alone over a manifest's `indent_width
602 // = 4` lands on four spaces rather than resetting to two.
603 let base_width = match base.indent {
604 IndentStyle::Spaces(n) => Some(n),
605 IndentStyle::Tab => None,
606 };
607 let kind = self.indent.unwrap_or(match base.indent {
608 IndentStyle::Tab => IndentKind::Tab,
609 IndentStyle::Spaces(_) => IndentKind::Spaces,
610 });
611 let indent = match (kind, self.indent_width) {
612 (IndentKind::Tab, None) => IndentStyle::Tab,
613 // A width alongside tab indentation is silently meaningless, which
614 // is exactly the kind of ignored flag that costs an hour to
615 // notice. Say so instead — naming the *effective* indentation,
616 // since it may have come from the manifest rather than this run.
617 (IndentKind::Tab, Some(_)) => {
618 return Err(
619 "`--indent-width` applies only to spaces indentation, and this run resolves \
620 to tabs (pass `--indent spaces`, or set `[fmt] indent` in bynk.toml)"
621 .to_string(),
622 );
623 }
624 // 2 matches the `bynk.toml` `[fmt] indent_width` fallback, so the
625 // CLI and the editor agree from the same words.
626 (IndentKind::Spaces, width) => IndentStyle::Spaces(width.or(base_width).unwrap_or(2)),
627 };
628 Ok(FormatOptions {
629 indent,
630 max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
631 // Neither flag set defers to `base`; clap's `overrides_with` pair
632 // makes the last one given win.
633 trailing_comma: if self.no_trailing_comma {
634 false
635 } else if self.trailing_comma {
636 true
637 } else {
638 base.trailing_comma
639 },
640 })
641 }
642}
643
644/// Why a run could not settle on the options to format an input with.
645enum FmtOptionsError {
646 /// The project's `bynk.toml` `[fmt]` section is unusable.
647 Manifest(PathBuf, bynk_fmt::ConfigError),
648 /// #1665: the project's `bynk.toml` has a table (or a `[project]`/`[lsp]`
649 /// key) it doesn't define.
650 ManifestTables(PathBuf, project::ProjectPathsError),
651 /// The flags this run passed contradict each other or the manifest.
652 Args(String),
653}
654
655impl std::fmt::Display for FmtOptionsError {
656 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
657 match self {
658 // Named, because the manifest governing a file is not necessarily
659 // the one in the working directory.
660 Self::Manifest(path, e) => write!(f, "{}: {e}", path.display()),
661 Self::ManifestTables(path, e) => write!(f, "{}: {e}", path.display()),
662 Self::Args(e) => write!(f, "{e}"),
663 }
664 }
665}
666
667/// Per-directory memo of the `[fmt]` section governing an input.
668///
669/// A run typically formats many files under one project (`fmt src/*.bynk`), so
670/// the upward walk for `bynk.toml` and its parse happen once per starting
671/// directory rather than once per file.
672struct ManifestCache {
673 /// `--no-config`: skip discovery entirely and hand back the spec defaults.
674 disabled: bool,
675 by_dir: std::collections::HashMap<PathBuf, FormatOptions>,
676}
677
678impl ManifestCache {
679 fn new(disabled: bool) -> Self {
680 Self {
681 disabled,
682 by_dir: std::collections::HashMap::new(),
683 }
684 }
685
686 /// The options `input` inherits from its project, before this run's flags.
687 fn options_for(&mut self, input: &Path) -> Result<FormatOptions, FmtOptionsError> {
688 if self.disabled {
689 return Ok(FormatOptions::default());
690 }
691 // Stdin carries no path to search from; the working directory is the
692 // only project context a pipe has.
693 let start: PathBuf = if input.as_os_str() == "-" {
694 PathBuf::from(".")
695 } else {
696 match input.parent() {
697 // A bare `x.bynk` has an empty parent, which is the cwd.
698 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
699 _ => PathBuf::from("."),
700 }
701 };
702 // Absolutise before walking. A relative start has no ancestors to walk
703 // *through*: `Path::new("src").parent()` is `""` and `""`'s parent is
704 // `None`, so the search stops at the working directory and never
705 // reaches the project root above it. Run from `src/`, `fmt calc.bynk`
706 // therefore missed the very manifest `fmt src/calc.bynk` from the root
707 // found — silently formatting to the canonical style, and (under
708 // `--check`) gating CI on a style the editor never produces. Joining
709 // onto the cwd also collapses `src` and `/abs/src` to one cache key.
710 //
711 // `current_dir()` rather than `std::path::absolute`: same result here,
712 // and it does not raise the crate's MSRV.
713 let start = std::env::current_dir()
714 .map(|cwd| cwd.join(&start))
715 .unwrap_or(start);
716 if let Some(hit) = self.by_dir.get(&start) {
717 return Ok(*hit);
718 }
719 let opts = match bynk_fmt::find_manifest(&start) {
720 None => FormatOptions::default(),
721 Some(manifest) => {
722 let text = std::fs::read_to_string(&manifest).map_err(|e| {
723 FmtOptionsError::Manifest(
724 manifest.clone(),
725 bynk_fmt::ConfigError::Read(e.to_string()),
726 )
727 })?;
728 // `[fmt]` first: its reader owns the detailed TOML parse error
729 // and its own keys. #1665: then the manifest's table set.
730 let config = bynk_fmt::FmtConfig::from_manifest_str(&text)
731 .map_err(|e| FmtOptionsError::Manifest(manifest.clone(), e))?;
732 let options = config.apply(FormatOptions::default());
733 // #2207: path-aware, so a workspace member's own
734 // `[registries]` or `[workspace]` is refused here as in `check`.
735 let root = manifest.parent().unwrap_or(Path::new("."));
736 project::check_manifest(root, &HashMap::from([(root.join("bynk.toml"), text)]))
737 .map_err(|e| FmtOptionsError::ManifestTables(manifest, e))?;
738 options
739 }
740 };
741 self.by_dir.insert(start, opts);
742 Ok(opts)
743 }
744}
745
746/// The `fmt` command body shared by `bynkc fmt` and `bynk fmt`: each input is
747/// formatted and rewritten only when it changes; `--check` reports
748/// non-canonical files without writing; `-` reads stdin and writes the
749/// formatted result to stdout. `prog` prefixes messages (`bynk fmt: …`).
750pub fn run_fmt(prog: &str, args: &FmtArgs) -> ExitCode {
751 let check = args.check;
752 if args.inputs.is_empty() {
753 eprintln!("{prog} fmt: no input files (pass file or directory paths, or `-` for stdin)");
754 return ExitCode::FAILURE;
755 }
756 let inputs = match expand_fmt_inputs(&args.inputs) {
757 Ok(inputs) => inputs,
758 Err(e) => {
759 eprintln!("{prog} fmt: {e}");
760 return ExitCode::FAILURE;
761 }
762 };
763 let inputs = &inputs;
764 // Resolve *every* input's options before formatting any of them. Options
765 // are per-input — `[fmt]` belongs to the project the file sits in, so a
766 // path outside the current project obeys that project's style — but a
767 // manifest error found on the third input must not land after the first two
768 // have already been rewritten. Configuration is a whole-run precondition:
769 // it fails before a byte is written, or not at all.
770 let mut manifests = ManifestCache::new(args.no_config);
771 let mut resolved: Vec<FormatOptions> = Vec::with_capacity(inputs.len());
772 for input in inputs {
773 match manifests
774 .options_for(input)
775 .and_then(|base| args.apply_to(base).map_err(FmtOptionsError::Args))
776 {
777 Ok(opts) => resolved.push(opts),
778 Err(e) => {
779 eprintln!("{prog} fmt: {e}");
780 return ExitCode::FAILURE;
781 }
782 }
783 }
784
785 let mut had_diff = false;
786 let mut had_error = false;
787 for (input, opts) in inputs.iter().zip(resolved) {
788 if input.as_os_str() == "-" {
789 use std::io::Read;
790 let mut source = String::new();
791 if let Err(e) = std::io::stdin().read_to_string(&mut source) {
792 eprintln!("{prog} fmt: read from stdin: {e}");
793 return ExitCode::FAILURE;
794 }
795 // #1763: compared, and errors rendered, modulo line endings.
796 let source = bynk_fmt::normalize_line_endings(&source);
797 match format_source(&source, &opts) {
798 Ok(formatted) => {
799 if check {
800 // `--check` on stdin must not print the formatted text
801 // (it would pollute a CI log) and must report a diff the
802 // same way the file path does — a `generator | bynk fmt
803 // --check -` gate is otherwise dead, passing green on
804 // non-canonical input.
805 if formatted != source {
806 eprintln!("{prog} fmt: <stdin> is not canonically formatted");
807 had_diff = true;
808 }
809 } else {
810 print!("{formatted}");
811 }
812 }
813 Err(e) => {
814 bynk_render::print_errors(&e.errors, &source, "<stdin>");
815 return ExitCode::FAILURE;
816 }
817 }
818 continue;
819 }
820 let raw = match std::fs::read_to_string(input) {
821 Ok(s) => s,
822 Err(e) => {
823 eprintln!("{prog} fmt: read `{}`: {e}", shown(input));
824 had_error = true;
825 continue;
826 }
827 };
828 // #1763: line endings are not a formatting difference. A CRLF copy of a
829 // canonical file passes `--check` and isn't rewritten; a file that does
830 // need formatting is written in the LF canonical form. Errors render
831 // against the normalised text, whose spans they carry.
832 let source = bynk_fmt::normalize_line_endings(&raw);
833 let filename = shown(input);
834 match format_source(&source, &opts) {
835 Ok(formatted) => {
836 if check {
837 if formatted != source {
838 eprintln!("{prog} fmt: {} is not canonically formatted", filename);
839 had_diff = true;
840 }
841 } else if formatted != source
842 && let Err(e) = atomic_write(input, &formatted)
843 {
844 eprintln!("{prog} fmt: write `{}`: {e}", filename);
845 had_error = true;
846 }
847 }
848 Err(e) => {
849 bynk_render::print_errors(&e.errors, &source, &filename);
850 had_error = true;
851 }
852 }
853 }
854 if had_error || (check && had_diff) {
855 ExitCode::FAILURE
856 } else {
857 ExitCode::SUCCESS
858 }
859}
860
861/// #1753: expand `fmt`'s arguments to the files it formats. A directory
862/// becomes the `.bynk` files [`project_source_files`] finds in it, which is what
863/// `check` and `test` read for the same argument: a project root's `[paths]`
864/// `include` trees minus `exclude`, or a plain directory walked recursively,
865/// hidden directories skipped either way. A file or `-` passes through. A file
866/// named more than once, directly or through a directory, is formatted once.
867///
868/// A directory with no `.bynk` files is an error rather than an empty run, so
869/// a mistyped path cannot pass `--check`. Expansion happens before any file is
870/// read, so its errors, like a manifest error, land before anything is written.
871fn expand_fmt_inputs(inputs: &[PathBuf]) -> Result<Vec<PathBuf>, String> {
872 let mut out = Vec::new();
873 let mut seen = std::collections::HashSet::new();
874 for input in inputs {
875 // #2207 (decision C): a workspace root formats every member's files,
876 // named from the root as the command was given it.
877 if input.as_os_str() != "-"
878 && let Some(workspace) = workspace_at(input)
879 {
880 let workspace = workspace.map_err(|e| format!("`{}`: {e}", input.display()))?;
881 for member in &workspace.members {
882 let member = member_dir(input, member);
883 let files = project_source_files(&member)
884 .map_err(|e| format!("`{}`: {e}", shown(&member)))?;
885 for file in files {
886 let key = std::fs::canonicalize(&file).unwrap_or_else(|_| file.clone());
887 if seen.insert(key) {
888 out.push(file);
889 }
890 }
891 }
892 continue;
893 }
894 let files = if input.as_os_str() != "-" && input.is_dir() {
895 let files =
896 project_source_files(input).map_err(|e| format!("`{}`: {e}", input.display()))?;
897 if files.is_empty() {
898 return Err(format!("no `.bynk` files under `{}`", input.display()));
899 }
900 files
901 } else {
902 vec![input.clone()]
903 };
904 for file in files {
905 let key = std::fs::canonicalize(&file).unwrap_or_else(|_| file.clone());
906 if seen.insert(key) {
907 out.push(file);
908 }
909 }
910 }
911 Ok(out)
912}
913
914/// Write `contents` to `path` atomically: the bytes land in a sibling temp
915/// file that is then `rename`d over `path`. A plain `std::fs::write` truncates
916/// the destination *before* writing, so an ENOSPC, a signal, or a crash
917/// mid-write leaves the file truncated or empty — and for `fmt`, whose only
918/// copy of the original is the in-memory `source`, that original is then gone.
919/// The rename is atomic on POSIX and Windows, so a reader sees either the whole
920/// old file or the whole new one, never a half-written mix.
921///
922/// The temp file is a sibling (same directory) so the rename stays within one
923/// filesystem — a cross-device rename would fail with `EXDEV`. Its name carries
924/// the PID and a per-process counter so concurrent `fmt` runs, or two files in
925/// one run, never collide, and it is opened with `create_new` (`O_EXCL`): a
926/// pre-existing path — a stale temp from an earlier crashed run, or a symlink a
927/// local actor pre-planted to redirect the formatted bytes — is refused rather
928/// than opened, and we bump the counter and retry. On any failure the temp file
929/// is removed so a botched write leaves no litter beside the untouched original.
930///
931/// The `rename` swaps in a fresh inode, so if `path` was a symlink or a
932/// hardlink the formatted file replaces the link rather than being written
933/// through it (the old `std::fs::write` wrote through). Uncommon for source
934/// files, and the atomicity is worth it.
935fn atomic_write(path: &Path, contents: &str) -> std::io::Result<()> {
936 use std::io::Write as _;
937 use std::sync::atomic::{AtomicU64, Ordering};
938
939 static COUNTER: AtomicU64 = AtomicU64::new(0);
940
941 let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
942 let file_name = path
943 .file_name()
944 .map(|n| n.to_string_lossy().into_owned())
945 .unwrap_or_default();
946
947 // Open a fresh sibling temp file exclusively, bumping the counter past any
948 // name that is already taken (stale temp or planted symlink).
949 let (mut file, tmp) = loop {
950 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
951 let tmp_name = format!(".{file_name}.bynk-fmt.{}.{n}.tmp", std::process::id());
952 let tmp = match dir {
953 Some(d) => d.join(tmp_name),
954 None => PathBuf::from(tmp_name),
955 };
956 match std::fs::OpenOptions::new()
957 .write(true)
958 .create_new(true)
959 .open(&tmp)
960 {
961 Ok(f) => break (f, tmp),
962 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
963 Err(e) => return Err(e),
964 }
965 };
966
967 // Scope the write so the handle is flushed and closed before the rename.
968 // The rename replaces the destination inode, so carry the original file's
969 // permissions onto the temp file first — otherwise a formatted file would
970 // silently pick up the process umask's default mode (e.g. an executable or
971 // group-restricted source would lose its bits).
972 let write_result = (|| {
973 // Best-effort: a filesystem that cannot honour the mode must not fail
974 // the whole write.
975 if let Ok(meta) = std::fs::metadata(path) {
976 let _ = file.set_permissions(meta.permissions());
977 }
978 file.write_all(contents.as_bytes())?;
979 file.sync_all()
980 })();
981 if let Err(e) = write_result {
982 let _ = std::fs::remove_file(&tmp);
983 return Err(e);
984 }
985 if let Err(e) = std::fs::rename(&tmp, path) {
986 let _ = std::fs::remove_file(&tmp);
987 return Err(e);
988 }
989 Ok(())
990}
991
992/// The `check` command body shared by `bynkc check` and `bynk check`: a
993/// directory routes through [`project::check_project`] (finding #64 —
994/// non-bailing, so a structural error anywhere does not hide diagnostics
995/// elsewhere the way `compile_project`'s bail-fast `Mode::Build` would), a
996/// single file through [`bynk_emit::check_with_warnings`]. `short` selects
997/// the one-line `--format short` rendering. `prog` prefixes messages
998/// (`bynk: …`).
999pub fn run_check(prog: &str, input: &Path, short: bool) -> ExitCode {
1000 // #2207 (decision C): at a workspace root, check every member in order.
1001 // Every member runs, and the run fails if any does. `rich` heads each
1002 // member's report with its path; `short` adds no headers, and its paths
1003 // are already relative to where the command ran.
1004 if let Some(workspace) = workspace_at(input) {
1005 let workspace = match workspace {
1006 Ok(w) => w,
1007 Err(e) => {
1008 eprintln!("{prog}: {}: {e}", shown(&input.join("bynk.toml")));
1009 return ExitCode::FAILURE;
1010 }
1011 };
1012 let mut failed = false;
1013 for member in &workspace.members {
1014 let member = member_dir(input, member);
1015 if !short {
1016 eprintln!("{}", member_header(&member));
1017 }
1018 failed |= !check_one(prog, &member, short, Some(&member));
1019 }
1020 return if failed {
1021 ExitCode::FAILURE
1022 } else {
1023 ExitCode::SUCCESS
1024 };
1025 }
1026 if check_one(prog, input, short, None) {
1027 ExitCode::SUCCESS
1028 } else {
1029 ExitCode::FAILURE
1030 }
1031}
1032
1033/// #2207: if `input` is a workspace root (a directory whose `bynk.toml` holds
1034/// `[workspace]`), the workspace it loads to, or why it doesn't.
1035pub fn workspace_at(input: &Path) -> Option<Result<project::Workspace, ProjectPathsError>> {
1036 (input.is_dir() && project::is_workspace_root(input)).then(|| project::load_workspace(input))
1037}
1038
1039/// #2207: a member's directory as the command names it: `root` (as the user
1040/// typed it) joined with the member's relative path, without `.` components,
1041/// so a run at `.` names `packages/money`, not `./packages/money`.
1042pub fn member_dir(root: &Path, member: &Path) -> PathBuf {
1043 root.join(member)
1044 .components()
1045 .filter(|c| !matches!(c, std::path::Component::CurDir))
1046 .collect()
1047}
1048
1049/// #2207 (decision C): the line that heads one member's report in a
1050/// workspace run, `── packages/money ──`, its path as typed from the working
1051/// directory.
1052pub fn member_header(member: &Path) -> String {
1053 format!("── {} ──", shown(member))
1054}
1055
1056/// [`run_check`] over one project or file; `true` when it passes. `member`
1057/// names the workspace member being checked, so a project-level error says
1058/// which one it is.
1059fn check_one(prog: &str, input: &Path, short: bool, member: Option<&Path>) -> bool {
1060 let prog = match member {
1061 Some(m) => format!("{prog}: {}", shown(m)),
1062 None => prog.to_string(),
1063 };
1064 let prog = prog.as_str();
1065 if input.is_dir() {
1066 let options = match try_project_options(input) {
1067 Ok(o) => o,
1068 Err(e) => {
1069 eprintln!("{prog}: {e}");
1070 return false;
1071 }
1072 };
1073 // #2078: reconcile events against the committed `bynk.schema.lock`,
1074 // read-only, so a non-additive change fails `check` as it fails a
1075 // build. Nothing is written: an additive change is left for a build
1076 // to record.
1077 let options = match schema_lock::read_only(input) {
1078 Ok(lock) => options.schema_registry(lock),
1079 Err(e) => {
1080 eprintln!("{prog}: {e}");
1081 return false;
1082 }
1083 };
1084 let check = project::check_project(&options);
1085 let has_errors = check.has_errors();
1086 if short {
1087 print_project_check_short(&check);
1088 } else {
1089 print_project_check(&check);
1090 }
1091 !has_errors
1092 } else {
1093 let source = match std::fs::read_to_string(input) {
1094 Ok(s) => s,
1095 Err(e) => {
1096 eprintln!("{prog}: could not read `{}`: {e}", shown(input));
1097 return false;
1098 }
1099 };
1100 let filename = shown(input);
1101 match bynk_emit::check_with_warnings(&source, &filename) {
1102 Ok(warnings) => {
1103 if !warnings.is_empty() {
1104 if short {
1105 bynk_render::print_errors_short(&warnings, &source, &filename);
1106 } else {
1107 bynk_render::print_errors(&warnings, &source, &filename);
1108 }
1109 }
1110 true
1111 }
1112 Err(errors) => {
1113 if short {
1114 bynk_render::print_errors_short(&errors, &source, &filename);
1115 } else {
1116 bynk_render::print_errors(&errors, &source, &filename);
1117 }
1118 false
1119 }
1120 }
1121 }
1122}
1123
1124#[cfg(test)]
1125mod tests {
1126 use super::*;
1127 use clap::Parser;
1128
1129 /// A minimal parser around [`FmtArgs`], so these assert what the real CLIs
1130 /// parse rather than what a hand-built struct claims.
1131 #[derive(clap::Parser, Debug)]
1132 struct Harness {
1133 #[command(flatten)]
1134 args: FmtArgs,
1135 }
1136
1137 fn parse(argv: &[&str]) -> FmtArgs {
1138 let mut full = vec!["fmt"];
1139 full.extend_from_slice(argv);
1140 Harness::parse_from(full).args
1141 }
1142
1143 /// The options a run resolves with no manifest in play.
1144 fn resolve(argv: &[&str]) -> Result<FormatOptions, String> {
1145 parse(argv).apply_to(FormatOptions::default())
1146 }
1147
1148 /// A stand-in for what `bynk.toml` `[fmt]` resolved to.
1149 fn manifest(toml: &str) -> FormatOptions {
1150 bynk_fmt::FmtConfig::from_manifest_str(toml)
1151 .expect("manifest parses")
1152 .apply(FormatOptions::default())
1153 }
1154
1155 #[test]
1156 fn no_flags_is_the_canonical_style() {
1157 let opts = resolve(&["a.bynk"]).expect("valid");
1158 assert_eq!(opts, FormatOptions::default());
1159 }
1160
1161 #[test]
1162 fn spaces_without_a_width_falls_back_to_two() {
1163 // The same fallback `bynk.toml`'s `[fmt] indent_width` uses, so the CLI
1164 // and the language server land on the same style from the same words.
1165 let opts = resolve(&["--indent", "spaces", "a.bynk"]).expect("valid");
1166 assert_eq!(opts.indent, IndentStyle::Spaces(2));
1167 }
1168
1169 #[test]
1170 fn spaces_takes_the_given_width() {
1171 let opts =
1172 resolve(&["--indent", "spaces", "--indent-width", "4", "a.bynk"]).expect("valid");
1173 assert_eq!(opts.indent, IndentStyle::Spaces(4));
1174 }
1175
1176 #[test]
1177 fn a_width_with_tabs_is_an_error_not_a_silent_no_op() {
1178 let err = resolve(&["--indent", "tab", "--indent-width", "4", "a.bynk"])
1179 .expect_err("a meaningless width must be reported");
1180 assert!(err.contains("--indent-width"), "{err}");
1181 assert!(err.contains("spaces"), "{err}");
1182 }
1183
1184 #[test]
1185 fn the_trailing_comma_pair_is_last_one_wins() {
1186 // `overrides_with` in both directions: a script may append either flag
1187 // to a shared argument list and have it win rather than conflict-error.
1188 assert!(
1189 resolve(&["--no-trailing-comma", "--trailing-comma", "a.bynk"])
1190 .expect("valid")
1191 .trailing_comma
1192 );
1193 assert!(
1194 !resolve(&["--trailing-comma", "--no-trailing-comma", "a.bynk"])
1195 .expect("valid")
1196 .trailing_comma
1197 );
1198 }
1199
1200 #[test]
1201 fn max_line_width_is_taken_verbatim_and_zero_is_refused() {
1202 assert_eq!(
1203 resolve(&["--max-line-width", "60", "a.bynk"])
1204 .expect("valid")
1205 .max_line_width,
1206 60
1207 );
1208 // A zero-column budget is a nonsense input; clap rejects it at parse
1209 // time rather than the formatter wrapping every construct maximally.
1210 assert!(
1211 Harness::try_parse_from(["fmt", "--max-line-width", "0", "a.bynk"]).is_err(),
1212 "`--max-line-width 0` must not parse"
1213 );
1214 }
1215
1216 // -- #972: the `bynk.toml` `[fmt]` layer beneath the flags --
1217
1218 #[test]
1219 fn an_unflagged_run_takes_the_manifest_whole() {
1220 let base = manifest(
1221 "[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\ntrailing_comma = false\n",
1222 );
1223 let opts = parse(&["a.bynk"]).apply_to(base).expect("valid");
1224 assert_eq!(opts.indent, IndentStyle::Spaces(4));
1225 assert_eq!(opts.max_line_width, 120);
1226 assert!(!opts.trailing_comma);
1227 }
1228
1229 #[test]
1230 fn a_flag_beats_the_manifest_field_it_names_and_no_other() {
1231 let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\nmax_line_width = 120\n");
1232 let opts = parse(&["--max-line-width", "80", "a.bynk"])
1233 .apply_to(base)
1234 .expect("valid");
1235 assert_eq!(opts.max_line_width, 80, "the flag wins where it speaks");
1236 assert_eq!(
1237 opts.indent,
1238 IndentStyle::Spaces(4),
1239 "and stays silent everywhere else"
1240 );
1241 }
1242
1243 #[test]
1244 fn an_absent_flag_does_not_reset_the_manifest_to_the_default() {
1245 // The regression a clap `default_value` would have caused: "the user
1246 // said 100" is indistinguishable from "the user said nothing", so a
1247 // project's 120 would be overwritten by a flag nobody passed.
1248 let base = manifest("[fmt]\nmax_line_width = 120\n");
1249 assert_eq!(
1250 parse(&["a.bynk"])
1251 .apply_to(base)
1252 .expect("valid")
1253 .max_line_width,
1254 120
1255 );
1256 }
1257
1258 #[test]
1259 fn indent_spaces_alone_keeps_the_manifest_width() {
1260 let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
1261 let opts = parse(&["--indent", "spaces", "a.bynk"])
1262 .apply_to(base)
1263 .expect("valid");
1264 assert_eq!(opts.indent, IndentStyle::Spaces(4), "not reset to 2");
1265 }
1266
1267 #[test]
1268 fn indent_width_alone_applies_to_a_manifest_that_chose_spaces() {
1269 let base = manifest("[fmt]\nindent = \"spaces\"\n");
1270 let opts = parse(&["--indent-width", "8", "a.bynk"])
1271 .apply_to(base)
1272 .expect("valid");
1273 assert_eq!(opts.indent, IndentStyle::Spaces(8));
1274 }
1275
1276 #[test]
1277 fn indent_width_alone_is_refused_when_the_run_resolves_to_tabs() {
1278 // No `--indent`, and a manifest that says tabs (or none at all): the
1279 // width has nothing to apply to, and the message says so rather than
1280 // the flag vanishing.
1281 let err = parse(&["--indent-width", "8", "a.bynk"])
1282 .apply_to(manifest("[fmt]\nindent = \"tab\"\n"))
1283 .expect_err("refused");
1284 assert!(err.contains("resolves"), "{err}");
1285 }
1286
1287 #[test]
1288 fn an_explicit_tab_flag_overrides_a_manifest_choosing_spaces() {
1289 let base = manifest("[fmt]\nindent = \"spaces\"\nindent_width = 4\n");
1290 let opts = parse(&["--indent", "tab", "a.bynk"])
1291 .apply_to(base)
1292 .expect("valid");
1293 assert_eq!(opts.indent, IndentStyle::Tab);
1294 }
1295
1296 #[test]
1297 fn trailing_comma_flag_overrides_a_manifest_that_turned_it_off() {
1298 let base = manifest("[fmt]\ntrailing_comma = false\n");
1299 assert!(
1300 parse(&["--trailing-comma", "a.bynk"])
1301 .apply_to(base)
1302 .expect("valid")
1303 .trailing_comma
1304 );
1305 // …and an unflagged run still honours the manifest.
1306 assert!(
1307 !parse(&["a.bynk"])
1308 .apply_to(base)
1309 .expect("valid")
1310 .trailing_comma
1311 );
1312 }
1313
1314 #[test]
1315 fn no_config_is_parsed_and_defaults_are_used_in_its_presence() {
1316 // `--no-config` is honoured by the manifest *lookup* (ManifestCache),
1317 // so here it is enough that it parses and leaves the flag layer alone.
1318 let args = parse(&["--no-config", "a.bynk"]);
1319 assert!(args.no_config);
1320 assert_eq!(
1321 args.apply_to(FormatOptions::default()).expect("valid"),
1322 FormatOptions::default()
1323 );
1324 }
1325
1326 /// A throwaway on-disk directory, removed on drop (including on panic) —
1327 /// mirrors `bynk-driver/tests/project_diagnostics.rs`'s own `Scratch`.
1328 struct Scratch(PathBuf);
1329 impl Drop for Scratch {
1330 fn drop(&mut self) {
1331 let _ = fs::remove_dir_all(&self.0);
1332 }
1333 }
1334
1335 fn scratch_dir(tag: &str) -> Scratch {
1336 let dir = std::env::temp_dir().join(format!(
1337 "bynk_1077_{tag}_{}_{:?}",
1338 std::process::id(),
1339 std::thread::current().id()
1340 ));
1341 let _ = fs::remove_dir_all(&dir);
1342 fs::create_dir_all(&dir).unwrap();
1343 Scratch(dir)
1344 }
1345
1346 /// #1077 review: `manifest_overlay` keys its entry exactly as
1347 /// `try_read_project_paths_with` looks it up — `root.join("bynk.toml")`,
1348 /// literal, no canonicalisation — and reads `bynk.toml`'s real content.
1349 /// This is what stops that lookup from falling through to `bynk-emit`'s
1350 /// own disk fallback; a mismatched key would silently degrade to the
1351 /// conventional layout instead of surfacing as a test failure here, so
1352 /// this asserts the map entry directly rather than only the end-to-end
1353 /// behaviour (which the integration test in `project_diagnostics.rs`
1354 /// covers).
1355 #[test]
1356 fn manifest_overlay_keys_and_reads_a_real_bynk_toml() {
1357 let dir = scratch_dir("manifest_overlay");
1358 let toml = "[paths]\ninclude = [\"lib\"]\n";
1359 fs::write(dir.0.join("bynk.toml"), toml).unwrap();
1360
1361 let overlay = manifest_overlay(&dir.0);
1362
1363 assert_eq!(
1364 overlay.get(&dir.0.join("bynk.toml")).map(String::as_str),
1365 Some(toml)
1366 );
1367 }
1368
1369 #[test]
1370 fn manifest_overlay_is_empty_with_no_bynk_toml() {
1371 let dir = scratch_dir("manifest_overlay_missing");
1372 assert!(manifest_overlay(&dir.0).is_empty());
1373 }
1374
1375 /// Review of #1084: `ProjectOptionsError::Paths` had no test anywhere in
1376 /// the repo, despite being the one arm where an overlay/disk divergence
1377 /// in the manifest read would actually be observable — everywhere else,
1378 /// `read_source`'s still-present disk fallback quietly reproduces the
1379 /// same result either way. This also re-pins that `?`'s automatic
1380 /// `From<ProjectPathsError>` conversion (not an explicit `map_err`) still
1381 /// reaches the caller correctly.
1382 #[test]
1383 fn try_project_options_surfaces_an_unknown_paths_key() {
1384 let dir = scratch_dir("try_project_options_unknown_key");
1385 fs::write(dir.0.join("bynk.toml"), "[paths]\ninculde = [\"src\"]\n").unwrap();
1386 fs::create_dir_all(dir.0.join("src")).unwrap();
1387 fs::write(dir.0.join("src/thing.bynk"), "context thing\n").unwrap();
1388
1389 let err = match try_project_options(&dir.0) {
1390 Err(e) => e,
1391 Ok(_) => panic!("an unrecognised [paths] key must be reported, not silently ignored"),
1392 };
1393 assert!(
1394 matches!(
1395 &err,
1396 ProjectOptionsError::Paths(ProjectPathsError::UnknownKey(k)) if k == "inculde"
1397 ),
1398 "expected Paths(UnknownKey(\"inculde\")), got: {err:?}"
1399 );
1400 }
1401
1402 /// #1665: an unknown table reaches the caller from the same strict path,
1403 /// even with a valid `[paths]`. Before, `[pahts]` built cleanly.
1404 #[test]
1405 fn try_project_options_surfaces_an_unknown_table() {
1406 let dir = scratch_dir("try_project_options_unknown_table");
1407 fs::write(
1408 dir.0.join("bynk.toml"),
1409 "[paths]\ninclude = [\"src\"]\n\n[pahts]\nexclude = []\n",
1410 )
1411 .unwrap();
1412 fs::create_dir_all(dir.0.join("src")).unwrap();
1413 fs::write(dir.0.join("src/thing.bynk"), "context thing\n").unwrap();
1414
1415 let err = match try_project_options(&dir.0) {
1416 Err(e) => e,
1417 Ok(_) => panic!("an unknown table must be reported, not silently ignored"),
1418 };
1419 assert!(
1420 matches!(
1421 &err,
1422 ProjectOptionsError::Paths(ProjectPathsError::UnknownTable(t)) if t == "pahts"
1423 ),
1424 "expected Paths(UnknownTable(\"pahts\")), got: {err:?}"
1425 );
1426 assert!(err.to_string().contains("did you mean `[paths]`?"), "{err}");
1427 }
1428
1429 /// A one-context project with `bynk.toml` `manifest`, for the #1890 tests.
1430 fn workers_project(name: &str, manifest: &str) -> Scratch {
1431 let dir = scratch_dir(name);
1432 fs::write(dir.0.join("bynk.toml"), manifest).unwrap();
1433 fs::create_dir_all(dir.0.join("src")).unwrap();
1434 fs::write(
1435 dir.0.join("src/greet.bynk"),
1436 "context greet\n\nservice hello {\n on call(name: String) -> Effect[Result[String, ()]] {\n Ok(name)\n }\n}\n",
1437 )
1438 .unwrap();
1439 dir
1440 }
1441
1442 /// The `wrangler.toml` a Workers build of `options` writes for `greet`,
1443 /// and the build's warnings' codes.
1444 fn greet_wrangler(options: CompileOptions) -> (String, Vec<&'static str>) {
1445 let out = project::compile_project(&options.target(project::BuildTarget::Workers))
1446 .unwrap_or_else(|f| {
1447 let msgs: Vec<&str> = f.errors.iter().map(|e| e.error.message.as_str()).collect();
1448 panic!("builds: {msgs:?}")
1449 });
1450 let text = out.artefacts.docs[Path::new("workers/greet/wrangler.toml")].text();
1451 (
1452 text,
1453 out.warnings.iter().map(|w| w.error.category).collect(),
1454 )
1455 }
1456
1457 /// #1890: `[workers] compatibility_flags` reaches every emitted
1458 /// `wrangler.toml`, after the default, from both option builders. A flag
1459 /// already on by default is dropped with a warning.
1460 #[test]
1461 fn manifest_compatibility_flags_reach_wrangler_toml() {
1462 let dir = workers_project(
1463 "compat_flags_1890",
1464 "[workers]\ncompatibility_flags = [\"nodejs_compat\", \"global_fetch_strictly_public\"]\n",
1465 );
1466 let want = "compatibility_flags = [\"global_fetch_strictly_public\", \"nodejs_compat\"]\n";
1467 for options in [
1468 try_project_options(&dir.0).expect("reads"),
1469 project_options(&dir.0).expect("reads"),
1470 ] {
1471 let (text, warnings) = greet_wrangler(options);
1472 assert!(text.contains(want), "{text}");
1473 assert_eq!(
1474 warnings,
1475 ["bynk.project.duplicate_compatibility_flag"],
1476 "the repeated default is reported"
1477 );
1478 }
1479 }
1480
1481 /// `[workers] workers_dev` is written into every `wrangler.toml` when set,
1482 /// and left out when it isn't; a non-boolean is refused.
1483 #[test]
1484 fn manifest_workers_dev_reaches_wrangler_toml() {
1485 let dir = workers_project("workers_dev_off", "[workers]\nworkers_dev = false\n");
1486 for options in [
1487 try_project_options(&dir.0).expect("reads"),
1488 project_options(&dir.0).expect("reads"),
1489 ] {
1490 let (text, _) = greet_wrangler(options);
1491 assert!(text.contains("workers_dev = false\n"), "{text}");
1492 }
1493 let dir = workers_project("workers_dev_unset", "[workers]\ncompatibility_flags = []\n");
1494 let (text, _) = greet_wrangler(try_project_options(&dir.0).expect("reads"));
1495 assert!(!text.contains("workers_dev"), "{text}");
1496 let dir = workers_project("workers_dev_bad", "[workers]\nworkers_dev = \"no\"\n");
1497 assert!(matches!(
1498 try_project_options(&dir.0),
1499 Err(ProjectOptionsError::Paths(
1500 ProjectPathsError::NotABool { .. }
1501 ))
1502 ));
1503 }
1504
1505 /// #1890: with no `[workers]` table, a Worker still gets the default flag.
1506 #[test]
1507 fn a_worker_gets_the_default_compatibility_flag_without_a_workers_table() {
1508 let dir = workers_project("compat_flags_default_1890", "[project]\nname = \"p\"\n");
1509 let (text, warnings) = greet_wrangler(try_project_options(&dir.0).expect("reads"));
1510 assert!(
1511 text.contains("compatibility_flags = [\"global_fetch_strictly_public\"]\n"),
1512 "{text}"
1513 );
1514 assert!(warnings.is_empty(), "{warnings:?}");
1515 }
1516
1517 /// #1890: a non-string flag is refused on the CLIs' strict path.
1518 #[test]
1519 fn try_project_options_refuses_a_non_string_compatibility_flag() {
1520 let dir = workers_project(
1521 "compat_flags_bad_1890",
1522 "[workers]\ncompatibility_flags = [\"nodejs_compat\", 2]\n",
1523 );
1524 match try_project_options(&dir.0) {
1525 Err(ProjectOptionsError::Paths(ProjectPathsError::NotAStringList { .. })) => {}
1526 Err(e) => panic!("wrong error: {e:?}"),
1527 Ok(_) => panic!("a non-string flag must be refused"),
1528 }
1529 }
1530}
1531
1532#[cfg(test)]
1533mod display_path_tests {
1534 use super::display_path;
1535 use std::path::Path;
1536
1537 #[test]
1538 fn a_relative_root_is_joined_as_typed() {
1539 assert_eq!(
1540 display_path(Path::new("test/fixtures/x"), Path::new("src/a.bynk")),
1541 "test/fixtures/x/src/a.bynk"
1542 );
1543 }
1544
1545 /// `bynk check` defaults its input to `.`; that adds no `./`.
1546 #[test]
1547 fn a_dot_root_adds_no_prefix() {
1548 assert_eq!(
1549 display_path(Path::new("."), Path::new("src/a.bynk")),
1550 "src/a.bynk"
1551 );
1552 assert_eq!(
1553 display_path(Path::new(""), Path::new("./src/a.bynk")),
1554 "src/a.bynk"
1555 );
1556 }
1557
1558 /// `bynk dev` hands an absolute root; inside the cwd it shows relative.
1559 #[test]
1560 fn an_absolute_root_inside_the_cwd_is_shown_relative() {
1561 let cwd = std::env::current_dir().unwrap();
1562 assert_eq!(
1563 display_path(&cwd.join("proj"), Path::new("src/a.bynk")),
1564 "proj/src/a.bynk"
1565 );
1566 // A canonicalised root (on Windows, a verbatim `\\?\` path) too.
1567 let canonical = cwd.canonicalize().unwrap();
1568 assert_eq!(
1569 display_path(&canonical.join("proj"), Path::new("src/a.bynk")),
1570 "proj/src/a.bynk"
1571 );
1572 }
1573
1574 /// Outside the cwd, the path stays absolute, with `/` separators.
1575 #[test]
1576 fn an_absolute_root_outside_the_cwd_is_shown_absolute() {
1577 let cwd = std::env::current_dir().unwrap();
1578 let outside = cwd.parent().unwrap().join("bynk-display-path-elsewhere");
1579 let shown = display_path(&outside, Path::new("src/a.bynk"));
1580 assert!(
1581 shown.ends_with("bynk-display-path-elsewhere/src/a.bynk"),
1582 "{shown}"
1583 );
1584 assert!(
1585 !shown.contains('\\') && !shown.starts_with("//?/"),
1586 "{shown}"
1587 );
1588 }
1589
1590 /// On Windows, a verbatim root outside the cwd loses its `\\?\` prefix.
1591 #[cfg(windows)]
1592 #[test]
1593 fn a_verbatim_root_outside_the_cwd_loses_its_prefix() {
1594 assert_eq!(
1595 display_path(Path::new(r"\\?\C:\elsewhere"), Path::new("src/a.bynk")),
1596 "C:/elsewhere/src/a.bynk"
1597 );
1598 }
1599}