Skip to main content

omena_parser/facts/
variables.rs

1//! Parser facts for Sass and CSS variable-like declarations and references.
2//!
3//! The collector distinguishes declaration/reference positions at token level
4//! so later layers can resolve scope and module visibility explicitly.
5
6use cstree::text::TextRange;
7use omena_syntax::{StyleDialect, SyntaxKind};
8use std::collections::BTreeMap;
9
10use crate::{
11    ParseResult, Token, containing_at_rule_header_name, matches_ignore_ascii_case,
12    next_non_trivia_token, previous_non_trivia_token, previous_non_trivia_token_index,
13};
14
15use super::{syntax_node_is_top_level, tokens_from_syntax_node};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct ParsedVariableFact {
19    pub kind: ParsedVariableFactKind,
20    pub name: String,
21    pub range: TextRange,
22    /// For a `CustomPropertyReference` written as `var(--x, fallback)`, records that a
23    /// top-level fallback argument is present. The reference cannot be "missing" in any
24    /// observable way — the fallback guarantees a value — so the `missingCustomProperty`
25    /// lint must skip it. `false` for declarations and fallback-less references.
26    pub has_fallback: bool,
27    pub value_repr: Option<Box<str>>,
28    pub defaulted: bool,
29    pub is_top_level: bool,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
33pub enum ParsedVariableFactKind {
34    ScssDeclaration,
35    ScssReference,
36    LessDeclaration,
37    LessReference,
38    CustomPropertyDeclaration,
39    CustomPropertyReference,
40}
41
42pub(crate) fn collect_variable_facts_from_cst(
43    text: &str,
44    parsed: &ParseResult,
45) -> Vec<ParsedVariableFact> {
46    let mut variables = Vec::new();
47    let mut seen = std::collections::BTreeSet::new();
48    for tokens in variable_fact_statement_tokens_from_cst(text, parsed) {
49        for fact in variable_facts_from_token_view(&tokens) {
50            push_variable_fact(&mut variables, &mut seen, fact);
51        }
52    }
53    if !matches!(parsed.dialect(), StyleDialect::Scss | StyleDialect::Sass)
54        || !variables
55            .iter()
56            .any(|fact| fact.kind == ParsedVariableFactKind::ScssDeclaration)
57    {
58        return variables;
59    }
60    let declaration_metadata = scss_variable_declaration_metadata_from_cst(text, parsed);
61    for fact in &mut variables {
62        let key = (u32::from(fact.range.start()), u32::from(fact.range.end()));
63        if let Some(metadata) = declaration_metadata.get(&key) {
64            fact.value_repr = metadata.value_repr.clone();
65            fact.defaulted = metadata.defaulted;
66            fact.is_top_level = metadata.is_top_level;
67        }
68    }
69    variables
70}
71
72fn variable_fact_statement_tokens_from_cst<'text>(
73    text: &'text str,
74    parsed: &ParseResult,
75) -> Vec<Vec<Token<'text>>> {
76    parsed
77        .syntax()
78        .children()
79        .map(|node| tokens_from_syntax_node(text, parsed, node))
80        .collect()
81}
82
83fn variable_facts_from_token_view(tokens: &[Token<'_>]) -> Vec<ParsedVariableFact> {
84    let mut variables = Vec::new();
85    for (index, token) in tokens.iter().enumerate() {
86        let kind = match token.kind {
87            SyntaxKind::ScssVariable => {
88                if scss_variable_token_is_declaration(tokens, index) {
89                    ParsedVariableFactKind::ScssDeclaration
90                } else {
91                    ParsedVariableFactKind::ScssReference
92                }
93            }
94            SyntaxKind::LessVariable => {
95                if next_non_trivia_token(tokens, index + 1)
96                    .is_some_and(|candidate| candidate.kind == SyntaxKind::Colon)
97                {
98                    ParsedVariableFactKind::LessDeclaration
99                } else {
100                    ParsedVariableFactKind::LessReference
101                }
102            }
103            SyntaxKind::CustomPropertyName => {
104                if previous_non_trivia_token(tokens, 0, index).is_some_and(|candidate| {
105                    matches!(candidate.kind, SyntaxKind::Ampersand | SyntaxKind::Dot)
106                }) {
107                    continue;
108                }
109                if let Some(at_rule_name) = containing_at_rule_header_name(tokens, index) {
110                    if at_rule_name == "@property" {
111                        ParsedVariableFactKind::CustomPropertyDeclaration
112                    } else {
113                        continue;
114                    }
115                } else if next_non_trivia_token(tokens, index + 1)
116                    .is_some_and(|candidate| candidate.kind == SyntaxKind::Colon)
117                {
118                    ParsedVariableFactKind::CustomPropertyDeclaration
119                } else {
120                    ParsedVariableFactKind::CustomPropertyReference
121                }
122            }
123            _ => continue,
124        };
125        let has_fallback = kind == ParsedVariableFactKind::CustomPropertyReference
126            && custom_property_reference_has_var_fallback(tokens, index);
127        variables.push(ParsedVariableFact {
128            kind,
129            name: token.text.to_string(),
130            range: token.range,
131            has_fallback,
132            value_repr: None,
133            defaulted: false,
134            is_top_level: false,
135        });
136    }
137    variables
138}
139
140#[derive(Debug, Clone)]
141struct ScssVariableDeclarationMetadata {
142    value_repr: Option<Box<str>>,
143    defaulted: bool,
144    is_top_level: bool,
145}
146
147fn scss_variable_declaration_metadata_from_cst(
148    text: &str,
149    parsed: &ParseResult,
150) -> BTreeMap<(u32, u32), ScssVariableDeclarationMetadata> {
151    parsed
152        .syntax()
153        .descendants()
154        .filter(|node| node.kind() == SyntaxKind::ScssVariableDeclaration)
155        .filter_map(|node| {
156            let tokens = tokens_from_syntax_node(text, parsed, node);
157            let variable_index = tokens
158                .iter()
159                .position(|token| token.kind == SyntaxKind::ScssVariable)?;
160            let colon_index = tokens
161                .iter()
162                .enumerate()
163                .skip(variable_index + 1)
164                .find_map(|(index, token)| (token.kind == SyntaxKind::Colon).then_some(index))?;
165            let (value_end, defaulted) =
166                scss_variable_value_end_and_default(&tokens, colon_index + 1);
167            let value_repr = tokens[colon_index + 1..value_end]
168                .iter()
169                .map(|token| token.text)
170                .collect::<String>();
171            let variable = tokens[variable_index];
172            Some((
173                (
174                    u32::from(variable.range.start()),
175                    u32::from(variable.range.end()),
176                ),
177                ScssVariableDeclarationMetadata {
178                    value_repr: (!value_repr.trim().is_empty())
179                        .then(|| value_repr.trim().to_string().into_boxed_str()),
180                    defaulted,
181                    is_top_level: syntax_node_is_top_level(node),
182                },
183            ))
184        })
185        .collect()
186}
187
188fn scss_variable_value_end_and_default(tokens: &[Token<'_>], start: usize) -> (usize, bool) {
189    let mut end = tokens.len();
190    let mut defaulted = false;
191    for index in start..tokens.len() {
192        if matches!(
193            tokens[index].kind,
194            SyntaxKind::Semicolon | SyntaxKind::SassOptionalSemicolon
195        ) {
196            end = end.min(index);
197            break;
198        }
199        if tokens[index].kind != SyntaxKind::Delim || tokens[index].text != "!" {
200            continue;
201        }
202        let Some(flag) = next_non_trivia_token(tokens, index + 1) else {
203            continue;
204        };
205        if flag.kind == SyntaxKind::Ident
206            && matches_ignore_ascii_case(flag.text, &["default", "global"])
207        {
208            end = end.min(index);
209            defaulted |= matches_ignore_ascii_case(flag.text, &["default"]);
210        }
211    }
212    (end, defaulted)
213}
214
215fn push_variable_fact(
216    variables: &mut Vec<ParsedVariableFact>,
217    seen: &mut std::collections::BTreeSet<(ParsedVariableFactKind, String, u32, u32, bool)>,
218    fact: ParsedVariableFact,
219) {
220    if seen.insert((
221        fact.kind,
222        fact.name.clone(),
223        u32::from(fact.range.start()),
224        u32::from(fact.range.end()),
225        fact.has_fallback,
226    )) {
227        variables.push(fact);
228    }
229}
230
231/// Detect a `var(--x, fallback)` fallback for the `CustomPropertyName` at `index`.
232///
233/// True iff the reference is the first argument of an enclosing `var(` call *and* a
234/// top-level comma follows it before that call's closing paren. Scoped per-`var()`: in
235/// `var(--a, var(--b))` only `--a` carries a fallback; the nested `--b` (no fallback of
236/// its own) is unaffected and stays a live `missingCustomProperty` candidate.
237fn custom_property_reference_has_var_fallback(tokens: &[Token<'_>], index: usize) -> bool {
238    // The reference must be the leading argument of a `var(` call: its immediate
239    // non-trivia predecessor is `(`, preceded by an identifier `var`.
240    let Some(open_index) = previous_non_trivia_token_index(tokens, index, 0) else {
241        return false;
242    };
243    if tokens[open_index].kind != SyntaxKind::LeftParen {
244        return false;
245    }
246    let Some(callee_index) = previous_non_trivia_token_index(tokens, open_index, 0) else {
247        return false;
248    };
249    if tokens[callee_index].kind != SyntaxKind::Ident
250        || !matches_ignore_ascii_case(tokens[callee_index].text, &["var"])
251    {
252        return false;
253    }
254    // Scan forward at this call's paren depth for a top-level comma before its close.
255    let mut depth = 0usize;
256    let mut cursor = open_index;
257    while cursor < tokens.len() {
258        match tokens[cursor].kind {
259            SyntaxKind::LeftParen => depth += 1,
260            SyntaxKind::RightParen => {
261                depth = depth.saturating_sub(1);
262                if depth == 0 {
263                    return false;
264                }
265            }
266            SyntaxKind::Comma if depth == 1 => return true,
267            _ => {}
268        }
269        cursor += 1;
270    }
271    false
272}
273
274pub(crate) fn scss_variable_token_is_declaration(tokens: &[Token<'_>], index: usize) -> bool {
275    if scss_loop_variable_token_is_binding(tokens, index) {
276        return true;
277    }
278    next_non_trivia_token(tokens, index + 1).is_some_and(|candidate| {
279        candidate.kind == SyntaxKind::Colon
280            || (matches!(candidate.kind, SyntaxKind::Comma | SyntaxKind::RightParen)
281                && containing_at_rule_header_name(tokens, index)
282                    .is_some_and(|name| matches_ignore_ascii_case(name, &["@mixin", "@function"])))
283    })
284}
285
286/// Positional guard for `@each` / `@for` loop bindings.
287///
288/// In `@each $k, $v in $map` the `$k`/`$v` are *bindings* (declarations), while
289/// the iterable `$map` after `in` is a *reference*. In `@for $i from $start
290/// through $end` the `$i` is a binding, while `$start`/`$end` after `from` are
291/// references. A `$var` is a binding iff it sits in the loop header *before* the
292/// top-level separator keyword (`in` for `@each`, `from` for `@for`). `@while` /
293/// `@if` headers introduce no bindings and stay reference-only.
294fn scss_loop_variable_token_is_binding(tokens: &[Token<'_>], index: usize) -> bool {
295    let Some(header_index) = containing_at_rule_header_index(tokens, index) else {
296        return false;
297    };
298    let separator = match () {
299        _ if matches_ignore_ascii_case(tokens[header_index].text, &["@each"]) => "in",
300        _ if matches_ignore_ascii_case(tokens[header_index].text, &["@for"]) => "from",
301        _ => return false,
302    };
303    // Scan the header from just after the at-keyword up to (but excluding) the
304    // variable token. If the top-level separator keyword has already appeared,
305    // the variable is part of the iterable/bounds expression -> reference.
306    let mut paren_depth = 0usize;
307    for token in &tokens[header_index + 1..index] {
308        match token.kind {
309            SyntaxKind::LeftParen => paren_depth += 1,
310            SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
311            SyntaxKind::Ident
312                if paren_depth == 0 && matches_ignore_ascii_case(token.text, &[separator]) =>
313            {
314                return false;
315            }
316            _ => {}
317        }
318    }
319    true
320}
321
322/// Like [`containing_at_rule_header_name`] but returns the index of the
323/// enclosing `@`-keyword token rather than its text.
324pub(crate) fn containing_at_rule_header_index(tokens: &[Token<'_>], index: usize) -> Option<usize> {
325    let mut current = index;
326    while current > 0 {
327        current -= 1;
328        let token = tokens.get(current)?;
329        if token.kind.is_trivia() {
330            continue;
331        }
332        if matches!(
333            token.kind,
334            SyntaxKind::Semicolon
335                | SyntaxKind::SassOptionalSemicolon
336                | SyntaxKind::LeftBrace
337                | SyntaxKind::RightBrace
338                | SyntaxKind::SassIndent
339                | SyntaxKind::SassDedent
340        ) {
341            return None;
342        }
343        if token.kind == SyntaxKind::AtKeyword {
344            return Some(current);
345        }
346    }
347    None
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::{StyleDialect, parse};
354
355    #[test]
356    fn scss_declarations_expose_values_and_default_flags_from_cst() {
357        let source = "$theme: (primary: red, accent: blue) !default;\n.scope { $local: 2px; }";
358        let parsed = parse(source, StyleDialect::Scss);
359        let facts = collect_variable_facts_from_cst(source, &parsed);
360
361        let theme = facts.iter().find(|fact| {
362            fact.kind == ParsedVariableFactKind::ScssDeclaration && fact.name == "$theme"
363        });
364        assert!(theme.is_some(), "top-level variable declaration");
365        let Some(theme) = theme else {
366            return;
367        };
368        assert_eq!(
369            theme.value_repr.as_deref(),
370            Some("(primary: red, accent: blue)")
371        );
372        assert!(theme.defaulted);
373        assert!(theme.is_top_level);
374
375        let local = facts.iter().find(|fact| {
376            fact.kind == ParsedVariableFactKind::ScssDeclaration && fact.name == "$local"
377        });
378        assert!(local.is_some(), "local variable declaration");
379        let Some(local) = local else {
380            return;
381        };
382        assert_eq!(local.value_repr.as_deref(), Some("2px"));
383        assert!(!local.defaulted);
384        assert!(!local.is_top_level);
385    }
386}