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}