Skip to main content

omena_parser/facts/
sass.rs

1//! Parser facts for Sass symbols, module edges, includes, and extend targets.
2//!
3//! These records intentionally stop at syntax-level visibility and target
4//! extraction; module graph resolution is owned by downstream query layers.
5
6use 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    /// RFC-0007-D1 (#44): whether this `@import` target carries a trailing media
539    /// qualifier (`@import "foo" screen`, `@import "foo" (min-width: 100px)`). Sass
540    /// keeps media-qualified imports as plain CSS (NOT deprecated). Recoverable only
541    /// in the parser, where the target's comma-peer segment is still tokenized: a
542    /// non-`Comma` significant token after the target String marks the qualifier.
543    /// Always `false` for `Use`/`Forward` edges (media qualifiers are `@import`-only).
544    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        // A trailing media qualifier keeps `@import` as plain CSS. Classify per
684        // comma-peer target: `@import "a", "b" screen` qualifies only `"b"`.
685        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/// RFC-0007-E1 (#45): the target of an `@extend` rule. The `ScssExtendRule` node previously
861/// parsed and then discarded its target, so an `@extend %nonexistent` / `@extend .missing`
862/// (a dart-sass hard error) went unreported. This fact captures the simple target selector,
863/// whether it carries the `!optional` flag, and its source range for diagnostic anchoring.
864#[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
878/// Capture the target of each `@extend` rule. For each `@extend` keyword, the
879/// statement runs to the next `;`/`}`/indent boundary. Within it we capture the
880/// first simple target: a `%placeholder` token or a `.class` token pair. Compound
881/// targets record only the first simple selector; dart-sass rejects compound
882/// `@extend` targets, so the first-simple capture is sufficient for missing-target
883/// checks without over-reporting. Interpolated targets produce no simple token
884/// here and are skipped because they are not statically checkable.
885pub(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        // `!optional` may appear after the target; scan the whole statement for it first.
910        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}