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#[allow(clippy::too_many_arguments)]
285pub(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
344pub(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 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 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
756fn 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}