cargo_fmt/
main.rs

1// Inspired by Paul Woolcock's cargo-fmt (https://github.com/pwoolcoc/cargo-fmt/).
2
3#![deny(warnings)]
4#![allow(clippy::match_like_matches_macro)]
5
6use std::cmp::Ordering;
7use std::collections::{BTreeMap, BTreeSet};
8use std::env;
9use std::ffi::OsStr;
10use std::fs;
11use std::hash::{Hash, Hasher};
12use std::io::{self, Write};
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use std::str;
16
17use cargo_metadata::Edition;
18use clap::{CommandFactory, Parser};
19
20#[path = "test/mod.rs"]
21#[cfg(test)]
22mod cargo_fmt_tests;
23
24#[derive(Parser)]
25#[command(
26    disable_version_flag = true,
27    bin_name = "cargo fmt",
28    about = "This utility formats all bin and lib files of \
29             the current crate using rustfmt."
30)]
31#[command(styles = clap_cargo::style::CLAP_STYLING)]
32pub struct Opts {
33    /// No output printed to stdout
34    #[arg(short = 'q', long = "quiet")]
35    quiet: bool,
36
37    /// Use verbose output
38    #[arg(short = 'v', long = "verbose")]
39    verbose: bool,
40
41    /// Print rustfmt version and exit
42    #[arg(long = "version")]
43    version: bool,
44
45    /// Specify package to format
46    #[arg(
47        short = 'p',
48        long = "package",
49        value_name = "package",
50        num_args = 1..
51    )]
52    packages: Vec<String>,
53
54    /// Specify path to Cargo.toml
55    #[arg(long = "manifest-path", value_name = "manifest-path")]
56    manifest_path: Option<String>,
57
58    /// Specify message-format: short|json|human
59    #[arg(long = "message-format", value_name = "message-format")]
60    message_format: Option<String>,
61
62    /// Options passed to rustfmt
63    // 'raw = true' to make `--` explicit.
64    #[arg(id = "rustfmt_options", raw = true)]
65    rustfmt_options: Vec<String>,
66
67    /// Format all packages, and also their local path-based dependencies
68    #[arg(long = "all")]
69    format_all: bool,
70
71    /// Run rustfmt in check mode
72    #[arg(long = "check")]
73    check: bool,
74}
75
76fn main() {
77    let exit_status = execute();
78    std::io::stdout().flush().unwrap();
79    std::process::exit(exit_status);
80}
81
82const SUCCESS: i32 = 0;
83const FAILURE: i32 = 1;
84
85fn execute() -> i32 {
86    // Drop extra `fmt` argument provided by `cargo`.
87    let mut found_fmt = false;
88    let args = env::args().filter(|x| {
89        if found_fmt {
90            true
91        } else {
92            found_fmt = x == "fmt";
93            x != "fmt"
94        }
95    });
96
97    let opts = Opts::parse_from(args);
98
99    let verbosity = match (opts.verbose, opts.quiet) {
100        (false, false) => Verbosity::Normal,
101        (false, true) => Verbosity::Quiet,
102        (true, false) => Verbosity::Verbose,
103        (true, true) => {
104            print_usage_to_stderr("quiet mode and verbose mode are not compatible");
105            return FAILURE;
106        }
107    };
108
109    if opts.version {
110        return handle_command_status(get_rustfmt_info(&[String::from("--version")]));
111    }
112    if opts.rustfmt_options.iter().any(|s| {
113        ["--print-config", "-h", "--help", "-V", "--version"].contains(&s.as_str())
114            || s.starts_with("--help=")
115            || s.starts_with("--print-config=")
116    }) {
117        return handle_command_status(get_rustfmt_info(&opts.rustfmt_options));
118    }
119
120    let strategy = CargoFmtStrategy::from_opts(&opts);
121    let mut rustfmt_args = opts.rustfmt_options;
122    if opts.check {
123        let check_flag = "--check";
124        if !rustfmt_args.iter().any(|o| o == check_flag) {
125            rustfmt_args.push(check_flag.to_owned());
126        }
127    }
128    if let Some(message_format) = opts.message_format {
129        if let Err(msg) = convert_message_format_to_rustfmt_args(&message_format, &mut rustfmt_args)
130        {
131            print_usage_to_stderr(&msg);
132            return FAILURE;
133        }
134    }
135
136    if let Some(specified_manifest_path) = opts.manifest_path {
137        if !specified_manifest_path.ends_with("Cargo.toml") {
138            print_usage_to_stderr("the manifest-path must be a path to a Cargo.toml file");
139            return FAILURE;
140        }
141        let manifest_path = PathBuf::from(specified_manifest_path);
142        handle_command_status(format_crate(
143            verbosity,
144            &strategy,
145            rustfmt_args,
146            Some(&manifest_path),
147        ))
148    } else {
149        handle_command_status(format_crate(verbosity, &strategy, rustfmt_args, None))
150    }
151}
152
153fn rustfmt_command() -> Command {
154    let rustfmt_var = env::var_os("RUSTFMT");
155    let rustfmt = match &rustfmt_var {
156        Some(rustfmt) => rustfmt,
157        None => OsStr::new("rustfmt"),
158    };
159    Command::new(rustfmt)
160}
161
162fn convert_message_format_to_rustfmt_args(
163    message_format: &str,
164    rustfmt_args: &mut Vec<String>,
165) -> Result<(), String> {
166    let mut contains_emit_mode = false;
167    let mut contains_check = false;
168    let mut contains_list_files = false;
169    for arg in rustfmt_args.iter() {
170        if arg.starts_with("--emit") {
171            contains_emit_mode = true;
172        }
173        if arg == "--check" {
174            contains_check = true;
175        }
176        if arg == "-l" || arg == "--files-with-diff" {
177            contains_list_files = true;
178        }
179    }
180    match message_format {
181        "short" => {
182            if !contains_list_files {
183                rustfmt_args.push(String::from("-l"));
184            }
185            Ok(())
186        }
187        "json" => {
188            if contains_emit_mode {
189                return Err(String::from(
190                    "cannot include --emit arg when --message-format is set to json",
191                ));
192            }
193            if contains_check {
194                return Err(String::from(
195                    "cannot include --check arg when --message-format is set to json",
196                ));
197            }
198            rustfmt_args.push(String::from("--emit"));
199            rustfmt_args.push(String::from("json"));
200            Ok(())
201        }
202        "human" => Ok(()),
203        _ => Err(format!(
204            "invalid --message-format value: {message_format}. Allowed values are: short|json|human"
205        )),
206    }
207}
208
209fn print_usage_to_stderr(reason: &str) {
210    eprintln!("{reason}");
211    let app = Opts::command();
212    let help = app.after_help("").render_help();
213    eprintln!("{help}");
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub enum Verbosity {
218    Verbose,
219    Normal,
220    Quiet,
221}
222
223fn handle_command_status(status: Result<i32, io::Error>) -> i32 {
224    match status {
225        Err(e) => {
226            print_usage_to_stderr(&e.to_string());
227            FAILURE
228        }
229        Ok(status) => status,
230    }
231}
232
233fn get_rustfmt_info(args: &[String]) -> Result<i32, io::Error> {
234    let mut command = rustfmt_command()
235        .stdout(std::process::Stdio::inherit())
236        .args(args)
237        .spawn()
238        .map_err(|e| match e.kind() {
239            io::ErrorKind::NotFound => io::Error::new(
240                io::ErrorKind::Other,
241                "Could not run rustfmt, please make sure it is in your PATH.",
242            ),
243            _ => e,
244        })?;
245    let result = command.wait()?;
246    if result.success() {
247        Ok(SUCCESS)
248    } else {
249        Ok(result.code().unwrap_or(SUCCESS))
250    }
251}
252
253fn format_crate(
254    verbosity: Verbosity,
255    strategy: &CargoFmtStrategy,
256    rustfmt_args: Vec<String>,
257    manifest_path: Option<&Path>,
258) -> Result<i32, io::Error> {
259    let targets = get_targets(strategy, manifest_path)?;
260
261    // Currently only bin and lib files get formatted.
262    run_rustfmt(&targets, &rustfmt_args, verbosity)
263}
264
265/// Target uses a `path` field for equality and hashing.
266#[derive(Debug)]
267pub struct Target {
268    /// A path to the main source file of the target.
269    path: PathBuf,
270    /// A kind of target (e.g., lib, bin, example, ...).
271    kind: String,
272    /// Rust edition for this target.
273    edition: Edition,
274}
275
276impl Target {
277    pub fn from_target(target: &cargo_metadata::Target) -> Self {
278        let path = PathBuf::from(&target.src_path);
279        let canonicalized = fs::canonicalize(&path).unwrap_or(path);
280
281        Target {
282            path: canonicalized,
283            kind: target.kind[0].clone(),
284            edition: target.edition,
285        }
286    }
287}
288
289impl PartialEq for Target {
290    fn eq(&self, other: &Target) -> bool {
291        self.path == other.path
292    }
293}
294
295impl PartialOrd for Target {
296    fn partial_cmp(&self, other: &Target) -> Option<Ordering> {
297        Some(self.path.cmp(&other.path))
298    }
299}
300
301impl Ord for Target {
302    fn cmp(&self, other: &Target) -> Ordering {
303        self.path.cmp(&other.path)
304    }
305}
306
307impl Eq for Target {}
308
309impl Hash for Target {
310    fn hash<H: Hasher>(&self, state: &mut H) {
311        self.path.hash(state);
312    }
313}
314
315#[derive(Debug, PartialEq, Eq)]
316pub enum CargoFmtStrategy {
317    /// Format every packages and dependencies.
318    All,
319    /// Format packages that are specified by the command line argument.
320    Some(Vec<String>),
321    /// Format the root packages only.
322    Root,
323}
324
325impl CargoFmtStrategy {
326    pub fn from_opts(opts: &Opts) -> CargoFmtStrategy {
327        match (opts.format_all, opts.packages.is_empty()) {
328            (false, true) => CargoFmtStrategy::Root,
329            (true, _) => CargoFmtStrategy::All,
330            (false, false) => CargoFmtStrategy::Some(opts.packages.clone()),
331        }
332    }
333}
334
335/// Based on the specified `CargoFmtStrategy`, returns a set of main source files.
336fn get_targets(
337    strategy: &CargoFmtStrategy,
338    manifest_path: Option<&Path>,
339) -> Result<BTreeSet<Target>, io::Error> {
340    let mut targets = BTreeSet::new();
341
342    match *strategy {
343        CargoFmtStrategy::Root => get_targets_root_only(manifest_path, &mut targets)?,
344        CargoFmtStrategy::All => {
345            get_targets_recursive(manifest_path, &mut targets, &mut BTreeSet::new())?
346        }
347        CargoFmtStrategy::Some(ref hitlist) => {
348            get_targets_with_hitlist(manifest_path, hitlist, &mut targets)?
349        }
350    }
351
352    if targets.is_empty() {
353        Err(io::Error::new(
354            io::ErrorKind::Other,
355            "Failed to find targets".to_owned(),
356        ))
357    } else {
358        Ok(targets)
359    }
360}
361
362fn get_targets_root_only(
363    manifest_path: Option<&Path>,
364    targets: &mut BTreeSet<Target>,
365) -> Result<(), io::Error> {
366    let metadata = get_cargo_metadata(manifest_path)?;
367    let workspace_root_path = PathBuf::from(&metadata.workspace_root).canonicalize()?;
368    let (in_workspace_root, current_dir_manifest) = if let Some(target_manifest) = manifest_path {
369        (
370            workspace_root_path == target_manifest,
371            target_manifest.canonicalize()?,
372        )
373    } else {
374        let current_dir = env::current_dir()?.canonicalize()?;
375        (
376            workspace_root_path == current_dir,
377            current_dir.join("Cargo.toml"),
378        )
379    };
380
381    let package_targets = match metadata.packages.len() {
382        1 => metadata.packages.into_iter().next().unwrap().targets,
383        _ => metadata
384            .packages
385            .into_iter()
386            .filter(|p| {
387                in_workspace_root
388                    || PathBuf::from(&p.manifest_path)
389                        .canonicalize()
390                        .unwrap_or_default()
391                        == current_dir_manifest
392            })
393            .flat_map(|p| p.targets)
394            .collect(),
395    };
396
397    for target in package_targets {
398        targets.insert(Target::from_target(&target));
399    }
400
401    Ok(())
402}
403
404fn get_targets_recursive(
405    manifest_path: Option<&Path>,
406    targets: &mut BTreeSet<Target>,
407    visited: &mut BTreeSet<String>,
408) -> Result<(), io::Error> {
409    let metadata = get_cargo_metadata(manifest_path)?;
410    for package in &metadata.packages {
411        add_targets(&package.targets, targets);
412
413        // Look for local dependencies using information available since cargo v1.51
414        // It's theoretically possible someone could use a newer version of rustfmt with
415        // a much older version of `cargo`, but we don't try to explicitly support that scenario.
416        // If someone reports an issue with path-based deps not being formatted, be sure to
417        // confirm their version of `cargo` (not `cargo-fmt`) is >= v1.51
418        // https://github.com/rust-lang/cargo/pull/8994
419        for dependency in &package.dependencies {
420            if dependency.path.is_none() || visited.contains(&dependency.name) {
421                continue;
422            }
423
424            let manifest_path = PathBuf::from(dependency.path.as_ref().unwrap()).join("Cargo.toml");
425            if manifest_path.exists()
426                && !metadata
427                    .packages
428                    .iter()
429                    .any(|p| p.manifest_path.eq(&manifest_path))
430            {
431                visited.insert(dependency.name.to_owned());
432                get_targets_recursive(Some(&manifest_path), targets, visited)?;
433            }
434        }
435    }
436
437    Ok(())
438}
439
440fn get_targets_with_hitlist(
441    manifest_path: Option<&Path>,
442    hitlist: &[String],
443    targets: &mut BTreeSet<Target>,
444) -> Result<(), io::Error> {
445    let metadata = get_cargo_metadata(manifest_path)?;
446    let mut workspace_hitlist: BTreeSet<&String> = BTreeSet::from_iter(hitlist);
447
448    for package in metadata.packages {
449        if workspace_hitlist.remove(&package.name) {
450            for target in package.targets {
451                targets.insert(Target::from_target(&target));
452            }
453        }
454    }
455
456    if workspace_hitlist.is_empty() {
457        Ok(())
458    } else {
459        let package = workspace_hitlist.iter().next().unwrap();
460        Err(io::Error::new(
461            io::ErrorKind::InvalidInput,
462            format!("package `{package}` is not a member of the workspace"),
463        ))
464    }
465}
466
467fn add_targets(target_paths: &[cargo_metadata::Target], targets: &mut BTreeSet<Target>) {
468    for target in target_paths {
469        targets.insert(Target::from_target(target));
470    }
471}
472
473fn run_rustfmt(
474    targets: &BTreeSet<Target>,
475    fmt_args: &[String],
476    verbosity: Verbosity,
477) -> Result<i32, io::Error> {
478    let by_edition = targets
479        .iter()
480        .inspect(|t| {
481            if verbosity == Verbosity::Verbose {
482                println!("[{} ({})] {:?}", t.kind, t.edition, t.path)
483            }
484        })
485        .fold(BTreeMap::new(), |mut h, t| {
486            h.entry(&t.edition).or_insert_with(Vec::new).push(&t.path);
487            h
488        });
489
490    let mut status = vec![];
491    for (edition, files) in by_edition {
492        let stdout = if verbosity == Verbosity::Quiet {
493            std::process::Stdio::null()
494        } else {
495            std::process::Stdio::inherit()
496        };
497
498        if verbosity == Verbosity::Verbose {
499            print!("rustfmt");
500            print!(" --edition {edition}");
501            fmt_args.iter().for_each(|f| print!(" {}", f));
502            files.iter().for_each(|f| print!(" {}", f.display()));
503            println!();
504        }
505
506        let mut command = rustfmt_command()
507            .stdout(stdout)
508            .args(files)
509            .args(["--edition", edition.as_str()])
510            .args(fmt_args)
511            .spawn()
512            .map_err(|e| match e.kind() {
513                io::ErrorKind::NotFound => io::Error::new(
514                    io::ErrorKind::Other,
515                    "Could not run rustfmt, please make sure it is in your PATH.",
516                ),
517                _ => e,
518            })?;
519
520        status.push(command.wait()?);
521    }
522
523    Ok(status
524        .iter()
525        .filter_map(|s| if s.success() { None } else { s.code() })
526        .next()
527        .unwrap_or(SUCCESS))
528}
529
530fn get_cargo_metadata(manifest_path: Option<&Path>) -> Result<cargo_metadata::Metadata, io::Error> {
531    let mut cmd = cargo_metadata::MetadataCommand::new();
532    cmd.no_deps();
533    if let Some(manifest_path) = manifest_path {
534        cmd.manifest_path(manifest_path);
535    }
536    cmd.other_options(vec![String::from("--offline")]);
537
538    match cmd.exec() {
539        Ok(metadata) => Ok(metadata),
540        Err(_) => {
541            cmd.other_options(vec![]);
542            match cmd.exec() {
543                Ok(metadata) => Ok(metadata),
544                Err(error) => Err(io::Error::new(io::ErrorKind::Other, error.to_string())),
545            }
546        }
547    }
548}