clippy_fixer/
main.rs

1use std::collections::{BTreeMap, HashSet, btree_map};
2use std::io::Read;
3use std::process::{Command, Stdio};
4
5use anyhow::{Context, bail};
6use camino::{Utf8Path, Utf8PathBuf};
7use clap::Parser;
8use rustfix::{CodeFix, Filter};
9
10/// Executes `bazel info` to get a map of context information.
11fn bazel_info(
12    bazel: &Utf8Path,
13    workspace: Option<&Utf8Path>,
14    output_base: Option<&Utf8Path>,
15    bazel_startup_options: &[String],
16    bazel_args: &[String],
17) -> anyhow::Result<BTreeMap<String, String>> {
18    let output = bazel_command(bazel, workspace, output_base)
19        .args(bazel_startup_options)
20        .arg("info")
21        .args(bazel_args)
22        .output()?;
23
24    if !output.status.success() {
25        let status = output.status;
26        let stderr = String::from_utf8_lossy(&output.stderr);
27        bail!("bazel info failed: ({status:?})\n{stderr}");
28    }
29
30    // Extract and parse the output.
31    let info_map = String::from_utf8(output.stdout)?
32        .trim()
33        .split('\n')
34        .filter_map(|line| line.split_once(':'))
35        .map(|(k, v)| (k.to_owned(), v.trim().to_owned()))
36        .collect();
37
38    Ok(info_map)
39}
40
41fn bazel_command(bazel: &Utf8Path, workspace: Option<&Utf8Path>, output_base: Option<&Utf8Path>) -> Command {
42    let mut cmd = Command::new(bazel);
43
44    cmd
45        // Switch to the workspace directory if one was provided.
46        .current_dir(workspace.unwrap_or(Utf8Path::new(".")))
47        .env_remove("BAZELISK_SKIP_WRAPPER")
48        .env_remove("BUILD_WORKING_DIRECTORY")
49        .env_remove("BUILD_WORKSPACE_DIRECTORY")
50        // Set the output_base if one was provided.
51        .args(output_base.map(|s| format!("--output_base={s}")));
52
53    cmd
54}
55
56// TODO(david): This shells out to an expected rule in the workspace root //:rust_analyzer that the user must define.
57// It would be more convenient if it could automatically discover all the rust code in the workspace if this target
58// does not exist.
59fn main() -> anyhow::Result<()> {
60    env_logger::init();
61
62    let config = Config::parse()?;
63
64    log::info!("running build query");
65
66    let mut command = bazel_command(&config.bazel, Some(&config.workspace), Some(&config.output_base))
67        .arg("query")
68        .arg(format!(r#"kind("rust_clippy rule", set({}))"#, config.targets.join(" ")))
69        .stderr(Stdio::inherit())
70        .stdout(Stdio::piped())
71        .spawn()
72        .context("bazel query")?;
73
74    let mut stdout = command.stdout.take().unwrap();
75    let mut targets = String::new();
76    stdout.read_to_string(&mut targets).context("stdout read")?;
77    if !command.wait().context("query wait")?.success() {
78        bail!("failed to run bazel query")
79    }
80
81    let items: Vec<_> = targets.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect();
82
83    let mut command = bazel_command(&config.bazel, Some(&config.workspace), Some(&config.output_base))
84        .arg("cquery")
85        .args(&config.bazel_args)
86        .arg(format!("set({})", items.join(" ")))
87        .arg("--output=starlark")
88        .arg("--starlark:expr=[file.path for file in target.files.to_list()]")
89        .arg("--build")
90        .arg("--output_groups=rust_clippy")
91        .stderr(Stdio::inherit())
92        .stdout(Stdio::piped())
93        .spawn()
94        .context("bazel cquery")?;
95
96    let mut stdout = command.stdout.take().unwrap();
97
98    let mut targets = String::new();
99    stdout.read_to_string(&mut targets).context("stdout read")?;
100
101    if !command.wait().context("cquery wait")?.success() {
102        bail!("failed to run bazel cquery")
103    }
104
105    let mut clippy_files = Vec::new();
106
107    for line in targets.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
108        clippy_files.extend(serde_json::from_str::<Vec<String>>(line).context("parse line")?);
109    }
110
111    let only = HashSet::new();
112    let mut suggestions = Vec::new();
113    for file in clippy_files {
114        let path = config.execution_root.join(&file);
115        if !path.exists() {
116            log::warn!("missing {file}");
117            continue;
118        }
119
120        let content = std::fs::read_to_string(path).context("read")?;
121        for line in content.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
122            if line.contains(r#""$message_type":"artifact""#) {
123                continue;
124            }
125
126            suggestions
127                .extend(rustfix::get_suggestions_from_json(line, &only, Filter::MachineApplicableOnly).context("items")?)
128        }
129    }
130
131    struct File {
132        codefix: CodeFix,
133    }
134
135    let mut files = BTreeMap::new();
136    let solutions: HashSet<_> = suggestions.iter().flat_map(|s| &s.solutions).collect();
137    for solution in solutions {
138        let Some(replacement) = solution.replacements.first() else {
139            continue;
140        };
141
142        let path = config.workspace.join(&replacement.snippet.file_name);
143        let mut entry = files.entry(path);
144        let file = match entry {
145            btree_map::Entry::Vacant(v) => {
146                let file = std::fs::read_to_string(v.key()).context("read source")?;
147
148                v.insert(File {
149                    codefix: CodeFix::new(&file),
150                })
151            }
152            btree_map::Entry::Occupied(ref mut o) => o.get_mut(),
153        };
154
155        file.codefix.apply_solution(solution).context("apply solution")?;
156    }
157
158    for (path, file) in files {
159        if !file.codefix.modified() {
160            continue;
161        }
162
163        let modified = file.codefix.finish().context("finish")?;
164        std::fs::write(path, modified).context("write")?;
165    }
166
167    Ok(())
168}
169
170#[derive(Debug)]
171struct Config {
172    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
173    workspace: Utf8PathBuf,
174
175    /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`.
176    execution_root: Utf8PathBuf,
177
178    /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`.
179    output_base: Utf8PathBuf,
180
181    /// The path to a Bazel binary.
182    bazel: Utf8PathBuf,
183
184    /// Arguments to pass to `bazel` invocations.
185    /// See the [Command-Line Reference](<https://bazel.build/reference/command-line-reference>)
186    /// for more details.
187    bazel_args: Vec<String>,
188
189    /// Space separated list of target patterns that comes after all other args.
190    targets: Vec<String>,
191}
192
193impl Config {
194    // Parse the configuration flags and supplement with bazel info as needed.
195    fn parse() -> anyhow::Result<Self> {
196        let ConfigParser {
197            workspace,
198            execution_root,
199            output_base,
200            bazel,
201            config,
202            targets,
203        } = ConfigParser::parse();
204
205        let bazel_args = vec![format!("--config={config}")];
206
207        match (workspace, execution_root, output_base) {
208            (Some(workspace), Some(execution_root), Some(output_base)) => Ok(Config {
209                workspace,
210                execution_root,
211                output_base,
212                bazel,
213                bazel_args,
214                targets,
215            }),
216            (workspace, _, output_base) => {
217                let mut info_map = bazel_info(&bazel, workspace.as_deref(), output_base.as_deref(), &[], &bazel_args)?;
218
219                let config = Config {
220                    workspace: info_map
221                        .remove("workspace")
222                        .expect("'workspace' must exist in bazel info")
223                        .into(),
224                    execution_root: info_map
225                        .remove("execution_root")
226                        .expect("'execution_root' must exist in bazel info")
227                        .into(),
228                    output_base: info_map
229                        .remove("output_base")
230                        .expect("'output_base' must exist in bazel info")
231                        .into(),
232                    bazel,
233                    bazel_args,
234                    targets,
235                };
236
237                Ok(config)
238            }
239        }
240    }
241}
242
243#[derive(Debug, Parser)]
244struct ConfigParser {
245    /// The path to the Bazel workspace directory. If not specified, uses the result of `bazel info workspace`.
246    #[clap(long, env = "BUILD_WORKSPACE_DIRECTORY")]
247    workspace: Option<Utf8PathBuf>,
248
249    /// The path to the Bazel execution root. If not specified, uses the result of `bazel info execution_root`.
250    #[clap(long)]
251    execution_root: Option<Utf8PathBuf>,
252
253    /// The path to the Bazel output user root. If not specified, uses the result of `bazel info output_base`.
254    #[clap(long, env = "OUTPUT_BASE")]
255    output_base: Option<Utf8PathBuf>,
256
257    /// The path to a Bazel binary.
258    #[clap(long, default_value = "bazel")]
259    bazel: Utf8PathBuf,
260
261    /// A config to pass to Bazel invocations with `--config=<config>`.
262    #[clap(long, default_value = "wrapper")]
263    config: String,
264
265    /// Space separated list of target patterns that comes after all other args.
266    #[clap(default_value = "@//...")]
267    targets: Vec<String>,
268}