const_css_minify/lib.rs
1//! [<img alt="github" src="https://img.shields.io/badge/github-scpso%2Fconst--css--minify-7c72ff?logo=github">](https://github.com/scpso/const-css-minify)
2//! [<img alt="crates.io" src="https://img.shields.io/crates/v/const-css-minify.svg?logo=rust">](https://crates.io/crates/const-css-minify)
3//! [<img alt="docs.rs" src="https://img.shields.io/docsrs/const-css-minify/latest?logo=docs.rs">](https://docs.rs/const-css-minify)
4//!
5//! Include a minified css file as an inline const in your high-performance compiled web
6//! application.
7//!
8//! You can call it with a path to your source css file, just like you might use the built-in
9//! macro `include_str!()`:
10//!
11//! ```rust
12//! use const_css_minify::minify;
13//!
14//! // this is probably the pattern you want to use
15//! const CSS: &str = minify!("./path/to/style.css");
16//! ```
17//!
18//! <div class="warning">
19//!
20//! ***IMPORTANT!*** the current version of `const_css_minify` resolves paths relative to the crate
21//! root (i.e. the directory where your `Cargo.toml` is). This behaviour is ***DIFFERENT*** from the
22//! rust built-in macros like `include_str!()` which use a path relative to the source file from
23//! which it's invoked. Consider the current behaviour unstable and likely to change - our
24//! preference would be to match the established convention, but implementing this change is
25//! dependant on the stabilisation of a source path api in `proc_macro` as per
26//! <https://github.com/rust-lang/rust/issues/54725>
27//!
28//! </div>
29//!
30//! It's also possible to include a raw string with your css directly in your rust source:
31//! ```rust
32//! use const_css_minify::minify;
33//!
34//! const CSS: &str = minify!(r#"
35//! input[type="radio"]:checked, .button:hover {
36//! color: rgb(0 255 100% / 0.8);
37//! margin: 10px 10px;
38//! }
39//! "#);
40//! assert_eq!(CSS, "input[type=\"radio\"]:checked,.button:hover{color:#0ffc;margin:10px 10px}");
41//! ```
42//!
43//! Note also that the current version of `const_css_minify` does not support passing in a variable.
44//! only the above two patterns of a path to an external file or a literal str will work.
45//!
46//! `const_css_minify` is not a good solution if your css changes out-of-step with your binary, as
47//! you will not be able to change the css without recompiling your application.
48//!
49//! #### `const_css_minify` ***will:***
50//! * remove unneeded whitespace and linebreaks
51//! * remove comments
52//! * remove unneeded trailing semicolon in each declaration block
53//! * opportunistically minify colors specified either by literal hex values or by `rgb()`,
54//! `rgba()`, `hsl()` and `hsla()` functions (in either legacy syntax with commas or modern
55//! syntax without commas) without changing the color. e.g. `#ffffff` will be substituted with
56//! `#fff`, `hsl(180 50 50)` with `#40bfbf`, `rgba(20%, 40%, 60%, 0.8)` with `#369c`, etc.
57//! `const-css-minify` will not attempt to calculate nested/complicated/relative rgb expressions
58//! (which will be passed through unadulturated for the end user's browser to figure out for
59//! itself) but many simple/literal expressions will be resolved and minified.
60//! * silently ignore css syntax errors originating in your source file*, and in so doing possibly
61//! elicit slightly different failure modes from renderers by altering the placement of
62//! whitespace around misplaced operators
63//!
64//! #### `const_css_minify` will ***not:***
65//! * compress your css using `gz`, `br` or `deflate`
66//! * change the semantic meaning of your semantically valid css
67//! * make any substitutions other than identical literal colors
68//! * alert you to invalid css* - it's not truly parsing the css, just scanning for and removing
69//! characters it identifies as unnecessary
70//!
71//! note*: The current version of `const-css-minify` will emit compile-time warning messages for
72//! some syntax errors (specifically unclosed quote strings and comments) which indicate an error
73//! in the css (or a bug in `const-css-minify`), however these messages do not offer much help to
74//! the user to locate the source of the error. Internally, these error states are identifed and
75//! handled to avoid panicking due to indexing out-of-bounds, and so reporting the error message at
76//! compile time is in a sense 'for free', but this is a non-core feature of the library and may be
77//! removed in a future version if it turns out to do more harm than good. In any case,
78//! `const-css-minify` generally assumes it is being fed valid css as input and offers no
79//! guarantees about warnings. `const-css-minify` should not be relied upon for linting of css.
80//!
81//! `const_css_minify` is a lightweight solution - the current version of `const_css_minify` has
82//! zero dependencies outside rust's built-in std and proc_macro libraries.
83
84use proc_macro::TokenStream;
85use proc_macro::TokenTree::Literal;
86use std::collections::HashMap;
87use std::fmt;
88use std::fs;
89use std::path::Path;
90use std::str::FromStr;
91
92/// Produce a minified css file as an inline const
93#[proc_macro]
94pub fn minify(input: TokenStream) -> TokenStream {
95 let token_trees: Vec<_> = input.into_iter().collect();
96 if token_trees.len() != 1 {
97 panic!("const_css_minify requires a single str as input");
98 }
99 let Literal(literal) = token_trees.first().unwrap() else {
100 panic!("const_css_minify requires a literal str as input");
101 };
102 let mut literal = literal.to_string();
103
104 // not a raw string, so we must de-escape special chars
105 // this is not comprehensive but is anyone ever going to even notice?
106 // what weird and strange things might they even be trying to achieve?
107 if let Some(c) = literal.get(0..=0) {
108 if c != "r" {
109 literal = literal
110 .replace("\\\"", "\"")
111 .replace("\\n", "\n")
112 .replace("\\r", "\r")
113 .replace("\\t", "\t")
114 .replace("\\\\", "\\")
115 }
116 }
117
118 // trim leading and trailing ".." or r#".."# from string literal
119 let start = &literal.find('\"').unwrap() + 1;
120 let end = &literal.rfind('\"').unwrap() - 1;
121 //bail if literal is empty
122 if start > end {
123 return TokenStream::from_str(&literal).unwrap();
124 }
125 literal = literal[start..=end].to_string();
126
127 // check if we're dealing with path or literal
128 let mut minified = fs::read_to_string(Path::new(&literal)).unwrap_or(literal);
129
130 let mut minifier = Minifier::new();
131 minifier.minify_string(&minified);
132 minifier.emit_error_msgs();
133 minified = minifier.get_output();
134
135 // wrap in quotes, ready to emit as rust raw str token
136 minified = "r####\"".to_string() + &minified + "\"####";
137
138 TokenStream::from_str(&minified).unwrap()
139}
140
141struct ParseError {
142 msg: String,
143}
144
145impl ParseError {
146 pub fn from_msg(msg: &str) -> Self {
147 Self {
148 msg: msg.to_string(),
149 }
150 }
151}
152
153impl fmt::Display for ParseError {
154 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
155 write!(f, "{}", self.msg)
156 }
157}
158
159// we do not attempt to decode all valid rgb func expressions, but we do attempt simple expressions
160// that consist of purely literal numeric expressions.
161const RGB_FUNC_DECODABLE: [u8; 15] = [
162 b'0', b'1', b'2', b'3', b'4', b'5', b'6', b'7', b'8', b'9', b' ', b',', b'%', b'.', b'/',
163];
164
165/*
166 * css is relatively simple but there are a few gotchas. Nested classes basically means any
167 * property can be a selector, so we can't generically distinguish between the two without
168 * a lookup to known legal names, and also the fact that pseudo classes and elements are
169 * denoted with ':' which is also the value assignment operator means we need to scan ahead to
170 * decide if a particular ':' on the input is part of a selector and requires leading
171 * whitespace to be preserved, or if it's the assignment operator and doesn't require leading
172 * whitespace. To avoid re-implementing comment and quote handling while scanning forward, we
173 * instead mark the index as a backreference and remove it later if we can. This also has the
174 * conseqence that we also cannot generically identify if we are currently parsing a property
175 * or a value without a lookup to known legal names, which as far as I know shouldn't cause
176 * problems for handling correct css but eliminates some avenues for error tolerance. But
177 * intelligent handling of incorrect css is beyond this scope of this crate so this is
178 * acceptable.
179 */
180struct Minifier<'a> {
181 input: Option<&'a [u8]>,
182 output0: Vec<u8>,
183 output1: Vec<u8>,
184 // start and end indexes
185 quotes0: HashMap<usize, usize>,
186 errors: Vec<ParseError>,
187}
188
189impl<'a> Minifier<'a> {
190 pub fn get_output(self) -> String {
191 String::from_utf8(self.output1).unwrap()
192 }
193
194 pub fn new() -> Self {
195 Self {
196 input: None,
197 output0: Vec::<u8>::with_capacity(0),
198 output1: Vec::<u8>::with_capacity(0),
199 quotes0: HashMap::<usize, usize>::new(),
200 errors: Vec::<ParseError>::new(),
201 }
202 }
203
204 pub fn minify_string(&mut self, input: &'a String) {
205 self.input = Some(input.as_bytes());
206 self.pass0();
207 self.pass1();
208 }
209
210 fn add_error_msg(&mut self, msg: &str) {
211 self.errors.push(ParseError::from_msg(msg));
212 }
213
214 fn emit_error_msgs(&self) {
215 for error in &self.errors {
216 eprintln!("WARN! const-css-minify parse error: {}", error);
217 }
218 }
219
220 //collapse all whitespace sequences into single ' ', remove comments,
221 //mark quotes in output stream
222 fn pass0(&mut self) {
223 let input = self.input.unwrap();
224 let len = input.len();
225 let mut output = Vec::<u8>::with_capacity(len);
226 let mut read = 0;
227 loop {
228 match read {
229 i if i == len => break,
230 i if i > len => unreachable!(), // to catch errors of reasoning in indexing
231 _ => (),
232 }
233 match input[read] {
234 // trim excess whitespace, convert to space
235 w if w.is_ascii_whitespace() => {
236 // if the last element was a comment that was entirely ignored, and if the
237 // comment was preceeded by whitespace, we might end up with two consecutive
238 // whitespaces, which violates the promise of this method. Thus we explicitly
239 // check and remove it if present.
240 if let Some(last) = output.pop() {
241 if last != b' ' {
242 output.push(last);
243 }
244 }
245 read += 1;
246 while read < len && input[read].is_ascii_whitespace() {
247 read += 1;
248 }
249 // don't add whitespace to head or tail
250 if !output.is_empty() && read < len {
251 output.push(b' ');
252 }
253 }
254 // css comments
255 b'/' if len > read + 1 && input[read + 1] == b'*' => {
256 let mut found_end = false;
257 // move read index to first char after '*' in the matched pattern, or possibly
258 // past the end of input if '/*' are the last two chars.
259 read += 2;
260 // below we are comparing against a '*' at read - 1, and we explicitly want to
261 // avoid opening and closing a comment on '/*/' - a correct comment consists of
262 // '/**/ at a minimum. Therefore we must increment read once more, but we only
263 // want to do this if we aren't already beyond the end of input
264 if read < len {
265 read += 1;
266 }
267 while read < len {
268 let s = &input[read - 1..=read];
269 read += 1;
270 if s == [b'*', b'/'] {
271 found_end = true;
272 break;
273 }
274 }
275 if !found_end {
276 self.add_error_msg("reached end of input while inside comment");
277 }
278 }
279 // quotes
280 q @ (b'"' | b'\'') => {
281 let start = output.len();
282 output.push(input[read]);
283 read += 1;
284 let mut found_end = false;
285 while read < len {
286 let b = input[read];
287 output.push(b);
288 read += 1;
289 if b == q {
290 found_end = true;
291 break;
292 }
293 }
294 if !found_end {
295 self.add_error_msg("reached end of input while inside quote string");
296 }
297 let end = output.len() - 1;
298 self.quotes0.insert(start, end);
299 }
300 _ => {
301 output.push(input[read]);
302 read += 1;
303 }
304 }
305 }
306 self.output0 = output;
307 }
308
309 fn pass1(&mut self) {
310 let input = &self.output0;
311 let len = input.len();
312 let mut output = Vec::<u8>::with_capacity(len);
313 let mut read = 0;
314 let mut peek;
315 let mut backreference = None;
316 loop {
317 match read {
318 i if i == len => break,
319 i if i > len => unreachable!(), // to catch errors of reasoning in indexing
320 _ => (),
321 }
322 match input[read] {
323 // copy quotes verbatim
324 b'\'' | b'"' => {
325 let end = self.quotes0.get(&read).unwrap();
326 while read <= *end {
327 output.push(input[read]);
328 read += 1
329 }
330 }
331 // enter declaration block
332 b'{' => {
333 backreference = None;
334 if let Some(last) = output.pop() {
335 if last != b' ' {
336 output.push(last);
337 }
338 }
339 output.push(input[read]);
340 read += 1;
341 // drop trailing space
342 if read < len && input[read] == b' ' {
343 read += 1;
344 }
345 }
346 // exit declaration block
347 b'}' => {
348 if let Some(br) = backreference {
349 output.remove(br);
350 }
351 backreference = None;
352 if let Some(last) = output.pop() {
353 if last != b' ' {
354 output.push(last);
355 }
356 }
357 // drop final semicolon in declaration block
358 if let Some(last) = output.pop() {
359 if last != b';' {
360 output.push(last);
361 }
362 }
363 output.push(input[read]);
364 read += 1;
365 // drop trailing space
366 if read < len && input[read] == b' ' {
367 read += 1;
368 }
369 }
370 // value assignement OR pseudo class/element
371 b':' => {
372 backreference = None;
373 // pseudo element
374 if len > read + 1 && input[read + 1] == b':' {
375 output.push(b':');
376 output.push(b':');
377 read += 2;
378 } else {
379 if let Some(last) = output.pop() {
380 // mark backreference for possible future removal
381 if last == b' ' {
382 backreference = Some(output.len());
383 }
384 output.push(last);
385 }
386 output.push(input[read]);
387 read += 1;
388 // drop trailing space
389 if read < len && input[read] == b' ' {
390 read += 1;
391 }
392 }
393 }
394 // comma separator
395 b',' => {
396 // drop spaces preceeding commas
397 if let Some(last) = output.pop() {
398 if last != b' ' {
399 output.push(last);
400 }
401 }
402 output.push(input[read]);
403 read += 1;
404 // drop trailing space
405 if read < len && input[read] == b' ' {
406 read += 1;
407 }
408 }
409 // semicolon separator
410 b';' => {
411 if let Some(br) = backreference {
412 output.remove(br);
413 }
414 backreference = None;
415 // drop leading space
416 if let Some(last) = output.pop() {
417 if last != b' ' {
418 output.push(last);
419 }
420 }
421 output.push(input[read]);
422 read += 1;
423 // drop trailing space
424 if read < len && input[read] == b' ' {
425 read += 1;
426 }
427 }
428
429 // possible hex color
430 b'#' if len > read + 3 => {
431 peek = read + 1;
432 while len > peek && input[peek].is_ascii_hexdigit() {
433 peek += 1;
434 }
435 if let Ok(mut hex_color) = try_minify_hex_color(&input[read..peek]) {
436 output.append(&mut hex_color);
437 read = peek;
438 } else {
439 output.push(input[read]);
440 read += 1;
441 }
442 }
443 // possible hsl func
444 b'h' if len > read + 9
445 && (input[read + 1..=read + 3] == [b's', b'l', b'(']
446 || input[read + 1..=read + 4] == [b's', b'l', b'a', b'(']) =>
447 {
448 peek = read + 4;
449 if input[peek] == b'(' {
450 peek += 1;
451 }
452 while len > peek
453 && input[peek] != b')'
454 && RGB_FUNC_DECODABLE.contains(&input[peek])
455 {
456 peek += 1
457 }
458 if input[peek] == b')' {
459 if let Ok(mut hex_color) = try_decode_hsl_func(&input[read..=peek]) {
460 hex_color = try_minify_hex_color(&hex_color).unwrap();
461 output.append(&mut hex_color);
462 read = peek + 1;
463 continue;
464 }
465 }
466 output.push(input[read]);
467 read += 1;
468 }
469 // possible rgb func
470 b'r' if len > read + 9
471 && (input[read + 1..=read + 3] == [b'g', b'b', b'(']
472 || input[read + 1..=read + 4] == [b'g', b'b', b'a', b'(']) =>
473 {
474 peek = read + 4;
475 if input[peek] == b'(' {
476 peek += 1;
477 }
478 while len > peek
479 && input[peek] != b')'
480 && RGB_FUNC_DECODABLE.contains(&input[peek])
481 {
482 peek += 1
483 }
484 if input[peek] == b')' {
485 if let Ok(mut hex_color) = try_decode_rgb_func(&input[read..=peek]) {
486 hex_color = try_minify_hex_color(&hex_color).unwrap();
487 output.append(&mut hex_color);
488 read = peek + 1;
489 continue;
490 }
491 }
492 output.push(input[read]);
493 read += 1;
494 }
495 // all else copy verbatim
496 _ => {
497 output.push(input[read]);
498 read += 1;
499 }
500 }
501 }
502 self.output0.clear();
503 self.output0.shrink_to_fit();
504 self.output1 = output;
505 }
506}
507
508/*
509 * requires input to start with "hsl(" or "hsla(" and end with ")"
510 */
511fn try_decode_hsl_func(input: &[u8]) -> Result<Vec<u8>, ()> {
512 let mut v = vec![b'#'];
513 let mut read = 3;
514 if input[read] == b'a' {
515 read += 1;
516 }
517 if input[read] != b'(' {
518 return Err(());
519 }
520 read += 1;
521 let mut hsla_d = [
522 String::with_capacity(10),
523 String::with_capacity(10),
524 String::with_capacity(10),
525 String::with_capacity(10),
526 ];
527 let mut percents = [false, false, false, false];
528 let mut i = 0;
529
530 while input[read] != b')' {
531 match input[read] {
532 x if !RGB_FUNC_DECODABLE.contains(&x) => return Err(()),
533 d if d.is_ascii_digit() || d == b'.' => hsla_d[i].push(char::from(d)),
534 b'%' => percents[i] = true,
535 b' ' | b',' | b'/' => {
536 i += 1;
537 while [b' ', b',', b'/'].contains(&input[read + 1]) {
538 read += 1;
539 }
540 }
541 _ => unreachable!(), // did we add chars to RGB_FUNC_DECODABLE and not match here?
542 }
543 read += 1;
544 }
545
546 // check we got required input for h, s, l
547 for digits in &hsla_d[0..=2] {
548 if digits.is_empty() {
549 return Err(());
550 }
551 }
552
553 let h = f32::from_str(&hsla_d[0]).or(Err(()))?;
554 if !(0.0..=360.0).contains(&h) {
555 return Err(());
556 }
557 let s = f32::from_str(&hsla_d[1]).or(Err(()))? / 100.0;
558 if !(0.0..=1.0).contains(&s) {
559 return Err(());
560 }
561 let l = f32::from_str(&hsla_d[2]).or(Err(()))? / 100.0;
562 if !(0.0..=1.0).contains(&l) {
563 return Err(());
564 }
565
566 // weird algorithm from wikipedia...
567 let a = s * {
568 if l <= 0.5 {
569 l
570 } else {
571 1_f32 - l
572 }
573 };
574 let ks = [
575 (h / 30_f32) % 12_f32,
576 (8_f32 + h / 30_f32) % 12_f32,
577 (4_f32 + h / 30_f32) % 12_f32,
578 ];
579 for k in ks {
580 let c = match k {
581 ..=2_f32 => -1_f32,
582 2_f32..=4_f32 => k - 3_f32,
583 4_f32..=8_f32 => 1_f32,
584 8_f32..=10_f32 => 9_f32 - k,
585 10_f32.. => -1_f32,
586 _ => unreachable!(),
587 };
588 let integer = ((l - a * c) * 255_f32).round();
589 if integer < u8::MIN.into() || integer > u8::MAX.into() {
590 return Err(());
591 }
592 let byte: u8 = unsafe { integer.to_int_unchecked() };
593 let hex = format!("{:04x}", byte).into_bytes();
594 //igore leading '0x' get only the actual hexadecimal digits
595 v.push(hex[2]);
596 v.push(hex[3]);
597 }
598
599 // alpha channel
600 if !hsla_d[3].is_empty() && !["1", "1.0", "100"].contains(&hsla_d[3].as_str()) {
601 let decimal = f32::from_str(&hsla_d[3]).or(Err(()))?;
602 let integer = if percents[3] {
603 (decimal * 255_f32 / 100_f32).round()
604 } else {
605 (decimal * 255_f32).round()
606 };
607 if integer < u8::MIN.into() || integer > u8::MAX.into() {
608 return Err(());
609 }
610 let byte: u8 = unsafe { integer.to_int_unchecked() };
611
612 //format as hexadecimal
613 let hex = format!("{:04x}", byte).into_bytes();
614 //igore leading '0x' get only the actual hexadecimal digits
615 v.push(hex[2]);
616 v.push(hex[3]);
617 }
618 Ok(v)
619}
620
621/*
622 * requires input to start with "rgb(" or "rgba(" and end with ")"
623 */
624fn try_decode_rgb_func(input: &[u8]) -> Result<Vec<u8>, ()> {
625 let mut v = vec![b'#'];
626 let mut read = 3;
627 if input[read] == b'a' {
628 read += 1;
629 }
630 if input[read] != b'(' {
631 return Err(());
632 }
633 read += 1;
634 let mut rgba_d = [
635 String::with_capacity(10),
636 String::with_capacity(10),
637 String::with_capacity(10),
638 String::with_capacity(10),
639 ];
640 let mut percents = [false, false, false, false];
641 let mut i = 0;
642 while input[read] != b')' {
643 match input[read] {
644 x if !RGB_FUNC_DECODABLE.contains(&x) => return Err(()),
645 d if d.is_ascii_digit() || d == b'.' => rgba_d[i].push(char::from(d)),
646 b'%' => percents[i] = true,
647 b' ' | b',' | b'/' => {
648 i += 1;
649 while [b' ', b',', b'/'].contains(&input[read + 1]) {
650 read += 1;
651 }
652 }
653 _ => unreachable!(), // did we add chars to RGB_FUNC_DECODABLE and not match here?
654 }
655 read += 1;
656 }
657 // check we got required input for r, g, b
658 for i in 0..=2 {
659 if rgba_d[i].is_empty() {
660 return Err(());
661 }
662 let byte: u8 = if percents[i] {
663 let decimal = f32::from_str(&rgba_d[i]).or(Err(()))?; // 👈 #unexpectedlisp
664 let integer = (decimal * 255_f32 / 100_f32).round();
665 if integer < u8::MIN.into() || integer > u8::MAX.into() {
666 return Err(());
667 }
668 unsafe { integer.to_int_unchecked() }
669 } else {
670 u8::from_str(&rgba_d[i]).or(Err(()))?
671 };
672 //format as hexadecimal
673 let hex = format!("{:04x}", byte).into_bytes();
674 //igore leading '0x' get only the actual hexadecimal digits
675 v.push(hex[2]);
676 v.push(hex[3]);
677 }
678 // alpha channel
679 if !rgba_d[3].is_empty() && !["1", "1.0", "100"].contains(&rgba_d[3].as_str()) {
680 let decimal = f32::from_str(&rgba_d[3]).or(Err(()))?;
681 let integer = if percents[3] {
682 (decimal * 255_f32 / 100_f32).round()
683 } else {
684 (decimal * 255_f32).round()
685 };
686 if integer < u8::MIN.into() || integer > u8::MAX.into() {
687 return Err(());
688 }
689 let byte: u8 = unsafe { integer.to_int_unchecked() };
690
691 //format as hexadecimal
692 let hex = format!("{:04x}", byte).into_bytes();
693 //igore leading '0x' get only the actual hexadecimal digits
694 v.push(hex[2]);
695 v.push(hex[3]);
696 }
697 Ok(v)
698}
699
700fn try_minify_hex_color(input: &[u8]) -> Result<Vec<u8>, ()> {
701 let len = input.len();
702 if ![4, 5, 7, 9].contains(&len) || input[0] != b'#' {
703 return Err(());
704 }
705 let mut v = vec![b'#'];
706 for byte in &input[1..] {
707 if !byte.is_ascii_hexdigit() {
708 return Err(());
709 }
710 v.push(*byte);
711 }
712 if len == 9 && v[1] == v[2] && v[3] == v[4] && v[5] == v[6] && v[7] == v[8] {
713 v.remove(8);
714 v.remove(6);
715 v.remove(4);
716 v.remove(2);
717 }
718 if len == 7 && v[1] == v[2] && v[3] == v[4] && v[5] == v[6] {
719 v.remove(6);
720 v.remove(4);
721 v.remove(2);
722 }
723 Ok(v)
724}