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