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(
118        &mut self,
119        source_paths: &[&Path],
120        css_generator: &mut CssGenerator,
121    ) -> Result<TreeShakeResults> {
122        let start_time = std::time::Instant::now();
123
124        if !self.config.enabled {
125            return Ok(TreeShakeResults {
126                kept_classes: css_generator.get_rules().keys().cloned().collect(),
127                removed_classes: HashSet::new(),
128                original_size: css_generator.generate_css().len(),
129                optimized_size: css_generator.generate_css().len(),
130                reduction_percentage: 0.0,
131                stats: TreeShakeStats {
132                    classes_analyzed: css_generator.rule_count(),
133                    classes_removed: 0,
134                    responsive_removed: 0,
135                    conditional_removed: 0,
136                    custom_removed: 0,
137                    processing_time_ms: start_time.elapsed().as_millis() as u64,
138                },
139            });
140        }
141
142        // Scan source files for used classes
143        let used_classes = self.scan_used_classes(source_paths)?;
144
145        // Build dependency graph if enabled
146        if self.config.analyze_dependencies {
147            self.build_dependency_graph(css_generator);
148        }
149
150        // Determine which classes to keep
151        let classes_to_keep = self.determine_classes_to_keep(&used_classes, css_generator);
152
153        // Remove unused classes and track statistics
154        let removal_stats = self.remove_unused_classes(css_generator, &classes_to_keep);
155
156        // Calculate results
157        let original_size = css_generator.generate_css().len();
158        let optimized_size = css_generator.generate_css().len();
159        let reduction_percentage = if original_size > 0 {
160            ((original_size - optimized_size) as f64 / original_size as f64) * 100.0
161        } else {
162            0.0
163        };
164
165        let stats = TreeShakeStats {
166            classes_analyzed: css_generator.rule_count() + removal_stats.total_removed,
167            classes_removed: removal_stats.total_removed,
168            responsive_removed: removal_stats.responsive_removed,
169            conditional_removed: removal_stats.conditional_removed,
170            custom_removed: removal_stats.custom_removed,
171            processing_time_ms: start_time.elapsed().as_millis() as u64,
172        };
173
174        Ok(TreeShakeResults {
175            kept_classes: classes_to_keep,
176            removed_classes: removal_stats.removed_classes,
177            original_size,
178            optimized_size,
179            reduction_percentage,
180            stats,
181        })
182    }
183
184    /// Scan source files to find used classes
185    fn scan_used_classes(&self, source_paths: &[&Path]) -> Result<HashSet<String>> {
186        let mut scanner = ClassScanner::new();
187        let mut all_used_classes = HashSet::new();
188
189        for path in source_paths {
190            let results = scanner.scan_directory(path)?;
191            all_used_classes.extend(results.classes);
192        }
193
194        Ok(all_used_classes)
195    }
196
197    /// Build dependency graph between CSS classes
198    fn build_dependency_graph(&mut self, css_generator: &CssGenerator) {
199        self.dependency_graph.clear();
200        self.reverse_dependencies.clear();
201
202        // Analyze CSS rules for dependencies
203        for (class_name, rule) in css_generator.get_rules() {
204            let mut dependencies = HashSet::new();
205
206            // Look for class references in CSS values
207            for property in &rule.properties {
208                if let Some(dep_class) = self.extract_class_dependency(&property.value) {
209                    dependencies.insert(dep_class);
210                }
211            }
212
213            if !dependencies.is_empty() {
214                self.dependency_graph
215                    .insert(class_name.clone(), dependencies);
216            }
217        }
218
219        // Build reverse dependencies
220        for (class, deps) in &self.dependency_graph {
221            for dep in deps {
222                self.reverse_dependencies
223                    .entry(dep.clone())
224                    .or_default()
225                    .insert(class.clone());
226            }
227        }
228    }
229
230    /// Extract class dependency from CSS value
231    fn extract_class_dependency(&self, value: &str) -> Option<String> {
232        // Look for class references in CSS values
233        // This is a simplified implementation
234        if value.contains("var(--") {
235            // Extract CSS custom property reference
236            if let Some(start) = value.find("var(--") {
237                if let Some(end) = value[start..].find(')') {
238                    let var_name = &value[start + 6..start + end];
239                    return Some(format!("--{}", var_name));
240                }
241            }
242        }
243        None
244    }
245
246    /// Determine which classes should be kept
247    fn determine_classes_to_keep(
248        &self,
249        used_classes: &HashSet<String>,
250        _css_generator: &CssGenerator,
251    ) -> HashSet<String> {
252        let mut classes_to_keep = HashSet::new();
253
254        // Add explicitly used classes
255        classes_to_keep.extend(used_classes.iter().cloned());
256
257        // Add whitelisted classes
258        classes_to_keep.extend(self.config.keep_classes.iter().cloned());
259
260        // Remove blacklisted classes
261        for class in &self.config.remove_classes {
262            classes_to_keep.remove(class);
263        }
264
265        // Add dependent classes if dependency analysis is enabled
266        if self.config.analyze_dependencies {
267            let mut to_process: Vec<String> = classes_to_keep.iter().cloned().collect();
268            let mut processed = HashSet::new();
269
270            while let Some(class) = to_process.pop() {
271                if processed.contains(&class) {
272                    continue;
273                }
274                processed.insert(class.clone());
275
276                // Add dependencies
277                if let Some(deps) = self.dependency_graph.get(&class) {
278                    for dep in deps {
279                        if !classes_to_keep.contains(dep) {
280                            classes_to_keep.insert(dep.clone());
281                            to_process.push(dep.clone());
282                        }
283                    }
284                }
285
286                // Add reverse dependencies (classes that depend on this one)
287                if let Some(reverse_deps) = self.reverse_dependencies.get(&class) {
288                    for reverse_dep in reverse_deps {
289                        if !classes_to_keep.contains(reverse_dep) {
290                            classes_to_keep.insert(reverse_dep.clone());
291                            to_process.push(reverse_dep.clone());
292                        }
293                    }
294                }
295            }
296        }
297
298        classes_to_keep
299    }
300
301    /// Remove unused classes from CSS generator
302    fn remove_unused_classes(
303        &self,
304        css_generator: &mut CssGenerator,
305        classes_to_keep: &HashSet<String>,
306    ) -> RemovalStats {
307        let mut removed_classes = HashSet::new();
308        let mut responsive_removed = 0;
309        let mut conditional_removed = 0;
310        let mut custom_removed = 0;
311        let rules = css_generator.get_rules().clone();
312
313        for (class_name, _rule) in rules {
314            if !classes_to_keep.contains(&class_name) {
315                removed_classes.insert(class_name.clone());
316
317                // Categorize the removed class
318                if class_name.contains("sm:")
319                    || class_name.contains("md:")
320                    || class_name.contains("lg:")
321                    || class_name.contains("xl:")
322                    || class_name.contains("2xl:")
323                {
324                    responsive_removed += 1;
325                } else if class_name.contains("hover:")
326                    || class_name.contains("focus:")
327                    || class_name.contains("active:")
328                    || class_name.contains("disabled:")
329                {
330                    conditional_removed += 1;
331                } else if class_name.starts_with("--") || class_name.contains("var(") {
332                    custom_removed += 1;
333                }
334            }
335        }
336
337        RemovalStats {
338            total_removed: removed_classes.len(),
339            responsive_removed,
340            conditional_removed,
341            custom_removed,
342            removed_classes,
343        }
344    }
345
346    /// Get the current configuration
347    pub fn get_config(&self) -> &TreeShakeConfig {
348        &self.config
349    }
350
351    /// Update the configuration
352    pub fn set_config(&mut self, config: TreeShakeConfig) {
353        self.config = config;
354    }
355
356    /// Add a class to the whitelist
357    pub fn keep_class(&mut self, class: String) {
358        self.config.keep_classes.insert(class);
359    }
360
361    /// Add a class to the blacklist
362    pub fn remove_class(&mut self, class: String) {
363        self.config.remove_classes.insert(class);
364    }
365
366    /// Clear all configuration
367    pub fn clear(&mut self) {
368        self.config.keep_classes.clear();
369        self.config.remove_classes.clear();
370        self.dependency_graph.clear();
371        self.reverse_dependencies.clear();
372    }
373}
374
375impl Default for TreeShaker {
376    fn default() -> Self {
377        Self::new()
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn test_tree_shaker_creation() {
387        let shaker = TreeShaker::new();
388        assert!(shaker.get_config().enabled);
389        assert!(shaker.get_config().analyze_dependencies);
390    }
391
392    #[test]
393    fn test_custom_config() {
394        let config = TreeShakeConfig {
395            enabled: false,
396            remove_unused_responsive: false,
397            remove_unused_conditional: false,
398            remove_unused_custom: false,
399            keep_classes: HashSet::new(),
400            remove_classes: HashSet::new(),
401            analyze_dependencies: false,
402        };
403
404        let shaker = TreeShaker::with_config(config);
405        assert!(!shaker.get_config().enabled);
406        assert!(!shaker.get_config().analyze_dependencies);
407    }
408
409    #[test]
410    fn test_keep_and_remove_classes() {
411        let mut shaker = TreeShaker::new();
412
413        shaker.keep_class("important-class".to_string());
414        shaker.remove_class("unwanted-class".to_string());
415
416        assert!(shaker.get_config().keep_classes.contains("important-class"));
417        assert!(shaker
418            .get_config()
419            .remove_classes
420            .contains("unwanted-class"));
421    }
422
423    #[test]
424    fn test_clear() {
425        let mut shaker = TreeShaker::new();
426
427        shaker.keep_class("test-class".to_string());
428        shaker.remove_class("test-remove".to_string());
429
430        assert!(!shaker.get_config().keep_classes.is_empty());
431        assert!(!shaker.get_config().remove_classes.is_empty());
432
433        shaker.clear();
434
435        assert!(shaker.get_config().keep_classes.is_empty());
436        assert!(shaker.get_config().remove_classes.is_empty());
437    }
438
439    #[test]
440    fn test_dependency_extraction() {
441        let shaker = TreeShaker::new();
442
443        // Test CSS custom property extraction
444        assert_eq!(
445            shaker.extract_class_dependency("var(--primary-color)"),
446            Some("--primary-color".to_string())
447        );
448        assert_eq!(
449            shaker.extract_class_dependency("var(--spacing-4)"),
450            Some("--spacing-4".to_string())
451        );
452        assert_eq!(shaker.extract_class_dependency("1rem"), None);
453        assert_eq!(shaker.extract_class_dependency("#ffffff"), None);
454    }
455
456    #[test]
457    fn test_determine_classes_to_keep() {
458        let shaker = TreeShaker::new();
459        let mut used_classes = HashSet::new();
460        used_classes.insert("p-4".to_string());
461        used_classes.insert("bg-blue-500".to_string());
462
463        let mut css_generator = CssGenerator::new();
464        css_generator.add_class("p-4").unwrap();
465        css_generator.add_class("bg-blue-500").unwrap();
466        css_generator.add_class("m-2").unwrap();
467
468        let classes_to_keep = shaker.determine_classes_to_keep(&used_classes, &css_generator);
469
470        assert!(classes_to_keep.contains("p-4"));
471        assert!(classes_to_keep.contains("bg-blue-500"));
472        assert!(!classes_to_keep.contains("m-2"));
473    }
474
475    #[test]
476    fn test_disabled_tree_shaking() {
477        let mut config = TreeShakeConfig::default();
478        config.enabled = false;
479
480        let mut shaker = TreeShaker::with_config(config);
481        let mut css_generator = CssGenerator::new();
482        css_generator.add_class("p-4").unwrap();
483
484        let temp_dir = std::env::temp_dir();
485        let results = shaker.shake(&[&temp_dir], &mut css_generator).unwrap();
486
487        assert_eq!(results.stats.classes_removed, 0);
488        assert_eq!(results.reduction_percentage, 0.0);
489    }
490}