Skip to main content

lattice_format/
spec.rs

1//! What to run, per language.
2
3use lattice_syntax::Lang;
4
5/// How to invoke one external formatter.
6///
7/// Every formatter here reads the buffer on **stdin** and writes the
8/// result to **stdout**. That is deliberate and not merely convenient:
9/// a formatter that rewrites the file in place cannot be run against an
10/// unsaved buffer, and formatting only what is on disk is the wrong
11/// behaviour for an editor.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct FormatterSpec {
14    /// Program name, resolved through `PATH`.
15    pub program: &'static str,
16    /// Fixed arguments.
17    pub args: &'static [&'static str],
18    /// When set, the buffer's path is appended as
19    /// `<flag>=<path>`. Several formatters need the filename to pick a
20    /// parser or find a config file even when reading stdin
21    /// (`prettier --stdin-filepath`, `clang-format
22    /// --assume-filename`).
23    pub filename_flag: Option<&'static str>,
24}
25
26impl FormatterSpec {
27    /// The default formatter for `lang`, or `None` when there is no
28    /// obvious choice.
29    ///
30    /// **This table is a starting point, not a contract.** Formatter
31    /// CLIs change flags across versions, and houses disagree about
32    /// which formatter to use at all. Every entry is one line to
33    /// correct, and `formatprg` overrides the whole thing per buffer.
34    /// Absent entries are not gaps to be filled on principle — a
35    /// language with no consensus formatter is better served by the
36    /// user naming one.
37    pub fn for_lang(lang: Lang) -> Option<Self> {
38        let (program, args, filename_flag) = match lang {
39            Lang::Rust => ("rustfmt", &["--emit", "stdout"][..], None),
40            Lang::Go => ("gofmt", &[][..], None),
41            Lang::Python => ("black", &["-q", "-"][..], None),
42            Lang::Lua => ("stylua", &["-"][..], None),
43            Lang::Bash => ("shfmt", &[][..], None),
44            Lang::Toml => ("taplo", &["format", "-"][..], None),
45            Lang::C | Lang::Cpp | Lang::Java => {
46                ("clang-format", &[][..], Some("--assume-filename"))
47            }
48            Lang::JavaScript
49            | Lang::TypeScript
50            | Lang::Tsx
51            | Lang::Css
52            | Lang::Html
53            | Lang::Json
54            | Lang::Yaml
55            | Lang::Markdown => ("prettier", &[][..], Some("--stdin-filepath")),
56            // No consensus formatter: Ruby is split between rubocop
57            // and standardrb, SQL between a dozen tools with
58            // incompatible opinions, and `Plain` has nothing to format.
59            // WIT has no stdin-oriented consensus formatter — `wasm-tools
60            // component wit` reformats a file in place, which is a different
61            // shape from the pipe every arm above uses.
62            Lang::Ruby | Lang::Sql | Lang::Plain | Lang::Wit => return None,
63            // No built-in formatter for a language the editor learned
64            // about at runtime. `formatprg` still works — it overrides
65            // this table per buffer and is the documented answer for
66            // any language without a consensus formatter.
67            Lang::Plugin(_) => return None,
68        };
69        Some(Self {
70            program,
71            args,
72            filename_flag,
73        })
74    }
75
76    /// Parse a user-supplied formatter command line — an `external:` rung
77    /// of a `format.*` chain (RF.5), or the deprecated `formatprg`.
78    ///
79    /// Whitespace-split, first word is the program. Leaked to
80    /// `'static` because [`FormatterSpec`] holds `&'static str` for the
81    /// built-in table's sake; a handful of leaked option strings over a
82    /// session is not a leak worth a lifetime parameter through the
83    /// whole crate.
84    ///
85    /// Returns `None` for an empty or whitespace-only string, which is
86    /// the "not configured" signal rather than an error.
87    pub fn parse(s: &str) -> Option<Self> {
88        let mut words = s.split_whitespace();
89        let program: &'static str = Box::leak(words.next()?.to_string().into_boxed_str());
90        let args: Vec<&'static str> = words
91            .map(|w| &*Box::leak(w.to_string().into_boxed_str()))
92            .collect();
93        Some(Self {
94            program,
95            args: Box::leak(args.into_boxed_slice()),
96            filename_flag: None,
97        })
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn known_languages_map_to_their_usual_formatter() {
107        assert_eq!(
108            FormatterSpec::for_lang(Lang::Rust).unwrap().program,
109            "rustfmt"
110        );
111        assert_eq!(FormatterSpec::for_lang(Lang::Go).unwrap().program, "gofmt");
112        assert_eq!(
113            FormatterSpec::for_lang(Lang::TypeScript).unwrap().program,
114            "prettier"
115        );
116    }
117
118    #[test]
119    fn languages_without_consensus_have_no_default() {
120        // Not gaps: naming one would pick a side in an argument the
121        // editor has no stake in.
122        assert!(FormatterSpec::for_lang(Lang::Ruby).is_none());
123        assert!(FormatterSpec::for_lang(Lang::Sql).is_none());
124        assert!(FormatterSpec::for_lang(Lang::Plain).is_none());
125    }
126
127    #[test]
128    fn formatters_needing_a_filename_carry_the_flag() {
129        assert_eq!(
130            FormatterSpec::for_lang(Lang::Cpp).unwrap().filename_flag,
131            Some("--assume-filename")
132        );
133        assert_eq!(
134            FormatterSpec::for_lang(Lang::Json).unwrap().filename_flag,
135            Some("--stdin-filepath")
136        );
137        assert_eq!(
138            FormatterSpec::for_lang(Lang::Rust).unwrap().filename_flag,
139            None
140        );
141    }
142
143    #[test]
144    fn formatprg_parses_into_program_and_args() {
145        let spec = FormatterSpec::parse("  my-fmt --stdin  --width 80 ").unwrap();
146        assert_eq!(spec.program, "my-fmt");
147        assert_eq!(spec.args, &["--stdin", "--width", "80"]);
148    }
149
150    #[test]
151    fn an_empty_formatprg_is_not_configured_rather_than_an_error() {
152        assert!(FormatterSpec::parse("").is_none());
153        assert!(FormatterSpec::parse("   ").is_none());
154    }
155}