1use super::{OmenaQueryCssModulesCrossFileResolutionV0, OmenaQueryModuleInterfaceProjectionV0};
2use crate::{
3 OmenaCrossFileSummaryViewReportV0, OmenaQueryCssModulesExportUsageReportV0,
4 OmenaQueryModuleIdV0,
5};
6use omena_syntax::ident::{CanonicalClassKeyV0, ClassNameV0};
7use serde::Serialize;
8use std::collections::{BTreeMap, BTreeSet};
9
10pub(super) type EmittedClassNameIndexV0 = BTreeMap<(String, String), String>;
11pub(super) type ExportSourceSpanIndexV0 =
12 BTreeMap<String, BTreeMap<String, Vec<OmenaQueryCssModuleExportSourceSpanV0>>>;
13
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct OmenaQueryCssModulesInterfaceBundleV0 {
17 pub schema_version: &'static str,
18 pub product: &'static str,
19 pub module_count: usize,
20 pub class_export_count: usize,
21 pub icss_export_count: usize,
22 pub modules: Vec<OmenaQueryCssModuleInterfaceV0>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "camelCase")]
27pub struct OmenaQueryCssModuleInterfaceV0 {
28 pub module_id: OmenaQueryModuleIdV0,
29 pub style_path: String,
30 pub class_exports: Vec<OmenaQueryCssModuleClassExportV0>,
31 pub icss_exports: Vec<OmenaQueryCssModuleIcssExportV0>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
35#[serde(rename_all = "camelCase")]
36pub struct OmenaQueryCssModuleClassReferenceV0 {
37 pub module_id: OmenaQueryModuleIdV0,
38 pub name: String,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
42#[serde(rename_all = "camelCase")]
43pub struct OmenaQueryCssModuleExportSourceSpanV0 {
44 pub start: usize,
45 pub end: usize,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
49#[serde(rename_all = "camelCase")]
50pub struct OmenaQueryCssModuleClassExportV0 {
51 pub name: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub named_export: Option<String>,
54 pub resolved_classes: Vec<OmenaQueryCssModuleClassReferenceV0>,
55 pub emitted_classes: Vec<String>,
56 pub source_spans: Vec<OmenaQueryCssModuleExportSourceSpanV0>,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub struct OmenaQueryCssModuleIcssExportV0 {
62 pub name: String,
63 pub value: String,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 pub named_export: Option<String>,
66 pub source_spans: Vec<OmenaQueryCssModuleExportSourceSpanV0>,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
70#[serde(rename_all = "camelCase")]
71pub struct OmenaQueryCssModulesInterfaceSummaryViewV0 {
72 pub schema_version: &'static str,
73 pub product: &'static str,
74 pub module_count: usize,
75 pub export_count: usize,
76 pub icss_export_count: usize,
77 pub unused_export_count: usize,
78 pub skipped_export_count: usize,
79 pub cross_file_summary_ready: bool,
80 pub interface_summary_ready: bool,
81}
82
83pub(super) fn summarize_css_modules_interface_bundle_from_projections(
84 projections: &[OmenaQueryModuleInterfaceProjectionV0],
85 resolution: &OmenaQueryCssModulesCrossFileResolutionV0,
86 icss_export_values_by_path: &BTreeMap<String, BTreeMap<String, String>>,
87 class_export_source_spans_by_path: &ExportSourceSpanIndexV0,
88 icss_export_source_spans_by_path: &ExportSourceSpanIndexV0,
89 emitted_class_names: &EmittedClassNameIndexV0,
90) -> OmenaQueryCssModulesInterfaceBundleV0 {
91 let mut projections = projections.iter().collect::<Vec<_>>();
92 projections.sort_by(|left, right| left.style_path.cmp(&right.style_path));
93
94 let modules = projections
95 .into_iter()
96 .map(|projection| {
97 module_interface_from_projection(
98 projection,
99 resolution,
100 icss_export_values_by_path.get(projection.style_path.as_str()),
101 class_export_source_spans_by_path.get(projection.style_path.as_str()),
102 icss_export_source_spans_by_path.get(projection.style_path.as_str()),
103 emitted_class_names,
104 )
105 })
106 .collect::<Vec<_>>();
107 let class_export_count = modules
108 .iter()
109 .map(|module| module.class_exports.len())
110 .sum();
111 let icss_export_count = modules.iter().map(|module| module.icss_exports.len()).sum();
112
113 OmenaQueryCssModulesInterfaceBundleV0 {
114 schema_version: "0",
115 product: "omena-query.css-modules-interface-bundle",
116 module_count: modules.len(),
117 class_export_count,
118 icss_export_count,
119 modules,
120 }
121}
122
123fn module_interface_from_projection(
124 projection: &OmenaQueryModuleInterfaceProjectionV0,
125 resolution: &OmenaQueryCssModulesCrossFileResolutionV0,
126 icss_export_values: Option<&BTreeMap<String, String>>,
127 class_export_source_spans: Option<
128 &BTreeMap<String, Vec<OmenaQueryCssModuleExportSourceSpanV0>>,
129 >,
130 icss_export_source_spans: Option<&BTreeMap<String, Vec<OmenaQueryCssModuleExportSourceSpanV0>>>,
131 emitted_class_names: &EmittedClassNameIndexV0,
132) -> OmenaQueryCssModuleInterfaceV0 {
133 let module_id = OmenaQueryModuleIdV0::new(projection.style_path.clone());
134 let class_exports = projection
135 .css_modules_style_facts
136 .class_selector_names
137 .iter()
138 .cloned()
139 .collect::<BTreeSet<_>>()
140 .into_iter()
141 .map(|name| {
142 let owner = OmenaQueryCssModuleClassReferenceV0 {
143 module_id: module_id.clone(),
144 name: name.clone(),
145 };
146 let owner_key = canonical_class_reference_key(&owner);
147 let mut resolved_classes = BTreeMap::from([(owner_key.clone(), owner)]);
148 for edge in resolution.composes_closure_edges.iter().filter(|edge| {
149 edge.from_style_path == projection.style_path
150 && ClassNameV0::new(&edge.owner_selector_name).same_as(&ClassNameV0::new(&name))
151 }) {
152 let reference = OmenaQueryCssModuleClassReferenceV0 {
153 module_id: OmenaQueryModuleIdV0::new(edge.target_style_path.clone()),
154 name: edge.target_selector_name.clone(),
155 };
156 resolved_classes
157 .entry(canonical_class_reference_key(&reference))
158 .or_insert(reference);
159 }
160 let mut resolved_classes = resolved_classes.into_values().collect::<Vec<_>>();
161 resolved_classes.sort_by_key(|class| {
162 let key = canonical_class_reference_key(class);
163 (key != owner_key, key)
164 });
165 let emitted_classes = resolved_classes
166 .iter()
167 .filter_map(|class| {
168 emitted_class_names
169 .get(&(class.module_id.as_str().to_string(), class.name.clone()))
170 .or_else(|| {
171 emitted_class_names.iter().find_map(
172 |((module_id, raw_name), emitted_name)| {
173 (module_id == class.module_id.as_str()
174 && ClassNameV0::new(raw_name)
175 .same_as(&ClassNameV0::new(&class.name)))
176 .then_some(emitted_name)
177 },
178 )
179 })
180 .cloned()
181 })
182 .collect();
183 OmenaQueryCssModuleClassExportV0 {
184 named_export: ecmascript_named_export(&name),
185 source_spans: class_export_source_spans
186 .and_then(|spans| spans.get(name.as_str()))
187 .cloned()
188 .unwrap_or_default(),
189 name,
190 resolved_classes,
191 emitted_classes,
192 }
193 })
194 .collect::<Vec<_>>();
195
196 let icss_exports = projection
197 .css_modules_style_facts
198 .icss_export_names
199 .iter()
200 .filter_map(|export_name| {
201 icss_export_values
202 .and_then(|values| values.get(export_name.as_str()))
203 .map(|value| (export_name.clone(), value.clone()))
204 })
205 .collect::<BTreeMap<_, _>>()
206 .into_iter()
207 .map(|(name, value)| OmenaQueryCssModuleIcssExportV0 {
208 named_export: ecmascript_named_export(&name),
209 source_spans: icss_export_source_spans
210 .and_then(|spans| spans.get(name.as_str()))
211 .cloned()
212 .unwrap_or_default(),
213 name,
214 value,
215 })
216 .collect();
217
218 OmenaQueryCssModuleInterfaceV0 {
219 module_id,
220 style_path: projection.style_path.clone(),
221 class_exports,
222 icss_exports,
223 }
224}
225
226fn canonical_class_reference_key(
227 reference: &OmenaQueryCssModuleClassReferenceV0,
228) -> (OmenaQueryModuleIdV0, CanonicalClassKeyV0) {
229 (
230 reference.module_id.clone(),
231 ClassNameV0::new(&reference.name).canonical_key(),
232 )
233}
234
235pub fn render_omena_query_css_module_typescript_declaration(
236 module: &OmenaQueryCssModuleInterfaceV0,
237) -> String {
238 let mut output = String::from(
239 "// Emitted CSS Modules tokens and ICSS values occupy distinct export namespaces.\n\
240 export declare const classExports: {\n",
241 );
242 let class_export_names = module
243 .class_exports
244 .iter()
245 .map(|export| export.name.as_str())
246 .collect::<BTreeSet<_>>();
247 for export_name in class_export_names {
248 let name = serde_json::to_string(export_name).unwrap_or_else(|_| "\"\"".to_string());
249 output.push_str(" readonly ");
250 output.push_str(name.as_str());
251 output.push_str(": string;\n");
252 }
253 output.push_str("};\nexport declare const valueExports: {\n");
254 for export_name in module
255 .icss_exports
256 .iter()
257 .map(|export| export.name.as_str())
258 .collect::<BTreeSet<_>>()
259 {
260 let name = serde_json::to_string(export_name).unwrap_or_else(|_| "\"\"".to_string());
261 output.push_str(" readonly ");
262 output.push_str(name.as_str());
263 output.push_str(": string;\n");
264 }
265 output.push_str("};\ndeclare const styles: typeof classExports;\nexport default styles;\n");
266 let mut named_export_counts = BTreeMap::<&str, usize>::new();
267 for named_export in module
268 .class_exports
269 .iter()
270 .filter_map(|export| export.named_export.as_deref())
271 .chain(
272 module
273 .icss_exports
274 .iter()
275 .filter_map(|export| export.named_export.as_deref()),
276 )
277 {
278 *named_export_counts.entry(named_export).or_default() += 1;
279 }
280 for (named_export, count) in named_export_counts {
281 if count != 1 || matches!(named_export, "classExports" | "valueExports") {
282 continue;
283 }
284 output.push_str("export const ");
285 output.push_str(named_export);
286 output.push_str(": string;\n");
287 }
288 output
289}
290
291fn ecmascript_named_export(name: &str) -> Option<String> {
292 let mut chars = name.chars();
293 let first = chars.next()?;
294 if !(first == '_' || first == '$' || first.is_ascii_alphabetic())
295 || !chars.all(|ch| ch == '_' || ch == '$' || ch.is_ascii_alphanumeric())
296 || ECMASCRIPT_RESERVED_WORDS.contains(&name)
297 {
298 return None;
299 }
300 Some(name.to_string())
301}
302
303const ECMASCRIPT_RESERVED_WORDS: &[&str] = &[
304 "await",
305 "break",
306 "case",
307 "catch",
308 "class",
309 "const",
310 "continue",
311 "debugger",
312 "default",
313 "delete",
314 "do",
315 "else",
316 "enum",
317 "export",
318 "extends",
319 "false",
320 "finally",
321 "for",
322 "function",
323 "if",
324 "implements",
325 "import",
326 "in",
327 "instanceof",
328 "interface",
329 "let",
330 "new",
331 "null",
332 "package",
333 "private",
334 "protected",
335 "public",
336 "return",
337 "static",
338 "super",
339 "switch",
340 "this",
341 "throw",
342 "true",
343 "try",
344 "typeof",
345 "var",
346 "void",
347 "while",
348 "with",
349 "yield",
350];
351
352pub fn render_omena_query_css_modules_interface_json(
353 bundle: &OmenaQueryCssModulesInterfaceBundleV0,
354) -> Result<String, serde_json::Error> {
355 serde_json::to_string_pretty(bundle).map(|mut output| {
356 output.push('\n');
357 output
358 })
359}
360
361pub fn summarize_omena_query_css_modules_interface_summary_view(
362 cross_file_summary_view: &OmenaCrossFileSummaryViewReportV0,
363 bundle: &OmenaQueryCssModulesInterfaceBundleV0,
364 usage: &OmenaQueryCssModulesExportUsageReportV0,
365) -> OmenaQueryCssModulesInterfaceSummaryViewV0 {
366 let export_count_matches = bundle.class_export_count == usage.export_count;
367 OmenaQueryCssModulesInterfaceSummaryViewV0 {
368 schema_version: "0",
369 product: "omena-query.css-modules-interface-summary-view",
370 module_count: bundle.module_count,
371 export_count: bundle.class_export_count,
372 icss_export_count: bundle.icss_export_count,
373 unused_export_count: usage.unused_export_count,
374 skipped_export_count: usage.skipped_export_count,
375 cross_file_summary_ready: cross_file_summary_view.summary_view_ready,
376 interface_summary_ready: cross_file_summary_view.summary_view_ready
377 && export_count_matches
378 && usage.unresolved_import_edge_count == 0
379 && usage.skipped_export_count == 0,
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::{OmenaQueryStylePackageManifestV0, OmenaQueryStyleSourceInputV0};
387
388 #[test]
389 fn css_modules_interface_resolves_composes_and_preserves_icss_values() -> Result<(), String> {
390 let sources = vec![
391 OmenaQueryStyleSourceInputV0 {
392 style_path: "/workspace/base.module.css".to_string(),
393 style_source: ".base {}".to_string(),
394 },
395 OmenaQueryStyleSourceInputV0 {
396 style_path: "/workspace/middle.module.css".to_string(),
397 style_source:
398 ".middle { composes: base from \"./base.module.css\"; }".to_string(),
399 },
400 OmenaQueryStyleSourceInputV0 {
401 style_path: "/workspace/button.module.css".to_string(),
402 style_source:
403 ":export { tone: #0af; } .button { composes: middle from \"./middle.module.css\"; }"
404 .to_string(),
405 },
406 ];
407
408 let bundle = super::super::summarize_omena_query_css_modules_interface_bundle(
409 &sources,
410 &Vec::<OmenaQueryStylePackageManifestV0>::new(),
411 );
412
413 assert_eq!(bundle.module_count, 3);
414 let button = bundle
415 .modules
416 .iter()
417 .find(|module| module.style_path.ends_with("button.module.css"))
418 .ok_or_else(|| "missing button interface".to_string())?;
419 assert_eq!(button.icss_exports[0].name, "tone");
420 assert_eq!(button.icss_exports[0].value, "#0af");
421 assert_eq!(button.class_exports[0].name, "button");
422 assert_eq!(button.class_exports[0].resolved_classes.len(), 3);
423 assert!(
424 button.class_exports[0]
425 .resolved_classes
426 .iter()
427 .any(|class| {
428 class.module_id.as_str().ends_with("base.module.css") && class.name == "base"
429 })
430 );
431 assert!(
432 button.class_exports[0]
433 .resolved_classes
434 .iter()
435 .any(|class| {
436 class.module_id.as_str().ends_with("middle.module.css")
437 && class.name == "middle"
438 })
439 );
440 let declaration = render_omena_query_css_module_typescript_declaration(button);
441 assert!(declaration.contains("readonly \"button\": string;"));
442 assert!(declaration.contains("readonly \"tone\": string;"));
443 Ok(())
444 }
445
446 #[test]
447 fn css_modules_interface_collapses_decode_equivalent_composes_in_owner_first_order() {
448 let sources = vec![
449 OmenaQueryStyleSourceInputV0 {
450 style_path: "/workspace/base.module.css".to_string(),
451 style_source: r".a\62 c {} .abc {}".to_string(),
452 },
453 OmenaQueryStyleSourceInputV0 {
454 style_path: "/workspace/button.module.css".to_string(),
455 style_source: r#".button { composes: a\62 c abc from "./base.module.css"; }"#
456 .to_string(),
457 },
458 ];
459
460 let bundle = super::super::summarize_omena_query_css_modules_interface_bundle(
461 sources.as_slice(),
462 &[],
463 );
464 let button = bundle
465 .modules
466 .iter()
467 .find(|module| module.style_path.ends_with("button.module.css"))
468 .and_then(|module| module.class_exports.first());
469
470 assert!(button.is_some());
471 if let Some(button) = button {
472 assert_eq!(button.resolved_classes.len(), 2);
473 assert_eq!(button.resolved_classes[0].name, "button");
474 assert_eq!(
475 ClassNameV0::new(&button.resolved_classes[1].name)
476 .canonical_key()
477 .as_str(),
478 "abc"
479 );
480 }
481 }
482
483 #[test]
484 fn css_modules_interface_declaration_and_json_are_byte_deterministic() -> Result<(), String> {
485 let sources = vec![
486 OmenaQueryStyleSourceInputV0 {
487 style_path: "/workspace/zeta.module.css".to_string(),
488 style_source: ".zeta {} .alpha {} :export { tone: rgb(1, 2, 3); }".to_string(),
489 },
490 OmenaQueryStyleSourceInputV0 {
491 style_path: "/workspace/alpha.module.css".to_string(),
492 style_source: ".root {}".to_string(),
493 },
494 ];
495 let mut reversed_sources = sources.clone();
496 reversed_sources.reverse();
497 let bundle =
498 super::super::summarize_omena_query_css_modules_interface_bundle(&sources, &[]);
499 let reversed_bundle = super::super::summarize_omena_query_css_modules_interface_bundle(
500 &reversed_sources,
501 &[],
502 );
503
504 let first_declaration =
505 render_omena_query_css_module_typescript_declaration(&bundle.modules[1]);
506 let second_declaration =
507 render_omena_query_css_module_typescript_declaration(&reversed_bundle.modules[1]);
508 assert_eq!(first_declaration, second_declaration);
509 assert!(first_declaration.starts_with(
510 "// Emitted CSS Modules tokens and ICSS values occupy distinct export namespaces.\n"
511 ));
512 assert!(first_declaration.contains("export declare const classExports:"));
513 assert!(first_declaration.contains("export declare const valueExports:"));
514 assert!(first_declaration.contains("declare const styles: typeof classExports;"));
515 let mut token_changed_module = bundle.modules[1].clone();
516 for export in &mut token_changed_module.class_exports {
517 export.emitted_classes = vec!["opaque-token-change".to_string()];
518 }
519 assert_eq!(
520 first_declaration,
521 render_omena_query_css_module_typescript_declaration(&token_changed_module),
522 "the declaration header and public keys must not depend on opaque emitted tokens"
523 );
524 let alpha_offset = first_declaration
525 .find("alpha")
526 .ok_or_else(|| "missing alpha declaration".to_string())?;
527 let zeta_offset = first_declaration
528 .find("zeta")
529 .ok_or_else(|| "missing zeta declaration".to_string())?;
530 assert!(alpha_offset < zeta_offset);
531
532 let first_json = render_omena_query_css_modules_interface_json(&bundle)
533 .map_err(|error| error.to_string())?;
534 let second_json = render_omena_query_css_modules_interface_json(&reversed_bundle)
535 .map_err(|error| error.to_string())?;
536 assert_eq!(first_json, second_json);
537 Ok(())
538 }
539
540 #[test]
541 fn css_modules_interface_summary_is_a_view_over_existing_outputs() {
542 let sources = vec![OmenaQueryStyleSourceInputV0 {
543 style_path: "/workspace/card.module.css".to_string(),
544 style_source: ".used {} .ghost {}".to_string(),
545 }];
546 let source_documents = vec![crate::OmenaQuerySourceDocumentInputV0 {
547 source_path: "/workspace/card.tsx".to_string(),
548 source_source: r#"import styles from "./card.module.css";
549export const Card = () => <div className={styles.used} />;"#
550 .to_string(),
551 source_syntax_index: None,
552 has_unresolved_style_import: false,
553 }];
554 let bundle =
555 super::super::summarize_omena_query_css_modules_interface_bundle(&sources, &[]);
556 let usage = crate::summarize_omena_query_css_modules_export_usage(
557 &sources,
558 &source_documents,
559 &[],
560 None,
561 );
562 let summary = crate::summarize_omena_query_workspace_cross_file_summary(
563 &sources,
564 &source_documents,
565 &[],
566 );
567 let cross_file_view = crate::summarize_cross_file_summary_view_v0(&summary);
568
569 let view = summarize_omena_query_css_modules_interface_summary_view(
570 &cross_file_view,
571 &bundle,
572 &usage,
573 );
574
575 assert_eq!(view.module_count, bundle.module_count);
576 assert_eq!(view.export_count, bundle.class_export_count);
577 assert_eq!(view.unused_export_count, usage.unused_export_count);
578 assert!(view.interface_summary_ready);
579 }
580}