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