1use std::collections::HashMap;
2use std::ffi::OsString;
3use std::fs;
4use std::path::{Path, PathBuf};
5
6use crate::tool::{self, group_by_parent};
7
8#[non_exhaustive]
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum CssTool {
20 LightningCss,
35 #[cfg(test)]
40 TestEcho,
41 #[cfg(test)]
44 TestMissing,
45}
46
47impl CssTool {
48 pub(crate) fn binary_name(&self) -> &'static str {
49 match self {
50 CssTool::LightningCss => "lightningcss",
51 #[cfg(test)]
52 CssTool::TestEcho => "cp",
53 #[cfg(test)]
54 CssTool::TestMissing => "definitely-not-a-real-binary-9f3c2a",
55 }
56 }
57
58 pub(crate) fn install_hint(&self) -> &'static str {
59 match self {
60 CssTool::LightningCss => {
61 "install via `npm install -g lightningcss-cli` (or add it as a project \
62 devDependency and put its bin/ on PATH)"
63 }
64 #[cfg(test)]
65 CssTool::TestEcho | CssTool::TestMissing => "test-only tool, not installable",
66 }
67 }
68
69 fn args(&self, bundle: bool, minify: bool, entry: &Path, output: &Path) -> Vec<OsString> {
70 match self {
71 CssTool::LightningCss => {
72 let mut args = Vec::new();
73 if bundle {
74 args.push(OsString::from("--bundle"));
75 }
76 if minify {
77 args.push(OsString::from("--minify"));
78 }
79 args.push(OsString::from("-o"));
80 args.push(output.into());
81 args.push(entry.into());
82 args
83 }
84 #[cfg(test)]
85 CssTool::TestEcho => vec![entry.into(), output.into()],
86 #[cfg(test)]
87 CssTool::TestMissing => vec![],
88 }
89 }
90
91 fn batch_args(
103 &self,
104 bundle: bool,
105 minify: bool,
106 inputs: &[PathBuf],
107 out_dir: &Path,
108 ) -> Vec<OsString> {
109 match self {
110 CssTool::LightningCss => {
111 let mut args = Vec::new();
112 if bundle {
113 args.push(OsString::from("--bundle"));
114 }
115 if minify {
116 args.push(OsString::from("--minify"));
117 }
118 args.push(OsString::from("--output-dir"));
119 args.push(out_dir.into());
120 args.extend(inputs.iter().map(OsString::from));
121 args
122 }
123 #[cfg(test)]
125 CssTool::TestEcho => {
126 let mut args: Vec<OsString> = inputs.iter().map(OsString::from).collect();
127 args.push(out_dir.into());
128 args
129 }
130 #[cfg(test)]
131 CssTool::TestMissing => vec![],
132 }
133 }
134}
135
136struct ScratchDir {
142 path: PathBuf,
143}
144
145impl ScratchDir {
146 fn new(path: PathBuf) -> Result<Self, CssError> {
147 fs::create_dir_all(&path).map_err(|e| CssError::WriteOutput {
148 path: path.clone(),
149 reason: e.to_string(),
150 })?;
151 Ok(ScratchDir { path })
152 }
153}
154
155impl Drop for ScratchDir {
156 fn drop(&mut self) {
157 let _ = fs::remove_dir_all(&self.path);
158 }
159}
160
161#[derive(Debug, Clone)]
164pub struct CssOptions {
165 bundle: bool,
166 minify: bool,
167 bundle_output_name: String,
168}
169
170impl CssOptions {
171 pub fn new() -> Self {
173 CssOptions {
174 bundle: false,
175 minify: false,
176 bundle_output_name: "styles.css".to_string(),
177 }
178 }
179
180 pub fn bundle(mut self, bundle: bool) -> Self {
183 self.bundle = bundle;
184 self
185 }
186
187 pub fn minify(mut self, minify: bool) -> Self {
189 self.minify = minify;
190 self
191 }
192
193 pub fn bundle_output_name(mut self, name: impl Into<String>) -> Self {
196 self.bundle_output_name = name.into();
197 self
198 }
199
200 pub(crate) fn is_bundle(&self) -> bool {
201 self.bundle
202 }
203
204 pub(crate) fn is_minify(&self) -> bool {
205 self.minify
206 }
207
208 pub(crate) fn output_file_name(&self) -> &str {
209 &self.bundle_output_name
210 }
211}
212
213impl Default for CssOptions {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219#[derive(Debug)]
222pub enum CssError {
223 ReadSource { path: PathBuf, reason: String },
224 WriteOutput { path: PathBuf, reason: String },
225 NoFilesFound(PathBuf),
226 Tool(tool::ToolError),
227}
228
229impl std::fmt::Display for CssError {
230 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231 match self {
232 CssError::ReadSource { path, reason } => {
233 write!(f, "failed to read {}: {reason}", path.display())
234 }
235 CssError::WriteOutput { path, reason } => {
236 write!(f, "failed to write {}: {reason}", path.display())
237 }
238 CssError::NoFilesFound(path) => write!(f, "no CSS files found in {}", path.display()),
239 CssError::Tool(e) => write!(f, "{e}"),
240 }
241 }
242}
243
244impl std::error::Error for CssError {}
245
246impl From<tool::ToolError> for CssError {
247 fn from(e: tool::ToolError) -> Self {
248 CssError::Tool(e)
249 }
250}
251
252pub(crate) fn build_css_bundle(
263 css_tool: CssTool,
264 options: &CssOptions,
265 source_dirs: &[PathBuf],
266 output_path: &Path,
267) -> Result<(), CssError> {
268 let css_files = find_css_files(source_dirs)?;
269 if css_files.is_empty() {
270 let first = source_dirs
271 .first()
272 .cloned()
273 .unwrap_or_else(|| PathBuf::from("."));
274 return Err(CssError::NoFilesFound(first));
275 }
276
277 if !options.bundle && !options.minify {
279 let mut combined = Vec::new();
280 for file in &css_files {
281 let bytes = fs::read(file).map_err(|e| CssError::ReadSource {
282 path: file.clone(),
283 reason: e.to_string(),
284 })?;
285 combined.extend_from_slice(&bytes);
286 }
287 return write_output(output_path, &combined);
288 }
289
290 let scratch = ScratchDir::new(scratch_dir_path(output_path))?;
297 let mut produced: HashMap<PathBuf, PathBuf> = HashMap::new();
298
299 for (index, (_parent, files)) in group_by_parent(&css_files).into_iter().enumerate() {
300 let group_dir = scratch.path.join(index.to_string());
301 fs::create_dir_all(&group_dir).map_err(|e| CssError::WriteOutput {
302 path: group_dir.clone(),
303 reason: e.to_string(),
304 })?;
305
306 let outputs: Vec<PathBuf> = files
307 .iter()
308 .map(|file| group_dir.join(file.file_name().unwrap_or_default()))
309 .collect();
310
311 run_tool_batch(
312 css_tool,
313 options.bundle,
314 options.minify,
315 &files,
316 &group_dir,
317 &outputs,
318 )?;
319
320 for (file, output) in files.into_iter().zip(outputs) {
321 produced.insert(file, output);
322 }
323 }
324
325 let mut combined = Vec::new();
328 for file in &css_files {
329 let output = produced.get(file).ok_or_else(|| CssError::ReadSource {
330 path: file.clone(),
331 reason: "the tool produced no output for this file".to_string(),
332 })?;
333 let bytes = fs::read(output).map_err(|e| CssError::ReadSource {
334 path: output.clone(),
335 reason: e.to_string(),
336 })?;
337 combined.extend_from_slice(&bytes);
338 }
339
340 write_output(output_path, &combined)
341}
342
343pub(crate) fn build_css_files(
362 css_tool: CssTool,
363 options: &CssOptions,
364 pairs: &[(PathBuf, PathBuf)],
365) -> Result<(), CssError> {
366 let (transform, bypass): (Vec<_>, Vec<_>) = pairs
367 .iter()
368 .partition(|(source, _)| options.minify && !is_already_minified(source));
369
370 for (source, output) in bypass {
371 copy_file(source, output)?;
372 }
373 if transform.is_empty() {
374 return Ok(());
375 }
376
377 let by_output_dir = group_by_parent(
378 &transform
379 .iter()
380 .map(|(_, output)| output.clone())
381 .collect::<Vec<_>>(),
382 );
383
384 for (out_dir, outputs) in by_output_dir {
385 fs::create_dir_all(&out_dir).map_err(|e| CssError::WriteOutput {
386 path: out_dir.clone(),
387 reason: e.to_string(),
388 })?;
389
390 let group: Vec<&(PathBuf, PathBuf)> = transform
391 .iter()
392 .copied()
393 .filter(|(_, output)| outputs.contains(output))
394 .collect();
395 let inputs: Vec<PathBuf> = group.iter().map(|(source, _)| source.clone()).collect();
396
397 if run_tool_batch(css_tool, false, true, &inputs, &out_dir, &outputs).is_err() {
398 for (source, output) in group {
399 build_css_file(css_tool, options, source, output)?;
400 }
401 }
402 }
403
404 Ok(())
405}
406
407pub(crate) fn build_css_file(
408 css_tool: CssTool,
409 options: &CssOptions,
410 source: &Path,
411 output: &Path,
412) -> Result<(), CssError> {
413 if !options.minify || is_already_minified(source) {
414 return copy_file(source, output);
415 }
416
417 if let Err(e) = run_tool(css_tool, false, true, source, output) {
418 eprintln!(
419 "css tool: minify failed for {}, serving raw bytes: {e}",
420 source.display()
421 );
422 return copy_file(source, output);
423 }
424 Ok(())
425}
426
427fn run_tool(
428 css_tool: CssTool,
429 bundle: bool,
430 minify: bool,
431 entry: &Path,
432 output: &Path,
433) -> Result<(), tool::ToolError> {
434 if let Some(parent) = output.parent() {
435 let _ = fs::create_dir_all(parent);
436 }
437 let args = css_tool.args(bundle, minify, entry, output);
438 tool::execute(
439 css_tool.binary_name(),
440 css_tool.install_hint(),
441 css_tool.binary_name(),
442 &args,
443 &[output],
444 tool::TOOL_TIMEOUT,
445 )
446}
447
448fn run_tool_batch(
454 css_tool: CssTool,
455 bundle: bool,
456 minify: bool,
457 inputs: &[PathBuf],
458 out_dir: &Path,
459 expected: &[PathBuf],
460) -> Result<(), tool::ToolError> {
461 let args = css_tool.batch_args(bundle, minify, inputs, out_dir);
462 let expected: Vec<&Path> = expected.iter().map(PathBuf::as_path).collect();
463 tool::execute(
464 css_tool.binary_name(),
465 css_tool.install_hint(),
466 css_tool.binary_name(),
467 &args,
468 &expected,
469 tool::TOOL_TIMEOUT,
470 )
471}
472
473fn is_already_minified(path: &Path) -> bool {
477 path.file_name()
478 .and_then(|name| name.to_str())
479 .is_some_and(|name| name.ends_with(".min.css"))
480}
481
482fn scratch_dir_path(output_path: &Path) -> PathBuf {
488 let file_name = output_path
489 .file_name()
490 .and_then(|n| n.to_str())
491 .unwrap_or("output");
492 output_path.with_file_name(format!(".{file_name}.building"))
493}
494
495fn write_output(output_path: &Path, bytes: &[u8]) -> Result<(), CssError> {
496 if let Some(parent) = output_path.parent() {
497 fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
498 path: output_path.to_path_buf(),
499 reason: e.to_string(),
500 })?;
501 }
502 fs::write(output_path, bytes).map_err(|e| CssError::WriteOutput {
503 path: output_path.to_path_buf(),
504 reason: e.to_string(),
505 })
506}
507
508fn copy_file(source: &Path, output: &Path) -> Result<(), CssError> {
509 if let Some(parent) = output.parent() {
510 fs::create_dir_all(parent).map_err(|e| CssError::WriteOutput {
511 path: output.to_path_buf(),
512 reason: e.to_string(),
513 })?;
514 }
515 fs::copy(source, output).map_err(|e| CssError::WriteOutput {
516 path: output.to_path_buf(),
517 reason: e.to_string(),
518 })?;
519 Ok(())
520}
521
522fn find_css_files(source_dirs: &[PathBuf]) -> Result<Vec<PathBuf>, CssError> {
524 let mut files = Vec::new();
525 for dir in source_dirs {
526 let found = walk_for_extension(dir, "css").map_err(|e| CssError::ReadSource {
527 path: dir.clone(),
528 reason: e.to_string(),
529 })?;
530 files.extend(found);
531 }
532 files.sort();
533 Ok(files)
534}
535
536fn walk_for_extension(dir: &Path, ext: &str) -> std::io::Result<Vec<PathBuf>> {
537 let mut files = Vec::new();
538 let mut dirs = vec![dir.to_path_buf()];
539
540 while let Some(current_dir) = dirs.pop() {
541 for entry in fs::read_dir(¤t_dir)? {
542 let entry = entry?;
543 let path = entry.path();
544 let file_type = entry.file_type()?;
545
546 if file_type.is_dir() {
547 dirs.push(path);
548 } else if file_type.is_file() && path.extension().and_then(|s| s.to_str()) == Some(ext)
549 {
550 files.push(path);
551 }
552 }
553 }
554
555 Ok(files)
556}
557
558#[cfg(test)]
559#[path = "../tests/unit/css.rs"]
560mod tests;