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);
        }
    }
}