1use crate::structure_comparator::{
2 ComparisonOptions, SourceLocation, Structure, StructureComparator, StructureComparisonResult,
3 StructureIdentifier, StructureKind, StructureMember, StructureMetadata,
4};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone)]
9pub struct CssStructDef {
10 pub selector: String,
11 pub declarations: Vec<(String, String)>,
12 pub file_path: String,
13 pub start_line: usize,
14 pub end_line: usize,
15 pub media_query: Option<String>,
16 pub parent_selectors: Vec<String>,
17}
18
19impl From<CssStructDef> for Structure {
21 fn from(css_rule: CssStructDef) -> Self {
22 let kind = if css_rule.selector.starts_with('.') {
23 StructureKind::CssClass
24 } else {
25 StructureKind::CssRule
26 };
27
28 let mut members = Vec::new();
29
30 for (property, value) in css_rule.declarations {
32 members.push(StructureMember {
33 name: property.clone(),
34 value_type: categorize_css_value(&value),
35 modifiers: vec![],
36 nested: None,
37 });
38 }
39
40 if let Some(media) = &css_rule.media_query {
42 members.push(StructureMember {
43 name: "@media".to_string(),
44 value_type: media.clone(),
45 modifiers: vec!["media-query".to_string()],
46 nested: None,
47 });
48 }
49
50 if !css_rule.parent_selectors.is_empty() {
52 members.push(StructureMember {
53 name: "@parent".to_string(),
54 value_type: css_rule.parent_selectors.join(" "),
55 modifiers: vec!["parent-selector".to_string()],
56 nested: None,
57 });
58 }
59
60 Structure {
61 identifier: StructureIdentifier {
62 name: css_rule.selector.clone(),
63 kind,
64 namespace: Some(css_rule.file_path.clone()),
65 },
66 members,
67 metadata: StructureMetadata {
68 location: SourceLocation {
69 file_path: css_rule.file_path,
70 start_line: css_rule.start_line,
71 end_line: css_rule.end_line,
72 },
73 generics: vec![],
74 extends: vec![],
75 visibility: None,
76 },
77 }
78 }
79}
80
81fn categorize_css_value(value: &str) -> String {
83 let value = value.trim();
84 let is_single_token = !value.chars().any(char::is_whitespace);
85
86 if value.starts_with('#')
88 || value.starts_with("rgb")
89 || value.starts_with("hsl")
90 || value.starts_with("rgba")
91 || value.starts_with("hsla")
92 || is_named_color(value)
93 {
94 return "color".to_string();
95 }
96
97 if is_single_token
99 && (value.ends_with("px")
100 || value.ends_with("em")
101 || value.ends_with("rem")
102 || value.ends_with("%")
103 || value.ends_with("vh")
104 || value.ends_with("vw")
105 || value.ends_with("pt")
106 || value.ends_with("cm")
107 || value.ends_with("mm"))
108 {
109 return "length".to_string();
110 }
111
112 if value.ends_with("s") || value.ends_with("ms") {
114 return "time".to_string();
115 }
116
117 if is_font_family(value) {
119 return "font-family".to_string();
120 }
121
122 if value.parse::<f64>().is_ok() {
124 return "number".to_string();
125 }
126
127 if value.starts_with("url(") {
129 return "url".to_string();
130 }
131
132 if is_css_keyword(value) {
134 return "keyword".to_string();
135 }
136
137 "value".to_string()
139}
140
141fn is_named_color(value: &str) -> bool {
142 matches!(
143 value,
144 "red"
145 | "green"
146 | "blue"
147 | "black"
148 | "white"
149 | "gray"
150 | "grey"
151 | "yellow"
152 | "orange"
153 | "purple"
154 | "pink"
155 | "brown"
156 | "cyan"
157 | "magenta"
158 | "lime"
159 | "indigo"
160 | "violet"
161 | "transparent"
162 | "currentColor"
163 )
164}
165
166fn is_font_family(value: &str) -> bool {
167 value.contains("serif")
168 || value.contains("sans-serif")
169 || value.contains("monospace")
170 || value.contains("cursive")
171 || value.contains("fantasy")
172 || value.contains("Arial")
173 || value.contains("Helvetica")
174 || value.contains("Times")
175 || value.contains("Courier")
176 || value.contains("Georgia")
177 || value.contains("Verdana")
178 || value.contains('"')
179 || value.contains('\'')
180}
181
182fn is_css_keyword(value: &str) -> bool {
183 matches!(
184 value,
185 "none"
186 | "auto"
187 | "inherit"
188 | "initial"
189 | "unset"
190 | "normal"
191 | "bold"
192 | "italic"
193 | "underline"
194 | "center"
195 | "left"
196 | "right"
197 | "top"
198 | "bottom"
199 | "middle"
200 | "baseline"
201 | "flex"
202 | "grid"
203 | "block"
204 | "inline"
205 | "inline-block"
206 | "table"
207 | "relative"
208 | "absolute"
209 | "fixed"
210 | "sticky"
211 | "static"
212 | "hidden"
213 | "visible"
214 | "scroll"
215 | "pointer"
216 | "default"
217 | "solid"
218 | "dashed"
219 | "dotted"
220 )
221}
222
223pub struct CssStructureComparator {
225 pub comparator: StructureComparator,
226}
227
228impl Default for CssStructureComparator {
229 fn default() -> Self {
230 Self::new()
231 }
232}
233
234impl CssStructureComparator {
235 pub fn new() -> Self {
236 let options = ComparisonOptions {
237 name_weight: 0.4, structure_weight: 0.6, threshold: 0.7,
240 fuzzy_matching: true, ignore_order: true, ..Default::default()
243 };
244
245 Self { comparator: StructureComparator::new(options) }
246 }
247
248 pub fn with_options(options: ComparisonOptions) -> Self {
249 Self { comparator: StructureComparator::new(options) }
250 }
251
252 pub fn compare_rules(
254 &mut self,
255 rule1: &CssStructDef,
256 rule2: &CssStructDef,
257 ) -> StructureComparisonResult {
258 let struct1 = Structure::from(rule1.clone());
259 let struct2 = Structure::from(rule2.clone());
260 self.comparator.compare(&struct1, &struct2)
261 }
262
263 pub fn normalize_selector(selector: &str) -> String {
265 let mut normalized = selector.trim().to_string();
267
268 while normalized.contains(" ") {
270 normalized = normalized.replace(" ", " ");
271 }
272
273 normalized = normalized.replace(" > ", ">").replace(" + ", "+").replace(" ~ ", "~");
275
276 if normalized.contains(',') {
278 let mut parts: Vec<_> = normalized.split(',').map(|s| s.trim()).collect();
279 parts.sort();
280 normalized = parts.join(", ");
281 }
282
283 normalized
284 }
285
286 pub fn normalize_properties(declarations: &[(String, String)]) -> Vec<(String, String)> {
288 let mut normalized = Vec::new();
289 let mut property_map: HashMap<String, String> = HashMap::new();
290
291 for (prop, value) in declarations {
292 if is_shorthand_property(prop) {
294 let expanded = expand_shorthand(prop, value);
295 for (exp_prop, exp_value) in expanded {
296 property_map.insert(exp_prop, exp_value);
297 }
298 } else {
299 property_map.insert(prop.clone(), value.clone());
300 }
301 }
302
303 let mut entries: Vec<_> = property_map.into_iter().collect();
305 entries.sort_by_key(|(k, _)| k.clone());
306
307 for (prop, value) in entries {
308 normalized.push((prop, normalize_css_value(&value)));
309 }
310
311 normalized
312 }
313}
314
315fn is_shorthand_property(property: &str) -> bool {
316 matches!(
317 property,
318 "margin"
319 | "padding"
320 | "border"
321 | "border-radius"
322 | "background"
323 | "font"
324 | "flex"
325 | "grid"
326 | "animation"
327 | "transition"
328 | "transform"
329 )
330}
331
332fn expand_shorthand(property: &str, value: &str) -> Vec<(String, String)> {
333 let parts: Vec<&str> = value.split_whitespace().collect();
334
335 match property {
336 "margin" | "padding" => {
337 let prefix = property;
338 match parts.len() {
339 1 => vec![
340 (format!("{}-top", prefix), parts[0].to_string()),
341 (format!("{}-right", prefix), parts[0].to_string()),
342 (format!("{}-bottom", prefix), parts[0].to_string()),
343 (format!("{}-left", prefix), parts[0].to_string()),
344 ],
345 2 => vec![
346 (format!("{}-top", prefix), parts[0].to_string()),
347 (format!("{}-right", prefix), parts[1].to_string()),
348 (format!("{}-bottom", prefix), parts[0].to_string()),
349 (format!("{}-left", prefix), parts[1].to_string()),
350 ],
351 3 => vec![
352 (format!("{}-top", prefix), parts[0].to_string()),
353 (format!("{}-right", prefix), parts[1].to_string()),
354 (format!("{}-bottom", prefix), parts[2].to_string()),
355 (format!("{}-left", prefix), parts[1].to_string()),
356 ],
357 4 => vec![
358 (format!("{}-top", prefix), parts[0].to_string()),
359 (format!("{}-right", prefix), parts[1].to_string()),
360 (format!("{}-bottom", prefix), parts[2].to_string()),
361 (format!("{}-left", prefix), parts[3].to_string()),
362 ],
363 _ => vec![(property.to_string(), value.to_string())],
364 }
365 }
366 "border" => {
367 vec![
369 ("border-width".to_string(), value.to_string()),
370 ("border-style".to_string(), value.to_string()),
371 ("border-color".to_string(), value.to_string()),
372 ]
373 }
374 _ => vec![(property.to_string(), value.to_string())],
375 }
376}
377
378fn normalize_css_value(value: &str) -> String {
379 let mut normalized = value.trim().to_lowercase();
380
381 if normalized.starts_with('#') {
383 if normalized.len() == 4 {
385 let r = &normalized[1..2];
386 let g = &normalized[2..3];
387 let b = &normalized[3..4];
388 normalized = format!("#{}{}{}{}{}{}", r, r, g, g, b, b);
389 }
390 }
391
392 if normalized == "0px"
394 || normalized == "0em"
395 || normalized == "0rem"
396 || normalized == "0%"
397 || normalized == "0pt"
398 {
399 normalized = "0".to_string();
400 }
401
402 normalized
403}
404
405pub struct CssBatchComparator {
407 comparator: CssStructureComparator,
408 fingerprint_cache: HashMap<String, Vec<Structure>>,
409}
410
411impl Default for CssBatchComparator {
412 fn default() -> Self {
413 Self::new()
414 }
415}
416
417impl CssBatchComparator {
418 pub fn new() -> Self {
419 Self { comparator: CssStructureComparator::new(), fingerprint_cache: HashMap::new() }
420 }
421
422 pub fn group_by_fingerprint(&mut self, rules: Vec<CssStructDef>) {
424 for rule in rules {
425 let structure = Structure::from(rule);
426 let fingerprint = self.comparator.comparator.generate_fingerprint(&structure);
427 self.fingerprint_cache.entry(fingerprint).or_default().push(structure);
428 }
429 }
430
431 pub fn find_similar_rules(&mut self, threshold: f64) -> Vec<(Structure, Structure, f64)> {
433 use crate::structure_comparator::should_compare_fingerprints;
434
435 let mut results = Vec::new();
436 let fingerprints: Vec<String> = self.fingerprint_cache.keys().cloned().collect();
437
438 for i in 0..fingerprints.len() {
439 for j in i..fingerprints.len() {
440 let fp1 = &fingerprints[i];
441 let fp2 = &fingerprints[j];
442
443 if !should_compare_fingerprints(fp1, fp2) {
444 continue;
445 }
446
447 let structures1 = &self.fingerprint_cache[fp1];
448 let structures2 = &self.fingerprint_cache[fp2];
449
450 for s1 in structures1 {
451 let start_idx = if i == j {
452 structures2
453 .iter()
454 .position(|s| std::ptr::eq(s, s1))
455 .map(|pos| pos + 1)
456 .unwrap_or(0)
457 } else {
458 0
459 };
460
461 for s2 in &structures2[start_idx..] {
462 let result = self.comparator.comparator.compare(s1, s2);
463
464 if result.overall_similarity >= threshold {
465 results.push((s1.clone(), s2.clone(), result.overall_similarity));
466 }
467 }
468 }
469 }
470 }
471
472 results.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap());
473 results
474 }
475}
476
477#[cfg(test)]
478mod tests {
479 use super::*;
480
481 #[test]
482 fn test_css_to_structure_conversion() {
483 let css_rule = CssStructDef {
484 selector: ".button".to_string(),
485 declarations: vec![
486 ("background-color".to_string(), "#007bff".to_string()),
487 ("color".to_string(), "white".to_string()),
488 ("padding".to_string(), "10px 20px".to_string()),
489 ("border-radius".to_string(), "4px".to_string()),
490 ],
491 file_path: "styles.css".to_string(),
492 start_line: 1,
493 end_line: 6,
494 media_query: None,
495 parent_selectors: vec![],
496 };
497
498 let structure = Structure::from(css_rule);
499
500 assert_eq!(structure.identifier.name, ".button");
501 assert_eq!(structure.identifier.kind, StructureKind::CssClass);
502 assert_eq!(structure.members.len(), 4);
503
504 let bg_color = structure.members.iter().find(|m| m.name == "background-color").unwrap();
506 assert_eq!(bg_color.value_type, "color");
507
508 let padding = structure.members.iter().find(|m| m.name == "padding").unwrap();
509 assert_eq!(padding.value_type, "value"); }
511
512 #[test]
513 fn test_css_comparison() {
514 let mut comparator = CssStructureComparator::new();
515
516 let rule1 = CssStructDef {
517 selector: ".btn-primary".to_string(),
518 declarations: vec![
519 ("background".to_string(), "#007bff".to_string()),
520 ("color".to_string(), "#fff".to_string()),
521 ("padding".to_string(), "8px 16px".to_string()),
522 ],
523 file_path: "buttons.css".to_string(),
524 start_line: 1,
525 end_line: 5,
526 media_query: None,
527 parent_selectors: vec![],
528 };
529
530 let rule2 = CssStructDef {
531 selector: ".button-primary".to_string(),
532 declarations: vec![
533 ("background-color".to_string(), "#007bff".to_string()),
534 ("color".to_string(), "white".to_string()),
535 ("padding".to_string(), "8px 16px".to_string()),
536 ],
537 file_path: "components.css".to_string(),
538 start_line: 10,
539 end_line: 14,
540 media_query: None,
541 parent_selectors: vec![],
542 };
543
544 let result = comparator.compare_rules(&rule1, &rule2);
545
546 assert!(result.overall_similarity > 0.7);
548 assert_eq!(result.member_matches.len(), 3); }
550
551 #[test]
552 fn test_selector_normalization() {
553 assert_eq!(
554 CssStructureComparator::normalize_selector(".class1 > .class2"),
555 ".class1>.class2"
556 );
557
558 assert_eq!(CssStructureComparator::normalize_selector("h1, h3, h2"), "h1, h2, h3");
559 }
560
561 #[test]
562 fn test_value_categorization() {
563 assert_eq!(categorize_css_value("#ff0000"), "color");
564 assert_eq!(categorize_css_value("rgb(255, 0, 0)"), "color");
565 assert_eq!(categorize_css_value("10px"), "length");
566 assert_eq!(categorize_css_value("2em"), "length");
567 assert_eq!(categorize_css_value("100%"), "length");
568 assert_eq!(categorize_css_value("0.5s"), "time");
569 assert_eq!(categorize_css_value("300ms"), "time");
570 assert_eq!(categorize_css_value("url(image.png)"), "url");
571 assert_eq!(categorize_css_value("bold"), "keyword");
572 assert_eq!(categorize_css_value("42"), "number");
573 }
574}