├── .gitignore ├── fuzz ├── .gitignore ├── Cargo.toml └── fuzz_targets │ └── cssparser.rs ├── tests ├── files │ └── test.json └── js_minify.rs ├── .github ├── FUNDING.yml └── workflows │ └── CI.yml ├── src ├── main.rs ├── lib.rs ├── js │ ├── mod.rs │ ├── utils.rs │ ├── tools.rs │ └── token.rs ├── css │ ├── mod.rs │ ├── tests.rs │ └── token.rs ├── json │ ├── read │ │ ├── internal_buffer.rs │ │ ├── internal_reader.rs │ │ ├── json_read.rs │ │ └── byte_to_char.rs │ ├── json_minifier.rs │ ├── string.rs │ └── mod.rs ├── cli.rs └── html.rs ├── README.md ├── Cargo.toml └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | target 3 | -------------------------------------------------------------------------------- /fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | corpus 3 | artifacts 4 | coverage 5 | -------------------------------------------------------------------------------- /tests/files/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": "\" test2", 3 | "test2": "", 4 | "test3": " " 5 | } -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [GuillaumeGomez] 4 | patreon: GuillaumeGomez 5 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | mod cli; 4 | 5 | fn main() { 6 | cli::Cli::init(); 7 | } 8 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | #[cfg(feature = "html")] 4 | extern crate regex; 5 | 6 | pub mod css; 7 | #[cfg(feature = "html")] 8 | pub mod html; 9 | pub mod js; 10 | pub mod json; 11 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/js/mod.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | mod token; 4 | mod tools; 5 | mod utils; 6 | 7 | pub use self::token::{tokenize, Condition, Keyword, Operation, ReservedChar, Token, Tokens}; 8 | pub use self::tools::{ 9 | aggregate_strings, aggregate_strings_into_array, aggregate_strings_into_array_filter, 10 | aggregate_strings_into_array_with_separation, 11 | aggregate_strings_into_array_with_separation_filter, aggregate_strings_with_separation, minify, 12 | simple_minify, Minified, 13 | }; 14 | pub use self::utils::{ 15 | clean_token, clean_token_except, clean_tokens, clean_tokens_except, 16 | get_variable_name_and_value_positions, replace_token_with, replace_tokens_with, 17 | }; 18 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "minifier" 3 | version = "0.3.6" 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /src/json/read/internal_buffer.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | const ARRAY_DEFAULT: u8 = 0; 4 | 5 | #[derive(Debug)] 6 | pub struct Buffer { 7 | buffer: Vec, 8 | read_pos: usize, 9 | buffer_size: usize, 10 | data_size: usize, 11 | } 12 | 13 | impl Buffer { 14 | pub fn new(size: usize) -> Buffer { 15 | Buffer { 16 | buffer: vec![ARRAY_DEFAULT; size], 17 | read_pos: 0, 18 | buffer_size: size, 19 | data_size: 0, 20 | } 21 | } 22 | 23 | pub fn as_mut(&mut self) -> &mut [u8] { 24 | self.buffer.as_mut() 25 | } 26 | 27 | pub fn update_metadata(&mut self, size: usize) { 28 | self.read_pos = 0; 29 | self.data_size = size; 30 | } 31 | 32 | pub fn next(&mut self) -> Option { 33 | if self.read_pos >= self.data_size { 34 | return None; 35 | } 36 | let item = self.buffer.get(self.read_pos); 37 | self.read_pos += 1; 38 | item.copied() 39 | } 40 | 41 | pub fn cont(&self) -> bool { 42 | self.data_size == self.buffer_size 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/json/json_minifier.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | #[derive(Debug, Clone)] 4 | pub struct JsonMinifier { 5 | pub is_string: bool, 6 | pub escaped_quotation: u8, 7 | } 8 | 9 | impl Default for JsonMinifier { 10 | fn default() -> Self { 11 | Self::new() 12 | } 13 | } 14 | 15 | impl JsonMinifier { 16 | pub fn new() -> Self { 17 | JsonMinifier { 18 | is_string: false, 19 | escaped_quotation: 0, 20 | } 21 | } 22 | } 23 | 24 | #[inline] 25 | pub fn keep_element(minifier: &mut JsonMinifier, item1: &char, item2: Option<&char>) -> bool { 26 | let remove_element = 27 | item1.is_ascii_control() || is_whitespace_outside_string(minifier, item1, item2); 28 | !remove_element 29 | } 30 | 31 | #[inline] 32 | fn is_whitespace_outside_string( 33 | minifier: &mut JsonMinifier, 34 | item1: &char, 35 | item2: Option<&char>, 36 | ) -> bool { 37 | if !minifier.is_string && item1.eq(&'"') { 38 | minifier.is_string = true; 39 | } else if minifier.is_string { 40 | if item1.eq(&'\\') && item2.eq(&Some(&'"')) { 41 | minifier.escaped_quotation = 4; 42 | } 43 | if minifier.escaped_quotation > 0 { 44 | minifier.escaped_quotation -= 1; 45 | } else if item1.eq(&'"') { 46 | minifier.is_string = false; 47 | } 48 | } 49 | !minifier.is_string && item1.is_whitespace() 50 | } 51 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/json/read/internal_reader.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use super::internal_buffer::Buffer; 4 | use std::{ 5 | fmt, 6 | io::{Error, Read}, 7 | }; 8 | 9 | pub struct InternalReader { 10 | read: R, 11 | buffer_size: usize, 12 | buffer: Buffer, 13 | } 14 | 15 | impl InternalReader { 16 | pub fn new(mut read: R, buffer_size: usize) -> Result { 17 | let mut buffer = Buffer::new(buffer_size); 18 | InternalReader::read_data(&mut read, &mut buffer)?; 19 | Ok(InternalReader { 20 | read, 21 | buffer_size, 22 | buffer, 23 | }) 24 | } 25 | 26 | fn read_data(read: &mut R, buffer: &mut Buffer) -> Result<(), Error> { 27 | let size = read.read(buffer.as_mut())?; 28 | buffer.update_metadata(size); 29 | Ok(()) 30 | } 31 | } 32 | 33 | impl fmt::Debug for InternalReader { 34 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 35 | f.debug_struct("JsonReader") 36 | .field("read", &self.read) 37 | .field("buffer_size", &self.buffer_size) 38 | .field("buffer", &self.buffer) 39 | .finish() 40 | } 41 | } 42 | 43 | impl Iterator for InternalReader { 44 | type Item = Result; 45 | 46 | #[inline] 47 | fn next(&mut self) -> Option> { 48 | if self.buffer_size == 0 { 49 | return None; 50 | } 51 | loop { 52 | if let Some(item) = self.buffer.next() { 53 | return Some(Ok(item)); 54 | } else if self.buffer.cont() { 55 | if let Err(err) = InternalReader::read_data(&mut self.read, &mut self.buffer) { 56 | return Some(Err(err)); 57 | }; 58 | } else { 59 | return None; 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/js_minify.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | extern crate minifier; 4 | 5 | use std::ops::Range; 6 | 7 | fn get_ranges(pos: usize, s: &str) -> Range { 8 | let mut start = pos; 9 | let mut end = pos; 10 | if start >= 20 { 11 | start -= 20; 12 | } else { 13 | start = 0; 14 | } 15 | if end < s.len() - 20 { 16 | end += 20; 17 | } else { 18 | end = s.len(); 19 | } 20 | start..end 21 | } 22 | 23 | fn compare_strs(minified: &str, str2: &str) { 24 | let mut it1 = minified.char_indices(); 25 | let mut it2 = str2.char_indices(); 26 | 27 | loop { 28 | match (it1.next(), it2.next()) { 29 | (Some((pos, c1)), Some((_, c2))) => { 30 | if c1 != c2 { 31 | println!( 32 | "{}\n==== differs from: ====\n{}", 33 | &minified[get_ranges(pos, minified)], 34 | &str2[get_ranges(pos, str2)] 35 | ); 36 | panic!("Chars differ. Complete minified content:\n{minified}"); 37 | } 38 | } 39 | (None, Some((pos, _))) => { 40 | println!("missing: {}...", &str2[get_ranges(pos + 20, str2)]); 41 | panic!("Missing parts of minified content"); 42 | } 43 | (Some((pos, _)), None) => { 44 | println!("extra: {}...", &minified[get_ranges(pos + 20, minified)]); 45 | panic!("Extra part in minified content"); 46 | } 47 | (None, None) => { 48 | break; 49 | } 50 | } 51 | } 52 | } 53 | 54 | #[test] 55 | fn test_minification() { 56 | use minifier::js::minify; 57 | 58 | let source = include_str!("./files/main.js"); 59 | let expected_result = include_str!("./files/minified_main.js"); 60 | compare_strs(&minify(source).to_string(), expected_result); 61 | } 62 | -------------------------------------------------------------------------------- /src/json/string.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use crate::json::json_minifier::JsonMinifier; 4 | 5 | use std::fmt; 6 | use std::str::Chars; 7 | 8 | #[derive(Clone)] 9 | pub struct JsonMultiFilter<'a, P: Clone> { 10 | minifier: JsonMinifier, 11 | iter: Chars<'a>, 12 | predicate: P, 13 | initialized: bool, 14 | item1: Option< as Iterator>::Item>, 15 | } 16 | 17 | impl<'a, P: Clone> JsonMultiFilter<'a, P> { 18 | #[inline] 19 | pub fn new(iter: Chars<'a>, predicate: P) -> Self { 20 | JsonMultiFilter { 21 | minifier: JsonMinifier::default(), 22 | iter, 23 | predicate, 24 | initialized: false, 25 | item1: None, 26 | } 27 | } 28 | } 29 | 30 | impl fmt::Debug for JsonMultiFilter<'_, P> { 31 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 32 | f.debug_struct("Filter") 33 | .field("minifier", &self.minifier) 34 | .field("iter", &self.iter) 35 | .field("initialized", &self.initialized) 36 | .finish() 37 | } 38 | } 39 | 40 | impl<'a, P: Clone> Iterator for JsonMultiFilter<'a, P> 41 | where 42 | P: FnMut( 43 | &mut JsonMinifier, 44 | & as Iterator>::Item, 45 | Option<& as Iterator>::Item>, 46 | ) -> bool, 47 | { 48 | type Item = as Iterator>::Item; 49 | 50 | #[inline] 51 | fn next(&mut self) -> Option< as Iterator>::Item> { 52 | if !self.initialized { 53 | self.item1 = self.iter.next(); 54 | self.initialized = true; 55 | } 56 | 57 | while let Some(item) = self.item1.take() { 58 | self.item1 = self.iter.next(); 59 | if (self.predicate)(&mut self.minifier, &item, self.item1.as_ref()) { 60 | return Some(item); 61 | } 62 | } 63 | None 64 | } 65 | } 66 | 67 | impl<'a, P> JsonMultiFilter<'a, P> 68 | where 69 | P: FnMut( 70 | &mut JsonMinifier, 71 | & as Iterator>::Item, 72 | Option<& as Iterator>::Item>, 73 | ) -> bool 74 | + Clone, 75 | { 76 | pub(super) fn write(self, mut w: W) -> std::io::Result<()> { 77 | for token in self { 78 | write!(w, "{token}")?; 79 | } 80 | Ok(()) 81 | } 82 | } 83 | 84 | impl<'a, P> fmt::Display for JsonMultiFilter<'a, P> 85 | where 86 | P: FnMut( 87 | &mut JsonMinifier, 88 | & as Iterator>::Item, 89 | Option<& as Iterator>::Item>, 90 | ) -> bool 91 | + Clone, 92 | { 93 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 94 | let s = (*self).clone(); 95 | for token in s { 96 | write!(f, "{token}")?; 97 | } 98 | Ok(()) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /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/json/mod.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use crate::json::{ 4 | json_minifier::{keep_element, JsonMinifier}, 5 | read::json_read::JsonRead, 6 | string::JsonMultiFilter, 7 | }; 8 | use std::fmt; 9 | use std::io::{self, Read}; 10 | 11 | mod read { 12 | mod byte_to_char; 13 | mod internal_buffer; 14 | mod internal_reader; 15 | pub mod json_read; 16 | } 17 | 18 | mod json_minifier; 19 | mod string; 20 | 21 | type JsonMethod = fn(&mut JsonMinifier, &char, Option<&char>) -> bool; 22 | 23 | /// Minifies a given String by JSON minification rules 24 | /// 25 | /// # Example 26 | /// 27 | /// ```rust 28 | /// use minifier::json::minify; 29 | /// 30 | /// let json = r#" 31 | /// { 32 | /// "test": "test", 33 | /// "test2": 2 34 | /// } 35 | /// "#.into(); 36 | /// let json_minified = minify(json); 37 | /// assert_eq!(&json_minified.to_string(), r#"{"test":"test","test2":2}"#); 38 | /// ``` 39 | #[inline] 40 | pub fn minify(json: &str) -> Minified<'_> { 41 | Minified(JsonMultiFilter::new(json.chars(), keep_element)) 42 | } 43 | 44 | #[derive(Debug)] 45 | pub struct Minified<'a>(JsonMultiFilter<'a, JsonMethod>); 46 | 47 | impl Minified<'_> { 48 | pub fn write(self, w: W) -> io::Result<()> { 49 | self.0.write(w) 50 | } 51 | } 52 | 53 | impl fmt::Display for Minified<'_> { 54 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 55 | self.0.fmt(f) 56 | } 57 | } 58 | 59 | /// Minifies a given Read by JSON minification rules 60 | /// 61 | /// # Example 62 | /// 63 | /// ```rust 64 | /// extern crate minifier; 65 | /// use std::fs::File; 66 | /// use std::io::Read; 67 | /// use minifier::json::minify_from_read; 68 | /// 69 | /// fn main() { 70 | /// let mut html_minified = String::new(); 71 | /// let mut file = File::open("tests/files/test.json").expect("file not found"); 72 | /// minify_from_read(file).read_to_string(&mut html_minified); 73 | /// } 74 | /// ``` 75 | #[inline] 76 | pub fn minify_from_read(json: R) -> JsonRead { 77 | JsonRead::new(json, keep_element) 78 | } 79 | 80 | #[test] 81 | fn removal_from_read() { 82 | use std::fs::File; 83 | 84 | let input = File::open("tests/files/test.json").expect("file not found"); 85 | let expected: String = "{\"test\":\"\\\" test2\",\"test2\":\"\",\"test3\":\" \"}".into(); 86 | let mut actual = String::new(); 87 | minify_from_read(input) 88 | .read_to_string(&mut actual) 89 | .expect("error at read"); 90 | assert_eq!(actual, expected); 91 | } 92 | 93 | #[test] 94 | fn removal_of_control_characters() { 95 | let input = "\n"; 96 | let expected: String = "".into(); 97 | let actual = minify(input); 98 | assert_eq!(actual.to_string(), expected); 99 | } 100 | 101 | #[test] 102 | fn removal_of_whitespace_outside_of_tags() { 103 | let input = r#" 104 | { 105 | "test": "\" test2", 106 | "test2": "", 107 | "test3": " " 108 | } 109 | "#; 110 | let expected: String = "{\"test\":\"\\\" test2\",\"test2\":\"\",\"test3\":\" \"}".into(); 111 | let actual = minify(input); 112 | assert_eq!(actual.to_string(), expected); 113 | } 114 | -------------------------------------------------------------------------------- /src/json/read/json_read.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use crate::json::{ 4 | json_minifier::JsonMinifier, 5 | read::byte_to_char::{ByteToChar, CharsError}, 6 | }; 7 | use std::{ 8 | fmt, 9 | io::{Error, ErrorKind, Read}, 10 | vec::IntoIter, 11 | }; 12 | 13 | pub struct JsonRead { 14 | minifier: JsonMinifier, 15 | read: Option, 16 | iter: Option>, 17 | predicate: P, 18 | initialized: bool, 19 | item_iter: Option>, 20 | item1: Option, 21 | } 22 | 23 | impl JsonRead { 24 | #[inline] 25 | pub fn new(read: R, predicate: P) -> Self { 26 | JsonRead { 27 | minifier: JsonMinifier::default(), 28 | read: Some(read), 29 | iter: None, 30 | predicate, 31 | initialized: false, 32 | item_iter: None, 33 | item1: None, 34 | } 35 | } 36 | 37 | fn get_next(&mut self) -> Result, CharsError> { 38 | match self.iter.as_mut().unwrap().next() { 39 | None => Ok(None), 40 | Some(item) => match item { 41 | Ok(item) => Ok(Some(item)), 42 | Err(err) => Err(err), 43 | }, 44 | } 45 | } 46 | 47 | fn add_char_to_buffer(&mut self, buf: &mut [u8], buf_pos: &mut usize) { 48 | if let Some(ref mut iter) = self.item_iter { 49 | while *buf_pos < buf.len() { 50 | if let Some(byte) = iter.next() { 51 | buf[*buf_pos] = byte; 52 | *buf_pos += 1; 53 | } else { 54 | break; 55 | } 56 | } 57 | } 58 | } 59 | } 60 | 61 | impl fmt::Debug for JsonRead { 62 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 63 | f.debug_struct("Filter") 64 | .field("iter", &self.iter) 65 | .field("initialized", &self.initialized) 66 | .finish() 67 | } 68 | } 69 | 70 | impl Read for JsonRead 71 | where 72 | R: Read, 73 | P: FnMut(&mut JsonMinifier, &char, Option<&char>) -> bool, 74 | { 75 | fn read(&mut self, buf: &mut [u8]) -> Result { 76 | let mut buf_pos: usize = 0; 77 | 78 | if buf.is_empty() { 79 | return Ok(0); 80 | } 81 | 82 | if !self.initialized { 83 | self.iter = Some(ByteToChar::new(self.read.take().unwrap(), buf.len())?); 84 | self.item1 = self.get_next()?; 85 | self.initialized = true; 86 | } 87 | 88 | while let Some(item) = self.item1.take() { 89 | self.item1 = self.get_next()?; 90 | if (self.predicate)(&mut self.minifier, &item, self.item1.as_ref()) { 91 | self.item_iter = Some(item.to_string().into_bytes().into_iter()); 92 | self.add_char_to_buffer(buf, &mut buf_pos); 93 | } 94 | if buf_pos >= buf.len() { 95 | break; 96 | } 97 | } 98 | Ok(buf_pos) 99 | } 100 | } 101 | 102 | impl From for Error { 103 | fn from(_: CharsError) -> Self { 104 | Error::from(ErrorKind::InvalidData) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/json/read/byte_to_char.rs: -------------------------------------------------------------------------------- 1 | // Take a look at the license at the top of the repository in the LICENSE file. 2 | 3 | use crate::json::read::internal_reader::InternalReader; 4 | use std::{ 5 | error, fmt, 6 | io::{Error, Read}, 7 | str::from_utf8, 8 | }; 9 | 10 | pub struct ByteToChar { 11 | iter: InternalReader, 12 | } 13 | 14 | impl ByteToChar { 15 | #[inline] 16 | pub fn new(read: R, buffer_size: usize) -> Result { 17 | Ok(ByteToChar { 18 | iter: InternalReader::new(read, buffer_size)?, 19 | }) 20 | } 21 | 22 | fn get_next(&mut self) -> Result, CharsError> { 23 | match self.iter.next() { 24 | None => Ok(None), 25 | Some(item) => match item { 26 | Ok(item) => Ok(Some(item)), 27 | Err(err) => Err(CharsError::Other(err)), 28 | }, 29 | } 30 | } 31 | } 32 | 33 | impl fmt::Debug for ByteToChar { 34 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 35 | f.debug_struct("Filter").field("iter", &self.iter).finish() 36 | } 37 | } 38 | 39 | impl Iterator for ByteToChar { 40 | type Item = Result; 41 | 42 | fn next(&mut self) -> Option> { 43 | let first_byte = match self.get_next() { 44 | Err(err) => return Some(Err(err)), 45 | Ok(item) => item?, 46 | }; 47 | 48 | let width = utf8_char_width(first_byte); 49 | if width == 1 { 50 | return Some(Ok(first_byte as char)); 51 | } 52 | if width == 0 { 53 | return Some(Err(CharsError::NotUtf8)); 54 | } 55 | let mut buf = [first_byte, 0, 0, 0]; 56 | { 57 | let mut start = 1; 58 | while start < width { 59 | let byte = match self.get_next() { 60 | Err(err) => return Some(Err(err)), 61 | Ok(item) => match item { 62 | Some(item) => item, 63 | None => return Some(Err(CharsError::NotUtf8)), 64 | }, 65 | }; 66 | buf[start] = byte; 67 | start += 1; 68 | } 69 | } 70 | Some(match from_utf8(&buf[..width]).ok() { 71 | Some(s) => Ok(s.chars().next().unwrap()), 72 | None => Err(CharsError::NotUtf8), 73 | }) 74 | } 75 | } 76 | 77 | fn utf8_char_width(b: u8) -> usize { 78 | UTF8_CHAR_WIDTH[b as usize] as usize 79 | } 80 | 81 | // https://tools.ietf.org/html/rfc3629 82 | static UTF8_CHAR_WIDTH: [u8; 256] = [ 83 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 84 | 1, // 0x1F 85 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 86 | 1, // 0x3F 87 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 88 | 1, // 0x5F 89 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 90 | 1, // 0x7F 91 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 92 | 0, // 0x9F 93 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 94 | 0, // 0xBF 95 | 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 96 | 2, // 0xDF 97 | 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, // 0xEF 98 | 4, 4, 4, 4, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0xFF 99 | ]; 100 | 101 | /// An enumeration of possible errors that can be generated from the `Chars` 102 | /// adapter. 103 | #[derive(Debug)] 104 | pub enum CharsError { 105 | /// Variant representing that the underlying stream was read successfully 106 | /// but it did not contain valid utf8 data. 107 | NotUtf8, 108 | 109 | /// Variant representing that an I/O error occurred. 110 | Other(Error), 111 | } 112 | 113 | impl error::Error for CharsError { 114 | fn cause(&self) -> Option<&dyn error::Error> { 115 | match *self { 116 | CharsError::NotUtf8 => None, 117 | CharsError::Other(ref e) => e.source(), 118 | } 119 | } 120 | } 121 | 122 | impl fmt::Display for CharsError { 123 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 124 | match *self { 125 | CharsError::NotUtf8 => "byte stream did not contain valid utf8".fmt(f), 126 | CharsError::Other(ref e) => e.fmt(f), 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /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!("{file_path:?}: unknow file extension..."); 138 | return; 139 | }; 140 | match get_all_data(file_path) { 141 | Ok(content) => { 142 | let out = match out_path { 143 | Some(op) => { 144 | let mut op = op.clone(); 145 | if op.is_dir() { 146 | op.push(file_path); 147 | op.set_extension(format!("min.{}", file_ext.as_str())); 148 | }; 149 | if op.parent().is_some() && !op.parent().unwrap().is_dir() { 150 | std::fs::create_dir_all(op.parent().unwrap()).unwrap(); 151 | }; 152 | op 153 | } 154 | None => { 155 | let mut p = file_path.clone(); 156 | p.set_extension(format!("min.{}", file_ext.as_str())); 157 | p 158 | } 159 | }; 160 | if let Ok(mut file) = OpenOptions::new() 161 | .truncate(true) 162 | .write(true) 163 | .create(true) 164 | .open(&out) 165 | { 166 | let func = |s: &str| -> String { 167 | match file_ext { 168 | FileType::Css => { 169 | css::minify(s).expect("css minification failed").to_string() 170 | } 171 | FileType::Js => js::minify(s).to_string(), 172 | FileType::Json => json::minify(s).to_string(), 173 | FileType::Unknown => panic!("{file_path:?}: unknow file extension..."), 174 | } 175 | }; 176 | if let Err(e) = write!(file, "{}", func(&content)) { 177 | eprintln!("Impossible to write into {out:?}: {e}"); 178 | } else { 179 | println!("{file_path:?}: done -> generated into {out:?}"); 180 | } 181 | } else { 182 | eprintln!("Impossible to create new file: {out:?}"); 183 | } 184 | } 185 | Err(e) => eprintln!("{file_path:?}: {e}"), 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /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 |