Skip to main content

omena_query/style/
diagnostics.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use omena_parser::{
4    ParsedAnimationFactKind, ParsedCssModuleComposesEdgeKind, ParsedSassModuleEdgeFactKind,
5    ParsedSelectorFactKind,
6};
7
8use super::cascade_checker::summarize_query_cascade_checker_diagnostics;
9use super::diagnostic_suppressions::apply_omena_query_style_diagnostic_suppressions;
10use super::parser_facade::collect_omena_query_omena_parser_style_facts_raw;
11use super::*;
12
13const LSP_DIAGNOSTIC_TAG_UNNECESSARY: u8 = 1;
14const LSP_DIAGNOSTIC_TAG_DEPRECATED: u8 = 2;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub enum OmenaQueryExternalModuleModeV0 {
18    Ignored,
19    Sif,
20}
21
22pub fn summarize_omena_query_missing_custom_property_diagnostics(
23    style_uri: &str,
24    source: &str,
25    candidates: &[OmenaQueryStyleHoverCandidateV0],
26) -> Vec<OmenaQueryStyleDiagnosticV0> {
27    let declaration_names = candidates
28        .iter()
29        .filter(|candidate| candidate.kind == "customPropertyDeclaration")
30        .map(|candidate| candidate.name.as_str())
31        .collect::<BTreeSet<_>>();
32    if declaration_names.is_empty() {
33        return Vec::new();
34    }
35
36    let insertion_range = end_of_source_range(source);
37    candidates
38        .iter()
39        .filter(|candidate| {
40            candidate.kind == "customPropertyReference"
41                && !declaration_names.contains(candidate.name.as_str())
42        })
43        .map(|candidate| OmenaQueryStyleDiagnosticV0 {
44            code: "missingCustomProperty",
45            severity: "warning",
46            provenance: vec![
47                "omena-parser.custom-property-facts",
48                "omena-query.style-diagnostics",
49            ],
50            range: candidate.range,
51            message: format!(
52                "CSS custom property '{}' not found in indexed style tokens.",
53                candidate.name
54            ),
55            tags: Vec::new(),
56            create_custom_property: Some(OmenaQueryCreateCustomPropertyActionV0 {
57                uri: style_uri.to_string(),
58                range: insertion_range,
59                new_text: format!("\n\n:root {{\n  {}: ;\n}}\n", candidate.name),
60                property_name: candidate.name.clone(),
61            }),
62        })
63        .collect()
64}
65
66pub fn summarize_omena_query_cascade_aware_style_diagnostics(
67    style_uri: &str,
68    source: &str,
69    candidates: &[OmenaQueryStyleHoverCandidateV0],
70) -> Vec<OmenaQueryStyleDiagnosticV0> {
71    let declarations_by_name = candidates
72        .iter()
73        .filter(|candidate| candidate.kind == "customPropertyDeclaration")
74        .map(|candidate| (candidate.name.as_str(), candidate.range))
75        .collect::<BTreeMap<_, _>>();
76
77    let dialect = omena_parser_dialect_for_style_path(style_uri);
78    let mut diagnostics =
79        summarize_static_css_custom_property_fixed_point_from_source(source, dialect)
80            .entries
81            .into_iter()
82            .filter(|entry| entry.guaranteed_invalid)
83            .filter_map(|entry| {
84                declarations_by_name
85                    .get(entry.name.as_str())
86                    .copied()
87                    .map(|range| OmenaQueryStyleDiagnosticV0 {
88                        code: "guaranteedInvalidCustomProperty",
89                        severity: "warning",
90                        provenance: vec![
91                            "omena-transform-passes.custom-property-lfp",
92                            "omena-query.cascade-aware-diagnostics",
93                        ],
94                        range,
95                        message: format!(
96                            "CSS custom property '{}' resolves to the guaranteed-invalid value.",
97                            entry.name
98                        ),
99                        tags: Vec::new(),
100                        create_custom_property: None,
101                    })
102            })
103            .collect::<Vec<_>>();
104
105    diagnostics.extend(summarize_query_cascade_checker_diagnostics(
106        style_uri, source,
107    ));
108
109    diagnostics
110}
111
112pub fn summarize_omena_query_missing_keyframes_diagnostics(
113    style_uri: &str,
114    source: &str,
115) -> Vec<OmenaQueryStyleDiagnosticV0> {
116    let dialect = omena_parser_dialect_for_style_path(style_uri);
117    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
118    let declared_keyframes = facts
119        .animations
120        .iter()
121        .filter(|animation| animation.kind == ParsedAnimationFactKind::KeyframesDeclaration)
122        .map(|animation| animation.name.clone())
123        .collect::<BTreeSet<_>>();
124    let mut emitted = BTreeSet::new();
125
126    facts
127        .animations
128        .into_iter()
129        .filter(|animation| animation.kind == ParsedAnimationFactKind::AnimationNameReference)
130        .filter(|animation| !declared_keyframes.contains(animation.name.as_str()))
131        .filter_map(|animation| {
132            let start: u32 = animation.range.start().into();
133            let end: u32 = animation.range.end().into();
134            let byte_span = ParserByteSpanV0 {
135                start: start as usize,
136                end: end as usize,
137            };
138            if !emitted.insert((animation.name.clone(), byte_span.start, byte_span.end)) {
139                return None;
140            }
141            Some((animation, parser_range_for_byte_span(source, byte_span)))
142        })
143        .map(|(animation, range)| OmenaQueryStyleDiagnosticV0 {
144            code: "missingKeyframes",
145            severity: "warning",
146            provenance: vec![
147                "omena-parser.animation-facts",
148                "omena-query.style-diagnostics",
149            ],
150            range,
151            message: format!("@keyframes '{}' not found in this file.", animation.name),
152            tags: Vec::new(),
153            create_custom_property: None,
154        })
155        .collect()
156}
157
158pub fn summarize_omena_query_missing_sass_symbol_diagnostics(
159    style_uri: &str,
160    source: &str,
161) -> Vec<OmenaQueryStyleDiagnosticV0> {
162    let dialect = omena_parser_dialect_for_style_path(style_uri);
163    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
164    let mut declarations = BTreeSet::<(&'static str, Option<String>, String)>::new();
165    let mut emitted = BTreeSet::new();
166    let mut diagnostics = Vec::new();
167
168    for symbol in facts.sass_symbols {
169        let key = (
170            symbol.symbol_kind,
171            symbol.namespace.clone(),
172            symbol.name.clone(),
173        );
174        if omena_query_sass_symbol_fact_kind_is_declaration(symbol.kind) {
175            declarations.insert(key);
176            continue;
177        }
178        if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
179            continue;
180        }
181        if declarations.contains(&key) {
182            continue;
183        }
184        if is_omena_query_sass_builtin_symbol_reference_resolved(
185            &facts.sass_module_edges,
186            symbol.symbol_kind,
187            symbol.namespace.as_deref(),
188            symbol.name.as_str(),
189        ) {
190            continue;
191        }
192
193        let start: u32 = symbol.range.start().into();
194        let end: u32 = symbol.range.end().into();
195        let byte_span = ParserByteSpanV0 {
196            start: start as usize,
197            end: end as usize,
198        };
199        if !emitted.insert((
200            symbol.symbol_kind,
201            symbol.namespace.clone(),
202            symbol.name.clone(),
203            byte_span.start,
204            byte_span.end,
205        )) {
206            continue;
207        }
208        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
209            code: "missingSassSymbol",
210            severity: "warning",
211            provenance: vec![
212                "omena-parser.sass-symbol-facts",
213                "omena-query.style-diagnostics",
214            ],
215            range: parser_range_for_byte_span(source, byte_span),
216            message: format!(
217                "{} not found in this file.",
218                format_query_sass_symbol_label(symbol.symbol_kind, symbol.name.as_str())
219            ),
220            tags: Vec::new(),
221            create_custom_property: None,
222        });
223    }
224
225    diagnostics
226}
227
228pub fn summarize_omena_query_sass_import_deprecation_hints(
229    style_uri: &str,
230    source: &str,
231) -> Vec<OmenaQueryStyleDiagnosticV0> {
232    let dialect = omena_parser_dialect_for_style_path(style_uri);
233    if !matches!(
234        dialect,
235        OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass
236    ) {
237        return Vec::new();
238    }
239
240    let facts = collect_omena_query_omena_parser_style_facts_raw(source, dialect);
241    facts
242        .sass_module_edges
243        .into_iter()
244        .filter(|edge| edge.kind == ParsedSassModuleEdgeFactKind::Import)
245        .map(|edge| {
246            let start: u32 = edge.range.start().into();
247            let end: u32 = edge.range.end().into();
248            OmenaQueryStyleDiagnosticV0 {
249                code: "deprecatedSassImport",
250                severity: "information",
251                provenance: vec![
252                    "omena-parser.sass-module-edges",
253                    "omena-query.sass-import-deprecation-hints",
254                ],
255                range: parser_range_for_byte_span(
256                    source,
257                    ParserByteSpanV0 {
258                        start: start as usize,
259                        end: end as usize,
260                    },
261                ),
262                message: "Sass @import is deprecated; prefer @use or @forward.".to_string(),
263                tags: vec![LSP_DIAGNOSTIC_TAG_DEPRECATED],
264                create_custom_property: None,
265            }
266        })
267        .collect()
268}
269
270pub fn summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace(
271    target_style_path: &str,
272    style_sources: &[OmenaQueryStyleSourceInputV0],
273    package_manifests: &[OmenaQueryStylePackageManifestV0],
274) -> Vec<OmenaQueryStyleDiagnosticV0> {
275    summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
276        target_style_path,
277        style_sources,
278        package_manifests,
279        &[],
280    )
281}
282
283fn summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
284    target_style_path: &str,
285    style_sources: &[OmenaQueryStyleSourceInputV0],
286    package_manifests: &[OmenaQueryStylePackageManifestV0],
287    external_sifs: &[OmenaQueryExternalSifInputV0],
288) -> Vec<OmenaQueryStyleDiagnosticV0> {
289    let Some(target) = style_sources
290        .iter()
291        .find(|source| source.style_path == target_style_path)
292    else {
293        return Vec::new();
294    };
295    let style_source_refs = style_sources
296        .iter()
297        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
298        .collect::<Vec<_>>();
299    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
300    let facts_by_path = style_fact_entries
301        .iter()
302        .map(|entry| (entry.style_path.as_str(), &entry.facts))
303        .collect::<BTreeMap<_, _>>();
304    let resolution =
305        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
306    let visible_symbols = collect_visible_sass_symbol_keys(
307        target_style_path,
308        &facts_by_path,
309        &resolution,
310        external_sifs,
311    );
312    let facts = collect_omena_query_omena_parser_style_facts_raw(
313        target.style_source.as_str(),
314        omena_parser_dialect_for_style_path(target_style_path),
315    );
316    let mut emitted = BTreeSet::new();
317    let mut diagnostics = Vec::new();
318
319    for symbol in facts.sass_symbols {
320        if !omena_query_sass_symbol_fact_kind_is_reference(symbol.kind) {
321            continue;
322        }
323        let key = sass_symbol_key(
324            symbol.symbol_kind,
325            symbol.namespace.clone(),
326            symbol.name.clone(),
327        );
328        if visible_symbols.contains(&key) {
329            continue;
330        }
331        if is_omena_query_sass_builtin_symbol_reference_resolved(
332            &facts.sass_module_edges,
333            symbol.symbol_kind,
334            symbol.namespace.as_deref(),
335            symbol.name.as_str(),
336        ) {
337            continue;
338        }
339
340        let start: u32 = symbol.range.start().into();
341        let end: u32 = symbol.range.end().into();
342        let byte_span = ParserByteSpanV0 {
343            start: start as usize,
344            end: end as usize,
345        };
346        if !emitted.insert((
347            symbol.symbol_kind,
348            symbol.namespace.clone(),
349            symbol.name.clone(),
350            byte_span.start,
351            byte_span.end,
352        )) {
353            continue;
354        }
355        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
356            code: "missingSassSymbol",
357            severity: "warning",
358            provenance: vec![
359                "omena-parser.sass-symbol-facts",
360                "omena-query.graph-aware-sass-diagnostics",
361            ],
362            range: parser_range_for_byte_span(target.style_source.as_str(), byte_span),
363            message: format!(
364                "{} not found in the visible Sass module graph.",
365                format_query_sass_symbol_label(symbol.symbol_kind, symbol.name.as_str())
366            ),
367            tags: Vec::new(),
368            create_custom_property: None,
369        });
370    }
371
372    diagnostics
373}
374
375pub fn summarize_omena_query_style_diagnostics_for_file(
376    style_uri: &str,
377    source: &str,
378    candidates: &[OmenaQueryStyleHoverCandidateV0],
379) -> OmenaQueryStyleDiagnosticsForFileV0 {
380    let mut diagnostics =
381        summarize_omena_query_missing_custom_property_diagnostics(style_uri, source, candidates);
382    diagnostics.extend(summarize_omena_query_cascade_aware_style_diagnostics(
383        style_uri, source, candidates,
384    ));
385    diagnostics.extend(summarize_omena_query_missing_keyframes_diagnostics(
386        style_uri, source,
387    ));
388    diagnostics.extend(summarize_omena_query_sass_import_deprecation_hints(
389        style_uri, source,
390    ));
391    diagnostics.extend(summarize_omena_query_missing_sass_symbol_diagnostics(
392        style_uri, source,
393    ));
394    let mut summary = OmenaQueryStyleDiagnosticsForFileV0 {
395        schema_version: "0",
396        product: "omena-query.diagnostics-for-file",
397        file_uri: style_uri.to_string(),
398        file_kind: "style",
399        diagnostic_count: diagnostics.len(),
400        diagnostics,
401        ready_surfaces: vec![
402            "missingCustomPropertyDiagnostics",
403            "cascadeAwareDiagnostics",
404            "missingKeyframesDiagnostics",
405            "sassImportDeprecationHints",
406            "missingSassSymbolDiagnostics",
407        ],
408    };
409    apply_omena_query_style_diagnostic_suppressions(source, &mut summary);
410    summary
411}
412
413pub fn summarize_omena_query_style_diagnostics_for_workspace_file(
414    target_style_path: &str,
415    style_sources: &[OmenaQueryStyleSourceInputV0],
416    source_documents: &[OmenaQuerySourceDocumentInputV0],
417    package_manifests: &[OmenaQueryStylePackageManifestV0],
418    classname_transform: Option<&str>,
419) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
420    summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode(
421        target_style_path,
422        style_sources,
423        source_documents,
424        package_manifests,
425        classname_transform,
426        OmenaQueryExternalModuleModeV0::Ignored,
427    )
428}
429
430pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode(
431    target_style_path: &str,
432    style_sources: &[OmenaQueryStyleSourceInputV0],
433    source_documents: &[OmenaQuerySourceDocumentInputV0],
434    package_manifests: &[OmenaQueryStylePackageManifestV0],
435    classname_transform: Option<&str>,
436    external_mode: OmenaQueryExternalModuleModeV0,
437) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
438    summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs(
439        target_style_path,
440        style_sources,
441        source_documents,
442        package_manifests,
443        classname_transform,
444        external_mode,
445        &[],
446    )
447}
448
449pub fn summarize_omena_query_style_diagnostics_for_workspace_file_with_external_mode_and_sifs(
450    target_style_path: &str,
451    style_sources: &[OmenaQueryStyleSourceInputV0],
452    source_documents: &[OmenaQuerySourceDocumentInputV0],
453    package_manifests: &[OmenaQueryStylePackageManifestV0],
454    classname_transform: Option<&str>,
455    external_mode: OmenaQueryExternalModuleModeV0,
456    external_sifs: &[OmenaQueryExternalSifInputV0],
457) -> Option<OmenaQueryStyleDiagnosticsForFileV0> {
458    let target = style_sources
459        .iter()
460        .find(|source| source.style_path == target_style_path)?;
461    let candidates =
462        summarize_omena_query_style_hover_candidates(target_style_path, &target.style_source)?;
463    let mut summary = summarize_omena_query_style_diagnostics_for_file(
464        target_style_path,
465        &target.style_source,
466        candidates.candidates.as_slice(),
467    );
468    summary
469        .diagnostics
470        .retain(|diagnostic| diagnostic.code != "missingSassSymbol");
471    summary.diagnostics.extend(
472        summarize_omena_query_missing_sass_symbol_diagnostics_for_workspace_with_sifs(
473            target_style_path,
474            style_sources,
475            package_manifests,
476            external_sifs,
477        ),
478    );
479    summary.diagnostics.extend(
480        summarize_omena_query_css_modules_resolution_style_diagnostics(
481            target_style_path,
482            &target.style_source,
483            style_sources,
484            package_manifests,
485        ),
486    );
487    summary
488        .diagnostics
489        .extend(summarize_omena_query_unused_selector_style_diagnostics(
490            target_style_path,
491            &target.style_source,
492            style_sources,
493            source_documents,
494            package_manifests,
495            classname_transform,
496        ));
497    summary.diagnostic_count = summary.diagnostics.len();
498    push_omena_query_ready_surface(
499        &mut summary.ready_surfaces,
500        "cssModulesComposesResolutionDiagnostics",
501    );
502    push_omena_query_ready_surface(
503        &mut summary.ready_surfaces,
504        "cssModulesValueResolutionDiagnostics",
505    );
506    push_omena_query_ready_surface(&mut summary.ready_surfaces, "unusedSelectorDiagnostics");
507    push_omena_query_ready_surface(
508        &mut summary.ready_surfaces,
509        "graphAwareSassSymbolDiagnostics",
510    );
511    if external_mode == OmenaQueryExternalModuleModeV0::Sif {
512        let top_any_external_symbol_ranges =
513            collect_omena_query_external_top_any_sass_symbol_ranges(
514                target_style_path,
515                style_sources,
516                package_manifests,
517                external_sifs,
518            );
519        summary.diagnostics.retain(|diagnostic| {
520            diagnostic.code != "missingSassSymbol"
521                || !top_any_external_symbol_ranges.contains(&diagnostic.range)
522        });
523        summary
524            .diagnostics
525            .extend(summarize_omena_query_external_sif_boundary_diagnostics(
526                target_style_path,
527                style_sources,
528                package_manifests,
529                external_sifs,
530            ));
531        push_omena_query_ready_surface(
532            &mut summary.ready_surfaces,
533            "externalSifBoundaryDiagnostics",
534        );
535    }
536    apply_omena_query_style_diagnostic_suppressions(&target.style_source, &mut summary);
537    summary.diagnostic_count = summary.diagnostics.len();
538    Some(summary)
539}
540
541fn collect_omena_query_external_top_any_sass_symbol_ranges(
542    target_style_path: &str,
543    style_sources: &[OmenaQueryStyleSourceInputV0],
544    package_manifests: &[OmenaQueryStylePackageManifestV0],
545    external_sifs: &[OmenaQueryExternalSifInputV0],
546) -> BTreeSet<ParserRangeV0> {
547    let Some(target) = style_sources
548        .iter()
549        .find(|source| source.style_path == target_style_path)
550    else {
551        return BTreeSet::new();
552    };
553    let style_source_refs = style_sources
554        .iter()
555        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
556        .collect::<Vec<_>>();
557    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
558    let resolution =
559        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
560    let top_any_namespaces = resolution
561        .edges
562        .iter()
563        .filter(|edge| edge.from_style_path == target_style_path)
564        .filter(|edge| edge.status == "external")
565        .filter(|edge| find_omena_query_external_sif(edge.source.as_str(), external_sifs).is_none())
566        .filter_map(|edge| match edge.edge_kind {
567            "sassUse"
568                if edge.namespace_kind == Some("default")
569                    || edge.namespace_kind == Some("alias") =>
570            {
571                edge.namespace.clone().map(Some)
572            }
573            "sassUse" if edge.namespace_kind == Some("wildcard") => Some(None),
574            "sassImport" => Some(None),
575            _ => None,
576        })
577        .collect::<BTreeSet<_>>();
578    if top_any_namespaces.is_empty() {
579        return BTreeSet::new();
580    }
581
582    let facts = collect_omena_query_omena_parser_style_facts_raw(
583        target.style_source.as_str(),
584        omena_parser_dialect_for_style_path(target_style_path),
585    );
586    facts
587        .sass_symbols
588        .into_iter()
589        .filter(|symbol| omena_query_sass_symbol_fact_kind_is_reference(symbol.kind))
590        .filter(|symbol| top_any_namespaces.contains(&symbol.namespace))
591        .map(|symbol| {
592            let start: u32 = symbol.range.start().into();
593            let end: u32 = symbol.range.end().into();
594            parser_range_for_byte_span(
595                target.style_source.as_str(),
596                ParserByteSpanV0 {
597                    start: start as usize,
598                    end: end as usize,
599                },
600            )
601        })
602        .collect()
603}
604
605fn summarize_omena_query_external_sif_boundary_diagnostics(
606    target_style_path: &str,
607    style_sources: &[OmenaQueryStyleSourceInputV0],
608    package_manifests: &[OmenaQueryStylePackageManifestV0],
609    external_sifs: &[OmenaQueryExternalSifInputV0],
610) -> Vec<OmenaQueryStyleDiagnosticV0> {
611    let Some(target) = style_sources
612        .iter()
613        .find(|source| source.style_path == target_style_path)
614    else {
615        return Vec::new();
616    };
617    let style_source_refs = style_sources
618        .iter()
619        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
620        .collect::<Vec<_>>();
621    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
622    let resolution =
623        summarize_sass_module_cross_file_resolution(&style_fact_entries, package_manifests);
624    let external_sources = resolution
625        .edges
626        .iter()
627        .filter(|edge| edge.from_style_path == target_style_path)
628        .filter(|edge| edge.status == "external")
629        .filter(|edge| find_omena_query_external_sif(edge.source.as_str(), external_sifs).is_none())
630        .map(|edge| edge.source.as_str())
631        .collect::<BTreeSet<_>>();
632    if external_sources.is_empty() {
633        return Vec::new();
634    }
635
636    let facts = collect_omena_query_omena_parser_style_facts_raw(
637        target.style_source.as_str(),
638        omena_parser_dialect_for_style_path(target_style_path),
639    );
640    let mut emitted = BTreeSet::new();
641    facts
642        .sass_module_edges
643        .into_iter()
644        .filter(|edge| external_sources.contains(edge.source.as_str()))
645        .filter_map(|edge| {
646            if !emitted.insert((edge.kind, edge.source.clone())) {
647                return None;
648            }
649            let state = OmenaResolverBoundaryStateV0::missing(
650                None,
651                "SIF mode requires a local SIF artifact for this external Sass module",
652            );
653            let start: u32 = edge.range.start().into();
654            let end: u32 = edge.range.end().into();
655            Some(OmenaQueryStyleDiagnosticV0 {
656                code: "missingExternalSif",
657                severity: "warning",
658                provenance: vec![
659                    "omena-resolver.boundary-state",
660                    "omena-query.external-sif-boundary-diagnostics",
661                ],
662                range: parser_range_for_byte_span(
663                    target.style_source.as_str(),
664                    ParserByteSpanV0 {
665                        start: start as usize,
666                        end: end as usize,
667                    },
668                ),
669                message: format!(
670                    "External Sass module '{}' is {} ({}); generate or provide a SIF artifact, or use --external ignored.",
671                    edge.source, state.state_name, state.top_name
672                ),
673                tags: Vec::new(),
674                create_custom_property: None,
675            })
676        })
677        .collect()
678}
679
680type SassSymbolKey = (&'static str, Option<String>, String);
681
682fn sass_symbol_key(
683    symbol_kind: &'static str,
684    namespace: Option<String>,
685    name: String,
686) -> SassSymbolKey {
687    (symbol_kind, namespace, name)
688}
689
690fn collect_visible_sass_symbol_keys(
691    target_style_path: &str,
692    facts_by_path: &BTreeMap<&str, &OmenaQueryOmenaParserStyleFactsV0>,
693    resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
694    external_sifs: &[OmenaQueryExternalSifInputV0],
695) -> BTreeSet<SassSymbolKey> {
696    let mut visible = BTreeSet::new();
697    if let Some(facts) = facts_by_path.get(target_style_path) {
698        visible.extend(
699            own_sass_symbol_declaration_keys(facts)
700                .into_iter()
701                .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
702        );
703    }
704
705    for edge in resolution
706        .edges
707        .iter()
708        .filter(|edge| edge.from_style_path == target_style_path)
709    {
710        let exported = if let Some(module_name) = sass_builtin_module_name(edge.source.as_str()) {
711            builtin_sass_symbol_exports(module_name)
712        } else if edge.status == "resolved" {
713            let mut visiting = BTreeSet::new();
714            edge.resolved_style_path
715                .as_deref()
716                .map(|path| {
717                    collect_exported_sass_symbol_keys(
718                        path,
719                        facts_by_path,
720                        resolution,
721                        external_sifs,
722                        &mut visiting,
723                    )
724                })
725                .unwrap_or_default()
726        } else if edge.status == "external" {
727            find_omena_query_external_sif(edge.source.as_str(), external_sifs)
728                .map(|sif| collect_sif_exported_sass_symbol_keys(&sif.sif))
729                .unwrap_or_default()
730        } else {
731            BTreeSet::new()
732        };
733
734        match edge.edge_kind {
735            "sassUse"
736                if edge.namespace_kind == Some("default")
737                    || edge.namespace_kind == Some("alias") =>
738            {
739                if let Some(namespace) = edge.namespace.clone() {
740                    visible.extend(exported.into_iter().map(|(symbol_kind, name)| {
741                        sass_symbol_key(symbol_kind, Some(namespace.clone()), name)
742                    }));
743                }
744            }
745            "sassUse" if edge.namespace_kind == Some("wildcard") => {
746                visible.extend(
747                    exported
748                        .into_iter()
749                        .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
750                );
751            }
752            "sassImport" => {
753                visible.extend(
754                    exported
755                        .into_iter()
756                        .map(|(symbol_kind, name)| sass_symbol_key(symbol_kind, None, name)),
757                );
758            }
759            _ => {}
760        }
761    }
762
763    visible
764}
765
766fn collect_exported_sass_symbol_keys(
767    style_path: &str,
768    facts_by_path: &BTreeMap<&str, &OmenaQueryOmenaParserStyleFactsV0>,
769    resolution: &OmenaQuerySassModuleCrossFileResolutionV0,
770    external_sifs: &[OmenaQueryExternalSifInputV0],
771    visiting: &mut BTreeSet<String>,
772) -> BTreeSet<(&'static str, String)> {
773    if !visiting.insert(style_path.to_string()) {
774        return BTreeSet::new();
775    }
776
777    let mut exported = facts_by_path
778        .get(style_path)
779        .map(|facts| own_sass_symbol_declaration_keys(facts))
780        .unwrap_or_default();
781
782    for edge in resolution
783        .edges
784        .iter()
785        .filter(|edge| edge.from_style_path == style_path)
786        .filter(|edge| edge.edge_kind == "sassForward" || edge.edge_kind == "sassImport")
787    {
788        let module_exports =
789            if let Some(module_name) = sass_builtin_module_name(edge.source.as_str()) {
790                builtin_sass_symbol_exports(module_name)
791            } else if edge.status == "resolved" {
792                edge.resolved_style_path
793                    .as_deref()
794                    .map(|path| {
795                        collect_exported_sass_symbol_keys(
796                            path,
797                            facts_by_path,
798                            resolution,
799                            external_sifs,
800                            visiting,
801                        )
802                    })
803                    .unwrap_or_default()
804            } else if edge.status == "external" {
805                find_omena_query_external_sif(edge.source.as_str(), external_sifs)
806                    .map(|sif| collect_sif_exported_sass_symbol_keys(&sif.sif))
807                    .unwrap_or_default()
808            } else {
809                BTreeSet::new()
810            };
811
812        for (symbol_kind, name) in module_exports {
813            if !sass_forward_visibility_allows(edge, symbol_kind, name.as_str()) {
814                continue;
815            }
816            let exported_name = if edge.edge_kind == "sassForward" {
817                apply_sass_forward_prefix(edge.forward_prefix.as_deref(), name.as_str())
818            } else {
819                name
820            };
821            exported.insert((symbol_kind, exported_name));
822        }
823    }
824
825    visiting.remove(style_path);
826    exported
827}
828
829fn own_sass_symbol_declaration_keys(
830    facts: &OmenaQueryOmenaParserStyleFactsV0,
831) -> BTreeSet<(&'static str, String)> {
832    facts
833        .sass_symbol_facts
834        .iter()
835        .filter(|fact| is_omena_query_sass_symbol_declaration_kind(fact.kind))
836        .map(|fact| (fact.symbol_kind, fact.name.clone()))
837        .collect()
838}
839
840fn collect_sif_exported_sass_symbol_keys(
841    sif: &omena_sif::OmenaSifV1,
842) -> BTreeSet<(&'static str, String)> {
843    let mut exported = BTreeSet::new();
844    exported.extend(sif.exports.variables.iter().map(|variable| {
845        (
846            "variable",
847            variable.name.trim_start_matches('$').to_string(),
848        )
849    }));
850    exported.extend(
851        sif.exports
852            .mixins
853            .iter()
854            .map(|mixin| ("mixin", mixin.name.clone())),
855    );
856    exported.extend(
857        sif.exports
858            .functions
859            .iter()
860            .map(|function| ("function", function.name.clone())),
861    );
862    exported
863}
864
865fn find_omena_query_external_sif<'a>(
866    canonical_url: &str,
867    external_sifs: &'a [OmenaQueryExternalSifInputV0],
868) -> Option<&'a OmenaQueryExternalSifInputV0> {
869    external_sifs.iter().find(|input| {
870        input.canonical_url == canonical_url || input.sif.canonical_url == canonical_url
871    })
872}
873
874fn sass_forward_visibility_allows(
875    edge: &OmenaQuerySassModuleEdgeResolutionV0,
876    symbol_kind: &'static str,
877    name: &str,
878) -> bool {
879    let prefixed = apply_sass_forward_prefix(edge.forward_prefix.as_deref(), name);
880    let matches_filter = |filter_name: &String| {
881        filter_name == name
882            || filter_name == prefixed.as_str()
883            || filter_name.trim_start_matches('$') == name
884            || filter_name.trim_start_matches('$') == prefixed.as_str()
885            || (symbol_kind != "variable" && filter_name.trim_start_matches('@') == name)
886    };
887    match edge.visibility_filter_kind {
888        Some("show") => edge.visibility_filter_names.iter().any(matches_filter),
889        Some("hide") => !edge.visibility_filter_names.iter().any(matches_filter),
890        _ => true,
891    }
892}
893
894fn apply_sass_forward_prefix(prefix: Option<&str>, name: &str) -> String {
895    match prefix {
896        Some(prefix) if prefix.contains('*') => prefix.replace('*', name),
897        Some(prefix) => format!("{prefix}{name}"),
898        None => name.to_string(),
899    }
900}
901
902fn is_omena_query_sass_builtin_symbol_reference_resolved(
903    edges: &[omena_parser::ParsedSassModuleEdgeFact],
904    symbol_kind: &'static str,
905    namespace: Option<&str>,
906    name: &str,
907) -> bool {
908    edges
909        .iter()
910        .filter(|edge| edge.kind == ParsedSassModuleEdgeFactKind::Use)
911        .filter_map(|edge| {
912            sass_builtin_module_name(edge.source.as_str()).map(|module| (edge, module))
913        })
914        .any(|(edge, module)| {
915            let namespace_matches =
916                match (namespace, edge.namespace_kind, edge.namespace.as_deref()) {
917                    (Some(reference_namespace), Some("default" | "alias"), Some(use_namespace)) => {
918                        reference_namespace == use_namespace
919                    }
920                    (None, Some("wildcard"), _) => true,
921                    _ => false,
922                };
923            namespace_matches && sass_builtin_module_has_symbol(module, symbol_kind, name)
924        })
925}
926
927fn sass_builtin_module_name(source: &str) -> Option<&str> {
928    source.strip_prefix("sass:")
929}
930
931fn builtin_sass_symbol_exports(module: &str) -> BTreeSet<(&'static str, String)> {
932    let mut exports = BTreeSet::new();
933    for name in sass_builtin_module_function_names(module) {
934        exports.insert(("function", (*name).to_string()));
935    }
936    for name in sass_builtin_module_mixin_names(module) {
937        exports.insert(("mixin", (*name).to_string()));
938    }
939    for name in sass_builtin_module_variable_names(module) {
940        exports.insert(("variable", (*name).to_string()));
941    }
942    exports
943}
944
945fn sass_builtin_module_has_symbol(module: &str, symbol_kind: &'static str, name: &str) -> bool {
946    match symbol_kind {
947        "function" => sass_builtin_module_function_names(module).contains(&name),
948        "mixin" => sass_builtin_module_mixin_names(module).contains(&name),
949        "variable" => sass_builtin_module_variable_names(module).contains(&name),
950        _ => false,
951    }
952}
953
954fn sass_builtin_module_function_names(module: &str) -> &'static [&'static str] {
955    match module {
956        "color" => &[
957            "adjust",
958            "alpha",
959            "blue",
960            "channel",
961            "change",
962            "complement",
963            "desaturate",
964            "fade-in",
965            "fade-out",
966            "grayscale",
967            "green",
968            "hsl",
969            "hsla",
970            "hue",
971            "ie-hex-str",
972            "invert",
973            "is-legacy",
974            "is-missing",
975            "is-powerless",
976            "lighten",
977            "lightness",
978            "mix",
979            "opacify",
980            "opacity",
981            "red",
982            "same",
983            "saturate",
984            "saturation",
985            "scale",
986            "space",
987            "to-gamut",
988            "to-space",
989            "transparentize",
990        ],
991        "math" => &[
992            "abs",
993            "acos",
994            "asin",
995            "atan",
996            "atan2",
997            "ceil",
998            "clamp",
999            "compatible",
1000            "cos",
1001            "div",
1002            "floor",
1003            "hypot",
1004            "is-unitless",
1005            "log",
1006            "max",
1007            "min",
1008            "percentage",
1009            "pow",
1010            "random",
1011            "round",
1012            "sin",
1013            "sqrt",
1014            "tan",
1015            "unit",
1016        ],
1017        "list" => &[
1018            "append",
1019            "index",
1020            "is-bracketed",
1021            "join",
1022            "length",
1023            "separator",
1024            "set-nth",
1025            "slash",
1026            "nth",
1027            "zip",
1028        ],
1029        "map" => &[
1030            "deep-merge",
1031            "deep-remove",
1032            "get",
1033            "has-key",
1034            "keys",
1035            "merge",
1036            "remove",
1037            "set",
1038            "values",
1039        ],
1040        "string" => &[
1041            "index",
1042            "insert",
1043            "length",
1044            "quote",
1045            "slice",
1046            "split",
1047            "to-lower-case",
1048            "to-upper-case",
1049            "unique-id",
1050            "unquote",
1051        ],
1052        "selector" => &[
1053            "append",
1054            "extend",
1055            "is-superselector",
1056            "nest",
1057            "parse",
1058            "replace",
1059            "simple-selectors",
1060            "unify",
1061        ],
1062        "meta" => &[
1063            "accepts-content",
1064            "calc-args",
1065            "calc-name",
1066            "call",
1067            "content-exists",
1068            "feature-exists",
1069            "function-exists",
1070            "get-function",
1071            "global-variable-exists",
1072            "inspect",
1073            "keywords",
1074            "mixin-exists",
1075            "module-functions",
1076            "module-mixins",
1077            "module-variables",
1078            "type-of",
1079            "variable-exists",
1080        ],
1081        _ => &[],
1082    }
1083}
1084
1085fn sass_builtin_module_mixin_names(module: &str) -> &'static [&'static str] {
1086    match module {
1087        "meta" => &["load-css"],
1088        _ => &[],
1089    }
1090}
1091
1092fn sass_builtin_module_variable_names(module: &str) -> &'static [&'static str] {
1093    match module {
1094        "math" => &["e", "epsilon", "max-safe-integer", "min-safe-integer", "pi"],
1095        _ => &[],
1096    }
1097}
1098
1099pub fn summarize_omena_query_css_modules_resolution_style_diagnostics(
1100    target_style_path: &str,
1101    target_source: &str,
1102    style_sources: &[OmenaQueryStyleSourceInputV0],
1103    package_manifests: &[OmenaQueryStylePackageManifestV0],
1104) -> Vec<OmenaQueryStyleDiagnosticV0> {
1105    let style_source_refs = style_sources
1106        .iter()
1107        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1108        .collect::<Vec<_>>();
1109    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1110    let available_style_paths = style_fact_entries
1111        .iter()
1112        .map(|entry| entry.style_path.as_str())
1113        .collect::<BTreeSet<_>>();
1114    let facts_by_path = style_fact_entries
1115        .iter()
1116        .map(|entry| (entry.style_path.as_str(), entry.facts.clone()))
1117        .collect::<BTreeMap<_, _>>();
1118    let dialect = omena_parser_dialect_for_style_path(target_style_path);
1119    let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
1120    let mut diagnostics = Vec::new();
1121
1122    for edge in target_facts.css_module_composes_edges {
1123        if edge.kind == ParsedCssModuleComposesEdgeKind::Global {
1124            continue;
1125        }
1126        let start: u32 = edge.range.start().into();
1127        let end: u32 = edge.range.end().into();
1128        let range = parser_range_for_byte_span(
1129            target_source,
1130            ParserByteSpanV0 {
1131                start: start as usize,
1132                end: end as usize,
1133            },
1134        );
1135        let target_style = if edge.kind == ParsedCssModuleComposesEdgeKind::External {
1136            let Some(source) = edge.import_source.as_deref() else {
1137                continue;
1138            };
1139            let Some(resolved_style_path) = resolve_style_module_source(
1140                target_style_path,
1141                source,
1142                &available_style_paths,
1143                package_manifests,
1144            ) else {
1145                diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1146                    code: "missingComposedModule",
1147                    severity: "warning",
1148                    provenance: vec![
1149                        "omena-parser.css-modules-composes-facts",
1150                        "omena-resolver.style-module-resolution",
1151                    ],
1152                    range,
1153                    message: format!("Cannot resolve composed CSS Module '{}'.", source),
1154                    tags: Vec::new(),
1155                    create_custom_property: None,
1156                });
1157                continue;
1158            };
1159            resolved_style_path
1160        } else {
1161            target_style_path.to_string()
1162        };
1163        let target_class_names = facts_by_path
1164            .get(target_style.as_str())
1165            .map(|facts| facts.class_selector_names.as_slice())
1166            .unwrap_or(&[])
1167            .iter()
1168            .map(String::as_str)
1169            .collect::<BTreeSet<_>>();
1170
1171        for target_name in edge.target_names {
1172            if target_class_names.contains(target_name.as_str()) {
1173                continue;
1174            }
1175            let message = if let Some(source) = edge.import_source.as_deref() {
1176                format!(
1177                    "Selector '.{}' not found in composed module '{}'.",
1178                    target_name, source
1179                )
1180            } else {
1181                format!(
1182                    "Selector '.{}' not found in this file for composes.",
1183                    target_name
1184                )
1185            };
1186            diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1187                code: "missingComposedSelector",
1188                severity: "warning",
1189                provenance: vec![
1190                    "omena-parser.css-modules-composes-facts",
1191                    "omena-query.css-modules-resolution-diagnostics",
1192                ],
1193                range,
1194                message,
1195                tags: Vec::new(),
1196                create_custom_property: None,
1197            });
1198        }
1199    }
1200
1201    let mut reported_missing_value_modules = BTreeSet::new();
1202    for edge in target_facts.css_module_value_import_edges {
1203        let start: u32 = edge.range.start().into();
1204        let end: u32 = edge.range.end().into();
1205        let range = parser_range_for_byte_span(
1206            target_source,
1207            ParserByteSpanV0 {
1208                start: start as usize,
1209                end: end as usize,
1210            },
1211        );
1212        let Some(resolved_style_path) = resolve_style_module_source(
1213            target_style_path,
1214            &edge.import_source,
1215            &available_style_paths,
1216            package_manifests,
1217        ) else {
1218            if reported_missing_value_modules.insert(edge.import_source.clone()) {
1219                diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1220                    code: "missingValueModule",
1221                    severity: "warning",
1222                    provenance: vec![
1223                        "omena-parser.css-modules-value-facts",
1224                        "omena-resolver.style-module-resolution",
1225                    ],
1226                    range,
1227                    message: format!(
1228                        "Cannot resolve imported @value module '{}'.",
1229                        edge.import_source
1230                    ),
1231                    tags: Vec::new(),
1232                    create_custom_property: None,
1233                });
1234            }
1235            continue;
1236        };
1237        let target_value_names = facts_by_path
1238            .get(resolved_style_path.as_str())
1239            .map(|facts| facts.css_module_value_definition_names.as_slice())
1240            .unwrap_or(&[])
1241            .iter()
1242            .map(String::as_str)
1243            .collect::<BTreeSet<_>>();
1244        if target_value_names.contains(edge.remote_name.as_str()) {
1245            continue;
1246        }
1247        let message = if edge.local_name == edge.remote_name {
1248            format!(
1249                "@value '{}' not found in '{}'.",
1250                edge.remote_name, edge.import_source
1251            )
1252        } else {
1253            format!(
1254                "@value '{}' not found in '{}' for local binding '{}'.",
1255                edge.remote_name, edge.import_source, edge.local_name
1256            )
1257        };
1258        diagnostics.push(OmenaQueryStyleDiagnosticV0 {
1259            code: "missingImportedValue",
1260            severity: "warning",
1261            provenance: vec![
1262                "omena-parser.css-modules-value-facts",
1263                "omena-query.css-modules-resolution-diagnostics",
1264            ],
1265            range,
1266            message,
1267            tags: Vec::new(),
1268            create_custom_property: None,
1269        });
1270    }
1271
1272    diagnostics
1273}
1274
1275pub fn summarize_omena_query_unused_selector_style_diagnostics(
1276    target_style_path: &str,
1277    target_source: &str,
1278    style_sources: &[OmenaQueryStyleSourceInputV0],
1279    source_documents: &[OmenaQuerySourceDocumentInputV0],
1280    package_manifests: &[OmenaQueryStylePackageManifestV0],
1281    classname_transform: Option<&str>,
1282) -> Vec<OmenaQueryStyleDiagnosticV0> {
1283    if source_documents.is_empty() {
1284        return Vec::new();
1285    }
1286
1287    let style_source_refs = style_sources
1288        .iter()
1289        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
1290        .collect::<Vec<_>>();
1291    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
1292    let available_style_paths = style_fact_entries
1293        .iter()
1294        .map(|entry| entry.style_path.as_str())
1295        .collect::<BTreeSet<_>>();
1296    let facts_by_path = style_fact_entries
1297        .iter()
1298        .map(|entry| (entry.style_path.as_str(), entry.facts.clone()))
1299        .collect::<BTreeMap<_, _>>();
1300    let aliases_by_path = collect_classname_transform_aliases(&facts_by_path, classname_transform);
1301    let (mut used_selectors, unresolved_dynamic_usage) =
1302        collect_omena_query_source_selector_usage_by_style(
1303            &available_style_paths,
1304            source_documents,
1305            package_manifests,
1306            &aliases_by_path,
1307        );
1308    if unresolved_dynamic_usage.contains(target_style_path) {
1309        return Vec::new();
1310    }
1311
1312    let composes_graph = collect_css_modules_composes_adjacency(
1313        &facts_by_path,
1314        &available_style_paths,
1315        package_manifests,
1316    );
1317    propagate_omena_query_composes_usage(&composes_graph, &mut used_selectors);
1318
1319    let dialect = omena_parser_dialect_for_style_path(target_style_path);
1320    let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
1321    let used_in_target = used_selectors
1322        .get(target_style_path)
1323        .cloned()
1324        .unwrap_or_default();
1325    let mut emitted = BTreeSet::new();
1326
1327    target_facts
1328        .selectors
1329        .into_iter()
1330        .filter(|selector| selector.kind == ParsedSelectorFactKind::Class)
1331        .filter(|selector| !used_in_target.contains(selector.name.as_str()))
1332        .filter_map(|selector| {
1333            let start: u32 = selector.range.start().into();
1334            let end: u32 = selector.range.end().into();
1335            if !emitted.insert(selector.name.clone()) {
1336                return None;
1337            }
1338            Some(OmenaQueryStyleDiagnosticV0 {
1339                code: "unusedSelector",
1340                severity: "hint",
1341                provenance: vec![
1342                    "omena-parser.selector-facts",
1343                    "omena-query.source-selector-usage",
1344                ],
1345                range: parser_range_for_byte_span(
1346                    target_source,
1347                    ParserByteSpanV0 {
1348                        start: start as usize,
1349                        end: end as usize,
1350                    },
1351                ),
1352                message: format!("Selector '.{}' is declared but never used.", selector.name),
1353                tags: vec![LSP_DIAGNOSTIC_TAG_UNNECESSARY],
1354                create_custom_property: None,
1355            })
1356        })
1357        .collect()
1358}
1359
1360fn collect_omena_query_source_selector_usage_by_style(
1361    available_style_paths: &BTreeSet<&str>,
1362    source_documents: &[OmenaQuerySourceDocumentInputV0],
1363    package_manifests: &[OmenaQueryStylePackageManifestV0],
1364    aliases_by_path: &BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
1365) -> (BTreeMap<String, BTreeSet<String>>, BTreeSet<String>) {
1366    let mut used_selectors = BTreeMap::<String, BTreeSet<String>>::new();
1367    let mut unresolved_dynamic_usage = BTreeSet::<String>::new();
1368
1369    for document in source_documents {
1370        let imports = summarize_omena_query_source_import_declarations_for_source_language(
1371            document.source_path.as_str(),
1372            &document.source_source,
1373            None,
1374        );
1375        let mut imported_style_bindings = Vec::new();
1376        let mut classnames_bind_bindings = Vec::new();
1377        for import in imports.imports {
1378            if import.specifier == "classnames/bind" {
1379                classnames_bind_bindings.push(import.binding);
1380                continue;
1381            }
1382            let Some(style_path) = resolve_style_module_source(
1383                &document.source_path,
1384                &import.specifier,
1385                available_style_paths,
1386                package_manifests,
1387            ) else {
1388                continue;
1389            };
1390            imported_style_bindings.push(OmenaQuerySourceImportedStyleBindingV0 {
1391                binding: import.binding,
1392                style_uri: style_path,
1393            });
1394        }
1395        if imported_style_bindings.is_empty() {
1396            continue;
1397        }
1398
1399        let index = summarize_omena_query_source_syntax_index_for_source_language(
1400            document.source_path.as_str(),
1401            &document.source_source,
1402            None,
1403            imported_style_bindings,
1404            classnames_bind_bindings,
1405        );
1406        for reference in index.selector_references {
1407            let Some(target_style_path) = reference.target_style_uri else {
1408                continue;
1409            };
1410            let Some(selector_name) = reference.selector_name.or_else(|| {
1411                source_reference_text_selector_name(&document.source_source, reference.byte_span)
1412            }) else {
1413                unresolved_dynamic_usage.insert(target_style_path);
1414                continue;
1415            };
1416            let used_for_style = used_selectors.entry(target_style_path.clone()).or_default();
1417            if let Some(canonical_names) = aliases_by_path
1418                .get(target_style_path.as_str())
1419                .and_then(|aliases| aliases.get(selector_name.as_str()))
1420            {
1421                used_for_style.extend(canonical_names.iter().cloned());
1422            } else {
1423                used_for_style.insert(selector_name);
1424            }
1425        }
1426    }
1427
1428    (used_selectors, unresolved_dynamic_usage)
1429}
1430
1431fn collect_classname_transform_aliases(
1432    facts_by_path: &BTreeMap<&str, OmenaQueryOmenaParserStyleFactsV0>,
1433    classname_transform: Option<&str>,
1434) -> BTreeMap<String, BTreeMap<String, BTreeSet<String>>> {
1435    let mut aliases_by_path = BTreeMap::<String, BTreeMap<String, BTreeSet<String>>>::new();
1436    for (style_path, facts) in facts_by_path {
1437        let aliases = aliases_by_path
1438            .entry((*style_path).to_string())
1439            .or_default();
1440        for selector_name in &facts.class_selector_names {
1441            for alias in classname_transform_aliases(selector_name.as_str(), classname_transform) {
1442                aliases
1443                    .entry(alias)
1444                    .or_default()
1445                    .insert(selector_name.clone());
1446            }
1447        }
1448    }
1449    aliases_by_path
1450}
1451
1452fn classname_transform_aliases(name: &str, classname_transform: Option<&str>) -> Vec<String> {
1453    match classname_transform.unwrap_or("asIs") {
1454        "camelCase" => keep_original_plus_transformed(name, to_ascii_camel_case(name)),
1455        "camelCaseOnly" => vec![to_ascii_camel_case(name)],
1456        "dashes" => keep_original_plus_transformed(name, dashes_to_ascii_camel(name)),
1457        "dashesOnly" => vec![dashes_to_ascii_camel(name)],
1458        _ => vec![name.to_string()],
1459    }
1460}
1461
1462fn keep_original_plus_transformed(name: &str, transformed: String) -> Vec<String> {
1463    if transformed == name {
1464        vec![name.to_string()]
1465    } else {
1466        vec![name.to_string(), transformed]
1467    }
1468}
1469
1470fn dashes_to_ascii_camel(name: &str) -> String {
1471    transform_ascii_separated_name(name, |byte| byte == b'-')
1472}
1473
1474fn to_ascii_camel_case(name: &str) -> String {
1475    transform_ascii_separated_name(name, |byte| byte == b'-' || byte == b'_' || byte == b' ')
1476}
1477
1478fn transform_ascii_separated_name(name: &str, is_separator: impl Fn(u8) -> bool) -> String {
1479    let mut output = String::with_capacity(name.len());
1480    let mut capitalize_next = false;
1481    for byte in name.bytes() {
1482        if is_separator(byte) {
1483            capitalize_next = true;
1484            continue;
1485        }
1486        if capitalize_next {
1487            output.push((byte as char).to_ascii_uppercase());
1488            capitalize_next = false;
1489            continue;
1490        }
1491        output.push(byte as char);
1492    }
1493    output
1494}
1495
1496fn propagate_omena_query_composes_usage(
1497    composes_graph: &BTreeMap<CssModulesComposesNode, BTreeSet<CssModulesComposesNode>>,
1498    used_selectors: &mut BTreeMap<String, BTreeSet<String>>,
1499) {
1500    let mut used_nodes = used_selectors
1501        .iter()
1502        .flat_map(|(style_path, selectors)| {
1503            selectors
1504                .iter()
1505                .map(|selector_name| CssModulesComposesNode {
1506                    style_path: style_path.clone(),
1507                    selector_name: selector_name.clone(),
1508                })
1509        })
1510        .collect::<BTreeSet<_>>();
1511
1512    let mut changed = true;
1513    while changed {
1514        changed = false;
1515        for (owner, targets) in composes_graph {
1516            if !used_nodes.contains(owner) {
1517                continue;
1518            }
1519            for target in targets {
1520                if used_nodes.insert(target.clone()) {
1521                    used_selectors
1522                        .entry(target.style_path.clone())
1523                        .or_default()
1524                        .insert(target.selector_name.clone());
1525                    changed = true;
1526                }
1527            }
1528        }
1529    }
1530}