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
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    /// RFC-0007-D1 (#44): whether this `@import` target carries a trailing media
531    /// qualifier (`@import "foo" screen`, `@import "foo" (min-width: 100px)`). Sass
532    /// keeps media-qualified imports as plain CSS (NOT deprecated). Recoverable only
533    /// in the parser, where the target's comma-peer segment is still tokenized: a
534    /// non-`Comma` significant token after the target String marks the qualifier.
535    /// Always `false` for `Use`/`Forward` edges (media qualifiers are `@import`-only).
536    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        // A trailing media qualifier keeps `@import` as plain CSS. Classify per
667        // comma-peer target: `@import "a", "b" screen` qualifies only `"b"`.
668        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/// RFC-0007-E1 (#45): the target of an `@extend` rule. The `ScssExtendRule` node previously
850/// parsed and then discarded its target, so an `@extend %nonexistent` / `@extend .missing`
851/// (a dart-sass hard error) went unreported. This fact captures the simple target selector,
852/// whether it carries the `!optional` flag, and its source range for diagnostic anchoring.
853#[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/// Capture the target of each `@extend` rule. For each `@extend` keyword, the
868/// statement runs to the next `;`/`}`/indent boundary. Within it we capture the
869/// first simple target: a `%placeholder` token or a `.class` token pair. Compound
870/// targets record only the first simple selector; dart-sass rejects compound
871/// `@extend` targets, so the first-simple capture is sufficient for missing-target
872/// checks without over-reporting. Interpolated targets produce no simple token
873/// here and are skipped because they are not statically checkable.
874#[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        // `!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 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}