diff --git a/.github/settings.yml b/.github/settings.yml index b7e759a4..285ba003 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -125,13 +125,13 @@ branches: # Required. Require branches to be up to date before merging. strict: true # Required. The list of status checks to require in order to merge into this branch - contexts: [ "bors" ] + contexts: [ ] # Required. Enforce all configured restrictions for administrators. Set to true to enforce required status checks for repository administrators. Set to null to disable. enforce_admins: false - # Disabled for bors to work + # Disabled for mergify to work required_linear_history: false # Required. Restrict who can push to this branch. Team and user restrictions are only available for organization-owned repositories. Set to null to disable. restrictions: - apps: [ "bors" ] + apps: [ "mergify" ] users: [] teams: [] diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index a9a8d60e..56f6c93b 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -14,7 +14,7 @@ jobs: matrix: os: - macos-latest - - ubuntu-20.04 + - ubuntu-latest runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 494d08ed..8cf94905 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -14,7 +14,7 @@ jobs: matrix: os: - macos-latest - - ubuntu-20.04 + - ubuntu-latest - windows-2019 runs-on: ${{ matrix.os }} steps: diff --git a/.mergify.yml b/.mergify.yml new file mode 100644 index 00000000..bc261914 --- /dev/null +++ b/.mergify.yml @@ -0,0 +1,26 @@ +queue_rules: + - name: default + merge_conditions: + - check-success=Evaluate flake.nix + - check-success=build (macos-latest) + - check-success=build (ubuntu-latest) + - check-success=build_and_test (macos-latest) + - check-success=build_and_test (ubuntu-latest) + - check-success=build_and_test (windows-2019) + - check-success=clippy_check + - check-success=devShell default [x86_64-linux] + - check-success=gh-pages + - check-success=package default [x86_64-linux] + - check-success=package docs [x86_64-linux] +defaults: + actions: + queue: + allow_merging_configuration_change: true + method: rebase +pull_request_rules: + - name: merge using the merge queue + conditions: + - base=main + - label~=merge-queue|dependencies + actions: + queue: {} diff --git a/CHANGELOG.md b/CHANGELOG.md index d6bb6103..7c7259ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,26 @@ +0.6.0 / 2023-09-28 +================== + + * docs: better formatter-spec structure, lots of fixes everywhere + * docs: use mkdocs-numtide (#227) + * feat: clap integration (#214) + * feat: improve the formatter load error (#211) + * feat: more concise stats (#190) + * feat: replace custom log with env_logger (#199) + * feat: update rust to 1.65.0 (#193) + * fix: Don't ignore --config-file when using --stdin (#231) + * fix: change default log level to info (#212) + * fix: default log level should be 'Warn' not 'Off' (#207) + * fix: log formatter error as warn instead of info (#207) + * fix: minor typos (#248) + * fix: stop symlinked tree root being deref'd (#252) + * fix: treefmt --stdin when changes are moved into tempfile (#225) + * flake: expose the list of supported systems (#228) + * flake: make treefmt the default package + * flake: move treefmt.withConfig to a separate repo (#204) + * repo: switch default branch to main + 0.5.0 / 2022-12-01 ================== diff --git a/Cargo.lock b/Cargo.lock index 9f6d5162..c6878a27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1127,7 +1127,7 @@ dependencies = [ [[package]] name = "treefmt" -version = "0.5.0" +version = "0.6.0" dependencies = [ "anyhow", "clap 4.3.11", diff --git a/Cargo.toml b/Cargo.toml index e85383df..b7c6ed15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "treefmt" -version = "0.5.0" +version = "0.6.0" edition = "2018" description = "one CLI to format your repo" license = "MIT" diff --git a/bors.toml b/bors.toml deleted file mode 100644 index 705f46cb..00000000 --- a/bors.toml +++ /dev/null @@ -1,14 +0,0 @@ -cut_body_after = "" # don't include text from the PR body in the merge commit message -status = [ - "Evaluate flake.nix", - "build (macos-latest)", - "build (ubuntu-20.04)", - "build_and_test (macos-latest)", - "build_and_test (ubuntu-20.04)", - "build_and_test (windows-2019)", - "clippy", - "clippy_check", - "devShell default [x86_64-linux]", - "gh-pages", - "package default [x86_64-linux]" -] diff --git a/ci.sh b/ci.sh index 0be76717..2bcf804a 100755 --- a/ci.sh +++ b/ci.sh @@ -4,6 +4,7 @@ set -exuo pipefail # Quick sanity check cargo test +cargo test -- --ignored --test-threads=1 # Check that no code needs reformatting. Acts as a minimal integration test. cargo run -- --fail-on-change diff --git a/default.nix b/default.nix index a45f28a0..4bfc7146 100644 --- a/default.nix +++ b/default.nix @@ -12,6 +12,10 @@ let lib = nixpkgs.lib; + # Override license so that we can build zerotierone without + # having to re-import nixpkgs. + terraform' = nixpkgs.terraform.overrideAttrs (old: { meta = { }; }); + rustVersionExtended = rustVersion.override { # include source for IDE's and other tools that resolve the source automatically via # $(rustc --print sysroot)/lib/rustlib/src/rust @@ -85,7 +89,7 @@ let rufo shellcheck shfmt - terraform + terraform' # Docs mkdocs-numtide diff --git a/docs/treefmt-configuration.md b/docs/treefmt-configuration.md index a117fbbb..7d45a1c6 100644 --- a/docs/treefmt-configuration.md +++ b/docs/treefmt-configuration.md @@ -2,7 +2,7 @@ `treefmt` can only be run in the presence of `treefmt.toml` where files are mapped to specific code formatters. -Usually the config file sits in the project root folder. If you're running `treefmt` in one of the project's folders, then `treefmt` will look for the config in the parent folders up until the project's root. However, you can place the config anywhere in your project's file tree and specify the path in the the ---config-file flag. +Usually the config file sits in the project root folder. If you're running `treefmt` in one of the project's folders, then `treefmt` will look for the config in the parent folders up until the project's root. However, you can place the config anywhere in your project's file tree and specify the path in the ---config-file flag. The typical section of `treefmt.toml` looks like this: @@ -10,7 +10,7 @@ The typical section of `treefmt.toml` looks like this: [formatter.] command = "" options = [""...] -includes = [""] +includes = [""...] ``` ...where name is just an identifier. diff --git a/examples/html/index.html b/examples/html/index.html index 67a59b51..201633de 100644 --- a/examples/html/index.html +++ b/examples/html/index.html @@ -1,4 +1,4 @@ - + diff --git a/flake.lock b/flake.lock index fdd2ad0d..01641d5a 100644 --- a/flake.lock +++ b/flake.lock @@ -7,11 +7,11 @@ ] }, "locked": { - "lastModified": 1670441596, - "narHash": "sha256-+T487QnluBT5F9tVk0chG/zzv+9zzTrx3o7rlOBK7ps=", + "lastModified": 1696343447, + "narHash": "sha256-B2xAZKLkkeRFG5XcHHSXXcP7To9Xzr59KXeZiRf4vdQ=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "8d0e2444ab05f79df93b70e5e497f8c708eb6b9b", + "rev": "c9afaba3dfa4085dbd2ccb38dfade5141e33d9d4", "type": "github" }, "original": { @@ -21,12 +21,15 @@ } }, "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1659877975, - "narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=", + "lastModified": 1681202837, + "narHash": "sha256-H+Rh19JDwRtpVPAWp64F+rlEtxUWBAQW28eAi3SRSzg=", "owner": "numtide", "repo": "flake-utils", - "rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0", + "rev": "cfacdce06f30d2b68473a46042957675eebb3401", "type": "github" }, "original": { @@ -42,11 +45,11 @@ ] }, "locked": { - "lastModified": 1680358379, - "narHash": "sha256-f4v6oIcqoQfUKfOsWbFkgKeGZkKTAo0nRwvL0IQGGnQ=", + "lastModified": 1687786869, + "narHash": "sha256-KhaNnOTjj9FgPLtRHTFGa1RFXvSc+nF1UPcBiYf/CCY=", "owner": "numtide", "repo": "mkdocs-numtide", - "rev": "af6c4a5f7c0a59da3b557795f57dcae5707523ac", + "rev": "b3008171c75083f2bf2f1dc4e6781d4737dfaa49", "type": "github" }, "original": { @@ -57,11 +60,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1671160997, - "narHash": "sha256-fcPZMRjAkUhrfXwoq2RPejfhtPnQ+aI5CTr4x8d0JPs=", + "lastModified": 1697009197, + "narHash": "sha256-viVRhBTFT8fPJTb1N3brQIpFZnttmwo3JVKNuWRVc3s=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "4172cdda7e56a48065475fb98c57b03b83c1fde4", + "rev": "01441e14af5e29c9d27ace398e6dd0b293e25a54", "type": "github" }, "original": { @@ -77,7 +80,7 @@ "mkdocs-numtide": "mkdocs-numtide", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay", - "systems": "systems" + "systems": "systems_2" } }, "rust-overlay": { @@ -88,11 +91,11 @@ ] }, "locked": { - "lastModified": 1671157233, - "narHash": "sha256-gvQaOKaV1UK6IzsFzkVLsEavGxnAsQFT3zUqcg0RXLU=", + "lastModified": 1697249410, + "narHash": "sha256-OmsnxNsjBB1DJlUuJyzDJJ7psbm4/VzokNT+o0ajzFQ=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "7da2f6b3a0c32f661cb2864d7fbd1d7e6f0c7543", + "rev": "dce60ca7fca201014868c08a612edb73a998310f", "type": "github" }, "original": { @@ -103,11 +106,26 @@ }, "systems": { "locked": { - "lastModified": 1680980633, - "narHash": "sha256-mz27VfAExPMYuoWsb1cf++DIyUWWBEbAvXD0BJ+AT/E=", + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", "owner": "nix-systems", "repo": "default", - "rev": "4e9a51a15ceb27e5141819142a7d2ee827943fc8", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index 18915a53..b13565b1 100644 --- a/flake.nix +++ b/flake.nix @@ -16,28 +16,37 @@ inputs.mkdocs-numtide.inputs.nixpkgs.follows = "nixpkgs"; outputs = { self, nixpkgs, flake-parts, mkdocs-numtide, systems, ... }@inputs: - flake-parts.lib.mkFlake { inherit self; } { - systems = import systems; - perSystem = { system, pkgs, ... }: - let - packages = import ./. { - inherit system; - mkdocs-numtide = mkdocs-numtide.packages.${system}.default; + (flake-parts.lib.evalFlakeModule + { inherit inputs; } + { + systems = import systems; + perSystem = { system, self', lib, pkgs, ... }: + let + packages = import ./. { + inherit system; + mkdocs-numtide = mkdocs-numtide.packages.${system}.default; + }; + in + { + # This contains a mix of packages, modules, ... + legacyPackages = packages; + + # Allow `nix run github:numtide/treefmt`. + packages.default = packages.treefmt; + + packages.docs = mkdocs-numtide.lib.${system}.mkDocs { + name = "treefmt-docs"; + src = ./.; + }; + + checks = + let + packages = lib.mapAttrs' (n: lib.nameValuePair "package-${n}") self'.packages; + devShells = lib.mapAttrs' (n: lib.nameValuePair "devShell-${n}") self'.devShells; + in + packages // devShells; + + devShells.default = packages.devShell; }; - in - { - # This contains a mix of packages, modules, ... - legacyPackages = packages; - - # Allow `nix run github:numtide/treefmt`. - packages.default = packages.treefmt; - - packages.docs = mkdocs-numtide.lib.${system}.mkDocs { - name = "treefmt-docs"; - src = ./.; - }; - - devShells.default = packages.devShell; - }; - }; + }).config.flake; } diff --git a/src/command/mod.rs b/src/command/mod.rs index 7dc7f191..13f8b162 100644 --- a/src/command/mod.rs +++ b/src/command/mod.rs @@ -12,7 +12,8 @@ use crate::config; use crate::expand_path; use anyhow::anyhow; use clap::Parser; -use clap_verbosity_flag::Verbosity; +use clap_verbosity_flag::{InfoLevel, Verbosity}; +use log::warn; use std::{ env, path::{Path, PathBuf}, @@ -53,7 +54,7 @@ pub struct Cli { /// Log verbosity is based off the number of v used #[clap(flatten)] - pub verbose: Verbosity, + pub verbose: Verbosity, /// Run as if treefmt was started in instead of the current working directory. #[arg(short = 'C', default_value = ".", value_parser = parse_path)] @@ -76,15 +77,27 @@ pub struct Cli { pub formatters: Option>, } +fn current_dir() -> anyhow::Result { + // First we try and read $PWD as it will (hopefully) honour symlinks + env::var("PWD") + .map(PathBuf::from) + // If we can't read the $PWD var then we fall back to use getcwd + .or_else(|_| { + warn!("PWD environment variable not set, if current directory is a symlink it will be dereferenced"); + env::current_dir() + }) + .map_err(anyhow::Error::new) +} + fn parse_path(s: &str) -> anyhow::Result { // Obtain current dir and ensure is absolute - let cwd = match env::current_dir() { + let cwd = match current_dir() { Ok(dir) => dir, Err(err) => return Err(anyhow!("{}", err)), }; assert!(cwd.is_absolute()); - // TODO: Include validation for incorrect paths or caracters + // TODO: Include validation for incorrect paths or characters let path = Path::new(s); // Make sure the path is an absolute path. @@ -158,3 +171,41 @@ pub fn run_cli(cli: &Cli) -> anyhow::Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + #[test] + #[ignore = "std::env::set_var: should not be run in parallel."] + fn current_dir_prefers_pwd_env_var() { + use crate::command::current_dir; + use std::env; + use std::path::PathBuf; + + let expected_pwd = "/tmp"; + let prev_pwd = env::var("PWD").unwrap(); + env::set_var("PWD", expected_pwd); + + let result = current_dir().unwrap(); + + env::set_var("PWD", prev_pwd); + + assert_eq!(result, PathBuf::from(expected_pwd)); + } + + #[test] + #[ignore = "std::env::set_var: should not be run in parallel."] + fn current_dir_uses_dereferenced_path_when_pwd_env_var_not_set() { + use crate::command::current_dir; + use std::env; + + let expected_pwd = env::current_dir().unwrap(); + let prev_pwd = env::var("PWD").unwrap(); + env::remove_var("PWD"); + + let result = current_dir().unwrap(); + + env::set_var("PWD", prev_pwd); + + assert_eq!(result, expected_pwd); + } +} diff --git a/src/engine.rs b/src/engine.rs index c0b7a314..2d973fe9 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -1,9 +1,10 @@ //! The main formatting engine logic is in this module. +use crate::config::Root; use crate::{config, eval_cache::CacheManifest, formatter::FormatterName}; use crate::{expand_path, formatter::Formatter, get_meta, get_path_meta, FileMeta}; use anyhow::anyhow; -use ignore::WalkBuilder; +use ignore::{Walk, WalkBuilder}; use log::{debug, error, info, warn}; use rayon::prelude::*; use std::fs::File; @@ -39,21 +40,7 @@ pub fn run_treefmt( assert!(cache_dir.is_absolute()); assert!(treefmt_toml.is_absolute()); - let start_time = Instant::now(); - let mut phase_time = Instant::now(); - let mut timed_debug = |description: &str| { - let now = Instant::now(); - debug!( - "{}: {:.2?} (Δ {:.2?})", - description, - start_time.elapsed(), - now.saturating_duration_since(phase_time) - ); - phase_time = now; - }; - - let mut traversed_files: usize = 0; - let mut matched_files: usize = 0; + let mut stats = Statistics::init(); // Make sure all the given paths are absolute. Ignore the ones that point outside of the project root. let paths = paths.iter().fold(vec![], |mut sum, path| { @@ -78,50 +65,15 @@ pub fn run_treefmt( // Load the treefmt.toml file let project_config = config::from_path(treefmt_toml)?; - let global_excludes = project_config - .global - .map(|g| g.excludes) - .unwrap_or_default(); - - timed_debug("load config"); - - // Load all the formatter instances from the config. - let mut expected_count = 0; - - let formatters = project_config.formatter.into_iter().fold( - BTreeMap::new(), - |mut sum, (name, mut fmt_config)| { - expected_count += 1; - fmt_config.excludes.extend_from_slice(&global_excludes); - match Formatter::from_config(tree_root, &name, &fmt_config) { - Ok(fmt_matcher) => match selected_formatters { - Some(f) => { - if f.contains(&name) { - sum.insert(fmt_matcher.name.clone(), fmt_matcher); - } - } - None => { - sum.insert(fmt_matcher.name.clone(), fmt_matcher); - } - }, - Err(err) => { - if allow_missing_formatter { - error!("Ignoring formatter #{} due to error: {}", name, err) - } else { - error!("Failed to load formatter #{}: {}", name, err) - } - } - }; - sum - }, - ); - - timed_debug("load formatters"); + stats.timed_debug("load config"); - // Check the number of configured formatters matches the number of formatters loaded - if !(allow_missing_formatter || formatters.len() == expected_count) { - return Err(anyhow!("One or more formatters are missing")); - } + let formatters = load_formatters( + project_config, + tree_root, + allow_missing_formatter, + selected_formatters, + &mut stats, + )?; // Load the eval cache let mut cache = if no_cache || clear_cache { @@ -130,71 +82,17 @@ pub fn run_treefmt( } else { CacheManifest::load(cache_dir, treefmt_toml) }; - timed_debug("load cache"); + stats.timed_debug("load cache"); if !no_cache { // Insert the new formatter configs cache.update_formatters(formatters.clone()); } - // Configure the tree walker - let walker = { - // For some reason the WalkBuilder must start with one path, but can add more paths later. - // unwrap: we checked before that there is at least one path in the vector - let mut builder = WalkBuilder::new(paths.first().unwrap()); - // Add the other paths - for path in paths[1..].iter() { - builder.add(path); - } - // TODO: builder has a lot of interesting options. - // TODO: use build_parallel with a Visitor. - // See https://docs.rs/ignore/0.4.17/ignore/struct.WalkParallel.html#method.visit - builder.build() - }; - - // Start a collection of formatter names to path to mtime - let mut matches: BTreeMap> = BTreeMap::new(); - - // Now traverse the filesystem and classify each file. We also want the file mtime to see if it changed - // afterwards. - for walk_entry in walker { - match walk_entry { - Ok(dir_entry) => { - if let Some(file_type) = dir_entry.file_type() { - // Ignore folders and symlinks. We don't want to format files outside the - // directory, and if the symlink destination is in the repo, it'll be matched - // when iterating over it. - if !file_type.is_dir() && !file_type.is_symlink() { - // Keep track of how many files were traversed - traversed_files += 1; - - let path = dir_entry.path().to_path_buf(); - // FIXME: complain if multiple matchers match the same path. - for (_, fmt) in formatters.clone() { - if fmt.clone().is_match(&path) { - // Keep track of how many files were associated with a formatter - matched_files += 1; + let walker = build_walker(paths); - // unwrap: since the file exists, we assume that the metadata is also available - let mtime = get_meta(&dir_entry.metadata().unwrap()); - - matches - .entry(fmt.name) - .or_insert_with(BTreeMap::new) - .insert(path.clone(), mtime); - } - } - } - } else { - warn!("Couldn't get file type for {:?}", dir_entry.path()) - } - } - Err(err) => { - warn!("traversal error: {}", err); - } - } - } - timed_debug("tree walk"); + let matches = collect_matches_from_walker(walker, &formatters, &mut stats); + stats.timed_debug("tree walk"); // Filter out all of the paths that were already in the cache let matches = if !no_cache { @@ -203,10 +101,11 @@ pub fn run_treefmt( matches }; - timed_debug("filter_matches"); + stats.timed_debug("filter_matches"); // Keep track of the paths that are actually going to be formatted let filtered_files: usize = matches.values().map(|x| x.len()).sum(); + stats.set_filtered_files(filtered_files); // Now run all the formatters and collect the formatted paths. let new_matches: BTreeMap> = matches @@ -250,24 +149,62 @@ pub fn run_treefmt( } }) .collect::>>>()?; - timed_debug("format"); + stats.timed_debug("format"); if !no_cache { // Record the new matches in the cache cache.add_results(new_matches.clone()); // And write to disk cache.write(cache_dir, treefmt_toml); - timed_debug("write cache"); + stats.timed_debug("write cache"); } - // Diff the old matches with the new matches let changed_matches: BTreeMap> = + diff_matches(new_matches, matches, &mut stats); + + let mut ret: anyhow::Result<()> = Ok(()); + // Fail if --fail-on-change was passed. + if stats.reformatted_files > 0 && fail_on_change { + // Switch the display type to long + // TODO: this will be configurable by the user in the future. + stats.display = DisplayType::Long; + ret = Err(anyhow!("fail-on-change")); + } + + match stats.display { + DisplayType::Summary => { + stats.print_summary(); + } + DisplayType::Long => { + stats.print_summary(); + println!("\nformatted files:"); + for (name, paths) in changed_matches { + if !paths.is_empty() { + println!("{}:", name); + for path in paths { + println!("- {}", path.display()); + } + } + } + } + } + + ret +} + +/// Diff the old matches with the new matches +fn diff_matches( + new_matches: BTreeMap>, + matches: BTreeMap>, + stats: &mut Statistics, +) -> BTreeMap> { + let diffed_matches = new_matches .into_iter() .fold(BTreeMap::new(), |mut sum, (name, new_paths)| { // unwrap: we know that the name exists let old_paths = matches.get(&name).unwrap().clone(); - let filtered = new_paths + let filtered: Vec = new_paths .iter() .filter_map(|(k, v)| { // unwrap: we know that the key exists @@ -282,70 +219,121 @@ pub fn run_treefmt( sum.insert(name, filtered); sum }); - // Get how many files were reformatted. - let reformatted_files: usize = changed_matches.values().map(|x| x.len()).sum(); - // TODO: this will be configurable by the user in the future. - let mut display_type = DisplayType::Summary; - let mut ret: anyhow::Result<()> = Ok(()); + let reformatted_files = diffed_matches.values().map(|x| x.len()).sum(); + stats.set_reformatted_files(reformatted_files); + diffed_matches +} - // Fail if --fail-on-change was passed. - if reformatted_files > 0 && fail_on_change { - // Switch the display type to long - display_type = DisplayType::Long; - ret = Err(anyhow!("fail-on-change")) +/// Load all the formatter instances from the config. +/// Returns an error if a formatter is missing and not explicitly allowed. +fn load_formatters( + root: Root, + tree_root: &Path, + allow_missing_formatter: bool, + selected_formatters: &Option>, + stats: &mut Statistics, +) -> anyhow::Result> { + let mut expected_count = 0; + let formatter = root.formatter; + let global_excludes = root.global.map(|g| g.excludes).unwrap_or_default(); + let formatters = + formatter + .into_iter() + .fold(BTreeMap::new(), |mut sum, (name, mut fmt_config)| { + expected_count += 1; + fmt_config.excludes.extend_from_slice(&global_excludes); + match Formatter::from_config(tree_root, &name, &fmt_config) { + Ok(fmt_matcher) => match selected_formatters { + Some(f) => { + if f.contains(&name) { + sum.insert(fmt_matcher.name.clone(), fmt_matcher); + } + } + None => { + sum.insert(fmt_matcher.name.clone(), fmt_matcher); + } + }, + Err(err) => { + if allow_missing_formatter { + error!("Ignoring formatter #{} due to error: {}", name, err) + } else { + error!("Failed to load formatter #{}: {}", name, err) + } + } + }; + sum + }); + + stats.timed_debug("load formatters"); + // Check if the number of configured formatters matches the number of formatters loaded + if !(allow_missing_formatter || formatters.len() == expected_count) { + return Err(anyhow!("One or more formatters are missing")); } + Ok(formatters) +} - match display_type { - DisplayType::Summary => { - print_summary( - traversed_files, - matched_files, - filtered_files, - reformatted_files, - start_time, - ); - } - DisplayType::Long => { - print_summary( - traversed_files, - matched_files, - filtered_files, - reformatted_files, - start_time, - ); - println!("\nformatted files:"); - for (name, paths) in changed_matches { - if !paths.is_empty() { - println!("{}:", name); - for path in paths { - println!("- {}", path.display()); +/// Walk over the entries and collect it's matches. +/// The matches are a collection of formatter names to path to mtime. +/// We want the file mtime to see if it changed afterwards. +fn collect_matches_from_walker( + walker: Walk, + formatters: &BTreeMap, + stats: &mut Statistics, +) -> BTreeMap> { + let mut matches = BTreeMap::new(); + + for walk_entry in walker { + match walk_entry { + Ok(dir_entry) => { + if let Some(file_type) = dir_entry.file_type() { + // Ignore folders and symlinks. We don't want to format files outside the + // directory, and if the symlink destination is in the repo, it'll be matched + // when iterating over it. + if !file_type.is_dir() && !file_type.is_symlink() { + stats.traversed_file(); + + let path = dir_entry.path().to_path_buf(); + // FIXME: complain if multiple matchers match the same path. + for (_, fmt) in formatters.clone() { + if fmt.clone().is_match(&path) { + stats.matched_file(); + + // unwrap: since the file exists, we assume that the metadata is also available + let mtime = get_meta(&dir_entry.metadata().unwrap()); + + matches + .entry(fmt.name) + .or_insert_with(BTreeMap::new) + .insert(path.clone(), mtime); + } + } } + } else { + warn!("Couldn't get file type for {:?}", dir_entry.path()) } } + Err(err) => { + warn!("traversal error: {}", err); + } } } - - ret + matches } -fn print_summary( - traversed_files: usize, - matched_files: usize, - filtered_files: usize, - reformatted_files: usize, - start_time: std::time::Instant, -) { - println!( - r#" -{} files changed in {:.0?} (found {}, matched {}, cache misses {}) - "#, - reformatted_files, - start_time.elapsed(), - traversed_files, - matched_files, - filtered_files, - ); +/// Configure and build the tree walker +fn build_walker(paths: Vec) -> Walk { + // For some reason the WalkBuilder must start with one path, but can add more paths later. + // unwrap: we checked before that there is at least one path in the vector + let mut builder = WalkBuilder::new(paths.first().unwrap()); + // Add the other paths + for path in paths[1..].iter() { + builder.add(path); + } + // TODO: builder has a lot of interesting options. + // TODO: use build_parallel with a Visitor. + // See https://docs.rs/ignore/0.4.17/ignore/struct.WalkParallel.html#method.visit + builder.build() } /// Run the treefmt in a stdin buffer, and print it out back to stdout @@ -454,3 +442,1067 @@ pub fn run_treefmt_stdin( ret } + +struct Statistics { + display: DisplayType, + start_time: Instant, + phase_time: Instant, + traversed_files: usize, + matched_files: usize, + filtered_files: usize, + reformatted_files: usize, +} + +impl Statistics { + /// Initialize the timer + fn init() -> Self { + let start_time = Instant::now(); + let phase_time = Instant::now(); + Self { + display: DisplayType::Summary, + start_time, + phase_time, + traversed_files: 0, + matched_files: 0, + filtered_files: 0, + reformatted_files: 0, + } + } + /// Keep track of how many files were traversed + fn traversed_file(&mut self) { + self.traversed_files += 1; + } + /// Keep track of how many files were associated with a formatter + fn matched_file(&mut self) { + self.matched_files += 1; + } + fn timed_debug(&mut self, description: &str) { + let now = Instant::now(); + debug!( + "{}: {:.2?} (Δ {:.2?})", + description, + self.start_time.elapsed(), + now.saturating_duration_since(self.phase_time) + ); + self.phase_time = now; + } + + fn set_filtered_files(&mut self, filtered_files: usize) { + self.filtered_files = filtered_files; + } + + fn set_reformatted_files(&mut self, reformatted_files: usize) { + self.reformatted_files = reformatted_files; + } + fn print_summary(&self) { + println!( + "{} files changed in {:.0?} (found {}, matched {}, cache misses {})", + self.reformatted_files, + self.start_time.elapsed(), + self.traversed_files, + self.matched_files, + self.filtered_files, + ); + } +} + +#[cfg(test)] +mod tests { + use crate::config::from_string; + + use super::*; + + pub mod utils { + use std::fs::{self, File}; + use std::io::Write; + use std::os::unix::prelude::OpenOptionsExt; + use std::path::{Path, PathBuf}; + + use tempfile::TempDir; + + pub fn tmp_mkdir() -> TempDir { + tempfile::tempdir().unwrap() + } + pub fn mkdir

(path: P) + where + P: AsRef, + { + fs::create_dir_all(path).unwrap(); + } + pub fn write_file

(path: P, stream: &str) + where + P: AsRef, + { + let mut file = File::create(path).unwrap(); + file.write_all(stream.as_bytes()).unwrap(); + } + pub fn write_binary_file

(path: P, stream: &str) + where + P: AsRef, + { + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .mode(0o770) + .open(path) + .unwrap(); + file.write_all(stream.as_bytes()).unwrap(); + } + + pub struct Git<'a> { + path: PathBuf, + exclude: Option<&'a str>, + ignore: Option<&'a str>, + git_ignore: Option<&'a str>, + /// Whether to write into `.git` directory. + /// Useful in case testing outside of git is desired. + write_git: bool, + } + + impl<'a> Git<'a> { + pub fn new(root: PathBuf) -> Self { + Self { + path: root, + exclude: None, + ignore: None, + git_ignore: None, + write_git: true, + } + } + pub fn exclude(&mut self, content: &'a str) -> &mut Self { + self.exclude = Some(content); + self + } + pub fn ignore(&mut self, content: &'a str) -> &mut Self { + self.ignore = Some(content); + self + } + pub fn git_ignore(&mut self, content: &'a str) -> &mut Self { + self.ignore = Some(content); + self + } + pub fn write_git(&mut self, write_git: bool) -> &mut Self { + self.write_git = write_git; + self + } + /// Creates all configured directories and files. + pub fn create(&mut self) { + let git_dir = self.path.join(".git"); + if self.write_git { + mkdir(&git_dir); + } + if let Some(exclude) = self.exclude { + if !self.write_git { + panic!( + "Can't write git specific personal excludes without a .git directory." + ) + } + let info_dir = git_dir.join("info"); + mkdir(&info_dir); + let exclude_file = info_dir.join("exclude"); + write_file(&exclude_file, exclude); + } + if let Some(ignore) = self.ignore { + let ignore_file = self.path.join(".ignore"); + write_file(&ignore_file, ignore); + } + if let Some(gitignore) = self.git_ignore { + let ignore_file = self.path.join(".gitignore"); + write_file(&ignore_file, gitignore); + } + } + } + } + + #[test] + fn test_diff_matches_no_changes_no_files() { + let new_matches = BTreeMap::new(); + let matches = BTreeMap::new(); + let mut stats = Statistics::init(); + + let result = diff_matches(new_matches, matches, &mut stats); + + assert_eq!(result.len(), 0); + assert_eq!(stats.reformatted_files, 0); + } + #[test] + fn test_diff_matches_no_changes() { + let mk_file_meta = |mtime, size| FileMeta { mtime, size }; + let mut new_matches = BTreeMap::new(); + let mut matches = BTreeMap::new(); + let mut stats = Statistics::init(); + + let metadata = [ + mk_file_meta(0, 0), + mk_file_meta(1, 1), + mk_file_meta(2, 2), + mk_file_meta(3, 3), + ]; + let files = ["test", "test1", "test2", "test3"]; + let file_metadata: BTreeMap<_, _> = metadata + .iter() + .zip(files.iter()) + .map(|(meta, name)| (PathBuf::from(name), *meta)) + .collect(); + + new_matches.insert(FormatterName::new("gofmt"), file_metadata.clone()); + new_matches.insert(FormatterName::new("rustfmt"), file_metadata.clone()); + matches.insert(FormatterName::new("gofmt"), file_metadata.clone()); + matches.insert(FormatterName::new("rustfmt"), file_metadata); + + let result = diff_matches(new_matches, matches, &mut stats); + + assert_eq!(result.len(), 2); + assert_eq!(stats.reformatted_files, 0); + } + + #[test] + fn test_diff_matches_with_changes() { + let mk_file_meta = |mtime, size| FileMeta { mtime, size }; + let mut new_matches = BTreeMap::new(); + let mut matches = BTreeMap::new(); + let mut stats = Statistics::init(); + + let metadata = [ + mk_file_meta(0, 0), + mk_file_meta(1, 1), + mk_file_meta(2, 2), + mk_file_meta(3, 3), + ]; + let metadata_gofmt = [ + mk_file_meta(0, 0), + mk_file_meta(2, 1), // Changed + mk_file_meta(2, 2), + mk_file_meta(5, 3), // Changed + ]; + let metadata_rustfmt = [ + mk_file_meta(0, 0), + mk_file_meta(1, 1), + mk_file_meta(3, 2), // Changed + mk_file_meta(3, 3), + ]; + let files = ["test", "test1", "test2", "test3"]; + let file_metadata: BTreeMap<_, _> = metadata + .iter() + .zip(files.iter()) + .map(|(meta, name)| (PathBuf::from(name), *meta)) + .collect(); + let file_metadata_gofmt: BTreeMap<_, _> = metadata_gofmt + .iter() + .zip(files.iter()) + .map(|(meta, name)| (PathBuf::from(name), *meta)) + .collect(); + let file_metadata_rusftfmt: BTreeMap<_, _> = metadata_rustfmt + .iter() + .zip(files.iter()) + .map(|(meta, name)| (PathBuf::from(name), *meta)) + .collect(); + + new_matches.insert(FormatterName::new("gofmt"), file_metadata.clone()); + new_matches.insert(FormatterName::new("rustfmt"), file_metadata); + matches.insert(FormatterName::new("gofmt"), file_metadata_gofmt); + matches.insert(FormatterName::new("rustfmt"), file_metadata_rusftfmt); + + let result = diff_matches(new_matches, matches, &mut stats); + + assert_eq!(result.len(), 2); + assert_eq!(stats.reformatted_files, 3); + } + + #[test] + fn test_formatter_loading_some() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + " + ); + + let root = from_string(&config).unwrap(); + let tree_root = tmpdir.path(); + + let selected_formatters = None; + let allow_missing_formatter = false; + let mut stats = Statistics::init(); + + let formatters = load_formatters( + root, + tree_root, + allow_missing_formatter, + &selected_formatters, + &mut stats, + ) + .unwrap(); + + assert_eq!(formatters.len(), 2); + } + #[test] + #[should_panic] + fn test_formatter_loading_some_missing_formatter() { + let tmpdir = utils::tmp_mkdir(); + + // black is the missing formatter here + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + utils::write_binary_file(&nixpkgs_fmt, " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + " + ); + + let root = from_string(&config).unwrap(); + let tree_root = tmpdir.path(); + + let selected_formatters = None; + let allow_missing_formatter = false; + let mut stats = Statistics::init(); + + load_formatters( + root, + tree_root, + allow_missing_formatter, + &selected_formatters, + &mut stats, + ) + .unwrap(); + } + #[test] + fn test_formatter_loading_some_missing_formatter_allowed() { + let tmpdir = utils::tmp_mkdir(); + + // black is the missing formatter here + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + utils::write_binary_file(&nixpkgs_fmt, " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + " + ); + + let root = from_string(&config).unwrap(); + let tree_root = tmpdir.path(); + + let selected_formatters = None; + let allow_missing_formatter = true; + let mut stats = Statistics::init(); + + let formatters = load_formatters( + root, + tree_root, + allow_missing_formatter, + &selected_formatters, + &mut stats, + ) + .unwrap(); + assert_eq!(formatters.len(), 1); + } + #[test] + #[should_panic] + fn test_formatter_loading_missing_includes() { + let tmpdir = utils::tmp_mkdir(); + + let config = r#" + [formatter.python] + command = "black" + includes = ["*.py"] + + [formatter.nix] + command = "nixpkgs-fmt" + "#; + + let root = from_string(config).unwrap(); + let tree_root = tmpdir.path(); + + let selected_formatters = None; + let allow_missing_formatter = false; + let mut stats = Statistics::init(); + + load_formatters( + root, + tree_root, + allow_missing_formatter, + &selected_formatters, + &mut stats, + ) + .unwrap(); + } + #[test] + fn test_formatter_loading_selected_allow_missing() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + let elm_fmt = tmpdir.path().join("elm-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + utils::write_binary_file(&elm_fmt, " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + + [formatter.elm] + command = {elm_fmt:?} + options = [\"--yes\"] + includes = [\"*.elm\"] + " + ); + + let root = from_string(&config).unwrap(); + let tree_root = tmpdir.path(); + + let selected_formatters = Some(vec!["python".into(), "nix".into(), "gofmt".into()]); + let allow_missing_formatter = true; + let mut stats = Statistics::init(); + + let formatters = load_formatters( + root, + tree_root, + allow_missing_formatter, + &selected_formatters, + &mut stats, + ) + .unwrap(); + + assert_eq!(formatters.len(), 2); + } + #[test] + #[should_panic] + fn test_formatter_loading_selected_missing() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + let elm_fmt = tmpdir.path().join("elm-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + utils::write_binary_file(&elm_fmt, " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + + [formatter.elm] + command = {elm_fmt:?} + options = [\"--yes\"] + includes = [\"*.elm\"] + " + ); + + let root = from_string(&config).unwrap(); + let tree_root = tmpdir.path(); + + // Selecting a different formatter + let selected_formatters = Some(vec!["python".into(), "nix".into(), "gofmt".into()]); + let allow_missing_formatter = false; + let mut stats = Statistics::init(); + + load_formatters( + root, + tree_root, + allow_missing_formatter, + &selected_formatters, + &mut stats, + ) + .unwrap(); + } + + #[test] + fn test_walker_no_matches() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + let elm_fmt = tmpdir.path().join("elm-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + utils::write_binary_file(&elm_fmt, " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + + [formatter.elm] + command = {elm_fmt:?} + options = [\"--yes\"] + includes = [\"*.elm\"] + " + ); + + let root = from_string(&config).unwrap(); + let tree_root = tmpdir.path(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let _matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 3); + assert_eq!(stats.matched_files, 0); + } + #[test] + fn test_walker_some_matches() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + let elm_fmt = tmpdir.path().join("elm-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + utils::write_binary_file(&elm_fmt, " "); + let tree_root = tmpdir.path(); + + let files = vec!["test", "test1", "test3", ".test4"]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(format!("{file}.elm")), " "); + utils::write_file(tree_root.join(file), " "); + } + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + + [formatter.elm] + command = {elm_fmt:?} + options = [\"--yes\"] + includes = [\"*.elm\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let _matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 15); + assert_eq!(stats.matched_files, 9); + } + #[test] + fn test_walker_some_matches_specific_include() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + let elm_fmt = tmpdir.path().join("elm-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + utils::write_binary_file(&elm_fmt, " "); + let tree_root = tmpdir.path(); + + let files = vec!["test", "test1", "test3", ".test4"]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(format!("{file}.elm")), " "); + utils::write_file(tree_root.join(file), " "); + } + + let config = format!( + // The hidden file is not being matched. + " + [formatter.python] + command = {black:?} + includes = [\"*.py\", \"test\", \".test4\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + + [formatter.elm] + command = {elm_fmt:?} + options = [\"--yes\"] + includes = [\"*.elm\", \"test3\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let _matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 15); + assert_eq!(stats.matched_files, 11); + } + #[test] + fn test_walker_some_matches_local_exclude() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + let elm_fmt = tmpdir.path().join("elm-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + utils::write_binary_file(&elm_fmt, " "); + let tree_root = tmpdir.path(); + + let files = vec!["test", "test1", "test3", ".test4"]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(file), " "); + } + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\", \"test\"] + excludes = [\"test.py\", \"test1.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + excludes = [\"test.nix\"] + + [formatter.elm] + command = {elm_fmt:?} + options = [\"--yes\"] + includes = [\"*.elm\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 12); + assert_eq!(stats.matched_files, 4); + let python_matches: Vec = matches + .get(&FormatterName::new("python")) + .unwrap() + .keys() + .cloned() + .collect(); + let elm_matches = matches.get(&FormatterName::new("elm")).is_none(); + let nix_matches: Vec = matches + .get(&FormatterName::new("nix")) + .unwrap() + .keys() + .cloned() + .collect(); + let expected_python_matches: Vec = ["test", "test3.py"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + let expected_nix_matches: Vec = ["test1.nix", "test3.nix"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + assert_eq!(python_matches, expected_python_matches); + assert_eq!(nix_matches, expected_nix_matches); + assert!(elm_matches); + } + #[test] + fn test_walker_some_matches_global_exclude() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + let elm_fmt = tmpdir.path().join("elm-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + utils::write_binary_file(&elm_fmt, " "); + let tree_root = tmpdir.path(); + + let files = vec![ + "test", + "test1", + "test3", + ".test4", + "not-a-match", + "still-not-a-match", + ]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(file), " "); + } + + let config = format!( + " + [global] + excludes = [\"*not*\"] + [formatter.python] + command = {black:?} + includes = [\"*.py\", \"test\"] + excludes = [\"test.py\", \"test1.py\"] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + excludes = [\"test.nix\"] + + [formatter.elm] + command = {elm_fmt:?} + options = [\"--yes\"] + includes = [\"*.elm\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 18); + assert_eq!(stats.matched_files, 4); + let python_matches: Vec = matches + .get(&FormatterName::new("python")) + .unwrap() + .keys() + .cloned() + .collect(); + let elm_matches = matches.get(&FormatterName::new("elm")).is_none(); + let nix_matches: Vec = matches + .get(&FormatterName::new("nix")) + .unwrap() + .keys() + .cloned() + .collect(); + let expected_python_matches: Vec = ["test", "test3.py"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + let expected_nix_matches: Vec = ["test1.nix", "test3.nix"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + assert_eq!(python_matches, expected_python_matches); + assert_eq!(nix_matches, expected_nix_matches); + assert!(elm_matches); + } + #[test] + fn test_walker_some_matches_gitignore() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + let tree_root = tmpdir.path(); + + utils::Git::new(tmpdir.path().to_path_buf()) + .git_ignore("test1.nix\nresult") + .create(); + + let files = vec!["test", "test1", ".test4"]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(file), " "); + } + utils::write_file(tree_root.join("result"), " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\", \"test\"] + excludes = [\"test.py\" ] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + excludes = [\"test.nix\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 7); + assert_eq!(stats.matched_files, 2); + let python_matches: Vec = matches + .get(&FormatterName::new("python")) + .unwrap() + .keys() + .cloned() + .collect(); + let nix_matches: bool = matches.get(&FormatterName::new("nix")).is_none(); + let expected_python_matches: Vec = ["test", "test1.py"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + assert_eq!(python_matches, expected_python_matches); + assert!(nix_matches); + } + #[test] + fn test_walker_some_matches_ignore_gitignore() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + let tree_root = tmpdir.path(); + + utils::Git::new(tmpdir.path().to_path_buf()) + .git_ignore("test1.nix") + .ignore("result\ntest1\nignored*") + .create(); + + let files = vec!["test", "test1", ".test4", "ignored"]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(file), " "); + } + utils::write_file(tree_root.join("result"), " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\", \"test\"] + excludes = [\"test.py\" ] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + excludes = [\"test.nix\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 7); + assert_eq!(stats.matched_files, 3); + let python_matches: Vec = matches + .get(&FormatterName::new("python")) + .unwrap() + .keys() + .cloned() + .collect(); + let nix_matches: Vec = matches + .get(&FormatterName::new("nix")) + .unwrap() + .keys() + .cloned() + .collect(); + let expected_python_matches: Vec = ["test", "test1.py"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + let expected_nix_matches: Vec = + ["test1.nix"].iter().map(|p| tree_root.join(p)).collect(); + assert_eq!(python_matches, expected_python_matches); + assert_eq!(nix_matches, expected_nix_matches); + } + #[test] + fn test_walker_some_matches_ignore_gitignore_not_a_git_directory() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + let tree_root = tmpdir.path(); + + utils::Git::new(tmpdir.path().to_path_buf()) + .git_ignore("test1.nix") + .ignore("result\nignored*") + .write_git(false) + .create(); + + let files = vec!["test", "test1", ".test4", "ignored"]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(file), " "); + } + utils::write_file(tree_root.join("result"), " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\", \"test\"] + excludes = [\"test.py\" ] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + excludes = [\"test.nix\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 8); + assert_eq!(stats.matched_files, 3); + let python_matches: Vec = matches + .get(&FormatterName::new("python")) + .unwrap() + .keys() + .cloned() + .collect(); + let nix_matches: Vec = matches + .get(&FormatterName::new("nix")) + .unwrap() + .keys() + .cloned() + .collect(); + let expected_python_matches: Vec = ["test", "test1.py"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + let expected_nix_matches: Vec = + ["test1.nix"].iter().map(|p| tree_root.join(p)).collect(); + assert_eq!(python_matches, expected_python_matches); + assert_eq!(nix_matches, expected_nix_matches); + } + #[test] + fn test_walker_some_matches_exclude_gitignore() { + let tmpdir = utils::tmp_mkdir(); + + let black = tmpdir.path().join("black"); + let nixpkgs_fmt = tmpdir.path().join("nixpkgs-fmt"); + utils::write_binary_file(&black, " "); + utils::write_binary_file(&nixpkgs_fmt, " "); + let tree_root = tmpdir.path(); + + utils::Git::new(tmpdir.path().to_path_buf()) + .git_ignore("test1.nix") + .exclude("result") + .create(); + + let files = vec!["test", "test1", ".test4"]; + + for file in files { + utils::write_file(tree_root.join(format!("{file}.py")), " "); + utils::write_file(tree_root.join(format!("{file}.nix")), " "); + utils::write_file(tree_root.join(file), " "); + } + utils::write_file(tree_root.join("result"), " "); + + let config = format!( + " + [formatter.python] + command = {black:?} + includes = [\"*.py\", \"test\"] + excludes = [\"test.py\" ] + + [formatter.nix] + command = {nixpkgs_fmt:?} + includes = [\"*.nix\"] + excludes = [\"test.nix\"] + " + ); + + let root = from_string(&config).unwrap(); + let mut stats = Statistics::init(); + + let formatters = load_formatters(root, tree_root, false, &None, &mut stats).unwrap(); + + let walker = build_walker(vec![tree_root.to_path_buf()]); + let matches = collect_matches_from_walker(walker, &formatters, &mut stats); + + assert_eq!(stats.traversed_files, 7); + assert_eq!(stats.matched_files, 2); + let python_matches: Vec = matches + .get(&FormatterName::new("python")) + .unwrap() + .keys() + .cloned() + .collect(); + let nix_matches: bool = matches.get(&FormatterName::new("nix")).is_none(); + let expected_python_matches: Vec = ["test", "test1.py"] + .iter() + .map(|p| tree_root.join(p)) + .collect(); + assert_eq!(python_matches, expected_python_matches); + assert!(nix_matches); + } +} diff --git a/src/eval_cache.rs b/src/eval_cache.rs index d7beb69e..4ca64495 100644 --- a/src/eval_cache.rs +++ b/src/eval_cache.rs @@ -74,7 +74,7 @@ impl CacheManifest { } } - /// Seralizes back the manifest into place. + /// Serializes back the manifest into place. pub fn try_write(self, cache_dir: &Path, treefmt_toml: &Path) -> Result<()> { let manifest_path = get_manifest_path(cache_dir, treefmt_toml); debug!("cache: writing to {}", manifest_path.display()); @@ -92,7 +92,7 @@ impl CacheManifest { Ok(()) } - /// Seralizes back the manifest into place. + /// Serializes back the manifest into place. pub fn write(self, cache_dir: &Path, treefmt_toml: &Path) { if let Err(err) = self.try_write(cache_dir, treefmt_toml) { warn!("cache: failed to write to disk: {}", err); diff --git a/src/formatter.rs b/src/formatter.rs index dd40cc8a..4b695106 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -15,6 +15,16 @@ use crate::{expand_exe, expand_if_path, expand_path}; #[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct FormatterName(String); +#[cfg(test)] +impl FormatterName { + pub(crate) fn new(name: S) -> Self + where + S: Into, + { + Self(name.into()) + } +} + impl Serialize for FormatterName { fn serialize(&self, serializer: S) -> Result where