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.
63///
64/// Formatter commands are trusted local executables. LeanCTX terminates the
65/// formatter and ordinary descendants through a Unix process group or Windows
66/// Job Object; a deliberately detached Unix process (`setsid`) is outside this
67/// cooperative cleanup boundary.
68pub fn run_command_formatter(
69    template: &str,
70    abs_path: &str,
71    project_root: &str,
72) -> Result<(), String> {
73    run_command_formatter_with_timeout(
74        template,
75        abs_path,
76        project_root,
77        std::time::Duration::from_secs(30),
78    )
79}
80
81fn run_command_formatter_with_timeout(
82    template: &str,
83    abs_path: &str,
84    project_root: &str,
85    timeout: std::time::Duration,
86) -> Result<(), String> {
87    wait_for_command_formatter(
88        spawn_command_formatter(template, abs_path, project_root)?,
89        timeout,
90    )
91}
92
93fn spawn_command_formatter(
94    template: &str,
95    abs_path: &str,
96    project_root: &str,
97) -> Result<crate::core::process_capture::CapturedChild, String> {
98    use std::process::{Command, Stdio};
99
100    let argv = build_argv(template, abs_path);
101    let (bin, rest) = argv
102        .split_first()
103        .ok_or_else(|| "INVALID_TARGET: empty formatter template".to_string())?;
104    let mut command = Command::new(bin);
105    command
106        .args(rest)
107        .current_dir(project_root)
108        .stdin(Stdio::null());
109    crate::core::process_capture::spawn_capture(&mut command)
110}
111
112fn wait_for_command_formatter(
113    child: crate::core::process_capture::CapturedChild,
114    timeout: std::time::Duration,
115) -> Result<(), String> {
116    let bin = child.program().to_owned();
117    let captured = crate::core::process_capture::wait_for_capture(child, timeout)?;
118    if captured.timed_out {
119        return Err(format!(
120            "formatter '{bin}' timed out after {}s",
121            timeout.as_secs()
122        ));
123    }
124    if !captured.output.status.success() {
125        let code = captured
126            .output
127            .status
128            .code()
129            .map_or_else(|| "signal".to_string(), |code| code.to_string());
130        let stderr = String::from_utf8_lossy(&captured.output.stderr);
131        return Err(format!("{bin} exited {code}: {}", stderr.trim()));
132    }
133    Ok(())
134}
135
136/// Hex BLAKE3 of the file content, for honest before/after change detection.
137pub fn blake3_of(abs_path: &str) -> Result<String, String> {
138    let bytes = std::fs::read(abs_path).map_err(|e| format!("FILE_NOT_FOUND: {abs_path}: {e}"))?;
139    Ok(crate::core::hasher::hash_hex(&bytes))
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn wait_for_path(path: &std::path::Path, timeout: std::time::Duration) -> bool {
147        let deadline = std::time::Instant::now() + timeout;
148        while !path.exists() && std::time::Instant::now() < deadline {
149            std::thread::sleep(std::time::Duration::from_millis(5));
150        }
151        path.exists()
152    }
153
154    fn assert_descendant_stopped(release: &std::path::Path, survived: &std::path::Path) {
155        std::fs::write(release, "release").unwrap();
156        assert!(
157            !wait_for_path(survived, std::time::Duration::from_secs(2)),
158            "formatter descendant performed work after cleanup"
159        );
160    }
161
162    /// Spawn a formatter script the test has just written. When another test
163    /// thread forks while that write handle is still open, the child inherits
164    /// it and `exec` fails with ETXTBSY until the child execs or exits. Retry
165    /// only that error; any other spawn failure is returned at once.
166    #[cfg(unix)]
167    fn spawn_fresh_script(
168        template: &str,
169        abs_path: &str,
170        project_root: &str,
171    ) -> Result<crate::core::process_capture::CapturedChild, String> {
172        let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
173        loop {
174            match spawn_command_formatter(template, abs_path, project_root) {
175                Err(error)
176                    if error.contains("Text file busy") && std::time::Instant::now() < deadline =>
177                {
178                    std::thread::sleep(std::time::Duration::from_millis(20));
179                }
180                result => return result,
181            }
182        }
183    }
184
185    #[test]
186    fn rs_defaults_to_rustfmt() {
187        let f = resolve_formatter("/x/a.rs");
188        assert!(matches!(f, Formatter::Command(ref t) if t == "rustfmt {file}"));
189    }
190
191    #[test]
192    fn md_and_unknown_and_no_ext_default_to_jetbrains() {
193        assert!(matches!(resolve_formatter("/x/a.md"), Formatter::Jetbrains));
194        assert!(matches!(
195            resolve_formatter("/x/a.txt"),
196            Formatter::Jetbrains
197        ));
198        assert!(matches!(
199            resolve_formatter("/x/README"),
200            Formatter::Jetbrains
201        ));
202    }
203
204    #[test]
205    fn extension_is_case_insensitive() {
206        assert!(matches!(
207            resolve_formatter("/x/A.RS"),
208            Formatter::Command(_)
209        ));
210    }
211
212    #[test]
213    fn command_label_is_first_token() {
214        assert_eq!(command_label("rustfmt {file}"), "rustfmt");
215        assert_eq!(command_label("ruff format {file}"), "ruff");
216        assert_eq!(command_label(""), "formatter");
217    }
218
219    #[test]
220    fn argv_substitutes_placeholder() {
221        assert_eq!(
222            build_argv("rustfmt {file}", "/x/a.rs"),
223            vec!["rustfmt".to_string(), "/x/a.rs".to_string()]
224        );
225        assert_eq!(
226            build_argv("ruff format {file}", "/x/a.py"),
227            vec![
228                "ruff".to_string(),
229                "format".to_string(),
230                "/x/a.py".to_string()
231            ]
232        );
233    }
234
235    #[test]
236    fn argv_appends_path_when_no_placeholder() {
237        assert_eq!(
238            build_argv("gofmt -w", "/x/a.go"),
239            vec!["gofmt".to_string(), "-w".to_string(), "/x/a.go".to_string()]
240        );
241    }
242
243    #[test]
244    fn argv_path_with_spaces_stays_one_arg() {
245        let argv = build_argv("rustfmt {file}", "/x/my dir/a.rs");
246        assert_eq!(
247            argv,
248            vec!["rustfmt".to_string(), "/x/my dir/a.rs".to_string()]
249        );
250    }
251
252    #[test]
253    fn blake3_detects_change() {
254        let dir = tempfile::tempdir().unwrap();
255        let f = dir.path().join("a.txt");
256        std::fs::write(&f, "one").unwrap();
257        let p = f.to_str().unwrap();
258        let h1 = blake3_of(p).unwrap();
259        let h2 = blake3_of(p).unwrap();
260        assert_eq!(h1, h2, "same content → same hash");
261        std::fs::write(&f, "two").unwrap();
262        assert_ne!(
263            h1,
264            blake3_of(p).unwrap(),
265            "changed content → different hash"
266        );
267    }
268
269    #[test]
270    fn blake3_missing_file_errors() {
271        assert!(blake3_of("/no/such/file.xyz").is_err());
272    }
273
274    #[test]
275    fn run_command_missing_binary_errors() {
276        let dir = tempfile::tempdir().unwrap();
277        let f = dir.path().join("a.rs");
278        std::fs::write(&f, "fn x(){}\n").unwrap();
279        let err = run_command_formatter(
280            "definitely-not-a-formatter-binary {file}",
281            f.to_str().unwrap(),
282            dir.path().to_str().unwrap(),
283        )
284        .unwrap_err();
285        assert!(err.contains("not found"), "got: {err}");
286    }
287
288    #[test]
289    fn run_command_nonzero_exit_errors() {
290        let dir = tempfile::tempdir().unwrap();
291        #[cfg(unix)]
292        let (template, formatter) = {
293            let formatter = dir.path().join("fail-formatter");
294            std::fs::write(&formatter, "#!/bin/sh\nexit 7\n").unwrap();
295            ("sh {file}", formatter)
296        };
297        #[cfg(windows)]
298        let (template, formatter) = {
299            let formatter = dir.path().join("fail-formatter.cmd");
300            std::fs::write(&formatter, "@echo off\r\nexit /B 7\r\n").unwrap();
301            ("cmd.exe /D /S /C {file}", formatter)
302        };
303        let err = run_command_formatter(
304            template,
305            formatter.to_str().unwrap(),
306            dir.path().to_str().unwrap(),
307        )
308        .unwrap_err();
309        assert!(err.contains("exited"), "got: {err}");
310    }
311
312    #[cfg(windows)]
313    #[test]
314    fn command_formatter_timeout_closes_descendant_pipes() {
315        use std::time::Duration;
316
317        let dir = tempfile::tempdir().unwrap();
318        let formatter = dir.path().join("stall-formatter.ps1");
319        let descendant = dir.path().join("descendant.ps1");
320        let ready = dir.path().join("descendant.ready");
321        let release = dir.path().join("descendant.release");
322        let survived = dir.path().join("descendant.survived");
323        let ps_quote = |path: &std::path::Path| path.display().to_string().replace('\'', "''");
324        std::fs::write(
325            &descendant,
326            format!(
327                "$ErrorActionPreference = 'Stop'\r\nSet-Content -LiteralPath '{}' -Value ready\r\nwhile (-not (Test-Path -LiteralPath '{}')) {{ Start-Sleep -Milliseconds 10 }}\r\nSet-Content -LiteralPath '{}' -Value survived\r\n",
328                ps_quote(&ready),
329                ps_quote(&release),
330                ps_quote(&survived)
331            ),
332        )
333        .unwrap();
334        std::fs::write(
335            &formatter,
336            format!(
337                "$ErrorActionPreference = 'Stop'\r\nStart-Process powershell.exe -NoNewWindow -ArgumentList @('-NoProfile','-ExecutionPolicy','Bypass','-File','{}')\r\nwhile ($true) {{ Start-Sleep -Seconds 1 }}\r\n",
338                ps_quote(&descendant)
339            ),
340        )
341        .unwrap();
342        let process = spawn_command_formatter(
343            "powershell.exe -NoProfile -ExecutionPolicy Bypass -File {file}",
344            formatter.to_str().unwrap(),
345            dir.path().to_str().unwrap(),
346        )
347        .expect("formatter process must spawn");
348        // #1536: PowerShell cold-start on a loaded Windows runner can exceed
349        // 5 s. The poll is bounded either way, so the generous ceiling only
350        // costs wall-clock time in the case where the test is about to fail.
351        assert!(
352            wait_for_path(&ready, Duration::from_secs(30)),
353            "formatter descendant must start"
354        );
355        let error = wait_for_command_formatter(process, Duration::from_millis(250))
356            .expect_err("stalled formatter tree must fail closed");
357
358        assert!(error.contains("timed out"), "got: {error}");
359        assert_descendant_stopped(&release, &survived);
360    }
361
362    #[cfg(unix)]
363    #[test]
364    fn command_formatter_times_out_and_reaps_a_stalled_child() {
365        use std::os::unix::fs::PermissionsExt;
366        use std::time::Duration;
367
368        let dir = tempfile::tempdir().unwrap();
369        let formatter = dir.path().join("stall-formatter");
370        std::fs::write(
371            &formatter,
372            "#!/bin/sh\n(printf ready > \"${0}.ready\"; while [ ! -e \"${0}.release\" ]; do sleep 0.01; done; printf survived > \"${0}.survived\") &\nwait\n",
373        )
374        .unwrap();
375        let mut permissions = std::fs::metadata(&formatter).unwrap().permissions();
376        permissions.set_mode(0o755);
377        std::fs::set_permissions(&formatter, permissions).unwrap();
378        let source = dir.path().join("a.rs");
379        std::fs::write(&source, "fn x() {}\n").unwrap();
380        let ready = formatter.with_file_name("stall-formatter.ready");
381        let release = formatter.with_file_name("stall-formatter.release");
382        let survived = formatter.with_file_name("stall-formatter.survived");
383        let template = format!("{} {{file}}", formatter.display());
384        let source_path = source.to_str().unwrap().to_owned();
385        let project_root = dir.path().to_str().unwrap().to_owned();
386        let process = spawn_fresh_script(&template, &source_path, &project_root)
387            .expect("formatter process must spawn");
388
389        assert!(
390            wait_for_path(&ready, Duration::from_secs(10)),
391            "formatter descendant must start"
392        );
393        let error = wait_for_command_formatter(process, Duration::from_millis(250))
394            .expect_err("stalled formatter must fail closed");
395
396        assert!(error.contains("timed out"), "unexpected error: {error}");
397        assert_descendant_stopped(&release, &survived);
398    }
399
400    #[cfg(unix)]
401    #[test]
402    fn command_formatter_cleans_descendant_after_parent_exits() {
403        use std::os::unix::fs::PermissionsExt;
404        use std::time::Duration;
405
406        let dir = tempfile::tempdir().unwrap();
407        let formatter = dir.path().join("background-formatter");
408        std::fs::write(
409            &formatter,
410            "#!/bin/sh\n(printf ready > \"${0}.ready\"; while [ ! -e \"${0}.release\" ]; do sleep 0.01; done; printf survived > \"${0}.survived\") &\nexit 0\n",
411        )
412        .unwrap();
413        let mut permissions = std::fs::metadata(&formatter).unwrap().permissions();
414        permissions.set_mode(0o755);
415        std::fs::set_permissions(&formatter, permissions).unwrap();
416        let source = dir.path().join("a.rs");
417        std::fs::write(&source, "fn x() {}\n").unwrap();
418
419        let process = spawn_fresh_script(
420            &format!("{} {{file}}", formatter.display()),
421            source.to_str().unwrap(),
422            dir.path().to_str().unwrap(),
423        )
424        .expect("formatter process must spawn");
425        let ready = formatter.with_file_name("background-formatter.ready");
426        let release = formatter.with_file_name("background-formatter.release");
427        let survived = formatter.with_file_name("background-formatter.survived");
428        assert!(
429            wait_for_path(&ready, Duration::from_secs(10)),
430            "formatter descendant must start"
431        );
432
433        wait_for_command_formatter(process, Duration::from_secs(1))
434            .expect("successful formatter must clean up its background process");
435        assert_descendant_stopped(&release, &survived);
436    }
437
438    #[test]
439    fn run_rustfmt_formats_and_reports_change() {
440        // Gated: only runs when rustfmt is installed.
441        if std::process::Command::new("rustfmt")
442            .arg("--version")
443            .output()
444            .is_err()
445        {
446            eprintln!("SKIP: rustfmt not in PATH");
447            return;
448        }
449        let dir = tempfile::tempdir().unwrap();
450        let f = dir.path().join("a.rs");
451        std::fs::write(&f, "fn   x( ){let y=1;}\n").unwrap(); // deliberate drift
452        let p = f.to_str().unwrap();
453        let before = blake3_of(p).unwrap();
454        run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
455        let after = blake3_of(p).unwrap();
456        assert_ne!(
457            before, after,
458            "rustfmt should have changed the drifted file"
459        );
460
461        // A second run is a no-op (already conformant).
462        let before2 = blake3_of(p).unwrap();
463        run_command_formatter("rustfmt {file}", p, dir.path().to_str().unwrap()).unwrap();
464        assert_eq!(
465            before2,
466            blake3_of(p).unwrap(),
467            "second run should be unchanged"
468        );
469    }
470}