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