1use cstree::text::{TextRange, TextSize};
7use omena_syntax::{StyleDialect, SyntaxKind};
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::{
11 ParseResult, Token, containing_at_rule_header_name, css_module_value_source_name,
12 css_module_value_statement_end, matches_ignore_ascii_case, next_non_trivia_token,
13 next_non_trivia_token_index_until, previous_non_trivia_token, previous_non_trivia_token_index,
14 skip_trivia_tokens, top_level_token_text_index,
15};
16
17use super::scss_variable_token_is_declaration;
18use super::syntax_node_is_top_level;
19use super::tokens_from_syntax_node;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct ParsedSassSymbolFact {
23 pub kind: ParsedSassSymbolFactKind,
24 pub symbol_kind: &'static str,
25 pub name: String,
26 pub role: &'static str,
27 pub namespace: Option<String>,
28 pub range: TextRange,
29 pub callable_signature: Option<Box<ParsedSassCallableSignatureFact>>,
30 pub is_top_level: bool,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ParsedSassCallableSignatureFact {
35 pub parameters: Vec<ParsedSassCallableParameterFact>,
36 pub accepts_content: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ParsedSassCallableParameterFact {
41 pub name: String,
42 pub default_repr: Option<String>,
43 pub variadic: bool,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
47pub enum ParsedSassSymbolFactKind {
48 VariableDeclaration,
49 VariableReference,
50 MixinDeclaration,
51 MixinInclude,
52 FunctionDeclaration,
53 FunctionCall,
54}
55
56pub(crate) fn collect_sass_symbol_facts_from_cst(
57 text: &str,
58 parsed: &ParseResult,
59) -> Vec<ParsedSassSymbolFact> {
60 let statement_tokens = sass_symbol_statement_tokens_from_cst(text, parsed);
61 let declared_functions = statement_tokens
62 .iter()
63 .flat_map(|tokens| collect_sass_callable_declaration_names(tokens, "@function"))
64 .collect::<BTreeSet<_>>();
65 let mut facts = statement_tokens
66 .iter()
67 .flat_map(|tokens| {
68 sass_symbol_facts_from_token_view_with_declared_functions(tokens, &declared_functions)
69 })
70 .collect::<Vec<_>>();
71 if !matches!(parsed.dialect(), StyleDialect::Scss | StyleDialect::Sass)
72 || !facts.iter().any(|fact| {
73 matches!(
74 fact.kind,
75 ParsedSassSymbolFactKind::MixinDeclaration
76 | ParsedSassSymbolFactKind::FunctionDeclaration
77 )
78 })
79 {
80 return facts;
81 }
82 let declaration_metadata = sass_callable_declaration_metadata_from_cst(text, parsed);
83 for fact in &mut facts {
84 let key = (
85 fact.kind,
86 u32::from(fact.range.start()),
87 u32::from(fact.range.end()),
88 );
89 if let Some(metadata) = declaration_metadata.get(&key) {
90 fact.callable_signature = Some(Box::new(metadata.signature.clone()));
91 fact.is_top_level = metadata.is_top_level;
92 }
93 }
94 facts
95}
96
97fn sass_symbol_statement_tokens_from_cst<'text>(
98 text: &'text str,
99 parsed: &ParseResult,
100) -> Vec<Vec<Token<'text>>> {
101 parsed
102 .syntax()
103 .children()
104 .map(|node| tokens_from_syntax_node(text, parsed, node))
105 .collect()
106}
107
108fn sass_symbol_facts_from_token_view_with_declared_functions(
109 tokens: &[Token<'_>],
110 declared_functions: &BTreeSet<String>,
111) -> Vec<ParsedSassSymbolFact> {
112 let mut symbols = Vec::new();
113
114 for (index, token) in tokens.iter().enumerate() {
115 match token.kind {
116 SyntaxKind::ScssVariable => {
117 let kind = if scss_variable_token_is_declaration(tokens, index) {
118 ParsedSassSymbolFactKind::VariableDeclaration
119 } else {
120 ParsedSassSymbolFactKind::VariableReference
121 };
122 let namespace = (!scss_variable_token_is_declaration(tokens, index))
123 .then(|| sass_member_namespace_before(tokens, index))
124 .flatten();
125 symbols.push(ParsedSassSymbolFact {
126 kind,
127 symbol_kind: "variable",
128 name: token.text.trim_start_matches('$').to_string(),
129 role: match kind {
130 ParsedSassSymbolFactKind::VariableDeclaration => "declaration",
131 _ => "reference",
132 },
133 namespace,
134 range: sass_symbol_variable_range(token, kind),
135 callable_signature: None,
136 is_top_level: false,
137 });
138 }
139 SyntaxKind::AtKeyword if matches_ignore_ascii_case(token.text, &["@mixin"]) => {
140 if let Some(name) = sass_callable_name_after_at_rule(tokens, index) {
141 symbols.push(ParsedSassSymbolFact {
142 kind: ParsedSassSymbolFactKind::MixinDeclaration,
143 symbol_kind: "mixin",
144 name: name.text.to_string(),
145 role: "declaration",
146 namespace: None,
147 range: name.range,
148 callable_signature: None,
149 is_top_level: false,
150 });
151 }
152 }
153 SyntaxKind::AtKeyword if matches_ignore_ascii_case(token.text, &["@include"]) => {
154 if let Some((name, namespace)) = sass_include_name_after_at_rule(tokens, index) {
155 symbols.push(ParsedSassSymbolFact {
156 kind: ParsedSassSymbolFactKind::MixinInclude,
157 symbol_kind: "mixin",
158 name: name.text.to_string(),
159 role: "include",
160 namespace,
161 range: name.range,
162 callable_signature: None,
163 is_top_level: false,
164 });
165 }
166 }
167 SyntaxKind::AtKeyword if matches_ignore_ascii_case(token.text, &["@function"]) => {
168 if let Some(name) = sass_callable_name_after_at_rule(tokens, index) {
169 symbols.push(ParsedSassSymbolFact {
170 kind: ParsedSassSymbolFactKind::FunctionDeclaration,
171 symbol_kind: "function",
172 name: name.text.to_string(),
173 role: "declaration",
174 namespace: None,
175 range: name.range,
176 callable_signature: None,
177 is_top_level: false,
178 });
179 }
180 }
181 SyntaxKind::Ident
182 if (declared_functions.contains(&canonical_sass_callable_name(token.text))
183 || sass_member_namespace_before(tokens, index).is_some())
184 && next_non_trivia_token(tokens, index + 1)
185 .is_some_and(|candidate| candidate.kind == SyntaxKind::LeftParen)
186 && !containing_at_rule_header_name(tokens, index)
187 .is_some_and(|name| matches_ignore_ascii_case(name, &["@include"]))
188 && previous_non_trivia_token(tokens, 0, index).is_none_or(|candidate| {
189 !matches!(candidate.kind, SyntaxKind::AtKeyword)
190 }) =>
191 {
192 symbols.push(ParsedSassSymbolFact {
193 kind: ParsedSassSymbolFactKind::FunctionCall,
194 symbol_kind: "function",
195 name: token.text.to_string(),
196 role: "call",
197 namespace: sass_member_namespace_before(tokens, index),
198 range: token.range,
199 callable_signature: None,
200 is_top_level: false,
201 });
202 }
203 _ => {}
204 }
205 }
206
207 symbols
208}
209
210#[derive(Debug, Clone)]
211struct SassCallableDeclarationMetadata {
212 signature: ParsedSassCallableSignatureFact,
213 is_top_level: bool,
214}
215
216fn sass_callable_declaration_metadata_from_cst(
217 text: &str,
218 parsed: &ParseResult,
219) -> BTreeMap<(ParsedSassSymbolFactKind, u32, u32), SassCallableDeclarationMetadata> {
220 parsed
221 .syntax()
222 .descendants()
223 .filter_map(|node| {
224 let fact_kind = match node.kind() {
225 SyntaxKind::ScssMixinDeclaration => ParsedSassSymbolFactKind::MixinDeclaration,
226 SyntaxKind::ScssFunctionDeclaration => {
227 ParsedSassSymbolFactKind::FunctionDeclaration
228 }
229 _ => return None,
230 };
231 let tokens = tokens_from_syntax_node(text, parsed, node);
232 let at_rule_index = tokens
233 .iter()
234 .position(|token| token.kind == SyntaxKind::AtKeyword)?;
235 let name = sass_callable_name_after_at_rule(&tokens, at_rule_index)?;
236 let signature = ParsedSassCallableSignatureFact {
237 parameters: sass_callable_parameters_from_tokens(&tokens, at_rule_index),
238 accepts_content: fact_kind == ParsedSassSymbolFactKind::MixinDeclaration
239 && sass_callable_node_accepts_content(node),
240 };
241 let key = (
242 fact_kind,
243 u32::from(name.range.start()),
244 u32::from(name.range.end()),
245 );
246 let metadata = SassCallableDeclarationMetadata {
247 signature,
248 is_top_level: syntax_node_is_top_level(node),
249 };
250 Some((key, metadata))
251 })
252 .collect()
253}
254
255fn sass_callable_node_accepts_content(
256 declaration: &cstree::syntax::SyntaxNode<SyntaxKind>,
257) -> bool {
258 declaration
259 .descendants()
260 .filter(|node| node.kind() == SyntaxKind::ScssContentRule)
261 .any(|content| {
262 content
263 .ancestors()
264 .skip(1)
265 .take_while(|ancestor| *ancestor != declaration)
266 .all(|ancestor| {
267 !matches!(
268 ancestor.kind(),
269 SyntaxKind::ScssMixinDeclaration | SyntaxKind::ScssFunctionDeclaration
270 )
271 })
272 })
273}
274
275fn sass_callable_parameters_from_tokens(
276 tokens: &[Token<'_>],
277 at_rule_index: usize,
278) -> Vec<ParsedSassCallableParameterFact> {
279 let Some(open_index) = tokens
280 .iter()
281 .enumerate()
282 .skip(at_rule_index + 1)
283 .find_map(|(index, token)| (token.kind == SyntaxKind::LeftParen).then_some(index))
284 else {
285 return Vec::new();
286 };
287 let Some(close_index) = matching_right_paren_index(tokens, open_index) else {
288 return Vec::new();
289 };
290
291 split_sass_parameter_ranges(tokens, open_index + 1, close_index)
292 .into_iter()
293 .filter_map(|(start, end)| sass_parameter_from_token_range(tokens, start, end))
294 .collect()
295}
296
297fn matching_right_paren_index(tokens: &[Token<'_>], open_index: usize) -> Option<usize> {
298 let mut depth = 0usize;
299 for (index, token) in tokens.iter().enumerate().skip(open_index) {
300 match token.kind {
301 SyntaxKind::LeftParen => depth += 1,
302 SyntaxKind::RightParen => {
303 depth = depth.saturating_sub(1);
304 if depth == 0 {
305 return Some(index);
306 }
307 }
308 _ => {}
309 }
310 }
311 None
312}
313
314fn split_sass_parameter_ranges(
315 tokens: &[Token<'_>],
316 start: usize,
317 end: usize,
318) -> Vec<(usize, usize)> {
319 let mut ranges = Vec::new();
320 let mut segment_start = start;
321 let mut paren_depth = 0usize;
322 let mut bracket_depth = 0usize;
323 let mut brace_depth = 0usize;
324 for (index, token) in tokens.iter().enumerate().take(end).skip(start) {
325 match token.kind {
326 SyntaxKind::LeftParen => paren_depth += 1,
327 SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
328 SyntaxKind::LeftBracket => bracket_depth += 1,
329 SyntaxKind::RightBracket => bracket_depth = bracket_depth.saturating_sub(1),
330 SyntaxKind::LeftBrace => brace_depth += 1,
331 SyntaxKind::RightBrace => brace_depth = brace_depth.saturating_sub(1),
332 SyntaxKind::Comma if paren_depth == 0 && bracket_depth == 0 && brace_depth == 0 => {
333 ranges.push((segment_start, index));
334 segment_start = index + 1;
335 }
336 _ => {}
337 }
338 }
339 ranges.push((segment_start, end));
340 ranges
341}
342
343fn sass_parameter_from_token_range(
344 tokens: &[Token<'_>],
345 start: usize,
346 end: usize,
347) -> Option<ParsedSassCallableParameterFact> {
348 let variable_index =
349 (start..end).find(|index| tokens[*index].kind == SyntaxKind::ScssVariable)?;
350 let colon_index =
351 (variable_index + 1..end).find(|index| tokens[*index].kind == SyntaxKind::Colon);
352 let default_repr = colon_index
353 .map(|colon| {
354 tokens[colon + 1..end]
355 .iter()
356 .map(|token| token.text)
357 .collect::<String>()
358 })
359 .map(|value| value.trim().trim_end_matches("...").trim().to_string())
360 .filter(|value| !value.is_empty());
361 let suffix = tokens[variable_index + 1..end]
362 .iter()
363 .map(|token| token.text)
364 .collect::<String>();
365 Some(ParsedSassCallableParameterFact {
366 name: tokens[variable_index]
367 .text
368 .trim_start_matches('$')
369 .to_string(),
370 default_repr,
371 variadic: suffix.trim().ends_with("..."),
372 })
373}
374
375fn sass_symbol_variable_range(token: &Token<'_>, kind: ParsedSassSymbolFactKind) -> TextRange {
376 if kind == ParsedSassSymbolFactKind::VariableDeclaration && token.text.starts_with('$') {
377 let start = u32::from(token.range.start());
378 let end = u32::from(token.range.end());
379 if start < end {
380 return TextRange::new(TextSize::from(start + 1), TextSize::from(end));
381 }
382 }
383 token.range
384}
385
386fn collect_sass_callable_declaration_names(
387 tokens: &[Token<'_>],
388 at_keyword: &str,
389) -> BTreeSet<String> {
390 tokens
391 .iter()
392 .enumerate()
393 .filter_map(|(index, token)| {
394 (token.kind == SyntaxKind::AtKeyword
395 && matches_ignore_ascii_case(token.text, &[at_keyword]))
396 .then(|| sass_callable_name_after_at_rule(tokens, index))
397 .flatten()
398 .map(|name| canonical_sass_callable_name(name.text))
399 })
400 .collect()
401}
402
403fn canonical_sass_callable_name(name: &str) -> String {
404 name.trim().replace('_', "-")
405}
406
407fn sass_callable_name_after_at_rule<'text>(
408 tokens: &[Token<'text>],
409 at_rule_index: usize,
410) -> Option<Token<'text>> {
411 let statement_end = css_module_value_statement_end(tokens, at_rule_index + 1);
412 let name_index = next_non_trivia_token_index_until(tokens, at_rule_index + 1, statement_end)?;
413 let name = tokens[name_index];
414 if name.kind != SyntaxKind::Ident {
415 return None;
416 }
417 if next_non_trivia_token_index_until(tokens, name_index + 1, statement_end)
418 .is_some_and(|next| tokens[next].kind == SyntaxKind::Dot)
419 {
420 return None;
421 }
422 Some(name)
423}
424
425fn sass_include_name_after_at_rule<'text>(
426 tokens: &[Token<'text>],
427 at_rule_index: usize,
428) -> Option<(Token<'text>, Option<String>)> {
429 let statement_end = css_module_value_statement_end(tokens, at_rule_index + 1);
430 let first_index = next_non_trivia_token_index_until(tokens, at_rule_index + 1, statement_end)?;
431 let first = tokens[first_index];
432 if first.kind != SyntaxKind::Ident {
433 return None;
434 }
435 let Some(dot_index) = next_non_trivia_token_index_until(tokens, first_index + 1, statement_end)
436 else {
437 return Some((first, None));
438 };
439 if tokens[dot_index].kind != SyntaxKind::Dot {
440 return Some((first, None));
441 }
442 let member_index = next_non_trivia_token_index_until(tokens, dot_index + 1, statement_end)?;
443 let member = tokens[member_index];
444 (member.kind == SyntaxKind::Ident).then(|| (member, Some(first.text.to_string())))
445}
446
447fn sass_member_namespace_before(tokens: &[Token<'_>], member_index: usize) -> Option<String> {
448 let dot_index = previous_non_trivia_token_index(tokens, member_index, 0)?;
449 if tokens[dot_index].kind != SyntaxKind::Dot {
450 return None;
451 }
452 let namespace = tokens[previous_non_trivia_token_index(tokens, dot_index, 0)?];
453 (namespace.kind == SyntaxKind::Ident).then(|| namespace.text.to_string())
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
457pub struct ParsedSassIncludeFact {
458 pub name: String,
459 pub namespace: Option<String>,
460 pub params: String,
461 pub range: TextRange,
462}
463
464pub(crate) fn collect_sass_include_facts_from_cst(
465 source: &str,
466 parsed: &ParseResult,
467) -> Vec<ParsedSassIncludeFact> {
468 let mut includes = Vec::new();
469 for tokens in scss_include_rule_tokens_from_cst(source, parsed) {
470 collect_sass_include_facts_from_rule_tokens(&tokens, &mut includes);
471 }
472 includes
473}
474
475fn collect_sass_include_facts_from_rule_tokens(
476 tokens: &[Token<'_>],
477 includes: &mut Vec<ParsedSassIncludeFact>,
478) {
479 for (index, token) in tokens.iter().enumerate() {
480 if token.kind != SyntaxKind::AtKeyword
481 || !matches_ignore_ascii_case(token.text, &["@include"])
482 {
483 continue;
484 }
485 let statement_end = css_module_value_statement_end(tokens, index + 1);
486 let Some((name, namespace)) = sass_include_name_after_at_rule(tokens, index) else {
487 continue;
488 };
489 let header_end = previous_non_trivia_token_index(tokens, statement_end, index + 1)
490 .map(|previous| tokens[previous].range.end())
491 .unwrap_or(name.range.end());
492 let params = token_text_between_offsets(tokens, name.range.end(), header_end)
493 .trim()
494 .to_string();
495 includes.push(ParsedSassIncludeFact {
496 name: name.text.to_string(),
497 namespace,
498 params,
499 range: TextRange::new(token.range.start(), header_end),
500 });
501 }
502}
503
504fn scss_include_rule_tokens_from_cst<'text>(
505 text: &'text str,
506 parsed: &ParseResult,
507) -> Vec<Vec<Token<'text>>> {
508 parsed
509 .syntax()
510 .descendants()
511 .filter(|node| node.kind() == SyntaxKind::ScssIncludeRule)
512 .map(|node| tokens_from_syntax_node(text, parsed, node))
513 .collect()
514}
515
516fn token_text_between_offsets(
517 tokens: &[Token<'_>],
518 start: cstree::text::TextSize,
519 end: cstree::text::TextSize,
520) -> String {
521 tokens
522 .iter()
523 .filter(|token| token.range.start() >= start && token.range.end() <= end)
524 .map(|token| token.text)
525 .collect()
526}
527
528#[derive(Debug, Clone, PartialEq, Eq)]
529pub struct ParsedSassModuleEdgeFact {
530 pub kind: ParsedSassModuleEdgeFactKind,
531 pub source: String,
532 pub namespace_kind: Option<&'static str>,
533 pub namespace: Option<String>,
534 pub forward_prefix: Option<String>,
535 pub visibility_filter_kind: Option<&'static str>,
536 pub visibility_filter_names: Vec<String>,
537 pub visibility_filter_export_names: Vec<String>,
538 pub media_qualified: bool,
545 pub range: TextRange,
546 pub is_top_level: bool,
547}
548
549#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
550pub enum ParsedSassModuleEdgeFactKind {
551 Use,
552 Forward,
553 Import,
554}
555
556pub(crate) fn collect_sass_module_edge_facts_from_cst(
557 text: &str,
558 parsed: &ParseResult,
559) -> Vec<ParsedSassModuleEdgeFact> {
560 let mut edges = Vec::new();
561 let mut seen = BTreeSet::new();
562 for (tokens, is_top_level) in sass_module_rule_tokens_from_cst(text, parsed) {
563 collect_sass_module_edge_facts_from_rule_tokens(
564 &tokens,
565 is_top_level,
566 &mut edges,
567 &mut seen,
568 );
569 }
570 edges
571}
572
573fn collect_sass_module_edge_facts_from_rule_tokens(
574 tokens: &[Token<'_>],
575 is_top_level: bool,
576 edges: &mut Vec<ParsedSassModuleEdgeFact>,
577 seen: &mut BTreeSet<(ParsedSassModuleEdgeFactKind, String, u32, u32)>,
578) {
579 for (index, token) in tokens.iter().enumerate() {
580 if token.kind != SyntaxKind::AtKeyword {
581 continue;
582 }
583 let Some(kind) = sass_module_edge_kind(token.text) else {
584 continue;
585 };
586 let start = skip_trivia_tokens(tokens, index + 1, tokens.len());
587 let end = css_module_value_statement_end(tokens, start);
588 if kind == ParsedSassModuleEdgeFactKind::Import {
589 collect_sass_import_module_edges(tokens, start, end, is_top_level, edges, seen);
590 continue;
591 }
592 let Some(source_index) = next_non_trivia_token_index_until(tokens, start, end) else {
593 continue;
594 };
595 let source = tokens[source_index];
596 if !matches!(source.kind, SyntaxKind::String | SyntaxKind::Url) {
597 continue;
598 }
599 let source_name = css_module_value_source_name(source);
600 let (namespace_kind, namespace) = if kind == ParsedSassModuleEdgeFactKind::Use {
601 sass_module_use_namespace(tokens, source_name.as_str(), source_index + 1, end)
602 } else {
603 (None, None)
604 };
605 let (visibility_filter_kind, visibility_filter_names, visibility_filter_export_names) =
606 if kind == ParsedSassModuleEdgeFactKind::Forward {
607 sass_module_forward_visibility_filter(tokens, source_index + 1, end)
608 } else {
609 (None, Vec::new(), Vec::new())
610 };
611 let forward_prefix = if kind == ParsedSassModuleEdgeFactKind::Forward {
612 sass_module_forward_prefix(tokens, source_index + 1, end)
613 } else {
614 None
615 };
616 push_sass_module_edge_fact(
617 edges,
618 seen,
619 ParsedSassModuleEdgeFact {
620 kind,
621 source: source_name,
622 namespace_kind,
623 namespace,
624 forward_prefix,
625 visibility_filter_kind,
626 visibility_filter_names,
627 visibility_filter_export_names,
628 media_qualified: false,
629 range: source.range,
630 is_top_level,
631 },
632 );
633 }
634}
635
636fn sass_module_rule_tokens_from_cst<'text>(
637 text: &'text str,
638 parsed: &ParseResult,
639) -> Vec<(Vec<Token<'text>>, bool)> {
640 parsed
641 .syntax()
642 .descendants()
643 .filter(|node| {
644 matches!(
645 node.kind(),
646 SyntaxKind::ScssUseRule | SyntaxKind::ScssForwardRule | SyntaxKind::ImportRule
647 )
648 })
649 .map(|node| {
650 (
651 tokens_from_syntax_node(text, parsed, node),
652 syntax_node_is_top_level(node),
653 )
654 })
655 .collect()
656}
657
658fn sass_module_edge_kind(text: &str) -> Option<ParsedSassModuleEdgeFactKind> {
659 if matches_ignore_ascii_case(text, &["@use"]) {
660 Some(ParsedSassModuleEdgeFactKind::Use)
661 } else if matches_ignore_ascii_case(text, &["@forward"]) {
662 Some(ParsedSassModuleEdgeFactKind::Forward)
663 } else if matches_ignore_ascii_case(text, &["@import"]) {
664 Some(ParsedSassModuleEdgeFactKind::Import)
665 } else {
666 None
667 }
668}
669
670fn collect_sass_import_module_edges(
671 tokens: &[Token<'_>],
672 start: usize,
673 end: usize,
674 is_top_level: bool,
675 edges: &mut Vec<ParsedSassModuleEdgeFact>,
676 seen: &mut BTreeSet<(ParsedSassModuleEdgeFactKind, String, u32, u32)>,
677) {
678 for index in start..end {
679 let token = tokens[index];
680 if !matches!(token.kind, SyntaxKind::String | SyntaxKind::Url) {
681 continue;
682 }
683 let media_qualified = next_non_trivia_token_index_until(tokens, index + 1, end)
686 .is_some_and(|next| tokens[next].kind != SyntaxKind::Comma);
687 push_sass_module_edge_fact(
688 edges,
689 seen,
690 ParsedSassModuleEdgeFact {
691 kind: ParsedSassModuleEdgeFactKind::Import,
692 source: css_module_value_source_name(token),
693 namespace_kind: None,
694 namespace: None,
695 forward_prefix: None,
696 visibility_filter_kind: None,
697 visibility_filter_names: Vec::new(),
698 visibility_filter_export_names: Vec::new(),
699 media_qualified,
700 range: token.range,
701 is_top_level,
702 },
703 );
704 }
705}
706
707fn sass_module_use_namespace(
708 tokens: &[Token<'_>],
709 source: &str,
710 start: usize,
711 end: usize,
712) -> (Option<&'static str>, Option<String>) {
713 let Some(as_index) = top_level_token_text_index(tokens, start, end, "as") else {
714 return (
715 Some("default"),
716 sass_module_default_namespace(source).map(str::to_string),
717 );
718 };
719 let Some(namespace_index) = next_non_trivia_token_index_until(tokens, as_index + 1, end) else {
720 return (Some("invalid"), None);
721 };
722 let namespace = tokens[namespace_index];
723 match namespace.kind {
724 SyntaxKind::Star => (Some("wildcard"), None),
725 SyntaxKind::Ident => (Some("alias"), Some(namespace.text.to_string())),
726 _ => (Some("invalid"), None),
727 }
728}
729
730fn sass_module_forward_prefix(tokens: &[Token<'_>], start: usize, end: usize) -> Option<String> {
731 let as_index = top_level_token_text_index(tokens, start, end, "as")?;
732 let prefix_index = next_non_trivia_token_index_until(tokens, as_index + 1, end)?;
733 let prefix_end = ["show", "hide", "with"]
734 .into_iter()
735 .filter_map(|keyword| top_level_token_text_index(tokens, prefix_index, end, keyword))
736 .min()
737 .unwrap_or(end);
738 let prefix = tokens[prefix_index..prefix_end]
739 .iter()
740 .map(|token| token.text)
741 .collect::<String>();
742 let prefix = prefix.trim();
743 let prefix = prefix.strip_suffix('*').unwrap_or(prefix).trim_end();
744 if prefix.is_empty() {
745 return None;
746 }
747 Some(prefix.to_string())
748}
749
750fn sass_module_forward_visibility_filter(
751 tokens: &[Token<'_>],
752 start: usize,
753 end: usize,
754) -> (Option<&'static str>, Vec<String>, Vec<String>) {
755 let show_index = top_level_token_text_index(tokens, start, end, "show");
756 let hide_index = top_level_token_text_index(tokens, start, end, "hide");
757 let (filter_kind, filter_index) = match (show_index, hide_index) {
758 (Some(show_index), Some(hide_index)) if show_index <= hide_index => ("show", show_index),
759 (Some(_), Some(hide_index)) => ("hide", hide_index),
760 (Some(show_index), None) => ("show", show_index),
761 (None, Some(hide_index)) => ("hide", hide_index),
762 (None, None) => return (None, Vec::new(), Vec::new()),
763 };
764 let clause_end =
765 top_level_token_text_index(tokens, filter_index + 1, end, "with").unwrap_or(end);
766 let (names, export_names) =
767 sass_module_visibility_filter_names(tokens, filter_index + 1, clause_end);
768 (Some(filter_kind), names, export_names)
769}
770
771fn sass_module_visibility_filter_names(
772 tokens: &[Token<'_>],
773 start: usize,
774 end: usize,
775) -> (Vec<String>, Vec<String>) {
776 let mut names = BTreeSet::new();
777 let mut export_names = BTreeSet::new();
778 for token in &tokens[start..end] {
779 match token.kind {
780 SyntaxKind::Ident | SyntaxKind::ScssVariable => {
781 if matches_ignore_ascii_case(token.text, &["show", "hide", "with", "as"]) {
782 continue;
783 }
784 let name = token.text.trim_start_matches('$');
785 if !name.is_empty() {
786 names.insert(name.to_string());
787 export_names.insert(token.text.to_string());
788 }
789 }
790 _ => {}
791 }
792 }
793 (
794 names.into_iter().collect(),
795 export_names.into_iter().collect(),
796 )
797}
798
799fn sass_module_default_namespace(source: &str) -> Option<&str> {
800 let basename = source
801 .rsplit(['/', '\\', ':'])
802 .next()
803 .unwrap_or(source)
804 .trim_start_matches('_');
805 let namespace = basename.split('.').next().unwrap_or(basename);
806 (!namespace.is_empty()).then_some(namespace)
807}
808
809fn push_sass_module_edge_fact(
810 edges: &mut Vec<ParsedSassModuleEdgeFact>,
811 seen: &mut BTreeSet<(ParsedSassModuleEdgeFactKind, String, u32, u32)>,
812 edge: ParsedSassModuleEdgeFact,
813) {
814 let start: u32 = edge.range.start().into();
815 let end: u32 = edge.range.end().into();
816 if seen.insert((edge.kind, edge.source.clone(), start, end)) {
817 edges.push(edge);
818 }
819}
820
821#[derive(Debug, Clone, PartialEq, Eq)]
822pub struct ParsedSassPlaceholderDefinitionFact {
823 pub name: String,
824 pub range: TextRange,
825 pub is_top_level: bool,
826}
827
828pub(crate) fn collect_sass_placeholder_definition_facts_from_cst(
829 text: &str,
830 parsed: &ParseResult,
831) -> Vec<ParsedSassPlaceholderDefinitionFact> {
832 if !matches!(parsed.dialect(), StyleDialect::Scss | StyleDialect::Sass)
833 || !parsed
834 .syntax_token_views()
835 .iter()
836 .any(|token| token.kind == SyntaxKind::ScssPlaceholder)
837 {
838 return Vec::new();
839 }
840 parsed
841 .syntax()
842 .descendants()
843 .filter(|node| node.kind() == SyntaxKind::ScssPlaceholderSelector)
844 .filter_map(|node| {
845 let placeholder = tokens_from_syntax_node(text, parsed, node)
846 .into_iter()
847 .find(|token| token.kind == SyntaxKind::ScssPlaceholder)?;
848 let rule = node
849 .ancestors()
850 .find(|ancestor| ancestor.kind() == SyntaxKind::Rule)?;
851 Some(ParsedSassPlaceholderDefinitionFact {
852 name: placeholder.text.trim_start_matches('%').to_string(),
853 range: placeholder.range,
854 is_top_level: syntax_node_is_top_level(rule),
855 })
856 })
857 .collect()
858}
859
860#[derive(Debug, Clone, PartialEq, Eq)]
865pub struct ParsedExtendTargetFact {
866 pub kind: ParsedExtendTargetFactKind,
867 pub name: String,
868 pub optional: bool,
869 pub range: TextRange,
870}
871
872#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
873pub enum ParsedExtendTargetFactKind {
874 Class,
875 Placeholder,
876}
877
878pub(crate) fn collect_extend_target_facts_from_cst(
886 text: &str,
887 parsed: &ParseResult,
888) -> Vec<ParsedExtendTargetFact> {
889 let mut targets = Vec::new();
890 for tokens in scss_extend_rule_tokens_from_cst(text, parsed) {
891 collect_extend_target_facts_from_rule_tokens(&tokens, &mut targets);
892 }
893 targets
894}
895
896fn collect_extend_target_facts_from_rule_tokens(
897 tokens: &[Token<'_>],
898 targets: &mut Vec<ParsedExtendTargetFact>,
899) {
900 for (index, token) in tokens.iter().enumerate() {
901 if token.kind != SyntaxKind::AtKeyword
902 || !matches_ignore_ascii_case(token.text, &["@extend"])
903 {
904 continue;
905 }
906 let start = skip_trivia_tokens(tokens, index + 1, tokens.len());
907 let end = css_module_value_statement_end(tokens, start);
908
909 let optional = extend_statement_has_optional_flag(tokens, start, end);
911
912 let mut cursor = start;
913 let mut captured: Option<ParsedExtendTargetFact> = None;
914 while cursor < end {
915 let current = tokens[cursor];
916 if current.kind == SyntaxKind::ScssPlaceholder {
917 captured = Some(ParsedExtendTargetFact {
918 kind: ParsedExtendTargetFactKind::Placeholder,
919 name: current.text.trim_start_matches('%').to_string(),
920 optional,
921 range: current.range,
922 });
923 break;
924 }
925 if current.kind == SyntaxKind::Dot
926 && let Some(name_index) = next_non_trivia_token_index_until(tokens, cursor + 1, end)
927 && tokens[name_index].kind == SyntaxKind::Ident
928 {
929 let name_token = tokens[name_index];
930 let range = TextRange::new(current.range.start(), name_token.range.end());
931 captured = Some(ParsedExtendTargetFact {
932 kind: ParsedExtendTargetFactKind::Class,
933 name: name_token.text.to_string(),
934 optional,
935 range,
936 });
937 break;
938 }
939 cursor += 1;
940 }
941
942 if let Some(target) = captured {
943 targets.push(target);
944 }
945 }
946}
947
948fn scss_extend_rule_tokens_from_cst<'text>(
949 text: &'text str,
950 parsed: &ParseResult,
951) -> Vec<Vec<Token<'text>>> {
952 parsed
953 .syntax()
954 .descendants()
955 .filter(|node| node.kind() == SyntaxKind::ScssExtendRule)
956 .map(|node| tokens_from_syntax_node(text, parsed, node))
957 .collect()
958}
959
960fn extend_statement_has_optional_flag(tokens: &[Token<'_>], start: usize, end: usize) -> bool {
961 let mut index = start;
962 while index < end {
963 if tokens[index].kind == SyntaxKind::Delim
964 && tokens[index].text == "!"
965 && let Some(next_index) = next_non_trivia_token_index_until(tokens, index + 1, end)
966 && tokens[next_index].kind == SyntaxKind::Ident
967 && matches_ignore_ascii_case(tokens[next_index].text, &["optional"])
968 {
969 return true;
970 }
971 index += 1;
972 }
973 false
974}
975
976#[cfg(test)]
977mod tests {
978 use super::*;
979 use crate::{StyleDialect, parse};
980
981 #[test]
982 fn callable_declarations_expose_export_signatures_from_cst() {
983 let source = r#"
984@mixin surface($tone: red, $parts...) {
985 @content;
986}
987
988@function scale($value: 1) {
989 @return $value;
990}
991"#;
992 let parsed = parse(source, StyleDialect::Scss);
993 let facts = collect_sass_symbol_facts_from_cst(source, &parsed);
994
995 let mixin = facts
996 .iter()
997 .find(|fact| fact.kind == ParsedSassSymbolFactKind::MixinDeclaration);
998 assert!(mixin.is_some(), "mixin declaration fact");
999 let Some(mixin) = mixin else {
1000 return;
1001 };
1002 assert!(mixin.is_top_level);
1003 let signature = mixin.callable_signature.as_deref();
1004 assert!(signature.is_some(), "mixin signature");
1005 let Some(signature) = signature else {
1006 return;
1007 };
1008 assert_eq!(
1009 signature.parameters,
1010 vec![
1011 ParsedSassCallableParameterFact {
1012 name: "tone".to_string(),
1013 default_repr: Some("red".to_string()),
1014 variadic: false,
1015 },
1016 ParsedSassCallableParameterFact {
1017 name: "parts".to_string(),
1018 default_repr: None,
1019 variadic: true,
1020 },
1021 ]
1022 );
1023 assert!(signature.accepts_content);
1024
1025 let function = facts
1026 .iter()
1027 .find(|fact| fact.kind == ParsedSassSymbolFactKind::FunctionDeclaration);
1028 assert!(function.is_some(), "function declaration fact");
1029 let Some(function) = function else {
1030 return;
1031 };
1032 assert!(function.is_top_level);
1033 let function_signature = function.callable_signature.as_deref();
1034 assert!(function_signature.is_some(), "function signature");
1035 let Some(function_signature) = function_signature else {
1036 return;
1037 };
1038 assert_eq!(
1039 function_signature.parameters[0].default_repr.as_deref(),
1040 Some("1")
1041 );
1042 assert!(!function_signature.accepts_content);
1043 }
1044
1045 #[test]
1046 fn placeholder_definitions_remain_distinct_from_extend_targets() {
1047 let source = "%surface { color: red; }\n.card { @extend %surface; }";
1048 let parsed = parse(source, StyleDialect::Scss);
1049
1050 let definitions = collect_sass_placeholder_definition_facts_from_cst(source, &parsed);
1051 assert_eq!(definitions.len(), 1);
1052 assert_eq!(definitions[0].name, "surface");
1053 assert!(definitions[0].is_top_level);
1054
1055 let targets = collect_extend_target_facts_from_cst(source, &parsed);
1056 assert_eq!(targets.len(), 1);
1057 assert_eq!(targets[0].name, "surface");
1058 }
1059
1060 #[test]
1061 fn module_edges_preserve_top_level_export_visibility() {
1062 let source =
1063 "@forward './public' as api-* show $brand, tone;\n.scope { @forward './nested'; }";
1064 let parsed = parse(source, StyleDialect::Scss);
1065 let edges = collect_sass_module_edge_facts_from_cst(source, &parsed);
1066
1067 assert_eq!(edges.len(), 2);
1068 assert!(edges[0].is_top_level);
1069 assert_eq!(edges[0].forward_prefix.as_deref(), Some("api-"));
1070 assert_eq!(edges[0].visibility_filter_names, vec!["brand", "tone"]);
1071 assert_eq!(
1072 edges[0].visibility_filter_export_names,
1073 vec!["$brand", "tone"]
1074 );
1075 assert!(!edges[1].is_top_level);
1076 }
1077}