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::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;
59
60    let mut current_target = None;
61    let mut seen = HashSet::new();
62
63    loop {
64        trace!("iteration={iteration}");
65        trace!("current_target={current_target:?}");
66        let (messages, _exit_code) = check(&args)?;
67
68        let (mut errors, build_unit_map) = collect_errors(messages, &seen);
69
70        let mut made_changes = false;
71
72        for (build_unit, file_map) in build_unit_map {
73            if current_target.is_none() && file_map.is_empty() {
74                if seen.iter().all(|b| b.package_id != build_unit.package_id) {
75                    shell::status("Checking", format_package_id(&build_unit.package_id)?)?;
76                }
77                seen.insert(build_unit);
78            } else if !file_map.is_empty()
79                && current_target.get_or_insert(build_unit.clone()) == &build_unit
80                && fix_errors(&mut files, file_map, &mut errors)?
81            {
82                made_changes = true;
83                break;
84            }
85        }
86
87        trace!("made_changes={made_changes:?}");
88        trace!("current_target={current_target:?}");
89
90        last_errors = errors;
91        iteration += 1;
92
93        if !made_changes || iteration >= max_iterations {
94            if let Some(pkg) = current_target {
95                if seen.iter().all(|b| b.package_id != pkg.package_id) {
96                    shell::status("Fixed", format_package_id(&pkg.package_id)?)?;
97                }
98                seen.insert(pkg);
99                current_target = None;
100                iteration = 0;
101            } else {
102                break;
103            }
104        }
105    }
106    for (name, file) in files {
107        shell::fixed(name, file.fixes)?;
108    }
109
110    for e in last_errors {
111        shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
112    }
113
114    Ok(())
115}
116
117fn check(args: &FixitArgs) -> CargoResult<(impl Iterator<Item = CheckOutput>, Option<i32>)> {
118    let cmd = if args.clippy { "clippy" } else { "check" };
119    let command = std::process::Command::new(env!("CARGO"))
120        .args([cmd, "--message-format", "json-diagnostic-rendered-ansi"])
121        .args(args.check_flags.to_flags())
122        .stderr(Stdio::piped())
123        .stdout(Stdio::piped())
124        .output()?;
125
126    let buf = BufReader::new(Cursor::new(command.stdout));
127
128    Ok((
129        buf.lines()
130            .map_while(|l| l.ok())
131            .filter_map(|l| serde_json::from_str(&l).ok()),
132        command.status.code(),
133    ))
134}
135
136#[tracing::instrument(skip_all)]
137#[allow(clippy::type_complexity)]
138fn collect_errors(
139    messages: impl Iterator<Item = CheckOutput>,
140    seen: &HashSet<BuildUnit>,
141) -> (
142    IndexSet<String>,
143    IndexMap<BuildUnit, IndexMap<String, IndexSet<(Suggestion, Option<String>)>>>,
144) {
145    let only = HashSet::new();
146    let mut build_unit_map = IndexMap::new();
147
148    let mut errors = IndexSet::new();
149
150    for message in messages {
151        let Message {
152            build_unit,
153            message: diagnostic,
154        } = match message {
155            CheckOutput::Message(m) => m,
156            CheckOutput::Artifact(a) => {
157                if !seen.contains(&a.build_unit) && !a.fresh {
158                    build_unit_map
159                        .entry(a.build_unit.clone())
160                        .or_insert(IndexMap::new());
161                }
162                continue;
163            }
164        };
165
166        if seen.contains(&build_unit) {
167            trace!("rejecting build unit `{:?}` already seen", build_unit);
168            if let Some(rendered) = diagnostic.rendered {
169                errors.insert(rendered);
170            }
171            continue;
172        }
173
174        let file_map = build_unit_map
175            .entry(build_unit.clone())
176            .or_insert(IndexMap::new());
177
178        let filter = if env::var("__CARGO_FIX_YOLO").is_ok() {
179            rustfix::Filter::Everything
180        } else {
181            rustfix::Filter::MachineApplicableOnly
182        };
183
184        let Some(suggestion) = collect_suggestions(&diagnostic, &only, filter) else {
185            trace!("rejecting as not a MachineApplicable diagnosis: {diagnostic:?}");
186            if let Some(rendered) = diagnostic.rendered {
187                errors.insert(rendered);
188            }
189            continue;
190        };
191
192        let mut file_names = suggestion
193            .solutions
194            .iter()
195            .flat_map(|s| s.replacements.iter())
196            .map(|r| &r.snippet.file_name);
197
198        let Some(file_name) = file_names.next() else {
199            trace!("rejecting as it has no solutions {:?}", suggestion);
200            if let Some(rendered) = diagnostic.rendered {
201                errors.insert(rendered);
202            }
203            continue;
204        };
205
206        if !file_names.all(|f| f == file_name) {
207            trace!("rejecting as it changes multiple files: {:?}", suggestion);
208            if let Some(rendered) = diagnostic.rendered {
209                errors.insert(rendered);
210            }
211            continue;
212        }
213
214        let file_path = Path::new(&file_name);
215        // Do not write into registry cache. See rust-lang/cargo#9857.
216        if let Ok(home) = env::var("CARGO_HOME") {
217            if file_path.starts_with(home) {
218                continue;
219            }
220        }
221
222        file_map
223            .entry(file_name.to_owned())
224            .or_insert_with(IndexSet::new)
225            .insert((suggestion, diagnostic.rendered));
226    }
227
228    (errors, build_unit_map)
229}
230
231#[tracing::instrument(skip_all)]
232fn fix_errors(
233    files: &mut IndexMap<String, File>,
234    file_map: IndexMap<String, IndexSet<(Suggestion, Option<String>)>>,
235    errors: &mut IndexSet<String>,
236) -> CargoResult<bool> {
237    let mut made_changes = false;
238    for (file, suggestions) in file_map {
239        let code = match paths::read(file.as_ref()) {
240            Ok(s) => s,
241            Err(e) => {
242                warn!("failed to read `{}`: {}", file, e);
243                errors.extend(suggestions.iter().filter_map(|(_, e)| e.clone()));
244                continue;
245            }
246        };
247
248        let mut fixed = CodeFix::new(&code);
249        let mut num_fixes = 0;
250
251        for (suggestion, rendered) in suggestions.iter().rev() {
252            match fixed.apply(suggestion) {
253                Ok(()) => num_fixes += 1,
254                Err(rustfix::Error::AlreadyReplaced {
255                    is_identical: true, ..
256                }) => {}
257                Err(e) => {
258                    if let Some(rendered) = rendered {
259                        errors.insert(rendered.to_owned());
260                    }
261                    warn!("{e:?}");
262                }
263            }
264        }
265        if fixed.modified() {
266            let new_code = fixed.finish()?;
267            paths::write(&file, new_code)?;
268            made_changes = true;
269            files.entry(file).or_default().fixes += num_fixes;
270        }
271    }
272
273    Ok(made_changes)
274}