rustyle_css/optimization/
minify.rs

1//! CSS minification utilities
2//!
3//! Provides CSS minification and tree shaking capabilities.
4
5use lightningcss::{
6    stylesheet::{ParserOptions, PrinterOptions, StyleSheet},
7    targets::Browsers,
8};
9
10/// Minify CSS using lightningcss
11pub fn minify_css(css: &str) -> Result<String, String> {
12    let stylesheet = StyleSheet::parse(
13        css,
14        ParserOptions {
15            filename: "style.css".to_string(),
16            ..Default::default()
17        },
18    )
19    .map_err(|e| format!("CSS parsing error: {:?}", e))?;
20
21    let targets = Browsers::default();
22    let printer_options = PrinterOptions {
23        minify: true,
24        targets: targets.into(),
25        ..Default::default()
26    };
27
28    let result = stylesheet
29        .to_css(printer_options)
30        .map_err(|e| format!("CSS generation error: {:?}", e))?;
31
32    Ok(result.code)
33}
34
35/// Remove unused CSS rules (tree shaking)
36///
37/// This function analyzes CSS and removes rules that don't match any of the
38/// provided used selectors. This is useful for eliminating dead CSS code.
39///
40/// # Arguments
41/// * `css` - The CSS stylesheet to analyze
42/// * `used_selectors` - A list of selectors that are actually used in the application
43///
44/// # Returns
45/// The CSS with unused rules removed
46pub fn remove_unused_css(css: &str, used_selectors: &[&str]) -> String {
47    if used_selectors.is_empty() {
48        // If no selectors provided, return empty (or minimal CSS)
49        return String::new();
50    }
51
52    let used_set: std::collections::HashSet<&str> = used_selectors.iter().copied().collect();
53
54    // Split CSS into rules
55    let rules = split_css_into_rules(css);
56
57    // Filter rules that match used selectors
58    let used_rules: Vec<String> = rules
59        .into_iter()
60        .filter(|rule| {
61            // Check if rule matches any used selector
62            let matches = rule_matches_any_selector(rule, &used_set);
63            // Also keep rules that contain common patterns
64            matches || rule.contains("body") || rule.contains("html") || rule.contains("*")
65        })
66        .collect();
67
68    // Always keep @rules (media queries, keyframes, etc.) as they may be needed
69    let at_rules = extract_at_rules(css);
70
71    // Combine used rules and @rules
72    let mut result = used_rules.join("\n\n");
73    if !at_rules.is_empty() {
74        if !result.is_empty() {
75            result.push_str("\n\n");
76        }
77        result.push_str(&at_rules.join("\n\n"));
78    }
79
80    result
81}
82
83/// Split CSS into individual rules
84fn split_css_into_rules(css: &str) -> Vec<String> {
85    let mut rules = Vec::new();
86    let mut current_rule = String::new();
87    let mut brace_depth = 0;
88    let mut in_string = false;
89    let mut string_char = '\0';
90
91    for ch in css.chars() {
92        match ch {
93            '{' if !in_string => {
94                brace_depth += 1;
95                current_rule.push(ch);
96            }
97            '}' if !in_string => {
98                current_rule.push(ch);
99                brace_depth -= 1;
100                if brace_depth == 0 {
101                    let rule = current_rule.trim().to_string();
102                    if !rule.is_empty() {
103                        rules.push(rule);
104                    }
105                    current_rule.clear();
106                }
107            }
108            '"' | '\'' if !in_string => {
109                in_string = true;
110                string_char = ch;
111                current_rule.push(ch);
112            }
113            _ if in_string && ch == string_char => {
114                in_string = false;
115                current_rule.push(ch);
116            }
117            _ => {
118                if brace_depth > 0 || !current_rule.trim().is_empty() {
119                    current_rule.push(ch);
120                }
121            }
122        }
123    }
124
125    if !current_rule.trim().is_empty() {
126        rules.push(current_rule.trim().to_string());
127    }
128
129    rules
130}
131
132/// Check if a CSS rule matches any of the used selectors
133fn rule_matches_any_selector(rule: &str, used_selectors: &std::collections::HashSet<&str>) -> bool {
134    // Extract selectors from the rule (before the first '{')
135    if let Some(selector_part) = rule.split('{').next() {
136        let selector_part = selector_part.trim();
137
138        // Split by comma to get individual selectors
139        for selector in selector_part.split(',') {
140            let selector = selector.trim();
141
142            // Check if this selector matches any used selector
143            for used_sel in used_selectors {
144                if selector_contains(selector, used_sel) {
145                    return true;
146                }
147            }
148
149            // Also check for partial matches (e.g., ".button" matches ".button-primary")
150            for used_sel in used_selectors {
151                if selector.contains(used_sel) || used_sel.contains(selector) {
152                    return true;
153                }
154            }
155        }
156    }
157
158    false
159}
160
161/// Check if a CSS selector contains a used selector
162fn selector_contains(selector: &str, used: &str) -> bool {
163    // Exact match
164    if selector == used {
165        return true;
166    }
167
168    // Class selector matching
169    if used.starts_with('.') && selector.contains(used) {
170        return true;
171    }
172
173    // ID selector matching
174    if used.starts_with('#') && selector.contains(used) {
175        return true;
176    }
177
178    // Element selector matching
179    if !used.starts_with('.') && !used.starts_with('#') {
180        // Check if used element appears in selector
181        if selector.split_whitespace().any(|part| part == used) {
182            return true;
183        }
184    }
185
186    false
187}
188
189/// Extract @rules (media queries, keyframes, etc.)
190fn extract_at_rules(css: &str) -> Vec<String> {
191    let mut at_rules = Vec::new();
192
193    // Match @rules with their content (handles nested braces)
194    let mut in_at_rule = false;
195    let mut current_rule = String::new();
196    let mut brace_depth = 0;
197
198    for line in css.lines() {
199        if line.trim().starts_with('@') && !in_at_rule {
200            in_at_rule = true;
201            current_rule = line.to_string();
202            brace_depth = line.matches('{').count() as i32 - line.matches('}').count() as i32;
203        } else if in_at_rule {
204            current_rule.push('\n');
205            current_rule.push_str(line);
206            brace_depth += line.matches('{').count() as i32 - line.matches('}').count() as i32;
207
208            if brace_depth == 0 {
209                at_rules.push(current_rule.trim().to_string());
210                current_rule.clear();
211                in_at_rule = false;
212            }
213        }
214    }
215
216    // If we have an incomplete @rule, add it anyway
217    if !current_rule.trim().is_empty() {
218        at_rules.push(current_rule.trim().to_string());
219    }
220
221    at_rules
222}