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