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