Skip to main content

bynk_fmt/
config.rs

1//! The `[fmt]` section of a project's `bynk.toml`.
2//!
3//! One reader, two consumers (#972). The language server has read `[fmt]` for
4//! format-on-save since v0.3; the `bynkc fmt` / `bynk fmt` CLI did not, so a
5//! project that set a style there had the editor and the command line disagree
6//! — and `bynk fmt --check` in CI gated on a style the editor never produced.
7//! Wiring the CLI to a *second* parser of the same section would have made that
8//! two implementations to keep in step, so the parser lives here, beside the
9//! [`FormatOptions`] it produces, and both front-ends call it.
10//!
11//! Every key is optional, and reading yields a [`FmtConfig`] of `Option`s
12//! rather than a filled-in [`FormatOptions`]. That is what lets a caller layer
13//! the sources in the right order — spec default, then `bynk.toml`, then an
14//! explicit CLI flag — with an absent key meaning "defer", not "reset to
15//! default".
16
17use std::path::{Path, PathBuf};
18
19use serde::Deserialize;
20
21use crate::fmt::{FormatOptions, IndentStyle};
22
23/// The manifest file a project is rooted by.
24pub const MANIFEST: &str = "bynk.toml";
25
26/// A `[fmt]` section, as read. Each field is `Some` only when the manifest
27/// actually stated it, so [`FmtConfig::apply`] can leave the rest alone.
28#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
29pub struct FmtConfig {
30    pub indent: Option<IndentStyle>,
31    pub max_line_width: Option<u32>,
32    pub trailing_comma: Option<bool>,
33}
34
35/// Why a `bynk.toml` could not be turned into a [`FmtConfig`].
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum ConfigError {
38    /// The file could not be read.
39    Read(String),
40    /// The file is not valid TOML, or `[fmt]` has a key of the wrong type.
41    Parse(String),
42    /// `[fmt] indent` is neither `"tab"` nor `"spaces"`.
43    Indent(String),
44    /// `[fmt] max_line_width` is zero — no width at all is not a width.
45    MaxLineWidth(u32),
46}
47
48impl std::fmt::Display for ConfigError {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        match self {
51            Self::Read(e) => write!(f, "{e}"),
52            Self::Parse(e) => write!(f, "{e}"),
53            Self::Indent(found) => write!(
54                f,
55                "`[fmt] indent` must be \"tab\" or \"spaces\", found \"{found}\""
56            ),
57            Self::MaxLineWidth(n) => {
58                write!(f, "`[fmt] max_line_width` must be at least 1, found {n}")
59            }
60        }
61    }
62}
63
64impl std::error::Error for ConfigError {}
65
66/// The raw shape serde reads. Separate from [`FmtConfig`] because the manifest
67/// spells indentation as two keys (`indent` + `indent_width`) while the
68/// formatter models it as one [`IndentStyle`], and because `deny_unknown_fields`
69/// on the section catches a typo (`max_line_length`) that would otherwise be
70/// silently ignored.
71#[derive(Debug, Deserialize, Default)]
72struct RawManifest {
73    #[serde(default)]
74    fmt: RawFmt,
75}
76
77/// `deny_unknown_fields` on the *section* (not the manifest): a `[fmt]` key
78/// that is not one of these four is a typo, and the failure it otherwise
79/// produces — `max_line_length = 120` sitting in a manifest for months while
80/// the formatter quietly uses 100 — is exactly what a config layer must not
81/// do. Pre-1.0 this trades forward compatibility for that, deliberately: a
82/// manifest naming a key an older binary lacks is refused rather than
83/// half-applied.
84///
85/// #2096: with the `schema` feature this struct is also the source of the
86/// `[fmt]` table in `bynk.toml`'s JSON Schema ([`fmt_table_schema`]), so the
87/// field doc comments below are what an editor shows on hover. The defaults
88/// they state are pinned to [`FormatOptions::default`] by `xtask`'s schema test.
89#[derive(Debug, Deserialize, Default)]
90#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
91#[cfg_attr(
92    feature = "schema",
93    schemars(
94        description = "Formatter settings, read by format-on-save in the editor and by \
95        `bynkc fmt` / `bynk fmt`, through one shared reader. A flag passed to `fmt` \
96        overrides the key it names."
97    )
98)]
99#[serde(deny_unknown_fields)]
100struct RawFmt {
101    /// Indentation style: `"tab"` (one tab per nesting level) or `"spaces"`
102    /// (`indent_width` spaces per level). Default: `"tab"`.
103    #[cfg_attr(
104        feature = "schema",
105        schemars(extend("enum" = ["tab", "spaces"], "default" = "tab"))
106    )]
107    indent: Option<String>,
108    /// Spaces per nesting level. Only consulted when `indent = "spaces"`, where
109    /// it defaults to `2`.
110    #[cfg_attr(feature = "schema", schemars(extend("default" = 2)))]
111    indent_width: Option<u8>,
112    /// Soft target line width. A construct wider than this wraps across lines
113    /// where the grammar allows. Must be at least 1. Default: `100`.
114    #[cfg_attr(
115        feature = "schema",
116        schemars(range(min = 1), extend("default" = 100))
117    )]
118    max_line_width: Option<u32>,
119    /// Emit trailing commas in multi-line records, sums, list literals and
120    /// `exports` clauses. Default: `true`.
121    #[cfg_attr(feature = "schema", schemars(extend("default" = true)))]
122    trailing_comma: Option<bool>,
123}
124
125/// #2096: the JSON Schema of `bynk.toml`'s `[fmt]` table, derived from the
126/// struct that reads it. `xtask manifest-schema` places it in the whole
127/// manifest's schema beside `bynk-project`'s tables.
128#[cfg(feature = "schema")]
129pub fn fmt_table_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
130    generator.subschema_for::<RawFmt>()
131}
132
133impl FmtConfig {
134    /// Read a `[fmt]` section out of a `bynk.toml`'s text. A manifest with no
135    /// `[fmt]` section is not an error — it yields an empty config, which
136    /// defers every field.
137    pub fn from_manifest_str(text: &str) -> Result<Self, ConfigError> {
138        // Only `[fmt]` is read here; the manifest's other sections belong to
139        // other readers, so unknown *top-level* keys must stay tolerated.
140        let raw: RawManifest =
141            toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
142        let indent = match raw.fmt.indent.as_deref() {
143            None => None,
144            Some("tab") => Some(IndentStyle::Tab),
145            // `indent_width` alongside `indent = "tab"` is ignored rather than
146            // refused: a manifest is declarative and the key is commonly left
147            // behind when a project switches back to tabs. (The CLI's
148            // `--indent-width` *is* refused with `--indent tab` — passing a
149            // flag is a deliberate act in a way that a stale file key is not.)
150            Some("spaces") => Some(IndentStyle::Spaces(raw.fmt.indent_width.unwrap_or(2))),
151            Some(other) => return Err(ConfigError::Indent(other.to_string())),
152        };
153        if let Some(0) = raw.fmt.max_line_width {
154            return Err(ConfigError::MaxLineWidth(0));
155        }
156        Ok(Self {
157            indent,
158            max_line_width: raw.fmt.max_line_width,
159            trailing_comma: raw.fmt.trailing_comma,
160        })
161    }
162
163    /// Layer this config over `base`: a field the manifest stated wins, one it
164    /// omitted leaves `base` untouched.
165    ///
166    /// `indent_width` is carried across when the manifest names `spaces`
167    /// without a width *and* `base` already holds one, so a CLI `--indent
168    /// spaces` over a manifest `indent_width = 4` lands on four spaces rather
169    /// than silently resetting to two.
170    pub fn apply(&self, base: FormatOptions) -> FormatOptions {
171        FormatOptions {
172            indent: self.indent.unwrap_or(base.indent),
173            max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
174            trailing_comma: self.trailing_comma.unwrap_or(base.trailing_comma),
175        }
176    }
177}
178
179/// The nearest `bynk.toml` at or above `start`, or `None` when the walk reaches
180/// the filesystem root without finding one.
181///
182/// The search starts at `start` itself when it is a directory, otherwise at its
183/// parent — so a caller can hand over either a project root or the source file
184/// being formatted. Formatting a file resolves the manifest that governs *that
185/// file*, not the one the shell happens to be standing in, which is what makes
186/// `bynk fmt ../other-project/src/x.bynk` obey the other project's style.
187pub fn find_manifest(start: &Path) -> Option<PathBuf> {
188    let mut dir = if start.is_dir() {
189        Some(start)
190    } else {
191        start.parent()
192    };
193    while let Some(d) = dir {
194        let candidate = d.join(MANIFEST);
195        if candidate.is_file() {
196            return Some(candidate);
197        }
198        dir = d.parent();
199    }
200    None
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn an_absent_fmt_section_defers_every_field() {
209        let cfg = FmtConfig::from_manifest_str("[project]\nname = \"x\"\n").expect("parses");
210        assert_eq!(cfg, FmtConfig::default());
211        // Deferring means `apply` is the identity over any base.
212        let base = FormatOptions {
213            indent: IndentStyle::Spaces(3),
214            max_line_width: 77,
215            trailing_comma: false,
216        };
217        let out = cfg.apply(base);
218        assert_eq!(out.indent, base.indent);
219        assert_eq!(out.max_line_width, base.max_line_width);
220        assert_eq!(out.trailing_comma, base.trailing_comma);
221    }
222
223    #[test]
224    fn a_partial_section_overrides_only_what_it_states() {
225        let cfg = FmtConfig::from_manifest_str("[fmt]\nmax_line_width = 120\n").expect("parses");
226        let out = cfg.apply(FormatOptions::default());
227        assert_eq!(out.max_line_width, 120);
228        // Untouched fields keep the base's values.
229        assert_eq!(out.indent, IndentStyle::Tab);
230        assert!(out.trailing_comma);
231    }
232
233    #[test]
234    fn spaces_takes_indent_width_and_defaults_to_two() {
235        let four = FmtConfig::from_manifest_str("[fmt]\nindent = \"spaces\"\nindent_width = 4\n")
236            .expect("parses");
237        assert_eq!(four.indent, Some(IndentStyle::Spaces(4)));
238        let bare = FmtConfig::from_manifest_str("[fmt]\nindent = \"spaces\"\n").expect("parses");
239        assert_eq!(bare.indent, Some(IndentStyle::Spaces(2)));
240    }
241
242    #[test]
243    fn indent_width_beside_tab_is_ignored_not_refused() {
244        // Declarative, and commonly left behind when a project switches back.
245        let cfg = FmtConfig::from_manifest_str("[fmt]\nindent = \"tab\"\nindent_width = 4\n")
246            .expect("parses");
247        assert_eq!(cfg.indent, Some(IndentStyle::Tab));
248    }
249
250    #[test]
251    fn an_unknown_indent_word_is_an_error() {
252        let err = FmtConfig::from_manifest_str("[fmt]\nindent = \"tabs\"\n").expect_err("refused");
253        assert_eq!(err, ConfigError::Indent("tabs".into()));
254        assert!(err.to_string().contains("\"tab\" or \"spaces\""), "{err}");
255    }
256
257    #[test]
258    fn a_zero_max_line_width_is_an_error() {
259        let err = FmtConfig::from_manifest_str("[fmt]\nmax_line_width = 0\n").expect_err("refused");
260        assert_eq!(err, ConfigError::MaxLineWidth(0));
261    }
262
263    #[test]
264    fn a_misspelled_fmt_key_is_an_error_not_a_silent_no_op() {
265        // The failure this catches: `max_line_length = 120` sitting in a
266        // manifest for months, formatting at 100 the whole time.
267        let err =
268            FmtConfig::from_manifest_str("[fmt]\nmax_line_length = 120\n").expect_err("refused");
269        assert!(
270            matches!(err, ConfigError::Parse(_)),
271            "expected a parse error, got {err:?}"
272        );
273    }
274
275    #[test]
276    fn other_manifest_sections_are_left_to_their_own_readers() {
277        // `[paths]` belongs to `bynk-emit::project`; reading `[fmt]` must not
278        // reject a manifest for carrying it.
279        let cfg = FmtConfig::from_manifest_str(
280            "[project]\nname = \"x\"\n\n[paths]\ninclude = [\"src\"]\n\n[lsp]\ndiagnostics_mode = \"live\"\n\n[fmt]\ntrailing_comma = false\n",
281        )
282        .expect("parses");
283        assert_eq!(cfg.trailing_comma, Some(false));
284    }
285
286    #[test]
287    fn malformed_toml_is_reported_not_ignored() {
288        let err = FmtConfig::from_manifest_str("[fmt\nindent = \"tab\"\n").expect_err("refused");
289        assert!(matches!(err, ConfigError::Parse(_)), "{err:?}");
290    }
291}