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