Skip to main content

omena_query/style/
module_interface.rs

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}