Skip to main content

bynk_driver/
lib.rs

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