1use 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 if animation_name_token_is_interpolation_adjacent(tokens, index) {
197 return false;
198 }
199 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
215fn 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
318fn 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 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}