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::{
8    StyleDialect, SyntaxKind,
9    ident::{AuthoredPropertyTextV0, CanonicalCustomPropertyNameV0, PropertyNameV0},
10};
11use std::{collections::BTreeMap, fmt};
12
13#[cfg(test)]
14use crate::ParseResult;
15use crate::{
16    Token, containing_at_rule_header_name, matches_ignore_ascii_case, next_non_trivia_token,
17    previous_non_trivia_token, previous_non_trivia_token_index,
18};
19
20use super::StyleFactSink;
21
22#[derive(Debug, Clone)]
23pub struct ParsedVariableFact {
24    pub kind: ParsedVariableFactKind,
25    pub name: ParsedVariableFactNameV0,
26    pub property_key: Option<CanonicalCustomPropertyNameV0>,
27    pub range: TextRange,
28    /// For a `CustomPropertyReference` written as `var(--x, fallback)`, records that a
29    /// top-level fallback argument is present. The reference cannot be "missing" in any
30    /// observable way — the fallback guarantees a value — so the `missingCustomProperty`
31    /// lint must skip it. `false` for declarations and fallback-less references.
32    pub has_fallback: bool,
33    pub value_repr: Option<Box<str>>,
34    pub defaulted: bool,
35    pub is_top_level: bool,
36}
37
38#[derive(Clone)]
39pub enum ParsedVariableFactNameV0 {
40    NonProperty(String),
41    CustomProperty(AuthoredPropertyTextV0),
42}
43
44impl fmt::Debug for ParsedVariableFactNameV0 {
45    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
46        match self {
47            Self::NonProperty(name) => fmt::Debug::fmt(name, formatter),
48            Self::CustomProperty(name) => {
49                let mut rendered = String::new();
50                name.write_into(&mut rendered)?;
51                fmt::Debug::fmt(&rendered, formatter)
52            }
53        }
54    }
55}
56
57impl ParsedVariableFactNameV0 {
58    pub fn as_non_property(&self) -> Option<&str> {
59        match self {
60            Self::NonProperty(name) => Some(name),
61            Self::CustomProperty(_) => None,
62        }
63    }
64
65    pub fn as_custom_property(&self) -> Option<&AuthoredPropertyTextV0> {
66        match self {
67            Self::NonProperty(_) => None,
68            Self::CustomProperty(name) => Some(name),
69        }
70    }
71
72    fn identity(&self) -> ParsedVariableFactNameIdentityV0 {
73        match self {
74            Self::NonProperty(name) => ParsedVariableFactNameIdentityV0::NonProperty(name.clone()),
75            Self::CustomProperty(name) => {
76                ParsedVariableFactNameIdentityV0::CustomProperty(name.to_custom_key())
77            }
78        }
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
83enum ParsedVariableFactNameIdentityV0 {
84    NonProperty(String),
85    CustomProperty(CanonicalCustomPropertyNameV0),
86}
87
88impl PartialEq for ParsedVariableFactNameV0 {
89    fn eq(&self, other: &Self) -> bool {
90        self.identity() == other.identity()
91    }
92}
93
94impl Eq for ParsedVariableFactNameV0 {}
95
96impl PartialEq for ParsedVariableFact {
97    fn eq(&self, other: &Self) -> bool {
98        self.kind == other.kind
99            && self.name == other.name
100            && self.property_key == other.property_key
101            && self.range == other.range
102            && self.has_fallback == other.has_fallback
103            && self.value_repr == other.value_repr
104            && self.defaulted == other.defaulted
105            && self.is_top_level == other.is_top_level
106    }
107}
108
109impl Eq for ParsedVariableFact {}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
112pub enum ParsedVariableFactKind {
113    ScssDeclaration,
114    ScssReference,
115    LessDeclaration,
116    LessReference,
117    CustomPropertyDeclaration,
118    CustomPropertyReference,
119}
120
121#[cfg(test)]
122pub(crate) fn collect_variable_facts_from_cst(
123    text: &str,
124    parsed: &ParseResult,
125) -> Vec<ParsedVariableFact> {
126    let sink = StyleFactSink::from_cst(text, parsed);
127    collect_variable_facts_from_sink(&sink)
128}
129
130pub(crate) fn collect_variable_facts_from_sink(
131    sink: &StyleFactSink<'_>,
132) -> Vec<ParsedVariableFact> {
133    let mut variables = Vec::new();
134    let mut seen = std::collections::BTreeSet::new();
135    for node in sink.nodes().filter(|node| node.is_top_level) {
136        for fact in variable_facts_from_token_view(sink.node_tokens(node)) {
137            push_variable_fact(&mut variables, &mut seen, fact);
138        }
139    }
140    if !matches!(sink.dialect(), StyleDialect::Scss | StyleDialect::Sass)
141        || !variables
142            .iter()
143            .any(|fact| fact.kind == ParsedVariableFactKind::ScssDeclaration)
144    {
145        return variables;
146    }
147    let declaration_metadata = scss_variable_declaration_metadata_from_sink(sink);
148    for fact in &mut variables {
149        let key = (u32::from(fact.range.start()), u32::from(fact.range.end()));
150        if let Some(metadata) = declaration_metadata.get(&key) {
151            fact.value_repr = metadata.value_repr.clone();
152            fact.defaulted = metadata.defaulted;
153            fact.is_top_level = metadata.is_top_level;
154        }
155    }
156    variables
157}
158
159fn variable_facts_from_token_view(tokens: &[Token<'_>]) -> Vec<ParsedVariableFact> {
160    let mut variables = Vec::new();
161    for (index, token) in tokens.iter().enumerate() {
162        let kind = match token.kind {
163            SyntaxKind::ScssVariable => {
164                if scss_variable_token_is_declaration(tokens, index) {
165                    ParsedVariableFactKind::ScssDeclaration
166                } else {
167                    ParsedVariableFactKind::ScssReference
168                }
169            }
170            SyntaxKind::LessVariable => {
171                if next_non_trivia_token(tokens, index + 1)
172                    .is_some_and(|candidate| candidate.kind == SyntaxKind::Colon)
173                {
174                    ParsedVariableFactKind::LessDeclaration
175                } else {
176                    ParsedVariableFactKind::LessReference
177                }
178            }
179            SyntaxKind::CustomPropertyName => {
180                if previous_non_trivia_token(tokens, 0, index).is_some_and(|candidate| {
181                    matches!(candidate.kind, SyntaxKind::Ampersand | SyntaxKind::Dot)
182                }) {
183                    continue;
184                }
185                if let Some(at_rule_name) = containing_at_rule_header_name(tokens, index) {
186                    if at_rule_name == "@property" {
187                        ParsedVariableFactKind::CustomPropertyDeclaration
188                    } else {
189                        continue;
190                    }
191                } else if next_non_trivia_token(tokens, index + 1)
192                    .is_some_and(|candidate| candidate.kind == SyntaxKind::Colon)
193                {
194                    ParsedVariableFactKind::CustomPropertyDeclaration
195                } else {
196                    ParsedVariableFactKind::CustomPropertyReference
197                }
198            }
199            _ => continue,
200        };
201        let has_fallback = kind == ParsedVariableFactKind::CustomPropertyReference
202            && custom_property_reference_has_var_fallback(tokens, index);
203        let property_key = matches!(
204            kind,
205            ParsedVariableFactKind::CustomPropertyDeclaration
206                | ParsedVariableFactKind::CustomPropertyReference
207        )
208        .then(|| PropertyNameV0::canonical_custom_key(token.text));
209        let name = if property_key.is_some() {
210            ParsedVariableFactNameV0::CustomProperty(AuthoredPropertyTextV0::new(token.text))
211        } else {
212            ParsedVariableFactNameV0::NonProperty(token.text.to_string())
213        };
214        variables.push(ParsedVariableFact {
215            kind,
216            name,
217            property_key,
218            range: token.range,
219            has_fallback,
220            value_repr: None,
221            defaulted: false,
222            is_top_level: false,
223        });
224    }
225    variables
226}
227
228#[derive(Debug, Clone)]
229struct ScssVariableDeclarationMetadata {
230    value_repr: Option<Box<str>>,
231    defaulted: bool,
232    is_top_level: bool,
233}
234
235fn scss_variable_declaration_metadata_from_sink(
236    sink: &StyleFactSink<'_>,
237) -> BTreeMap<(u32, u32), ScssVariableDeclarationMetadata> {
238    sink.nodes()
239        .filter(|node| node.kind == SyntaxKind::ScssVariableDeclaration)
240        .filter_map(|node| {
241            let tokens = sink.node_tokens(node);
242            let variable_index = tokens
243                .iter()
244                .position(|token| token.kind == SyntaxKind::ScssVariable)?;
245            let colon_index = tokens
246                .iter()
247                .enumerate()
248                .skip(variable_index + 1)
249                .find_map(|(index, token)| (token.kind == SyntaxKind::Colon).then_some(index))?;
250            let (value_end, defaulted) =
251                scss_variable_value_end_and_default(tokens, colon_index + 1);
252            let value_repr = tokens[colon_index + 1..value_end]
253                .iter()
254                .map(|token| token.text)
255                .collect::<String>();
256            let variable = tokens[variable_index];
257            Some((
258                (
259                    u32::from(variable.range.start()),
260                    u32::from(variable.range.end()),
261                ),
262                ScssVariableDeclarationMetadata {
263                    value_repr: (!value_repr.trim().is_empty())
264                        .then(|| value_repr.trim().to_string().into_boxed_str()),
265                    defaulted,
266                    is_top_level: node.is_top_level,
267                },
268            ))
269        })
270        .collect()
271}
272
273fn scss_variable_value_end_and_default(tokens: &[Token<'_>], start: usize) -> (usize, bool) {
274    let mut end = tokens.len();
275    let mut defaulted = false;
276    for index in start..tokens.len() {
277        if matches!(
278            tokens[index].kind,
279            SyntaxKind::Semicolon | SyntaxKind::SassOptionalSemicolon
280        ) {
281            end = end.min(index);
282            break;
283        }
284        if tokens[index].kind != SyntaxKind::Delim || tokens[index].text != "!" {
285            continue;
286        }
287        let Some(flag) = next_non_trivia_token(tokens, index + 1) else {
288            continue;
289        };
290        if flag.kind == SyntaxKind::Ident
291            && matches_ignore_ascii_case(flag.text, &["default", "global"])
292        {
293            end = end.min(index);
294            defaulted |= matches_ignore_ascii_case(flag.text, &["default"]);
295        }
296    }
297    (end, defaulted)
298}
299
300fn push_variable_fact(
301    variables: &mut Vec<ParsedVariableFact>,
302    seen: &mut std::collections::BTreeSet<(
303        ParsedVariableFactKind,
304        ParsedVariableFactNameIdentityV0,
305        u32,
306        u32,
307        bool,
308    )>,
309    fact: ParsedVariableFact,
310) {
311    if seen.insert((
312        fact.kind,
313        fact.name.identity(),
314        u32::from(fact.range.start()),
315        u32::from(fact.range.end()),
316        fact.has_fallback,
317    )) {
318        variables.push(fact);
319    }
320}
321
322/// Detect a `var(--x, fallback)` fallback for the `CustomPropertyName` at `index`.
323///
324/// True iff the reference is the first argument of an enclosing `var(` call *and* a
325/// top-level comma follows it before that call's closing paren. Scoped per-`var()`: in
326/// `var(--a, var(--b))` only `--a` carries a fallback; the nested `--b` (no fallback of
327/// its own) is unaffected and stays a live `missingCustomProperty` candidate.
328fn custom_property_reference_has_var_fallback(tokens: &[Token<'_>], index: usize) -> bool {
329    // The reference must be the leading argument of a `var(` call: its immediate
330    // non-trivia predecessor is `(`, preceded by an identifier `var`.
331    let Some(open_index) = previous_non_trivia_token_index(tokens, index, 0) else {
332        return false;
333    };
334    if tokens[open_index].kind != SyntaxKind::LeftParen {
335        return false;
336    }
337    let Some(callee_index) = previous_non_trivia_token_index(tokens, open_index, 0) else {
338        return false;
339    };
340    if tokens[callee_index].kind != SyntaxKind::Ident
341        || !matches_ignore_ascii_case(tokens[callee_index].text, &["var"])
342    {
343        return false;
344    }
345    // Scan forward at this call's paren depth for a top-level comma before its close.
346    let mut depth = 0usize;
347    let mut cursor = open_index;
348    while cursor < tokens.len() {
349        match tokens[cursor].kind {
350            SyntaxKind::LeftParen => depth += 1,
351            SyntaxKind::RightParen => {
352                depth = depth.saturating_sub(1);
353                if depth == 0 {
354                    return false;
355                }
356            }
357            SyntaxKind::Comma if depth == 1 => return true,
358            _ => {}
359        }
360        cursor += 1;
361    }
362    false
363}
364
365pub(crate) fn scss_variable_token_is_declaration(tokens: &[Token<'_>], index: usize) -> bool {
366    if scss_loop_variable_token_is_binding(tokens, index) {
367        return true;
368    }
369    next_non_trivia_token(tokens, index + 1).is_some_and(|candidate| {
370        candidate.kind == SyntaxKind::Colon
371            || (matches!(candidate.kind, SyntaxKind::Comma | SyntaxKind::RightParen)
372                && containing_at_rule_header_name(tokens, index)
373                    .is_some_and(|name| matches_ignore_ascii_case(name, &["@mixin", "@function"])))
374    })
375}
376
377/// Positional guard for `@each` / `@for` loop bindings.
378///
379/// In `@each $k, $v in $map` the `$k`/`$v` are *bindings* (declarations), while
380/// the iterable `$map` after `in` is a *reference*. In `@for $i from $start
381/// through $end` the `$i` is a binding, while `$start`/`$end` after `from` are
382/// references. A `$var` is a binding iff it sits in the loop header *before* the
383/// top-level separator keyword (`in` for `@each`, `from` for `@for`). `@while` /
384/// `@if` headers introduce no bindings and stay reference-only.
385fn scss_loop_variable_token_is_binding(tokens: &[Token<'_>], index: usize) -> bool {
386    let Some(header_index) = containing_at_rule_header_index(tokens, index) else {
387        return false;
388    };
389    let separator = match () {
390        _ if matches_ignore_ascii_case(tokens[header_index].text, &["@each"]) => "in",
391        _ if matches_ignore_ascii_case(tokens[header_index].text, &["@for"]) => "from",
392        _ => return false,
393    };
394    // Scan the header from just after the at-keyword up to (but excluding) the
395    // variable token. If the top-level separator keyword has already appeared,
396    // the variable is part of the iterable/bounds expression -> reference.
397    let mut paren_depth = 0usize;
398    for token in &tokens[header_index + 1..index] {
399        match token.kind {
400            SyntaxKind::LeftParen => paren_depth += 1,
401            SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
402            SyntaxKind::Ident
403                if paren_depth == 0 && matches_ignore_ascii_case(token.text, &[separator]) =>
404            {
405                return false;
406            }
407            _ => {}
408        }
409    }
410    true
411}
412
413/// Like [`containing_at_rule_header_name`] but returns the index of the
414/// enclosing `@`-keyword token rather than its text.
415pub(crate) fn containing_at_rule_header_index(tokens: &[Token<'_>], index: usize) -> Option<usize> {
416    let mut current = index;
417    while current > 0 {
418        current -= 1;
419        let token = tokens.get(current)?;
420        if token.kind.is_trivia() {
421            continue;
422        }
423        if matches!(
424            token.kind,
425            SyntaxKind::Semicolon
426                | SyntaxKind::SassOptionalSemicolon
427                | SyntaxKind::LeftBrace
428                | SyntaxKind::RightBrace
429                | SyntaxKind::SassIndent
430                | SyntaxKind::SassDedent
431        ) {
432            return None;
433        }
434        if token.kind == SyntaxKind::AtKeyword {
435            return Some(current);
436        }
437    }
438    None
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444    use crate::{StyleDialect, parse};
445
446    #[test]
447    fn parsed_variable_fact_name_identity_uses_custom_property_keys() {
448        let name = |value: &str| {
449            ParsedVariableFactNameV0::CustomProperty(AuthoredPropertyTextV0::new(value))
450        };
451
452        assert_eq!(name(r"--f\6f o"), name("--foo"));
453        assert_ne!(name("--foo"), name("--FOO"));
454    }
455
456    #[test]
457    fn parsed_variable_fact_identity_uses_custom_property_keys() -> Result<(), String> {
458        let source = ":root { --foo: red; }";
459        let parsed = parse(source, StyleDialect::Css);
460        let decoded = collect_variable_facts_from_cst(source, &parsed)
461            .into_iter()
462            .find(|fact| fact.name.as_custom_property().is_some())
463            .ok_or_else(|| "custom-property declaration produces a variable fact".to_string())?;
464        let mut escaped = decoded.clone();
465        escaped.name =
466            ParsedVariableFactNameV0::CustomProperty(AuthoredPropertyTextV0::new(r"--f\6f o"));
467
468        assert_eq!(escaped, decoded);
469        Ok(())
470    }
471
472    #[test]
473    fn scss_declarations_expose_values_and_default_flags_from_cst() {
474        let source = "$theme: (primary: red, accent: blue) !default;\n.scope { $local: 2px; }";
475        let parsed = parse(source, StyleDialect::Scss);
476        let facts = collect_variable_facts_from_cst(source, &parsed);
477
478        let theme = facts.iter().find(|fact| {
479            fact.kind == ParsedVariableFactKind::ScssDeclaration
480                && fact.name.as_non_property() == Some("$theme")
481        });
482        assert!(theme.is_some(), "top-level variable declaration");
483        let Some(theme) = theme else {
484            return;
485        };
486        assert_eq!(
487            theme.value_repr.as_deref(),
488            Some("(primary: red, accent: blue)")
489        );
490        assert!(theme.defaulted);
491        assert!(theme.is_top_level);
492
493        let local = facts.iter().find(|fact| {
494            fact.kind == ParsedVariableFactKind::ScssDeclaration
495                && fact.name.as_non_property() == Some("$local")
496        });
497        assert!(local.is_some(), "local variable declaration");
498        let Some(local) = local else {
499            return;
500        };
501        assert_eq!(local.value_repr.as_deref(), Some("2px"));
502        assert!(!local.defaulted);
503        assert!(!local.is_top_level);
504    }
505}