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