Skip to main content

css_variable_lsp/
specificity.rs

1use once_cell::sync::Lazy;
2use regex::Regex;
3
4use crate::dom_tree::DomTree;
5use crate::types::{CssVariable, DOMNodeInfo};
6
7/// Memoized regex patterns for specificity calculation
8static PSEUDO_ELEMENT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"::[a-zA-Z-]+").unwrap());
9static ID_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"#[a-zA-Z0-9_-]+").unwrap());
10static CLASS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"\.[a-zA-Z0-9_-]+").unwrap());
11static ATTR_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r#"\[[^\]'"']*\]"#).unwrap());
12static NOT_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r":not\((?:[^()]|\([^)]*\))+\)").unwrap());
13static IS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r":is\((?:[^()]|\([^)]*\))+\)").unwrap());
14static WHERE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r":where\((?:[^()]|\([^)]*\))+\)").unwrap());
15static PSEUDO_CLASS_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r":[a-zA-Z-]+(\([^)]*\))?").unwrap());
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct Specificity {
19    pub ids: u32,
20    pub classes: u32,
21    pub elements: u32,
22}
23
24impl Specificity {
25    pub fn new(ids: u32, classes: u32, elements: u32) -> Self {
26        Self {
27            ids,
28            classes,
29            elements,
30        }
31    }
32}
33
34/// Extract the argument of a top-level pseudo-class call with parentheses.
35/// Uses a depth tracker to handle nested parentheses correctly.
36///
37/// # Arguments
38/// * `selector` - The CSS selector string
39/// * `prefix` - The prefix bytes to match (e.g., b":is(" or b":not(")
40///
41/// # Returns
42/// The content between the opening '(' and its matching closing ')', or None if not found.
43fn extract_pseudo_arg<'a>(selector: &'a str, prefix: &[u8]) -> Option<&'a str> {
44    let bytes = selector.as_bytes();
45    let len = bytes.len();
46    let prefix_len = prefix.len();
47
48    for i in 0..len {
49        if bytes[i] != b':' || i + prefix_len > len {
50            continue;
51        }
52        let mut match_prefix = true;
53        for j in 0..prefix_len {
54            if bytes[i + j] != prefix[j] {
55                match_prefix = false;
56                break;
57            }
58        }
59        if !match_prefix {
60            continue;
61        }
62
63        // Found prefix at position i. Now find matching ) at depth 1.
64        let mut depth = 1;
65        let mut pos = i + prefix_len;
66        while pos < len {
67            match bytes[pos] {
68                b'(' => {
69                    depth += 1;
70                }
71                b')' => {
72                    depth -= 1;
73                    if depth == 0 {
74                        return Some(&selector[i + prefix_len..pos]);
75                    }
76                }
77                _ => {}
78            }
79            pos += 1;
80        }
81        return None;
82    }
83    None
84}
85
86/// Extract the argument of a top-level :is() call.
87fn extract_is_arg(selector: &str) -> Option<&str> {
88    extract_pseudo_arg(selector, b":is(")
89}
90
91/// Extract the argument of a top-level :not() call.
92fn extract_not_arg(selector: &str) -> Option<&str> {
93    extract_pseudo_arg(selector, b":not(")
94}
95
96/// Calculate the specificity of a pseudo-class argument (:not, :is, :where).
97/// Per CSS spec: :not(.foo, #bar) → take max specificity across comma-separated arguments.
98fn specificity_of_pseudo_argument(arg: &str) -> Specificity {
99    let parts: Vec<&str> = arg
100        .split(',')
101        .map(|s| s.trim())
102        .filter(|s| !s.is_empty())
103        .collect();
104    if parts.is_empty() {
105        return Specificity::new(0, 0, 0);
106    }
107    let mut max_ids: u32 = 0;
108    let mut max_classes: u32 = 0;
109    let mut max_elements: u32 = 0;
110    for part in parts {
111        let spec = specificity_of_selector_part(part);
112        max_ids = max_ids.max(spec.ids);
113        max_classes = max_classes.max(spec.classes);
114        max_elements = max_elements.max(spec.elements);
115    }
116    Specificity::new(max_ids, max_classes, max_elements)
117}
118
119/// Calculate specificity for a single selector part (no comma-separated handling).
120/// Handles nested :not(), :is(), :where() by extracting and processing their arguments.
121fn specificity_of_selector_part(selector: &str) -> Specificity {
122    let selector = selector.trim();
123    if selector.is_empty() || selector == "*" {
124        return Specificity::new(0, 0, 0);
125    }
126    let mut working = selector.to_string();
127
128    let pseudo_elements = PSEUDO_ELEMENT_RE.find_iter(&working).count() as u32;
129    working = PSEUDO_ELEMENT_RE.replace_all(&working, "").to_string();
130
131    // Recursively handle nested :not() in this argument.
132    let not_arg = extract_not_arg(selector);
133    let (extra_ids, extra_classes, extra_elements) = if let Some(arg) = not_arg {
134        let spec = specificity_of_pseudo_argument(arg);
135        (spec.ids, spec.classes, spec.elements)
136    } else {
137        (0, 0, 0)
138    };
139    // Remove :not() blocks
140    working = NOT_RE.replace_all(&working, "").to_string();
141
142    // Recursively handle nested :is() in this argument.
143    // :is() adds specificity of its argument per CSS spec.
144    let is_arg = extract_is_arg(selector);
145    let (is_ids, is_classes, is_elements) = if let Some(arg) = is_arg {
146        let spec = specificity_of_pseudo_argument(arg);
147        (spec.ids, spec.classes, spec.elements)
148    } else {
149        (0, 0, 0)
150    };
151    // Remove :is() blocks so they aren't counted as pseudo-classes
152    working = IS_RE.replace_all(&working, "").to_string();
153
154    // :where() has zero specificity per CSS spec - it's transparent.
155    // Remove :where() blocks without processing their arguments.
156    working = WHERE_RE.replace_all(&working, "").to_string();
157
158    let ids = ID_RE.find_iter(&working).count() as u32;
159    working = ID_RE.replace_all(&working, "").to_string();
160
161    let classes = CLASS_RE.find_iter(&working).count() as u32;
162    working = CLASS_RE.replace_all(&working, "").to_string();
163
164    working = ATTR_RE.replace_all(&working, "").to_string();
165
166    working = PSEUDO_CLASS_RE.replace_all(&working, "").to_string();
167
168    let mut elements = pseudo_elements + extra_elements + is_elements;
169    working = working.replace(['>', '+', '~', ' '], " ");
170    for part in working.split_whitespace() {
171        if !part.is_empty() && part != "*" {
172            elements += 1;
173        }
174    }
175    Specificity::new(
176        ids + extra_ids + is_ids,
177        classes + extra_classes + is_classes,
178        elements,
179    )
180}
181
182pub fn calculate_specificity(selector: &str) -> Specificity {
183    let selector = selector.trim();
184    if selector.is_empty() || selector == "*" {
185        return Specificity::new(0, 0, 0);
186    }
187
188    let selectors: Vec<&str> = selector
189        .split(',')
190        .map(|s| s.trim())
191        .filter(|s| !s.is_empty())
192        .collect();
193    // Only split by comma for top-level selector lists (e.g., "div, .foo")
194    // Don't split for :not(), :is(), etc. with comma-separated arguments -
195    // those are handled separately by extract_not_arg and specificity_of_pseudo_argument
196    if selectors.len() > 1
197        && !selector.contains(":not(")
198        && !selector.contains(":is(")
199        && !selector.contains(":where(")
200    {
201        let mut best = Specificity::new(0, 0, 0);
202        for sel in selectors {
203            let spec = calculate_specificity(sel);
204            if compare_specificity(spec, best) > 0 {
205                best = spec;
206            }
207        }
208        return best;
209    }
210
211    let mut working = selector.to_string();
212
213    let pseudo_elements = PSEUDO_ELEMENT_RE.find_iter(&working).count() as u32;
214    working = PSEUDO_ELEMENT_RE.replace_all(&working, "").to_string();
215
216    // Per CSS spec: :not() adds specificity of its argument.
217    // Extract and add :not() specificity BEFORE counting IDs/classes in remaining selector.
218    let not_arg = extract_not_arg(selector);
219    let (not_ids, not_classes, not_elements) = if let Some(arg) = not_arg {
220        let spec = specificity_of_pseudo_argument(arg);
221        (spec.ids, spec.classes, spec.elements)
222    } else {
223        (0, 0, 0)
224    };
225    // Remove :not() blocks so they aren't double-counted by ID/class regexes
226    // Handles nested parens via (?:[^()]|\([^)]*\))+
227    working = NOT_RE.replace_all(&working, "").to_string();
228
229    // Per CSS spec: :is() adds specificity of its argument.
230    // Extract and add :is() specificity BEFORE counting IDs/classes in remaining selector.
231    // Only do this if :not() is NOT present at top level (handled by specificity_of_pseudo_argument).
232    let is_arg = if not_arg.is_none() {
233        extract_is_arg(selector)
234    } else {
235        None
236    };
237    let (is_ids, is_classes, is_elements) = if let Some(arg) = is_arg {
238        let spec = specificity_of_pseudo_argument(arg);
239        (spec.ids, spec.classes, spec.elements)
240    } else {
241        (0, 0, 0)
242    };
243    // Remove :is() blocks so they aren't double-counted
244    working = IS_RE.replace_all(&working, "").to_string();
245
246    // Per CSS spec: :where() has ZERO specificity - it's completely transparent
247    // Remove :where() blocks so they aren't double-counted
248    working = WHERE_RE.replace_all(&working, "").to_string();
249
250    let ids = ID_RE.find_iter(&working).count() as u32;
251    working = ID_RE.replace_all(&working, "").to_string();
252
253    let classes = CLASS_RE.find_iter(&working).count() as u32;
254    working = CLASS_RE.replace_all(&working, "").to_string();
255
256    let attrs = ATTR_RE.find_iter(&working).count() as u32;
257    working = ATTR_RE.replace_all(&working, "").to_string();
258
259    let pseudo_classes = PSEUDO_CLASS_RE.find_iter(&working).count() as u32;
260    working = PSEUDO_CLASS_RE.replace_all(&working, "").to_string();
261
262    let mut elements = pseudo_elements;
263    working = working.replace(['>', '+', '~', ' '], " ");
264    for part in working.split_whitespace() {
265        if !part.is_empty() && part != "*" {
266            elements += 1;
267        }
268    }
269
270    Specificity::new(
271        ids + not_ids + is_ids,
272        classes + attrs + pseudo_classes + not_classes + is_classes,
273        elements + not_elements + is_elements,
274    )
275}
276
277pub fn compare_specificity(a: Specificity, b: Specificity) -> i32 {
278    if a.ids != b.ids {
279        return if a.ids > b.ids { 1 } else { -1 };
280    }
281    if a.classes != b.classes {
282        return if a.classes > b.classes { 1 } else { -1 };
283    }
284    if a.elements != b.elements {
285        return if a.elements > b.elements { 1 } else { -1 };
286    }
287    0
288}
289
290pub fn format_specificity(spec: Specificity) -> String {
291    format!("({},{},{})", spec.ids, spec.classes, spec.elements)
292}
293
294pub fn matches_context(
295    definition_selector: &str,
296    usage_context: &str,
297    dom_tree: Option<&DomTree>,
298    dom_node: Option<&DOMNodeInfo>,
299) -> bool {
300    if let (Some(tree), Some(node)) = (dom_tree, dom_node) {
301        if let Some(node_index) = node.node_index {
302            return tree.matches_selector(node_index, definition_selector);
303        }
304    }
305
306    let def_trim = definition_selector.trim();
307    let usage_trim = usage_context.trim();
308
309    if def_trim == ":root" {
310        return true;
311    }
312
313    if def_trim == usage_trim {
314        return true;
315    }
316
317    let def_parts: Vec<&str> = def_trim.split(&[' ', '>', '+', '~'][..]).collect();
318    let usage_parts: Vec<&str> = usage_trim.split(&[' ', '>', '+', '~'][..]).collect();
319
320    def_parts.iter().any(|def_part| {
321        usage_parts.iter().any(|usage_part| {
322            !def_part.is_empty()
323                && !usage_part.is_empty()
324                && (usage_part.contains(def_part) || def_part.contains(usage_part))
325        })
326    })
327}
328
329/// Sort variables by cascade rules (winner first):
330/// !important > inline > specificity > source order (later wins)
331pub fn sort_by_cascade(variables: &mut [CssVariable]) {
332    variables.sort_by(|a, b| {
333        if a.important != b.important {
334            return if a.important {
335                std::cmp::Ordering::Less
336            } else {
337                std::cmp::Ordering::Greater
338            };
339        }
340
341        if a.inline != b.inline {
342            return if a.inline {
343                std::cmp::Ordering::Less
344            } else {
345                std::cmp::Ordering::Greater
346            };
347        }
348
349        let spec_a = calculate_specificity(&a.selector);
350        let spec_b = calculate_specificity(&b.selector);
351        let spec_cmp = compare_specificity(spec_a, spec_b);
352        if spec_cmp != 0 {
353            return if spec_cmp > 0 {
354                std::cmp::Ordering::Less
355            } else {
356                std::cmp::Ordering::Greater
357            };
358        }
359
360        b.source_position.cmp(&a.source_position)
361    });
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn basic_specificity_calculation() {
370        let root = calculate_specificity(":root");
371        assert_eq!(root.ids, 0);
372        assert_eq!(root.classes, 1);
373        assert_eq!(root.elements, 0);
374        assert_eq!(format_specificity(root), "(0,1,0)");
375    }
376
377    #[test]
378    fn element_selector_specificity() {
379        let div = calculate_specificity("div");
380        assert_eq!(div.ids, 0);
381        assert_eq!(div.classes, 0);
382        assert_eq!(div.elements, 1);
383    }
384
385    #[test]
386    fn class_selector_specificity() {
387        let class = calculate_specificity(".button");
388        assert_eq!(class.ids, 0);
389        assert_eq!(class.classes, 1);
390        assert_eq!(class.elements, 0);
391    }
392
393    #[test]
394    fn id_selector_specificity() {
395        let id = calculate_specificity("#main");
396        assert_eq!(id.ids, 1);
397        assert_eq!(id.classes, 0);
398        assert_eq!(id.elements, 0);
399    }
400
401    #[test]
402    fn complex_selector_specificity() {
403        let spec = calculate_specificity("div.button#submit");
404        assert_eq!(spec.ids, 1);
405        assert_eq!(spec.classes, 1);
406        assert_eq!(spec.elements, 1);
407    }
408
409    #[test]
410    fn specificity_comparison() {
411        let root = calculate_specificity(":root");
412        let div = calculate_specificity("div");
413        let cls = calculate_specificity(".button");
414        let id = calculate_specificity("#main");
415
416        assert_eq!(compare_specificity(div, root), -1);
417        assert_eq!(compare_specificity(cls, div), 1);
418        assert_eq!(compare_specificity(id, cls), 1);
419        assert_eq!(compare_specificity(root, root), 0);
420    }
421
422    #[test]
423    fn context_matching_basics() {
424        assert!(matches_context(":root", "div", None, None));
425        assert!(matches_context("div", "div", None, None));
426        assert!(matches_context(":root", ".button", None, None));
427    }
428
429    #[test]
430    fn not_with_class_specificity() {
431        // :not(.foo) has specificity of .foo → (0,1,0)
432        let spec = calculate_specificity(":not(.foo)");
433        assert_eq!(spec.ids, 0);
434        assert_eq!(spec.classes, 1);
435        assert_eq!(spec.elements, 0);
436    }
437
438    #[test]
439    fn not_with_id_specificity() {
440        // :not(#bar) has specificity of #bar → (1,0,0)
441        let spec = calculate_specificity(":not(#bar)");
442        assert_eq!(spec.ids, 1);
443        assert_eq!(spec.classes, 0);
444        assert_eq!(spec.elements, 0);
445    }
446
447    #[test]
448    fn not_with_multiple_args_specificity() {
449        // :not(.foo, #bar) → take max across args → (1,1,0)
450        let spec = calculate_specificity(":not(.foo, #bar)");
451        assert_eq!(spec.ids, 1);
452        assert_eq!(spec.classes, 1);
453        assert_eq!(spec.elements, 0);
454    }
455
456    #[test]
457    fn not_with_complex_selector_specificity() {
458        // :not(.foo.bar) → specificity of .foo.bar = 2 classes → (0,2,0)
459        let spec = calculate_specificity(":not(.foo.bar)");
460        assert_eq!(spec.ids, 0);
461        assert_eq!(spec.classes, 2);
462        assert_eq!(spec.elements, 0);
463    }
464
465    #[test]
466    fn not_with_element_specificity() {
467        // :not(div) → (0,0,1)
468        let spec = calculate_specificity(":not(div)");
469        assert_eq!(spec.ids, 0);
470        assert_eq!(spec.classes, 0);
471        assert_eq!(spec.elements, 1);
472    }
473
474    #[test]
475    fn not_preserves_other_selectors() {
476        // .foo:not(#bar) → (1,1,0)
477        let spec = calculate_specificity(".foo:not(#bar)");
478        assert_eq!(spec.ids, 1);
479        assert_eq!(spec.classes, 1);
480        assert_eq!(spec.elements, 0);
481    }
482
483    #[test]
484    fn not_nested_is_specificity() {
485        // :not(:is(.foo)) should return specificity of .foo → (0,1,0)
486        // The :not() takes specificity of its argument :is(.foo),
487        // which in turn takes specificity of .foo
488        let spec = calculate_specificity(":not(:is(.foo))");
489        assert_eq!(spec.ids, 0);
490        assert_eq!(spec.classes, 1);
491        assert_eq!(spec.elements, 0);
492    }
493
494    #[test]
495    fn where_zero_specificity() {
496        // :where() has zero specificity per CSS spec - it's transparent
497        let spec = calculate_specificity(":where(.foo)");
498        assert_eq!(spec.ids, 0);
499        assert_eq!(spec.classes, 0);
500        assert_eq!(spec.elements, 0);
501    }
502
503    #[test]
504    fn where_nested_in_not() {
505        // :not(:where(.foo)) should return zero specificity from :where()
506        let spec = calculate_specificity(":not(:where(.foo))");
507        assert_eq!(spec.ids, 0);
508        assert_eq!(spec.classes, 0);
509        assert_eq!(spec.elements, 0);
510    }
511
512    #[test]
513    fn is_with_class_specificity() {
514        // :is(.foo) should return specificity of .foo → (0,1,0)
515        let spec = calculate_specificity(":is(.foo)");
516        assert_eq!(spec.ids, 0);
517        assert_eq!(spec.classes, 1);
518        assert_eq!(spec.elements, 0);
519    }
520
521    #[test]
522    fn is_with_id_specificity() {
523        // :is(#bar) should return specificity of #bar → (1,0,0)
524        let spec = calculate_specificity(":is(#bar)");
525        assert_eq!(spec.ids, 1);
526        assert_eq!(spec.classes, 0);
527        assert_eq!(spec.elements, 0);
528    }
529
530    #[test]
531    fn is_with_multiple_args_specificity() {
532        // :is(.foo, #bar) → take max across args → (1,1,0)
533        let spec = calculate_specificity(":is(.foo, #bar)");
534        assert_eq!(spec.ids, 1);
535        assert_eq!(spec.classes, 1);
536        assert_eq!(spec.elements, 0);
537    }
538}