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