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