use cstree::text::TextRange;
use omena_syntax::{StyleDialect, SyntaxKind};
use std::collections::BTreeMap;
use crate::{
ParseResult, Token, containing_at_rule_header_name, matches_ignore_ascii_case,
next_non_trivia_token, previous_non_trivia_token, previous_non_trivia_token_index,
};
use super::{syntax_node_is_top_level, tokens_from_syntax_node};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedVariableFact {
pub kind: ParsedVariableFactKind,
pub name: String,
pub range: TextRange,
pub has_fallback: bool,
pub value_repr: Option<Box<str>>,
pub defaulted: bool,
pub is_top_level: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum ParsedVariableFactKind {
ScssDeclaration,
ScssReference,
LessDeclaration,
LessReference,
CustomPropertyDeclaration,
CustomPropertyReference,
}
pub(crate) fn collect_variable_facts_from_cst(
text: &str,
parsed: &ParseResult,
) -> Vec<ParsedVariableFact> {
let mut variables = Vec::new();
let mut seen = std::collections::BTreeSet::new();
for tokens in variable_fact_statement_tokens_from_cst(text, parsed) {
for fact in variable_facts_from_token_view(&tokens) {
push_variable_fact(&mut variables, &mut seen, fact);
}
}
if !matches!(parsed.dialect(), StyleDialect::Scss | StyleDialect::Sass)
|| !variables
.iter()
.any(|fact| fact.kind == ParsedVariableFactKind::ScssDeclaration)
{
return variables;
}
let declaration_metadata = scss_variable_declaration_metadata_from_cst(text, parsed);
for fact in &mut variables {
let key = (u32::from(fact.range.start()), u32::from(fact.range.end()));
if let Some(metadata) = declaration_metadata.get(&key) {
fact.value_repr = metadata.value_repr.clone();
fact.defaulted = metadata.defaulted;
fact.is_top_level = metadata.is_top_level;
}
}
variables
}
fn variable_fact_statement_tokens_from_cst<'text>(
text: &'text str,
parsed: &ParseResult,
) -> Vec<Vec<Token<'text>>> {
parsed
.syntax()
.children()
.map(|node| tokens_from_syntax_node(text, parsed, node))
.collect()
}
fn variable_facts_from_token_view(tokens: &[Token<'_>]) -> Vec<ParsedVariableFact> {
let mut variables = Vec::new();
for (index, token) in tokens.iter().enumerate() {
let kind = match token.kind {
SyntaxKind::ScssVariable => {
if scss_variable_token_is_declaration(tokens, index) {
ParsedVariableFactKind::ScssDeclaration
} else {
ParsedVariableFactKind::ScssReference
}
}
SyntaxKind::LessVariable => {
if next_non_trivia_token(tokens, index + 1)
.is_some_and(|candidate| candidate.kind == SyntaxKind::Colon)
{
ParsedVariableFactKind::LessDeclaration
} else {
ParsedVariableFactKind::LessReference
}
}
SyntaxKind::CustomPropertyName => {
if previous_non_trivia_token(tokens, 0, index).is_some_and(|candidate| {
matches!(candidate.kind, SyntaxKind::Ampersand | SyntaxKind::Dot)
}) {
continue;
}
if let Some(at_rule_name) = containing_at_rule_header_name(tokens, index) {
if at_rule_name == "@property" {
ParsedVariableFactKind::CustomPropertyDeclaration
} else {
continue;
}
} else if next_non_trivia_token(tokens, index + 1)
.is_some_and(|candidate| candidate.kind == SyntaxKind::Colon)
{
ParsedVariableFactKind::CustomPropertyDeclaration
} else {
ParsedVariableFactKind::CustomPropertyReference
}
}
_ => continue,
};
let has_fallback = kind == ParsedVariableFactKind::CustomPropertyReference
&& custom_property_reference_has_var_fallback(tokens, index);
variables.push(ParsedVariableFact {
kind,
name: token.text.to_string(),
range: token.range,
has_fallback,
value_repr: None,
defaulted: false,
is_top_level: false,
});
}
variables
}
#[derive(Debug, Clone)]
struct ScssVariableDeclarationMetadata {
value_repr: Option<Box<str>>,
defaulted: bool,
is_top_level: bool,
}
fn scss_variable_declaration_metadata_from_cst(
text: &str,
parsed: &ParseResult,
) -> BTreeMap<(u32, u32), ScssVariableDeclarationMetadata> {
parsed
.syntax()
.descendants()
.filter(|node| node.kind() == SyntaxKind::ScssVariableDeclaration)
.filter_map(|node| {
let tokens = tokens_from_syntax_node(text, parsed, node);
let variable_index = tokens
.iter()
.position(|token| token.kind == SyntaxKind::ScssVariable)?;
let colon_index = tokens
.iter()
.enumerate()
.skip(variable_index + 1)
.find_map(|(index, token)| (token.kind == SyntaxKind::Colon).then_some(index))?;
let (value_end, defaulted) =
scss_variable_value_end_and_default(&tokens, colon_index + 1);
let value_repr = tokens[colon_index + 1..value_end]
.iter()
.map(|token| token.text)
.collect::<String>();
let variable = tokens[variable_index];
Some((
(
u32::from(variable.range.start()),
u32::from(variable.range.end()),
),
ScssVariableDeclarationMetadata {
value_repr: (!value_repr.trim().is_empty())
.then(|| value_repr.trim().to_string().into_boxed_str()),
defaulted,
is_top_level: syntax_node_is_top_level(node),
},
))
})
.collect()
}
fn scss_variable_value_end_and_default(tokens: &[Token<'_>], start: usize) -> (usize, bool) {
let mut end = tokens.len();
let mut defaulted = false;
for index in start..tokens.len() {
if matches!(
tokens[index].kind,
SyntaxKind::Semicolon | SyntaxKind::SassOptionalSemicolon
) {
end = end.min(index);
break;
}
if tokens[index].kind != SyntaxKind::Delim || tokens[index].text != "!" {
continue;
}
let Some(flag) = next_non_trivia_token(tokens, index + 1) else {
continue;
};
if flag.kind == SyntaxKind::Ident
&& matches_ignore_ascii_case(flag.text, &["default", "global"])
{
end = end.min(index);
defaulted |= matches_ignore_ascii_case(flag.text, &["default"]);
}
}
(end, defaulted)
}
fn push_variable_fact(
variables: &mut Vec<ParsedVariableFact>,
seen: &mut std::collections::BTreeSet<(ParsedVariableFactKind, String, u32, u32, bool)>,
fact: ParsedVariableFact,
) {
if seen.insert((
fact.kind,
fact.name.clone(),
u32::from(fact.range.start()),
u32::from(fact.range.end()),
fact.has_fallback,
)) {
variables.push(fact);
}
}
fn custom_property_reference_has_var_fallback(tokens: &[Token<'_>], index: usize) -> bool {
let Some(open_index) = previous_non_trivia_token_index(tokens, index, 0) else {
return false;
};
if tokens[open_index].kind != SyntaxKind::LeftParen {
return false;
}
let Some(callee_index) = previous_non_trivia_token_index(tokens, open_index, 0) else {
return false;
};
if tokens[callee_index].kind != SyntaxKind::Ident
|| !matches_ignore_ascii_case(tokens[callee_index].text, &["var"])
{
return false;
}
let mut depth = 0usize;
let mut cursor = open_index;
while cursor < tokens.len() {
match tokens[cursor].kind {
SyntaxKind::LeftParen => depth += 1,
SyntaxKind::RightParen => {
depth = depth.saturating_sub(1);
if depth == 0 {
return false;
}
}
SyntaxKind::Comma if depth == 1 => return true,
_ => {}
}
cursor += 1;
}
false
}
pub(crate) fn scss_variable_token_is_declaration(tokens: &[Token<'_>], index: usize) -> bool {
if scss_loop_variable_token_is_binding(tokens, index) {
return true;
}
next_non_trivia_token(tokens, index + 1).is_some_and(|candidate| {
candidate.kind == SyntaxKind::Colon
|| (matches!(candidate.kind, SyntaxKind::Comma | SyntaxKind::RightParen)
&& containing_at_rule_header_name(tokens, index)
.is_some_and(|name| matches_ignore_ascii_case(name, &["@mixin", "@function"])))
})
}
fn scss_loop_variable_token_is_binding(tokens: &[Token<'_>], index: usize) -> bool {
let Some(header_index) = containing_at_rule_header_index(tokens, index) else {
return false;
};
let separator = match () {
_ if matches_ignore_ascii_case(tokens[header_index].text, &["@each"]) => "in",
_ if matches_ignore_ascii_case(tokens[header_index].text, &["@for"]) => "from",
_ => return false,
};
let mut paren_depth = 0usize;
for token in &tokens[header_index + 1..index] {
match token.kind {
SyntaxKind::LeftParen => paren_depth += 1,
SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
SyntaxKind::Ident
if paren_depth == 0 && matches_ignore_ascii_case(token.text, &[separator]) =>
{
return false;
}
_ => {}
}
}
true
}
pub(crate) fn containing_at_rule_header_index(tokens: &[Token<'_>], index: usize) -> Option<usize> {
let mut current = index;
while current > 0 {
current -= 1;
let token = tokens.get(current)?;
if token.kind.is_trivia() {
continue;
}
if matches!(
token.kind,
SyntaxKind::Semicolon
| SyntaxKind::SassOptionalSemicolon
| SyntaxKind::LeftBrace
| SyntaxKind::RightBrace
| SyntaxKind::SassIndent
| SyntaxKind::SassDedent
) {
return None;
}
if token.kind == SyntaxKind::AtKeyword {
return Some(current);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{StyleDialect, parse};
#[test]
fn scss_declarations_expose_values_and_default_flags_from_cst() {
let source = "$theme: (primary: red, accent: blue) !default;\n.scope { $local: 2px; }";
let parsed = parse(source, StyleDialect::Scss);
let facts = collect_variable_facts_from_cst(source, &parsed);
let theme = facts.iter().find(|fact| {
fact.kind == ParsedVariableFactKind::ScssDeclaration && fact.name == "$theme"
});
assert!(theme.is_some(), "top-level variable declaration");
let Some(theme) = theme else {
return;
};
assert_eq!(
theme.value_repr.as_deref(),
Some("(primary: red, accent: blue)")
);
assert!(theme.defaulted);
assert!(theme.is_top_level);
let local = facts.iter().find(|fact| {
fact.kind == ParsedVariableFactKind::ScssDeclaration && fact.name == "$local"
});
assert!(local.is_some(), "local variable declaration");
let Some(local) = local else {
return;
};
assert_eq!(local.value_repr.as_deref(), Some("2px"));
assert!(!local.defaulted);
assert!(!local.is_top_level);
}
}