cargo_fixit/ops/
fixit.rs

1use std::{
2    collections::HashSet,
3    env,
4    io::{BufRead, BufReader, Cursor},
5    path::Path,
6    process::Stdio,
7};
8
9use cargo_util::paths;
10use clap::Parser;
11use indexmap::{IndexMap, IndexSet};
12use rustfix::{collect_suggestions, CodeFix, Suggestion};
13use tracing::{trace, warn};
14
15use crate::{
16    core::{shell, sysroot::get_sysroot},
17    ops::check::{BuildUnit, CheckOutput, Message},
18    util::{
19        cli::CheckFlags, messages::gen_please_report_this_bug_text, package::format_package_id,
20        vcs::VcsOpts,
21    },
22    CargoResult,
23};
24
25#[derive(Debug, Parser)]
26pub struct FixitArgs {
27    /// Run `clippy` instead of `check`
28    #[arg(long)]
29    clippy: bool,
30
31    /// Fix code even if it already has compiler errors
32    #[arg(long)]
33    broken_code: bool,
34
35    #[command(flatten)]
36    color: colorchoice_clap::Color,
37
38    #[command(flatten)]
39    vcs_opts: VcsOpts,
40
41    #[command(flatten)]
42    check_flags: CheckFlags,
43}
44
45impl FixitArgs {
46    pub fn exec(self) -> CargoResult<()> {
47        exec(self)
48    }
49}
50
51#[derive(Debug, Default)]
52struct File {
53    fixes: u32,
54    original_source: String,
55}
56
57#[tracing::instrument(skip_all)]
58fn exec(args: FixitArgs) -> CargoResult<()> {
59    args.color.write_global();
60
61    args.vcs_opts.valid_vcs()?;
62
63    let mut files: IndexMap<String, File> = IndexMap::new();
64
65    let max_iterations: usize = env::var("CARGO_FIX_MAX_RETRIES")
66        .ok()
67        .and_then(|i| i.parse().ok())
68        .unwrap_or(4);
69    let mut iteration = 0;
70
71    let mut last_errors = IndexMap::new();
72    let mut current_target: Option<BuildUnit> = None;
73    let mut seen = HashSet::new();
74
75    loop {
76        trace!("iteration={iteration}");
77        trace!("current_target={current_target:?}");
78        let (messages, exit_code) = check(&args)?;
79
80        if !args.broken_code && exit_code != Some(0) {
81            let mut out = String::new();
82
83            if current_target.is_some() {
84                out.push_str(
85                    "failed to automatically apply fixes suggested by rustc\n\n\
86                    after fixes were automatically applied the \
87                    compiler reported errors within these files:\n\n",
88                );
89
90                for (
91                    file,
92                    File {
93                        fixes: _,
94                        original_source,
95                    },
96                ) in &files
97                {
98                    out.push_str(&format!("  * {file}\n"));
99                    shell::note(format!("reverting `{file}` to its original state"))?;
100                    paths::write(file, original_source)?;
101                }
102                out.push('\n');
103
104                out.push_str(&gen_please_report_this_bug_text(args.clippy));
105
106                let mut errors = messages
107                    .filter_map(|e| match e {
108                        CheckOutput::Message(m) => m.message.rendered,
109                        _ => None,
110                    })
111                    .peekable();
112                if errors.peek().is_some() {
113                    out.push_str("The errors reported are:\n");
114                }
115
116                for e in errors {
117                    out.push_str(&format!("{}\n\n", e.trim_end()));
118                }
119
120                let (messages, _) = check(&args)?;
121                let mut errors = messages
122                    .filter_map(|e| match e {
123                        CheckOutput::Message(m) => m.message.rendered,
124                        _ => None,
125                    })
126                    .peekable();
127
128                if errors.peek().is_some() {
129                    out.push_str("The original errors are:\n");
130                }
131
132                for e in errors {
133                    out.push_str(&format!("{}\n\n", e.trim_end()));
134                }
135
136                shell::warn(out)?;
137            } else {
138                for e in messages.filter_map(|e| match e {
139                    CheckOutput::Message(m) => m.message.rendered,
140                    _ => None,
141                }) {
142                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
143                }
144            }
145
146            shell::note("try using `--broken-code` to fix errors")?;
147            anyhow::bail!("could not compile");
148        }
149
150        let (mut errors, build_unit_map) = collect_errors(messages, &seen);
151
152        if iteration >= max_iterations {
153            if let Some(target) = current_target {
154                if seen.iter().all(|b| b.package_id != target.package_id) {
155                    shell::status("Checking", format_package_id(&target.package_id)?)?;
156                }
157
158                for (name, file) in files {
159                    shell::fixed(name, file.fixes)?;
160                }
161                files = IndexMap::new();
162
163                let mut errors = errors.shift_remove(&target).unwrap_or_else(IndexSet::new);
164
165                if let Some(e) = build_unit_map.get(&target) {
166                    for (_, e) in e.iter().flat_map(|(_, s)| s) {
167                        let Some(e) = e else {
168                            continue;
169                        };
170                        errors.insert(e.to_owned());
171                    }
172                }
173                for e in errors {
174                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
175                }
176
177                seen.insert(target);
178                current_target = None;
179                iteration = 0;
180            } else {
181                break;
182            }
183        }
184
185        let mut made_changes = false;
186
187        for (build_unit, file_map) in build_unit_map {
188            if seen.contains(&build_unit) {
189                continue;
190            }
191
192            let build_unit_errors = errors
193                .entry(build_unit.clone())
194                .or_insert_with(IndexSet::new);
195
196            if current_target.is_none() && file_map.is_empty() {
197                if seen.iter().all(|b| b.package_id != build_unit.package_id) {
198                    shell::status("Checking", format_package_id(&build_unit.package_id)?)?;
199                }
200                for e in build_unit_errors.iter() {
201                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
202                }
203                errors.shift_remove(&build_unit);
204
205                seen.insert(build_unit);
206            } else if !file_map.is_empty()
207                && current_target.get_or_insert(build_unit.clone()) == &build_unit
208                && fix_errors(&mut files, file_map, build_unit_errors)?
209            {
210                made_changes = true;
211                break;
212            }
213        }
214
215        trace!("made_changes={made_changes:?}");
216        trace!("current_target={current_target:?}");
217
218        last_errors = errors;
219        iteration += 1;
220
221        if !made_changes {
222            if let Some(pkg) = current_target {
223                if seen.iter().all(|b| b.package_id != pkg.package_id) {
224                    shell::status("Checking", format_package_id(&pkg.package_id)?)?;
225                }
226
227                for (name, file) in files {
228                    shell::fixed(name, file.fixes)?;
229                }
230                files = IndexMap::new();
231
232                let errors = last_errors.shift_remove(&pkg).unwrap_or_else(IndexSet::new);
233                for e in errors {
234                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
235                }
236
237                seen.insert(pkg);
238                current_target = None;
239                iteration = 0;
240            } else {
241                break;
242            }
243        }
244    }
245
246    for (name, file) in files {
247        shell::fixed(name, file.fixes)?;
248    }
249
250    for e in last_errors.iter().flat_map(|(_, e)| e) {
251        shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
252    }
253
254    Ok(())
255}
256
257fn check(args: &FixitArgs) -> CargoResult<(impl Iterator<Item = CheckOutput>, Option<i32>)> {
258    let cmd = if args.clippy { "clippy" } else { "check" };
259    let command = std::process::Command::new(env!("CARGO"))
260        .args([cmd, "--message-format", "json-diagnostic-rendered-ansi"])
261        .args(args.check_flags.to_flags())
262        // This allows `cargo fix` to work even if the crate has #[deny(warnings)].
263        .env(
264            "RUSTFLAGS",
265            format!(
266                "--cap-lints=warn {}",
267                env::var("RUSTFLAGS").unwrap_or("".to_owned())
268            ),
269        )
270        .stderr(Stdio::piped())
271        .stdout(Stdio::piped())
272        .output()?;
273
274    let buf = BufReader::new(Cursor::new(command.stdout));
275
276    Ok((
277        buf.lines()
278            .map_while(|l| l.ok())
279            .filter_map(|l| serde_json::from_str(&l).ok()),
280        command.status.code(),
281    ))
282}
283
284#[tracing::instrument(skip_all)]
285#[allow(clippy::type_complexity)]
286fn collect_errors(
287    messages: impl Iterator<Item = CheckOutput>,
288    seen: &HashSet<BuildUnit>,
289) -> (
290    IndexMap<BuildUnit, IndexSet<String>>,
291    IndexMap<BuildUnit, IndexMap<String, IndexSet<(Suggestion, Option<String>)>>>,
292) {
293    let only = HashSet::new();
294    let mut build_unit_map = IndexMap::new();
295
296    let mut errors = IndexMap::new();
297
298    for message in messages {
299        let Message {
300            build_unit,
301            message: diagnostic,
302        } = match message {
303            CheckOutput::Message(m) => m,
304            CheckOutput::Artifact(a) => {
305                if !seen.contains(&a.build_unit) && !a.fresh {
306                    build_unit_map
307                        .entry(a.build_unit.clone())
308                        .or_insert(IndexMap::new());
309                }
310                continue;
311            }
312        };
313
314        let errors = errors
315            .entry(build_unit.clone())
316            .or_insert_with(IndexSet::new);
317
318        if seen.contains(&build_unit) {
319            trace!("rejecting build unit `{:?}` already seen", build_unit);
320            continue;
321        }
322
323        let file_map = build_unit_map
324            .entry(build_unit.clone())
325            .or_insert(IndexMap::new());
326
327        let filter = if env::var("__CARGO_FIX_YOLO").is_ok() {
328            rustfix::Filter::Everything
329        } else {
330            rustfix::Filter::MachineApplicableOnly
331        };
332
333        let Some(suggestion) = collect_suggestions(&diagnostic, &only, filter) else {
334            trace!("rejecting as not a MachineApplicable diagnosis: {diagnostic:?}");
335            if let Some(rendered) = diagnostic.rendered {
336                errors.insert(rendered);
337            }
338            continue;
339        };
340
341        let mut file_names = suggestion
342            .solutions
343            .iter()
344            .flat_map(|s| s.replacements.iter())
345            .map(|r| &r.snippet.file_name);
346
347        let Some(file_name) = file_names.next() else {
348            trace!("rejecting as it has no solutions {:?}", suggestion);
349            if let Some(rendered) = diagnostic.rendered {
350                errors.insert(rendered);
351            }
352            continue;
353        };
354
355        if !file_names.all(|f| f == file_name) {
356            trace!("rejecting as it changes multiple files: {:?}", suggestion);
357            if let Some(rendered) = diagnostic.rendered {
358                errors.insert(rendered);
359            }
360            continue;
361        }
362
363        let file_path = Path::new(&file_name);
364        // Do not write into registry cache. See rust-lang/cargo#9857.
365        if let Ok(home) = env::var("CARGO_HOME") {
366            if file_path.starts_with(home) {
367                if let Some(rendered) = diagnostic.rendered {
368                    errors.insert(rendered);
369                }
370                continue;
371            }
372        }
373
374        if let Some(sysroot) = get_sysroot() {
375            if file_path.starts_with(sysroot) {
376                if let Some(rendered) = diagnostic.rendered {
377                    errors.insert(rendered);
378                }
379                continue;
380            }
381        }
382
383        file_map
384            .entry(file_name.to_owned())
385            .or_insert_with(IndexSet::new)
386            .insert((suggestion, diagnostic.rendered));
387    }
388
389    (errors, build_unit_map)
390}
391
392#[tracing::instrument(skip_all)]
393fn fix_errors(
394    files: &mut IndexMap<String, File>,
395    file_map: IndexMap<String, IndexSet<(Suggestion, Option<String>)>>,
396    errors: &mut IndexSet<String>,
397) -> CargoResult<bool> {
398    let mut made_changes = false;
399    for (file, suggestions) in file_map {
400        let source = match paths::read(file.as_ref()) {
401            Ok(s) => s,
402            Err(e) => {
403                warn!("failed to read `{}`: {}", file, e);
404                errors.extend(suggestions.iter().filter_map(|(_, e)| e.clone()));
405                continue;
406            }
407        };
408
409        let mut fixed = CodeFix::new(&source);
410        let mut num_fixes = 0;
411
412        for (suggestion, rendered) in suggestions.iter().rev() {
413            match fixed.apply(suggestion) {
414                Ok(()) => num_fixes += 1,
415                Err(rustfix::Error::AlreadyReplaced {
416                    is_identical: true, ..
417                }) => {}
418                Err(e) => {
419                    if let Some(rendered) = rendered {
420                        errors.insert(rendered.to_owned());
421                    }
422                    warn!("{e:?}");
423                }
424            }
425        }
426        if fixed.modified() {
427            let new_source = fixed.finish()?;
428            paths::write(&file, new_source)?;
429            made_changes = true;
430            files
431                .entry(file)
432                .or_insert(File {
433                    fixes: 0,
434                    original_source: source,
435                })
436                .fixes += num_fixes;
437        }
438    }
439
440    Ok(made_changes)
441}