use std::{ collections::{HashSet, VecDeque}, path::Path, }; use anyhow::{Context, Error}; use bstr::{ByteSlice, ByteVec}; use clap::Parser; use lightningcss::{ printer::PrinterOptions, stylesheet::{MinifyOptions, ParserOptions, StyleSheet}, }; use serde::{Deserialize, Serialize}; use wax::Glob; const NOTICE_TYPE_PADDING: usize = 24; fn notice(notice_type: S1, message: S2) where S1: AsRef, S2: AsRef, { let notice_type = notice_type.as_ref(); let message = message.as_ref(); if notice_type.len() > NOTICE_TYPE_PADDING { println!( "{} {}", notice_type .chars() .take(NOTICE_TYPE_PADDING) .collect::(), message ); } else { println!( "{}{} {}", notice_type, " ".repeat(NOTICE_TYPE_PADDING - notice_type.len()), message ); } } fn path_display(path: &Path) -> Result, Error> { Ok(path.strip_prefix(std::env::current_dir()?)?.display()) } #[derive(Serialize, Deserialize, Debug, Clone)] enum Handler { Ignore, Copy, MinifyJs, MinifyCss, CompressPng, Command(Vec), } impl Handler { fn notice_type_present(&self) -> String { match self { Handler::Ignore => "Ignore".into(), Handler::Copy => "Copy".into(), Handler::MinifyJs => "Minify (JS)".into(), Handler::MinifyCss => "Minify (CSS)".into(), Handler::CompressPng => "Compress (PNG)".into(), Handler::Command(cmd) => { if cmd.len() < 1 { return "MISSING-COMMAND".into(); } if let Some((cmdpath, _)) = cmd[0].split_once(' ') { if let Some((_, cmdbase)) = cmdpath.rsplit_once(std::path::MAIN_SEPARATOR) { cmdbase.to_string() } else { cmdpath.to_string() } } else { cmd[0].clone() } } } } fn notice_type_past(&self) -> String { match self { Handler::Ignore => "Ignored".into(), Handler::Copy => "Copied".into(), Handler::MinifyJs => "Minified (JS)".into(), Handler::MinifyCss => "Minified (CSS)".into(), Handler::CompressPng => "Compressed (PNG)".into(), Handler::Command { .. } => self.notice_type_present(), } } } fn minify_css(source: &str) -> Result { let mut sheet = StyleSheet::parse(source, ParserOptions::default()) // We have to map_err because, inexplicably, the lightningcss // error type contains a reference to the code, so returning // it requires that the lifetime of the code be static .map_err(|err| Error::msg(err.to_string()))?; sheet.minify(MinifyOptions::default())?; let compiled = sheet .to_css(PrinterOptions { minify: true, ..Default::default() })? .code; Ok(compiled) } fn minify_js(source: &[u8]) -> Result, Error> { // Using oxc because the previous minify-js crate panicked instead // of returning errors, sometimes even on valid input. If oxc's // minifier turns out to be unsatisfactory (e.g. correctness or // output-size regressions), swc_ecma_minifier is the next // candidate -- it's the most battle-tested Rust JS minifier, at // the cost of a much heavier dependency tree. use oxc::allocator::Allocator; use oxc::codegen::{Codegen, CodegenOptions}; use oxc::minifier::{MangleOptions, Minifier, MinifierOptions}; use oxc::parser::Parser; use oxc::span::SourceType; let source = std::str::from_utf8(source).context("JS source is not valid UTF-8")?; let allocator = Allocator::default(); let source_type = SourceType::mjs(); let parser_return = Parser::new(&allocator, source, source_type).parse(); if !parser_return.errors.is_empty() { let msg = parser_return .errors .iter() .map(|e| e.to_string()) .collect::>() .join("; "); return Err(Error::msg(msg)); } let mut program = parser_return.program; // Enable identifier mangling on top of the default compress pass. // This renames locals (and top-level bindings in modules) to short // names, which is the biggest single size win. It is safe for code // that doesn't rely on eval, with, or Function.prototype.name. let options = MinifierOptions { mangle: Some(MangleOptions::default()), ..MinifierOptions::default() }; let minifier_return = Minifier::new(options).minify(&allocator, &mut program); // The minifier records mangled names in a side table rather than // rewriting the AST, so the scoping and private-member mappings // must be threaded into codegen for the short names to appear in // the output. let code = Codegen::new() .with_options(CodegenOptions { minify: true, ..Default::default() }) .with_scoping(minifier_return.scoping) .with_private_member_mappings(minifier_return.class_private_mappings) .build(&program) .code; Ok(code.into_bytes()) } fn minify_png(source: &[u8]) -> Result, Error> { let compiled = oxipng::optimize_from_memory(source, &oxipng::Options::max_compression())?; Ok(compiled) } #[derive(Serialize, Deserialize, Debug, Clone)] struct Rule { include: String, exclude: Option, handler: Handler, rename: Option, skip_unchanged: Option, } impl Rule { fn summary(&self) -> String { let action = self.handler.notice_type_present(); let exclude = if let Some(excl) = &self.exclude { format!(" (excluding {})", excl) } else { String::new() }; let rename = if let Some(rename) = &self.rename { format!(" as {}", rename) } else { String::new() }; format!("{} {}{}{}", action, &self.include, exclude, rename) } fn pseudo(&self, _inpath: &Path, _outpath: &Path) -> Result { let notice_type = self.handler.notice_type_present(); Ok(notice_type) } fn exec(&self, inpath: &Path, outpath: &Path) -> Result { let notice_type = self.handler.notice_type_past(); if let Some(outdir) = outpath.parent() { std::fs::create_dir_all(outdir)?; } match &self.handler { Handler::Ignore => {} Handler::Copy => { std::fs::copy(inpath, outpath) .context(format!("Copying {}", path_display(inpath)?))?; } Handler::MinifyJs => { let source = std::fs::read(inpath)?; let compiled = match minify_js(&source) { Ok(minified) => minified, Err(err) => { println!( "Failed to minify {} ({}), copying instead", path_display(inpath)?, err ); source } }; std::fs::write(outpath, compiled)?; } Handler::MinifyCss => { let source = std::fs::read_to_string(inpath)?; let compiled = match minify_css(&source) { Ok(minified) => minified, Err(err) => { println!( "Failed to minify {} ({}), copying instead", path_display(inpath)?, err ); source } }; std::fs::write(outpath, compiled)?; } Handler::CompressPng => { let source = std::fs::read(inpath)?; let compiled = match minify_png(&source) { Ok(minified) => minified, Err(err) => { println!( "Failed to minify {} ({}), copying", path_display(inpath)?, err ); source } }; std::fs::write(outpath, compiled)?; } Handler::Command(cmd) => { let infile = inpath .to_str() .ok_or_else(|| Error::msg("Input file path is not valid UTF-8, but it needs to be to contruct the subcommand invocation"))?; let outfile = outpath .to_str() .ok_or_else(|| Error::msg("Output file path is not valid UTF-8, but it needs to be to contruct the subcommand invocation"))?; let mut args: VecDeque = cmd .iter() .map(|arg| match arg.as_ref() { "$infile" => infile.to_string(), "$outfile" => outfile.to_string(), _ => arg.clone(), }) .collect(); let Some(executable) = args.pop_front() else { return Err(Error::msg("Empty command in rule")); }; let mut proc = std::process::Command::new(executable).args(args).spawn()?; let _status = proc.wait()?; } } Ok(notice_type) } } #[derive(Serialize, Deserialize, Default, Debug, Clone)] struct Config { input: Option, output: Option, rules: Vec, } impl Config { fn standard() -> Self { Config { input: Some(String::from("static")), output: Some(String::from("dist")), rules: vec![ Rule { include: String::from("**/*.min.js"), exclude: None, handler: Handler::Copy, rename: None, skip_unchanged: None, }, Rule { include: String::from("**/*.min.css"), exclude: None, handler: Handler::Copy, rename: None, skip_unchanged: None, }, Rule { include: String::from("**/*.js"), exclude: None, handler: Handler::MinifyJs, rename: None, skip_unchanged: None, }, Rule { include: String::from("**/*.css"), exclude: None, handler: Handler::MinifyCss, rename: None, skip_unchanged: None, }, Rule { include: String::from("**/*.png"), exclude: None, handler: Handler::CompressPng, rename: None, skip_unchanged: None, }, Rule { include: String::from("**/*.*"), exclude: None, handler: Handler::Copy, rename: None, skip_unchanged: None, }, Rule { include: String::from("**/*"), exclude: None, handler: Handler::Copy, rename: None, skip_unchanged: None, }, ], } } fn combine(mut self, mut other: Config) -> Config { other.rules.append(&mut self.rules); Config { input: other.input.or(self.input), output: other.output.or(self.output), rules: other.rules, } } } #[derive(Parser, Debug, Default)] struct Args { /// The path to the parent directory of the files to be processed #[arg(long)] input: Option, /// The path to the parent directory where the resulting files should be stored #[arg(long)] output: Option, /// The path to the configuration file to use #[arg(long)] config: Option, /// Print the configuration to the terminal #[arg(long)] show: bool, /// Save the configuration to the shenzi.toml file #[arg(long)] save: bool, /// Do not include the standard rules for processing common file types #[arg(long)] no_standard: bool, /// Do not actually process files #[arg(long)] dry_run: bool, /// Print extra information #[arg(long)] verbose: bool, } enum ProcessStatus { Ok, HandledError, } fn process() -> Result { let args = Args::parse(); let mut config = if args.no_standard { Config::default() } else { Config::standard() }; let config_file = args.config.unwrap_or_else(|| String::from("shenzi.toml")); if let Ok(cfg) = std::fs::read_to_string(&config_file) { config = config.combine(match toml::from_str(&cfg) { Ok(cfg) => cfg, Err(_) => { eprintln!("Unable to parse the config file"); return Ok(ProcessStatus::HandledError); } }); } if args.input.is_some() { config.input = args.input; } if args.output.is_some() { config.output = args.output; } if args.show { println!( "{}", match toml::to_string_pretty(&config) { Ok(cfg) => cfg, Err(_) => { eprintln!("Unable to render the configuration"); return Ok(ProcessStatus::HandledError); } } ); } if args.save { match std::fs::write( &config_file, match toml::to_string_pretty(&config) { Ok(cfg) => cfg, Err(_) => { eprintln!("Unable to render the config file"); return Ok(ProcessStatus::HandledError); } }, ) { Ok(_) => {} Err(_) => { eprintln!("Unable to save the config file"); return Ok(ProcessStatus::HandledError); } }; } let mut processed = HashSet::new(); let mut stored = HashSet::new(); let Some(input) = config.input else { eprintln!("Input directory is required"); return Ok(ProcessStatus::HandledError); }; let Some(output) = config.output else { eprintln!("Output directory is required"); return Ok(ProcessStatus::HandledError); }; let input_path = input.as_bytes().to_path()?; let output_path = output.as_bytes().to_path()?; if !output_path.exists() { std::fs::create_dir_all(&output_path)?; } if !input_path.is_dir() { eprintln!("Input path must be a directory"); return Ok(ProcessStatus::HandledError); } if !output_path.is_dir() { eprintln!("Output path must be a directory"); return Ok(ProcessStatus::HandledError); } let input_path = std::fs::canonicalize(input_path)?; let output_path = std::fs::canonicalize(output_path)?; for rule in config.rules.iter() { if args.verbose { println!("# {}", rule.summary()); } let skip_unchanged = rule.skip_unchanged.unwrap_or(true); let include = match Glob::new(&rule.include) { Ok(glob) => glob, Err(_) => { eprintln!("Invalid pattern: {}", &rule.include); return Ok(ProcessStatus::HandledError); } }; let files = if let Some(exclude) = &rule.exclude { let exclude = match Glob::new(exclude) { Ok(glob) => glob, Err(_) => { eprintln!("Invalid pattern: {}", exclude); return Ok(ProcessStatus::HandledError); } }; include.walk(&input_path).not(vec![exclude]).unwrap() } else { include.walk(&input_path).not(vec![]).unwrap() }; for entry in files { let entry = match entry { Ok(e) => e, Err(x) => { let emsg = x.to_string(); let kind = std::io::Error::from(x).kind(); if kind == std::io::ErrorKind::NotFound { notice("Not Found", input_path.display().to_string()); continue; } eprintln!("Error: {}", emsg); return Ok(ProcessStatus::HandledError); } }; let meta = entry.metadata().unwrap(); if meta.is_dir() { continue; } let source = entry.path().to_path_buf(); if args.verbose { println!("File {}", source.to_string_lossy()); } let destination = if let Some(rename) = &rule.rename { let captures: Vec<_> = include.captures().collect(); let matches = entry.matched(); let mut destination = as ByteVec>::from_path_buf(output_path.join(&rename)) .map_err(|_| Error::msg("Unable to generate output path"))?; for i in 1..=captures.len() { let refname = format!("${}", i); destination = destination.replace(&refname, matches.get(i).unwrap_or("")); } destination.into_path_buf()? } else { let Some(destination) = <[u8] as ByteSlice>::from_path(entry.path()) .and_then(|p| p.replacen(&input, &output, 1).into_path_buf().ok()) else { eprintln!( "Unable to determine the destination file path for source {}", &source.to_string_lossy() ); return Ok(ProcessStatus::HandledError); }; destination }; std::fs::create_dir_all( destination .parent() .ok_or_else(|| Error::msg("Unable to find parent directory of output"))?, )?; let destdir = std::fs::canonicalize( destination .parent() .ok_or_else(|| Error::msg("Unable to find parent directory of output"))?, )?; if !destdir.starts_with(&output_path) { eprintln!( "File output location is not within the output folder: {}", &destdir.to_string_lossy() ); return Ok(ProcessStatus::HandledError); } if processed.contains(&source) { if args.verbose { notice("Already processed", source.to_string_lossy()); } continue; } if stored.contains(&destination) { if args.verbose { notice("Already processed", destination.to_string_lossy()); } continue; } if let Ok(dmeta) = destination.metadata() { if skip_unchanged && dmeta.modified()? >= meta.modified()? { if args.verbose { notice("Output Is Newer", source.to_string_lossy()); } processed.insert(source); stored.insert(destination); continue; } } let notice_type = if args.dry_run { rule.pseudo(&source, &destination)? } else { rule.exec(&source, &destination)? }; notice( notice_type, format!( "{} -> {}", source.strip_prefix(&input_path)?.to_string_lossy(), destination.strip_prefix(&output_path)?.to_string_lossy() ), ); processed.insert(source); stored.insert(destination); } if args.verbose { println!(""); } } Ok(ProcessStatus::Ok) } fn main() { use std::process::exit; match process() { Ok(ProcessStatus::Ok) => exit(0), Ok(ProcessStatus::HandledError) => exit(1), Err(x) => { eprintln!("Error: {}", x); std::process::exit(1); } } }