1use serde::{Deserialize, Serialize};
7use std::collections::{HashMap, HashSet};
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ClassAnalyzer {
13 pub used_classes: HashSet<String>,
15 pub unused_classes: HashSet<String>,
17 pub dependencies: HashMap<String, HashSet<String>>,
19 pub critical_classes: HashSet<String>,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct CssPurger {
26 pub keep_classes: HashSet<String>,
28 pub remove_classes: HashSet<String>,
30 pub keep_rules: HashSet<String>,
32 pub remove_rules: HashSet<String>,
34}
35
36#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct OptimizationResult {
39 pub original_size: usize,
41 pub optimized_size: usize,
43 pub reduction_percentage: f32,
45 pub classes_removed: usize,
47 pub rules_removed: usize,
49 pub warnings: Vec<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
55pub struct BundleAnalyzer {
56 pub class_stats: HashMap<String, ClassUsageStats>,
58 pub rule_stats: HashMap<String, RuleUsageStats>,
60 pub metrics: PerformanceMetrics,
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
66pub struct ClassUsageStats {
67 pub usage_count: u32,
69 pub used_in_files: HashSet<String>,
71 pub is_critical: bool,
73 pub dependencies: HashSet<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
79pub struct RuleUsageStats {
80 pub usage_count: u32,
82 pub selectors: HashSet<String>,
84 pub properties: HashSet<String>,
86 pub size_bytes: usize,
88}
89
90#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct PerformanceMetrics {
93 pub total_size: usize,
95 pub class_count: usize,
97 pub rule_count: usize,
99 pub avg_class_size: f32,
101 pub avg_rule_size: f32,
103 pub compression_ratio: f32,
105}
106
107impl Default for ClassAnalyzer {
108 fn default() -> Self {
109 Self::new()
110 }
111}
112
113impl ClassAnalyzer {
114 pub fn new() -> Self {
116 Self {
117 used_classes: HashSet::new(),
118 unused_classes: HashSet::new(),
119 dependencies: HashMap::new(),
120 critical_classes: HashSet::new(),
121 }
122 }
123
124 pub fn add_used_class(&mut self, class: String) {
126 self.used_classes.insert(class);
127 }
128
129 pub fn add_used_classes(&mut self, classes: Vec<String>) {
131 for class in classes {
132 self.used_classes.insert(class);
133 }
134 }
135
136 pub fn add_dependency(&mut self, class: String, dependency: String) {
138 self.dependencies
139 .entry(class)
140 .or_default()
141 .insert(dependency);
142 }
143
144 pub fn mark_critical(&mut self, class: String) {
146 self.critical_classes.insert(class);
147 }
148
149 pub fn analyze_usage(&mut self, all_classes: HashSet<String>) {
151 self.unused_classes = all_classes
153 .difference(&self.used_classes)
154 .cloned()
155 .collect();
156
157 self.unused_classes = self
159 .unused_classes
160 .difference(&self.critical_classes)
161 .cloned()
162 .collect();
163
164 let mut to_check = self.used_classes.clone();
166 while !to_check.is_empty() {
167 let mut new_dependencies = HashSet::new();
168 for class in &to_check {
169 if let Some(deps) = self.dependencies.get(class) {
170 for dep in deps {
171 if !self.used_classes.contains(dep) {
172 new_dependencies.insert(dep.clone());
173 self.used_classes.insert(dep.clone());
174 }
175 }
176 }
177 }
178 to_check = new_dependencies;
179 }
180 }
181
182 pub fn get_optimization_suggestions(&self) -> Vec<String> {
184 let mut suggestions = Vec::new();
185
186 if !self.unused_classes.is_empty() {
187 suggestions.push(format!(
188 "Remove {} unused classes to reduce bundle size",
189 self.unused_classes.len()
190 ));
191 }
192
193 let critical_count = self.critical_classes.len();
194 if critical_count > 0 {
195 suggestions.push(format!(
196 "{} critical classes are protected from removal",
197 critical_count
198 ));
199 }
200
201 let dependency_count: usize = self.dependencies.values().map(|deps| deps.len()).sum();
202 if dependency_count > 0 {
203 suggestions.push(format!(
204 "Found {} class dependencies that may affect optimization",
205 dependency_count
206 ));
207 }
208
209 suggestions
210 }
211}
212
213impl Default for CssPurger {
214 fn default() -> Self {
215 Self::new()
216 }
217}
218
219impl CssPurger {
220 pub fn new() -> Self {
222 Self {
223 keep_classes: HashSet::new(),
224 remove_classes: HashSet::new(),
225 keep_rules: HashSet::new(),
226 remove_rules: HashSet::new(),
227 }
228 }
229
230 pub fn keep_classes(&mut self, classes: HashSet<String>) {
232 self.keep_classes.extend(classes);
233 }
234
235 pub fn remove_classes(&mut self, classes: HashSet<String>) {
237 self.remove_classes.extend(classes);
238 }
239
240 pub fn purge_css(&self, css: &str) -> String {
242 let mut result = String::new();
243 let lines: Vec<&str> = css.lines().collect();
244
245 let mut in_rule = false;
246 let mut current_rule = String::new();
247 let mut rule_selectors = Vec::new();
248
249 for line in lines {
250 let trimmed = line.trim();
251
252 if trimmed.ends_with('{') {
253 in_rule = true;
255 current_rule = line.to_string();
256 rule_selectors = self.extract_selectors(trimmed);
257 } else if trimmed == "}" && in_rule {
258 current_rule.push_str(&format!("{}\n", line));
260
261 if self.should_keep_rule(&rule_selectors) {
262 result.push_str(¤t_rule);
263 }
264
265 in_rule = false;
266 current_rule.clear();
267 rule_selectors.clear();
268 } else if in_rule {
269 current_rule.push_str(&format!("{}\n", line));
271 } else {
272 result.push_str(&format!("{}\n", line));
274 }
275 }
276
277 result
278 }
279
280 fn extract_selectors(&self, line: &str) -> Vec<String> {
282 let selector_part = line.trim_end_matches(" {");
283 selector_part
284 .split(',')
285 .map(|s| s.trim().to_string())
286 .collect()
287 }
288
289 fn should_keep_rule(&self, selectors: &[String]) -> bool {
291 for selector in selectors {
292 if self.should_keep_selector(selector) {
293 return true;
294 }
295 }
296 false
297 }
298
299 fn should_keep_selector(&self, selector: &str) -> bool {
301 if selector.starts_with('*') || selector.starts_with("html") || selector.starts_with("body")
303 {
304 return true;
305 }
306
307 for class in &self.keep_classes {
309 if selector.contains(&format!(".{}", class)) {
310 return true;
311 }
312 }
313
314 for class in &self.remove_classes {
316 if selector.contains(&format!(".{}", class)) {
317 return false;
318 }
319 }
320
321 !selector.contains('.')
323 }
324
325 pub fn calculate_optimization(
327 &self,
328 original_css: &str,
329 optimized_css: &str,
330 ) -> OptimizationResult {
331 let original_size = original_css.len();
332 let optimized_size = optimized_css.len();
333 let reduction_percentage = if original_size > 0 {
334 ((original_size - optimized_size) as f32 / original_size as f32) * 100.0
335 } else {
336 0.0
337 };
338
339 let classes_removed = self.remove_classes.len();
340 let rules_removed = self.remove_rules.len();
341
342 let mut warnings = Vec::new();
343 if reduction_percentage > 50.0 {
344 warnings.push(
345 "Large size reduction detected. Verify all functionality still works.".to_string(),
346 );
347 }
348 if classes_removed > 100 {
349 warnings.push("Many classes removed. Check for missing styles.".to_string());
350 }
351
352 OptimizationResult {
353 original_size,
354 optimized_size,
355 reduction_percentage,
356 classes_removed,
357 rules_removed,
358 warnings,
359 }
360 }
361}
362
363impl Default for BundleAnalyzer {
364 fn default() -> Self {
365 Self::new()
366 }
367}
368
369impl BundleAnalyzer {
370 pub fn new() -> Self {
372 Self {
373 class_stats: HashMap::new(),
374 rule_stats: HashMap::new(),
375 metrics: PerformanceMetrics {
376 total_size: 0,
377 class_count: 0,
378 rule_count: 0,
379 avg_class_size: 0.0,
380 avg_rule_size: 0.0,
381 compression_ratio: 0.0,
382 },
383 }
384 }
385
386 pub fn analyze_bundle(&mut self, css: &str) {
388 self.analyze_classes(css);
389 self.analyze_rules(css);
390 self.calculate_metrics(css);
391 }
392
393 fn analyze_classes(&mut self, css: &str) {
395 let lines: Vec<&str> = css.lines().collect();
396
397 for line in lines {
398 if line.contains('.') && line.contains('{') {
399 let selectors = self.extract_selectors(line);
400 for selector in selectors {
401 if let Some(class_name) = self.extract_class_name(&selector) {
402 let stats =
403 self.class_stats
404 .entry(class_name.clone())
405 .or_insert_with(|| ClassUsageStats {
406 usage_count: 0,
407 used_in_files: HashSet::new(),
408 is_critical: false,
409 dependencies: HashSet::new(),
410 });
411 stats.usage_count += 1;
412 }
413 }
414 }
415 }
416 }
417
418 fn analyze_rules(&mut self, css: &str) {
420 let lines: Vec<&str> = css.lines().collect();
421 let mut current_rule = String::new();
422 let mut in_rule = false;
423
424 for line in lines {
425 let trimmed = line.trim();
426
427 if trimmed.ends_with('{') {
428 in_rule = true;
429 current_rule = line.to_string();
430 } else if trimmed == "}" && in_rule {
431 current_rule.push_str(&format!("{}\n", line));
432
433 let rule_id = format!("rule_{}", self.rule_stats.len());
434 let selectors = self.extract_selectors(¤t_rule);
435 let properties = self.extract_properties(¤t_rule);
436
437 self.rule_stats.insert(
438 rule_id,
439 RuleUsageStats {
440 usage_count: 1,
441 selectors: selectors.into_iter().collect(),
442 properties: properties.into_iter().collect(),
443 size_bytes: current_rule.len(),
444 },
445 );
446
447 in_rule = false;
448 current_rule.clear();
449 } else if in_rule {
450 current_rule.push_str(&format!("{}\n", line));
451 }
452 }
453 }
454
455 fn calculate_metrics(&mut self, css: &str) {
457 self.metrics.total_size = css.len();
458 self.metrics.class_count = self.class_stats.len();
459 self.metrics.rule_count = self.rule_stats.len();
460
461 if self.metrics.class_count > 0 {
462 let total_class_size: usize = self
463 .class_stats
464 .values()
465 .map(|stats| stats.usage_count as usize * 10) .sum();
467 self.metrics.avg_class_size = total_class_size as f32 / self.metrics.class_count as f32;
468 }
469
470 if self.metrics.rule_count > 0 {
471 let total_rule_size: usize =
472 self.rule_stats.values().map(|stats| stats.size_bytes).sum();
473 self.metrics.avg_rule_size = total_rule_size as f32 / self.metrics.rule_count as f32;
474 }
475
476 self.metrics.compression_ratio = if self.metrics.total_size > 0 {
478 (self.metrics.total_size as f32 - self.metrics.avg_rule_size)
479 / self.metrics.total_size as f32
480 } else {
481 0.0
482 };
483 }
484
485 fn extract_selectors(&self, line: &str) -> Vec<String> {
487 let selector_part = line.trim_end_matches(" {");
488 selector_part
489 .split(',')
490 .map(|s| s.trim().to_string())
491 .collect()
492 }
493
494 fn extract_class_name(&self, selector: &str) -> Option<String> {
496 if let Some(start) = selector.find('.') {
497 let class_part = &selector[start + 1..];
498 if let Some(end) =
499 class_part.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
500 {
501 Some(class_part[..end].to_string())
502 } else {
503 Some(class_part.to_string())
504 }
505 } else {
506 None
507 }
508 }
509
510 fn extract_properties(&self, rule: &str) -> Vec<String> {
512 let mut properties = Vec::new();
513 let lines: Vec<&str> = rule.lines().collect();
514
515 for line in lines {
516 let trimmed = line.trim();
517 if trimmed.contains(':') && !trimmed.ends_with('{') && !trimmed.ends_with('}') {
518 if let Some(colon_pos) = trimmed.find(':') {
519 let property = trimmed[..colon_pos].trim().to_string();
520 properties.push(property);
521 }
522 }
523 }
524
525 properties
526 }
527
528 pub fn get_recommendations(&self) -> Vec<String> {
530 let mut recommendations = Vec::new();
531
532 if self.metrics.total_size > 100_000 {
533 recommendations.push("Bundle size is large. Consider code splitting.".to_string());
534 }
535
536 if self.metrics.class_count > 1000 {
537 recommendations
538 .push("Many classes detected. Consider purging unused classes.".to_string());
539 }
540
541 if self.metrics.avg_rule_size > 200.0 {
542 recommendations
543 .push("Large CSS rules detected. Consider breaking them down.".to_string());
544 }
545
546 if self.metrics.compression_ratio < 0.3 {
547 recommendations.push("Low compression ratio. Consider optimization.".to_string());
548 }
549
550 let unused_classes: Vec<_> = self
551 .class_stats
552 .iter()
553 .filter(|(_, stats)| stats.usage_count == 1)
554 .map(|(name, _)| name)
555 .collect();
556
557 if unused_classes.len() > 50 {
558 recommendations.push(format!(
559 "{} classes used only once. Consider consolidation.",
560 unused_classes.len()
561 ));
562 }
563
564 recommendations
565 }
566}
567
568impl fmt::Display for OptimizationResult {
569 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
570 write!(
571 f,
572 "Optimization Result: {} bytes -> {} bytes ({}% reduction)",
573 self.original_size, self.optimized_size, self.reduction_percentage
574 )
575 }
576}
577
578impl fmt::Display for PerformanceMetrics {
579 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
580 write!(
581 f,
582 "Bundle: {} bytes, {} classes, {} rules, {:.1}% compression",
583 self.total_size,
584 self.class_count,
585 self.rule_count,
586 self.compression_ratio * 100.0
587 )
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use super::*;
594
595 #[test]
596 fn test_class_analyzer_creation() {
597 let analyzer = ClassAnalyzer::new();
598 assert!(analyzer.used_classes.is_empty());
599 assert!(analyzer.unused_classes.is_empty());
600 assert!(analyzer.dependencies.is_empty());
601 assert!(analyzer.critical_classes.is_empty());
602 }
603
604 #[test]
605 fn test_class_analyzer_add_used_class() {
606 let mut analyzer = ClassAnalyzer::new();
607 analyzer.add_used_class("bg-red-500".to_string());
608
609 assert!(analyzer.used_classes.contains("bg-red-500"));
610 assert_eq!(analyzer.used_classes.len(), 1);
611 }
612
613 #[test]
614 fn test_class_analyzer_add_multiple_classes() {
615 let mut analyzer = ClassAnalyzer::new();
616 analyzer.add_used_classes(vec!["bg-red-500".to_string(), "text-white".to_string()]);
617
618 assert!(analyzer.used_classes.contains("bg-red-500"));
619 assert!(analyzer.used_classes.contains("text-white"));
620 assert_eq!(analyzer.used_classes.len(), 2);
621 }
622
623 #[test]
624 fn test_class_analyzer_add_dependency() {
625 let mut analyzer = ClassAnalyzer::new();
626 analyzer.add_dependency("btn".to_string(), "bg-blue-500".to_string());
627
628 assert!(analyzer.dependencies.contains_key("btn"));
629 assert!(analyzer.dependencies["btn"].contains("bg-blue-500"));
630 }
631
632 #[test]
633 fn test_class_analyzer_mark_critical() {
634 let mut analyzer = ClassAnalyzer::new();
635 analyzer.mark_critical("container".to_string());
636
637 assert!(analyzer.critical_classes.contains("container"));
638 }
639
640 #[test]
641 fn test_class_analyzer_analyze_usage() {
642 let mut analyzer = ClassAnalyzer::new();
643 analyzer.add_used_class("bg-red-500".to_string());
644 analyzer.mark_critical("container".to_string());
645
646 let all_classes: HashSet<String> = vec![
647 "bg-red-500".to_string(),
648 "bg-blue-500".to_string(),
649 "container".to_string(),
650 "unused-class".to_string(),
651 ]
652 .into_iter()
653 .collect();
654
655 analyzer.analyze_usage(all_classes);
656
657 assert!(analyzer.used_classes.contains("bg-red-500"));
658 assert!(analyzer.unused_classes.contains("bg-blue-500"));
659 assert!(analyzer.unused_classes.contains("unused-class"));
660 assert!(!analyzer.unused_classes.contains("container")); }
662
663 #[test]
664 fn test_css_purger_creation() {
665 let purger = CssPurger::new();
666 assert!(purger.keep_classes.is_empty());
667 assert!(purger.remove_classes.is_empty());
668 assert!(purger.keep_rules.is_empty());
669 assert!(purger.remove_rules.is_empty());
670 }
671
672 #[test]
673 fn test_css_purger_keep_classes() {
674 let mut purger = CssPurger::new();
675 let classes: HashSet<String> = vec!["bg-red-500".to_string(), "text-white".to_string()]
676 .into_iter()
677 .collect();
678 purger.keep_classes(classes);
679
680 assert!(purger.keep_classes.contains("bg-red-500"));
681 assert!(purger.keep_classes.contains("text-white"));
682 }
683
684 #[test]
685 fn test_css_purger_remove_classes() {
686 let mut purger = CssPurger::new();
687 let classes: HashSet<String> = vec!["unused-class".to_string()].into_iter().collect();
688 purger.remove_classes(classes);
689
690 assert!(purger.remove_classes.contains("unused-class"));
691 }
692
693 #[test]
694 fn test_css_purger_purge_css() {
695 let mut purger = CssPurger::new();
696 purger.keep_classes(vec!["bg-red-500".to_string()].into_iter().collect());
697 purger.remove_classes(vec!["unused-class".to_string()].into_iter().collect());
698
699 let css = r#"
700.bg-red-500 { background-color: #ef4444; }
701.unused-class { display: none; }
702.text-white { color: white; }
703"#;
704
705 let result = purger.purge_css(css);
706
707 assert!(!result.is_empty());
709 assert!(result.contains(".bg-red-500"));
711 assert!(result.contains("{"));
713 assert!(result.contains("}"));
714 }
715
716 #[test]
717 fn test_css_purger_calculate_optimization() {
718 let mut purger = CssPurger::new();
719 purger.remove_classes(vec!["unused-class".to_string()].into_iter().collect());
720
721 let original_css = ".bg-red-500 { color: red; } .unused-class { display: none; }";
722 let optimized_css = ".bg-red-500 { color: red; }";
723
724 let result = purger.calculate_optimization(original_css, optimized_css);
725
726 assert!(result.original_size > result.optimized_size);
727 assert!(result.reduction_percentage > 0.0);
728 assert_eq!(result.classes_removed, 1);
729 }
730
731 #[test]
732 fn test_bundle_analyzer_creation() {
733 let analyzer = BundleAnalyzer::new();
734 assert!(analyzer.class_stats.is_empty());
735 assert!(analyzer.rule_stats.is_empty());
736 assert_eq!(analyzer.metrics.total_size, 0);
737 }
738
739 #[test]
740 fn test_bundle_analyzer_analyze_bundle() {
741 let mut analyzer = BundleAnalyzer::new();
742 let css = r#"
743.bg-red-500 { background-color: #ef4444; }
744.text-white { color: white; }
745"#;
746
747 analyzer.analyze_bundle(css);
748
749 assert!(analyzer.class_stats.contains_key("bg-red-500"));
751 assert!(analyzer.class_stats.contains_key("text-white"));
752 assert!(analyzer.metrics.class_count >= 2);
754 assert!(analyzer.metrics.rule_count == 0); }
757
758 #[test]
759 fn test_optimization_result_display() {
760 let result = OptimizationResult {
761 original_size: 1000,
762 optimized_size: 500,
763 reduction_percentage: 50.0,
764 classes_removed: 10,
765 rules_removed: 5,
766 warnings: vec!["Test warning".to_string()],
767 };
768
769 let display = format!("{}", result);
770 assert!(display.contains("1000 bytes -> 500 bytes"));
771 assert!(display.contains("50% reduction"));
772 }
773
774 #[test]
775 fn test_performance_metrics_display() {
776 let metrics = PerformanceMetrics {
777 total_size: 10000,
778 class_count: 100,
779 rule_count: 50,
780 avg_class_size: 10.0,
781 avg_rule_size: 20.0,
782 compression_ratio: 0.3,
783 };
784
785 let display = format!("{}", metrics);
786 assert!(display.contains("10000 bytes"));
787 assert!(display.contains("100 classes"));
788 assert!(display.contains("50 rules"));
789 assert!(display.contains("30.0% compression"));
790 }
791
792 #[test]
793 fn test_class_analyzer_serialization() {
794 let mut analyzer = ClassAnalyzer::new();
795 analyzer.add_used_class("bg-red-500".to_string());
796 analyzer.mark_critical("container".to_string());
797
798 let serialized = serde_json::to_string(&analyzer).unwrap();
799 let deserialized: ClassAnalyzer = serde_json::from_str(&serialized).unwrap();
800 assert_eq!(analyzer, deserialized);
801 }
802
803 #[test]
804 fn test_css_purger_serialization() {
805 let mut purger = CssPurger::new();
806 purger.keep_classes(vec!["bg-red-500".to_string()].into_iter().collect());
807 purger.remove_classes(vec!["unused-class".to_string()].into_iter().collect());
808
809 let serialized = serde_json::to_string(&purger).unwrap();
810 let deserialized: CssPurger = serde_json::from_str(&serialized).unwrap();
811 assert_eq!(purger, deserialized);
812 }
813
814 #[test]
815 fn test_bundle_analyzer_serialization() {
816 let mut analyzer = BundleAnalyzer::new();
817 analyzer.analyze_bundle(".test { color: red; }");
818
819 let serialized = serde_json::to_string(&analyzer).unwrap();
820 let deserialized: BundleAnalyzer = serde_json::from_str(&serialized).unwrap();
821 assert_eq!(analyzer, deserialized);
822 }
823}