diff --git a/Cargo.toml b/Cargo.toml index 057c194..426bdbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,6 @@ exclude = [ ] [dependencies] -quick-error = "1.2.1" serde = "1.0" serde_json = "1.0" serde_derive = "1.0" @@ -33,6 +32,3 @@ tempdir = "0.3.5" members = [ "cargo-fix", ] - -[patch.crates-io] -rustfix = { path = "." } diff --git a/cargo-fix/Cargo.toml b/cargo-fix/Cargo.toml index 69d1ccb..82e9b98 100644 --- a/cargo-fix/Cargo.toml +++ b/cargo-fix/Cargo.toml @@ -8,11 +8,17 @@ repository = "https://github.com/killercup/rustfix" documentation = "https://docs.rs/rustfix" readme = "README.md" +[[bin]] +name = "cargo-fix" +test = false + [dependencies] -clap = "2.9.2" -colored = "1.2.0" -quick-error = "1.2.1" -serde = "1.0" -serde_json = "1.0" -serde_derive = "1.0" -rustfix = "0.2.0" +failure = "0.1" +rustfix = { path = "..", version = "0.2" } +serde_json = "1" +log = "0.4" +env_logger = { version = "0.5", default-features = false } + +[dev-dependencies] +difference = "2" +url = "1" diff --git a/cargo-fix/src/lock.rs b/cargo-fix/src/lock.rs new file mode 100644 index 0000000..81294e1 --- /dev/null +++ b/cargo-fix/src/lock.rs @@ -0,0 +1,163 @@ +//! An implementation of IPC locks, guaranteed to be released if a process dies +//! +//! This module implements a locking server/client where the main `cargo fix` +//! process will start up a server and then all the client processes will +//! connect to it. The main purpose of this file is to enusre that each crate +//! (aka file entry point) is only fixed by one process at a time, currently +//! concurrent fixes can't happen. +//! +//! The basic design here is to use a TCP server which is pretty portable across +//! platforms. For simplicity it just uses threads as well. Clients connect to +//! the main server, inform the server what its name is, and then wait for the +//! server to give it the lock (aka write a byte). + +use std::collections::HashMap; +use std::env; +use std::io::{BufReader, BufRead, Read, Write}; +use std::net::{TcpStream, SocketAddr, TcpListener}; +use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread::{self, JoinHandle}; + +use failure::{Error, ResultExt}; + +pub struct Server { + listener: TcpListener, + threads: HashMap, + done: Arc, +} + +pub struct StartedServer { + done: Arc, + addr: SocketAddr, + thread: Option>, +} + +pub struct Client { + _socket: TcpStream, +} + +struct ServerClient { + thread: Option>, + lock: Arc)>>, +} + +impl Server { + pub fn new() -> Result { + let listener = TcpListener::bind("127.0.0.1:0") + .with_context(|_| "failed to bind TCP listener to manage locking")?; + env::set_var("__CARGO_FIX_SERVER", listener.local_addr()?.to_string()); + Ok(Server { + listener, + threads: HashMap::new(), + done: Arc::new(AtomicBool::new(false)), + }) + } + + pub fn start(self) -> Result { + let addr = self.listener.local_addr()?; + let done = self.done.clone(); + let thread = thread::spawn(|| { + self.run(); + }); + Ok(StartedServer { + addr, + thread: Some(thread), + done, + }) + } + + fn run(mut self) { + while let Ok((client, _)) = self.listener.accept() { + if self.done.load(Ordering::SeqCst) { + break + } + + // Learn the name of our connected client to figure out if it needs + // to wait for another process to release the lock. + let mut client = BufReader::new(client); + let mut name = String::new(); + if client.read_line(&mut name).is_err() { + continue + } + let client = client.into_inner(); + + // If this "named mutex" is already registered and the thread is + // still going, put it on the queue. Otherwise wait on the previous + // thread and we'll replace it just below. + if let Some(t) = self.threads.get_mut(&name) { + let mut state = t.lock.lock().unwrap(); + if state.0 { + state.1.push(client); + continue + } + drop(t.thread.take().unwrap().join()); + } + + let lock = Arc::new(Mutex::new((true, vec![client]))); + let lock2 = lock.clone(); + let thread = thread::spawn(move || { + loop { + let mut client = { + let mut state = lock2.lock().unwrap(); + if state.1.len() == 0 { + state.0 = false; + break + } else { + state.1.remove(0) + } + }; + // Inform this client that it now has the lock and wait for + // it to disconnect by waiting for EOF. + if client.write_all(&[1]).is_err() { + continue + } + let mut dst = Vec::new(); + drop(client.read_to_end(&mut dst)); + } + }); + + self.threads.insert(name, ServerClient { + thread: Some(thread), + lock, + }); + } + } +} + +impl Drop for Server { + fn drop(&mut self) { + for (_, mut client) in self.threads.drain() { + if let Some(thread) = client.thread.take() { + drop(thread.join()); + } + } + } +} + +impl Drop for StartedServer { + fn drop(&mut self) { + self.done.store(true, Ordering::SeqCst); + // Ignore errors here as this is largely best-effort + if TcpStream::connect(&self.addr).is_err() { + return + } + drop(self.thread.take().unwrap().join()); + } +} + +impl Client { + pub fn lock(name: &str) -> Result { + let addr = env::var("__CARGO_FIX_SERVER") + .map_err(|_| format_err!("locking strategy misconfigured"))?; + let mut client = TcpStream::connect(&addr) + .with_context(|_| "failed to connect to parent lock server")?; + client.write_all(name.as_bytes()) + .and_then(|_| client.write_all(b"\n")) + .with_context(|_| "failed to write to lock server")?; + let mut buf = [0]; + client.read_exact(&mut buf) + .with_context(|_| "failed to acquire lock")?; + Ok(Client { _socket: client }) + } +} diff --git a/cargo-fix/src/main.rs b/cargo-fix/src/main.rs index 97f870b..67bd39a 100644 --- a/cargo-fix/src/main.rs +++ b/cargo-fix/src/main.rs @@ -1,418 +1,193 @@ #[macro_use] -extern crate serde_derive; +extern crate failure; +extern crate rustfix; extern crate serde_json; #[macro_use] -extern crate quick_error; -#[macro_use] -extern crate clap; -extern crate colored; - -extern crate rustfix; +extern crate log; +extern crate env_logger; +use std::collections::{HashSet, HashMap}; +use std::env; use std::fs::File; use std::io::{Read, Write}; -use std::error::Error; -use std::process::Command; -use std::collections::HashSet; - -use colored::Colorize; -use clap::{Arg, App}; +use std::process::{self, Command, ExitStatus}; +use std::str; +use std::path::Path; -use std::str::FromStr; - -use rustfix::{Suggestion, Replacement}; use rustfix::diagnostics::Diagnostic; +use failure::{Error, ResultExt}; -const USER_OPTIONS: &str = "What do you want to do? \ - [0-9] | [r]eplace | [s]kip | save and [q]uit | [a]bort (without saving)"; +mod lock; fn main() { - let program = try_main(); - match program { - Ok(_) => std::process::exit(0), - Err(ProgramError::UserAbort) => { - writeln!(std::io::stdout(), "{}", ProgramError::UserAbort).unwrap(); - std::process::exit(0); - } - Err(error) => { - writeln!(std::io::stderr(), "An error occured: {}", error).unwrap(); - writeln!(std::io::stderr(), "{:?}", error).unwrap(); - if let Some(cause) = error.cause() { - writeln!(std::io::stderr(), "Cause: {:?}", cause).unwrap(); - } - std::process::exit(1); - } - } -} - -macro_rules! flush { - () => (try!(std::io::stdout().flush());) -} - -/// A list of `--only` aliases -const ALIASES: &[(&str, &[&str])] = &[ - ("use", &["E0412"]), -]; - -fn try_main() -> Result<(), ProgramError> { - // Quickfix to be usable as rustfix as well as cargo-fix - let args = if ::std::env::args_os().nth(1).map(|x| &x == "fix").unwrap_or(false) { - ::std::env::args_os().skip(1) + env_logger::init(); + let result = if env::var("__CARGO_FIX_NOW_RUSTC").is_ok() { + cargo_fix_rustc() } else { - ::std::env::args_os().skip(0) + cargo_fix() }; - - let matches = App::new("cargo-fix") - .about("Automatically apply suggestions made by rustc") - .version(crate_version!()) - .arg(Arg::with_name("clippy") - .long("clippy") - .help("Use `cargo clippy` for suggestions")) - .arg(Arg::with_name("yolo") - .long("yolo") - .help("Automatically apply all unambiguous suggestions")) - .arg(Arg::with_name("only") - .long("only") - .help("Only show errors or lints with the specific id(s) (comma separated)") - .use_delimiter(true)) - .arg(Arg::with_name("file") - .long("file") - .takes_value(true) - .help("Load errors from the given JSON file (produced by `cargo build --message-format=json`)")) - .get_matches_from(args); - - let mut extra_args = Vec::new(); - - if !matches.is_present("clippy") { - extra_args.push("-Aclippy"); - } - - let mode = if matches.is_present("yolo") { - AutofixMode::Yolo - } else { - AutofixMode::None + let err = match result { + Ok(()) => return, + Err(e) => e, }; - - let mut only: HashSet = matches - .values_of("only") - .map_or(HashSet::new(), |values| { - values.map(ToString::to_string).collect() - }); - - for alias in ALIASES { - if only.remove(alias.0) { - for alias in alias.1 { - only.insert(alias.to_string()); - } - } + eprintln!("error: {}", err); + for cause in err.causes().skip(1) { + eprintln!("\tcaused by: {}", cause); } - - // Get JSON output from rustc... - let json = if let Some(file) = matches.value_of("file") { - let mut f = File::open(file)?; - let mut j = "".into(); - f.read_to_string(&mut j)?; - j - } else { - get_json(&extra_args, matches.is_present("clippy"))? - }; - - let suggestions: Vec = json.lines() - .filter(not_empty) - // Convert JSON string (and eat parsing errors) - .flat_map(|line| serde_json::from_str::(line)) - // One diagnostic line might have multiple suggestions - .filter_map(|cargo_msg| rustfix::collect_suggestions(&cargo_msg.message, &only)) - .collect(); - - try!(handle_suggestions(suggestions, mode)); - - Ok(()) + process::exit(102); } -#[derive(Deserialize)] -struct CargoMessage { - message: Diagnostic, -} - -fn get_json(extra_args: &[&str], clippy: bool) -> Result { - let build_cmd = if clippy { - "clippy" - } else { - "rustc" - }; - let output = try!(Command::new("cargo") - .args(&[build_cmd, "--message-format", "json"]) - .arg("--") - .args(extra_args) - .output()); - - Ok(String::from_utf8(output.stdout)?) -} +fn cargo_fix() -> Result<(), Error> { + // Spin up our lock server which our subprocesses will use to synchronize + // fixes. + let _lockserver = lock::Server::new()?.start()?; + + let cargo = env::var_os("CARGO").unwrap_or("cargo".into()); + let mut cmd = Command::new(&cargo); + // TODO: shouldn't hardcode `check` here, we want to allow things like + // `cargo fix bench` or something like that + // + // TODO: somehow we need to force `check` to actually do something here, if + // `cargo check` was previously run it won't actually do anything again. + cmd.arg("check"); + cmd.args(env::args().skip(2)); // skip `cmd-fix fix` + + // Override the rustc compiler as ourselves. That way whenever rustc would + // run we run instead and have an opportunity to inject fixes. + let me = env::current_exe() + .context("failed to learn about path to current exe")?; + cmd.env("RUSTC", &me) + .env("__CARGO_FIX_NOW_RUSTC", "1"); + if let Some(rustc) = env::var_os("RUSTC") { + cmd.env("RUSTC_ORIGINAL", rustc); + } -#[derive(PartialEq, Eq, Debug, Clone, Copy)] -enum AutofixMode { - /// Do not apply any fixes automatically - None, - // /// Only apply suggestions of a whitelist of lints - // Whitelist, - // /// Check the confidence flag supplied by rustc - // Confidence, - /// Automatically apply all unambiguous suggestions - Yolo, + // An now execute all of Cargo! This'll fix everything along the way. + // + // TODO: we probably want to do something fancy here like collect results + // from the client processes and print out a summary of what happened. + let status = cmd.status() + .with_context(|e| { + format!("failed to execute `{}`: {}", cargo.to_string_lossy(), e) + })?; + exit_with(status); } -fn prelude(suggestion: &Replacement) { - let snippet = &suggestion.snippet; - if snippet.text.1.is_empty() { - // Check whether this suggestion wants to be inserted before or after another line - let wants_to_be_on_own_line = suggestion.replacement.ends_with('\n') - || suggestion.replacement.starts_with('\n'); - if wants_to_be_on_own_line { - println!("{}", "Insert line:".yellow().bold()); - } else { - println!("{}", "At:".yellow().bold()); - println!( - "{lead}{text}{tail}", - lead = indent(4, &snippet.text.0), - text = "v".red(), - tail = snippet.text.2, - ); - println!("{}\n", indent(snippet.text.0.len() as u32, "^").red()); - println!("{}\n", "insert:".yellow().bold()); +fn cargo_fix_rustc() -> Result<(), Error> { + // Try to figure out what we're compiling by looking for a rust-like file + // that exists. + let filename = env::args() + .skip(1) + .filter(|s| s.ends_with(".rs")) + .filter(|s| Path::new(s).exists()) + .next(); + + let rustc = env::var_os("RUSTC_ORIGINAL").unwrap_or("rustc".into()); + + // Our goal is to fix only the crates that the end user is interested in. + // That's very likely to only mean the crates in the workspace the user is + // working on, not random crates.io crates. + // + // To that end we only actually try to fix things if it looks like we're + // compiling a Rust file and it *doesn't* have an absolute filename. That's + // not the best heuristic but matches what Cargo does today at least. + if let Some(path) = filename { + if !Path::new(&path).is_absolute() { + rustfix_crate(rustc.as_ref(), &path)?; } - } else { - println!("{}\n", "Replace:".yellow().bold()); - println!( - "{lead}{text}{tail}\n\n\ - {with}\n", - with = "with:".yellow().bold(), - lead = indent(4, &snippet.text.0), - text = snippet.text.1.red(), - tail = snippet.text.2, - ); - } -} - -fn handle_suggestions( - suggestions: Vec, - mode: AutofixMode, -) -> Result<(), ProgramError> { - let mut accepted_suggestions: Vec = vec![]; - - if suggestions.is_empty() { - println!("I don't have any suggestions for you right now. Check back later!"); - return Ok(()); } - 'suggestions: for suggestion in suggestions { - print!("\n\n{info}: {message}\n", - info = "Info".green().bold(), - message = split_at_lint_name(&suggestion.message)); - for snippet in suggestion.snippets { - print!("{arrow} {file}:{range}\n", - arrow = " -->".blue().bold(), - file = snippet.file_name, - range = snippet.line_range); - } - - let mut i = 0; - for solution in &suggestion.solutions { - println!("\n{}", solution.message); - - // check whether we can squash all suggestions into a list - if solution.replacements.len() > 1 { - let first = solution.replacements[0].clone(); - let all_suggestions_replace_the_same_span = solution - .replacements - .iter() - .all(|s| first.snippet.file_name == s.snippet.file_name - && first.snippet.line_range == s.snippet.line_range); - if all_suggestions_replace_the_same_span { - prelude(&first); - for suggestion in &solution.replacements { - println!("[{}]: {}", i, suggestion.replacement.trim()); - i += 1; - } - continue; - } - } - for suggestion in &solution.replacements { - print!("[{}]: ", i); - prelude(suggestion); - println!("{}", indent(4, &suggestion.replacement)); - i += 1; - } - } - println!(); + // TODO: if we executed rustfix above and the previous rustc invocation was + // successful and this `status()` is not, then we should revert all fixes + // we applied, present a scary warning, and then move on. + let mut cmd = Command::new(&rustc); + cmd.args(env::args().skip(1)); + exit_with(cmd.status().context("failed to spawn rustc")?); +} - if mode == AutofixMode::Yolo && suggestion.solutions.len() == 1 && suggestion.solutions[0].replacements.len() == 1 { - let mut solutions = suggestion.solutions; - let mut replacements = solutions.remove(0).replacements; - accepted_suggestions.push(replacements.remove(0)); - println!("automatically applying suggestion (--yolo)"); - continue 'suggestions; +fn rustfix_crate(rustc: &Path, filename: &str) -> Result<(), Error> { + // If not empty, filter by these lints + // + // TODO: Implement a way to specify this + let only = HashSet::new(); + + // First up we want to make sure that each crate is only checked by one + // process at a time. If two invocations concurrently check a crate then + // it's likely to corrupt it. + // + // Currently we do this by assigning the name on our lock to the first + // argument that looks like a Rust file. + let _lock = lock::Client::lock(filename)?; + + let mut cmd = Command::new(&rustc); + cmd.args(env::args().skip(1)); + cmd.arg("--error-format=json"); + let output = cmd.output() + .with_context(|_| format!("failed to execute `{}`", rustc.display()))?; + + // Sift through the output of the compiler to look for JSON messages + // indicating fixes that we can apply. Note that we *do not* look at the + // exit status here, that's intentional! We want to apply fixes even if + // there are compiler errors. + let stderr = str::from_utf8(&output.stderr) + .context("failed to parse rustc stderr as utf-8")?; + + let suggestions = stderr.lines() + .filter(|x| !x.is_empty()) + + // Parse each line of stderr ignoring errors as they may not all be json + .filter_map(|line| serde_json::from_str::(line).ok()) + + // From each diagnostic try to extract suggestions from rustc + .filter_map(|diag| rustfix::collect_suggestions(&diag, &only)); + + // Collect suggestions by file so we can apply them one at a time later. + let mut file_map = HashMap::new(); + for suggestion in suggestions { + // Make sure we've got a file associated with this suggestion and all + // snippets point to the same location. Right now it's not clear what + // we would do with multiple locations. + let (file_name, range) = match suggestion.snippets.get(0) { + Some(s) => (s.file_name.clone(), s.line_range), + None => continue, + }; + if !suggestion.snippets.iter().all(|s| { + s.file_name == file_name && s.line_range == range + }) { + continue } - 'userinput: loop { - print!("{arrow} {user_options}\n\ - {prompt} ", - arrow = "==>".green().bold(), - prompt = " >".green().bold(), - user_options = USER_OPTIONS.green()); - - flush!(); - let mut input = String::new(); - try!(std::io::stdin().read_line(&mut input)); - - match input.trim() { - "s" => { - println!("Skipped."); - continue 'suggestions; - } - "q" => { - println!("Thanks for playing!"); - break 'suggestions; - } - "a" => { - return Err(ProgramError::UserAbort); - } - "r" => { - if suggestion.solutions.len() == 1 && suggestion.solutions[0].replacements.len() == 1 { - let mut solutions = suggestion.solutions; - accepted_suggestions.push(solutions.remove(0).replacements.remove(0)); - println!("Suggestion accepted. I'll remember that and apply it later."); - continue 'suggestions; - } else { - println!("{error}: multiple suggestions apply, please pick a number", - error = "Error".red().bold()); - } - } - s => { - if let Ok(i) = usize::from_str(s) { - let replacement = suggestion.solutions - .iter() - .flat_map(|sol| sol.replacements.iter()) - .nth(i); - if let Some(replacement) = replacement { - accepted_suggestions.push(replacement.clone()); - println!("Suggestion accepted. I'll remember that and apply it later."); - continue 'suggestions; - } else { - println!("{error}: {i} is not a valid suggestion index", - error = "Error".red().bold(), - i = i); - } - } else { - println!("{error}: I didn't quite get that. {user_options}", - error = "Error".red().bold(), - user_options = USER_OPTIONS); - continue 'userinput; - } - } - } - } + file_map.entry(file_name) + .or_insert_with(Vec::new) + .push(suggestion); } - if !accepted_suggestions.is_empty() { - println!("Good work. Let me just apply these {} changes!", - accepted_suggestions.len()); - - for suggestion in accepted_suggestions.iter().rev() { - try!(apply_suggestion(suggestion)); - - print!("."); - flush!(); + for (file, suggestions) in file_map { + // Attempt to read the source code for this file. If this fails then + // that'd be pretty surprising, so log a message and otherwise keep + // going. + let mut code = String::new(); + if let Err(e) = File::open(&file).and_then(|mut f| f.read_to_string(&mut code)) { + warn!("failed to read `{}`: {}", file, e); + continue } - - println!("\nDone."); + let new_code = rustfix::apply_suggestions(&code, &suggestions); + File::create(&file) + .and_then(|mut f| f.write_all(new_code.as_bytes())) + .with_context(|_| format!("failed to write file `{}`", file))?; } Ok(()) } -quick_error! { - /// All possible errors in programm lifecycle - #[derive(Debug)] - pub enum ProgramError { - UserAbort { - display("Let's get outta here!") - } - /// Missing File - NoFile { - display("No input file given") - } - SubcommandError(subcommand: String, output: String) { - display("Error executing subcommand `{}`", subcommand) - description(output) - } - /// Error while dealing with file or stdin/stdout - Io(err: std::io::Error) { - from() - cause(err) - display("I/O error") - description(err.description()) - } - Utf8Error(err: std::string::FromUtf8Error) { - from() - display("Error reading input as UTF-8") - } - /// Error with deserialization - Serde(err: serde_json::Error) { - from() - cause(err) - display("Serde JSON error") - description(err.description()) +fn exit_with(status: ExitStatus) -> ! { + #[cfg(unix)] + { + use std::os::unix::prelude::*; + if let Some(signal) = status.signal() { + eprintln!("child failed with signal `{}`", signal); + process::exit(2); } } -} - -// Helpers -// ------- - -fn read_file_to_string(file_name: &str) -> Result { - let mut file = try!(File::open(file_name)); - let mut buffer = String::new(); - try!(file.read_to_string(&mut buffer)); - Ok(buffer) -} - -fn not_empty(s: &&str) -> bool { - !s.trim().is_empty() -} - -fn split_at_lint_name(s: &str) -> String { - s.split(", #[") - .collect::>() - .join("\n #[") // Length of whitespace == length of "Info: " -} - -fn indent(size: u32, s: &str) -> String { - let whitespace: String = std::iter::repeat(' ').take(size as usize).collect(); - - s.lines() - .map(|l| format!("{}{}", whitespace, l)) - .collect::>() - .join("\n") -} - -/// Apply suggestion to a file -/// -/// Please beware of ugly hacks below! Originally, I wanted to replace byte ranges, but sadly the -/// ranges rustc's JSON output gives me do not correspond to the parts of the file they are meant -/// to correspond to. So, for now, let's just replace lines! -/// -/// This function is as stupid as possible. Make sure you call for the replacemnts in one file in -/// reverse order to not mess up the lines for replacements further down the road. -fn apply_suggestion(suggestion: &Replacement) -> Result<(), ProgramError> { - let mut file_content = try!(read_file_to_string(&suggestion.snippet.file_name)); - let new_content = rustfix::apply_suggestion(&mut file_content, suggestion); - - let mut file = try!(File::create(&suggestion.snippet.file_name)); - let new_content = new_content.as_bytes(); - - try!(file.set_len(new_content.len() as u64)); - try!(file.write_all(new_content)); - - Ok(()) + process::exit(status.code().unwrap_or(3)); } diff --git a/cargo-fix/tests/all/dependencies.rs b/cargo-fix/tests/all/dependencies.rs new file mode 100644 index 0000000..af3e9d8 --- /dev/null +++ b/cargo-fix/tests/all/dependencies.rs @@ -0,0 +1,100 @@ +use super::project; + +#[test] +fn fix_path_deps() { + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + + [dependencies] + bar = { path = 'bar' } + + [workspace] + "# + ) + .file("src/lib.rs", r#" + extern crate bar; + + fn add(a: &u32) -> u32 { + a + 1 + } + + pub fn foo() -> u32 { + add(1) + add(1) + } + "#) + .file( + "bar/Cargo.toml", + r#" + [package] + name = "bar" + version = "0.1.0" + "# + ) + .file("bar/src/lib.rs", r#" + fn add(a: &u32) -> u32 { + a + 1 + } + + pub fn foo() -> u32 { + add(1) + add(1) + } + "#) + .build(); + + let stderr = "\ +[CHECKING] bar v0.1.0 (CWD/bar) +[CHECKING] foo v0.1.0 (CWD) +[FINISHED] dev [unoptimized + debuginfo] +"; + p.expect_cmd("cargo fix") + .stdout("") + .stderr(stderr) + .run(); +} + +#[test] +fn do_not_fix_non_relevant_deps() { + let p = project() + .file( + "foo/Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + + [dependencies] + bar = { path = '../bar' } + + [workspace] + "# + ) + .file("foo/src/lib.rs", "") + .file( + "bar/Cargo.toml", + r#" + [package] + name = "bar" + version = "0.1.0" + "# + ) + .file("bar/src/lib.rs", r#" + fn add(a: &u32) -> u32 { + a + 1 + } + + pub fn foo() -> u32 { + add(1) + add(1) + } + "#) + .build(); + + p.expect_cmd("cargo fix") + .cwd("foo") + .status(101) + .run(); +} diff --git a/cargo-fix/tests/all/main.rs b/cargo-fix/tests/all/main.rs new file mode 100644 index 0000000..8547cdf --- /dev/null +++ b/cargo-fix/tests/all/main.rs @@ -0,0 +1,264 @@ +extern crate difference; +extern crate url; + +use std::env; +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::str; +use std::sync::atomic::*; +use std::time::Instant; + +use difference::{Changeset, Difference}; +use url::Url; + +static CNT: AtomicUsize = ATOMIC_USIZE_INIT; +thread_local!(static IDX: usize = CNT.fetch_add(1, Ordering::SeqCst)); + +struct ProjectBuilder { + files: Vec<(String, String)>, +} + +struct Project { + root: PathBuf, +} + +fn project() -> ProjectBuilder { + ProjectBuilder { + files: Vec::new(), + } +} + +fn root() -> PathBuf { + let idx = IDX.with(|x| *x); + + let mut me = env::current_exe().unwrap(); + me.pop(); // chop off exe name + me.pop(); // chop off `deps` + me.pop(); // chop off `debug` / `release` + me.push("generated-tests"); + me.push(&format!("test{}", idx)); + return me +} + +impl ProjectBuilder { + fn file(&mut self, name: &str, contents: &str) -> &mut ProjectBuilder { + self.files.push((name.to_string(), contents.to_string())); + self + } + + fn build(&mut self) -> Project { + if !self.files.iter().any(|f| f.0.ends_with("Cargo.toml")) { + let manifest = r#" + [package] + name = "foo" + version = "0.1.0" + + [workspace] + "#; + self.files.push(("Cargo.toml".to_string(), manifest.to_string())); + } + let root = root(); + drop(fs::remove_dir_all(&root)); + for &(ref file, ref contents) in self.files.iter() { + let dst = root.join(file); + fs::create_dir_all(dst.parent().unwrap()).unwrap(); + fs::File::create(&dst).unwrap().write_all(contents.as_ref()).unwrap(); + } + Project { root } + } +} + +impl Project { + fn expect_cmd<'a>(&'a self, cmd: &'a str) -> ExpectCmd<'a> { + ExpectCmd { + project: self, + cmd: cmd, + stdout: None, + stdout_contains: Vec::new(), + stderr: None, + stderr_contains: Vec::new(), + status: 0, + ran: false, + cwd: None, + } + } +} + +struct ExpectCmd<'a> { + ran: bool, + project: &'a Project, + cmd: &'a str, + stdout: Option, + stdout_contains: Vec, + stderr: Option, + stderr_contains: Vec, + status: i32, + cwd: Option, +} + +impl<'a> ExpectCmd<'a> { + fn status(&mut self, status: i32) -> &mut Self { + self.status = status; + self + } + + fn cwd>(&mut self, path: P) -> &mut Self { + self.cwd = Some(self.project.root.join(path)); + self + } + + fn stdout(&mut self, s: &str) -> &mut Self { + self.stdout = Some(s.to_string()); + self + } + + fn stderr(&mut self, s: &str) -> &mut Self { + self.stderr = Some(s.to_string()); + self + } + + fn stderr_contains(&mut self, s: &str) -> &mut Self { + self.stderr_contains.push(s.to_string()); + self + } + + fn run(&mut self) { + self.ran = true; + let mut parts = self.cmd.split_whitespace(); + let mut cmd = Command::new(parts.next().unwrap()); + cmd.args(parts); + match self.cwd { + Some(ref p) => { cmd.current_dir(p); } + None => { cmd.current_dir(&self.project.root); } + } + + let mut me = env::current_exe().unwrap(); + me.pop(); // chop off exe name + me.pop(); // chop off `deps` + + let mut new_path = Vec::new(); + new_path.push(me); + new_path.extend( + env::split_paths(&env::var_os("PATH").unwrap_or(Default::default())), + ); + cmd.env("PATH", env::join_paths(&new_path).unwrap()); + + println!("\n···················································"); + println!("running {:?}", cmd); + let start = Instant::now(); + let output = match cmd.output() { + Ok(output) => output, + Err(err) => panic!("failed to spawn: {}", err), + }; + let dur = start.elapsed(); + println!("dur: {}.{:03}ms", dur.as_secs(), dur.subsec_nanos() / 1_000_000); + println!("exit: {}", output.status); + if output.stdout.len() > 0 { + println!("stdout ---\n{}", String::from_utf8_lossy(&output.stdout)); + } + if output.stderr.len() > 0 { + println!("stderr ---\n{}", String::from_utf8_lossy(&output.stderr)); + } + println!("···················································"); + let code = match output.status.code() { + Some(code) => code, + None => panic!("super extra failure: {}", output.status), + }; + if code != self.status { + panic!("expected exit code `{}` got `{}`", self.status, code); + } + self.match_std(&output.stdout, &self.stdout, &self.stdout_contains); + self.match_std(&output.stderr, &self.stderr, &self.stderr_contains); + } + + fn match_std(&self, actual: &[u8], expected: &Option, contains: &[String]) { + let actual = match str::from_utf8(actual) { + Ok(s) => s, + Err(_) => panic!("std wasn't utf8"), + }; + let actual = self.clean(actual); + if let Some(ref expected) = *expected { + diff(&self.clean(expected), &actual); + } + for s in contains { + let s = self.clean(s); + if actual.contains(&s) { + continue + } + println!("\nfailed to find contents within output stream\n\ + expected to find\n {}\n\nwithin:\n\n{}\n\n", + s, + actual); + panic!("test failed"); + } + } + + fn clean(&self, s: &str) -> String { + let url = Url::from_file_path(&self.project.root).unwrap(); + let s = s.replace("[CHECKING]", " Checking") + .replace("[FINISHED]", " Finished") + .replace("[COMPILING]", " Compiling") + .replace(&url.to_string(), "CWD") + .replace(&self.project.root.display().to_string(), "CWD") + .replace("\\", "/"); + let lines = s.lines() + .map(|s| { + let i = match s.find("target(s) in") { + Some(i) => i, + None => return s.to_string(), + }; + if s.trim().starts_with("Finished") { + s[..i].to_string() + } else { + s.to_string() + } + }); + let mut ret = String::new(); + for (i, line) in lines.enumerate() { + if i != 0 { + ret.push_str("\n"); + } + ret.push_str(&line); + } + ret + } +} + +impl<'a> Drop for ExpectCmd<'a> { + fn drop(&mut self) { + if !self.ran { + panic!("forgot to run this command"); + } + } +} + +fn diff(expected: &str, actual: &str) { + let changeset = Changeset::new(expected.trim(), actual.trim(), "\n"); + + let mut different = false; + for diff in changeset.diffs { + let (prefix, diff) = match diff { + Difference::Same(_) => continue, + Difference::Add(add) => ("+", add), + Difference::Rem(rem) => ("-", rem), + }; + if !different { + println!("differences found (+ == actual, - == expected):\n"); + different = true; + } + for diff in diff.lines() { + println!("{} {}", prefix, diff); + } + } + if different { + println!(""); + panic!("found some differences"); + } +} + +mod dependencies; +mod smoke; +mod subtargets; +mod warnings; diff --git a/cargo-fix/tests/all/smoke.rs b/cargo-fix/tests/all/smoke.rs new file mode 100644 index 0000000..c1c5ca2 --- /dev/null +++ b/cargo-fix/tests/all/smoke.rs @@ -0,0 +1,89 @@ +use super::project; + +#[test] +fn no_changes_necessary() { + let p = project() + .file("src/lib.rs", "") + .build(); + + let stderr = "\ +[CHECKING] foo v0.1.0 (CWD) +[FINISHED] dev [unoptimized + debuginfo] +"; + p.expect_cmd("cargo fix") + .stdout("") + .stderr(stderr) + .run(); +} + +#[test] +fn fixes_missing_ampersand() { + let p = project() + .file("src/lib.rs", r#" + fn add(a: &u32) -> u32 { + a + 1 + } + + pub fn foo() -> u32 { + add(1) + } + "#) + .build(); + + let stderr = "\ +[CHECKING] foo v0.1.0 (CWD) +[FINISHED] dev [unoptimized + debuginfo] +"; + p.expect_cmd("cargo fix") + .stdout("") + .stderr(stderr) + .run(); +} + +#[test] +fn fixes_two_missing_ampersands() { + let p = project() + .file("src/lib.rs", r#" + fn add(a: &u32) -> u32 { + a + 1 + } + + pub fn foo() -> u32 { + add(1) + add(1) + } + "#) + .build(); + + let stderr = "\ +[CHECKING] foo v0.1.0 (CWD) +[FINISHED] dev [unoptimized + debuginfo] +"; + p.expect_cmd("cargo fix") + .stdout("") + .stderr(stderr) + .run(); +} + +#[test] +fn tricky_ampersand() { + let p = project() + .file("src/lib.rs", r#" + fn add(a: &u32) -> u32 { + a + 1 + } + + pub fn foo() -> u32 { + add(1) + add(1) + } + "#) + .build(); + + let stderr = "\ +[CHECKING] foo v0.1.0 (CWD) +[FINISHED] dev [unoptimized + debuginfo] +"; + p.expect_cmd("cargo fix") + .stdout("") + .stderr(stderr) + .run(); +} diff --git a/cargo-fix/tests/all/subtargets.rs b/cargo-fix/tests/all/subtargets.rs new file mode 100644 index 0000000..4cd99fb --- /dev/null +++ b/cargo-fix/tests/all/subtargets.rs @@ -0,0 +1,69 @@ +use super::project; + +#[test] +fn fixes_missing_ampersand() { + let p = project() + .file("src/main.rs", r#" + fn add(a: &u32) -> u32 { a + 1 } + fn main() { add(1); } + "#) + .file("src/lib.rs", r#" + fn add(a: &u32) -> u32 { a + 1 } + pub fn foo() -> u32 { add(1) } + + #[test] + pub fn foo2() { add(1); } + "#) + .file("tests/a.rs", r#" + fn add(a: &u32) -> u32 { a + 1 } + #[test] + pub fn foo() { add(1); } + "#) + .file("examples/foo.rs", r#" + fn add(a: &u32) -> u32 { a + 1 } + fn main() { add(1); } + "#) + .file("build.rs", r#" + fn add(a: &u32) -> u32 { a + 1 } + fn main() { add(1); } + "#) + .build(); + + let stderr = "\ +[COMPILING] foo v0.1.0 (CWD) +[FINISHED] dev [unoptimized + debuginfo] +"; + p.expect_cmd("cargo fix --all-targets").stdout("").stderr(stderr).run(); + p.expect_cmd("cargo build").run(); + p.expect_cmd("cargo test").run(); +} + +#[test] +fn fix_features() { + let p = project() + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + + [features] + bar = [] + + [workspace] + "# + ) + .file("src/lib.rs", r#" + fn add(a: &u32) -> u32 { a + 1 } + + #[cfg(feature = "bar")] + pub fn foo() -> u32 { add(1) } + "#) + .build(); + + p.expect_cmd("cargo fix").run(); + p.expect_cmd("cargo build").run(); + p.expect_cmd("cargo fix --features bar").run(); + p.expect_cmd("cargo build --features bar").run(); +} diff --git a/cargo-fix/tests/all/warnings.rs b/cargo-fix/tests/all/warnings.rs new file mode 100644 index 0000000..46d521b --- /dev/null +++ b/cargo-fix/tests/all/warnings.rs @@ -0,0 +1,15 @@ +use super::project; + +#[test] +fn shows_warnings() { + let p = project() + .file("src/lib.rs", r#" + use std::default::Default; + + pub fn foo() { + } + "#) + .build(); + + p.expect_cmd("cargo fix").stderr_contains("warning: unused import").run(); +}