lattice_core/format_chain.rs
1//! Who formats a range — an ordered chain of typed providers.
2//!
3//! Three *intents* (`indent`, `reflow`, `reformat`) each resolve a
4//! [`ProviderChain`]; the first provider that is available and returns a
5//! result wins. See `docs/dev/architecture/text-reflow.md` §6.
6//!
7//! This module is the **vocabulary only** — parsing, formatting and the
8//! ordering. Resolution lives in the host (RF.5), because it needs the
9//! LSP client, the process runner and the buffer.
10//!
11//! ## Why not `formatprg` / `equalprg`
12//!
13//! Those are one string slot each with a precedence order hardcoded in
14//! Rust. Every new placement — "prettier for markdown but the server for
15//! TypeScript", or a WASM plugin supplying a formatter — is then a host
16//! patch. The whole field converged on typed ordered lists instead
17//! (conform.nvim's `formatters_by_ft` + `lsp_format`, Zed's `formatter:`
18//! union, Helix's `[language.formatter]`), and paramount #2 is the
19//! reason to follow: a chain that is data can be extended by a plugin,
20//! and an `if` in `do_format_request` cannot.
21
22use std::fmt;
23
24crate::labeled_enum! {
25 // Serde because `FormatIntent` rides `AppEffect::FormatRange`, which
26 // is serialised across the plugin boundary like every other effect.
27 #[derive(serde::Serialize, serde::Deserialize)]
28 /// Which of the three formatting jobs a range wants done.
29 ///
30 /// Separate values rather than one "format", because they are not
31 /// substitutable and the failure directions differ: an indent that
32 /// reflows is destructive, a reformatter asked to reflow prose
33 /// mostly does nothing at all. `docs/dev/architecture/text-reflow.md`
34 /// §2 has the table.
35 ///
36 /// Each intent names one [`ProviderChain`] option
37 /// (`format.indent` / `format.reflow` / `format.reformat`) and one
38 /// or more verbs.
39 pub enum FormatIntent {
40 /// Leading whitespace only — `=`.
41 #[default]
42 Indent = "indent"
43 => "Re-indent: leading whitespace only",
44 /// Line breaks within a paragraph — `gq` / `gw`.
45 Reflow = "reflow"
46 => "Reflow: line breaks within a paragraph",
47 /// Anything the formatter likes — `:format`, `g=`, format-on-save.
48 Reformat = "reformat"
49 => "Reformat: whatever the formatter decides",
50 }
51}
52
53/// One rung of a [`ProviderChain`].
54///
55/// The set is open at the edges (`External`, `Plugin`) and closed in the
56/// middle, which is what lets `parse` reject a typo like `nativ` while
57/// still admitting formatters the editor has never heard of.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum FormatProvider {
60 /// The built-in engine for the intent: the tree-sitter indent engine
61 /// for `indent`, the `textwidth` reflow engine for `reflow`. Not
62 /// meaningful for `reformat`, where it resolves to nothing and the
63 /// chain moves on.
64 Native,
65 /// `textDocument/formatting` or `rangeFormatting` from an attached
66 /// server.
67 ///
68 /// **Eligible in any chain, default only in `reformat`.** The
69 /// protocol has no indent-only and no reflow request, so putting
70 /// this in `indent` or `reflow` gets you a *reformat* — which is a
71 /// legitimate thing to want and a bad default, because in the common
72 /// case (rustfmt's `wrap_comments` is off by default, prettier's
73 /// `proseWrap` defaults to `preserve`) it silently does nothing to a
74 /// comment paragraph. text-reflow.md §5.
75 Lsp,
76 /// The built-in per-language formatter table
77 /// (`lattice_format::FormatterSpec::for_lang`) — rustfmt, prettier,
78 /// black, gofmt and peers, each probed on `PATH` and skipped when
79 /// absent.
80 LangDefault,
81 /// A user-named external filter, as a command line:
82 /// `external:prettier --stdin-filepath %`. The replacement for
83 /// `formatprg`, and for the `equalprg` that was never implemented.
84 External(String),
85 /// A formatter contributed by a WASM plugin, by id.
86 Plugin(String),
87}
88
89impl FormatProvider {
90 /// Canonical string form. Round-trips with [`Self::parse`].
91 pub fn label(&self) -> String {
92 match self {
93 FormatProvider::Native => "native".to_string(),
94 FormatProvider::Lsp => "lsp".to_string(),
95 FormatProvider::LangDefault => "lang-default".to_string(),
96 FormatProvider::External(cmd) => format!("external:{cmd}"),
97 FormatProvider::Plugin(id) => format!("plugin:{id}"),
98 }
99 }
100
101 /// Parse one rung.
102 ///
103 /// The prefixed forms carry a payload; the bare forms are a closed
104 /// set, so an unrecognised bare word is an error rather than being
105 /// silently taken for an external command. That distinction is the
106 /// point of the `external:` prefix existing at all — without it,
107 /// `:set format.reformat=nativ` would install a chain that tries to
108 /// run a program called `nativ`.
109 pub fn parse(s: &str) -> Result<Self, String> {
110 let s = s.trim();
111 if let Some(cmd) = s.strip_prefix("external:") {
112 let cmd = cmd.trim();
113 if cmd.is_empty() {
114 return Err("`external:` needs a command line, e.g. \
115 `external:prettier --stdin-filepath %`"
116 .to_string());
117 }
118 return Ok(FormatProvider::External(cmd.to_string()));
119 }
120 if let Some(id) = s.strip_prefix("plugin:") {
121 let id = id.trim();
122 if id.is_empty() {
123 return Err("`plugin:` needs a plugin id, e.g. `plugin:my-formatter`".to_string());
124 }
125 return Ok(FormatProvider::Plugin(id.to_string()));
126 }
127 match s {
128 "native" => Ok(FormatProvider::Native),
129 "lsp" => Ok(FormatProvider::Lsp),
130 "lang-default" => Ok(FormatProvider::LangDefault),
131 other => Err(format!(
132 "unknown formatter `{other}` — expected one of \
133 native, lsp, lang-default, external:<command>, plugin:<id>"
134 )),
135 }
136 }
137}
138
139impl fmt::Display for FormatProvider {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 f.write_str(&self.label())
142 }
143}
144
145/// An ordered list of providers for one intent.
146///
147/// Written comma-separated: `:set format.reformat=lsp,lang-default`.
148///
149/// **A command line in an `external:` rung may not contain a comma** —
150/// the comma is the chain separator and there is no escape. This is a
151/// deliberate limit rather than an oversight: the alternative is a
152/// quoting grammar inside a `:set` value, which is a lot of machinery
153/// for a case (`--config a,b`) that a one-line wrapper script solves.
154/// The error message says so when it bites.
155#[derive(Debug, Clone, PartialEq, Eq, Default)]
156pub struct ProviderChain(pub Vec<FormatProvider>);
157
158impl ProviderChain {
159 /// A chain trying `providers` in order. An empty vector is a valid
160 /// "this intent does nothing" chain (see [`Self::parse`]).
161 pub fn new(providers: Vec<FormatProvider>) -> Self {
162 ProviderChain(providers)
163 }
164
165 /// The single-provider chain, for the common defaults.
166 pub fn of(provider: FormatProvider) -> Self {
167 ProviderChain(vec![provider])
168 }
169
170 /// The providers in resolution order — first available wins.
171 pub fn iter(&self) -> std::slice::Iter<'_, FormatProvider> {
172 self.0.iter()
173 }
174
175 /// Whether the chain names no provider at all (the user switched the
176 /// intent off).
177 pub fn is_empty(&self) -> bool {
178 self.0.is_empty()
179 }
180
181 /// Number of providers in the chain.
182 pub fn len(&self) -> usize {
183 self.0.len()
184 }
185
186 /// Canonical string form. Round-trips with [`Self::parse`].
187 pub fn label(&self) -> String {
188 self.0
189 .iter()
190 .map(FormatProvider::label)
191 .collect::<Vec<_>>()
192 .join(",")
193 }
194
195 /// Parse a comma-separated chain.
196 ///
197 /// The empty string is a valid EMPTY chain, not an error: it is how
198 /// a user says "this intent does nothing here", and rejecting it
199 /// would leave no way to express that short of a sentinel value.
200 /// An empty chain reports "nothing configured" at resolution rather
201 /// than falling back to a default the user just removed.
202 ///
203 /// # Errors
204 ///
205 /// A human-readable message for an empty entry (`lsp,,native`) or any
206 /// rung [`FormatProvider::parse`] rejects.
207 ///
208 /// # Examples
209 ///
210 /// ```
211 /// use lattice_core::{FormatProvider, ProviderChain};
212 ///
213 /// let chain = ProviderChain::parse("lsp, external:prettier --stdin-filepath %")
214 /// .unwrap_or_default();
215 /// assert_eq!(
216 /// chain.iter().cloned().collect::<Vec<_>>(),
217 /// [
218 /// FormatProvider::Lsp,
219 /// FormatProvider::External("prettier --stdin-filepath %".into()),
220 /// ],
221 /// );
222 /// assert_eq!(chain.label(), "lsp,external:prettier --stdin-filepath %");
223 ///
224 /// assert!(ProviderChain::parse("").is_ok_and(|c| c.is_empty()));
225 /// assert!(ProviderChain::parse("nativ").is_err()); // typo, not a program
226 /// ```
227 pub fn parse(s: &str) -> Result<Self, String> {
228 let s = s.trim();
229 if s.is_empty() {
230 return Ok(ProviderChain(Vec::new()));
231 }
232 let mut out = Vec::new();
233 for part in s.split(',') {
234 if part.trim().is_empty() {
235 return Err(
236 "empty entry in the chain — write `lsp,native`, not `lsp,,native`".to_string(),
237 );
238 }
239 out.push(FormatProvider::parse(part)?);
240 }
241 Ok(ProviderChain(out))
242 }
243}
244
245impl fmt::Display for ProviderChain {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.write_str(&self.label())
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 #![allow(clippy::unwrap_used)]
254 use super::*;
255
256 #[test]
257 fn every_provider_round_trips() {
258 for p in [
259 FormatProvider::Native,
260 FormatProvider::Lsp,
261 FormatProvider::LangDefault,
262 FormatProvider::External("prettier --stdin-filepath %".to_string()),
263 FormatProvider::Plugin("my-fmt".to_string()),
264 ] {
265 assert_eq!(FormatProvider::parse(&p.label()).unwrap(), p);
266 }
267 }
268
269 #[test]
270 fn a_chain_round_trips() {
271 let c = ProviderChain::parse("lsp,lang-default,native").unwrap();
272 assert_eq!(c.len(), 3);
273 assert_eq!(ProviderChain::parse(&c.label()).unwrap(), c);
274 }
275
276 /// The reason `external:` is a prefix rather than "anything we do
277 /// not recognise". Without it a typo installs a chain that tries to
278 /// execute a program named after the typo, and the failure surfaces
279 /// as "no such file or directory" at format time rather than as a
280 /// rejected `:set` at the moment the user made the mistake.
281 #[test]
282 fn a_bare_typo_is_rejected_rather_than_taken_for_a_command() {
283 let err = FormatProvider::parse("nativ").unwrap_err();
284 assert!(
285 err.contains("native"),
286 "the message must name the forms: {err}"
287 );
288 assert!(err.contains("external:<command>"), "{err}");
289 }
290
291 #[test]
292 fn an_external_rung_keeps_its_whole_command_line() {
293 let p = FormatProvider::parse("external:prettier --stdin-filepath %").unwrap();
294 assert_eq!(
295 p,
296 FormatProvider::External("prettier --stdin-filepath %".to_string())
297 );
298 }
299
300 #[test]
301 fn whitespace_around_entries_is_tolerated() {
302 assert_eq!(
303 ProviderChain::parse(" lsp , native ").unwrap(),
304 ProviderChain::new(vec![FormatProvider::Lsp, FormatProvider::Native])
305 );
306 }
307
308 /// An empty chain is "this intent does nothing here", which a user
309 /// must be able to say. Erroring would leave the intent expressible
310 /// only as some sentinel.
311 #[test]
312 fn the_empty_chain_is_valid_and_means_nothing_runs() {
313 let c = ProviderChain::parse("").unwrap();
314 assert!(c.is_empty());
315 assert_eq!(c.label(), "");
316 }
317
318 #[test]
319 fn a_hole_in_the_chain_is_an_error_not_a_silent_skip() {
320 assert!(ProviderChain::parse("lsp,,native").is_err());
321 }
322
323 #[test]
324 fn empty_payloads_are_rejected_with_an_example() {
325 for bad in ["external:", "plugin:", "external: "] {
326 let err = FormatProvider::parse(bad).unwrap_err();
327 assert!(err.contains("e.g."), "{bad} must suggest a form: {err}");
328 }
329 }
330}