Skip to main content

similarity_core/
css_structure_adapter.rs

1use crate::structure_comparator::{
2    ComparisonOptions, SourceLocation, Structure, StructureComparator, StructureComparisonResult,
3    StructureIdentifier, StructureKind, StructureMember, StructureMetadata,
4};
5use std::collections::HashMap;
6
7/// CSS rule definition for structure comparison
8#[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
19/// CSS構造を一般構造に変換
20impl 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        // CSSプロパティをメンバーとして追加
31        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        // メディアクエリがあれば特殊メンバーとして追加
41        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        // 親セレクタがあれば特殊メンバーとして追加
51        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
81/// CSS値をカテゴライズ(型として扱う)
82fn categorize_css_value(value: &str) -> String {
83    let value = value.trim();
84    let is_single_token = !value.chars().any(char::is_whitespace);
85
86    // Color values
87    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    // Length values
98    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    // Time values
113    if value.ends_with("s") || value.ends_with("ms") {
114        return "time".to_string();
115    }
116
117    // Font values
118    if is_font_family(value) {
119        return "font-family".to_string();
120    }
121
122    // Number values
123    if value.parse::<f64>().is_ok() {
124        return "number".to_string();
125    }
126
127    // URL values
128    if value.starts_with("url(") {
129        return "url".to_string();
130    }
131
132    // Keyword values
133    if is_css_keyword(value) {
134        return "keyword".to_string();
135    }
136
137    // Default
138    "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
223/// CSS用の比較エンジン
224pub 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,      // セレクタの重要度を高める
238            structure_weight: 0.6, // プロパティの重要度
239            threshold: 0.7,
240            fuzzy_matching: true, // CSSでは類似セレクタも検出したい
241            ignore_order: true,   // CSSプロパティの順序は無視
242            ..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    /// CSSルールを比較
253    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    /// セレクタの正規化(比較用)
264    pub fn normalize_selector(selector: &str) -> String {
265        // Remove whitespace variations
266        let mut normalized = selector.trim().to_string();
267
268        // Normalize multiple spaces to single space
269        while normalized.contains("  ") {
270            normalized = normalized.replace("  ", " ");
271        }
272
273        // Normalize combinators
274        normalized = normalized.replace(" > ", ">").replace(" + ", "+").replace(" ~ ", "~");
275
276        // Sort comma-separated selectors
277        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    /// プロパティを正規化して比較しやすくする
287    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            // ショートハンドプロパティを展開
293            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        // ソートして一貫性を保つ
304        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            // 簡略化: border: 1px solid red -> border-width, border-style, border-color
368            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    // Normalize hex colors to lowercase
382    if normalized.starts_with('#') {
383        // Convert 3-digit hex to 6-digit
384        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    // Normalize 0 values
393    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
405/// 複数のCSSルールを効率的に比較
406pub 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    /// CSSルールをフィンガープリントでグループ化
423    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    /// 類似CSSルールを検出
432    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        // Check value categorization
505        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"); // Complex value
510    }
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        // Should have high similarity (similar selectors and fuzzy-matched properties)
547        assert!(result.overall_similarity > 0.7);
548        assert_eq!(result.member_matches.len(), 3); // background/background-color, color, padding
549    }
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}