Skip to main content

omena_parser/facts/
animations.rs

1//! Parser facts for animation and keyframes references.
2//!
3//! These facts stay syntax-derived so diagnostics can consume animation names
4//! without depending on later semantic graph construction.
5
6use cstree::text::TextRange;
7use omena_syntax::SyntaxKind;
8use std::collections::BTreeSet;
9
10use crate::{ParseResult, Token, matches_ignore_ascii_case, next_non_trivia_token_index_until};
11
12use super::tokens_from_syntax_node;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ParsedAnimationFact {
16    pub kind: ParsedAnimationFactKind,
17    pub name: String,
18    pub range: TextRange,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
22pub enum ParsedAnimationFactKind {
23    KeyframesDeclaration,
24    AnimationNameReference,
25}
26
27pub(crate) fn collect_animation_facts_from_cst(
28    text: &str,
29    parsed: &ParseResult,
30) -> Vec<ParsedAnimationFact> {
31    let mut animations = Vec::new();
32    let mut seen = BTreeSet::new();
33    for tokens in animation_fact_tokens_from_cst(text, parsed) {
34        collect_animation_facts_from_syntax_tokens(&tokens, &mut animations, &mut seen);
35    }
36    animations
37}
38
39fn collect_animation_facts_from_syntax_tokens(
40    tokens: &[Token<'_>],
41    animations: &mut Vec<ParsedAnimationFact>,
42    seen: &mut BTreeSet<(ParsedAnimationFactKind, String, u32, u32)>,
43) {
44    for (index, token) in tokens.iter().enumerate() {
45        if token.kind == SyntaxKind::AtKeyword && at_keyword_is_keyframes_rule(token.text) {
46            if let Some(name_index) =
47                next_non_trivia_token_index_until(tokens, index + 1, tokens.len())
48                && let Some(name) = animation_name_from_token(tokens[name_index])
49            {
50                push_animation_fact(
51                    animations,
52                    seen,
53                    ParsedAnimationFactKind::KeyframesDeclaration,
54                    name,
55                    tokens[name_index].range,
56                );
57            }
58            continue;
59        }
60
61        if token.kind == SyntaxKind::Ident
62            && matches_ignore_ascii_case(token.text, &["animation-name"])
63            && let Some(colon_index) =
64                next_non_trivia_token_index_until(tokens, index + 1, tokens.len())
65            && tokens[colon_index].kind == SyntaxKind::Colon
66        {
67            collect_animation_name_references_until(tokens, colon_index + 1, animations, seen);
68        }
69
70        if token.kind == SyntaxKind::Ident
71            && matches_ignore_ascii_case(token.text, &["animation"])
72            && let Some(colon_index) =
73                next_non_trivia_token_index_until(tokens, index + 1, tokens.len())
74            && tokens[colon_index].kind == SyntaxKind::Colon
75        {
76            collect_animation_shorthand_references_until(tokens, colon_index + 1, animations, seen);
77        }
78    }
79}
80
81fn animation_fact_tokens_from_cst<'text>(
82    text: &'text str,
83    parsed: &ParseResult,
84) -> Vec<Vec<Token<'text>>> {
85    parsed
86        .syntax()
87        .descendants()
88        .filter(|node| {
89            matches!(
90                node.kind(),
91                SyntaxKind::KeyframesRule | SyntaxKind::Declaration
92            )
93        })
94        .map(|node| tokens_from_syntax_node(text, parsed, node))
95        .collect()
96}
97
98fn collect_animation_name_references_until(
99    tokens: &[Token<'_>],
100    start: usize,
101    animations: &mut Vec<ParsedAnimationFact>,
102    seen: &mut BTreeSet<(ParsedAnimationFactKind, String, u32, u32)>,
103) {
104    let mut index = start;
105    let mut paren_depth = 0usize;
106    let mut bracket_depth = 0usize;
107    while index < tokens.len() {
108        match tokens[index].kind {
109            SyntaxKind::LeftParen => paren_depth += 1,
110            SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
111            SyntaxKind::LeftBracket => bracket_depth += 1,
112            SyntaxKind::RightBracket => bracket_depth = bracket_depth.saturating_sub(1),
113            SyntaxKind::Semicolon
114            | SyntaxKind::SassOptionalSemicolon
115            | SyntaxKind::RightBrace
116            | SyntaxKind::SassDedent
117                if paren_depth == 0 && bracket_depth == 0 =>
118            {
119                break;
120            }
121            _ => {}
122        }
123
124        if paren_depth == 0
125            && bracket_depth == 0
126            && !animation_name_token_is_interpolation_adjacent(tokens, index)
127            && let Some(name) = animation_name_from_token(tokens[index])
128        {
129            push_animation_fact(
130                animations,
131                seen,
132                ParsedAnimationFactKind::AnimationNameReference,
133                name,
134                tokens[index].range,
135            );
136        }
137        index += 1;
138    }
139}
140
141fn collect_animation_shorthand_references_until(
142    tokens: &[Token<'_>],
143    start: usize,
144    animations: &mut Vec<ParsedAnimationFact>,
145    seen: &mut BTreeSet<(ParsedAnimationFactKind, String, u32, u32)>,
146) {
147    let mut index = start;
148    let mut paren_depth = 0usize;
149    let mut bracket_depth = 0usize;
150    while index < tokens.len() {
151        match tokens[index].kind {
152            SyntaxKind::LeftParen => paren_depth += 1,
153            SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
154            SyntaxKind::LeftBracket => bracket_depth += 1,
155            SyntaxKind::RightBracket => bracket_depth = bracket_depth.saturating_sub(1),
156            SyntaxKind::Semicolon
157            | SyntaxKind::SassOptionalSemicolon
158            | SyntaxKind::RightBrace
159            | SyntaxKind::SassDedent
160                if paren_depth == 0 && bracket_depth == 0 =>
161            {
162                break;
163            }
164            _ => {}
165        }
166
167        if paren_depth == 0
168            && bracket_depth == 0
169            && animation_shorthand_token_can_be_name(tokens, index)
170            && let Some(name) = animation_name_from_token(tokens[index])
171        {
172            push_animation_fact(
173                animations,
174                seen,
175                ParsedAnimationFactKind::AnimationNameReference,
176                name,
177                tokens[index].range,
178            );
179        }
180        index += 1;
181    }
182}
183
184fn animation_shorthand_token_can_be_name(tokens: &[Token<'_>], index: usize) -> bool {
185    let token = tokens[index];
186    if token.kind == SyntaxKind::String {
187        return true;
188    }
189    if token.kind != SyntaxKind::Ident {
190        return false;
191    }
192    // A literal fragment that is *immediately* adjacent to an interpolation boundary is part
193    // of a statically-unknown name (`#{$dur}s` unit suffix, `#{$p}-spin` / `spin-#{$p}`
194    // interpolated keyframes name), not a standalone animation name. Reject it so neither the
195    // unit nor the literal fragment is misread as a missing `@keyframes` reference.
196    if animation_name_token_is_interpolation_adjacent(tokens, index) {
197        return false;
198    }
199    // Standalone CSS time-unit idents (`s` / `ms`) are durations, never animation names.
200    if animation_shorthand_ident_is_time_unit(token.text) {
201        return false;
202    }
203    if let Some(next_index) = next_non_trivia_token_index_until(tokens, index + 1, tokens.len())
204        && tokens[next_index].kind == SyntaxKind::LeftParen
205    {
206        return false;
207    }
208    !animation_shorthand_ident_is_non_name(token.text)
209}
210
211fn animation_shorthand_ident_is_time_unit(name: &str) -> bool {
212    matches_ignore_ascii_case(name, &["s", "ms"])
213}
214
215/// An ident is part of an interpolated (statically-unknown) animation name when it is
216/// *immediately* adjacent to an interpolation boundary — `#{$p}-spin` (post-interpolation
217/// literal fragment) or `spin-#{$p}` (pre-interpolation literal fragment). The post-`#{...}`
218/// text is the trailing fragment of a dynamic name, not a real keyframes reference, so it
219/// must not be flagged as `missingKeyframes`.
220///
221/// Adjacency is checked against the *immediate* neighbor token (no trivia skipping): a
222/// fully-static name separated from an interpolation by whitespace (`#{$p} spin`, a real
223/// space-delimited keyframes reference) is NOT suppressed.
224fn animation_name_token_is_interpolation_adjacent(tokens: &[Token<'_>], index: usize) -> bool {
225    if index > 0
226        && matches!(
227            tokens[index - 1].kind,
228            SyntaxKind::ScssInterpolationEnd | SyntaxKind::LessInterpolationEnd
229        )
230    {
231        return true;
232    }
233    if let Some(next) = tokens.get(index + 1)
234        && matches!(
235            next.kind,
236            SyntaxKind::ScssInterpolationStart | SyntaxKind::LessInterpolationStart
237        )
238    {
239        return true;
240    }
241    false
242}
243
244fn animation_shorthand_ident_is_non_name(name: &str) -> bool {
245    matches_ignore_ascii_case(
246        name,
247        &[
248            "ease",
249            "ease-in",
250            "ease-out",
251            "ease-in-out",
252            "linear",
253            "step-start",
254            "step-end",
255            "infinite",
256            "normal",
257            "reverse",
258            "alternate",
259            "alternate-reverse",
260            "running",
261            "paused",
262            "forwards",
263            "backwards",
264            "both",
265            "replace",
266            "add",
267            "accumulate",
268            "auto",
269        ],
270    )
271}
272
273fn push_animation_fact(
274    animations: &mut Vec<ParsedAnimationFact>,
275    seen: &mut BTreeSet<(ParsedAnimationFactKind, String, u32, u32)>,
276    kind: ParsedAnimationFactKind,
277    name: String,
278    range: TextRange,
279) {
280    if seen.insert((
281        kind,
282        name.clone(),
283        u32::from(range.start()),
284        u32::from(range.end()),
285    )) {
286        animations.push(ParsedAnimationFact { kind, name, range });
287    }
288}
289
290fn animation_name_from_token(token: Token<'_>) -> Option<String> {
291    if !matches!(token.kind, SyntaxKind::Ident | SyntaxKind::String) {
292        return None;
293    }
294    let name = token
295        .text
296        .trim_matches(|character| character == '"' || character == '\'')
297        .to_string();
298    if name.is_empty() || animation_name_is_reserved(&name) {
299        return None;
300    }
301    Some(name)
302}
303
304fn animation_name_is_reserved(name: &str) -> bool {
305    matches_ignore_ascii_case(
306        name,
307        &[
308            "none",
309            "initial",
310            "inherit",
311            "unset",
312            "revert",
313            "revert-layer",
314        ],
315    )
316}
317
318/// Recognize an `@keyframes` at-rule prefix-insensitively.
319///
320/// Per CSS, `animation-name` resolves against any `@keyframes`/`@-webkit-keyframes`
321/// (and other vendor prefixes) with a matching name, so a vendor-prefixed at-rule
322/// must register the same bare keyframes-name fact as the unprefixed form. Strips a
323/// leading `@`, then an optional `-vendor-` prefix, and compares the remainder to
324/// `keyframes`.
325fn at_keyword_is_keyframes_rule(text: &str) -> bool {
326    let Some(rule) = text.strip_prefix('@') else {
327        return false;
328    };
329    if matches_ignore_ascii_case(rule, &["keyframes"]) {
330        return true;
331    }
332    // Accept a single `-vendor-` prefix (`-webkit-`, `-moz-`, `-o-`, `-ms-`, ...).
333    if let Some(rest) = rule.strip_prefix('-')
334        && let Some((vendor, remainder)) = rest.split_once('-')
335        && !vendor.is_empty()
336        && matches_ignore_ascii_case(remainder, &["keyframes"])
337    {
338        return true;
339    }
340    false
341}