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#[derive(Debug, Deserialize, Default)]
85#[serde(deny_unknown_fields)]
86struct RawFmt {
87    indent: Option<String>,
88    indent_width: Option<u8>,
89    max_line_width: Option<u32>,
90    trailing_comma: Option<bool>,
91}
92
93impl FmtConfig {
94    /// Read a `[fmt]` section out of a `bynk.toml`'s text. A manifest with no
95    /// `[fmt]` section is not an error — it yields an empty config, which
96    /// defers every field.
97    pub fn from_manifest_str(text: &str) -> Result<Self, ConfigError> {
98        // Only `[fmt]` is read here; the manifest's other sections belong to
99        // other readers, so unknown *top-level* keys must stay tolerated.
100        let raw: RawManifest =
101            toml::from_str(text).map_err(|e| ConfigError::Parse(e.to_string()))?;
102        let indent = match raw.fmt.indent.as_deref() {
103            None => None,
104            Some("tab") => Some(IndentStyle::Tab),
105            // `indent_width` alongside `indent = "tab"` is ignored rather than
106            // refused: a manifest is declarative and the key is commonly left
107            // behind when a project switches back to tabs. (The CLI's
108            // `--indent-width` *is* refused with `--indent tab` — passing a
109            // flag is a deliberate act in a way that a stale file key is not.)
110            Some("spaces") => Some(IndentStyle::Spaces(raw.fmt.indent_width.unwrap_or(2))),
111            Some(other) => return Err(ConfigError::Indent(other.to_string())),
112        };
113        if let Some(0) = raw.fmt.max_line_width {
114            return Err(ConfigError::MaxLineWidth(0));
115        }
116        Ok(Self {
117            indent,
118            max_line_width: raw.fmt.max_line_width,
119            trailing_comma: raw.fmt.trailing_comma,
120        })
121    }
122
123    /// Layer this config over `base`: a field the manifest stated wins, one it
124    /// omitted leaves `base` untouched.
125    ///
126    /// `indent_width` is carried across when the manifest names `spaces`
127    /// without a width *and* `base` already holds one, so a CLI `--indent
128    /// spaces` over a manifest `indent_width = 4` lands on four spaces rather
129    /// than silently resetting to two.
130    pub fn apply(&self, base: FormatOptions) -> FormatOptions {
131        FormatOptions {
132            indent: self.indent.unwrap_or(base.indent),
133            max_line_width: self.max_line_width.unwrap_or(base.max_line_width),
134            trailing_comma: self.trailing_comma.unwrap_or(base.trailing_comma),
135        }
136    }
137}
138
139/// The nearest `bynk.toml` at or above `start`, or `None` when the walk reaches
140/// the filesystem root without finding one.
141///
142/// The search starts at `start` itself when it is a directory, otherwise at its
143/// parent — so a caller can hand over either a project root or the source file
144/// being formatted. Formatting a file resolves the manifest that governs *that
145/// file*, not the one the shell happens to be standing in, which is what makes
146/// `bynk fmt ../other-project/src/x.bynk` obey the other project's style.
147pub fn find_manifest(start: &Path) -> Option<PathBuf> {
148    let mut dir = if start.is_dir() {
149        Some(start)
150    } else {
151        start.parent()
152    };
153    while let Some(d) = dir {
154        let candidate = d.join(MANIFEST);
155        if candidate.is_file() {
156            return Some(candidate);
157        }
158        dir = d.parent();
159    }
160    None
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn an_absent_fmt_section_defers_every_field() {
169        let cfg = FmtConfig::from_manifest_str("[project]\nname = \"x\"\n").expect("parses");
170        assert_eq!(cfg, FmtConfig::default());
171        // Deferring means `apply` is the identity over any base.
172        let base = FormatOptions {
173            indent: IndentStyle::Spaces(3),
174            max_line_width: 77,
175            trailing_comma: false,
176        };
177        let out = cfg.apply(base);
178        assert_eq!(out.indent, base.indent);
179        assert_eq!(out.max_line_width, base.max_line_width);
180        assert_eq!(out.trailing_comma, base.trailing_comma);
181    }
182
183    #[test]
184    fn a_partial_section_overrides_only_what_it_states() {
185        let cfg = FmtConfig::from_manifest_str("[fmt]\nmax_line_width = 120\n").expect("parses");
186        let out = cfg.apply(FormatOptions::default());
187        assert_eq!(out.max_line_width, 120);
188        // Untouched fields keep the base's values.
189        assert_eq!(out.indent, IndentStyle::Tab);
190        assert!(out.trailing_comma);
191    }
192
193    #[test]
194    fn spaces_takes_indent_width_and_defaults_to_two() {
195        let four = FmtConfig::from_manifest_str("[fmt]\nindent = \"spaces\"\nindent_width = 4\n")
196            .expect("parses");
197        assert_eq!(four.indent, Some(IndentStyle::Spaces(4)));
198        let bare = FmtConfig::from_manifest_str("[fmt]\nindent = \"spaces\"\n").expect("parses");
199        assert_eq!(bare.indent, Some(IndentStyle::Spaces(2)));
200    }
201
202    #[test]
203    fn indent_width_beside_tab_is_ignored_not_refused() {
204        // Declarative, and commonly left behind when a project switches back.
205        let cfg = FmtConfig::from_manifest_str("[fmt]\nindent = \"tab\"\nindent_width = 4\n")
206            .expect("parses");
207        assert_eq!(cfg.indent, Some(IndentStyle::Tab));
208    }
209
210    #[test]
211    fn an_unknown_indent_word_is_an_error() {
212        let err = FmtConfig::from_manifest_str("[fmt]\nindent = \"tabs\"\n").expect_err("refused");
213        assert_eq!(err, ConfigError::Indent("tabs".into()));
214        assert!(err.to_string().contains("\"tab\" or \"spaces\""), "{err}");
215    }
216
217    #[test]
218    fn a_zero_max_line_width_is_an_error() {
219        let err = FmtConfig::from_manifest_str("[fmt]\nmax_line_width = 0\n").expect_err("refused");
220        assert_eq!(err, ConfigError::MaxLineWidth(0));
221    }
222
223    #[test]
224    fn a_misspelled_fmt_key_is_an_error_not_a_silent_no_op() {
225        // The failure this catches: `max_line_length = 120` sitting in a
226        // manifest for months, formatting at 100 the whole time.
227        let err =
228            FmtConfig::from_manifest_str("[fmt]\nmax_line_length = 120\n").expect_err("refused");
229        assert!(
230            matches!(err, ConfigError::Parse(_)),
231            "expected a parse error, got {err:?}"
232        );
233    }
234
235    #[test]
236    fn other_manifest_sections_are_left_to_their_own_readers() {
237        // `[paths]` belongs to `bynk-emit::project`; reading `[fmt]` must not
238        // reject a manifest for carrying it.
239        let cfg = FmtConfig::from_manifest_str(
240            "[project]\nname = \"x\"\n\n[paths]\ninclude = [\"src\"]\n\n[lsp]\ndiagnostics_mode = \"live\"\n\n[fmt]\ntrailing_comma = false\n",
241        )
242        .expect("parses");
243        assert_eq!(cfg.trailing_comma, Some(false));
244    }
245
246    #[test]
247    fn malformed_toml_is_reported_not_ignored() {
248        let err = FmtConfig::from_manifest_str("[fmt\nindent = \"tab\"\n").expect_err("refused");
249        assert!(matches!(err, ConfigError::Parse(_)), "{err:?}");
250    }
251}