Skip to main content

lean_ctx/lsp/format/
mod.rs

1//! Formatter routing for `ctx_refactor action=reformat`: pick a formatter by
2//! file extension, using built-in routing per extension.
3
4/// The formatter selected for a file: either the IDE HTTP backend or an external
5/// shell command (template with a `{file}` placeholder).
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum Formatter {
8    Jetbrains,
9    Command(String),
10}
11
12/// Pick the formatter for `abs_path` using built-in defaults per extension.
13/// Extension match is case-insensitive; no extension or an unknown extension → `Jetbrains`.
14pub fn resolve_formatter(abs_path: &str) -> Formatter {
15    let ext = std::path::Path::new(abs_path)
16        .extension()
17        .and_then(|e| e.to_str())
18        .map(str::to_ascii_lowercase)
19        .unwrap_or_default();
20    builtin_default(&ext)
21}
22
23/// Built-in routing when the config has no entry for this extension.
24fn builtin_default(ext: &str) -> Formatter {
25    match ext {
26        "rs" => Formatter::Command("rustfmt {file}".to_string()),
27        _ => Formatter::Jetbrains,
28    }
29}
30
31/// The binary name of a command template, for the `via <name>` output label.
32pub fn command_label(template: &str) -> &str {
33    template.split_whitespace().next().unwrap_or("formatter")
34}
35
36/// Split a command template into argv, substituting the `{file}` placeholder with
37/// `abs_path`. `{file}` may be a standalone token or embedded in a token. If no
38/// placeholder is present, `abs_path` is appended as the final argument. The path
39/// is always a single argv element (spaces in the path are preserved).
40pub fn build_argv(template: &str, abs_path: &str) -> Vec<String> {
41    let mut argv: Vec<String> = Vec::new();
42    let mut saw_placeholder = false;
43    for tok in template.split_whitespace() {
44        if tok == "{file}" {
45            argv.push(abs_path.to_string());
46            saw_placeholder = true;
47        } else if tok.contains("{file}") {
48            argv.push(tok.replace("{file}", abs_path));
49            saw_placeholder = true;
50        } else {
51            argv.push(tok.to_string());
52        }
53    }
54    if !saw_placeholder {
55        argv.push(abs_path.to_string());
56    }
57    argv
58}
59
60/// Run an external formatter command on `abs_path` with cwd `project_root` (so
61/// tool config like `rustfmt.toml` is discovered). Returns `Err` with a clear
62/// message if the binary is missing or the command exits non-zero.
63pub fn run_command_formatter(
64    template: &str,
65    abs_path: &str,
66    project_root: &str,
67) -> Result<(), String> {
68    let argv = build_argv(template, abs_path);
69    let (bin, rest) = argv
70        .split_first()
71        .ok_or_else(|| "INVALID_TARGET: empty formatter template".to_string())?;
72    let output = std::process::Command::new(bin)
73        .args(rest)
74        .current_dir(project_root)
75        .output()
76        .map_err(|e| {
77            if e.kind() == std::io::ErrorKind::NotFound {
78                format!("formatter '{bin}' not found in PATH")
79            } else {
80                format!("failed to run '{bin}': {e}")
81            }
82        })?;
83    if !output.status.success() {
84        let code = output
85            .status
86            .code()
87            .map_or_else(|| "signal".to_string(), |c| c.to_string());
88        let stderr = String::from_utf8_lossy(&output.stderr);
89        return Err(format!("{bin} exited {code}: {}", stderr.trim()));
90    }
91    Ok(())
92}
93
94/// Hex BLAKE3 of the file content, for honest before/after change detection.
95pub fn blake3_of(abs_path: &str) -> Result<String, String> {
96    let bytes = std::fs::read(abs_path).map_err(|e| format!("FILE_NOT_FOUND: {abs_path}: {e}"))?;
97    Ok(crate::core::hasher::hash_hex(&bytes))
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn rs_defaults_to_rustfmt() {
106        let f = resolve_formatter("/x/a.rs");
107        assert!(matches!(f, Formatter::Command(ref t) if t == "rustfmt {file}"));
108    }
109
110    #[test]
111    fn md_and_unknown_and_no_ext_default_to_jetbrains() {
112        assert!(matches!(resolve_formatter("/x/a.md"), Formatter::Jetbrains));
113        assert!(matches!(
114            resolve_formatter("/x/a.txt"),
115            Formatter::Jetbrains
116        ));
117        assert!(matches!(
118            resolve_formatter("/x/README"),
119            Formatter::Jetbrains
120        ));
121    }
122
123    #[test]
124    fn extension_is_case_insensitive() {
125        assert!(matches!(
126            resolve_formatter("/x/A.RS"),
127            Formatter::Command(_)
128        ));
129    }
130
131    #[test]
132    fn command_label_is_first_token() {
133        assert_eq!(command_label("rustfmt {file}"), "rustfmt");
134        assert_eq!(command_label("ruff format {file}"), "ruff");
135        assert_eq!(command_label(""), "formatter");
136    }
137
138    #[test]
139    fn argv_substitutes_placeholder() {
140        assert_eq!(
141            build_argv("rustfmt {file}", "/x/a.rs"),
142            vec!["rustfmt".to_string(), "/x/a.rs".to_string()]
143        );
144        assert_eq!(
145            build_argv("ruff format {file}", "/x/a.py"),
146            vec![
147                "ruff".to_string(),
148                "format".to_string(),
149                "/x/a.py".to_string()
150            ]
151        );
152    }
153
154    #[test]
155    fn argv_appends_path_when_no_placeholder() {
156        assert_eq!(
157            build_argv("gofmt -w", "/x/a.go"),
158            vec!["gofmt".to_string(), "-w".to_string(), "/x/a.go".to_string()]
159        );
160    }
161
162    #[test]
163    fn argv_path_with_spaces_stays_one_arg() {
164        let argv = build_argv("rustfmt {file}", "/x/my dir/a.rs");
165        assert_eq!(
166            argv,
167            vec!["rustfmt".to_string(), "/x/my dir/a.rs".to_string()]
168        );
169    }
170
171    #[test]
172    fn blake3_detects_change() {
173        let dir = tempfile::tempdir().unwrap();
174        let f = dir.path().join("a.txt");
175        std::fs::write(&f, "one").unwrap();
176        let p = f.to_str().unwrap();
177        let h1 = blake3_of(p).unwrap();
178        let h2 = blake3_of(p).unwrap();
179        assert_eq!(h1, h2, "same content → same hash");
180        std::fs::write(&f, "two").unwrap();
181        assert_ne!(
182            h1,
183            blake3_of(p).unwrap(),
184            "changed content → different hash"
185        );
186    }
187
188    #[test]
189    fn blake3_missing_file_errors() {
190        assert!(blake3_of("/no/such/file.xyz").is_err());
191    }
192
193    #[test]
194    fn run_command_missing_binary_errors() {
195        let dir = tempfile::tempdir().unwrap();
196        let f = dir.path().join("a.rs");
197        std::fs::write(&f, "fn x(){}\n").unwrap();
198        let err = run_command_formatter(
199            "definitely-not-a-formatter-binary {file}",
200            f.to_str().unwrap(),
201            dir.path().to_str().unwrap(),
202        )
203        .unwrap_err();
204        assert!(err.contains("not found"), "got: {err}");
205    }
206
207    #[test]
208    fn run_command_nonzero_exit_errors() {
209        let dir = tempfile::tempdir().unwrap();
210        let f = dir.path().join("a.rs");
211        std::fs::write(&f, "fn x(){}\n").unwrap();
212        // `false` exits 1 and ignores its args.
213        let err = run_command_formatter(
214            "false {file}",
215            f.to_str().unwrap(),
216            dir.path().to_str().unwrap(),
217        )
218        .unwrap_err();
219        assert!(err.contains("exited"), "got: {err}");
220    }
221
222    #[test]
223    fn run_rustfmt_formats_and_reports_change() {
224        // Gated: only runs when rustfmt is installed.
225        if std::process::Command::new("rustfmt")
226            .arg("--version")
227            .output()
228            .is_err()
229        {
230            eprintln!("SKIP: rustfmt not in PATH");
231            return;
232        }
233        let dir = tempfile::tempdir().unwrap();
234        let f = dir.path().join("a.rs");
235        std::fs::write(&f, "fn   x( ){let y=1;}\n").unwrap(); // deliberate drift
236        let p = f.to_str().unwrap();
237        let before = blake3_of(p).unwrap();
238        run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
239        let after = blake3_of(p).unwrap();
240        assert_ne!(
241            before, after,
242            "rustfmt should have changed the drifted file"
243        );
244
245        // A second run is a no-op (already conformant).
246        let before2 = blake3_of(p).unwrap();
247        run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
248        assert_eq!(
249            before2,
250            blake3_of(p).unwrap(),
251            "second run should be unchanged"
252        );
253    }
254}