Skip to main content

lattice_format/
runner.rs

1//! Running a formatter, with the failure modes spelled out.
2//!
3//! Blocking by design. The caller decides where this runs — the host
4//! puts it on `spawn_blocking`, never the actor or UI thread — and
5//! keeping the spawn itself synchronous makes it testable without a
6//! runtime.
7
8use std::io::Write;
9use std::path::Path;
10use std::process::{Command, Stdio};
11use std::time::{Duration, Instant};
12
13use crate::spec::FormatterSpec;
14
15/// Wall-clock ceiling for one formatter run.
16///
17/// A formatter is a batch tool on a file-sized input; anything past
18/// this is hung, not slow. The number matters because IN.9 runs this
19/// on the save path, where the rule is that a formatter must never
20/// cost the user their write.
21pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2);
22
23/// Why a formatter run produced no edits.
24///
25/// Every variant is a case the caller must handle differently, which
26/// is why this is not a `String`: "not installed" is routine and
27/// silent on save, "non-zero exit" carries diagnostics the user needs
28/// to see, and "timed out" means a process was killed.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum FormatError {
31    /// The program is not on `PATH`. Routine — a user without
32    /// `prettier` installed should not be nagged on every save.
33    NotFound { program: String },
34    /// The formatter ran and rejected the input. `stderr` is the
35    /// compiler-style diagnostic and belongs in front of the user.
36    Failed { program: String, stderr: String },
37    /// Killed at [`FORMAT_TIMEOUT`].
38    TimedOut { program: String },
39    /// The formatter wrote bytes that are not UTF-8. Refusing is the
40    /// only safe answer: splicing them into the rope would corrupt the
41    /// buffer.
42    NotUtf8 { program: String },
43    /// Spawning or piping failed for a reason other than the program
44    /// being absent.
45    Io { program: String, message: String },
46}
47
48impl FormatError {
49    /// One-line form for the echo area.
50    pub fn message(&self) -> String {
51        match self {
52            Self::NotFound { program } => format!("formatter not found: {program}"),
53            Self::Failed { program, stderr } => {
54                let first = stderr.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
55                if first.is_empty() {
56                    format!("{program} failed")
57                } else {
58                    format!("{program}: {first}")
59                }
60            }
61            Self::TimedOut { program } => {
62                format!("{program} timed out after {}s", FORMAT_TIMEOUT.as_secs())
63            }
64            Self::NotUtf8 { program } => format!("{program} produced invalid UTF-8"),
65            Self::Io { program, message } => format!("{program}: {message}"),
66        }
67    }
68
69    /// Whether this is worth interrupting the user for.
70    ///
71    /// `NotFound` is not: it means the tool simply is not installed,
72    /// which is a configuration state rather than an event. The others
73    /// describe something that went wrong during work the user asked
74    /// for.
75    pub fn is_noteworthy(&self) -> bool {
76        !matches!(self, Self::NotFound { .. })
77    }
78}
79
80/// Run `spec` over `input`, returning the formatted text.
81///
82/// Blocking, bounded by [`FORMAT_TIMEOUT`]. On timeout the child is
83/// killed rather than left to leak.
84pub fn run(spec: &FormatterSpec, input: &str, path: Option<&Path>) -> Result<String, FormatError> {
85    let program = spec.program.to_string();
86    let mut command = Command::new(spec.program);
87    command.args(spec.args);
88    if let (Some(flag), Some(p)) = (spec.filename_flag, path) {
89        command.arg(format!("{flag}={}", p.display()));
90    }
91    command
92        .stdin(Stdio::piped())
93        .stdout(Stdio::piped())
94        .stderr(Stdio::piped());
95
96    let mut child = match command.spawn() {
97        Ok(c) => c,
98        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
99            return Err(FormatError::NotFound { program });
100        }
101        Err(e) => {
102            return Err(FormatError::Io {
103                program,
104                message: e.to_string(),
105            });
106        }
107    };
108
109    // Write the buffer and close stdin, or the formatter waits forever
110    // for input that is already all there.
111    if let Some(mut stdin) = child.stdin.take() {
112        // A formatter that exits early (bad input) closes the pipe
113        // while we are still writing, which surfaces as a broken pipe.
114        // That is not an I/O failure worth reporting — the real error
115        // is on stderr, and `wait_with_output` below will collect it.
116        let _ = stdin.write_all(input.as_bytes());
117    }
118
119    // Poll rather than block so the timeout can actually fire.
120    let started = Instant::now();
121    loop {
122        match child.try_wait() {
123            Ok(Some(_)) => break,
124            Ok(None) => {
125                if started.elapsed() >= FORMAT_TIMEOUT {
126                    let _ = child.kill();
127                    let _ = child.wait();
128                    return Err(FormatError::TimedOut { program });
129                }
130                std::thread::sleep(Duration::from_millis(5));
131            }
132            Err(e) => {
133                return Err(FormatError::Io {
134                    program,
135                    message: e.to_string(),
136                });
137            }
138        }
139    }
140
141    let output = match child.wait_with_output() {
142        Ok(o) => o,
143        Err(e) => {
144            return Err(FormatError::Io {
145                program,
146                message: e.to_string(),
147            });
148        }
149    };
150    if !output.status.success() {
151        return Err(FormatError::Failed {
152            program,
153            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
154        });
155    }
156    String::from_utf8(output.stdout).map_err(|_| FormatError::NotUtf8 { program })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    /// Write an executable script into a temp dir and return a spec
164    /// pointing at it.
165    ///
166    /// Every formatter test uses one of these rather than a real tool:
167    /// CI must not depend on `rustfmt` or `prettier` being installed,
168    /// and a fake lets the failure modes (non-zero exit, hang, garbage
169    /// output) be produced on demand instead of hoped for.
170    ///
171    /// Unix only, and so is every test that uses it: the fake is a `#!/bin/sh`
172    /// script, which Windows cannot execute ("not a valid Win32 application").
173    /// What these tests pin — exit status, timeout, stderr, UTF-8 — is the
174    /// runner's handling of a child process, which is the same code on every
175    /// platform; the not-found path below needs no fake and runs everywhere.
176    #[cfg(unix)]
177    fn fake(name: &str, body: &str) -> (tempfile::TempDir, FormatterSpec) {
178        use std::os::unix::fs::PermissionsExt;
179        let dir = tempfile::tempdir().expect("tempdir");
180        let path = dir.path().join(name);
181        std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).expect("write script");
182        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).expect("chmod");
183        // Run as `/bin/sh <script>`, not by exec'ing the script. A file that
184        // was just written cannot be exec'd while ANY process still holds it
185        // open for writing (ETXTBSY), and a concurrent test's `fork` inherits
186        // this one's write descriptor for the instant before it closes — so
187        // under the parallel test runner the fake occasionally failed to
188        // start, and the hang test saw an `Io` error where it wanted a
189        // timeout. A shell only READS the script, which is never refused.
190        let script: &'static str = Box::leak(path.to_string_lossy().into_owned().into_boxed_str());
191        let args: &'static [&'static str] = Box::leak(vec![script].into_boxed_slice());
192        (
193            dir,
194            FormatterSpec {
195                program: "/bin/sh",
196                args,
197                filename_flag: None,
198            },
199        )
200    }
201
202    #[cfg(unix)]
203    #[test]
204    fn a_successful_run_returns_stdout() {
205        let (_d, spec) = fake("ok", "sed 's/a/b/g'");
206        assert_eq!(run(&spec, "aaa\n", None).unwrap(), "bbb\n");
207    }
208
209    #[test]
210    fn a_missing_program_is_reported_as_not_found_and_is_not_noteworthy() {
211        let spec = FormatterSpec {
212            program: "definitely-not-a-real-formatter-xyz",
213            args: &[],
214            filename_flag: None,
215        };
216        let err = run(&spec, "x", None).unwrap_err();
217        assert!(matches!(err, FormatError::NotFound { .. }));
218        assert!(
219            !err.is_noteworthy(),
220            "an uninstalled tool must not nag on every save"
221        );
222    }
223
224    #[cfg(unix)]
225    #[test]
226    fn a_non_zero_exit_carries_stderr_to_the_user() {
227        let (_d, spec) = fake("bad", "echo 'syntax error on line 3' >&2; exit 1");
228        let err = run(&spec, "x", None).unwrap_err();
229        match &err {
230            FormatError::Failed { stderr, .. } => {
231                assert!(stderr.contains("syntax error on line 3"))
232            }
233            other => panic!("expected Failed, got {other:?}"),
234        }
235        assert!(err.is_noteworthy());
236        assert!(err.message().contains("syntax error on line 3"));
237    }
238
239    #[cfg(unix)]
240    #[test]
241    fn a_hanging_formatter_is_killed_at_the_timeout() {
242        let (_d, spec) = fake("hang", "sleep 30");
243        let started = Instant::now();
244        let err = run(&spec, "x", None).unwrap_err();
245        assert!(matches!(err, FormatError::TimedOut { .. }));
246        assert!(
247            started.elapsed() < FORMAT_TIMEOUT + Duration::from_secs(2),
248            "must not wait for the child to finish on its own"
249        );
250    }
251
252    #[cfg(unix)]
253    #[test]
254    fn invalid_utf8_output_is_refused_rather_than_spliced() {
255        // Octal, not `\\xff`: the fake runs under `/bin/sh`, and dash (Ubuntu's)
256        // has no hex escapes in `printf` — it emits the text `\xff\xfe`, which
257        // is valid UTF-8, so the formatter "succeeded" and the test failed on CI.
258        let (_d, spec) = fake("garbage", "printf '\\377\\376'");
259        assert!(matches!(
260            run(&spec, "x", None).unwrap_err(),
261            FormatError::NotUtf8 { .. }
262        ));
263    }
264
265    #[cfg(unix)]
266    #[test]
267    fn the_filename_flag_is_passed_when_the_spec_asks_for_it() {
268        let (_d, mut spec) = fake("echoargs", "cat >/dev/null; echo \"$1\"");
269        spec.filename_flag = Some("--name");
270        let out = run(&spec, "x", Some(Path::new("/tmp/a.ts"))).unwrap();
271        assert_eq!(out.trim(), "--name=/tmp/a.ts");
272    }
273}