diff --git a/.github/workflows/ci.generate.ts b/.github/workflows/ci.generate.ts index 5dcec7b..1dc7c06 100644 --- a/.github/workflows/ci.generate.ts +++ b/.github/workflows/ci.generate.ts @@ -1,10 +1,10 @@ import * as yaml from "https://deno.land/std@0.170.0/encoding/yaml.ts"; enum OperatingSystem { - Macx86 = "macos-12", + Macx86 = "macos-13", MacArm = "macos-latest", Windows = "windows-latest", - Linux = "ubuntu-20.04", + Linux = "ubuntu-22.04", } interface ProfileData { @@ -91,6 +91,13 @@ const ci = { RUST_BACKTRACE: "full", }, steps: [ + { + name: "Prepare git", + run: [ + "git config --global core.autocrlf false", + "git config --global core.eol lf", + ].join("\n"), + }, { uses: "actions/checkout@v4" }, { uses: "dsherret/rust-toolchain-file@v1" }, { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ddfe13..23547c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: config: - - os: macos-12 + - os: macos-13 run_tests: 'true' target: x86_64-apple-darwin cross: 'false' @@ -32,23 +32,23 @@ jobs: run_tests: 'true' target: x86_64-pc-windows-msvc cross: 'false' - - os: ubuntu-20.04 + - os: ubuntu-22.04 run_tests: 'true' target: x86_64-unknown-linux-gnu cross: 'false' - - os: ubuntu-20.04 + - os: ubuntu-22.04 run_tests: 'false' target: x86_64-unknown-linux-musl cross: 'false' - - os: ubuntu-20.04 + - os: ubuntu-22.04 run_tests: 'false' target: aarch64-unknown-linux-gnu cross: 'false' - - os: ubuntu-20.04 + - os: ubuntu-22.04 run_tests: 'false' target: aarch64-unknown-linux-musl cross: 'false' - - os: ubuntu-20.04 + - os: ubuntu-22.04 run_tests: 'false' target: riscv64gc-unknown-linux-gnu cross: 'true' @@ -65,6 +65,10 @@ jobs: CARGO_INCREMENTAL: 0 RUST_BACKTRACE: full steps: + - name: Prepare git + run: |- + git config --global core.autocrlf false + git config --global core.eol lf - uses: actions/checkout@v4 - uses: dsherret/rust-toolchain-file@v1 - name: Cache cargo diff --git a/Cargo.lock b/Cargo.lock index d0b60cf..819a514 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -256,6 +256,7 @@ dependencies = [ "pretty_assertions", "serde", "serde_json", + "sha2", "splitty", "tokio", ] @@ -707,9 +708,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.10.8" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", diff --git a/Cargo.toml b/Cargo.toml index 5002e45..afb5b7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ dprint-core = { version = "0.67.0", features = ["process"] } globset = "0.4.14" handlebars = "5.1.2" serde = { version = "1.0.204", features = ["derive"] } +sha2 = "0.10.9" splitty = "1.0.1" tokio = { version = "1.38.0", features = ["time"] } diff --git a/README.md b/README.md index b4fbf46..66dc85d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ This plugin executes CLI commands to format code via stdin (recommended) or via General config: - `cacheKey` - Optional value used to bust dprint's incremental cache (ex. provide `"1"`). This is useful if you want to force formatting to occur because the underlying command's code has changed. + - If you want to automatically calculate the cache key, consider using `command.cacheKeyFiles`. - `timeout` - Number of seconds to allow an executable format to occur before a timeout error occurs (default: `30`). - `cwd` - Recommend setting this to `${configDir}` to force it to use the cwd of the current config file. @@ -61,6 +62,7 @@ Command config: - You may have associations match multiple binaries in order to format a file with multiple binaries instead of just one. The order in the config file will dictate the order the formatting occurs in. - `stdin` - If the text should be provided via stdin (default: `true`) - `cwd` - Current working directory to use when launching this command (default: dprint's cwd or the root `cwd` setting if set) +- `cacheKeyFiles` - A list of paths (relative to `cwd`) to files used to automatically compute a `cacheKey`. This allows automatic invalidation of dprint's incremental cache when any of these files are changed. Command templates (ex. see the prettier example above): @@ -118,7 +120,12 @@ Use the `rustfmt` binary so you can format stdin. "cwd": "${configDir}", "commands": [{ "command": "rustfmt --edition 2021", - "exts": ["rs"] + "exts": ["rs"], + // add the config files for automatic cache invalidation when the rust version or rustfmt config changes + "cacheKeyFiles": [ + "rustfmt.toml", + "rust-toolchain.toml" + ] }] }, "plugins": [ @@ -139,7 +146,11 @@ Consider using [dprint-plugin-prettier](https://dprint.dev/plugins/prettier/) in "commands": [{ "command": "prettier --stdin-filepath {{file_path}} --tab-width {{indent_width}} --print-width {{line_width}}", // add more extensions that prettier should format - "exts": ["js", "ts", "html"] + "exts": ["js", "ts", "html"], + // add the config files for automatic cache invalidation when the prettier config config changes + "cacheKeyFiles": [ + ".prettierrc.json" + ] }] }, "plugins": [ diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 93c0233..87499a9 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,3 +1,3 @@ [toolchain] -channel = "1.79.0" +channel = "1.82.0" components = ["clippy", "rustfmt"] diff --git a/src/configuration.rs b/src/configuration.rs index 3708dd8..55bf7d3 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -1,4 +1,5 @@ use dprint_core::configuration::get_nullable_value; +use dprint_core::configuration::get_nullable_vec; use dprint_core::configuration::get_unknown_property_diagnostics; use dprint_core::configuration::get_value; use dprint_core::configuration::ConfigKeyMap; @@ -11,6 +12,9 @@ use globset::GlobMatcher; use handlebars::Handlebars; use serde::Serialize; use serde::Serializer; +use sha2::Digest; +use sha2::Sha256; +use std::fs::read_to_string; use std::path::Path; use std::path::PathBuf; @@ -40,6 +44,7 @@ pub struct CommandConfiguration { pub associations: Option, pub file_extensions: Vec, pub file_names: Vec, + pub cache_key_files_hash: Option, } impl CommandConfiguration { @@ -97,7 +102,7 @@ impl Configuration { let mut resolved_config = Configuration { is_valid: true, - cache_key: get_value(&mut config, "cacheKey", "0".to_string(), &mut diagnostics), + cache_key: "0".to_string(), line_width: get_value( &mut config, "lineWidth", @@ -126,6 +131,9 @@ impl Configuration { timeout: get_value(&mut config, "timeout", 30, &mut diagnostics), }; + let root_cache_key = get_nullable_value::(&mut config, "cacheKey", &mut diagnostics); + let mut cache_key_file_hashes = Vec::new(); + let root_cwd = get_nullable_value(&mut config, "cwd", &mut diagnostics); if let Some(commands) = config.swap_remove("commands").and_then(|c| c.into_array()) { @@ -142,7 +150,11 @@ impl Configuration { diagnostic.property_name = format!("commands[{}].{}", i, diagnostic.property_name); diagnostic })); - if let Some(command_config) = result.0 { + if let Some(mut command_config) = result.0 { + if let Some(cache_key_files_hash) = command_config.cache_key_files_hash.take() { + cache_key_file_hashes.push(cache_key_files_hash); + } + resolved_config.commands.push(command_config); } } @@ -155,6 +167,10 @@ impl Configuration { diagnostics.extend(get_unknown_property_diagnostics(config)); + if let Some(cache_key) = compute_cache_key(root_cache_key, &cache_key_file_hashes) { + resolved_config.cache_key = cache_key; + } + resolved_config.is_valid = diagnostics.is_empty(); ResolveConfigurationResult { @@ -201,6 +217,50 @@ fn parse_command_obj( } } + let cwd = get_cwd( + get_nullable_value(&mut command_obj, "cwd", &mut diagnostics) + .or_else(|| root_cwd.map(ToOwned::to_owned)), + ); + + let cache_key_files = get_nullable_vec( + &mut command_obj, + "cacheKeyFiles", + |value, i, diagnostics| match value { + ConfigKeyValue::String(value) => Some(cwd.join(value)), + _ => { + diagnostics.push(ConfigurationDiagnostic { + property_name: format!("cacheKeyFiles[{}]", i), + message: "Expected string element.".to_string(), + }); + None + } + }, + &mut diagnostics, + ); + + // compute the hash separately from the config read so we don't do the disk ops if the config is invalid. + let cache_key_files_hash = { + if let Some(cache_key_files) = cache_key_files { + let mut hasher = Sha256::new(); + for file in cache_key_files { + let contents = match read_to_string(&file) { + Ok(contents) => contents, + Err(err) => { + diagnostics.push(ConfigurationDiagnostic { + property_name: "cacheKeyFiles".to_string(), + message: format!("Unable to read file '{}': {}.", file.display(), err), + }); + return (None, diagnostics); + } + }; + hasher.update(contents); + } + Some(format!("{:x}", hasher.finalize())) + } else { + None + } + }; + let config = CommandConfiguration { executable: command.remove(0), args: command, @@ -252,10 +312,7 @@ fn parse_command_obj( } }) }, - cwd: get_cwd( - get_nullable_value(&mut command_obj, "cwd", &mut diagnostics) - .or_else(|| root_cwd.map(ToOwned::to_owned)), - ), + cwd, stdin: get_value(&mut command_obj, "stdin", true, &mut diagnostics), file_extensions: take_string_or_string_vec(&mut command_obj, "exts", &mut diagnostics) .into_iter() @@ -268,6 +325,7 @@ fn parse_command_obj( }) .collect::>(), file_names: take_string_or_string_vec(&mut command_obj, "fileNames", &mut diagnostics), + cache_key_files_hash, }; diagnostics.extend(get_unknown_property_diagnostics(command_obj)); @@ -328,6 +386,33 @@ fn get_cwd(dir: Option) -> PathBuf { } } +fn compute_cache_key( + root_cache_key: Option, + cache_key_file_hashes: &[String], +) -> Option { + match ( + root_cache_key, + compute_cache_key_files_hash(cache_key_file_hashes), + ) { + (Some(root), Some(files)) => Some(format!("{}{}", root, files)), + (Some(root), None) => Some(root), + (None, Some(files)) => Some(files), + (None, None) => None, + } +} + +fn compute_cache_key_files_hash(cache_key_file_hashes: &[String]) -> Option { + if cache_key_file_hashes.is_empty() { + return None; + } + + let mut hasher = Sha256::new(); + for file_hash in cache_key_file_hashes { + hasher.update(file_hash); + } + Some(format!("{:x}", hasher.finalize())) +} + #[cfg(test)] mod tests { use super::*; @@ -481,4 +566,150 @@ mod tests { fn parse_config(value: serde_json::Value) -> ConfigKeyMap { serde_json::from_value(value).unwrap() } + + mod cache_key { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn default_cache_key() { + let unresolved_config = parse_config(json!({ + "commands": [{ + "exts": ["txt"], + "command": "1" + }], + })); + let result = Configuration::resolve(unresolved_config, &Default::default()); + let config = result.config; + assert!(result.diagnostics.is_empty()); + assert_eq!(config.cache_key, "0"); + } + + #[test] + fn top_level_cache_key() { + let unresolved_config = parse_config(json!({ + "cacheKey": "99", + "commands": [{ + "exts": ["txt"], + "command": "1" + }], + })); + let result = Configuration::resolve(unresolved_config, &Default::default()); + assert!(result.diagnostics.is_empty()); + let config = result.config; + assert_eq!(config.cache_key, "99"); + } + + #[test] + fn top_level_cache_key_plus_command_cache_key_is_allowed() { + let unresolved_config = parse_config(json!({ + "cacheKey": "99", + "commands": [{ + "exts": ["txt"], + "command": "1", + "cacheKeyFiles": ["./tests/resources/one-line.txt"] + }], + })); + let result = Configuration::resolve(unresolved_config, &Default::default()); + assert!(result.config.is_valid); + assert_eq!(result.diagnostics, vec![]); + assert_eq!( + result.config.cache_key, + "99c7b3af761ad02238e72bf5a60c94be2f41eec6637ec3ec1bfa853a3a1fb91225" + ); + } + + #[test] + fn command_cache_key_fails_if_file_does_not_exist() { + let unresolved_config = parse_config(json!({ + "commands": [{ + "exts": ["txt"], + "command": "1", + "cacheKeyFiles": ["path/to/missing/file"] + }], + })); + let result = Configuration::resolve(unresolved_config, &Default::default()); + assert!(!result.config.is_valid); + assert_eq!(result.diagnostics.len(), 1); + assert_eq!( + result.diagnostics[0].property_name, + "commands[0].cacheKeyFiles" + ); + assert!(result.diagnostics[0] + .message + .starts_with("Unable to read file")); + } + + #[test] + fn command_cache_key_one_command_one_file() { + let unresolved_config = parse_config(json!({ + "commands": [{ + "exts": ["txt"], + "command": "1", + "cacheKeyFiles": [ + "./tests/resources/one-line.txt" + ] + }], + })); + let result = Configuration::resolve(unresolved_config, &Default::default()); + assert!(result.diagnostics.is_empty()); + let config = result.config; + assert_eq!( + config.cache_key, + "c7b3af761ad02238e72bf5a60c94be2f41eec6637ec3ec1bfa853a3a1fb91225" + ); + } + + #[test] + fn command_cache_key_one_command_multiple_files() { + let unresolved_config = parse_config(json!({ + "commands": [{ + "exts": ["txt"], + "command": "1", + "cacheKeyFiles": [ + "./tests/resources/one-line.txt", + "./tests/resources/multi-line.txt", + ] + }], + })); + let result = Configuration::resolve(unresolved_config, &Default::default()); + assert!(result.diagnostics.is_empty()); + let config = result.config; + assert_eq!( + config.cache_key, + "4321f2e747210582553e6ad8ef5b866d87c357a039cd09cdbdab6ebe33517c1a" + ); + } + + #[test] + fn command_cache_key_multiple_commands() { + let unresolved_config = parse_config(json!({ + "commands": [ + { + "exts": ["txt"], + "command": "1", + "cacheKeyFiles": [ + "./tests/resources/one-line.txt", + "./tests/resources/multi-line.txt", + ] + }, + { + "exts": ["txt"], + "command": "2", + "cacheKeyFiles": [ + "./tests/resources/one-line.txt", + "./tests/resources/multi-line.txt", + ] + }, + ], + })); + let result = Configuration::resolve(unresolved_config, &Default::default()); + assert!(result.diagnostics.is_empty()); + let config = result.config; + assert_eq!( + config.cache_key, + "51eaf161463bb6ba4957327330e27a80d039b7d2c0c27590ebdf844e7eca954a" + ); + } + } } diff --git a/src/lib.rs b/src/lib.rs index a7e3559..340eeeb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,7 +3,4 @@ extern crate dprint_core; pub mod configuration; pub mod handler; -#[cfg(feature = "process")] -pub use main::*; - pub use handler::format_bytes;