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