diff --git a/src/config.rs b/src/config.rs index 022334b..3d41434 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,5 +18,9 @@ fn default_folders() -> Vec { pub fn read_config() -> Config { let contents = fs::read_to_string("makeultra.toml").expect("ERROR: Could not read makeultra.toml."); - toml::from_str(&contents).unwrap() + let mut config: Config = toml::from_str(&contents).unwrap(); + for rule in &mut config.rules { + rule.compile_output_shape(); + } + config } diff --git a/src/main.rs b/src/main.rs index 5c02831..3068408 100644 --- a/src/main.rs +++ b/src/main.rs @@ -355,16 +355,37 @@ fn generate_children(path: String) { new_file = rule.get_output(&path).to_string(); // "Smart" rule exclusion. - // TODO: Make recursive instead of only evaluating the child rules // Imagine we have a rule that would match *.js, turning it into *.min.js. // Now imagine we have another rule that does something to *.min.js files. // Given the file a.min.js, we need to be able to determine that only the - // *.min.js rule should run: - if matching_rules.len() > 1 { - let new_file_rules = get_matching_rules(&new_file); - if new_file_rules.eq(matching_rules) { + // *.min.js rule should run. + // + // Rules that rewrite a file in-place are exempt: they are + // stored as self-loops in the graph and cannot grow a rule + // chain, so there is never anything to exclude. + if new_file != *path { + // If `path` already looks like an output of this rule, + // the rule was already applied somewhere along the + // chain that produced `path`, and applying it again + // would only pile up endless derivatives (a.min.min.js, + // a.min.min.min.js, ...). Because every file of a chain + // is checked against the output shapes of all of its + // matching rules, this covers chains of any depth + // (e.g. *.js -> *.min.js -> *.pkg.min.js). + if rule.matches_own_output(path) { return; } + + // Fall back to comparing rule sets, which also covers + // `to` templates that only rewrite part of a path: if + // the output file matches exactly the same rules as + // `path`, applying the rule makes no progress. + if matching_rules.len() > 1 { + let new_file_rules = get_matching_rules(&new_file); + if new_file_rules.eq(matching_rules) { + return; + } + } } let mut files = FILES.write(); diff --git a/src/rule.rs b/src/rule.rs index b2b3eb3..06233df 100644 --- a/src/rule.rs +++ b/src/rule.rs @@ -10,6 +10,11 @@ pub struct Rule { #[serde(default)] pub exclude: Option, pub command: String, + /// Regex matching every path that looks like an output of this rule, + /// derived from `to`. Compiled up-front by `compile_output_shape`; + /// `matches_own_output` falls back to compiling on the fly when unset. + #[serde(skip)] + output_shape: Option, } impl Rule { @@ -28,6 +33,82 @@ impl Rule { } false } + + /// Compiles and caches the regex used by `matches_own_output`. Called + /// once per rule after the config is read. + pub fn compile_output_shape(&mut self) { + self.output_shape = Some(output_shape_pattern(&self.to)); + } + + /// Returns `true` if `path` looks like a file this rule already + /// produced, i.e. it matches the shape of the rule's `to` template. + /// Given `to = '$name.min.js'`, this matches `a.min.js`, `a.pkg.min.js`, + /// and `a.min.min.js`, but not `a.js` or `a.min.js.br`. + pub fn matches_own_output(&self, path: &str) -> bool { + match &self.output_shape { + Some(shape) => shape.is_match(path), + None => output_shape_pattern(&self.to).is_match(path), + } + } +} + +/// Converts a rule's `to` replacement template into a regex matching every +/// path the template could have produced: capture group references +/// (`$name`, `${name}`, `$1`) become `.*`, `$$` becomes a literal `$`, and +/// everything else is matched literally. The pattern is anchored at both +/// ends, so templates that only rewrite part of a path produce a shape +/// that simply never matches. +fn output_shape_pattern(to: &str) -> Regex { + let mut pattern = String::from("^"); + let mut chars = to.chars().peekable(); + while let Some(c) = chars.next() { + if c != '$' { + pattern.push_str(®ex::escape(&c.to_string())); + continue; + } + match chars.peek() { + // `$$` is an escaped literal `$`. + Some('$') => { + chars.next(); + pattern.push_str("\\$"); + } + // `${name}` + Some('{') => { + chars.next(); + let mut name = String::new(); + let mut closed = false; + for c in chars.by_ref() { + if c == '}' { + closed = true; + break; + } + name.push(c); + } + if closed { + pattern.push_str(".*"); + } else { + // The regex crate treats an unclosed brace literally. + pattern.push_str(®ex::escape(&format!("${{{}", name))); + } + } + // `$name`, where the name is the longest run of + // `[0-9A-Za-z_]` characters (same as the regex crate). + Some(c) if c.is_ascii_alphanumeric() || *c == '_' => { + while let Some(c) = chars.peek() { + if c.is_ascii_alphanumeric() || *c == '_' { + chars.next(); + } else { + break; + } + } + pattern.push_str(".*"); + } + // A `$` followed by anything else is literal. + _ => pattern.push_str("\\$"), + } + } + pattern.push('$'); + Regex::new(&pattern).expect("Output shape patterns should always be valid.") } impl PartialEq for Rule { @@ -40,32 +121,97 @@ impl PartialEq for Rule { #[cfg(test)] mod tests { + use crate::rule::Rule; + use regex::Regex; + + fn minify_rule() -> Rule { + Rule { + from: Regex::new("(?P.*)\\.js$").unwrap(), + to: String::from("$name.min.js"), + command: "terser $i -o $o".to_string(), + exclude: None, + output_shape: None, + } + } + + fn brotli_rule() -> Rule { + Rule { + from: Regex::new("(?P.*)\\.min\\.js$").unwrap(), + to: String::from("$name.min.js.br"), + command: "brotli -f $i".to_string(), + exclude: None, + output_shape: None, + } + } + #[test] fn generate_rules() { #![allow(clippy::trivial_regex)] use std::{fs::File, io::Write}; - use crate::rule::Rule; use maplit::hashmap; - use regex::Regex; let rules = hashmap! { String::from("minify")=> Rule { - from: Regex::new("(?P.*)\\.js$").unwrap(), - to: String::from("$name.min.js"), - command: "terser $i -o $o".to_string(), exclude: Some(Regex::new("\\.min\\.js$").unwrap()), + ..minify_rule() }, - String::from("brotli") => Rule { - from: Regex::new("(?P.*)\\.min\\.js$").unwrap(), - to: String::from("$name.min.js.br"), - command: "brotli -f $i".to_string(), - exclude: None, - } + String::from("brotli") => brotli_rule(), }; File::create("rules.toml") .unwrap() .write_all(toml::to_string_pretty(&rules).unwrap().as_bytes()) .unwrap(); } + + #[test] + fn matches_own_output_single_level() { + let rule = minify_rule(); + assert!(rule.matches_own_output("a.min.js")); + assert!(rule.matches_own_output("dir/a.min.js")); + assert!(!rule.matches_own_output("a.js")); + assert!(!rule.matches_own_output("a.min.js.br")); + } + + #[test] + fn matches_own_output_any_depth() { + // *.js -> *.min.js -> *.pkg.min.js: files produced further down a + // rule chain still look like outputs of the first rule. + let rule = minify_rule(); + assert!(rule.matches_own_output("a.pkg.min.js")); + assert!(rule.matches_own_output("a.min.min.js")); + } + + #[test] + fn matches_own_output_ignores_other_rules_outputs() { + let rule = brotli_rule(); + assert!(!rule.matches_own_output("a.min.js")); + assert!(rule.matches_own_output("a.min.js.br")); + } + + #[test] + fn matches_own_output_uses_precompiled_shape() { + let mut rule = minify_rule(); + rule.compile_output_shape(); + assert!(rule.matches_own_output("a.min.js")); + assert!(!rule.matches_own_output("a.js")); + } + + #[test] + fn output_shape_braced_and_numbered_references() { + let shape = super::output_shape_pattern("${name}.min.$1"); + assert!(shape.is_match("a.min.js")); + assert!(!shape.is_match("a.js")); + } + + #[test] + fn output_shape_escapes_literals() { + let shape = super::output_shape_pattern("$$cache.$name.js"); + assert!(shape.is_match("$cache.foo.js")); + assert!(!shape.is_match("Xcache.foo.js")); + + // `.` in the template is literal, not a regex wildcard. + let shape = super::output_shape_pattern("$name.min.js"); + assert!(!shape.is_match("a.minxjs")); + } }