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,
17    ops::check::{BuildUnit, CheckOutput, Message},
18    util::{cli::CheckFlags, package::format_package_id, vcs::VcsOpts},
19    CargoResult,
20};
21
22#[derive(Debug, Parser)]
23pub struct FixitArgs {
24    /// Run `clippy` instead of `check`
25    #[arg(long)]
26    clippy: bool,
27
28    #[command(flatten)]
29    vcs_opts: VcsOpts,
30
31    #[command(flatten)]
32    check_flags: CheckFlags,
33}
34
35impl FixitArgs {
36    pub fn exec(self) -> CargoResult<()> {
37        exec(self)
38    }
39}
40
41#[derive(Debug, Default)]
42struct File {
43    fixes: u32,
44}
45
46#[tracing::instrument(skip_all)]
47fn exec(args: FixitArgs) -> CargoResult<()> {
48    args.vcs_opts.valid_vcs()?;
49
50    let mut files: IndexMap<String, File> = IndexMap::new();
51
52    let max_iterations: usize = env::var("CARGO_FIX_MAX_RETRIES")
53        .ok()
54        .and_then(|i| i.parse().ok())
55        .unwrap_or(4);
56    let mut iteration = 0;
57
58    let mut last_errors = IndexMap::new();
59    let mut current_target: Option<BuildUnit> = None;
60    let mut seen = HashSet::new();
61
62    loop {
63        trace!("iteration={iteration}");
64        trace!("current_target={current_target:?}");
65        let (messages, _exit_code) = check(&args)?;
66
67        let (mut errors, build_unit_map) = collect_errors(messages, &seen);
68
69        if iteration >= max_iterations {
70            if let Some(target) = current_target {
71                if seen.iter().all(|b| b.package_id != target.package_id) {
72                    shell::status("Checking", format_package_id(&target.package_id)?)?;
73                }
74
75                for (name, file) in files {
76                    shell::fixed(name, file.fixes)?;
77                }
78                files = IndexMap::new();
79
80                let mut errors = errors.shift_remove(&target).unwrap_or_else(IndexSet::new);
81
82                if let Some(e) = build_unit_map.get(&target) {
83                    for (_, e) in e.iter().flat_map(|(_, s)| s) {
84                        let Some(e) = e else {
85                            continue;
86                        };
87                        errors.insert(e.to_owned());
88                    }
89                }
90                for e in errors {
91                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
92                }
93
94                seen.insert(target);
95                current_target = None;
96                iteration = 0;
97            } else {
98                break;
99            }
100        }
101
102        let mut made_changes = false;
103
104        for (build_unit, file_map) in build_unit_map {
105            if seen.contains(&build_unit) {
106                continue;
107            }
108
109            let build_unit_errors = errors
110                .entry(build_unit.clone())
111                .or_insert_with(IndexSet::new);
112
113            if current_target.is_none() && file_map.is_empty() {
114                if seen.iter().all(|b| b.package_id != build_unit.package_id) {
115                    shell::status("Checking", format_package_id(&build_unit.package_id)?)?;
116                }
117                for e in build_unit_errors.iter() {
118                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
119                }
120                errors.shift_remove(&build_unit);
121
122                seen.insert(build_unit);
123            } else if !file_map.is_empty()
124                && current_target.get_or_insert(build_unit.clone()) == &build_unit
125                && fix_errors(&mut files, file_map, build_unit_errors)?
126            {
127                made_changes = true;
128                break;
129            }
130        }
131
132        trace!("made_changes={made_changes:?}");
133        trace!("current_target={current_target:?}");
134
135        last_errors = errors;
136        iteration += 1;
137
138        if !made_changes {
139            if let Some(pkg) = current_target {
140                if seen.iter().all(|b| b.package_id != pkg.package_id) {
141                    shell::status("Checking", format_package_id(&pkg.package_id)?)?;
142                }
143
144                for (name, file) in files {
145                    shell::fixed(name, file.fixes)?;
146                }
147                files = IndexMap::new();
148
149                let errors = last_errors.shift_remove(&pkg).unwrap_or_else(IndexSet::new);
150                for e in errors {
151                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
152                }
153
154                seen.insert(pkg);
155                current_target = None;
156                iteration = 0;
157            } else {
158                break;
159            }
160        }
161    }
162
163    for (name, file) in files {
164        shell::fixed(name, file.fixes)?;
165    }
166
167    for e in last_errors.iter().flat_map(|(_, e)| e) {
168        shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
169    }
170
171    Ok(())
172}
173
174fn check(args: &FixitArgs) -> CargoResult<(impl Iterator<Item = CheckOutput>, Option<i32>)> {
175    let cmd = if args.clippy { "clippy" } else { "check" };
176    let command = std::process::Command::new(env!("CARGO"))
177        .args([cmd, "--message-format", "json-diagnostic-rendered-ansi"])
178        .args(args.check_flags.to_flags())
179        // This allows `cargo fix` to work even if the crate has #[deny(warnings)].
180        .env("RUSTFLAGS", "--cap-lints=warn")
181        .stderr(Stdio::piped())
182        .stdout(Stdio::piped())
183        .output()?;
184
185    let buf = BufReader::new(Cursor::new(command.stdout));
186
187    Ok((
188        buf.lines()
189            .map_while(|l| l.ok())
190            .filter_map(|l| serde_json::from_str(&l).ok()),
191        command.status.code(),
192    ))
193}
194
195#[tracing::instrument(skip_all)]
196#[allow(clippy::type_complexity)]
197fn collect_errors(
198    messages: impl Iterator<Item = CheckOutput>,
199    seen: &HashSet<BuildUnit>,
200) -> (
201    IndexMap<BuildUnit, IndexSet<String>>,
202    IndexMap<BuildUnit, IndexMap<String, IndexSet<(Suggestion, Option<String>)>>>,
203) {
204    let only = HashSet::new();
205    let mut build_unit_map = IndexMap::new();
206
207    let mut errors = IndexMap::new();
208
209    for message in messages {
210        let Message {
211            build_unit,
212            message: diagnostic,
213        } = match message {
214            CheckOutput::Message(m) => m,
215            CheckOutput::Artifact(a) => {
216                if !seen.contains(&a.build_unit) && !a.fresh {
217                    build_unit_map
218                        .entry(a.build_unit.clone())
219                        .or_insert(IndexMap::new());
220                }
221                continue;
222            }
223        };
224
225        let errors = errors
226            .entry(build_unit.clone())
227            .or_insert_with(IndexSet::new);
228
229        if seen.contains(&build_unit) {
230            trace!("rejecting build unit `{:?}` already seen", build_unit);
231            continue;
232        }
233
234        let file_map = build_unit_map
235            .entry(build_unit.clone())
236            .or_insert(IndexMap::new());
237
238        let filter = if env::var("__CARGO_FIX_YOLO").is_ok() {
239            rustfix::Filter::Everything
240        } else {
241            rustfix::Filter::MachineApplicableOnly
242        };
243
244        let Some(suggestion) = collect_suggestions(&diagnostic, &only, filter) else {
245            trace!("rejecting as not a MachineApplicable diagnosis: {diagnostic:?}");
246            if let Some(rendered) = diagnostic.rendered {
247                errors.insert(rendered);
248            }
249            continue;
250        };
251
252        let mut file_names = suggestion
253            .solutions
254            .iter()
255            .flat_map(|s| s.replacements.iter())
256            .map(|r| &r.snippet.file_name);
257
258        let Some(file_name) = file_names.next() else {
259            trace!("rejecting as it has no solutions {:?}", suggestion);
260            if let Some(rendered) = diagnostic.rendered {
261                errors.insert(rendered);
262            }
263            continue;
264        };
265
266        if !file_names.all(|f| f == file_name) {
267            trace!("rejecting as it changes multiple files: {:?}", suggestion);
268            if let Some(rendered) = diagnostic.rendered {
269                errors.insert(rendered);
270            }
271            continue;
272        }
273
274        let file_path = Path::new(&file_name);
275        // Do not write into registry cache. See rust-lang/cargo#9857.
276        if let Ok(home) = env::var("CARGO_HOME") {
277            if file_path.starts_with(home) {
278                continue;
279            }
280        }
281
282        file_map
283            .entry(file_name.to_owned())
284            .or_insert_with(IndexSet::new)
285            .insert((suggestion, diagnostic.rendered));
286    }
287
288    (errors, build_unit_map)
289}
290
291#[tracing::instrument(skip_all)]
292fn fix_errors(
293    files: &mut IndexMap<String, File>,
294    file_map: IndexMap<String, IndexSet<(Suggestion, Option<String>)>>,
295    errors: &mut IndexSet<String>,
296) -> CargoResult<bool> {
297    let mut made_changes = false;
298    for (file, suggestions) in file_map {
299        let code = match paths::read(file.as_ref()) {
300            Ok(s) => s,
301            Err(e) => {
302                warn!("failed to read `{}`: {}", file, e);
303                errors.extend(suggestions.iter().filter_map(|(_, e)| e.clone()));
304                continue;
305            }
306        };
307
308        let mut fixed = CodeFix::new(&code);
309        let mut num_fixes = 0;
310
311        for (suggestion, rendered) in suggestions.iter().rev() {
312            match fixed.apply(suggestion) {
313                Ok(()) => num_fixes += 1,
314                Err(rustfix::Error::AlreadyReplaced {
315                    is_identical: true, ..
316                }) => {}
317                Err(e) => {
318                    if let Some(rendered) = rendered {
319                        errors.insert(rendered.to_owned());
320                    }
321                    warn!("{e:?}");
322                }
323            }
324        }
325        if fixed.modified() {
326            let new_code = fixed.finish()?;
327            paths::write(&file, new_code)?;
328            made_changes = true;
329            files.entry(file).or_default().fixes += num_fixes;
330        }
331    }
332
333    Ok(made_changes)
334}