返回 CodeWhale
rust_format.rs
根目录 / crates / tui / src / tools / rust_format.rs
1 //! Post-edit formatting normalization for Rust files (#6205).
2 //!
3 //! Model-generated edits rarely match `rustfmt` output exactly. Once an
4 //! unformatted edit lands, the *next* turn's `old_string` or patch context was
5 //! written against text that `cargo fmt` is about to move, so anchors drift and
6 //! the follow-up edit fails to match. Normalizing at edit time keeps anchors
7 //! stable for the rest of the session, and the tool result returns the
8 //! normalized text, so what the model remembers writing is what is on disk.
9 //!
10 //! `rustfmt` is the formatter, not `prettyplease`: `cargo fmt --check` is this
11 //! repository's actual gate, and a second formatter with its own opinions would
12 //! produce files that pass the edit path and fail the gate. Shelling out also
13 //! picks up the project's own `rustfmt.toml` — an in-process pretty-printer
14 //! cannot.
15 //!
16 //! # Policy
17 //!
18 //! Normalization applies to the whole edited file, and **only when that file
19 //! was already `rustfmt`-clean before the edit**. A file whose formatting the
20 //! author has not handed to `rustfmt` is never rewritten. Because a clean file
21 //! is a formatting fixpoint, reformatting it after an edit can only change the
22 //! edited region — so "whole file" and "edited region" coincide, without
23 //! needing span arithmetic to prove it.
24 //!
25 //! # Known limitations
26 //!
27 //! - **Non-fatal, always.** A missing `rustfmt`, a parse failure, a timeout, or
28 //! a non-zero exit skips normalization; the edit still lands. Nothing here
29 //! can fail an edit.
30 //! - **Silent when skipped.** Only an applied normalization is announced. A
31 //! "formatting skipped" note on every edit in a project without `rustfmt`
32 //! would be noise the model cannot act on, and "skipped" is indistinguishable
33 //! from "would have changed nothing" without running the formatter anyway.
34 //! - **Edition 2024 is assumed.** A file that `rustfmt` cannot parse under that
35 //! edition fails the clean-before check and is skipped, so the assumption
36 //! degrades to "no normalization", never to a mangled file.
37 //! - **CRLF files are skipped.** `rustfmt` emits LF; rewriting every line
38 //! ending is exactly the unrelated-churn this policy exists to avoid.
39
40 use std::path::{Path, PathBuf};
41 use std::process::Stdio;
42 use std::time::Duration;
43
44 use tokio::io::AsyncWriteExt;
45 use tokio::process::Command;
46
47 /// Files larger than this are not normalized. Two formatter runs on a
48 /// multi-megabyte file cost more interactive latency than stable anchors are
49 /// worth.
50 const MAX_FORMATTED_BYTES: usize = 1024 * 1024;
51
52 /// Wall-clock budget for one `rustfmt` run.
53 const FORMAT_TIMEOUT: Duration = Duration::from_secs(5);
54
55 /// Suffix appended to an edit summary when the content was normalized, so the
56 /// model knows the returned text is not byte-identical to what it sent.
57 pub(super) const NORMALIZED_NOTE: &str = " (rustfmt-normalized)";
58
59 /// Normalize `after` when the edit landed in an already-`rustfmt`-clean file.
60 ///
61 /// Returns the formatted text to write instead of `after`, or `None` to leave
62 /// `after` exactly as the caller produced it. Every failure path returns
63 /// `None`: normalization is a convenience and may never break an edit.
64 pub(super) async fn normalize_edit(path: &Path, before: &str, after: &str) -> Option<String> {
65 if path.extension()?.to_str()? != "rs" {
66 return None;
67 }
68 if after.len() > MAX_FORMATTED_BYTES || before.len() > MAX_FORMATTED_BYTES {
69 return None;
70 }
71 // Preserving a CRLF file's line endings outranks normalizing its layout.
72 if after.contains('\r') || before.contains('\r') {
73 return None;
74 }
75
76 let formatted = rustfmt(path, after).await?;
77 if formatted == after {
78 // Already canonical. The common case, and it costs one run, not two.
79 return None;
80 }
81 // Only now is the second run worth paying for: was this file the author's
82 // to format, or `rustfmt`'s?
83 if rustfmt(path, before).await? != before {
84 return None;
85 }
86 Some(formatted)
87 }
88
89 /// Find the `rustfmt.toml` governing `path`, walking up to the filesystem root.
90 ///
91 /// `None` means no config file exists, which is the signal to omit
92 /// `--config-path` entirely and let `rustfmt` use its defaults.
93 async fn nearest_config(path: &Path) -> Option<PathBuf> {
94 let mut directory = path.parent()?;
95 loop {
96 for name in ["rustfmt.toml", ".rustfmt.toml"] {
97 let candidate = directory.join(name);
98 if tokio::fs::try_exists(&candidate).await.unwrap_or(false) {
99 return Some(candidate);
100 }
101 }
102 directory = directory.parent()?;
103 }
104 }
105
106 /// Run `rustfmt` over `source`, returning its output, or `None` on any failure.
107 async fn rustfmt(path: &Path, source: &str) -> Option<String> {
108 let mut command = Command::new("rustfmt");
109 command
110 .arg("--emit")
111 .arg("stdout")
112 .arg("--edition")
113 .arg("2024")
114 .arg("--quiet");
115 // Pick up the project's own `rustfmt.toml`: with stdin input there is no
116 // file path for `rustfmt` to search upward from. The flag must name a
117 // config file that actually exists — pointed at a directory without one,
118 // `rustfmt` exits 1 with "unable to find a config file", which would
119 // silently disable normalization everywhere.
120 if let Some(config) = nearest_config(path).await {
121 command.arg("--config-path").arg(config);
122 }
123 let mut child = command
124 .stdin(Stdio::piped())
125 .stdout(Stdio::piped())
126 .stderr(Stdio::null())
127 // A hung formatter must not outlive the edit that spawned it.
128 .kill_on_drop(true)
129 .spawn()
130 .ok()?;
131
132 let mut stdin = child.stdin.take()?;
133 let payload = source.to_string();
134 // Write and wait concurrently: `rustfmt` streams its output, so writing the
135 // whole input before reading can deadlock on a full pipe buffer.
136 let writer = tokio::spawn(async move {
137 let _ = stdin.write_all(payload.as_bytes()).await;
138 let _ = stdin.shutdown().await;
139 });
140
141 let output = match tokio::time::timeout(FORMAT_TIMEOUT, child.wait_with_output()).await {
142 Ok(Ok(output)) => output,
143 // A timed-out child is already killed by dropping the future's handle
144 // on the `wait_with_output` path; either way the edit proceeds.
145 Ok(Err(_)) | Err(_) => {
146 writer.abort();
147 return None;
148 }
149 };
150 writer.abort();
151
152 if !output.status.success() {
153 return None;
154 }
155 String::from_utf8(output.stdout).ok()
156 }
157
158 #[cfg(test)]
159 mod tests {
160 use super::*;
161 use std::path::PathBuf;
162
163 fn rust_path() -> PathBuf {
164 PathBuf::from("src/lib.rs")
165 }
166
167 /// `rustfmt` ships with the toolchain this repository pins (rustup's
168 /// default profile), and `cargo fmt --check` is a standing gate here, so
169 /// its absence is a broken environment, not a reason to skip. A test that
170 /// passes vacuously without the formatter proves nothing — that exact
171 /// hazard hid a `--config-path` bug which disabled normalization
172 /// everywhere while the suite stayed green.
173 async fn require_rustfmt() {
174 assert_eq!(
175 rustfmt(&rust_path(), "fn main( ) {}\n").await.as_deref(),
176 Some("fn main() {}\n"),
177 "rustfmt must be on PATH for the formatting tests"
178 );
179 }
180
181 #[tokio::test]
182 async fn misformatted_edit_in_a_clean_file_is_normalized() {
183 require_rustfmt().await;
184 let before = "fn main() {\n let x = 1;\n}\n";
185 let after = "fn main() {\n let x = 1;\n let y=2;\n}\n";
186 let normalized = normalize_edit(&rust_path(), before, after)
187 .await
188 .expect("a clean file must be renormalized after a sloppy edit");
189 assert_eq!(
190 normalized,
191 "fn main() {\n let x = 1;\n let y = 2;\n}\n"
192 );
193 }
194
195 #[tokio::test]
196 async fn a_file_the_author_formats_by_hand_is_left_alone() {
197 require_rustfmt().await;
198 // `rustfmt` would rewrite this file wholesale, so it was never its to
199 // format: the edit lands verbatim.
200 let before = "fn main() {\n let x = 1;\n}\n";
201 let after = "fn main() {\n let x = 1;\n let y=2;\n}\n";
202 assert!(normalize_edit(&rust_path(), before, after).await.is_none());
203 }
204
205 #[tokio::test]
206 async fn already_canonical_content_needs_no_rewrite() {
207 require_rustfmt().await;
208 let before = "fn main() {\n let x = 1;\n}\n";
209 let after = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
210 assert!(normalize_edit(&rust_path(), before, after).await.is_none());
211 }
212
213 #[tokio::test]
214 async fn crlf_files_keep_their_line_endings() {
215 let before = "fn main() {\r\n let x = 1;\r\n}\r\n";
216 let after = "fn main() {\r\n let x = 1;\r\n}\r\n";
217 assert!(normalize_edit(&rust_path(), before, after).await.is_none());
218 }
219
220 #[tokio::test]
221 async fn non_rust_files_are_not_formatted() {
222 assert!(
223 normalize_edit(Path::new("data.json"), "{}", "{ }")
224 .await
225 .is_none()
226 );
227 }
228
229 #[tokio::test]
230 async fn unparseable_content_degrades_to_no_normalization() {
231 // `rustfmt` cannot parse this; the edit must still be allowed to land.
232 assert!(
233 normalize_edit(&rust_path(), "fn main() {}\n", "fn main( {\n")
234 .await
235 .is_none()
236 );
237 }
238 }
239
239 lines RUST