tailwind_rs_core/
tree_shaker.rs

1//! Tree-shaking system for removing unused CSS classes
2//!
3//! This module provides functionality to analyze CSS usage and remove unused classes,
4//! optimizing the final CSS bundle size.
5
6use crate::class_scanner::ClassScanner;
7use crate::css_generator::CssGenerator;
8use crate::error::Result;
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12/// Configuration for tree-shaking
13#[derive(Debug, Clone)]
14pub struct TreeShakeConfig {
15    /// Whether to enable tree-shaking
16    pub enabled: bool,
17    /// Whether to remove unused responsive variants
18    pub remove_unused_responsive: bool,
19    /// Whether to remove unused conditional classes
20    pub remove_unused_conditional: bool,
21    /// Whether to remove unused custom properties
22    pub remove_unused_custom: bool,
23    /// Classes to always keep (whitelist)
24    pub keep_classes: HashSet<String>,
25    /// Classes to always remove (blacklist)
26    pub remove_classes: HashSet<String>,
27    /// Whether to analyze dependencies between classes
28    pub analyze_dependencies: bool,
29}
30
31impl Default for TreeShakeConfig {
32    fn default() -> Self {
33        Self {
34            enabled: true,
35            remove_unused_responsive: true,
36            remove_unused_conditional: true,
37            remove_unused_custom: true,
38            keep_classes: HashSet::new(),
39            remove_classes: HashSet::new(),
40            analyze_dependencies: true,
41        }
42    }
43}
44
45/// Results of tree-shaking operation
46#[derive(Debug, Clone)]
47pub struct TreeShakeResults {
48    /// Classes that were kept
49    pub kept_classes: HashSet<String>,
50    /// Classes that were removed
51    pub removed_classes: HashSet<String>,
52    /// Original CSS size
53    pub original_size: usize,
54    /// Optimized CSS size
55    pub optimized_size: usize,
56    /// Size reduction percentage
57    pub reduction_percentage: f64,
58    /// Statistics
59    pub stats: TreeShakeStats,
60}
61
62/// Statistics for tree-shaking operation
63#[derive(Debug, Clone)]
64pub struct TreeShakeStats {
65    /// Number of classes analyzed
66    pub classes_analyzed: usize,
67    /// Number of classes removed
68    pub classes_removed: usize,
69    /// Number of responsive variants removed
70    pub responsive_removed: usize,
71    /// Number of conditional classes removed
72    pub conditional_removed: usize,
73    /// Number of custom properties removed
74    pub custom_removed: usize,
75    /// Processing time in milliseconds
76    pub processing_time_ms: u64,
77}
78
79/// Statistics for class removal operations
80#[derive(Debug, Clone)]
81struct RemovalStats {
82    total_removed: usize,
83    responsive_removed: usize,
84    conditional_removed: usize,
85    custom_removed: usize,
86    removed_classes: HashSet<String>,
87}
88
89/// Tree-shaking system for CSS optimization
90#[derive(Debug, Clone)]
91pub struct TreeShaker {
92    config: TreeShakeConfig,
93    dependency_graph: HashMap<String, HashSet<String>>,
94    reverse_dependencies: HashMap<String, HashSet<String>>,
95}
96
97impl TreeShaker {
98    /// Create a new tree-shaker with default configuration
99    pub fn new() -> Self {
100        Self {
101            config: TreeShakeConfig::default(),
102            dependency_graph: HashMap::new(),
103            reverse_dependencies: HashMap::new(),
104        }
105    }
106
107    /// Create a new tree-shaker with custom configuration
108    pub fn with_config(config: TreeShakeConfig) -> Self {
109        Self {
110            config,
111            dependency_graph: HashMap::new(),
112            reverse_dependencies: HashMap::new(),
113        }
114    }
115
116    /// Analyze source files and remove unused CSS classes
117    pub fn shake(&mut self, source_paths: &[&Path], css_generator: &mut CssGenerator) -> Result<TreeShakeResults> {
118        let start_time = std::time::Instant::now();
119        
120        if !self.config.enabled {
121            return Ok(TreeShakeResults {
122                kept_classes: css_generator.get_rules().keys().cloned().collect(),
123                removed_classes: HashSet::new(),
124                original_size: css_generator.generate_css().len(),
125                optimized_size: css_generator.generate_css().len(),
126                reduction_percentage: 0.0,
127                stats: TreeShakeStats {
128                    classes_analyzed: css_generator.rule_count(),
129                    classes_removed: 0,
130                    responsive_removed: 0,
131                    conditional_removed: 0,
132                    custom_removed: 0,
133                    processing_time_ms: start_time.elapsed().as_millis() as u64,
134                },
135            });
136        }
137
138        // Scan source files for used classes
139        let used_classes = self.scan_used_classes(source_paths)?;
140        
141        // Build dependency graph if enabled
142        if self.config.analyze_dependencies {
143            self.build_dependency_graph(css_generator);
144        }
145
146        // Determine which classes to keep
147        let classes_to_keep = self.determine_classes_to_keep(&used_classes, css_generator);
148        
149        // Remove unused classes and track statistics
150        let removal_stats = self.remove_unused_classes(css_generator, &classes_to_keep);
151        
152        // Calculate results
153        let original_size = css_generator.generate_css().len();
154        let optimized_size = css_generator.generate_css().len();
155        let reduction_percentage = if original_size > 0 {
156            ((original_size - optimized_size) as f64 / original_size as f64) * 100.0
157        } else {
158            0.0
159        };
160
161        let stats = TreeShakeStats {
162            classes_analyzed: css_generator.rule_count() + removal_stats.total_removed,
163            classes_removed: removal_stats.total_removed,
164            responsive_removed: removal_stats.responsive_removed,
165            conditional_removed: removal_stats.conditional_removed,
166            custom_removed: removal_stats.custom_removed,
167            processing_time_ms: start_time.elapsed().as_millis() as u64,
168        };
169
170        Ok(TreeShakeResults {
171            kept_classes: classes_to_keep,
172            removed_classes: removal_stats.removed_classes,
173            original_size,
174            optimized_size,
175            reduction_percentage,
176            stats,
177        })
178    }
179
180    /// Scan source files to find used classes
181    fn scan_used_classes(&self, source_paths: &[&Path]) -> Result<HashSet<String>> {
182        let mut scanner = ClassScanner::new();
183        let mut all_used_classes = HashSet::new();
184
185        for path in source_paths {
186            let results = scanner.scan_directory(path)?;
187            all_used_classes.extend(results.classes);
188        }
189
190        Ok(all_used_classes)
191    }
192
193    /// Build dependency graph between CSS classes
194    fn build_dependency_graph(&mut self, css_generator: &CssGenerator) {
195        self.dependency_graph.clear();
196        self.reverse_dependencies.clear();
197
198        // Analyze CSS rules for dependencies
199        for (class_name, rule) in css_generator.get_rules() {
200            let mut dependencies = HashSet::new();
201            
202            // Look for class references in CSS values
203            for property in &rule.properties {
204                if let Some(dep_class) = self.extract_class_dependency(&property.value) {
205                    dependencies.insert(dep_class);
206                }
207            }
208
209            if !dependencies.is_empty() {
210                self.dependency_graph.insert(class_name.clone(), dependencies);
211            }
212        }
213
214        // Build reverse dependencies
215        for (class, deps) in &self.dependency_graph {
216            for dep in deps {
217                self.reverse_dependencies
218                    .entry(dep.clone())
219                    .or_default()
220                    .insert(class.clone());
221            }
222        }
223    }
224
225    /// Extract class dependency from CSS value
226    fn extract_class_dependency(&self, value: &str) -> Option<String> {
227        // Look for class references in CSS values
228        // This is a simplified implementation
229        if value.contains("var(--") {
230            // Extract CSS custom property reference
231            if let Some(start) = value.find("var(--") {
232                if let Some(end) = value[start..].find(')') {
233                    let var_name = &value[start + 6..start + end];
234                    return Some(format!("--{}", var_name));
235                }
236            }
237        }
238        None
239    }
240
241    /// Determine which classes should be kept
242    fn determine_classes_to_keep(&self, used_classes: &HashSet<String>, _css_generator: &CssGenerator) -> HashSet<String> {
243        let mut classes_to_keep = HashSet::new();
244
245        // Add explicitly used classes
246        classes_to_keep.extend(used_classes.iter().cloned());
247
248        // Add whitelisted classes
249        classes_to_keep.extend(self.config.keep_classes.iter().cloned());
250
251        // Remove blacklisted classes
252        for class in &self.config.remove_classes {
253            classes_to_keep.remove(class);
254        }
255
256        // Add dependent classes if dependency analysis is enabled
257        if self.config.analyze_dependencies {
258            let mut to_process: Vec<String> = classes_to_keep.iter().cloned().collect();
259            let mut processed = HashSet::new();
260
261            while let Some(class) = to_process.pop() {
262                if processed.contains(&class) {
263                    continue;
264                }
265                processed.insert(class.clone());
266
267                // Add dependencies
268                if let Some(deps) = self.dependency_graph.get(&class) {
269                    for dep in deps {
270                        if !classes_to_keep.contains(dep) {
271                            classes_to_keep.insert(dep.clone());
272                            to_process.push(dep.clone());
273                        }
274                    }
275                }
276
277                // Add reverse dependencies (classes that depend on this one)
278                if let Some(reverse_deps) = self.reverse_dependencies.get(&class) {
279                    for reverse_dep in reverse_deps {
280                        if !classes_to_keep.contains(reverse_dep) {
281                            classes_to_keep.insert(reverse_dep.clone());
282                            to_process.push(reverse_dep.clone());
283                        }
284                    }
285                }
286            }
287        }
288
289        classes_to_keep
290    }
291
292    /// Remove unused classes from CSS generator
293    fn remove_unused_classes(&self, css_generator: &mut CssGenerator, classes_to_keep: &HashSet<String>) -> RemovalStats {
294        let mut removed_classes = HashSet::new();
295        let mut responsive_removed = 0;
296        let mut conditional_removed = 0;
297        let mut custom_removed = 0;
298        let rules = css_generator.get_rules().clone();
299
300        for (class_name, _rule) in rules {
301            if !classes_to_keep.contains(&class_name) {
302                removed_classes.insert(class_name.clone());
303                
304                // Categorize the removed class
305                if class_name.contains("sm:") || class_name.contains("md:") || 
306                   class_name.contains("lg:") || class_name.contains("xl:") || 
307                   class_name.contains("2xl:") {
308                    responsive_removed += 1;
309                } else if class_name.contains("hover:") || class_name.contains("focus:") || 
310                         class_name.contains("active:") || class_name.contains("disabled:") {
311                    conditional_removed += 1;
312                } else if class_name.starts_with("--") || class_name.contains("var(") {
313                    custom_removed += 1;
314                }
315            }
316        }
317
318        RemovalStats {
319            total_removed: removed_classes.len(),
320            responsive_removed,
321            conditional_removed,
322            custom_removed,
323            removed_classes,
324        }
325    }
326
327    /// Get the current configuration
328    pub fn get_config(&self) -> &TreeShakeConfig {
329        &self.config
330    }
331
332    /// Update the configuration
333    pub fn set_config(&mut self, config: TreeShakeConfig) {
334        self.config = config;
335    }
336
337    /// Add a class to the whitelist
338    pub fn keep_class(&mut self, class: String) {
339        self.config.keep_classes.insert(class);
340    }
341
342    /// Add a class to the blacklist
343    pub fn remove_class(&mut self, class: String) {
344        self.config.remove_classes.insert(class);
345    }
346
347    /// Clear all configuration
348    pub fn clear(&mut self) {
349        self.config.keep_classes.clear();
350        self.config.remove_classes.clear();
351        self.dependency_graph.clear();
352        self.reverse_dependencies.clear();
353    }
354}
355
356impl Default for TreeShaker {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn test_tree_shaker_creation() {
368        let shaker = TreeShaker::new();
369        assert!(shaker.get_config().enabled);
370        assert!(shaker.get_config().analyze_dependencies);
371    }
372
373    #[test]
374    fn test_custom_config() {
375        let config = TreeShakeConfig {
376            enabled: false,
377            remove_unused_responsive: false,
378            remove_unused_conditional: false,
379            remove_unused_custom: false,
380            keep_classes: HashSet::new(),
381            remove_classes: HashSet::new(),
382            analyze_dependencies: false,
383        };
384        
385        let shaker = TreeShaker::with_config(config);
386        assert!(!shaker.get_config().enabled);
387        assert!(!shaker.get_config().analyze_dependencies);
388    }
389
390    #[test]
391    fn test_keep_and_remove_classes() {
392        let mut shaker = TreeShaker::new();
393        
394        shaker.keep_class("important-class".to_string());
395        shaker.remove_class("unwanted-class".to_string());
396        
397        assert!(shaker.get_config().keep_classes.contains("important-class"));
398        assert!(shaker.get_config().remove_classes.contains("unwanted-class"));
399    }
400
401    #[test]
402    fn test_clear() {
403        let mut shaker = TreeShaker::new();
404        
405        shaker.keep_class("test-class".to_string());
406        shaker.remove_class("test-remove".to_string());
407        
408        assert!(!shaker.get_config().keep_classes.is_empty());
409        assert!(!shaker.get_config().remove_classes.is_empty());
410        
411        shaker.clear();
412        
413        assert!(shaker.get_config().keep_classes.is_empty());
414        assert!(shaker.get_config().remove_classes.is_empty());
415    }
416
417    #[test]
418    fn test_dependency_extraction() {
419        let shaker = TreeShaker::new();
420        
421        // Test CSS custom property extraction
422        assert_eq!(shaker.extract_class_dependency("var(--primary-color)"), Some("--primary-color".to_string()));
423        assert_eq!(shaker.extract_class_dependency("var(--spacing-4)"), Some("--spacing-4".to_string()));
424        assert_eq!(shaker.extract_class_dependency("1rem"), None);
425        assert_eq!(shaker.extract_class_dependency("#ffffff"), None);
426    }
427
428    #[test]
429    fn test_determine_classes_to_keep() {
430        let shaker = TreeShaker::new();
431        let mut used_classes = HashSet::new();
432        used_classes.insert("p-4".to_string());
433        used_classes.insert("bg-blue-500".to_string());
434        
435        let mut css_generator = CssGenerator::new();
436        css_generator.add_class("p-4").unwrap();
437        css_generator.add_class("bg-blue-500").unwrap();
438        css_generator.add_class("m-2").unwrap();
439        
440        let classes_to_keep = shaker.determine_classes_to_keep(&used_classes, &css_generator);
441        
442        assert!(classes_to_keep.contains("p-4"));
443        assert!(classes_to_keep.contains("bg-blue-500"));
444        assert!(!classes_to_keep.contains("m-2"));
445    }
446
447    #[test]
448    fn test_disabled_tree_shaking() {
449        let mut config = TreeShakeConfig::default();
450        config.enabled = false;
451        
452        let mut shaker = TreeShaker::with_config(config);
453        let mut css_generator = CssGenerator::new();
454        css_generator.add_class("p-4").unwrap();
455        
456        let temp_dir = std::env::temp_dir();
457        let results = shaker.shake(&[&temp_dir], &mut css_generator).unwrap();
458        
459        assert_eq!(results.stats.classes_removed, 0);
460        assert_eq!(results.reduction_percentage, 0.0);
461    }
462}