use std::collections::HashMap;
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 CssTool {
LightningCss,
#[cfg(test)]
TestEcho,
#[cfg(test)]
TestMissing,
}
impl CssTool {
pub(crate) fn binary_name(&self) -> &'static str {
match self {
CssTool::LightningCss => "lightningcss",
#[cfg(test)]
CssTool::TestEcho => "cp",
#[cfg(test)]
CssTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
}
}
pub(crate) fn install_hint(&self) -> &'static str {
match self {
CssTool::LightningCss => {
"install via `npm install -g lightningcss-cli` (or add it as a project \
devDependency and put its bin/ on PATH)"
}
#[cfg(test)]
CssTool::TestEcho | CssTool::TestMissing => "test-only tool, not installable",
}
}
fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
match self {
CssTool::LightningCss => {
let mut args = Vec::new();
if bundle {
args.push(OsString::from("--bundle"));
}
if minify {
args.push(OsString::from("--minify"));
}
args.push(OsString::from("-o"));
args.push(output.into());
args.push(entry.into());
args
}
#[cfg(test)]
CssTool::TestEcho => vec![entry.into(), output.into()],
#[cfg(test)]
CssTool::TestMissing => vec![],
}
}
fn batch_args(
&self,
bundle: bool,
minify: bool,
inputs: &[PathBuf],
out_dir: &Path,
) -> Vec<OsString> {
match self {
CssTool::LightningCss => {
let mut args = Vec::new();
if bundle {
args.push(OsString::from("--bundle"));
}
if minify {
args.push(OsString::from("--minify"));
}
args.push(OsString::from("--output-dir"));
args.push(out_dir.into());
args.extend(inputs.iter().map(OsString::from));
args
}
#[cfg(test)]
CssTool::TestEcho => {
let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
args.push(out_dir.into());
args
}
#[cfg(test)]
CssTool::TestMissing => vec![],
}
}
}
struct ScratchDir {
path: PathBuf,
}
impl ScratchDir {
fn new(path: PathBuf) -> Result<Self, CssError> {
fs::create_dir_all(&path).map_err(|e| CssError::WriteOutput {
path: path.clone(),
reason: e.to_string(),
})?;
Ok(ScratchDir { path })
}
}
impl Drop for ScratchDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[derive(Debug, Clone)]
pub struct CssOptions {
bundle: bool,
minify: bool,
bundle_output_name: String,
}
impl CssOptions {
pub fn new() -> Self {
CssOptions {
bundle: false,
minify: false,
bundle_output_name: "styles.css".to_string(),
}
}
pub fn bundle(mut self, bundle: bool) -> Self {
self.bundle = bundle;
self
}
pub fn minify(mut self, minify: bool) -> Self {
self.minify = minify;
self
}
pub fn bundle_output_name(mut self, name: impl Into<String>) -> Self {
self.bundle_output_name = name.into();
self
}
pub(crate) fn is_bundle(&self) -> bool {
self.bundle
}
pub(crate) fn is_minify(&self) -> bool {
self.minify
}
pub(crate) fn output_file_name(&self) -> &str {
&self.bundle_output_name
}
}
impl Default for CssOptions {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub enum CssError {
ReadSource { path: PathBuf, reason: String },
WriteOutput { path: PathBuf, reason: String },
NoFilesFound(PathBuf),
Tool(tool::ToolError),
}
impl std::fmt::Display for CssError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CssError::ReadSource { path, reason } => {
write!(f, "failed to read {}: {reason}", path.display())
}
CssError::WriteOutput { path, reason } => {
write!(f, "failed to write {}: {reason}", path.display())
}
CssError::NoFilesFound(path) => write!(f, "no CSS files found in {}", path.display()),
CssError::Tool(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for CssError {}
impl From<tool::ToolError> for CssError {
fn from(e: tool::ToolError) -> Self {
CssError::Tool(e)
}
}
pub(crate) fn build_css_bundle(
css_tool: CssTool,
options: &CssOptions,
source_dirs: &[PathBuf],
output_path: &Path,
) -> Result<(), CssError> {
let css_files = find_css_files(source_dirs)?;
if css_files.is_empty() {
let first = source_dirs
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("."));
return Err(CssError::NoFilesFound(first));
}
if !options.bundle && !options.minify {
let mut combined = Vec::new();
for file in &css_files {
let bytes = fs::read(file).map_err(|e| CssError::ReadSource {
path: file.clone(),
reason: e.to_string(),
})?;
combined.extend_from_slice(&bytes);
}
return write_output(output_path, &combined);
}
let scratch = ScratchDir::new(scratch_dir_path(output_path))?;
let mut produced: HashMap<PathBuf, PathBuf> = HashMap::new();
for (index, (_parent, files)) in group_by_parent(&css_files).into_iter().enumerate() {
let group_dir = scratch.path.join(index.to_string());
fs::create_dir_all(&group_dir).map_err(|e| CssError::WriteOutput {
path: group_dir.clone(),
reason: e.to_string(),
})?;
let outputs: Vec<PathBuf> = files
.iter()
.map(|file| group_dir.join(file.file_name().unwrap_or_default()))
.collect();
run_tool_batch(
css_tool,
options.bundle,
options.minify,
&files,
&group_dir,
&outputs,
)?;
for (file, output) in files.into_iter().zip(outputs) {
produced.insert(file, output);
}
}
let mut combined = Vec::new();
for file in &css_files {
let output = produced.get(file).ok_or_else(|| CssError::ReadSource {
path: file.clone(),
reason: "the tool produced no output for this file".to_string(),
})?;
let bytes = fs::read(output).map_err(|e| CssError::ReadSource {
path: output.clone(),
reason: e.to_string(),
})?;
combined.extend_from_slice(&bytes);
}
write_output(output_path, &combined)
}
pub(crate) fn build_css_files(
css_tool: CssTool,
options: &CssOptions,
pairs: &[(PathBuf, PathBuf)],
) -> Result<(), CssError> {
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| CssError::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(css_tool, false, true, &inputs, &out_dir, &outputs).is_err() {
for (source, output) in group {
build_css_file(css_tool, options, source, output)?;
}
}
}
Ok(())
}
pub(crate) fn build_css_file(
css_tool: CssTool,
options: &CssOptions,
source: &Path,
output: &Path,
) -> Result<(), CssError> {
if !options.minify || is_already_minified(source) {
return copy_file(source, output);
}
if let Err(e) = run_tool(css_tool, false, true, source, output) {
eprintln!(
"css tool: minify failed for {}, serving raw bytes: {e}",
source.display()
);
return copy_file(source, output);
}
Ok(())
}
fn run_tool(
css_tool: CssTool,
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 = css_tool.args(bundle, minify, entry, output);
tool::execute(
css_tool.binary_name(),
css_tool.install_hint(),
css_tool.binary_name(),
&args,
&[output],
tool::TOOL_TIMEOUT,
)
}
fn run_tool_batch(
css_tool: CssTool,
bundle: bool,
minify: bool,
inputs: &[PathBuf],
out_dir: &Path,
expected: &[PathBuf],
) -> Result<(), tool::ToolError> {
let args = css_tool.batch_args(bundle, minify, inputs, out_dir);
let expected: Vec<&Path> = expected.iter().map(PathBuf::as_path).collect();
tool::execute(
css_tool.binary_name(),
css_tool.install_hint(),
css_tool.binary_name(),
&args,
&expected,
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.css"))
}
fn scratch_dir_path(output_path: &Path) -> PathBuf {
let file_name = output_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("output");
output_path.with_file_name(format!(".{file_name}.building"))
}
fn write_output(output_path: &Path, bytes: &[u8]) -> Result<(), CssError> {
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
path: output_path.to_path_buf(),
reason: e.to_string(),
})?;
}
fs::write(output_path, bytes).map_err(|e| CssError::WriteOutput {
path: output_path.to_path_buf(),
reason: e.to_string(),
})
}
fn copy_file(source: &Path, output: &Path) -> Result<(), CssError> {
if let Some(parent) = output.parent() {
fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
}
fs::copy(source, output).map_err(|e| CssError::WriteOutput {
path: output.to_path_buf(),
reason: e.to_string(),
})?;
Ok(())
}
fn find_css_files(source_dirs: &[PathBuf]) -> Result<Vec<PathBuf>, CssError> {
let mut files = Vec::new();
for dir in source_dirs {
let found = walk_for_extension(dir, "css").map_err(|e| CssError::ReadSource {
path: dir.clone(),
reason: e.to_string(),
})?;
files.extend(found);
}
files.sort();
Ok(files)
}
fn walk_for_extension(dir: &Path, ext: &str) -> std::io::Result<Vec<PathBuf>> {
let mut files = Vec::new();
let mut dirs = vec![dir.to_path_buf()];
while let Some(current_dir) = dirs.pop() {
for entry in fs::read_dir(¤t_dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
if file_type.is_dir() {
dirs.push(path);
} else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext)
{
files.push(path);
}
}
}
Ok(files)
}
#[cfg(test)]
#[path = "../tests/unit/css.rs"]
mod tests;