Skip to main content

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, DiagnosticLevel, Message, MessageDiagnostic},
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    let mut lint_cap = false;
71
72    let mut last_errors = IndexMap::new();
73    let mut current_target: Option<BuildUnit> = None;
74    let mut seen = HashSet::new();
75
76    loop {
77        trace!("iteration={iteration}");
78        trace!("current_target={current_target:?}");
79        let (messages, exit_code) = check(&args, &mut lint_cap)?;
80
81        if !args.broken_code && exit_code != Some(0) {
82            let mut out = String::new();
83
84            if current_target.is_some() {
85                out.push_str(
86                    "failed to automatically apply fixes suggested by rustc\n\n\
87                    after fixes were automatically applied the \
88                    compiler reported errors within these files:\n\n",
89                );
90
91                for (
92                    file,
93                    File {
94                        fixes: _,
95                        original_source,
96                    },
97                ) in &files
98                {
99                    out.push_str(&format!("  * {file}\n"));
100                    shell::note(format!("reverting `{file}` to its original state"))?;
101                    paths::write(file, original_source)?;
102                }
103                out.push('\n');
104
105                out.push_str(&gen_please_report_this_bug_text(args.clippy));
106
107                let mut errors = messages
108                    .into_iter()
109                    .filter_map(|e| match e {
110                        CheckOutput::Message(m) => m.message.diagnostic.rendered,
111                        _ => None,
112                    })
113                    .peekable();
114                if errors.peek().is_some() {
115                    out.push_str("The errors reported are:\n");
116                }
117
118                for e in errors {
119                    out.push_str(&format!("{}\n\n", e.trim_end()));
120                }
121
122                let (messages, _) = check(&args, &mut lint_cap)?;
123                let mut errors = messages
124                    .into_iter()
125                    .filter_map(|e| match e {
126                        CheckOutput::Message(m) => m.message.diagnostic.rendered,
127                        _ => None,
128                    })
129                    .peekable();
130
131                if errors.peek().is_some() {
132                    out.push_str("The original errors are:\n");
133                }
134
135                for e in errors {
136                    out.push_str(&format!("{}\n\n", e.trim_end()));
137                }
138
139                shell::warn(out)?;
140            } else {
141                for e in messages.into_iter().filter_map(|e| match e {
142                    CheckOutput::Message(m) => m.message.diagnostic.rendered,
143                    _ => None,
144                }) {
145                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
146                }
147            }
148
149            shell::note("try using `--broken-code` to fix errors")?;
150            anyhow::bail!("could not compile");
151        }
152
153        let (mut errors, mut build_unit_map) = collect_errors(messages.into_iter(), &seen);
154
155        if iteration >= max_iterations {
156            if let Some(target) = current_target {
157                if let Some(file_map) = build_unit_map.get(&target) {
158                    let target_errors = errors.entry(target.clone()).or_default();
159                    target_errors.extend(
160                        file_map
161                            .values()
162                            .flatten()
163                            .filter_map(|(_, diagnostic)| diagnostic.clone()),
164                    );
165                }
166                finish_target(target, &mut files, &mut errors, &mut seen)?;
167                current_target = None;
168                iteration = 0;
169            } else {
170                break;
171            }
172        }
173
174        let mut finalized_target = false;
175        if let Some(target) = current_target.as_ref() {
176            if build_unit_map.get(target).is_none_or(IndexMap::is_empty) {
177                let target = current_target.take().expect("current target is present");
178                build_unit_map.shift_remove(&target);
179                finish_target(target, &mut files, &mut errors, &mut seen)?;
180                iteration = 0;
181                finalized_target = true;
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 finalized_target && build_unit_errors.is_empty() {
198                    continue;
199                }
200                if seen.iter().all(|b| b.package_id != build_unit.package_id) {
201                    shell::status("Checking", format_package_id(&build_unit.package_id)?)?;
202                }
203                for e in build_unit_errors.iter() {
204                    shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
205                }
206                errors.shift_remove(&build_unit);
207
208                seen.insert(build_unit);
209            } else if !file_map.is_empty()
210                && current_target.get_or_insert(build_unit.clone()) == &build_unit
211                && fix_errors(&mut files, file_map, build_unit_errors)?
212            {
213                made_changes = true;
214                break;
215            }
216        }
217
218        trace!("made_changes={made_changes:?}");
219        trace!("current_target={current_target:?}");
220
221        last_errors = errors;
222        iteration += 1;
223
224        if !made_changes {
225            if let Some(pkg) = current_target {
226                finish_target(pkg, &mut files, &mut last_errors, &mut seen)?;
227                current_target = None;
228                iteration = 0;
229                continue;
230            }
231            break;
232        }
233    }
234
235    for (name, file) in files {
236        shell::fixed(name, file.fixes)?;
237    }
238
239    for e in last_errors.iter().flat_map(|(_, e)| e) {
240        shell::print_ansi_stderr(format!("{}\n\n", e.trim_end()).as_bytes())?;
241    }
242
243    Ok(())
244}
245
246/// Marks a target complete after reporting its fixes and remaining diagnostics.
247fn finish_target(
248    target: BuildUnit,
249    files: &mut IndexMap<String, File>,
250    errors: &mut IndexMap<BuildUnit, IndexSet<String>>,
251    seen: &mut HashSet<BuildUnit>,
252) -> CargoResult<()> {
253    if seen
254        .iter()
255        .all(|build_unit| build_unit.package_id != target.package_id)
256    {
257        shell::status("Checking", format_package_id(&target.package_id)?)?;
258    }
259
260    for (name, file) in std::mem::take(files) {
261        shell::fixed(name, file.fixes)?;
262    }
263
264    for error in errors.shift_remove(&target).unwrap_or_default() {
265        shell::print_ansi_stderr(format!("{}\n\n", error.trim_end()).as_bytes())?;
266    }
267
268    seen.insert(target);
269    Ok(())
270}
271
272fn check(args: &FixitArgs, lint_cap: &mut bool) -> CargoResult<(Vec<CheckOutput>, Option<i32>)> {
273    let cmd = if args.clippy { "clippy" } else { "check" };
274    let mut command = std::process::Command::new(env!("CARGO"));
275    command
276        .args([cmd, "--message-format", "json-diagnostic-rendered-ansi"])
277        .args(args.check_flags.to_flags())
278        .stderr(Stdio::piped())
279        .stdout(Stdio::piped());
280    if *lint_cap {
281        cap_lints(&mut command);
282    }
283    let output = command.output()?;
284    let mut output = to_check_output(output);
285
286    if output.1 != Some(0) && !*lint_cap && denied_lint(&output.0) {
287        *lint_cap = true;
288        cap_lints(&mut command);
289        output = to_check_output(command.output()?);
290    }
291
292    Ok(output)
293}
294
295/// Applies the original lint cap while preserving existing compiler flags.
296fn cap_lints(command: &mut std::process::Command) {
297    if let Ok(flags) = env::var("CARGO_ENCODED_RUSTFLAGS") {
298        let separator = if flags.is_empty() { "" } else { "\u{1f}" };
299        command.env(
300            "CARGO_ENCODED_RUSTFLAGS",
301            format!("{flags}{separator}--cap-lints=warn"),
302        );
303    } else {
304        command.env(
305            "RUSTFLAGS",
306            format!(
307                "--cap-lints=warn {}",
308                env::var("RUSTFLAGS").unwrap_or("".to_owned())
309            ),
310        );
311    }
312}
313
314fn denied_lint(messages: &[CheckOutput]) -> bool {
315    messages.iter().any(|message| {
316        matches!(&message, CheckOutput::Message(message)
317                if message.message.level == DiagnosticLevel::Error
318                    && message.message.diagnostic.code.is_some())
319    })
320}
321
322fn to_check_output(output: std::process::Output) -> (Vec<CheckOutput>, Option<i32>) {
323    let buf = BufReader::new(Cursor::new(output.stdout));
324    (
325        buf.lines()
326            .map_while(|l| l.ok())
327            .filter_map(|l| serde_json::from_str(&l).ok())
328            .collect(),
329        output.status.code(),
330    )
331}
332
333#[tracing::instrument(skip_all)]
334#[allow(clippy::type_complexity)]
335fn collect_errors(
336    messages: impl Iterator<Item = CheckOutput>,
337    seen: &HashSet<BuildUnit>,
338) -> (
339    IndexMap<BuildUnit, IndexSet<String>>,
340    IndexMap<BuildUnit, IndexMap<String, IndexSet<(Suggestion, Option<String>)>>>,
341) {
342    let only = HashSet::new();
343    let mut build_unit_map = IndexMap::new();
344
345    let mut errors = IndexMap::new();
346
347    for message in messages {
348        let Message {
349            build_unit,
350            message: MessageDiagnostic { diagnostic, .. },
351        } = match message {
352            CheckOutput::Message(m) => m,
353            CheckOutput::Artifact(a) => {
354                if !seen.contains(&a.build_unit) && !a.fresh {
355                    build_unit_map
356                        .entry(a.build_unit.clone())
357                        .or_insert(IndexMap::new());
358                }
359                continue;
360            }
361        };
362
363        let errors = errors
364            .entry(build_unit.clone())
365            .or_insert_with(IndexSet::new);
366
367        if seen.contains(&build_unit) {
368            trace!("rejecting build unit `{:?}` already seen", build_unit);
369            continue;
370        }
371
372        let file_map = build_unit_map
373            .entry(build_unit.clone())
374            .or_insert(IndexMap::new());
375
376        let filter = if env::var("__CARGO_FIX_YOLO").is_ok() {
377            rustfix::Filter::Everything
378        } else {
379            rustfix::Filter::MachineApplicableOnly
380        };
381
382        let Some(suggestion) = collect_suggestions(&diagnostic, &only, filter) else {
383            trace!("rejecting as not a MachineApplicable diagnosis: {diagnostic:?}");
384            if let Some(rendered) = diagnostic.rendered {
385                errors.insert(rendered);
386            }
387            continue;
388        };
389
390        let mut file_names = suggestion
391            .solutions
392            .iter()
393            .flat_map(|s| s.replacements.iter())
394            .map(|r| &r.snippet.file_name);
395
396        let Some(file_name) = file_names.next() else {
397            trace!("rejecting as it has no solutions {:?}", suggestion);
398            if let Some(rendered) = diagnostic.rendered {
399                errors.insert(rendered);
400            }
401            continue;
402        };
403
404        if !file_names.all(|f| f == file_name) {
405            trace!("rejecting as it changes multiple files: {:?}", suggestion);
406            if let Some(rendered) = diagnostic.rendered {
407                errors.insert(rendered);
408            }
409            continue;
410        }
411
412        let file_path = Path::new(&file_name);
413        // Do not write into registry cache. See rust-lang/cargo#9857.
414        if let Ok(home) = env::var("CARGO_HOME") {
415            if file_path.starts_with(home) {
416                if let Some(rendered) = diagnostic.rendered {
417                    errors.insert(rendered);
418                }
419                continue;
420            }
421        }
422
423        if file_path.is_absolute() {
424            if let Some(sysroot) = get_sysroot() {
425                if file_path.starts_with(sysroot) {
426                    if let Some(rendered) = diagnostic.rendered {
427                        errors.insert(rendered);
428                    }
429                    continue;
430                }
431            }
432        }
433
434        file_map
435            .entry(file_name.to_owned())
436            .or_insert_with(IndexSet::new)
437            .insert((suggestion, diagnostic.rendered));
438    }
439
440    (errors, build_unit_map)
441}
442
443#[tracing::instrument(skip_all)]
444fn fix_errors(
445    files: &mut IndexMap<String, File>,
446    file_map: IndexMap<String, IndexSet<(Suggestion, Option<String>)>>,
447    errors: &mut IndexSet<String>,
448) -> CargoResult<bool> {
449    let mut made_changes = false;
450    for (file, suggestions) in file_map {
451        let source = match paths::read(file.as_ref()) {
452            Ok(s) => s,
453            Err(e) => {
454                warn!("failed to read `{}`: {}", file, e);
455                errors.extend(suggestions.iter().filter_map(|(_, e)| e.clone()));
456                continue;
457            }
458        };
459
460        let mut fixed = CodeFix::new(&source);
461        let mut num_fixes = 0;
462
463        for (suggestion, rendered) in suggestions.iter().rev() {
464            match fixed.apply(suggestion) {
465                Ok(()) => num_fixes += 1,
466                Err(rustfix::Error::AlreadyReplaced {
467                    is_identical: true, ..
468                }) => {}
469                Err(e) => {
470                    if let Some(rendered) = rendered {
471                        errors.insert(rendered.to_owned());
472                    }
473                    warn!("{e:?}");
474                }
475            }
476        }
477        if fixed.modified() {
478            let new_source = fixed.finish()?;
479            paths::write(&file, new_source)?;
480            made_changes = true;
481            files
482                .entry(file)
483                .or_insert(File {
484                    fixes: 0,
485                    original_source: source,
486                })
487                .fixes += num_fixes;
488        }
489    }
490
491    Ok(made_changes)
492}