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}