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