1use std::collections::HashSet;
7use std::path::Path;
8use std::process::Command;
9use std::sync::{LazyLock, Mutex};
10
11use tracing::debug;
12
13#[derive(Debug, Clone)]
15pub struct FormatterInfo {
16 pub name: &'static str,
18 pub command: &'static str,
20 pub extensions: &'static [&'static str],
22 pub available: bool,
24}
25
26struct FormatterDef {
28 name: &'static str,
29 command: &'static str,
30 args: &'static [&'static str],
32 extensions: &'static [&'static str],
33}
34
35const FORMATTERS: &[FormatterDef] = &[
37 FormatterDef {
38 name: "rustfmt",
39 command: "rustfmt",
40 args: &["{file}"],
41 extensions: &[".rs"],
42 },
43 FormatterDef {
44 name: "black",
45 command: "black",
46 args: &["--quiet", "--line-length", "100", "{file}"],
47 extensions: &[".py"],
48 },
49 FormatterDef {
50 name: "prettier",
51 command: "prettier",
52 args: &["--write", "{file}"],
53 extensions: &[
54 ".js", ".jsx", ".ts", ".tsx", ".css", ".scss", ".html", ".json", ".md", ".yaml", ".yml",
55 ],
56 },
57 FormatterDef {
58 name: "gofmt",
59 command: "gofmt",
60 args: &["-w", "{file}"],
61 extensions: &[".go"],
62 },
63 FormatterDef {
64 name: "clang-format",
65 command: "clang-format",
66 args: &["-i", "{file}"],
67 extensions: &[".c", ".cpp", ".h", ".hpp", ".cc", ".cxx"],
68 },
69 FormatterDef {
70 name: "shfmt",
71 command: "shfmt",
72 args: &["-w", "{file}"],
73 extensions: &[".sh", ".bash"],
74 },
75 FormatterDef {
76 name: "isort",
77 command: "isort",
78 args: &["--quiet", "{file}"],
79 extensions: &[".py"],
80 },
81 FormatterDef {
82 name: "ruff",
83 command: "ruff",
84 args: &["format", "{file}"],
85 extensions: &[".py", ".pyi"],
86 },
87 FormatterDef {
88 name: "biome",
89 command: "biome",
90 args: &["check", "--write", "{file}"],
91 extensions: &[
92 ".js", ".jsx", ".ts", ".tsx", ".css", ".scss", ".json", ".jsonc",
93 ],
94 },
95 FormatterDef {
96 name: "mix",
97 command: "mix",
98 args: &["format", "{file}"],
99 extensions: &[".ex", ".exs", ".eex", ".heex", ".leex"],
100 },
101 FormatterDef {
102 name: "zig",
103 command: "zig",
104 args: &["fmt", "{file}"],
105 extensions: &[".zig", ".zon"],
106 },
107 FormatterDef {
108 name: "dart",
109 command: "dart",
110 args: &["format", "{file}"],
111 extensions: &[".dart"],
112 },
113 FormatterDef {
114 name: "ktlint",
115 command: "ktlint",
116 args: &["-F", "{file}"],
117 extensions: &[".kt", ".kts"],
118 },
119 FormatterDef {
120 name: "ocamlformat",
121 command: "ocamlformat",
122 args: &["-i", "{file}"],
123 extensions: &[".ml", ".mli"],
124 },
125 FormatterDef {
126 name: "terraform",
127 command: "terraform",
128 args: &["fmt", "{file}"],
129 extensions: &[".tf", ".tfvars"],
130 },
131 FormatterDef {
132 name: "gleam",
133 command: "gleam",
134 args: &["format", "{file}"],
135 extensions: &[".gleam"],
136 },
137 FormatterDef {
138 name: "nixfmt",
139 command: "nixfmt",
140 args: &["{file}"],
141 extensions: &[".nix"],
142 },
143 FormatterDef {
144 name: "rubocop",
145 command: "rubocop",
146 args: &["--autocorrect", "{file}"],
147 extensions: &[".rb", ".rake", ".gemspec"],
148 },
149 FormatterDef {
150 name: "ormolu",
151 command: "ormolu",
152 args: &["-i", "{file}"],
153 extensions: &[".hs"],
154 },
155 FormatterDef {
156 name: "latexindent",
157 command: "latexindent",
158 args: &["-w", "-s", "{file}"],
159 extensions: &[".tex"],
160 },
161 FormatterDef {
162 name: "dfmt",
163 command: "dfmt",
164 args: &["-i", "{file}"],
165 extensions: &[".d"],
166 },
167 FormatterDef {
168 name: "cljfmt",
169 command: "cljfmt",
170 args: &["fix", "--quiet", "{file}"],
171 extensions: &[".clj", ".cljs", ".cljc", ".edn"],
172 },
173 FormatterDef {
174 name: "swift-format",
175 command: "swift-format",
176 args: &["--in-place", "{file}"],
177 extensions: &[".swift"],
178 },
179 FormatterDef {
180 name: "xmllint",
181 command: "xmllint",
182 args: &["--format", "--output", "{file}", "{file}"],
183 extensions: &[".xml", ".xsl", ".xslt"],
184 },
185];
186
187struct FormatterState {
189 detected: Vec<FormatterInfo>,
191 disabled: HashSet<String>,
193 initialized: bool,
195}
196
197static STATE: LazyLock<Mutex<FormatterState>> = LazyLock::new(|| {
198 Mutex::new(FormatterState {
199 detected: Vec::new(),
200 disabled: HashSet::new(),
201 initialized: false,
202 })
203});
204
205fn command_exists(cmd: &str) -> bool {
207 Command::new("which")
208 .arg(cmd)
209 .output()
210 .map(|o| o.status.success())
211 .unwrap_or(false)
212}
213
214pub fn detect_formatters() -> Vec<FormatterInfo> {
217 let mut state = STATE.lock().unwrap();
218 if state.initialized {
219 return state.detected.clone();
220 }
221
222 state.detected = FORMATTERS
223 .iter()
224 .map(|def| FormatterInfo {
225 name: def.name,
226 command: def.command,
227 extensions: def.extensions,
228 available: command_exists(def.command),
229 })
230 .collect();
231
232 let found: Vec<&str> = state
233 .detected
234 .iter()
235 .filter(|f| f.available)
236 .map(|f| f.name)
237 .collect();
238 if !found.is_empty() {
239 debug!("Detected {} formatters: {}", found.len(), found.join(", "));
240 }
241
242 state.initialized = true;
243 state.detected.clone()
244}
245
246pub fn apply_config(config: &opendev_models::config::FormatterConfig) {
251 if config.is_disabled() {
252 let mut state = STATE.lock().unwrap();
254 let names: Vec<String> = state.detected.iter().map(|f| f.name.to_string()).collect();
255 for name in names {
256 state.disabled.insert(name);
257 }
258 debug!("All formatters disabled via config");
259 return;
260 }
261
262 let overrides = config.overrides();
263 let mut state = STATE.lock().unwrap();
264 for (name, override_cfg) in overrides {
265 if override_cfg.disabled {
266 state.disabled.insert(name.clone());
267 debug!(name = %name, "Formatter disabled via config");
268 }
269 }
271}
272
273pub fn format_file_with_config(
279 file_path: &str,
280 working_dir: &Path,
281 config: &opendev_models::config::FormatterConfig,
282) -> Option<bool> {
283 if config.is_disabled() {
284 return Some(false);
285 }
286
287 let ext = Path::new(file_path)
288 .extension()
289 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))?;
290
291 for (name, override_cfg) in config.overrides() {
293 if override_cfg.disabled || override_cfg.command.is_empty() {
294 continue;
295 }
296 if !override_cfg.extensions.is_empty() && !override_cfg.extensions.contains(&ext) {
297 continue;
298 }
299
300 let args: Vec<String> = override_cfg
301 .command
302 .iter()
303 .map(|a| a.replace("$FILE", file_path).replace("{file}", file_path))
304 .collect();
305
306 if args.is_empty() {
307 continue;
308 }
309
310 let mut cmd = Command::new(&args[0]);
311 cmd.args(&args[1..]).current_dir(working_dir);
312 for (k, v) in &override_cfg.environment {
313 cmd.env(k, v);
314 }
315
316 match cmd.output() {
317 Ok(output) if output.status.success() => {
318 debug!("Formatted {} with custom formatter {}", file_path, name);
319 return Some(true);
320 }
321 Ok(output) => {
322 let stderr = String::from_utf8_lossy(&output.stderr);
323 debug!(
324 "Custom formatter {} failed on {}: {}",
325 name,
326 file_path,
327 &stderr[..stderr.len().min(200)]
328 );
329 return Some(false);
330 }
331 Err(e) => {
332 debug!("Failed to run custom formatter {}: {}", name, e);
333 return Some(false);
334 }
335 }
336 }
337
338 None }
340
341pub fn get_formatter_for_file(file_path: &str) -> Option<&'static str> {
343 let ext = Path::new(file_path)
344 .extension()
345 .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))?;
346
347 let state = {
348 drop(detect_formatters());
350 STATE.lock().unwrap()
351 };
352
353 for info in &state.detected {
354 if state.disabled.contains(info.name) {
355 continue;
356 }
357 if info.available && info.extensions.contains(&ext.as_str()) {
358 return Some(info.name);
359 }
360 }
361 None
362}
363
364pub fn format_file(file_path: &str, working_dir: &Path) -> bool {
369 let formatter_name = match get_formatter_for_file(file_path) {
370 Some(name) => name,
371 None => return false,
372 };
373
374 let def = match FORMATTERS.iter().find(|d| d.name == formatter_name) {
375 Some(d) => d,
376 None => return false,
377 };
378
379 let args: Vec<String> = def
380 .args
381 .iter()
382 .map(|a| a.replace("{file}", file_path))
383 .collect();
384
385 match Command::new(def.command)
386 .args(&args)
387 .current_dir(working_dir)
388 .output()
389 {
390 Ok(output) => {
391 if output.status.success() {
392 debug!("Formatted {} with {}", file_path, formatter_name);
393 true
394 } else {
395 let stderr = String::from_utf8_lossy(&output.stderr);
396 debug!(
397 "Formatter {} failed on {}: {}",
398 formatter_name,
399 file_path,
400 &stderr[..stderr.len().min(200)]
401 );
402 false
403 }
404 }
405 Err(_) => false,
406 }
407}
408
409pub fn disable_formatter(name: &str) {
411 let mut state = STATE.lock().unwrap();
412 state.disabled.insert(name.to_string());
413}
414
415pub fn enable_formatter(name: &str) {
417 let mut state = STATE.lock().unwrap();
418 state.disabled.remove(name);
419}
420
421pub fn get_status() -> Vec<FormatterInfo> {
423 detect_formatters()
424}
425
426#[cfg(test)]
427#[path = "formatter_tests.rs"]
428mod tests;