1use 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 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
231fn custom_property_reference_has_var_fallback(tokens: &[Token<'_>], index: usize) -> bool {
238 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 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
286fn 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 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
322pub(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}