use std::ffi::OsString;
use std::fs;
use std::path::{Path, PathBuf};
use crate::tool::{self, group_by_parent};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum JsTool {
Esbuild,
#[cfg(test)]
TestEcho,
#[cfg(test)]
TestMissing,
}
impl JsTool {
pub(crate) fn binary_name(&self) -> &'static str {
match self {
JsTool::Esbuild => "esbuild",
#[cfg(test)]
JsTool::TestEcho => "cp",
#[cfg(test)]
JsTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
}
}
pub(crate) fn install_hint(&self) -> &'static str {
match self {
JsTool::Esbuild => {
"install via `npm install -g esbuild` (or add it as a project \
devDependency and put its bin/ on PATH)"
}
#[cfg(test)]
JsTool::TestEcho | JsTool::TestMissing => "test-only tool, not installable",
}
}
fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
match self {
JsTool::Esbuild => {
let mut args = vec![OsString::from(entry)];
if bundle {
args.push(OsString::from("--bundle"));
}
if minify {
args.push(OsString::from("--minify"));
}
let mut outfile = OsString::from("--outfile=");
outfile.push(output);
args.push(outfile);
args
}
#[cfg(test)]
JsTool::TestEcho => vec![entry.into(), output.into()],
#[cfg(test)]
JsTool::TestMissing => vec![],
}
}
fn batch_args(&self, minify: bool, inputs: &[PathBuf], out_dir: &Path) -> Vec<OsString> {
match self {
JsTool::Esbuild => {
let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
if minify {
args.push(OsString::from("--minify"));
}
let mut outdir = OsString::from("--outdir=");
outdir.push(out_dir);
args.push(outdir);
args
}
#[cfg(test)]
JsTool::TestEcho => {
let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
args.push(out_dir.into());
args
}
#[cfg(test)]
JsTool::TestMissing => vec![],
}
}
}
#[derive(Debug, Clone, Default)]
pub struct JsOptions {
bundle: bool,
minify: bool,
entry: Option<PathBuf>,
bundle_output_name: Option<String>,
}
impl JsOptions {
pub fn new() -> Self {
JsOptions::default()
}
pub fn minify(mut self, minify: bool) -> Self {
self.minify = minify;
self
}
pub fn bundle_entry(mut self, entry: &Path, output_name: impl Into<String>) -> Self {
self.bundle = true;
self.entry = Some(entry.to_path_buf());
self.bundle_output_name = Some(output_name.into());
self
}
pub(crate) fn is_bundle(&self) -> bool {
self.bundle
}
pub(crate) fn is_minify(&self) -> bool {
self.minify
}
pub(crate) fn entry(&self) -> Option<&Path> {
self.entry.as_deref()
}
pub(crate) fn output_file_name(&self) -> Option<&str> {
self.bundle_output_name.as_deref()
}
}
#[derive(Debug)]
pub enum JsError {
WriteOutput { path: PathBuf, reason: String },
Tool(tool::ToolError),
}
impl std::fmt::Display for JsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
JsError::WriteOutput { path, reason } => {
write!(f, "failed to write {}: {reason}", path.display())
}
JsError::Tool(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for JsError {}
impl From<tool::ToolError> for JsError {
fn from(e: tool::ToolError) -> Self {
JsError::Tool(e)
}
}
pub(crate) fn build_js_bundle(
js_tool: JsTool,
options: &JsOptions,
entry: &Path,
output_path: &Path,
) -> Result<(), JsError> {
if !options.bundle && !options.minify {
return copy_file(entry, output_path);
}
Ok(run_tool(js_tool, true, options.minify, entry, output_path)?)
}
fn run_tool_batch(
js_tool: JsTool,
minify: bool,
inputs: &[PathBuf],
out_dir: &Path,
expected: &[PathBuf],
) -> Result<(), tool::ToolError> {
let args = js_tool.batch_args(minify, inputs, out_dir);
let expected: Vec<&Path> = expected.iter().map(PathBuf::as_path).collect();
tool::execute(
js_tool.binary_name(),
js_tool.install_hint(),
js_tool.binary_name(),
&args,
&expected,
tool::TOOL_TIMEOUT,
)
}
pub(crate) fn build_js_files(
js_tool: JsTool,
options: &JsOptions,
pairs: &[(PathBuf, PathBuf)],
) -> Result<(), JsError> {
let (transform, bypass): (Vec<_>, Vec<_>) = pairs
.iter()
.partition(|(source, _)| options.minify && !is_already_minified(source));
for (source, output) in bypass {
copy_file(source, output)?;
}
if transform.is_empty() {
return Ok(());
}
let by_output_dir = group_by_parent(
&transform
.iter()
.map(|(_, output)| output.clone())
.collect::<Vec<_>>(),
);
for (out_dir, outputs) in by_output_dir {
fs::create_dir_all(&out_dir).map_err(|e| JsError::WriteOutput {
path: out_dir.clone(),
reason: e.to_string(),
})?;
let group: Vec<&(PathBuf, PathBuf)> = transform
.iter()
.copied()
.filter(|(_, output)| outputs.contains(output))
.collect();
let inputs: Vec<PathBuf> = group.iter().map(|(source, _)| source.clone()).collect();
if run_tool_batch(js_tool, true, &inputs, &out_dir, &outputs).is_err() {
for (source, output) in group {
build_js_file(js_tool, options, source, output)?;
}
}
}
Ok(())
}
pub(crate) fn build_js_file(
js_tool: JsTool,
options: &JsOptions,
source: &Path,
output: &Path,
) -> Result<(), JsError> {
if !options.minify || is_already_minified(source) {
return copy_file(source, output);
}
if let Err(e) = run_tool(js_tool, false, true, source, output) {
eprintln!(
"js tool: minify failed for {}, serving raw bytes: {e}",
source.display()
);
return copy_file(source, output);
}
Ok(())
}
fn run_tool(
js_tool: JsTool,
bundle: bool,
minify: bool,
entry: &Path,
output: &Path,
) -> Result<(), tool::ToolError> {
if let Some(parent) = output.parent() {
let _ = fs::create_dir_all(parent);
}
let args = js_tool.args(bundle, minify, entry, output);
tool::execute(
js_tool.binary_name(),
js_tool.install_hint(),
js_tool.binary_name(),
&args,
&[output],
tool::TOOL_TIMEOUT,
)
}
fn is_already_minified(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".min.js"))
}
fn copy_file(source: &Path, output: &Path) -> Result<(), JsError> {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent).map_err(|e| JsError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
}
fs::copy(source, output).map_err(|e| JsError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
Ok(())
}
#[cfg(test)]
#[path = "../tests/unit/js.rs"]
mod tests;