Skip to main content

opendev_tools_impl/
formatter.rs

1//! Auto-formatting support for edited files.
2//!
3//! Detects system-available formatters (rustfmt, prettier, black, gofmt, etc.)
4//! and runs the appropriate one after file write/edit operations.
5
6use std::collections::HashSet;
7use std::path::Path;
8use std::process::Command;
9use std::sync::{LazyLock, Mutex};
10
11use tracing::debug;
12
13/// Information about a formatter.
14#[derive(Debug, Clone)]
15pub struct FormatterInfo {
16    /// Formatter name (e.g., "rustfmt").
17    pub name: &'static str,
18    /// Command to invoke.
19    pub command: &'static str,
20    /// File extensions this formatter handles.
21    pub extensions: &'static [&'static str],
22    /// Whether the formatter is available on the system.
23    pub available: bool,
24}
25
26/// Formatter definition (static config).
27struct FormatterDef {
28    name: &'static str,
29    command: &'static str,
30    /// Arguments template. `{file}` is replaced with the actual file path.
31    args: &'static [&'static str],
32    extensions: &'static [&'static str],
33}
34
35/// Known formatters and their configurations.
36const 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
187/// Global formatter manager state.
188struct FormatterState {
189    /// Detected formatter availability (name -> available).
190    detected: Vec<FormatterInfo>,
191    /// Formatters explicitly disabled by the user.
192    disabled: HashSet<String>,
193    /// Whether detection has run.
194    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
205/// Check if a command is available on the system PATH.
206fn 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
214/// Detect which formatters are available on the system.
215/// Results are cached after the first call.
216pub 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
246/// Apply formatter config overrides from `AppConfig.formatter`.
247///
248/// Disables formatters marked as `disabled: true`, and registers custom
249/// formatters with their specified command and extensions.
250pub fn apply_config(config: &opendev_models::config::FormatterConfig) {
251    if config.is_disabled() {
252        // Disable all formatters
253        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        // Custom formatter commands are handled at format_file time via config lookup
270    }
271}
272
273/// Format a file using a custom formatter from config (if available).
274///
275/// Returns `Some(true)` if a custom formatter ran successfully,
276/// `Some(false)` if it ran but failed,
277/// `None` if no custom formatter matched.
278pub 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    // Check custom formatters from config
292    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 // No custom formatter matched
339}
340
341/// Get the best formatter for a given file path.
342pub 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        // Ensure detection has run
349        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
364/// Run the appropriate formatter on a file.
365///
366/// Returns `true` if formatting was applied, `false` otherwise.
367/// Never panics or returns errors — formatting is best-effort.
368pub 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
409/// Disable a specific formatter by name.
410pub fn disable_formatter(name: &str) {
411    let mut state = STATE.lock().unwrap();
412    state.disabled.insert(name.to_string());
413}
414
415/// Enable a previously disabled formatter.
416pub fn enable_formatter(name: &str) {
417    let mut state = STATE.lock().unwrap();
418    state.disabled.remove(name);
419}
420
421/// Get status of all formatters.
422pub fn get_status() -> Vec<FormatterInfo> {
423    detect_formatters()
424}
425
426#[cfg(test)]
427#[path = "formatter_tests.rs"]
428mod tests;