| // Inspired by Clang's clang-format-diff: | |
| // | |
| // https://github.com/llvm-mirror/clang/blob/master/tools/clang-format/clang-format-diff.py | |
| #![deny(warnings)] | |
| use serde::{Deserialize, Serialize}; | |
| use serde_json as json; | |
| use thiserror::Error; | |
| use tracing::debug; | |
| use tracing_subscriber::EnvFilter; | |
| use std::collections::HashSet; | |
| use std::env; | |
| use std::ffi::OsStr; | |
| use std::io::{self, BufRead}; | |
| use std::process; | |
| use regex::Regex; | |
| use clap::{CommandFactory, Parser}; | |
| /// The default pattern of files to format. | |
| /// | |
| /// We only want to format rust files by default. | |
| const DEFAULT_PATTERN: &str = r".*\.rs"; | |
| #[derive(Error, Debug)] | |
| enum FormatDiffError { | |
| #[error("{0}")] | |
| IncorrectOptions(#[from] getopts::Fail), | |
| #[error("{0}")] | |
| IncorrectFilter(#[from] regex::Error), | |
| #[error("{0}")] | |
| IoError(#[from] io::Error), | |
| } | |
| #[derive(Parser, Debug)] | |
| #[command( | |
| name = "rustfmt-format-diff", | |
| disable_version_flag = true, | |
| next_line_help = true | |
| )] | |
| pub struct Opts { | |
| /// Skip the smallest prefix containing NUMBER slashes | |
| #[arg( | |
| short = 'p', | |
| long = "skip-prefix", | |
| value_name = "NUMBER", | |
| default_value = "0" | |
| )] | |
| skip_prefix: u32, | |
| /// Custom pattern selecting file paths to reformat | |
| #[arg( | |
| short = 'f', | |
| long = "filter", | |
| value_name = "PATTERN", | |
| default_value = DEFAULT_PATTERN | |
| )] | |
| filter: String, | |
| } | |
| fn main() { | |
| tracing_subscriber::fmt() | |
| .with_env_filter(EnvFilter::from_env("RUSTFMT_LOG")) | |
| .init(); | |
| let opts = Opts::parse(); | |
| if let Err(e) = run(opts) { | |
| println!("{e}"); | |
| Opts::command() | |
| .print_help() | |
| .expect("cannot write to stdout"); | |
| process::exit(1); | |
| } | |
| } | |
| #[derive(Debug, Eq, PartialEq, Serialize, Deserialize)] | |
| struct Range { | |
| file: String, | |
| range: [u32; 2], | |
| } | |
| fn run(opts: Opts) -> Result<(), FormatDiffError> { | |
| let (files, ranges) = scan_diff(io::stdin(), opts.skip_prefix, &opts.filter)?; | |
| run_rustfmt(&files, &ranges) | |
| } | |
| fn run_rustfmt(files: &HashSet<String>, ranges: &[Range]) -> Result<(), FormatDiffError> { | |
| if files.is_empty() || ranges.is_empty() { | |
| debug!("No files to format found"); | |
| return Ok(()); | |
| } | |
| let ranges_as_json = json::to_string(ranges).unwrap(); | |
| debug!("Files: {:?}", files); | |
| debug!("Ranges: {:?}", ranges); | |
| let rustfmt_var = env::var_os("RUSTFMT"); | |
| let rustfmt = match &rustfmt_var { | |
| Some(rustfmt) => rustfmt, | |
| None => OsStr::new("rustfmt"), | |
| }; | |
| let exit_status = process::Command::new(rustfmt) | |
| .args(files) | |
| .arg("--file-lines") | |
| .arg(ranges_as_json) | |
| .status()?; | |
| if !exit_status.success() { | |
| return Err(FormatDiffError::IoError(io::Error::new( | |
| io::ErrorKind::Other, | |
| format!("rustfmt failed with {exit_status}"), | |
| ))); | |
| } | |
| Ok(()) | |
| } | |
| /// Scans a diff from `from`, and returns the set of files found, and the ranges | |
| /// in those files. | |
| fn scan_diff<R>( | |
| from: R, | |
| skip_prefix: u32, | |
| file_filter: &str, | |
| ) -> Result<(HashSet<String>, Vec<Range>), FormatDiffError> | |
| where | |
| R: io::Read, | |
| { | |
| let diff_pattern = format!(r"^\+\+\+\s(?:.*?/){{{skip_prefix}}}(\S*)"); | |
| let diff_pattern = Regex::new(&diff_pattern).unwrap(); | |
| let lines_pattern = Regex::new(r"^@@.*\+(\d+)(,(\d+))?").unwrap(); | |
| let file_filter = Regex::new(&format!("^{file_filter}$"))?; | |
| let mut current_file = None; | |
| let mut files = HashSet::new(); | |
| let mut ranges = vec![]; | |
| for line in io::BufReader::new(from).lines() { | |
| let line = line.unwrap(); | |
| if let Some(captures) = diff_pattern.captures(&line) { | |
| current_file = Some(captures.get(1).unwrap().as_str().to_owned()); | |
| } | |
| let file = match current_file { | |
| Some(ref f) => &**f, | |
| None => continue, | |
| }; | |
| // FIXME(emilio): We could avoid this most of the time if needed, but | |
| // it's not clear it's worth it. | |
| if !file_filter.is_match(file) { | |
| continue; | |
| } | |
| let lines_captures = match lines_pattern.captures(&line) { | |
| Some(captures) => captures, | |
| None => continue, | |
| }; | |
| let start_line = lines_captures | |
| .get(1) | |
| .unwrap() | |
| .as_str() | |
| .parse::<u32>() | |
| .unwrap(); | |
| let line_count = match lines_captures.get(3) { | |
| Some(line_count) => line_count.as_str().parse::<u32>().unwrap(), | |
| None => 1, | |
| }; | |
| if line_count == 0 { | |
| continue; | |
| } | |
| let end_line = start_line + line_count - 1; | |
| files.insert(file.to_owned()); | |
| ranges.push(Range { | |
| file: file.to_owned(), | |
| range: [start_line, end_line], | |
| }); | |
| } | |
| Ok((files, ranges)) | |
| } | |
| #[test] | |
| fn scan_simple_git_diff() { | |
| const DIFF: &str = include_str!("test/bindgen.diff"); | |
| let (files, ranges) = scan_diff(DIFF.as_bytes(), 1, r".*\.rs").expect("scan_diff failed?"); | |
| assert!( | |
| files.contains("src/ir/traversal.rs"), | |
| "Should've matched the filter" | |
| ); | |
| assert!( | |
| !files.contains("tests/headers/anon_enum.hpp"), | |
| "Shouldn't have matched the filter" | |
| ); | |
| assert_eq!( | |
| &ranges, | |
| &[ | |
| Range { | |
| file: "src/ir/item.rs".to_owned(), | |
| range: [148, 158], | |
| }, | |
| Range { | |
| file: "src/ir/item.rs".to_owned(), | |
| range: [160, 170], | |
| }, | |
| Range { | |
| file: "src/ir/traversal.rs".to_owned(), | |
| range: [9, 16], | |
| }, | |
| Range { | |
| file: "src/ir/traversal.rs".to_owned(), | |
| range: [35, 43], | |
| }, | |
| ] | |
| ); | |
| } | |
| #[cfg(test)] | |
| mod cmd_line_tests { | |
| use super::*; | |
| #[test] | |
| fn default_options() { | |
| let empty: Vec<String> = vec![]; | |
| let o = Opts::parse_from(empty); | |
| assert_eq!(DEFAULT_PATTERN, o.filter); | |
| assert_eq!(0, o.skip_prefix); | |
| } | |
| #[test] | |
| fn good_options() { | |
| let o = Opts::parse_from(["test", "-p", "10", "-f", r".*\.hs"]); | |
| assert_eq!(r".*\.hs", o.filter); | |
| assert_eq!(10, o.skip_prefix); | |
| } | |
| #[test] | |
| fn unexpected_option() { | |
| assert!( | |
| Opts::command() | |
| .try_get_matches_from(["test", "unexpected"]) | |
| .is_err() | |
| ); | |
| } | |
| #[test] | |
| fn unexpected_flag() { | |
| assert!( | |
| Opts::command() | |
| .try_get_matches_from(["test", "--flag"]) | |
| .is_err() | |
| ); | |
| } | |
| #[test] | |
| fn overridden_option() { | |
| assert!( | |
| Opts::command() | |
| .try_get_matches_from(["test", "-p", "10", "-p", "20"]) | |
| .is_err() | |
| ); | |
| } | |
| #[test] | |
| fn negative_filter() { | |
| assert!( | |
| Opts::command() | |
| .try_get_matches_from(["test", "-p", "-1"]) | |
| .is_err() | |
| ); | |
| } | |
| } |