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
297pub(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 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 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
738fn 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}