main.rs
raw
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<S1, S2>(notice_type: S1, message: S2)
where
S1: AsRef<str>,
S2: AsRef<str>,
{
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::<String>(),
message
);
} else {
println!(
"{}{} {}",
notice_type,
" ".repeat(NOTICE_TYPE_PADDING - notice_type.len()),
message
);
}
}
fn path_display(path: &Path) -> Result<std::path::Display<'_>, Error> {
Ok(path.strip_prefix(std::env::current_dir()?)?.display())
}
#[derive(Serialize, Deserialize, Debug, Clone)]
enum Handler {
Ignore,
Copy,
MinifyJs,
MinifyCss,
CompressPng,
Command(Vec<String>),
}
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<String, Error> {
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<Vec<u8>, 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::<Vec<_>>()
.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<Vec<u8>, 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<String>,
handler: Handler,
rename: Option<String>,
skip_unchanged: Option<bool>,
}
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<String, Error> {
let notice_type = self.handler.notice_type_present();
Ok(notice_type)
}
fn exec(&self, inpath: &Path, outpath: &Path) -> Result<String, Error> {
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<String> = 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<String>,
output: Option<String>,
rules: Vec<Rule>,
}
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<String>,
/// The path to the parent directory where the resulting files should be stored
#[arg(long)]
output: Option<String>,
/// The path to the configuration file to use
#[arg(long)]
config: Option<String>,
/// 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<ProcessStatus, Error> {
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 =
<Vec<u8> 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);
}
}
}