Skip to main content

css_variable_lsp/
specificity.rs

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