├── .github ├── FUNDING.yml └── workflows │ └── CI.yml ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md ├── fuzz ├── .gitignore ├── Cargo.toml └── fuzz_targets │ └── cssparser.rs ├── src ├── cli.rs ├── css │ ├── mod.rs │ ├── tests.rs │ └── token.rs ├── html.rs ├── js │ ├── mod.rs │ ├── token.rs │ ├── tools.rs │ └── utils.rs ├── json │ ├── json_minifier.rs │ ├── mod.rs │ ├── read │ │ ├── byte_to_char.rs │ │ ├── internal_buffer.rs │ │ ├── internal_reader.rs │ │ └── json_read.rs │ └── string.rs ├── lib.rs └── main.rs └── tests ├── files ├── main.js ├── minified_main.js └── test.json └── js_minify.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [GuillaumeGomez] 4 | patreon: GuillaumeGomez 5 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [master] 4 | pull_request: 5 | 6 | name: CI 7 | 8 | jobs: 9 | rustfmt: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions-rs/toolchain@v1 14 | with: 15 | profile: minimal 16 | toolchain: stable 17 | override: true 18 | components: rustfmt 19 | - run: cargo fmt -- --check 20 | 21 | clippy: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v2 25 | - uses: actions-rs/toolchain@v1 26 | with: 27 | profile: minimal 28 | toolchain: nightly 29 | override: true 30 | components: clippy 31 | - run: cargo clippy -- -D warnings 32 | 33 | check: 34 | name: Check ${{ matrix.toolchain }} / ${{ matrix.triple.target }} 35 | runs-on: ubuntu-latest 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | toolchain: 40 | - stable 41 | - nightly 42 | steps: 43 | - uses: actions/checkout@v2 44 | - name: Install toolchain 45 | uses: actions-rs/toolchain@v1 46 | with: 47 | profile: minimal 48 | toolchain: ${{ matrix.toolchain }} 49 | override: true 50 | - name: Check build 51 | run: cargo build 52 | - name: Run tests 53 | run: cargo test 54 | - name: Build doc 55 | run: cargo doc 56 | - name: Run tests (with "html" feature) 57 | run: cargo test --features "html" 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | target 3 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "minifier" 3 | version = "0.3.5" 4 | authors = ["Guillaume Gomez "] 5 | 6 | description = "Minifier tool/lib for JS/CSS/JSON files" 7 | repository = "https://github.com/GuillaumeGomez/minifier-rs" 8 | documentation = "https://docs.rs/minifier-rs" 9 | readme = "README.md" 10 | keywords = ["minify", "minifier", "JS", "HTML", "CSS"] 11 | license = "MIT" 12 | edition = "2021" 13 | 14 | [features] 15 | default = ["clap"] 16 | html = ["regex"] 17 | 18 | [dependencies] 19 | clap = { version = "4.5.13", features = ["cargo"], optional = true } 20 | regex = { version = "1.5.5", optional = true } 21 | 22 | [lib] 23 | name = "minifier" 24 | 25 | [[bin]] 26 | name = "minifier" 27 | doc = false 28 | required-features = ["clap"] 29 | 30 | [profile.release] 31 | lto = true 32 | strip = true 33 | codegen-units = 1 34 | opt-level = 3 35 | 36 | [profile.release.package."*"] 37 | codegen-units = 1 38 | opt-level = 3 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Guillaume Gomez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # minifier-rs 2 | 3 | Minifier tool/lib for JS/CSS/JSON files. 4 | 5 | This crate provides both a library and binary, depending on your needs. 6 | 7 | ## Usage 8 | 9 | To use the binary, just run like this: 10 | 11 | ``` 12 | > cargo run test.js 13 | ``` 14 | 15 | To use the library, add it into your `Cargo.toml` file like this: 16 | 17 | ```toml 18 | [dependencies] 19 | minifier = "0.2" 20 | ``` 21 | 22 | ## WARNING!! 23 | 24 | Please be aware that this is still at a very early stage of development so you shouldn't rely on it too much! 25 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | coverage 5 | -------------------------------------------------------------------------------- /fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "minifier-fuzz" 3 | version = "0.0.0" 4 | publish = false 5 | edition = "2021" 6 | 7 | [package.metadata] 8 | cargo-fuzz = true 9 | 10 | [dependencies] 11 | libfuzzer-sys = "0.4" 12 | cssparser = "0.34.0" 13 | 14 | [dependencies.minifier] 15 | path = ".." 16 | 17 | [[bin]] 18 | name = "cssparser" 19 | path = "fuzz_targets/cssparser.rs" 20 | test = false 21 | doc = false 22 | bench = false 23 | -------------------------------------------------------------------------------- /fuzz/fuzz_targets/cssparser.rs: -------------------------------------------------------------------------------- 1 | // This file is almost entirely lifted from 2 | // https://github.com/servo/rust-cssparser/blob/fa6f5eb23f058c6fce444ac781b0b380003fdb59/fuzz/fuzz_targets/cssparser.rs 3 | // but we compare minified and unminified code instead. 4 | // 5 | // This parser branches on ascii characters, so you'll find interesting corner cases more quickly with 6 | // cargo fuzz run cssparser -- -only_ascii=1 7 | #![no_main] 8 | 9 | use cssparser::*; 10 | 11 | const DEBUG: bool = false; 12 | 13 | fn parse_and_serialize(input: &str) -> String { 14 | let mut input = ParserInput::new(input); 15 | let mut parser = Parser::new(&mut input); 16 | let mut serialization = String::new(); 17 | let result = do_parse_and_serialize( 18 | &mut parser, 19 | TokenSerializationType::nothing(), 20 | &mut serialization, 21 | 0, 22 | ); 23 | if result.is_err() { 24 | return String::new(); 25 | } 26 | serialization 27 | } 28 | 29 | fn do_parse_and_serialize<'i>( 30 | input: &mut Parser<'i, '_>, 31 | mut previous_token_type: TokenSerializationType, 32 | serialization: &mut String, 33 | indent_level: usize, 34 | ) -> Result<(), ParseError<'i, ()>> { 35 | loop { 36 | let token = input.next(); 37 | let token = match token { 38 | Ok(token) => token, 39 | Err(..) => break, 40 | }; 41 | if DEBUG { 42 | for _ in 0..indent_level { 43 | print!(" "); 44 | } 45 | println!("{:?}", token); 46 | } 47 | if token.is_parse_error() { 48 | let token = token.clone(); 49 | return Err(input.new_unexpected_token_error(token)) 50 | } 51 | let token_type = token.serialization_type(); 52 | if previous_token_type.needs_separator_when_before(token_type) { 53 | serialization.push_str("/**/"); 54 | } 55 | previous_token_type = token_type; 56 | token.to_css(serialization).unwrap(); 57 | let closing_token = match token { 58 | Token::Function(_) | Token::ParenthesisBlock => Token::CloseParenthesis, 59 | Token::SquareBracketBlock => Token::CloseSquareBracket, 60 | Token::CurlyBracketBlock => Token::CloseCurlyBracket, 61 | _ => continue, 62 | }; 63 | 64 | input.parse_nested_block(|input| -> Result<_, ParseError<()>> { 65 | do_parse_and_serialize(input, previous_token_type, serialization, indent_level + 1) 66 | })?; 67 | 68 | closing_token.to_css(serialization).unwrap(); 69 | } 70 | Ok(()) 71 | } 72 | 73 | fn fuzz(data: &str) { 74 | let unminified = parse_and_serialize(data); 75 | if unminified != "" { 76 | let Ok(minified) = minifier::css::minify(data) else { return }; 77 | let minified = minified.to_string(); 78 | eprintln!("{minified:?}"); 79 | let minified = parse_and_serialize(&minified); 80 | assert_eq!(unminified, minified); 81 | } 82 | } 83 | 84 | libfuzzer_sys::fuzz_target!(|data: &str| { 85 | fuzz(data); 86 | }); -------------------------------------------------------------------------------- /src/cli.rs: -------------------------------------------------------------------------------- 1 | use std::convert::From; 2 | use std::env; 3 | use std::fs::{File, OpenOptions}; 4 | use std::io::{self, Read, Write}; 5 | use std::path::{Path, PathBuf}; 6 | 7 | use clap::builder::PossibleValue; 8 | use clap::{command, value_parser, Arg, ArgAction, ValueEnum}; 9 | 10 | extern crate minifier; 11 | use minifier::{css, js, json}; 12 | 13 | pub struct Cli; 14 | 15 | impl Cli { 16 | pub fn init() { 17 | let matches = command!() 18 | .arg( 19 | Arg::new("FileType") 20 | .short('t') 21 | .long("type") 22 | .help( 23 | "File Extention without dot. This option is optional. 24 | If you don't provide this option, all input files 25 | type will detect via extension of input file. 26 | ", 27 | ) 28 | .required(false) 29 | .value_parser(value_parser!(FileType)), 30 | ) 31 | .arg( 32 | Arg::new("output") 33 | .short('o') 34 | .long("out") 35 | .help("Output file or directory (Default is parent dir of input files)") 36 | .required(false) 37 | .value_parser(value_parser!(PathBuf)), 38 | ) 39 | .arg( 40 | Arg::new("FILE") 41 | .help("Input Files...") 42 | .num_args(1..) 43 | .value_parser(value_parser!(PathBuf)) 44 | .action(ArgAction::Append), 45 | ) 46 | .get_matches(); 47 | let args: Vec<&PathBuf> = matches 48 | .get_many::("FILE") 49 | .unwrap_or_default() 50 | .collect::>(); 51 | let ext: Option<&FileType> = matches.get_one::("FileType"); 52 | let out: Option<&PathBuf> = matches.get_one::("output"); 53 | for path in args.into_iter() { 54 | write_out_file(path, out, ext); 55 | } 56 | } 57 | } 58 | 59 | #[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)] 60 | enum FileType { 61 | // Html, 62 | Css, 63 | Js, 64 | Json, 65 | Unknown, 66 | } 67 | 68 | impl FileType { 69 | fn as_str(&self) -> &str { 70 | match self { 71 | Self::Css => "css", 72 | Self::Js => "js", 73 | Self::Json => "json", 74 | Self::Unknown => "unknown", 75 | } 76 | } 77 | } 78 | 79 | impl ValueEnum for FileType { 80 | fn value_variants<'a>() -> &'a [Self] { 81 | &[FileType::Css, FileType::Js, FileType::Json] 82 | } 83 | fn to_possible_value(&self) -> Option { 84 | Some(match *self { 85 | FileType::Css => PossibleValue::new("css") 86 | .help("All the files will be consider as CSS, regardless of their extension."), 87 | FileType::Js => PossibleValue::new("js").help( 88 | "All the files will be consider as JavaScript, regardless of their extension.", 89 | ), 90 | FileType::Json => PossibleValue::new("json") 91 | .help("All the files will be consider as JSON, regardless of their extension."), 92 | FileType::Unknown => panic!("unknow file"), 93 | }) 94 | } 95 | } 96 | impl std::str::FromStr for FileType { 97 | type Err = String; 98 | fn from_str(s: &str) -> Result { 99 | for variant in Self::value_variants() { 100 | if variant.to_possible_value().unwrap().matches(s, false) { 101 | return Ok(*variant); 102 | }; 103 | } 104 | Err(format!("Invalid variant: {s}")) 105 | } 106 | } 107 | 108 | impl From<&PathBuf> for FileType { 109 | fn from(value: &PathBuf) -> Self { 110 | let ext = value.extension(); 111 | if ext.is_none() { 112 | return Self::Unknown; 113 | }; 114 | match ext.unwrap().to_ascii_lowercase().to_str().unwrap() { 115 | "css" => Self::Css, 116 | "js" => Self::Js, 117 | "json" => Self::Json, 118 | _ => Self::Unknown, 119 | } 120 | } 121 | } 122 | 123 | pub fn get_all_data>(file_path: T) -> io::Result { 124 | let mut file = File::open(file_path)?; 125 | let mut data = String::new(); 126 | file.read_to_string(&mut data).unwrap(); 127 | Ok(data) 128 | } 129 | 130 | fn write_out_file(file_path: &PathBuf, out_path: Option<&PathBuf>, ext: Option<&FileType>) { 131 | let file_ext = if let Some(v) = ext { 132 | v 133 | } else { 134 | &FileType::from(file_path) 135 | }; 136 | if file_ext == &FileType::Unknown { 137 | eprintln!("{:?}: unknow file extension...", file_path); 138 | return; 139 | }; 140 | match get_all_data(file_path) { 141 | Ok(content) => { 142 | let out = if out_path.is_some() { 143 | let mut op = out_path.unwrap().clone(); 144 | if op.is_dir() { 145 | op.push(file_path); 146 | op.set_extension(format!("min.{}", file_ext.as_str())); 147 | }; 148 | if op.parent().is_some() && !op.parent().unwrap().is_dir() { 149 | std::fs::create_dir_all(op.parent().unwrap()).unwrap(); 150 | }; 151 | op 152 | } else { 153 | let mut p = file_path.clone(); 154 | p.set_extension(format!("min.{}", file_ext.as_str())); 155 | p 156 | }; 157 | if let Ok(mut file) = OpenOptions::new() 158 | .truncate(true) 159 | .write(true) 160 | .create(true) 161 | .open(&out) 162 | { 163 | let func = |s: &str| -> String { 164 | match file_ext { 165 | FileType::Css => { 166 | css::minify(s).expect("css minification failed").to_string() 167 | } 168 | FileType::Js => js::minify(s).to_string(), 169 | FileType::Json => json::minify(s).to_string(), 170 | FileType::Unknown => panic!("{:?}: unknow file extension...", file_path), 171 | } 172 | }; 173 | if let Err(e) = write!(file, "{}", func(&content)) { 174 | eprintln!("Impossible to write into {:?}: {}", out, e); 175 | } else { 176 | println!("{:?}: done -> generated into {:?}", file_path, out); 177 | } 178 | } else { 179 | eprintln!("Impossible to create new file: {:?}", out); 180 | } 181 | } 182 | Err(e) => eprintln!("{:?}: {}", file_path, e), 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/css/mod.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use std::{fmt, io}; 4 | 5 | mod token; 6 | 7 | /// Minifies a given CSS source code. 8 | /// 9 | /// # Example 10 | /// 11 | /// ```rust 12 | /// use minifier::css::minify; 13 | /// 14 | /// let css = r#" 15 | /// .foo > p { 16 | /// color: red; 17 | /// }"#.into(); 18 | /// let css_minified = minify(css).expect("minification failed"); 19 | /// assert_eq!(&css_minified.to_string(), ".foo>p{color:red;}"); 20 | /// ``` 21 | pub fn minify(content: &str) -> Result, &'static str> { 22 | token::tokenize(content).map(Minified) 23 | } 24 | 25 | pub struct Minified<'a>(token::Tokens<'a>); 26 | 27 | impl Minified<'_> { 28 | pub fn write(self, w: W) -> io::Result<()> { 29 | self.0.write(w) 30 | } 31 | } 32 | 33 | impl fmt::Display for Minified<'_> { 34 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 35 | self.0.fmt(f) 36 | } 37 | } 38 | 39 | #[cfg(test)] 40 | mod tests; 41 | -------------------------------------------------------------------------------- /src/css/tests.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use crate::css::minify; 4 | 5 | /*enum Element { 6 | /// Rule starting with `@`: 7 | /// 8 | /// * charset 9 | /// * font-face 10 | /// * import 11 | /// * keyframes 12 | /// * media 13 | AtRule(AtRule<'a>), 14 | /// Any "normal" CSS rule block. 15 | /// 16 | /// Contains the selector(s) and its content. 17 | ElementRule(Vec<&'a str>, Vec>), 18 | } 19 | 20 | fn get_property<'a>(source: &'a str, iterator: &mut Peekable, 21 | start_pos: &mut usize) -> Option> { 22 | let mut end_pos = None; 23 | // First we get the property name. 24 | while let Some((pos, c)) = iterator.next() { 25 | if let Ok(c) = ReservedChar::try_from(c) { 26 | if c.is_useless() { 27 | continue 28 | } else if c == ReservedChar::OpenCurlyBrace { 29 | return None 30 | } else if c == ReservedChar::Colon { 31 | end_pos = Some(pos); 32 | break 33 | } else { // Invalid character. 34 | return None; 35 | } 36 | } else if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '-' { 37 | // everything's fine for now... 38 | } else { 39 | return None; // invalid character 40 | } 41 | } 42 | if end_pos.is_none() || end_pos == Some(*start_pos + 1) { 43 | return None; 44 | } 45 | while let Some((pos, c)) = iterator.next() { 46 | if let Ok(c) = ReservedChar::try_from(c) { 47 | if c == ReservedChar::DoubleQuote || c == ReservedChar::Quote { 48 | get_string(source, iterator, &mut 0, c) 49 | } else if c == ReservedChar::SemiColon { 50 | // we reached the end! 51 | let end_pos = end_pos.unwrap(); 52 | *start_pos = pos; 53 | return Property { 54 | name: &source[start_pos..end_pos], 55 | value: &source[end_pos..pos], 56 | } 57 | } 58 | } 59 | } 60 | None 61 | } 62 | 63 | enum Selector<'a> { 64 | Tag(&'a str), 65 | /// '.' 66 | Class(&'a str), 67 | /// '#' 68 | Id(&'a str), 69 | /// '<', '>', '(', ')', '+', ' ', '[', ']' 70 | Operator(char), 71 | } 72 | 73 | struct ElementRule<'a> { 74 | selectors: Vec>, 75 | properties: Vec>, 76 | } 77 | 78 | fn get_element_rule<'a>(source: &'a str, iterator: &mut Peekable, 79 | c: char) -> Option> { 80 | let mut selectors = Vec::with_capacity(2); 81 | 82 | while let Some(s) = get_next_selector(source, iterator, c) { 83 | if !selectors.is_empty() || !s.empty_operator() { 84 | } 85 | selectors.push(s); 86 | } 87 | } 88 | 89 | fn get_media_query<'a>(source: &'a str, iterator: &mut Peekable, 90 | start_pos: &mut usize) -> Option> { 91 | while let Some((pos, c)) = iterator.next() { 92 | if c == '{' { 93 | ; 94 | } 95 | } 96 | None // An error occurred, sad life... 97 | } 98 | 99 | 100 | fn get_properties<'a>(source: &'a str, iterator: &mut Peekable, 101 | start_pos: &mut usize) -> Vec { 102 | let mut ret = Vec::with_capacity(2); 103 | while let Some(property) = get_property(source, iterator, start_pos) { 104 | ret.push(property); 105 | } 106 | ret 107 | } 108 | 109 | pub struct Property<'a> { 110 | name: &'a str, 111 | value: &'a str, 112 | } 113 | 114 | pub enum AtRule<'a> { 115 | /// Contains the charset. Supposed to be the first rule in the style sheet and be present 116 | /// only once. 117 | Charset(&'a str), 118 | /// font-face rule. 119 | FontFace(Vec>), 120 | /// Contains the import. 121 | Import(&'a str), 122 | /// Contains the rule and the block. 123 | Keyframes(&'a str, Tokens<'a>), 124 | /// Contains the rules and the block. 125 | Media(Vec<&'a str>, Tokens<'a>), 126 | } 127 | 128 | impl fmt::Display for AtRule { 129 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 130 | write!(f, "@{}", &match *self { 131 | AtRule::Charset(c) => format!("charset {};", c), 132 | AtRule::FontFace(t) => format!("font-face {{{}}};", t), 133 | AtRule::Import(i) => format!("import {};", i), 134 | AtRule::Keyframes(r, t) => format!("keyframes {} {{{}}}", r, t), 135 | AtRule::Media(r, t) => format!("media {} {{{}}}", r.join(" ").collect::(), t), 136 | }) 137 | } 138 | }*/ 139 | 140 | #[test] 141 | fn check_minification() { 142 | let s = r#" 143 | /** Baguette! */ 144 | .b > p + div:hover { 145 | background: #fff; 146 | } 147 | 148 | a[target = "_blank"] { 149 | /* I like weird tests. */ 150 | border: 1px solid yellow ; 151 | } 152 | "#; 153 | let expected = r#"/*! Baguette! */ 154 | .b>p+div:hover{background:#fff;}a[target="_blank"]{border:1px solid yellow;}"#; 155 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 156 | } 157 | 158 | #[test] 159 | fn check_minification2() { 160 | let s = r#" 161 | h2, h3:not(.impl):not(.method):not(.type) { 162 | background-color: #0a042f !important; 163 | } 164 | 165 | :target { background: #494a3d; } 166 | 167 | .table-display tr td:first-child { 168 | float: right; 169 | } 170 | 171 | /* just some 172 | * long 173 | * 174 | * very 175 | * long 176 | * comment :) 177 | */ 178 | @media (max-width: 700px) { 179 | .theme-picker { 180 | left: 10px; 181 | top: 54px; 182 | z-index: 1; 183 | background-color: rgba(0, 0 , 0 , 0); 184 | font: 15px "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; 185 | } 186 | }"#; 187 | let expected = "h2,h3:not(.impl):not(.method):not(.type){background-color:#0a042f !important;}\ 188 | :target{background:#494a3d;}.table-display tr td:first-child{float:right;}\ 189 | @media (max-width:700px){.theme-picker{left:10px;top:54px;z-index:1;\ 190 | background-color:rgba(0,0,0,0);font:15px \"SFMono-Regular\",Consolas,\ 191 | \"Liberation Mono\",Menlo,Courier,monospace;}}"; 192 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 193 | } 194 | 195 | #[test] 196 | fn check_calc() { 197 | let s = ".foo { width: calc(100% - 34px); }"; 198 | let expected = ".foo{width:calc(100% - 34px);}"; 199 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 200 | } 201 | 202 | #[test] 203 | fn check_container() { 204 | let s = "@container rustdoc (min-width: 1250px) { .foo { width: 100px; } }"; 205 | let expected = "@container rustdoc (min-width:1250px){.foo{width:100px;}}"; 206 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 207 | } 208 | 209 | #[test] 210 | fn check_spaces() { 211 | let s = ".line-numbers .line-highlighted { color: #0a042f !important; }"; 212 | let expected = ".line-numbers .line-highlighted{color:#0a042f !important;}"; 213 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 214 | } 215 | 216 | #[test] 217 | fn check_space_after_paren() { 218 | let s = ".docblock:not(.type-decl) a:not(.srclink) {}"; 219 | let expected = ".docblock:not(.type-decl) a:not(.srclink){}"; 220 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 221 | } 222 | 223 | #[test] 224 | fn check_space_after_and() { 225 | let s = "@media only screen and (max-width : 600px) {}"; 226 | let expected = "@media only screen and (max-width:600px){}"; 227 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 228 | } 229 | 230 | #[test] 231 | fn check_space_after_or_not() { 232 | let s = "@supports not ((text-align-last: justify) or (-moz-text-align-last: justify)) {}"; 233 | let expected = "@supports not ((text-align-last:justify) or (-moz-text-align-last:justify)){}"; 234 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 235 | } 236 | 237 | #[test] 238 | fn check_space_after_brackets() { 239 | let s = "#main[data-behavior = \"1\"] {}"; 240 | let expected = "#main[data-behavior=\"1\"]{}"; 241 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 242 | 243 | let s = "#main[data-behavior = \"1\"] .aclass"; 244 | let expected = "#main[data-behavior=\"1\"] .aclass"; 245 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 246 | 247 | let s = "#main[data-behavior = \"1\"] ul.aclass"; 248 | let expected = "#main[data-behavior=\"1\"] ul.aclass"; 249 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 250 | } 251 | 252 | #[test] 253 | fn check_whitespaces_in_calc() { 254 | let s = ".foo { width: calc(130px + 10%); }"; 255 | let expected = ".foo{width:calc(130px + 10%);}"; 256 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 257 | 258 | let s = ".foo { width: calc(130px + (45% - 10% + (12 * 2px))); }"; 259 | let expected = ".foo{width:calc(130px + (45% - 10% + (12 * 2px)));}"; 260 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 261 | } 262 | 263 | #[test] 264 | fn check_weird_comments() { 265 | let s = ".test1 { 266 | font-weight: 30em; 267 | }/**/ 268 | .test2 { 269 | font-weight: 30em; 270 | }/**/ 271 | .test3 { 272 | font-weight: 30em; 273 | }/**/"; 274 | let expected = ".test1{font-weight:30em;}.test2{font-weight:30em;}.test3{font-weight:30em;}"; 275 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 276 | } 277 | 278 | #[test] 279 | fn check_slash_slash() { 280 | let s = "body { 281 | background-image: url(data:image/webp;base64,c//S4KP//ZZ/19Uj/UA==); 282 | }"; 283 | let expected = "body{background-image:url(data:image/webp;base64,c//S4KP//ZZ/19Uj/UA==);}"; 284 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 285 | } 286 | 287 | #[test] 288 | fn check_escaped_characters() { 289 | let s = r#".before\:prose-headings\:content-\[\'\#\'\] :is(:where(h1,h2,h3,h4,h5,h6,th):not(:where([class~="not-prose"] *)))::before{ 290 | --en-content: '#'; 291 | content: var(--en-content); 292 | }"#; 293 | let expected = r#".before\:prose-headings\:content-\[\'\#\'\] :is(:where(h1,h2,h3,h4,h5,h6,th):not(:where([class~="not-prose"] *)))::before{--en-content:'#';content:var(--en-content);}"#; 294 | assert_eq!(minify(s).expect("minify failed").to_string(), expected); 295 | } 296 | 297 | #[test] 298 | fn issue_80() { 299 | assert_eq!( 300 | minify("@import 'i';t{x: #fff;}").unwrap().to_string(), 301 | "@import 'i';t{x:#fff;}", 302 | ); 303 | } 304 | 305 | #[test] 306 | fn check_attribute() { 307 | assert_eq!(minify("x [y] {}").unwrap().to_string(), "x [y]{}",); 308 | } 309 | 310 | #[test] 311 | fn check_unicode_edge_cases() { 312 | let edge_cases = [";-/**\u{651}p", "\\\u{59c}"]; 313 | for edge_case in edge_cases { 314 | let _ = minify(edge_case); 315 | } 316 | } 317 | 318 | #[test] 319 | fn check_weird_input() { 320 | assert!(minify(r##""}"##).is_err()); 321 | assert!(minify(r##"/*}"##).is_err()); 322 | assert_eq!(minify(".").unwrap().to_string(), "."); 323 | assert_eq!(minify("//**/*").unwrap().to_string(), "/ *"); 324 | assert_eq!(minify("1/**/H").unwrap().to_string(), "1 H"); 325 | assert_eq!(minify("*/**/=").unwrap().to_string(), "* ="); 326 | assert_eq!(minify(r#"\-1"#).unwrap().to_string(), r#"\-1"#); 327 | } 328 | 329 | #[test] 330 | fn check_space_after_star() { 331 | assert_eq!(minify("* .original").unwrap().to_string(), "* .original"); 332 | assert_eq!( 333 | minify(".a * .original").unwrap().to_string(), 334 | ".a * .original" 335 | ); 336 | } 337 | -------------------------------------------------------------------------------- /src/css/token.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use std::convert::TryFrom; 4 | use std::fmt; 5 | use std::iter::Peekable; 6 | use std::str::CharIndices; 7 | 8 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 9 | pub enum ReservedChar { 10 | Comma, 11 | SuperiorThan, 12 | OpenParenthese, 13 | CloseParenthese, 14 | OpenCurlyBrace, 15 | CloseCurlyBrace, 16 | OpenBracket, 17 | CloseBracket, 18 | Colon, 19 | SemiColon, 20 | Slash, 21 | Plus, 22 | EqualSign, 23 | Space, 24 | Tab, 25 | Backline, 26 | Star, 27 | Quote, 28 | DoubleQuote, 29 | Pipe, 30 | Tilde, 31 | Dollar, 32 | Circumflex, 33 | Backslash, 34 | } 35 | 36 | impl fmt::Display for ReservedChar { 37 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 38 | write!( 39 | f, 40 | "{}", 41 | match *self { 42 | ReservedChar::Comma => ',', 43 | ReservedChar::OpenParenthese => '(', 44 | ReservedChar::CloseParenthese => ')', 45 | ReservedChar::OpenCurlyBrace => '{', 46 | ReservedChar::CloseCurlyBrace => '}', 47 | ReservedChar::OpenBracket => '[', 48 | ReservedChar::CloseBracket => ']', 49 | ReservedChar::Colon => ':', 50 | ReservedChar::SemiColon => ';', 51 | ReservedChar::Slash => '/', 52 | ReservedChar::Star => '*', 53 | ReservedChar::Plus => '+', 54 | ReservedChar::EqualSign => '=', 55 | ReservedChar::Space => ' ', 56 | ReservedChar::Tab => '\t', 57 | ReservedChar::Backline => '\n', 58 | ReservedChar::SuperiorThan => '>', 59 | ReservedChar::Quote => '\'', 60 | ReservedChar::DoubleQuote => '"', 61 | ReservedChar::Pipe => '|', 62 | ReservedChar::Tilde => '~', 63 | ReservedChar::Dollar => '$', 64 | ReservedChar::Circumflex => '^', 65 | ReservedChar::Backslash => '\\', 66 | } 67 | ) 68 | } 69 | } 70 | 71 | impl TryFrom for ReservedChar { 72 | type Error = &'static str; 73 | 74 | fn try_from(value: char) -> Result { 75 | match value { 76 | '\'' => Ok(ReservedChar::Quote), 77 | '"' => Ok(ReservedChar::DoubleQuote), 78 | ',' => Ok(ReservedChar::Comma), 79 | '(' => Ok(ReservedChar::OpenParenthese), 80 | ')' => Ok(ReservedChar::CloseParenthese), 81 | '{' => Ok(ReservedChar::OpenCurlyBrace), 82 | '}' => Ok(ReservedChar::CloseCurlyBrace), 83 | '[' => Ok(ReservedChar::OpenBracket), 84 | ']' => Ok(ReservedChar::CloseBracket), 85 | ':' => Ok(ReservedChar::Colon), 86 | ';' => Ok(ReservedChar::SemiColon), 87 | '/' => Ok(ReservedChar::Slash), 88 | '*' => Ok(ReservedChar::Star), 89 | '+' => Ok(ReservedChar::Plus), 90 | '=' => Ok(ReservedChar::EqualSign), 91 | ' ' => Ok(ReservedChar::Space), 92 | '\t' => Ok(ReservedChar::Tab), 93 | '\n' | '\r' => Ok(ReservedChar::Backline), 94 | '>' => Ok(ReservedChar::SuperiorThan), 95 | '|' => Ok(ReservedChar::Pipe), 96 | '~' => Ok(ReservedChar::Tilde), 97 | '$' => Ok(ReservedChar::Dollar), 98 | '^' => Ok(ReservedChar::Circumflex), 99 | '\\' => Ok(ReservedChar::Backslash), 100 | _ => Err("Unknown reserved char"), 101 | } 102 | } 103 | } 104 | 105 | impl ReservedChar { 106 | fn is_useless(&self) -> bool { 107 | *self == ReservedChar::Space 108 | || *self == ReservedChar::Tab 109 | || *self == ReservedChar::Backline 110 | } 111 | 112 | fn is_operator(&self) -> bool { 113 | Operator::try_from(*self).is_ok() 114 | } 115 | } 116 | 117 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 118 | pub enum Operator { 119 | Plus, 120 | Multiply, 121 | Minus, 122 | Modulo, 123 | Divide, 124 | } 125 | 126 | impl fmt::Display for Operator { 127 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 128 | write!( 129 | f, 130 | "{}", 131 | match *self { 132 | Operator::Plus => '+', 133 | Operator::Multiply => '*', 134 | Operator::Minus => '-', 135 | Operator::Modulo => '%', 136 | Operator::Divide => '/', 137 | } 138 | ) 139 | } 140 | } 141 | 142 | impl TryFrom for Operator { 143 | type Error = &'static str; 144 | 145 | fn try_from(value: char) -> Result { 146 | match value { 147 | '+' => Ok(Operator::Plus), 148 | '*' => Ok(Operator::Multiply), 149 | '-' => Ok(Operator::Minus), 150 | '%' => Ok(Operator::Modulo), 151 | '/' => Ok(Operator::Divide), 152 | _ => Err("Unknown operator"), 153 | } 154 | } 155 | } 156 | 157 | impl TryFrom for Operator { 158 | type Error = &'static str; 159 | 160 | fn try_from(value: ReservedChar) -> Result { 161 | match value { 162 | ReservedChar::Slash => Ok(Operator::Divide), 163 | ReservedChar::Star => Ok(Operator::Multiply), 164 | ReservedChar::Plus => Ok(Operator::Plus), 165 | _ => Err("Unknown operator"), 166 | } 167 | } 168 | } 169 | 170 | #[derive(Eq, PartialEq, Clone, Debug)] 171 | pub enum SelectorElement<'a> { 172 | PseudoClass(&'a str), 173 | Class(&'a str), 174 | Id(&'a str), 175 | Tag(&'a str), 176 | Media(&'a str), 177 | } 178 | 179 | impl<'a> TryFrom<&'a str> for SelectorElement<'a> { 180 | type Error = &'static str; 181 | 182 | fn try_from(value: &'a str) -> Result, Self::Error> { 183 | if let Some(value) = value.strip_prefix('.') { 184 | if value.is_empty() { 185 | Err("cannot determine selector") 186 | } else { 187 | Ok(SelectorElement::Class(value)) 188 | } 189 | } else if let Some(value) = value.strip_prefix('#') { 190 | if value.is_empty() { 191 | Err("cannot determine selector") 192 | } else { 193 | Ok(SelectorElement::Id(value)) 194 | } 195 | } else if let Some(value) = value.strip_prefix('@') { 196 | if value.is_empty() { 197 | Err("cannot determine selector") 198 | } else { 199 | Ok(SelectorElement::Media(value)) 200 | } 201 | } else if let Some(value) = value.strip_prefix(':') { 202 | if value.is_empty() { 203 | Err("cannot determine selector") 204 | } else { 205 | Ok(SelectorElement::PseudoClass(value)) 206 | } 207 | } else if value.chars().next().unwrap_or(' ').is_alphabetic() { 208 | Ok(SelectorElement::Tag(value)) 209 | } else { 210 | Err("unknown selector") 211 | } 212 | } 213 | } 214 | 215 | impl fmt::Display for SelectorElement<'_> { 216 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 217 | match *self { 218 | SelectorElement::Class(c) => write!(f, ".{}", c), 219 | SelectorElement::Id(i) => write!(f, "#{}", i), 220 | SelectorElement::Tag(t) => write!(f, "{}", t), 221 | SelectorElement::Media(m) => write!(f, "@{} ", m), 222 | SelectorElement::PseudoClass(pc) => write!(f, ":{}", pc), 223 | } 224 | } 225 | } 226 | 227 | #[derive(Eq, PartialEq, Clone, Debug, Copy)] 228 | pub enum SelectorOperator { 229 | /// `~=` 230 | OneAttributeEquals, 231 | /// `|=` 232 | EqualsOrStartsWithFollowedByDash, 233 | /// `$=` 234 | EndsWith, 235 | /// `^=` 236 | FirstStartsWith, 237 | /// `*=` 238 | Contains, 239 | } 240 | 241 | impl fmt::Display for SelectorOperator { 242 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 243 | match *self { 244 | SelectorOperator::OneAttributeEquals => write!(f, "~="), 245 | SelectorOperator::EqualsOrStartsWithFollowedByDash => write!(f, "|="), 246 | SelectorOperator::EndsWith => write!(f, "$="), 247 | SelectorOperator::FirstStartsWith => write!(f, "^="), 248 | SelectorOperator::Contains => write!(f, "*="), 249 | } 250 | } 251 | } 252 | 253 | #[derive(Eq, PartialEq, Clone, Debug)] 254 | pub enum Token<'a> { 255 | /// Comment. 256 | Comment(&'a str), 257 | /// Comment starting with `/**`. 258 | License(&'a str), 259 | Char(ReservedChar), 260 | Other(&'a str), 261 | SelectorElement(SelectorElement<'a>), 262 | String(&'a str), 263 | SelectorOperator(SelectorOperator), 264 | Operator(Operator), 265 | } 266 | 267 | impl fmt::Display for Token<'_> { 268 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 269 | match *self { 270 | // Token::AtRule(at_rule) => write!(f, "{}", at_rule, content), 271 | // Token::ElementRule(selectors) => write!(f, "{}", x), 272 | Token::Comment(c) => write!(f, "{}", c), 273 | Token::License(l) => writeln!(f, "/*!{}*/", l), 274 | Token::Char(c) => write!(f, "{}", c), 275 | Token::Other(s) => write!(f, "{}", s), 276 | Token::SelectorElement(ref se) => write!(f, "{}", se), 277 | Token::String(s) => write!(f, "{}", s), 278 | Token::SelectorOperator(so) => write!(f, "{}", so), 279 | Token::Operator(op) => write!(f, "{}", op), 280 | } 281 | } 282 | } 283 | 284 | impl Token<'_> { 285 | fn is_comment(&self) -> bool { 286 | matches!(*self, Token::Comment(_)) 287 | } 288 | 289 | fn is_char(&self) -> bool { 290 | matches!(*self, Token::Char(_)) 291 | } 292 | 293 | fn get_char(&self) -> Option { 294 | match *self { 295 | Token::Char(c) => Some(c), 296 | _ => None, 297 | } 298 | } 299 | 300 | fn is_useless(&self) -> bool { 301 | match *self { 302 | Token::Char(c) => c.is_useless(), 303 | _ => false, 304 | } 305 | } 306 | 307 | fn is_a_media(&self) -> bool { 308 | matches!(*self, Token::SelectorElement(SelectorElement::Media(_))) 309 | } 310 | 311 | fn is_a_selector_element(&self) -> bool { 312 | matches!(*self, Token::SelectorElement(_)) 313 | } 314 | 315 | fn is_a_license(&self) -> bool { 316 | matches!(*self, Token::License(_)) 317 | } 318 | 319 | fn is_operator(&self) -> bool { 320 | match *self { 321 | Token::Operator(_) => true, 322 | Token::Char(c) => c.is_operator(), 323 | _ => false, 324 | } 325 | } 326 | } 327 | 328 | impl PartialEq for Token<'_> { 329 | fn eq(&self, other: &ReservedChar) -> bool { 330 | match *self { 331 | Token::Char(c) => c == *other, 332 | _ => false, 333 | } 334 | } 335 | } 336 | 337 | fn get_comment<'a>( 338 | source: &'a str, 339 | iterator: &mut Peekable>, 340 | start_pos: &mut usize, 341 | ) -> Option> { 342 | let mut prev = ReservedChar::Quote; 343 | // eat the forward slash 344 | let mut content_start_pos = *start_pos + 1; 345 | let builder = if let Some((_, c)) = iterator.next() { 346 | if c == '!' || (c == '*' && iterator.peek().map(|(_, c)| c) != Some(&'/')) { 347 | content_start_pos += 1; 348 | Token::License 349 | } else { 350 | if let Ok(c) = ReservedChar::try_from(c) { 351 | prev = c; 352 | } 353 | Token::Comment 354 | } 355 | } else { 356 | Token::Comment 357 | }; 358 | 359 | for (pos, c) in iterator { 360 | if let Ok(c) = ReservedChar::try_from(c) { 361 | if c == ReservedChar::Slash && prev == ReservedChar::Star { 362 | let ret = Some(builder(&source[content_start_pos..pos - 1])); 363 | *start_pos = pos; 364 | return ret; 365 | } 366 | prev = c; 367 | } else { 368 | prev = ReservedChar::Space; 369 | } 370 | } 371 | None 372 | } 373 | 374 | fn get_string<'a>( 375 | source: &'a str, 376 | iterator: &mut Peekable>, 377 | start_pos: &mut usize, 378 | start: ReservedChar, 379 | ) -> Option> { 380 | while let Some((pos, c)) = iterator.next() { 381 | if c == '\\' { 382 | // we skip next character 383 | iterator.next(); 384 | continue; 385 | } 386 | if let Ok(c) = ReservedChar::try_from(c) { 387 | if c == start { 388 | let ret = Some(Token::String(&source[*start_pos..pos + 1])); 389 | *start_pos = pos; 390 | return ret; 391 | } 392 | } 393 | } 394 | None 395 | } 396 | 397 | fn fill_other<'a>( 398 | source: &'a str, 399 | v: &mut Vec>, 400 | start: usize, 401 | pos: usize, 402 | is_in_block: isize, 403 | is_in_media: bool, 404 | is_in_attribute_selector: bool, 405 | ) { 406 | if start < pos { 407 | if !is_in_attribute_selector 408 | && ((is_in_block == 0 && !is_in_media) || (is_in_media && is_in_block == 1)) 409 | { 410 | let mut is_pseudo_class = false; 411 | let mut add = 0; 412 | if let Some(&Token::Char(ReservedChar::Colon)) = v.last() { 413 | is_pseudo_class = true; 414 | add = 1; 415 | } 416 | if let Ok(s) = SelectorElement::try_from(&source[start - add..pos]) { 417 | if is_pseudo_class { 418 | v.pop(); 419 | } 420 | v.push(Token::SelectorElement(s)); 421 | } else { 422 | let s = &source[start..pos]; 423 | v.push(Token::Other(s)); 424 | } 425 | } else { 426 | v.push(Token::Other(&source[start..pos])); 427 | } 428 | } 429 | } 430 | 431 | #[allow(clippy::comparison_chain)] 432 | pub(super) fn tokenize(source: &str) -> Result, &'static str> { 433 | let mut v = Vec::with_capacity(1000); 434 | let mut iterator = source.char_indices().peekable(); 435 | let mut start = 0; 436 | let mut is_in_block: isize = 0; 437 | let mut is_in_media = false; 438 | let mut is_in_attribute_selector = false; 439 | 440 | loop { 441 | let (mut pos, c) = match iterator.next() { 442 | Some(x) => x, 443 | None => { 444 | fill_other( 445 | source, 446 | &mut v, 447 | start, 448 | source.len(), 449 | is_in_block, 450 | is_in_media, 451 | is_in_attribute_selector, 452 | ); 453 | break; 454 | } 455 | }; 456 | if let Ok(c) = ReservedChar::try_from(c) { 457 | fill_other( 458 | source, 459 | &mut v, 460 | start, 461 | pos, 462 | is_in_block, 463 | is_in_media, 464 | is_in_attribute_selector, 465 | ); 466 | is_in_media = is_in_media 467 | || v.last() 468 | .unwrap_or(&Token::Char(ReservedChar::Space)) 469 | .is_a_media(); 470 | 471 | match c { 472 | ReservedChar::Backslash => { 473 | v.push(Token::Char(ReservedChar::Backslash)); 474 | 475 | if let Some((idx, c)) = iterator.next() { 476 | pos += c.len_utf8(); 477 | v.push(Token::Other(&source[idx..idx + c.len_utf8()])); 478 | } 479 | } 480 | ReservedChar::Quote | ReservedChar::DoubleQuote => { 481 | if let Some(s) = get_string(source, &mut iterator, &mut pos, c) { 482 | v.push(s); 483 | } else { 484 | return Err("Unclosed string"); 485 | } 486 | } 487 | ReservedChar::Slash if matches!(iterator.peek(), Some(&(_, '*'))) => { 488 | // This is a comment. 489 | let _ = iterator.next(); 490 | pos += 1; 491 | if let Some(s) = get_comment(source, &mut iterator, &mut pos) { 492 | v.push(s); 493 | } else { 494 | return Err("Unclosed comment"); 495 | } 496 | } 497 | ReservedChar::OpenBracket => { 498 | if is_in_attribute_selector { 499 | return Err("Already in attribute selector"); 500 | } 501 | is_in_attribute_selector = true; 502 | v.push(Token::Char(c)); 503 | } 504 | ReservedChar::CloseBracket => { 505 | if !is_in_attribute_selector { 506 | return Err("Unexpected ']'"); 507 | } 508 | is_in_attribute_selector = false; 509 | v.push(Token::Char(c)); 510 | } 511 | ReservedChar::OpenCurlyBrace => { 512 | is_in_block += 1; 513 | v.push(Token::Char(c)); 514 | } 515 | ReservedChar::CloseCurlyBrace => { 516 | is_in_block -= 1; 517 | if is_in_block < 0 { 518 | return Err("Too much '}'"); 519 | } else if is_in_block == 0 { 520 | is_in_media = false; 521 | } 522 | v.push(Token::Char(c)); 523 | } 524 | ReservedChar::SemiColon if is_in_block == 0 => { 525 | is_in_media = false; 526 | v.push(Token::Char(c)); 527 | } 528 | ReservedChar::EqualSign => { 529 | match match v 530 | .last() 531 | .unwrap_or(&Token::Char(ReservedChar::Space)) 532 | .get_char() 533 | .unwrap_or(ReservedChar::Space) 534 | { 535 | ReservedChar::Tilde => Some(SelectorOperator::OneAttributeEquals), 536 | ReservedChar::Pipe => { 537 | Some(SelectorOperator::EqualsOrStartsWithFollowedByDash) 538 | } 539 | ReservedChar::Dollar => Some(SelectorOperator::EndsWith), 540 | ReservedChar::Circumflex => Some(SelectorOperator::FirstStartsWith), 541 | ReservedChar::Star => Some(SelectorOperator::Contains), 542 | _ => None, 543 | } { 544 | Some(r) => { 545 | v.pop(); 546 | v.push(Token::SelectorOperator(r)); 547 | } 548 | None => v.push(Token::Char(c)), 549 | } 550 | } 551 | c if !c.is_useless() => { 552 | v.push(Token::Char(c)); 553 | } 554 | c => { 555 | if match v.last() { 556 | Some(c) => { 557 | !c.is_useless() 558 | && (!c.is_char() 559 | || c.is_operator() 560 | || matches!( 561 | c.get_char(), 562 | Some( 563 | ReservedChar::CloseParenthese 564 | | ReservedChar::CloseBracket 565 | ) 566 | )) 567 | } 568 | _ => false, 569 | } { 570 | v.push(Token::Char(ReservedChar::Space)); 571 | } else if let Ok(op) = Operator::try_from(c) { 572 | v.push(Token::Operator(op)); 573 | } 574 | } 575 | } 576 | start = pos + 1; 577 | } 578 | } 579 | Ok(Tokens(clean_tokens(v))) 580 | } 581 | 582 | fn clean_tokens(mut v: Vec>) -> Vec> { 583 | // This function may remove multiple elements from the vector. Ideally we'd 584 | // use `Vec::retain`, but the traversal requires inspecting the previously 585 | // retained token and the next token, which `Vec::retain` doesn't allow. So 586 | // we have to use a lower-level mechanism. 587 | let mut i = 0; 588 | // Index of the previous retained token, if there is one. 589 | let mut previous_element_index: Option = None; 590 | let mut is_in_calc = false; 591 | let mut paren = 0; 592 | // A vector of bools indicating which elements are to be retained. 593 | let mut b = Vec::with_capacity(v.len()); 594 | 595 | while i < v.len() { 596 | if v[i] == Token::Other("calc") { 597 | is_in_calc = true; 598 | } else if is_in_calc { 599 | if v[i] == Token::Char(ReservedChar::CloseParenthese) { 600 | paren -= 1; 601 | is_in_calc = paren != 0; 602 | } else if v[i] == Token::Char(ReservedChar::OpenParenthese) { 603 | paren += 1; 604 | } 605 | } 606 | 607 | let mut retain = true; 608 | if v[i].is_useless() || v[i].is_comment() { 609 | if let Some(previous_element_index) = previous_element_index { 610 | #[allow(clippy::if_same_then_else)] 611 | if v[previous_element_index] == Token::Char(ReservedChar::CloseBracket) { 612 | if i + 1 < v.len() 613 | && (v[i + 1].is_useless() 614 | || v[i + 1] == Token::Char(ReservedChar::OpenCurlyBrace)) 615 | { 616 | retain = false; 617 | } 618 | } else if matches!(v[previous_element_index], Token::Other(_)) 619 | && matches!( 620 | v.get(i + 1), 621 | Some(Token::Other(_) | Token::Char(ReservedChar::OpenParenthese)) 622 | ) 623 | { 624 | // retain the space between keywords 625 | // and the space that disambiguates functions from keyword-plus-parens 626 | } else if v[previous_element_index].is_a_selector_element() 627 | && matches!(v.get(i + 1), Some(Token::Char(ReservedChar::Star))) 628 | { 629 | // retain the space before `*` if it's preceded by a selector. 630 | } else if matches!(v[previous_element_index], Token::Char(ReservedChar::Star)) 631 | && v.get(i + 1) 632 | .is_some_and(|elem| elem.is_a_selector_element()) 633 | { 634 | // retain the space after `*` if it's followed by a selector. 635 | } else if matches!( 636 | v[previous_element_index], 637 | Token::Char( 638 | ReservedChar::Star 639 | | ReservedChar::Circumflex 640 | | ReservedChar::Dollar 641 | | ReservedChar::Tilde 642 | ) 643 | ) && matches!(v.get(i + 1), Some(Token::Char(ReservedChar::EqualSign))) 644 | { 645 | // retain the space between an operator and an equal sign 646 | } else if matches!(v[previous_element_index], Token::Char(ReservedChar::Slash)) 647 | && matches!(v.get(i + 1), Some(Token::Char(ReservedChar::Star))) 648 | { 649 | // this looks like a comment, but it is not 650 | // retain the space between `/` and `*` 651 | } else if is_in_calc && v[previous_element_index].is_useless() { 652 | retain = false; 653 | } else if !is_in_calc { 654 | let prev = &v[previous_element_index]; 655 | if ((prev.is_char() && prev != &Token::Char(ReservedChar::CloseParenthese)) 656 | || prev.is_a_media() 657 | || prev.is_a_license()) 658 | || (i < v.len() - 1 659 | && v[i + 1].is_char() 660 | && v[i + 1] != Token::Char(ReservedChar::OpenBracket)) 661 | { 662 | retain = false; 663 | } 664 | } 665 | } 666 | if retain && v[i].is_comment() { 667 | // convert comments to spaces when minifying 668 | v[i] = Token::Char(ReservedChar::Space); 669 | } 670 | } 671 | if retain { 672 | previous_element_index = Some(i); 673 | } 674 | b.push(retain); 675 | i += 1; 676 | } 677 | assert_eq!(v.len(), b.len()); 678 | let mut b = b.into_iter(); 679 | v.retain(|_| b.next().unwrap()); 680 | v 681 | } 682 | 683 | #[derive(Debug, PartialEq, Eq, Clone)] 684 | pub(super) struct Tokens<'a>(Vec>); 685 | 686 | impl Tokens<'_> { 687 | pub(super) fn write(self, mut w: W) -> std::io::Result<()> { 688 | for token in self.0.iter() { 689 | write!(w, "{}", token)?; 690 | } 691 | Ok(()) 692 | } 693 | } 694 | 695 | impl fmt::Display for Tokens<'_> { 696 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 697 | for token in self.0.iter() { 698 | write!(f, "{}", token)?; 699 | } 700 | Ok(()) 701 | } 702 | } 703 | 704 | #[test] 705 | fn css_basic() { 706 | let s = r#" 707 | /*! just some license */ 708 | .foo > #bar p:hover { 709 | color: blue; 710 | background: "blue"; 711 | } 712 | 713 | /* a comment! */ 714 | @media screen and (max-width: 640px) { 715 | .block:hover { 716 | display: block; 717 | } 718 | }"#; 719 | let expected = vec![ 720 | Token::License(" just some license "), 721 | Token::SelectorElement(SelectorElement::Class("foo")), 722 | Token::Char(ReservedChar::SuperiorThan), 723 | Token::SelectorElement(SelectorElement::Id("bar")), 724 | Token::Char(ReservedChar::Space), 725 | Token::SelectorElement(SelectorElement::Tag("p")), 726 | Token::SelectorElement(SelectorElement::PseudoClass("hover")), 727 | Token::Char(ReservedChar::OpenCurlyBrace), 728 | Token::Other("color"), 729 | Token::Char(ReservedChar::Colon), 730 | Token::Other("blue"), 731 | Token::Char(ReservedChar::SemiColon), 732 | Token::Other("background"), 733 | Token::Char(ReservedChar::Colon), 734 | Token::String("\"blue\""), 735 | Token::Char(ReservedChar::SemiColon), 736 | Token::Char(ReservedChar::CloseCurlyBrace), 737 | Token::SelectorElement(SelectorElement::Media("media")), 738 | Token::Other("screen"), 739 | Token::Char(ReservedChar::Space), 740 | Token::Other("and"), 741 | Token::Char(ReservedChar::Space), 742 | Token::Char(ReservedChar::OpenParenthese), 743 | Token::Other("max-width"), 744 | Token::Char(ReservedChar::Colon), 745 | Token::Other("640px"), 746 | Token::Char(ReservedChar::CloseParenthese), 747 | Token::Char(ReservedChar::OpenCurlyBrace), 748 | Token::SelectorElement(SelectorElement::Class("block")), 749 | Token::SelectorElement(SelectorElement::PseudoClass("hover")), 750 | Token::Char(ReservedChar::OpenCurlyBrace), 751 | Token::Other("display"), 752 | Token::Char(ReservedChar::Colon), 753 | Token::Other("block"), 754 | Token::Char(ReservedChar::SemiColon), 755 | Token::Char(ReservedChar::CloseCurlyBrace), 756 | Token::Char(ReservedChar::CloseCurlyBrace), 757 | ]; 758 | assert_eq!(tokenize(s), Ok(Tokens(expected))); 759 | } 760 | 761 | #[test] 762 | fn elem_selector() { 763 | let s = r#" 764 | /** just some license */ 765 | a[href*="example"] { 766 | background: yellow; 767 | } 768 | a[href$=".org"] { 769 | font-style: italic; 770 | } 771 | span[lang|="zh"] { 772 | color: red; 773 | } 774 | a[href^="/"] { 775 | background-color: gold; 776 | } 777 | div[value~="test"] { 778 | border-width: 1px; 779 | } 780 | span[lang="pt"] { 781 | font-size: 12em; /* I love big fonts */ 782 | } 783 | "#; 784 | let expected = vec![ 785 | Token::License(" just some license "), 786 | Token::SelectorElement(SelectorElement::Tag("a")), 787 | Token::Char(ReservedChar::OpenBracket), 788 | Token::Other("href"), 789 | Token::SelectorOperator(SelectorOperator::Contains), 790 | Token::String("\"example\""), 791 | Token::Char(ReservedChar::CloseBracket), 792 | Token::Char(ReservedChar::OpenCurlyBrace), 793 | Token::Other("background"), 794 | Token::Char(ReservedChar::Colon), 795 | Token::Other("yellow"), 796 | Token::Char(ReservedChar::SemiColon), 797 | Token::Char(ReservedChar::CloseCurlyBrace), 798 | Token::SelectorElement(SelectorElement::Tag("a")), 799 | Token::Char(ReservedChar::OpenBracket), 800 | Token::Other("href"), 801 | Token::SelectorOperator(SelectorOperator::EndsWith), 802 | Token::String("\".org\""), 803 | Token::Char(ReservedChar::CloseBracket), 804 | Token::Char(ReservedChar::OpenCurlyBrace), 805 | Token::Other("font-style"), 806 | Token::Char(ReservedChar::Colon), 807 | Token::Other("italic"), 808 | Token::Char(ReservedChar::SemiColon), 809 | Token::Char(ReservedChar::CloseCurlyBrace), 810 | Token::SelectorElement(SelectorElement::Tag("span")), 811 | Token::Char(ReservedChar::OpenBracket), 812 | Token::Other("lang"), 813 | Token::SelectorOperator(SelectorOperator::EqualsOrStartsWithFollowedByDash), 814 | Token::String("\"zh\""), 815 | Token::Char(ReservedChar::CloseBracket), 816 | Token::Char(ReservedChar::OpenCurlyBrace), 817 | Token::Other("color"), 818 | Token::Char(ReservedChar::Colon), 819 | Token::Other("red"), 820 | Token::Char(ReservedChar::SemiColon), 821 | Token::Char(ReservedChar::CloseCurlyBrace), 822 | Token::SelectorElement(SelectorElement::Tag("a")), 823 | Token::Char(ReservedChar::OpenBracket), 824 | Token::Other("href"), 825 | Token::SelectorOperator(SelectorOperator::FirstStartsWith), 826 | Token::String("\"/\""), 827 | Token::Char(ReservedChar::CloseBracket), 828 | Token::Char(ReservedChar::OpenCurlyBrace), 829 | Token::Other("background-color"), 830 | Token::Char(ReservedChar::Colon), 831 | Token::Other("gold"), 832 | Token::Char(ReservedChar::SemiColon), 833 | Token::Char(ReservedChar::CloseCurlyBrace), 834 | Token::SelectorElement(SelectorElement::Tag("div")), 835 | Token::Char(ReservedChar::OpenBracket), 836 | Token::Other("value"), 837 | Token::SelectorOperator(SelectorOperator::OneAttributeEquals), 838 | Token::String("\"test\""), 839 | Token::Char(ReservedChar::CloseBracket), 840 | Token::Char(ReservedChar::OpenCurlyBrace), 841 | Token::Other("border-width"), 842 | Token::Char(ReservedChar::Colon), 843 | Token::Other("1px"), 844 | Token::Char(ReservedChar::SemiColon), 845 | Token::Char(ReservedChar::CloseCurlyBrace), 846 | Token::SelectorElement(SelectorElement::Tag("span")), 847 | Token::Char(ReservedChar::OpenBracket), 848 | Token::Other("lang"), 849 | Token::Char(ReservedChar::EqualSign), 850 | Token::String("\"pt\""), 851 | Token::Char(ReservedChar::CloseBracket), 852 | Token::Char(ReservedChar::OpenCurlyBrace), 853 | Token::Other("font-size"), 854 | Token::Char(ReservedChar::Colon), 855 | Token::Other("12em"), 856 | Token::Char(ReservedChar::SemiColon), 857 | Token::Char(ReservedChar::CloseCurlyBrace), 858 | ]; 859 | assert_eq!(tokenize(s), Ok(Tokens(expected))); 860 | } 861 | 862 | #[test] 863 | fn check_media() { 864 | let s = "@media (max-width: 700px) { color: red; }"; 865 | 866 | let expected = vec![ 867 | Token::SelectorElement(SelectorElement::Media("media")), 868 | Token::Char(ReservedChar::OpenParenthese), 869 | Token::Other("max-width"), 870 | Token::Char(ReservedChar::Colon), 871 | Token::Other("700px"), 872 | Token::Char(ReservedChar::CloseParenthese), 873 | Token::Char(ReservedChar::OpenCurlyBrace), 874 | Token::SelectorElement(SelectorElement::Tag("color")), 875 | Token::Char(ReservedChar::Colon), 876 | Token::Other("red"), 877 | Token::Char(ReservedChar::SemiColon), 878 | Token::Char(ReservedChar::CloseCurlyBrace), 879 | ]; 880 | 881 | assert_eq!(tokenize(s), Ok(Tokens(expected))); 882 | } 883 | 884 | #[test] 885 | fn check_supports() { 886 | let s = "@supports not (display: grid) { div { float: right; } }"; 887 | 888 | let expected = vec![ 889 | Token::SelectorElement(SelectorElement::Media("supports")), 890 | Token::Other("not"), 891 | Token::Char(ReservedChar::Space), 892 | Token::Char(ReservedChar::OpenParenthese), 893 | Token::Other("display"), 894 | Token::Char(ReservedChar::Colon), 895 | Token::Other("grid"), 896 | Token::Char(ReservedChar::CloseParenthese), 897 | Token::Char(ReservedChar::OpenCurlyBrace), 898 | Token::SelectorElement(SelectorElement::Tag("div")), 899 | Token::Char(ReservedChar::OpenCurlyBrace), 900 | Token::Other("float"), 901 | Token::Char(ReservedChar::Colon), 902 | Token::Other("right"), 903 | Token::Char(ReservedChar::SemiColon), 904 | Token::Char(ReservedChar::CloseCurlyBrace), 905 | Token::Char(ReservedChar::CloseCurlyBrace), 906 | ]; 907 | 908 | assert_eq!(tokenize(s), Ok(Tokens(expected))); 909 | } 910 | 911 | #[test] 912 | fn check_calc() { 913 | let s = ".foo { width: calc(100% - 34px); }"; 914 | 915 | let expected = vec![ 916 | Token::SelectorElement(SelectorElement::Class("foo")), 917 | Token::Char(ReservedChar::OpenCurlyBrace), 918 | Token::Other("width"), 919 | Token::Char(ReservedChar::Colon), 920 | Token::Other("calc"), 921 | Token::Char(ReservedChar::OpenParenthese), 922 | Token::Other("100%"), 923 | Token::Char(ReservedChar::Space), 924 | Token::Other("-"), 925 | Token::Char(ReservedChar::Space), 926 | Token::Other("34px"), 927 | Token::Char(ReservedChar::CloseParenthese), 928 | Token::Char(ReservedChar::SemiColon), 929 | Token::Char(ReservedChar::CloseCurlyBrace), 930 | ]; 931 | assert_eq!(tokenize(s), Ok(Tokens(expected))); 932 | } 933 | 934 | #[test] 935 | fn check_attr_ast() { 936 | let s = "x [y]"; 937 | 938 | let expected = vec![ 939 | Token::SelectorElement(SelectorElement::Tag("x")), 940 | Token::Char(ReservedChar::Space), 941 | Token::Char(ReservedChar::OpenBracket), 942 | Token::Other("y"), 943 | Token::Char(ReservedChar::CloseBracket), 944 | ]; 945 | assert_eq!(tokenize(s), Ok(Tokens(expected))); 946 | } 947 | -------------------------------------------------------------------------------- /src/html.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use regex::{Captures, Regex}; 4 | 5 | fn condense_whitespace(source: &str) -> String { 6 | let lower_source = source.to_lowercase(); 7 | if lower_source.find("\s+<").unwrap(); 10 | let source = re.replace_all(source, "> <").into_owned(); 11 | let re = Regex::new(r"\s{2,}|[\r\n]").unwrap(); 12 | re.replace_all(&source, " ").into_owned() 13 | } else { 14 | source.trim().to_owned() 15 | } 16 | } 17 | 18 | fn condense(source: &str) -> String { 19 | let re = Regex::new(r"<(style|script)[\w|\s].*?>").unwrap(); 20 | let type_reg = Regex::new(r#"\s*?type="[\w|\s].*?""#).unwrap(); 21 | re.replace_all(source, |caps: &Captures| { 22 | type_reg.replace_all(&caps[0], "").into_owned() 23 | }) 24 | .into_owned() 25 | } 26 | 27 | fn clean_unneeded_tags(source: &str) -> String { 28 | let useless_tags = [ 29 | "", 30 | "", 31 | "", 32 | "", 33 | "
", 34 | "", 35 | "", 36 | "", 37 | "", 38 | "", 39 | "", 40 | "", 41 | "", 42 | "", 43 | "", 44 | "", 45 | "", 46 | "", 47 | "", 48 | "", 49 | "", 50 | "", 51 | "", 52 | "", 53 | "", 54 | "", 55 | "", 56 | "", 57 | "", 58 | "", 59 | "", 60 | ]; 61 | let mut res = source.to_owned(); 62 | for useless_tag in &useless_tags { 63 | res = res.replace(useless_tag, ""); 64 | } 65 | res 66 | } 67 | 68 | fn remove_comments(source: &str) -> String { 69 | // "build" and "endbuild" should be matched case insensitively. 70 | let re = Regex::new("").unwrap(); 71 | re.replace_all(source, |caps: &Captures| { 72 | if caps[0].replace(" 178 | 179 |
180 | 184 |
185 |

A little sub title

186 |
    187 |
  • A list!
  • 188 |
  • Who doesn't like lists?
  • 189 |
  • Well, who cares...
  • 190 |
191 |
192 |
193 | 196 |
Narnia \ 205 |

Big header

\ 206 |

A little sub \ 208 | title

  • A list!
  • Who doesn't like lists? \ 209 |
  • Well, who cares...
\ 210 |