Skip to main content

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