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