Skip to main content

omena_parser/facts/
css_modules.rs

1//! Parser facts for CSS Modules `:export`, `:import`, `@value`, and `composes`.
2//!
3//! This module stays syntax-only: it records local edges and references so
4//! query/resolution layers can perform cross-file interpretation later.
5
6use cstree::text::TextRange;
7use omena_syntax::{SyntaxKind, css_keyword};
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::{
11    SelectorBranch, Token, css_module_block_scope_marker_in_header, matches_ignore_ascii_case,
12    next_non_trivia_token_index_until, previous_non_trivia_token_index, resolve_selector_header,
13    skip_trivia_tokens, top_level_token_kind_index, top_level_token_text_index,
14};
15
16use super::{StyleFactNodeEvent, StyleFactSink};
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct ParsedCssModuleValueFact {
20    pub kind: ParsedCssModuleValueFactKind,
21    pub name: String,
22    pub range: TextRange,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
26pub enum ParsedCssModuleValueFactKind {
27    Definition,
28    Reference,
29    ImportSource,
30}
31
32pub(crate) fn collect_css_module_value_facts_from_sink(
33    sink: &StyleFactSink<'_>,
34) -> Vec<ParsedCssModuleValueFact> {
35    let mut values = Vec::new();
36    let mut seen = BTreeSet::new();
37    let value_path_aliases = collect_css_module_value_path_aliases_from_sink(sink);
38    for node in css_module_value_statement_nodes(sink) {
39        collect_css_module_value_statement_facts(
40            sink.node_tokens(node),
41            &value_path_aliases,
42            &mut values,
43            &mut seen,
44        );
45    }
46    let local_value_names = values
47        .iter()
48        .filter(|value| value.kind == ParsedCssModuleValueFactKind::Definition)
49        .map(|value| value.name.clone())
50        .collect::<BTreeSet<_>>();
51    for node in css_module_value_reference_declaration_nodes(sink) {
52        collect_css_module_value_declaration_reference_facts_from_declaration_tokens(
53            sink.node_tokens(node),
54            &local_value_names,
55            &mut values,
56            &mut seen,
57        );
58    }
59    values
60}
61
62fn collect_css_module_value_statement_facts(
63    tokens: &[Token<'_>],
64    value_path_aliases: &BTreeMap<String, String>,
65    values: &mut Vec<ParsedCssModuleValueFact>,
66    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
67) {
68    let Some(index) = tokens.iter().position(|token| {
69        token.kind == SyntaxKind::AtKeyword && matches_ignore_ascii_case(token.text, &["@value"])
70    }) else {
71        return;
72    };
73
74    let start = skip_trivia_tokens(tokens, index + 1, tokens.len());
75    let end = css_module_value_statement_end(tokens, start);
76    let colon_index = top_level_token_kind_index(tokens, start, end, SyntaxKind::Colon);
77    let from_index = top_level_token_text_index(tokens, start, end, "from");
78
79    if let Some(from_index) = from_index
80        && match colon_index {
81            Some(colon_index) => from_index < colon_index,
82            None => true,
83        }
84    {
85        collect_css_module_value_import_facts(
86            tokens,
87            start,
88            from_index,
89            end,
90            value_path_aliases,
91            values,
92            seen,
93        );
94        return;
95    }
96
97    if let Some(colon_index) = colon_index {
98        if css_module_value_path_alias_from_tokens(tokens, start, colon_index, end).is_some() {
99            return;
100        }
101        collect_css_module_value_definition_facts(tokens, start, colon_index, values, seen);
102        collect_css_module_value_reference_facts(tokens, colon_index + 1, end, values, seen);
103    } else {
104        collect_css_module_value_definition_facts(tokens, start, end, values, seen);
105    }
106}
107
108fn collect_css_module_value_path_aliases_from_sink(
109    sink: &StyleFactSink<'_>,
110) -> BTreeMap<String, String> {
111    let mut aliases = BTreeMap::new();
112    for node in css_module_value_statement_nodes(sink) {
113        collect_css_module_value_path_aliases_from_statement_tokens(
114            sink.node_tokens(node),
115            &mut aliases,
116        );
117    }
118    aliases
119}
120
121fn collect_css_module_value_path_aliases_from_statement_tokens(
122    tokens: &[Token<'_>],
123    aliases: &mut BTreeMap<String, String>,
124) {
125    let Some(index) = tokens.iter().position(|token| {
126        token.kind == SyntaxKind::AtKeyword && matches_ignore_ascii_case(token.text, &["@value"])
127    }) else {
128        return;
129    };
130
131    let start = skip_trivia_tokens(tokens, index + 1, tokens.len());
132    let end = css_module_value_statement_end(tokens, start);
133    let Some(colon_index) = top_level_token_kind_index(tokens, start, end, SyntaxKind::Colon)
134    else {
135        return;
136    };
137    if top_level_token_text_index(tokens, start, end, "from").is_some() {
138        return;
139    }
140    if let Some((name, target)) =
141        css_module_value_path_alias_from_tokens(tokens, start, colon_index, end)
142    {
143        aliases.insert(name, target);
144    }
145}
146
147fn css_module_value_path_alias_from_tokens(
148    tokens: &[Token<'_>],
149    start: usize,
150    colon_index: usize,
151    end: usize,
152) -> Option<(String, String)> {
153    let name_index = next_non_trivia_token_index_until(tokens, start, colon_index)?;
154    let name_token = tokens[name_index];
155    if !css_module_value_name_token_can_define(name_token) {
156        return None;
157    }
158    let source_index = next_non_trivia_token_index_until(tokens, colon_index + 1, end)?;
159    let source_token = tokens[source_index];
160    if !matches!(source_token.kind, SyntaxKind::String | SyntaxKind::Url) {
161        return None;
162    }
163    let source = css_module_value_source_name(source_token);
164    css_module_value_source_looks_like_style_request(&source)
165        .then(|| (name_token.text.to_string(), source))
166}
167
168pub(crate) fn css_module_value_statement_end(tokens: &[Token<'_>], start: usize) -> usize {
169    let mut index = start;
170    let mut paren_depth = 0usize;
171    let mut bracket_depth = 0usize;
172    while index < tokens.len() {
173        match tokens[index].kind {
174            SyntaxKind::LeftParen => paren_depth += 1,
175            SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
176            SyntaxKind::LeftBracket => bracket_depth += 1,
177            SyntaxKind::RightBracket => bracket_depth = bracket_depth.saturating_sub(1),
178            SyntaxKind::Semicolon
179            | SyntaxKind::SassOptionalSemicolon
180            | SyntaxKind::LeftBrace
181            | SyntaxKind::RightBrace
182            | SyntaxKind::SassIndent
183            | SyntaxKind::SassDedent
184                if paren_depth == 0 && bracket_depth == 0 =>
185            {
186                return index;
187            }
188            _ => {}
189        }
190        index += 1;
191    }
192    index
193}
194
195fn collect_css_module_value_import_facts(
196    tokens: &[Token<'_>],
197    start: usize,
198    from_index: usize,
199    end: usize,
200    value_path_aliases: &BTreeMap<String, String>,
201    values: &mut Vec<ParsedCssModuleValueFact>,
202    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
203) {
204    collect_css_module_value_import_names(tokens, start, from_index, values, seen);
205    if let Some((source_name, source_range)) =
206        css_module_value_import_edge_source(tokens, from_index + 1, end, value_path_aliases)
207    {
208        push_css_module_value_fact(
209            values,
210            seen,
211            ParsedCssModuleValueFactKind::ImportSource,
212            source_name,
213            source_range,
214        );
215    }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct ParsedCssModuleValueImportEdgeFact {
220    pub remote_name: String,
221    pub local_name: String,
222    pub import_source: String,
223    pub local_range: TextRange,
224    pub remote_range: TextRange,
225    pub range: TextRange,
226}
227
228pub(crate) fn collect_css_module_value_import_edge_facts_from_sink(
229    sink: &StyleFactSink<'_>,
230) -> Vec<ParsedCssModuleValueImportEdgeFact> {
231    let mut edges = Vec::new();
232    let value_path_aliases = collect_css_module_value_path_aliases_from_sink(sink);
233    for node in css_module_value_statement_nodes(sink) {
234        collect_css_module_value_import_edge_statement_facts(
235            sink.node_tokens(node),
236            &value_path_aliases,
237            &mut edges,
238        );
239    }
240    edges
241}
242
243fn collect_css_module_value_import_edge_statement_facts(
244    tokens: &[Token<'_>],
245    value_path_aliases: &BTreeMap<String, String>,
246    edges: &mut Vec<ParsedCssModuleValueImportEdgeFact>,
247) {
248    let Some(index) = tokens.iter().position(|token| {
249        token.kind == SyntaxKind::AtKeyword && matches_ignore_ascii_case(token.text, &["@value"])
250    }) else {
251        return;
252    };
253
254    let start = skip_trivia_tokens(tokens, index + 1, tokens.len());
255    let end = css_module_value_statement_end(tokens, start);
256    let colon_index = top_level_token_kind_index(tokens, start, end, SyntaxKind::Colon);
257    let from_index = top_level_token_text_index(tokens, start, end, "from");
258    let Some(from_index) = from_index else {
259        return;
260    };
261    if colon_index.is_some_and(|colon_index| from_index > colon_index) {
262        return;
263    }
264    let Some((import_source, _source_range)) =
265        css_module_value_import_edge_source(tokens, from_index + 1, end, value_path_aliases)
266    else {
267        return;
268    };
269
270    collect_css_module_value_import_edges(tokens, start, from_index, import_source, edges);
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
274pub struct ParsedCssModuleValueDefinitionEdgeFact {
275    pub definition_name: String,
276    pub reference_names: Vec<String>,
277    pub range: TextRange,
278}
279
280pub(crate) fn collect_css_module_value_definition_edge_facts_from_sink(
281    sink: &StyleFactSink<'_>,
282) -> Vec<ParsedCssModuleValueDefinitionEdgeFact> {
283    let mut edges = Vec::new();
284    for node in css_module_value_statement_nodes(sink) {
285        collect_css_module_value_definition_edge_statement_facts(
286            sink.node_tokens(node),
287            &mut edges,
288        );
289    }
290    edges
291}
292
293fn collect_css_module_value_definition_edge_statement_facts(
294    tokens: &[Token<'_>],
295    edges: &mut Vec<ParsedCssModuleValueDefinitionEdgeFact>,
296) {
297    let Some(index) = tokens.iter().position(|token| {
298        token.kind == SyntaxKind::AtKeyword && matches_ignore_ascii_case(token.text, &["@value"])
299    }) else {
300        return;
301    };
302
303    let start = skip_trivia_tokens(tokens, index + 1, tokens.len());
304    let end = css_module_value_statement_end(tokens, start);
305    let colon_index = top_level_token_kind_index(tokens, start, end, SyntaxKind::Colon);
306    let from_index = top_level_token_text_index(tokens, start, end, "from");
307    let Some(colon_index) = colon_index else {
308        return;
309    };
310    if from_index.is_some_and(|from_index| from_index < colon_index) {
311        return;
312    }
313
314    let definition_names = collect_css_module_value_definition_edge_names(
315        tokens,
316        start,
317        colon_index,
318        |tokens, index| css_module_value_name_token_can_define(tokens[index]),
319    );
320    let reference_names = collect_css_module_value_definition_edge_names(
321        tokens,
322        colon_index + 1,
323        end,
324        css_module_value_reference_token_can_be_name,
325    );
326    if reference_names.is_empty() {
327        return;
328    }
329    let range_end = end
330        .checked_sub(1)
331        .and_then(|end| tokens.get(end))
332        .map(|token| token.range.end())
333        .unwrap_or_else(|| tokens[index].range.end());
334
335    for definition_name in definition_names {
336        edges.push(ParsedCssModuleValueDefinitionEdgeFact {
337            definition_name,
338            reference_names: reference_names.clone(),
339            range: TextRange::new(tokens[index].range.start(), range_end),
340        });
341    }
342}
343
344pub(crate) fn collect_css_module_value_definition_edge_names(
345    tokens: &[Token<'_>],
346    start: usize,
347    end: usize,
348    predicate: impl Fn(&[Token<'_>], usize) -> bool,
349) -> Vec<String> {
350    let mut names = Vec::new();
351    let mut index = start;
352    while index < end {
353        if predicate(tokens, index) && !names.iter().any(|name| name == tokens[index].text) {
354            names.push(tokens[index].text.to_string());
355        }
356        index += 1;
357    }
358    names
359}
360
361fn css_module_value_statement_nodes<'sink>(
362    sink: &'sink StyleFactSink<'_>,
363) -> impl Iterator<Item = &'sink StyleFactNodeEvent> {
364    sink.nodes().filter(|node| {
365        matches!(
366            node.kind,
367            SyntaxKind::CssModuleExportBlock
368                | SyntaxKind::CssModuleImportBlock
369                | SyntaxKind::BogusCssModuleBlock
370        )
371    })
372}
373
374fn css_module_value_reference_declaration_nodes<'sink>(
375    sink: &'sink StyleFactSink<'_>,
376) -> impl Iterator<Item = &'sink StyleFactNodeEvent> {
377    sink.nodes().filter(|node| {
378        matches!(
379            node.kind,
380            SyntaxKind::Declaration | SyntaxKind::CssModuleComposesDeclaration
381        )
382    })
383}
384
385fn css_module_composes_declaration_nodes<'sink>(
386    sink: &'sink StyleFactSink<'_>,
387) -> impl Iterator<Item = &'sink StyleFactNodeEvent> {
388    sink.nodes()
389        .filter(|node| node.kind == SyntaxKind::CssModuleComposesDeclaration)
390}
391
392fn css_module_value_import_edge_source(
393    tokens: &[Token<'_>],
394    start: usize,
395    end: usize,
396    value_path_aliases: &BTreeMap<String, String>,
397) -> Option<(String, TextRange)> {
398    let source_index = next_non_trivia_token_index_until(tokens, start, end)?;
399    let token = tokens[source_index];
400    if matches!(token.kind, SyntaxKind::String | SyntaxKind::Url) {
401        return Some((css_module_value_source_name(token), token.range));
402    }
403    if css_module_value_name_token_can_define(token) {
404        return css_module_value_source_alias_target(token.text, token.range, value_path_aliases);
405    }
406    None
407}
408
409fn css_module_value_source_alias_target(
410    name: &str,
411    range: TextRange,
412    value_path_aliases: &BTreeMap<String, String>,
413) -> Option<(String, TextRange)> {
414    value_path_aliases
415        .get(name)
416        .map(|source| (source.clone(), range))
417}
418
419fn collect_css_module_value_import_edges(
420    tokens: &[Token<'_>],
421    start: usize,
422    end: usize,
423    import_source: String,
424    edges: &mut Vec<ParsedCssModuleValueImportEdgeFact>,
425) {
426    let mut index = start;
427    while index < end {
428        let token = tokens[index];
429        if !css_module_value_name_token_can_define(token) {
430            index += 1;
431            continue;
432        }
433        if previous_non_trivia_token_index(tokens, index, start)
434            .is_some_and(|previous| css_keyword(tokens[previous].text).equals("as"))
435        {
436            index += 1;
437            continue;
438        }
439        let remote_name = token.text.to_string();
440        let mut local_name = remote_name.clone();
441        let mut local_range = token.range;
442        if let Some(as_index) = next_non_trivia_token_index_until(tokens, index + 1, end)
443            && css_keyword(tokens[as_index].text).equals("as")
444            && let Some(local_index) = next_non_trivia_token_index_until(tokens, as_index + 1, end)
445            && css_module_value_name_token_can_define(tokens[local_index])
446        {
447            local_name = tokens[local_index].text.to_string();
448            local_range = tokens[local_index].range;
449            index = local_index + 1;
450        } else {
451            index += 1;
452        }
453        edges.push(ParsedCssModuleValueImportEdgeFact {
454            remote_name,
455            local_name,
456            import_source: import_source.clone(),
457            local_range,
458            remote_range: token.range,
459            range: token.range,
460        });
461    }
462}
463
464fn collect_css_module_value_import_names(
465    tokens: &[Token<'_>],
466    start: usize,
467    end: usize,
468    values: &mut Vec<ParsedCssModuleValueFact>,
469    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
470) {
471    let mut index = start;
472    while index < end {
473        let token = tokens[index];
474        if css_module_value_name_token_can_define(token) {
475            let previous = previous_non_trivia_token_index(tokens, index, start);
476            let next = next_non_trivia_token_index_until(tokens, index + 1, end);
477            let kind = if previous
478                .is_some_and(|previous| css_keyword(tokens[previous].text).equals("as"))
479            {
480                Some(ParsedCssModuleValueFactKind::Definition)
481            } else if next.is_some_and(|next| css_keyword(tokens[next].text).equals("as")) {
482                Some(ParsedCssModuleValueFactKind::Reference)
483            } else {
484                Some(ParsedCssModuleValueFactKind::Definition)
485            };
486            if let Some(kind) = kind {
487                push_css_module_value_fact(values, seen, kind, token.text.to_string(), token.range);
488            }
489        }
490        index += 1;
491    }
492}
493
494fn collect_css_module_value_definition_facts(
495    tokens: &[Token<'_>],
496    start: usize,
497    end: usize,
498    values: &mut Vec<ParsedCssModuleValueFact>,
499    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
500) {
501    let mut index = start;
502    while index < end {
503        let token = tokens[index];
504        if css_module_value_name_token_can_define(token) {
505            push_css_module_value_fact(
506                values,
507                seen,
508                ParsedCssModuleValueFactKind::Definition,
509                token.text.to_string(),
510                token.range,
511            );
512        }
513        index += 1;
514    }
515}
516
517fn collect_css_module_value_reference_facts(
518    tokens: &[Token<'_>],
519    start: usize,
520    end: usize,
521    values: &mut Vec<ParsedCssModuleValueFact>,
522    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
523) {
524    let mut index = start;
525    let mut paren_depth = 0usize;
526    let mut bracket_depth = 0usize;
527    while index < end {
528        match tokens[index].kind {
529            SyntaxKind::LeftParen => paren_depth += 1,
530            SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
531            SyntaxKind::LeftBracket => bracket_depth += 1,
532            SyntaxKind::RightBracket => bracket_depth = bracket_depth.saturating_sub(1),
533            _ => {}
534        }
535        if paren_depth == 0
536            && bracket_depth == 0
537            && css_module_value_reference_token_can_be_name(tokens, index)
538        {
539            push_css_module_value_fact(
540                values,
541                seen,
542                ParsedCssModuleValueFactKind::Reference,
543                tokens[index].text.to_string(),
544                tokens[index].range,
545            );
546        }
547        index += 1;
548    }
549}
550
551fn collect_css_module_value_declaration_reference_facts_from_declaration_tokens(
552    tokens: &[Token<'_>],
553    local_value_names: &BTreeSet<String>,
554    values: &mut Vec<ParsedCssModuleValueFact>,
555    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
556) {
557    if local_value_names.is_empty() {
558        return;
559    }
560    if let Some(colon_index) = declaration_colon_index(tokens, 0, tokens.len()) {
561        collect_known_css_module_value_reference_facts(
562            tokens,
563            colon_index + 1,
564            tokens.len(),
565            local_value_names,
566            values,
567            seen,
568        );
569    }
570}
571
572pub(crate) fn declaration_colon_index(
573    tokens: &[Token<'_>],
574    start: usize,
575    end: usize,
576) -> Option<usize> {
577    let colon_index = top_level_token_kind_index(tokens, start, end, SyntaxKind::Colon)?;
578    let property_index = previous_non_trivia_token_index(tokens, colon_index, start)?;
579    if !matches!(
580        tokens[property_index].kind,
581        SyntaxKind::Ident
582            | SyntaxKind::CustomPropertyName
583            | SyntaxKind::ScssVariable
584            | SyntaxKind::LessVariable
585            | SyntaxKind::LessPropertyVariableToken
586    ) {
587        return None;
588    }
589    let value_index = next_non_trivia_token_index_until(tokens, colon_index + 1, end)?;
590    if matches!(
591        tokens[value_index].kind,
592        SyntaxKind::LeftBrace | SyntaxKind::LeftParen | SyntaxKind::LeftBracket
593    ) {
594        return None;
595    }
596    Some(colon_index)
597}
598
599fn collect_known_css_module_value_reference_facts(
600    tokens: &[Token<'_>],
601    start: usize,
602    end: usize,
603    local_value_names: &BTreeSet<String>,
604    values: &mut Vec<ParsedCssModuleValueFact>,
605    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
606) {
607    let mut index = start;
608    let mut paren_depth = 0usize;
609    let mut bracket_depth = 0usize;
610    while index < end {
611        match tokens[index].kind {
612            SyntaxKind::LeftParen => paren_depth += 1,
613            SyntaxKind::RightParen => paren_depth = paren_depth.saturating_sub(1),
614            SyntaxKind::LeftBracket => bracket_depth += 1,
615            SyntaxKind::RightBracket => bracket_depth = bracket_depth.saturating_sub(1),
616            _ => {}
617        }
618        if paren_depth == 0
619            && bracket_depth == 0
620            && css_module_value_reference_token_can_be_name(tokens, index)
621            && local_value_names.contains(tokens[index].text)
622        {
623            push_css_module_value_fact(
624                values,
625                seen,
626                ParsedCssModuleValueFactKind::Reference,
627                tokens[index].text.to_string(),
628                tokens[index].range,
629            );
630        }
631        index += 1;
632    }
633}
634
635fn push_css_module_value_fact(
636    values: &mut Vec<ParsedCssModuleValueFact>,
637    seen: &mut BTreeSet<(ParsedCssModuleValueFactKind, String, u32, u32)>,
638    kind: ParsedCssModuleValueFactKind,
639    name: String,
640    range: TextRange,
641) {
642    if seen.insert((
643        kind,
644        name.clone(),
645        u32::from(range.start()),
646        u32::from(range.end()),
647    )) {
648        values.push(ParsedCssModuleValueFact { kind, name, range });
649    }
650}
651
652fn css_module_value_name_token_can_define(token: Token<'_>) -> bool {
653    matches!(
654        token.kind,
655        SyntaxKind::Ident | SyntaxKind::CustomPropertyName
656    ) && !css_keyword(token.text).equals("as")
657        && !css_keyword(token.text).equals("from")
658}
659
660pub(crate) fn css_module_value_reference_token_can_be_name(
661    tokens: &[Token<'_>],
662    index: usize,
663) -> bool {
664    let token = tokens[index];
665    if !matches!(
666        token.kind,
667        SyntaxKind::Ident | SyntaxKind::CustomPropertyName
668    ) {
669        return false;
670    }
671    if let Some(next_index) = next_non_trivia_token_index_until(tokens, index + 1, tokens.len())
672        && tokens[next_index].kind == SyntaxKind::LeftParen
673    {
674        return false;
675    }
676    !css_module_value_literal_ident_is_not_reference(token.text)
677}
678
679fn css_module_value_literal_ident_is_not_reference(name: &str) -> bool {
680    matches_ignore_ascii_case(
681        name,
682        &[
683            "initial",
684            "inherit",
685            "unset",
686            "revert",
687            "revert-layer",
688            "none",
689            "auto",
690            "normal",
691            "transparent",
692            "currentcolor",
693            "black",
694            "white",
695            "red",
696            "green",
697            "blue",
698            "yellow",
699            "magenta",
700            "cyan",
701            "solid",
702            "dashed",
703            "block",
704            "inline",
705            "flex",
706            "grid",
707        ],
708    )
709}
710
711pub(crate) fn css_module_value_source_name(token: Token<'_>) -> String {
712    token
713        .text
714        .trim_matches(|character| character == '"' || character == '\'')
715        .to_string()
716}
717
718fn css_module_value_source_looks_like_style_request(source: &str) -> bool {
719    let lower = source.to_ascii_lowercase();
720    (lower.starts_with('/') || lower.starts_with("./") || lower.starts_with("../"))
721        && (lower.ends_with(".css")
722            || lower.ends_with(".scss")
723            || lower.ends_with(".sass")
724            || lower.ends_with(".less"))
725}
726
727#[derive(Debug, Clone, PartialEq, Eq)]
728pub struct ParsedCssModuleComposesFact {
729    pub kind: ParsedCssModuleComposesFactKind,
730    pub name: String,
731    pub range: TextRange,
732}
733
734#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
735pub enum ParsedCssModuleComposesFactKind {
736    Target,
737    ImportSource,
738}
739
740#[derive(Debug, Clone, PartialEq, Eq)]
741pub struct ParsedCssModuleComposesEdgeFact {
742    pub kind: ParsedCssModuleComposesEdgeKind,
743    pub owner_selector_names: Vec<String>,
744    pub target_names: Vec<String>,
745    pub import_source: Option<String>,
746    pub range: TextRange,
747}
748
749#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
750pub enum ParsedCssModuleComposesEdgeKind {
751    Local,
752    Global,
753    External,
754}
755
756pub(crate) fn collect_css_module_composes_facts_from_sink(
757    sink: &StyleFactSink<'_>,
758) -> Vec<ParsedCssModuleComposesFact> {
759    let mut composes = Vec::new();
760    let mut seen = BTreeSet::new();
761    for node in css_module_composes_declaration_nodes(sink) {
762        collect_css_module_composes_statement_facts(
763            sink.node_tokens(node),
764            &mut composes,
765            &mut seen,
766        );
767    }
768    composes
769}
770
771fn collect_css_module_composes_statement_facts(
772    tokens: &[Token<'_>],
773    composes: &mut Vec<ParsedCssModuleComposesFact>,
774    seen: &mut BTreeSet<(ParsedCssModuleComposesFactKind, String, u32, u32)>,
775) {
776    let Some(index) = tokens.iter().position(|token| {
777        token.kind == SyntaxKind::Ident && matches_ignore_ascii_case(token.text, &["composes"])
778    }) else {
779        return;
780    };
781    let Some(colon_index) = next_non_trivia_token_index_until(tokens, index + 1, tokens.len())
782    else {
783        return;
784    };
785    if tokens[colon_index].kind != SyntaxKind::Colon {
786        return;
787    }
788
789    let start = colon_index + 1;
790    let end = css_module_value_statement_end(tokens, start);
791    let from_index = top_level_token_text_index(tokens, start, end, "from");
792    let target_end = from_index.unwrap_or(end);
793    collect_css_module_composes_targets(tokens, start, target_end, composes, seen);
794    if let Some(from_index) = from_index {
795        collect_css_module_composes_import_source(tokens, from_index + 1, end, composes, seen);
796    }
797}
798
799pub(crate) fn collect_css_module_composes_edge_facts_from_sink(
800    sink: &StyleFactSink<'_>,
801) -> Vec<ParsedCssModuleComposesEdgeFact> {
802    let mut edges = Vec::new();
803    for declaration in sink
804        .nodes()
805        .filter(|node| node.kind == SyntaxKind::CssModuleComposesDeclaration)
806    {
807        let owner_branches = css_module_composes_owner_branches_from_sink(sink, declaration);
808        if owner_branches.is_empty() {
809            continue;
810        }
811        let tokens = sink.node_tokens(declaration);
812        collect_immediate_css_module_composes_edge_facts(
813            tokens,
814            0,
815            tokens.len(),
816            &owner_branches,
817            &mut edges,
818        );
819    }
820    edges
821}
822
823fn css_module_composes_owner_branches_from_sink(
824    sink: &StyleFactSink<'_>,
825    declaration: &StyleFactNodeEvent,
826) -> Vec<SelectorBranch> {
827    let mut branches = Vec::new();
828    let mut css_module_scope = None;
829    let mut ancestors = sink.ancestors_inclusive(declaration);
830    ancestors.reverse();
831    for ancestor in ancestors {
832        match ancestor.kind {
833            SyntaxKind::Rule | SyntaxKind::NestRule => {
834                let tokens = sink.node_tokens(ancestor);
835                let Some(open) = first_block_open_token_index(tokens) else {
836                    continue;
837                };
838                let header_start = if ancestor.kind == SyntaxKind::NestRule {
839                    tokens
840                        .iter()
841                        .position(|token| token.kind == SyntaxKind::AtKeyword)
842                        .map_or(0, |index| index + 1)
843                } else {
844                    0
845                };
846                let effective_scope = css_module_scope.or_else(|| {
847                    css_module_block_scope_marker_in_header(tokens, header_start, open)
848                });
849                if effective_scope == Some("global") {
850                    branches.clear();
851                } else {
852                    branches = resolve_selector_header(tokens, header_start, open, &branches);
853                }
854                css_module_scope = effective_scope;
855            }
856            SyntaxKind::CssModuleGlobalBlock => {
857                branches.clear();
858                css_module_scope = Some("global");
859            }
860            SyntaxKind::CssModuleLocalBlock if css_module_scope.is_none() => {
861                css_module_scope = Some("local");
862            }
863            _ => {}
864        }
865    }
866    if css_module_scope == Some("global") {
867        Vec::new()
868    } else {
869        branches
870    }
871}
872
873fn first_block_open_token_index(tokens: &[Token<'_>]) -> Option<usize> {
874    tokens
875        .iter()
876        .position(|token| matches!(token.kind, SyntaxKind::LeftBrace | SyntaxKind::SassIndent))
877}
878
879fn collect_immediate_css_module_composes_edge_facts(
880    tokens: &[Token<'_>],
881    start: usize,
882    end: usize,
883    owner_branches: &[SelectorBranch],
884    edges: &mut Vec<ParsedCssModuleComposesEdgeFact>,
885) {
886    let owner_selector_names = sorted_selector_branch_names(owner_branches);
887    let mut index = start;
888    let mut block_depth = 0usize;
889    while index < end {
890        match tokens[index].kind {
891            SyntaxKind::LeftBrace | SyntaxKind::SassIndent => {
892                block_depth += 1;
893                index += 1;
894                continue;
895            }
896            SyntaxKind::RightBrace | SyntaxKind::SassDedent => {
897                block_depth = block_depth.saturating_sub(1);
898                index += 1;
899                continue;
900            }
901            _ => {}
902        }
903        if block_depth > 0
904            || tokens[index].kind != SyntaxKind::Ident
905            || !matches_ignore_ascii_case(tokens[index].text, &["composes"])
906        {
907            index += 1;
908            continue;
909        }
910        let Some(colon_index) = next_non_trivia_token_index_until(tokens, index + 1, end) else {
911            index += 1;
912            continue;
913        };
914        if tokens[colon_index].kind != SyntaxKind::Colon {
915            index += 1;
916            continue;
917        }
918
919        let value_start = colon_index + 1;
920        let value_end = css_module_value_statement_end(tokens, value_start).min(end);
921        let from_index = top_level_token_text_index(tokens, value_start, value_end, "from");
922        let target_end = from_index.unwrap_or(value_end);
923        let target_names =
924            collect_css_module_composes_target_names(tokens, value_start, target_end);
925        if target_names.is_empty() {
926            index = value_end;
927            continue;
928        }
929
930        let (kind, import_source) = from_index
931            .and_then(|from_index| {
932                css_module_composes_import_edge_source(tokens, from_index + 1, value_end)
933            })
934            .map(|source| {
935                if source == "global" {
936                    (ParsedCssModuleComposesEdgeKind::Global, Some(source))
937                } else {
938                    (ParsedCssModuleComposesEdgeKind::External, Some(source))
939                }
940            })
941            .unwrap_or((ParsedCssModuleComposesEdgeKind::Local, None));
942        let range_end = value_end
943            .checked_sub(1)
944            .and_then(|end| tokens.get(end))
945            .map(|token| token.range.end())
946            .unwrap_or_else(|| tokens[index].range.end());
947
948        edges.push(ParsedCssModuleComposesEdgeFact {
949            kind,
950            owner_selector_names: owner_selector_names.clone(),
951            target_names,
952            import_source,
953            range: TextRange::new(tokens[index].range.start(), range_end),
954        });
955        index = value_end;
956    }
957}
958
959fn sorted_selector_branch_names(branches: &[SelectorBranch]) -> Vec<String> {
960    branches
961        .iter()
962        .map(|branch| branch.name.clone())
963        .collect::<BTreeSet<_>>()
964        .into_iter()
965        .collect()
966}
967
968fn collect_css_module_composes_target_names(
969    tokens: &[Token<'_>],
970    start: usize,
971    end: usize,
972) -> Vec<String> {
973    let mut names = Vec::new();
974    let mut index = start;
975    while index < end {
976        if let Some((open_index, close_index)) =
977            css_module_global_composes_function_range(tokens, index, end)
978        {
979            collect_css_module_composes_target_names_into(
980                tokens,
981                open_index + 1,
982                close_index,
983                &mut names,
984            );
985            index = close_index + 1;
986            continue;
987        }
988        push_css_module_composes_target_name(tokens[index], &mut names);
989        index += 1;
990    }
991    names
992}
993
994fn css_module_composes_import_edge_source(
995    tokens: &[Token<'_>],
996    start: usize,
997    end: usize,
998) -> Option<String> {
999    let source_index = next_non_trivia_token_index_until(tokens, start, end)?;
1000    let token = tokens[source_index];
1001    matches!(
1002        token.kind,
1003        SyntaxKind::String | SyntaxKind::Url | SyntaxKind::Ident
1004    )
1005    .then(|| css_module_value_source_name(token))
1006}
1007
1008fn collect_css_module_composes_targets(
1009    tokens: &[Token<'_>],
1010    start: usize,
1011    end: usize,
1012    composes: &mut Vec<ParsedCssModuleComposesFact>,
1013    seen: &mut BTreeSet<(ParsedCssModuleComposesFactKind, String, u32, u32)>,
1014) {
1015    let mut index = start;
1016    while index < end {
1017        if let Some((open_index, close_index)) =
1018            css_module_global_composes_function_range(tokens, index, end)
1019        {
1020            collect_css_module_composes_target_facts_into(
1021                tokens,
1022                open_index + 1,
1023                close_index,
1024                composes,
1025                seen,
1026            );
1027            index = close_index + 1;
1028            continue;
1029        }
1030        push_css_module_composes_target_fact(tokens[index], composes, seen);
1031        index += 1;
1032    }
1033}
1034
1035fn collect_css_module_composes_target_names_into(
1036    tokens: &[Token<'_>],
1037    start: usize,
1038    end: usize,
1039    names: &mut Vec<String>,
1040) {
1041    let mut index = start;
1042    while index < end {
1043        push_css_module_composes_target_name(tokens[index], names);
1044        index += 1;
1045    }
1046}
1047
1048fn push_css_module_composes_target_name(token: Token<'_>, names: &mut Vec<String>) {
1049    if matches!(
1050        token.kind,
1051        SyntaxKind::Ident | SyntaxKind::CustomPropertyName
1052    ) && !matches_ignore_ascii_case(token.text, &["from"])
1053        && !names.iter().any(|name| name == token.text)
1054    {
1055        names.push(token.text.to_string());
1056    }
1057}
1058
1059fn collect_css_module_composes_target_facts_into(
1060    tokens: &[Token<'_>],
1061    start: usize,
1062    end: usize,
1063    composes: &mut Vec<ParsedCssModuleComposesFact>,
1064    seen: &mut BTreeSet<(ParsedCssModuleComposesFactKind, String, u32, u32)>,
1065) {
1066    let mut index = start;
1067    while index < end {
1068        push_css_module_composes_target_fact(tokens[index], composes, seen);
1069        index += 1;
1070    }
1071}
1072
1073fn push_css_module_composes_target_fact(
1074    token: Token<'_>,
1075    composes: &mut Vec<ParsedCssModuleComposesFact>,
1076    seen: &mut BTreeSet<(ParsedCssModuleComposesFactKind, String, u32, u32)>,
1077) {
1078    if matches!(
1079        token.kind,
1080        SyntaxKind::Ident | SyntaxKind::CustomPropertyName
1081    ) && !matches_ignore_ascii_case(token.text, &["from"])
1082    {
1083        push_css_module_composes_fact(
1084            composes,
1085            seen,
1086            ParsedCssModuleComposesFactKind::Target,
1087            token.text.to_string(),
1088            token.range,
1089        );
1090    }
1091}
1092
1093fn css_module_global_composes_function_range(
1094    tokens: &[Token<'_>],
1095    index: usize,
1096    end: usize,
1097) -> Option<(usize, usize)> {
1098    if tokens.get(index)?.kind != SyntaxKind::Ident
1099        || !matches_ignore_ascii_case(tokens[index].text, &["global"])
1100    {
1101        return None;
1102    }
1103    let open_index = next_non_trivia_token_index_until(tokens, index + 1, end)?;
1104    if tokens[open_index].kind != SyntaxKind::LeftParen {
1105        return None;
1106    }
1107    let mut depth = 0usize;
1108    for (close_index, token) in tokens.iter().enumerate().take(end).skip(open_index) {
1109        match token.kind {
1110            SyntaxKind::LeftParen => depth += 1,
1111            SyntaxKind::RightParen => {
1112                depth = depth.saturating_sub(1);
1113                if depth == 0 {
1114                    return Some((open_index, close_index));
1115                }
1116            }
1117            _ => {}
1118        }
1119    }
1120    None
1121}
1122
1123fn collect_css_module_composes_import_source(
1124    tokens: &[Token<'_>],
1125    start: usize,
1126    end: usize,
1127    composes: &mut Vec<ParsedCssModuleComposesFact>,
1128    seen: &mut BTreeSet<(ParsedCssModuleComposesFactKind, String, u32, u32)>,
1129) {
1130    if let Some(source_index) = next_non_trivia_token_index_until(tokens, start, end) {
1131        let token = tokens[source_index];
1132        if matches!(
1133            token.kind,
1134            SyntaxKind::String | SyntaxKind::Url | SyntaxKind::Ident
1135        ) {
1136            push_css_module_composes_fact(
1137                composes,
1138                seen,
1139                ParsedCssModuleComposesFactKind::ImportSource,
1140                css_module_value_source_name(token),
1141                token.range,
1142            );
1143        }
1144    }
1145}
1146
1147fn push_css_module_composes_fact(
1148    composes: &mut Vec<ParsedCssModuleComposesFact>,
1149    seen: &mut BTreeSet<(ParsedCssModuleComposesFactKind, String, u32, u32)>,
1150    kind: ParsedCssModuleComposesFactKind,
1151    name: String,
1152    range: TextRange,
1153) {
1154    if seen.insert((
1155        kind,
1156        name.clone(),
1157        u32::from(range.start()),
1158        u32::from(range.end()),
1159    )) {
1160        composes.push(ParsedCssModuleComposesFact { kind, name, range });
1161    }
1162}