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