Skip to main content

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}