Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@ fn default_folders() -> Vec<String> {
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
}
31 changes: 26 additions & 5 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
168 changes: 157 additions & 11 deletions src/rule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ pub struct Rule {
#[serde(default)]
pub exclude: Option<Regex>,
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<Regex>,
}

impl Rule {
Expand All @@ -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(&regex::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(&regex::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 {
Expand All @@ -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<name>.*)\\.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<name>.*)\\.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<name>.*)\\.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<name>.*)\\.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"));
}
}