//! A tool for querying Rust source files wired into Bazel and running Rustfmt on them. use std::collections::HashMap; use std::env; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::str; /// The Bazel Rustfmt tool entry point fn main() { // Gather all command line and environment settings let options = parse_args(); // Gather a list of all formattable targets let targets = query_rustfmt_targets(&options); // Run rustfmt on these targets apply_rustfmt(&options, &targets); } /// The edition to use in cases where the default edition is unspecified by Bazel const FALLBACK_EDITION: &str = "2018"; /// Determine the Rust edition to use in cases where a target has not explicitly /// specified the edition via an `edition` attribute. fn get_default_edition() -> &'static str { if !env!("RUST_DEFAULT_EDITION").is_empty() { env!("RUST_DEFAULT_EDITION") } else { FALLBACK_EDITION } } /// Get a list of all editions to run formatting for fn get_editions() -> Vec { vec!["2015".to_owned(), "2018".to_owned(), "2021".to_owned()] } /// Run a bazel command, capturing stdout while streaming stderr to surface errors fn bazel_command(bazel_bin: &Path, args: &[String], current_dir: &Path) -> Vec { let child = Command::new(bazel_bin) .current_dir(current_dir) .args(args) .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .spawn() .expect("Failed to spawn bazel command"); let output = child .wait_with_output() .expect("Failed to wait on spawned command"); if !output.status.success() { eprintln!("Failed to perform `bazel query` command."); std::process::exit(output.status.code().unwrap_or(1)); } str::from_utf8(&output.stdout) .expect("Invalid stream from command") .split('\n') .filter(|line| !line.is_empty()) .map(|line| line.to_string()) .collect() } /// The regex representation of an empty `edition` attribute const EMPTY_EDITION: &str = "^$"; /// Query for all `*.rs` files in a workspace that are dependencies of targets with the requested edition. fn edition_query(bazel_bin: &Path, edition: &str, scope: &str, current_dir: &Path) -> Vec { let query_args = vec![ "query".to_owned(), // Query explanation: // Filter all local targets ending in `*.rs`. // Get all source files. // Get direct dependencies. // Get all targets with the specified `edition` attribute. // Except for targets tagged with `norustfmt`, `no-rustfmt`, or `no-format`. // And except for targets with a populated `crate` attribute since `crate` defines edition for this target format!( r#"let scope = set({scope}) in filter("^//.*\.rs$", kind("source file", deps(attr(edition, "{edition}", $scope) except attr(tags, "(^\[|, )(no-format|no-rustfmt|norustfmt)(, |\]$)", $scope) except attr(crate, ".*", $scope), 1)))"#, ), "--keep_going".to_owned(), "--noimplicit_deps".to_owned(), ]; bazel_command(bazel_bin, &query_args, current_dir) } /// Perform a `bazel` query to determine all source files which are to be /// formatted for particular Rust editions. fn query_rustfmt_targets(options: &Config) -> HashMap> { let scope = options .packages .clone() .into_iter() .reduce(|acc, item| acc + " " + &item) .unwrap_or_else(|| "//...:all".to_owned()); let editions = get_editions(); let default_edition = get_default_edition(); editions .into_iter() .map(|edition| { let mut targets = edition_query(&options.bazel, &edition, &scope, &options.workspace); // For all targets relying on the toolchain for it's edition, // query anything with an unset edition if edition == default_edition { targets.extend(edition_query( &options.bazel, EMPTY_EDITION, &scope, &options.workspace, )) } (edition, targets) }) .collect() } /// Run rustfmt on a set of Bazel targets fn apply_rustfmt(options: &Config, editions_and_targets: &HashMap>) { // There is no work to do if the list of targets is empty if editions_and_targets.is_empty() { return; } for (edition, targets) in editions_and_targets.iter() { if targets.is_empty() { continue; } // Get paths to all formattable sources let sources: Vec = targets .iter() .map(|target| target.replace(':', "/").trim_start_matches('/').to_owned()) .collect(); // Run rustfmt let status = Command::new(&options.rustfmt_config.rustfmt) .current_dir(&options.workspace) .arg("--edition") .arg(edition) .arg("--config-path") .arg(&options.rustfmt_config.config) .args(sources) .status() .expect("Failed to run rustfmt"); if !status.success() { std::process::exit(status.code().unwrap_or(1)); } } } /// A struct containing details used for executing rustfmt. #[derive(Debug)] struct Config { /// The path of the Bazel workspace root. pub workspace: PathBuf, /// The Bazel executable to use for builds and queries. pub bazel: PathBuf, /// Information about the current rustfmt binary to run. pub rustfmt_config: rustfmt_lib::RustfmtConfig, /// Optionally, users can pass a list of targets/packages/scopes /// (eg `//my:target` or `//my/pkg/...`) to control the targets /// to be formatted. If empty, all targets in the workspace will /// be formatted. pub packages: Vec, } /// Parse command line arguments and environment variables to /// produce config data for running rustfmt. fn parse_args() -> Config { Config{ workspace: PathBuf::from( env::var("BUILD_WORKSPACE_DIRECTORY") .expect("The environment variable BUILD_WORKSPACE_DIRECTORY is required for finding the workspace root") ), bazel: PathBuf::from( env::var("BAZEL_REAL") .unwrap_or_else(|_| "bazel".to_owned()) ), rustfmt_config: rustfmt_lib::parse_rustfmt_config(), packages: env::args().skip(1).collect(), } }