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