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>;
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}