Skip to main content

omena_query/style/diagnostics/
source_usage.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::ffi::OsString;
3use std::fs;
4use std::path::{Component, Path, PathBuf};
5
6use super::shared::*;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
9#[serde(rename_all = "camelCase")]
10pub enum OmenaQueryCssModuleExportUsageStatusV0 {
11    Used,
12    Unused,
13    Skipped,
14}
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
17#[serde(rename_all = "camelCase")]
18pub enum OmenaQueryCssModulesUnusedExportSkipReasonV0 {
19    NoSourceDocuments,
20    UnresolvedImportEdge,
21    UnresolvedStyleImport,
22    UnresolvedDynamicUsage,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct OmenaQueryCssModuleExportUsageV0 {
28    pub module_id: OmenaQueryModuleIdV0,
29    pub style_path: String,
30    pub export_name: String,
31    pub status: OmenaQueryCssModuleExportUsageStatusV0,
32    pub precision: FactPrecision,
33    pub skip_reasons: Vec<OmenaQueryCssModulesUnusedExportSkipReasonV0>,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
37#[serde(rename_all = "camelCase")]
38pub struct OmenaQueryCssModulesUnusedExportDiagnosticV0 {
39    pub code: &'static str,
40    pub severity: &'static str,
41    pub module_id: OmenaQueryModuleIdV0,
42    pub style_path: String,
43    pub export_name: String,
44    pub message: String,
45    pub precision: FactPrecision,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct OmenaQueryCssModulesUnusedExportSkipReasonCountV0 {
51    pub reason: OmenaQueryCssModulesUnusedExportSkipReasonV0,
52    pub count: usize,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct OmenaQueryCssModulesExportUsageReportV0 {
58    pub schema_version: &'static str,
59    pub product: &'static str,
60    pub export_count: usize,
61    pub used_export_count: usize,
62    pub unused_export_count: usize,
63    pub skipped_export_count: usize,
64    pub unresolved_import_edge_count: usize,
65    pub exports: Vec<OmenaQueryCssModuleExportUsageV0>,
66    pub diagnostics: Vec<OmenaQueryCssModulesUnusedExportDiagnosticV0>,
67    pub skip_reason_counts: Vec<OmenaQueryCssModulesUnusedExportSkipReasonCountV0>,
68}
69
70pub fn summarize_omena_query_css_modules_export_usage(
71    style_sources: &[OmenaQueryStyleSourceInputV0],
72    source_documents: &[OmenaQuerySourceDocumentInputV0],
73    package_manifests: &[OmenaQueryStylePackageManifestV0],
74    classname_transform: Option<&str>,
75) -> OmenaQueryCssModulesExportUsageReportV0 {
76    let style_source_refs = style_sources
77        .iter()
78        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
79        .collect::<Vec<_>>();
80    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
81    let resolution =
82        summarize_css_modules_cross_file_resolution(&style_fact_entries, package_manifests);
83    let shared = collect_omena_query_unused_selector_shared(
84        &style_fact_entries,
85        source_documents,
86        package_manifests,
87        classname_transform,
88        &[],
89        &[],
90        &[],
91        None,
92    );
93    let exact_precision =
94        omena_query_core::fact_precision_from_analysis_precision(&OmenaQueryAnalysisPrecisionV0 {
95            product: "omena-query.analysis-precision".to_string(),
96            value_domain: "styleModuleResolution".to_string(),
97            flow_sensitivity: "sourceSelectorUsage".to_string(),
98            context_sensitivity: "perModuleExport".to_string(),
99            revision_axis: "workspaceSnapshot".to_string(),
100        });
101    let unresolved_import_style_paths = resolution
102        .edges
103        .iter()
104        .filter(|edge| edge.resolved_style_path.is_none())
105        .map(|edge| edge.from_style_path.as_str())
106        .collect::<BTreeSet<_>>();
107
108    let mut exports = Vec::new();
109    let mut skip_reason_counts = BTreeMap::new();
110    for entry in &style_fact_entries {
111        let skip_reasons = export_usage_skip_reasons(
112            entry.style_path.as_str(),
113            source_documents,
114            unresolved_import_style_paths.contains(entry.style_path.as_str()),
115            shared.as_ref(),
116        );
117        let used_in_module = shared
118            .as_ref()
119            .and_then(|shared| shared.used_selectors.get(entry.style_path.as_str()));
120        for export_name in entry
121            .facts
122            .class_selector_names
123            .iter()
124            .cloned()
125            .collect::<BTreeSet<_>>()
126        {
127            let status = if !skip_reasons.is_empty() {
128                OmenaQueryCssModuleExportUsageStatusV0::Skipped
129            } else if used_in_module.is_some_and(|used| used.contains(export_name.as_str())) {
130                OmenaQueryCssModuleExportUsageStatusV0::Used
131            } else {
132                OmenaQueryCssModuleExportUsageStatusV0::Unused
133            };
134            for reason in &skip_reasons {
135                *skip_reason_counts.entry(*reason).or_insert(0usize) += 1;
136            }
137            exports.push(OmenaQueryCssModuleExportUsageV0 {
138                module_id: OmenaQueryModuleIdV0::new(entry.style_path.clone()),
139                style_path: entry.style_path.clone(),
140                export_name,
141                status,
142                precision: if status == OmenaQueryCssModuleExportUsageStatusV0::Skipped {
143                    FactPrecision::Unknown
144                } else {
145                    exact_precision
146                },
147                skip_reasons: skip_reasons.clone(),
148            });
149        }
150    }
151    exports.sort_by(|left, right| {
152        left.style_path
153            .cmp(&right.style_path)
154            .then_with(|| left.export_name.cmp(&right.export_name))
155    });
156
157    let diagnostics = exports
158        .iter()
159        .filter(|export| export.status == OmenaQueryCssModuleExportUsageStatusV0::Unused)
160        .map(|export| OmenaQueryCssModulesUnusedExportDiagnosticV0 {
161            code: "unusedModuleExport",
162            severity: "hint",
163            module_id: export.module_id.clone(),
164            style_path: export.style_path.clone(),
165            export_name: export.export_name.clone(),
166            message: format!(
167                "CSS Module export '.{}' is declared but never used.",
168                export.export_name
169            ),
170            precision: export.precision,
171        })
172        .collect::<Vec<_>>();
173    let used_export_count = exports
174        .iter()
175        .filter(|export| export.status == OmenaQueryCssModuleExportUsageStatusV0::Used)
176        .count();
177    let skipped_export_count = exports
178        .iter()
179        .filter(|export| export.status == OmenaQueryCssModuleExportUsageStatusV0::Skipped)
180        .count();
181
182    OmenaQueryCssModulesExportUsageReportV0 {
183        schema_version: "0",
184        product: "omena-query.css-modules-export-usage",
185        export_count: exports.len(),
186        used_export_count,
187        unused_export_count: diagnostics.len(),
188        skipped_export_count,
189        unresolved_import_edge_count: resolution.unresolved_import_edge_count,
190        exports,
191        diagnostics,
192        skip_reason_counts: skip_reason_counts
193            .into_iter()
194            .map(
195                |(reason, count)| OmenaQueryCssModulesUnusedExportSkipReasonCountV0 {
196                    reason,
197                    count,
198                },
199            )
200            .collect(),
201    }
202}
203
204fn export_usage_skip_reasons(
205    style_path: &str,
206    source_documents: &[OmenaQuerySourceDocumentInputV0],
207    has_unresolved_import_edge: bool,
208    shared: Option<&OmenaQueryUnusedSelectorSharedV0>,
209) -> Vec<OmenaQueryCssModulesUnusedExportSkipReasonV0> {
210    let mut reasons = BTreeSet::new();
211    if source_documents.is_empty() {
212        reasons.insert(OmenaQueryCssModulesUnusedExportSkipReasonV0::NoSourceDocuments);
213    }
214    if has_unresolved_import_edge {
215        reasons.insert(OmenaQueryCssModulesUnusedExportSkipReasonV0::UnresolvedImportEdge);
216    }
217    if shared.is_some_and(|shared| shared.has_unresolved_style_import) {
218        reasons.insert(OmenaQueryCssModulesUnusedExportSkipReasonV0::UnresolvedStyleImport);
219    }
220    if shared.is_some_and(|shared| shared.unresolved_dynamic_usage.contains(style_path)) {
221        reasons.insert(OmenaQueryCssModulesUnusedExportSkipReasonV0::UnresolvedDynamicUsage);
222    }
223    reasons.into_iter().collect()
224}
225
226pub fn summarize_omena_query_unused_selector_style_diagnostics(
227    target_style_path: &str,
228    target_source: &str,
229    style_sources: &[OmenaQueryStyleSourceInputV0],
230    source_documents: &[OmenaQuerySourceDocumentInputV0],
231    package_manifests: &[OmenaQueryStylePackageManifestV0],
232    classname_transform: Option<&str>,
233) -> Vec<OmenaQueryStyleDiagnosticV0> {
234    summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
235        target_style_path,
236        target_source,
237        style_sources,
238        source_documents,
239        package_manifests,
240        classname_transform,
241        &[],
242        &[],
243    )
244}
245
246#[allow(clippy::too_many_arguments)]
247pub fn summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings(
248    target_style_path: &str,
249    target_source: &str,
250    style_sources: &[OmenaQueryStyleSourceInputV0],
251    source_documents: &[OmenaQuerySourceDocumentInputV0],
252    package_manifests: &[OmenaQueryStylePackageManifestV0],
253    classname_transform: Option<&str>,
254    bundler_path_mappings: &[OmenaResolverBundlerPathAliasMappingV0],
255    tsconfig_path_mappings: &[OmenaResolverTsconfigPathMappingV0],
256) -> Vec<OmenaQueryStyleDiagnosticV0> {
257    if source_documents.is_empty() {
258        return Vec::new();
259    }
260
261    let style_source_refs = style_sources
262        .iter()
263        .map(|source| (source.style_path.as_str(), source.style_source.as_str()))
264        .collect::<Vec<_>>();
265    let style_fact_entries = collect_omena_query_style_fact_entries(style_source_refs.as_slice());
266    summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings_from_entries(
267        target_style_path,
268        target_source,
269        &style_fact_entries,
270        source_documents,
271        package_manifests,
272        classname_transform,
273        bundler_path_mappings,
274        tsconfig_path_mappings,
275        &[],
276        None,
277    )
278}
279
280/// Substrate-threaded core of the unused-selector pass (RFC 0009 Pillar B stage-2,
281/// #65). `style_fact_entries` is the substrate's ENTRIES slot; source usage now
282/// consumes precomputed source syntax indexes when callers provide them, while
283/// retaining the text-backed import/syntax fallback for non-indexed callers.
284#[allow(clippy::too_many_arguments)]
285/// Target-INDEPENDENT core of the unused-selector pass (rfcs#111 C1 slice
286/// 2): source-side import resolution across every source document, selector
287/// usage attribution, and composes propagation — the wave computes this ONCE
288/// and every target consumes it. Owned types by construction, so the product
289/// shares behind an `Arc` without borrowing the substrate.
290pub(in crate::style) struct OmenaQueryUnusedSelectorSharedV0 {
291    used_selectors: BTreeMap<String, BTreeSet<String>>,
292    unresolved_dynamic_usage: BTreeSet<String>,
293    has_unresolved_style_import: bool,
294}
295
296#[allow(clippy::too_many_arguments)]
297pub(in crate::style) fn collect_omena_query_unused_selector_shared(
298    style_fact_entries: &[OmenaQueryStyleFactEntry],
299    source_documents: &[OmenaQuerySourceDocumentInputV0],
300    package_manifests: &[OmenaQueryStylePackageManifestV0],
301    classname_transform: Option<&str>,
302    bundler_path_mappings: &[OmenaResolverBundlerPathAliasMappingV0],
303    tsconfig_path_mappings: &[OmenaResolverTsconfigPathMappingV0],
304    disk_style_path_identities: &[OmenaResolverStyleModuleDiskCandidateIdentityV0],
305    resolver_identity_index: Option<&OmenaResolverStyleModuleConfirmationIdentityIndexV0>,
306) -> Option<OmenaQueryUnusedSelectorSharedV0> {
307    if source_documents.is_empty() {
308        return None;
309    }
310
311    let available_style_paths = style_fact_entries
312        .iter()
313        .map(|entry| entry.style_path.as_str())
314        .collect::<BTreeSet<_>>();
315    let facts_by_path = style_fact_entries
316        .iter()
317        .map(|entry| (entry.style_path.as_str(), entry.facts.clone()))
318        .collect::<BTreeMap<_, _>>();
319    let aliases_by_path = collect_classname_transform_aliases(&facts_by_path, classname_transform);
320    let (mut used_selectors, unresolved_dynamic_usage, has_unresolved_style_import) =
321        collect_omena_query_source_selector_usage_by_style(SourceSelectorUsageResolutionContext {
322            available_style_paths: &available_style_paths,
323            source_documents,
324            package_manifests,
325            aliases_by_path: &aliases_by_path,
326            bundler_path_mappings,
327            tsconfig_path_mappings,
328            disk_style_path_identities,
329            resolver_identity_index,
330        });
331    let composes_graph = collect_css_modules_composes_adjacency(
332        &facts_by_path,
333        &available_style_paths,
334        package_manifests,
335    );
336    propagate_omena_query_composes_usage(&composes_graph, &mut used_selectors);
337    Some(OmenaQueryUnusedSelectorSharedV0 {
338        used_selectors,
339        unresolved_dynamic_usage,
340        has_unresolved_style_import,
341    })
342}
343
344/// The per-target remainder: two set lookups plus a parse of the target.
345pub(in crate::style) fn summarize_omena_query_unused_selector_style_diagnostics_with_shared(
346    target_style_path: &str,
347    target_source: &str,
348    shared: &OmenaQueryUnusedSelectorSharedV0,
349) -> Vec<OmenaQueryStyleDiagnosticV0> {
350    if shared.unresolved_dynamic_usage.contains(target_style_path) {
351        return Vec::new();
352    }
353    // RFC-0007-J (#50): when a source document imports a style module via a specifier we cannot
354    // resolve (e.g. a workspace alias `@/styles/a.module.scss` with no tsconfig/bundler path
355    // mapping wired in), we do not know which module its `cx('foo')`/`styles.foo` references point
356    // at — so we cannot prove any selector is unused. References/goto stay lenient with that
357    // ambiguity; the negative assertion (`unusedSelector`) must be conservative to match, instead
358    // of dimming every selector in the file. Treat such documents as "possibly using" and skip the
359    // lint for this target rather than emitting a wall of false positives.
360    if shared.has_unresolved_style_import {
361        return Vec::new();
362    }
363
364    let dialect = omena_parser_dialect_for_style_path(target_style_path);
365    let target_facts = collect_omena_query_omena_parser_style_facts_raw(target_source, dialect);
366    let used_in_target = shared
367        .used_selectors
368        .get(target_style_path)
369        .cloned()
370        .unwrap_or_default();
371    let mut emitted = BTreeSet::new();
372
373    target_facts
374        .selectors
375        .into_iter()
376        .filter(|selector| selector.kind == ParsedSelectorFactKind::Class)
377        .filter(|selector| !used_in_target.contains(selector.name.as_str()))
378        .filter_map(|selector| {
379            let start: u32 = selector.range.start().into();
380            let end: u32 = selector.range.end().into();
381            if !emitted.insert(selector.name.clone()) {
382                return None;
383            }
384            Some(OmenaQueryStyleDiagnosticV0 {
385                code: "unusedSelector",
386                severity: "hint",
387                provenance: omena_query_evidence_graph_provenance![
388                    "omena-parser.selector-facts",
389                    "omena-query.source-selector-usage",
390                ],
391                range: parser_range_for_byte_span(
392                    target_source,
393                    ParserByteSpanV0 {
394                        start: start as usize,
395                        end: end as usize,
396                    },
397                ),
398                message: format!("Selector '.{}' is declared but never used.", selector.name),
399                tags: vec![LSP_DIAGNOSTIC_TAG_UNNECESSARY],
400                create_custom_property: None,
401                cascade_narrowing: None,
402                cascade_confidence: None,
403                polynomial_provenance: None,
404                cross_file_scc: None,
405            })
406        })
407        .collect()
408}
409
410#[allow(clippy::too_many_arguments)]
411pub(super) fn summarize_omena_query_unused_selector_style_diagnostics_with_path_mappings_from_entries(
412    target_style_path: &str,
413    target_source: &str,
414    style_fact_entries: &[OmenaQueryStyleFactEntry],
415    source_documents: &[OmenaQuerySourceDocumentInputV0],
416    package_manifests: &[OmenaQueryStylePackageManifestV0],
417    classname_transform: Option<&str>,
418    bundler_path_mappings: &[OmenaResolverBundlerPathAliasMappingV0],
419    tsconfig_path_mappings: &[OmenaResolverTsconfigPathMappingV0],
420    disk_style_path_identities: &[OmenaResolverStyleModuleDiskCandidateIdentityV0],
421    resolver_identity_index: Option<&OmenaResolverStyleModuleConfirmationIdentityIndexV0>,
422) -> Vec<OmenaQueryStyleDiagnosticV0> {
423    let Some(shared) = collect_omena_query_unused_selector_shared(
424        style_fact_entries,
425        source_documents,
426        package_manifests,
427        classname_transform,
428        bundler_path_mappings,
429        tsconfig_path_mappings,
430        disk_style_path_identities,
431        resolver_identity_index,
432    ) else {
433        return Vec::new();
434    };
435    summarize_omena_query_unused_selector_style_diagnostics_with_shared(
436        target_style_path,
437        target_source,
438        &shared,
439    )
440}
441
442struct SourceSelectorUsageResolutionContext<'a> {
443    available_style_paths: &'a BTreeSet<&'a str>,
444    source_documents: &'a [OmenaQuerySourceDocumentInputV0],
445    package_manifests: &'a [OmenaQueryStylePackageManifestV0],
446    aliases_by_path: &'a BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
447    bundler_path_mappings: &'a [OmenaResolverBundlerPathAliasMappingV0],
448    tsconfig_path_mappings: &'a [OmenaResolverTsconfigPathMappingV0],
449    disk_style_path_identities: &'a [OmenaResolverStyleModuleDiskCandidateIdentityV0],
450    resolver_identity_index: Option<&'a OmenaResolverStyleModuleConfirmationIdentityIndexV0>,
451}
452
453fn collect_omena_query_source_selector_usage_by_style(
454    context: SourceSelectorUsageResolutionContext<'_>,
455) -> (BTreeMap<String, BTreeSet<String>>, BTreeSet<String>, bool) {
456    let mut used_selectors = BTreeMap::<String, BTreeSet<String>>::new();
457    let mut unresolved_dynamic_usage = BTreeSet::<String>::new();
458    // RFC-0007-J (#50): tracks whether any document imports a style-like specifier we failed to
459    // resolve (an unwired workspace alias). Such a document's selector usages cannot be attributed
460    // to a concrete module, so the caller treats the file as "possibly used" instead of dimming
461    // every selector.
462    let mut has_unresolved_style_import = false;
463
464    for document in context.source_documents {
465        if let Some(index) = document
466            .source_syntax_index
467            .as_ref()
468            .filter(|index| source_syntax_index_has_style_usage_facts(index))
469        {
470            let mut index = index.clone();
471            crate::canonicalize_omena_query_source_selector_references(
472                &mut index.selector_references,
473            );
474            if document.has_unresolved_style_import {
475                has_unresolved_style_import = true;
476            }
477            collect_omena_query_source_selector_usage_from_syntax_index(
478                document,
479                &index,
480                context.available_style_paths,
481                context.aliases_by_path,
482                &mut used_selectors,
483                &mut unresolved_dynamic_usage,
484            );
485            continue;
486        }
487
488        let imports = summarize_omena_query_source_import_declarations_for_source_language(
489            document.source_path.as_str(),
490            &document.source_source,
491            None,
492        );
493        let mut imported_style_bindings = Vec::new();
494        let mut classnames_bind_bindings = Vec::new();
495        for import in imports.imports {
496            if import.specifier == "classnames/bind" {
497                classnames_bind_bindings.push(import.binding);
498                continue;
499            }
500            let Some(style_path) =
501                resolve_style_module_source_with_path_mappings_and_identity_index(
502                    &document.source_path,
503                    &import.specifier,
504                    context.available_style_paths,
505                    context.package_manifests,
506                    context.bundler_path_mappings,
507                    context.tsconfig_path_mappings,
508                    context.disk_style_path_identities,
509                    context.resolver_identity_index,
510                )
511            else {
512                if specifier_targets_style_module(&import.specifier) {
513                    has_unresolved_style_import = true;
514                }
515                continue;
516            };
517            imported_style_bindings.push(OmenaQuerySourceImportedStyleBindingV0 {
518                binding: import.binding,
519                style_uri: style_path,
520            });
521        }
522        if imported_style_bindings.is_empty() {
523            continue;
524        }
525
526        let index = summarize_omena_query_source_syntax_index_for_source_language(
527            document.source_path.as_str(),
528            &document.source_source,
529            None,
530            imported_style_bindings,
531            classnames_bind_bindings,
532        );
533        for reference in index.selector_references {
534            let Some(target_style_path) = reference.target_style_uri else {
535                continue;
536            };
537            let Some(selector_name) = reference.selector_name.or_else(|| {
538                source_reference_text_selector_name(&document.source_source, reference.byte_span)
539            }) else {
540                unresolved_dynamic_usage.insert(target_style_path);
541                continue;
542            };
543            let used_for_style = used_selectors.entry(target_style_path.clone()).or_default();
544            if let Some(canonical_names) = context
545                .aliases_by_path
546                .get(target_style_path.as_str())
547                .and_then(|aliases| aliases.get(selector_name.as_str()))
548            {
549                used_for_style.extend(canonical_names.iter().cloned());
550            } else {
551                used_for_style.insert(selector_name);
552            }
553        }
554    }
555
556    (
557        used_selectors,
558        unresolved_dynamic_usage,
559        has_unresolved_style_import,
560    )
561}
562
563fn collect_omena_query_source_selector_usage_from_syntax_index(
564    document: &OmenaQuerySourceDocumentInputV0,
565    index: &OmenaQuerySourceSyntaxIndexV0,
566    available_style_paths: &BTreeSet<&str>,
567    aliases_by_path: &BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
568    used_selectors: &mut BTreeMap<String, BTreeSet<String>>,
569    unresolved_dynamic_usage: &mut BTreeSet<String>,
570) {
571    let single_imported_style_target_uri = (index.imported_style_bindings.len() == 1)
572        .then(|| index.imported_style_bindings[0].style_uri.clone());
573    for access in &index.style_property_accesses {
574        let Some(target_style_path) = access
575            .target_style_uri
576            .clone()
577            .or_else(|| single_imported_style_target_uri.clone())
578        else {
579            continue;
580        };
581        let target_style_path =
582            source_usage_available_style_path(target_style_path, available_style_paths);
583        let Some(selector_name) =
584            source_reference_text_selector_name(&document.source_source, access.byte_span)
585        else {
586            unresolved_dynamic_usage.insert(target_style_path);
587            continue;
588        };
589        record_omena_query_used_source_selector(
590            target_style_path,
591            selector_name,
592            aliases_by_path,
593            used_selectors,
594        );
595    }
596    for reference in &index.selector_references {
597        let Some(target_style_path) = reference.target_style_uri.clone() else {
598            continue;
599        };
600        let target_style_path =
601            source_usage_available_style_path(target_style_path, available_style_paths);
602        let Some(selector_name) = reference.selector_name.clone().or_else(|| {
603            source_reference_text_selector_name(&document.source_source, reference.byte_span)
604        }) else {
605            unresolved_dynamic_usage.insert(target_style_path);
606            continue;
607        };
608        record_omena_query_used_source_selector(
609            target_style_path,
610            selector_name,
611            aliases_by_path,
612            used_selectors,
613        );
614    }
615}
616
617fn source_syntax_index_has_style_usage_facts(index: &OmenaQuerySourceSyntaxIndexV0) -> bool {
618    index
619        .style_property_accesses
620        .iter()
621        .any(|access| access.target_style_uri.is_some())
622        || (index.imported_style_bindings.len() == 1 && !index.style_property_accesses.is_empty())
623        || index
624            .selector_references
625            .iter()
626            .any(|reference| reference.target_style_uri.is_some())
627}
628
629fn source_usage_available_style_path(
630    target_style_path: String,
631    available_style_paths: &BTreeSet<&str>,
632) -> String {
633    if available_style_paths.contains(target_style_path.as_str()) {
634        return target_style_path;
635    }
636    available_style_paths
637        .iter()
638        .find(|available_style_path| {
639            source_usage_style_paths_equivalent(target_style_path.as_str(), available_style_path)
640        })
641        .map(|available_style_path| (*available_style_path).to_string())
642        .unwrap_or(target_style_path)
643}
644
645fn source_usage_style_paths_equivalent(left: &str, right: &str) -> bool {
646    if left == right {
647        return true;
648    }
649    source_usage_style_identity(left) == source_usage_style_identity(right)
650}
651
652fn source_usage_style_identity(path_or_uri: &str) -> String {
653    let path = if let Some(path) = source_usage_file_uri_path(path_or_uri) {
654        PathBuf::from(path)
655    } else {
656        PathBuf::from(path_or_uri)
657    };
658    source_usage_normalize_path(
659        source_usage_canonicalize_existing_path_or_parent(path.as_path()).unwrap_or(path),
660    )
661    .to_string_lossy()
662    .replace('\\', "/")
663}
664
665fn source_usage_file_uri_path(uri: &str) -> Option<String> {
666    let path = uri.strip_prefix("file://")?;
667    source_usage_percent_decode_uri_path(path)
668}
669
670fn source_usage_percent_decode_uri_path(raw_path: &str) -> Option<String> {
671    let bytes = raw_path.as_bytes();
672    let mut decoded = Vec::with_capacity(bytes.len());
673    let mut index = 0usize;
674    while index < bytes.len() {
675        if bytes[index] == b'%' {
676            let high = bytes
677                .get(index + 1)
678                .and_then(|byte| source_usage_hex_value(*byte))?;
679            let low = bytes
680                .get(index + 2)
681                .and_then(|byte| source_usage_hex_value(*byte))?;
682            decoded.push((high << 4) | low);
683            index += 3;
684        } else {
685            decoded.push(bytes[index]);
686            index += 1;
687        }
688    }
689    String::from_utf8(decoded).ok()
690}
691
692fn source_usage_hex_value(byte: u8) -> Option<u8> {
693    match byte {
694        b'0'..=b'9' => Some(byte - b'0'),
695        b'a'..=b'f' => Some(byte - b'a' + 10),
696        b'A'..=b'F' => Some(byte - b'A' + 10),
697        _ => None,
698    }
699}
700
701fn source_usage_canonicalize_existing_path_or_parent(path: &Path) -> Option<PathBuf> {
702    if let Ok(canonical) = fs::canonicalize(path) {
703        return Some(canonical);
704    }
705
706    let mut current = path.to_path_buf();
707    let mut suffix = Vec::<OsString>::new();
708    while let Some(parent) = current.parent() {
709        if let Some(file_name) = current.file_name() {
710            suffix.push(file_name.to_os_string());
711        }
712        if let Ok(mut canonical_parent) = fs::canonicalize(parent) {
713            for segment in suffix.iter().rev() {
714                canonical_parent.push(segment);
715            }
716            return Some(canonical_parent);
717        }
718        current = parent.to_path_buf();
719    }
720    None
721}
722
723fn source_usage_normalize_path(path: PathBuf) -> PathBuf {
724    let mut normalized = PathBuf::new();
725    for component in path.components() {
726        match component {
727            Component::CurDir => {}
728            Component::ParentDir => {
729                normalized.pop();
730            }
731            Component::Normal(_) | Component::RootDir | Component::Prefix(_) => {
732                normalized.push(component.as_os_str());
733            }
734        }
735    }
736    normalized
737}
738
739fn record_omena_query_used_source_selector(
740    target_style_path: String,
741    selector_name: String,
742    aliases_by_path: &BTreeMap<String, BTreeMap<String, BTreeSet<String>>>,
743    used_selectors: &mut BTreeMap<String, BTreeSet<String>>,
744) {
745    let used_for_style = used_selectors.entry(target_style_path.clone()).or_default();
746    if let Some(canonical_names) = aliases_by_path
747        .get(target_style_path.as_str())
748        .and_then(|aliases| aliases.get(selector_name.as_str()))
749    {
750        used_for_style.extend(canonical_names.iter().cloned());
751    } else {
752        used_for_style.insert(selector_name);
753    }
754}
755
756/// Whether an import specifier names a CSS-family style module (so failing to resolve it is a
757/// style-resolution gap worth treating conservatively, RFC-0007-J #50) rather than an ordinary
758/// JS/TS dependency. A query string or hash on the specifier (e.g. `?inline`) is ignored.
759fn specifier_targets_style_module(specifier: &str) -> bool {
760    let path = specifier
761        .split(['?', '#'])
762        .next()
763        .unwrap_or(specifier)
764        .to_ascii_lowercase();
765    path.ends_with(".css")
766        || path.ends_with(".scss")
767        || path.ends_with(".sass")
768        || path.ends_with(".less")
769}
770
771fn collect_classname_transform_aliases(
772    facts_by_path: &BTreeMap<&str, OmenaQueryOmenaParserStyleFactsV0>,
773    classname_transform: Option<&str>,
774) -> BTreeMap<String, BTreeMap<String, BTreeSet<String>>> {
775    let mut aliases_by_path = BTreeMap::<String, BTreeMap<String, BTreeSet<String>>>::new();
776    for (style_path, facts) in facts_by_path {
777        let aliases = aliases_by_path
778            .entry((*style_path).to_string())
779            .or_default();
780        for selector_name in &facts.class_selector_names {
781            for alias in classname_transform_aliases(selector_name.as_str(), classname_transform) {
782                aliases
783                    .entry(alias)
784                    .or_default()
785                    .insert(selector_name.clone());
786            }
787        }
788    }
789    aliases_by_path
790}
791
792fn classname_transform_aliases(name: &str, classname_transform: Option<&str>) -> Vec<String> {
793    match classname_transform.unwrap_or("asIs") {
794        "camelCase" => keep_original_plus_transformed(name, to_ascii_camel_case(name)),
795        "camelCaseOnly" => vec![to_ascii_camel_case(name)],
796        "dashes" => keep_original_plus_transformed(name, dashes_to_ascii_camel(name)),
797        "dashesOnly" => vec![dashes_to_ascii_camel(name)],
798        _ => vec![name.to_string()],
799    }
800}
801
802fn keep_original_plus_transformed(name: &str, transformed: String) -> Vec<String> {
803    if transformed == name {
804        vec![name.to_string()]
805    } else {
806        vec![name.to_string(), transformed]
807    }
808}
809
810fn dashes_to_ascii_camel(name: &str) -> String {
811    transform_ascii_separated_name(name, |byte| byte == b'-')
812}
813
814fn to_ascii_camel_case(name: &str) -> String {
815    transform_ascii_separated_name(name, |byte| byte == b'-' || byte == b'_' || byte == b' ')
816}
817
818fn transform_ascii_separated_name(name: &str, is_separator: impl Fn(u8) -> bool) -> String {
819    let mut output = String::with_capacity(name.len());
820    let mut capitalize_next = false;
821    for byte in name.bytes() {
822        if is_separator(byte) {
823            capitalize_next = true;
824            continue;
825        }
826        if capitalize_next {
827            output.push((byte as char).to_ascii_uppercase());
828            capitalize_next = false;
829            continue;
830        }
831        output.push(byte as char);
832    }
833    output
834}
835
836fn propagate_omena_query_composes_usage(
837    composes_graph: &BTreeMap<CssModulesComposesNode, BTreeSet<CssModulesComposesNode>>,
838    used_selectors: &mut BTreeMap<String, BTreeSet<String>>,
839) {
840    let mut used_nodes = used_selectors
841        .iter()
842        .flat_map(|(style_path, selectors)| {
843            selectors
844                .iter()
845                .map(|selector_name| CssModulesComposesNode {
846                    style_path: style_path.clone(),
847                    selector_name: selector_name.clone(),
848                })
849        })
850        .collect::<BTreeSet<_>>();
851
852    let mut changed = true;
853    while changed {
854        changed = false;
855        for (owner, targets) in composes_graph {
856            if !used_nodes.contains(owner) {
857                continue;
858            }
859            for target in targets {
860                if used_nodes.insert(target.clone()) {
861                    used_selectors
862                        .entry(target.style_path.clone())
863                        .or_default()
864                        .insert(target.selector_name.clone());
865                    changed = true;
866                }
867            }
868        }
869    }
870}
871
872#[cfg(test)]
873mod export_usage_tests {
874    use super::{
875        FactPrecision, OmenaQueryCssModuleExportUsageStatusV0,
876        OmenaQueryCssModulesUnusedExportSkipReasonV0, OmenaQuerySourceDocumentInputV0,
877        OmenaQueryStyleSourceInputV0, summarize_omena_query_css_modules_export_usage,
878    };
879
880    #[test]
881    fn css_modules_interface_export_usage_reprojects_existing_selector_usage() {
882        let style_sources = vec![
883            OmenaQueryStyleSourceInputV0 {
884                style_path: "/workspace/base.module.css".to_string(),
885                style_source: ".base {}".to_string(),
886            },
887            OmenaQueryStyleSourceInputV0 {
888                style_path: "/workspace/middle.module.css".to_string(),
889                style_source: ".middle { composes: base from \"./base.module.css\"; }".to_string(),
890            },
891            OmenaQueryStyleSourceInputV0 {
892                style_path: "/workspace/app.module.css".to_string(),
893                style_source:
894                    ".composed { composes: middle from \"./middle.module.css\"; } .ghost {}"
895                        .to_string(),
896            },
897        ];
898        let source_documents = vec![OmenaQuerySourceDocumentInputV0 {
899            source_path: "/workspace/App.tsx".to_string(),
900            source_source: r#"import styles from "./app.module.css";
901export const App = () => <div className={styles.composed} />;"#
902                .to_string(),
903            source_syntax_index: None,
904            has_unresolved_style_import: false,
905        }];
906
907        let report = summarize_omena_query_css_modules_export_usage(
908            &style_sources,
909            &source_documents,
910            &[],
911            None,
912        );
913
914        assert_eq!(report.used_export_count, 3);
915        assert_eq!(report.unused_export_count, 1);
916        assert_eq!(report.skipped_export_count, 0);
917        assert_eq!(report.diagnostics[0].export_name, "ghost");
918        assert_eq!(report.diagnostics[0].precision, FactPrecision::Exact);
919        assert!(report.exports.iter().any(|export| {
920            export.style_path.ends_with("base.module.css")
921                && export.export_name == "base"
922                && export.status == OmenaQueryCssModuleExportUsageStatusV0::Used
923        }));
924        assert!(report.exports.iter().any(|export| {
925            export.style_path.ends_with("middle.module.css")
926                && export.export_name == "middle"
927                && export.status == OmenaQueryCssModuleExportUsageStatusV0::Used
928        }));
929    }
930
931    #[test]
932    fn css_modules_interface_unresolved_edges_skip_unused_export_claims() {
933        let style_sources = vec![
934            OmenaQueryStyleSourceInputV0 {
935                style_path: "/workspace/app.module.css".to_string(),
936                style_source:
937                    ".button { composes: missing from \"./missing.module.css\"; } .ghost {}"
938                        .to_string(),
939            },
940            OmenaQueryStyleSourceInputV0 {
941                style_path: "/workspace/safe.module.css".to_string(),
942                style_source: ".safe {} .safeGhost {}".to_string(),
943            },
944        ];
945        let source_documents = vec![
946            OmenaQuerySourceDocumentInputV0 {
947                source_path: "/workspace/App.tsx".to_string(),
948                source_source: r#"import styles from "./app.module.css";
949export const App = () => <div className={styles.button} />;"#
950                    .to_string(),
951                source_syntax_index: None,
952                has_unresolved_style_import: false,
953            },
954            OmenaQuerySourceDocumentInputV0 {
955                source_path: "/workspace/Safe.tsx".to_string(),
956                source_source: r#"import styles from "./safe.module.css";
957export const Safe = () => <div className={styles.safe} />;"#
958                    .to_string(),
959                source_syntax_index: None,
960                has_unresolved_style_import: false,
961            },
962        ];
963
964        let report = summarize_omena_query_css_modules_export_usage(
965            &style_sources,
966            &source_documents,
967            &[],
968            None,
969        );
970
971        assert_eq!(report.unresolved_import_edge_count, 1);
972        assert_eq!(report.used_export_count, 1);
973        assert_eq!(report.unused_export_count, 1);
974        assert_eq!(report.skipped_export_count, 2);
975        assert_eq!(report.diagnostics[0].export_name, "safeGhost");
976        assert!(
977            report
978                .exports
979                .iter()
980                .filter(|export| { export.style_path.ends_with("app.module.css") })
981                .all(|export| {
982                    export.status == OmenaQueryCssModuleExportUsageStatusV0::Skipped
983                        && export.precision == FactPrecision::Unknown
984                        && export.skip_reasons.contains(
985                            &OmenaQueryCssModulesUnusedExportSkipReasonV0::UnresolvedImportEdge,
986                        )
987                })
988        );
989        assert!(report.exports.iter().any(|export| {
990            export.style_path.ends_with("safe.module.css")
991                && export.export_name == "safe"
992                && export.status == OmenaQueryCssModuleExportUsageStatusV0::Used
993        }));
994    }
995}