1use crate::class_scanner::ClassScanner;
7use crate::css_generator::CssGenerator;
8use crate::error::Result;
9use std::collections::{HashMap, HashSet};
10use std::path::Path;
11
12#[derive(Debug, Clone)]
14pub struct TreeShakeConfig {
15 pub enabled: bool,
17 pub remove_unused_responsive: bool,
19 pub remove_unused_conditional: bool,
21 pub remove_unused_custom: bool,
23 pub keep_classes: HashSet<String>,
25 pub remove_classes: HashSet<String>,
27 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#[derive(Debug, Clone)]
47pub struct TreeShakeResults {
48 pub kept_classes: HashSet<String>,
50 pub removed_classes: HashSet<String>,
52 pub original_size: usize,
54 pub optimized_size: usize,
56 pub reduction_percentage: f64,
58 pub stats: TreeShakeStats,
60}
61
62#[derive(Debug, Clone)]
64pub struct TreeShakeStats {
65 pub classes_analyzed: usize,
67 pub classes_removed: usize,
69 pub responsive_removed: usize,
71 pub conditional_removed: usize,
73 pub custom_removed: usize,
75 pub processing_time_ms: u64,
77}
78
79#[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#[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 pub fn new() -> Self {
100 Self {
101 config: TreeShakeConfig::default(),
102 dependency_graph: HashMap::new(),
103 reverse_dependencies: HashMap::new(),
104 }
105 }
106
107 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 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 let used_classes = self.scan_used_classes(source_paths)?;
144
145 if self.config.analyze_dependencies {
147 self.build_dependency_graph(css_generator);
148 }
149
150 let classes_to_keep = self.determine_classes_to_keep(&used_classes, css_generator);
152
153 let removal_stats = self.remove_unused_classes(css_generator, &classes_to_keep);
155
156 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 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 fn build_dependency_graph(&mut self, css_generator: &CssGenerator) {
199 self.dependency_graph.clear();
200 self.reverse_dependencies.clear();
201
202 for (class_name, rule) in css_generator.get_rules() {
204 let mut dependencies = HashSet::new();
205
206 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 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 fn extract_class_dependency(&self, value: &str) -> Option<String> {
232 if value.contains("var(--") {
235 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 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 classes_to_keep.extend(used_classes.iter().cloned());
256
257 classes_to_keep.extend(self.config.keep_classes.iter().cloned());
259
260 for class in &self.config.remove_classes {
262 classes_to_keep.remove(class);
263 }
264
265 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 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 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 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 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 pub fn get_config(&self) -> &TreeShakeConfig {
348 &self.config
349 }
350
351 pub fn set_config(&mut self, config: TreeShakeConfig) {
353 self.config = config;
354 }
355
356 pub fn keep_class(&mut self, class: String) {
358 self.config.keep_classes.insert(class);
359 }
360
361 pub fn remove_class(&mut self, class: String) {
363 self.config.remove_classes.insert(class);
364 }
365
366 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 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}