Skip to main content

omena_query/style/
module_interface.rs

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}