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
309pub(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 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 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
744fn 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}