Skip to main content

omena_bundler/
emission_items.rs

1use std::collections::BTreeMap;
2
3use omena_parser::{
4    ModuleInstanceKeyV0, ParsedAnimationFactKind, ParsedEmissionSelectorFactKindV0,
5    ParsedEmissionSelectorFactsV0, ParsedSelectorFactKind, ParsedStyleFacts,
6};
7use serde::Serialize;
8
9use crate::{
10    GlobalRuleOrderV0, LinkedEmissionMaterializationErrorV0, LinkedStylesheetRuleV0,
11    TransformBundleLinkErrorV0, emission_order::EmissionModulePlanV0,
12};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
15#[serde(tag = "category", content = "nodeKind", rename_all = "camelCase")]
16#[non_exhaustive]
17pub enum EmissionItemKindV0 {
18    SelectorClass,
19    SelectorId,
20    SelectorPlaceholder,
21    SelectorElement,
22    SelectorAttribute,
23    SelectorUniversal,
24    SelectorPseudoClass,
25    SelectorPseudoElement,
26    UnknownStructuralSelector,
27    AtRule { node_kind: u32 },
28    UnknownAtRule,
29    KeyframesDeclaration,
30    AnimationNameReference,
31    ModuleBoundary,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "camelCase")]
36#[non_exhaustive]
37pub struct EmissionItemV0 {
38    pub kind: EmissionItemKindV0,
39    pub name: String,
40    pub range_start: u32,
41    pub range_end: u32,
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
45#[serde(rename_all = "camelCase")]
46#[non_exhaustive]
47pub enum EmissionItemFactCategoryV0 {
48    NamedSelectors,
49    EmissionSelectors,
50    Variables,
51    SassSymbols,
52    SassIncludes,
53    SassModuleEdges,
54    SassPlaceholderDefinitions,
55    ExtendTargets,
56    Animations,
57    CssModuleValues,
58    CssModuleValueImportEdges,
59    CssModuleValueDefinitionEdges,
60    CssModuleComposes,
61    CssModuleComposesEdges,
62    Icss,
63    IcssImportEdges,
64    IcssExportEdges,
65    AtRules,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
69#[serde(rename_all = "camelCase")]
70#[non_exhaustive]
71pub enum EmissionItemProjectionDispositionV0 {
72    Projected,
73    NotProjected,
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "camelCase")]
78#[non_exhaustive]
79pub enum EmissionItemProjectionReasonV0 {
80    SourceOrderedNamedSelector,
81    SourceOrderedStructuralSelector,
82    SourceOrderedAtRule,
83    SourceOrderedAnimation,
84    NotAnEmissionBoundary,
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
88#[serde(rename_all = "camelCase")]
89#[non_exhaustive]
90pub struct EmissionItemProjectionDisclosureV0 {
91    pub category: EmissionItemFactCategoryV0,
92    pub disposition: EmissionItemProjectionDispositionV0,
93    pub reason: EmissionItemProjectionReasonV0,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
97#[serde(rename_all = "camelCase")]
98#[non_exhaustive]
99pub struct EmissionItemInputV0 {
100    pub module_instance: ModuleInstanceKeyV0,
101    pub items: Vec<EmissionItemV0>,
102    pub disclosure: Vec<EmissionItemProjectionDisclosureV0>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
106#[serde(rename_all = "camelCase")]
107#[non_exhaustive]
108pub struct EmissionItemOrderKeyV0 {
109    pub module_instance: ModuleInstanceKeyV0,
110    pub intra_module_ordinal: u32,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114#[serde(rename_all = "camelCase")]
115#[non_exhaustive]
116pub struct EmissionItemPlanV0 {
117    pub policy: crate::EmissionOrderingPolicyV0,
118    pub entries: Vec<EmissionItemOrderKeyV0>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
122#[serde(rename_all = "camelCase")]
123#[non_exhaustive]
124pub struct LinkedEmissionItemV0 {
125    pub global_order_index: u32,
126    pub module_instance: ModuleInstanceKeyV0,
127    pub kind: EmissionItemKindV0,
128    pub name: String,
129    pub range_start: u32,
130    pub range_end: u32,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
134#[serde(rename_all = "camelCase")]
135#[non_exhaustive]
136pub struct LinkedEmissionItemOrderV0 {
137    pub items: Vec<LinkedEmissionItemV0>,
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
141#[serde(rename_all = "camelCase")]
142#[non_exhaustive]
143pub enum LinkedEmissionItemMaterializationErrorV0 {
144    LegacyMaterialization {
145        error: LinkedEmissionMaterializationErrorV0,
146    },
147    InvalidItemOrderIndex {
148        expected: u32,
149        actual: u32,
150    },
151    UnknownEmissionItemModule {
152        module_instance: ModuleInstanceKeyV0,
153    },
154    MissingEmissionItem {
155        module_instance: ModuleInstanceKeyV0,
156    },
157}
158
159impl From<LinkedEmissionMaterializationErrorV0> for LinkedEmissionItemMaterializationErrorV0 {
160    fn from(error: LinkedEmissionMaterializationErrorV0) -> Self {
161        Self::LegacyMaterialization { error }
162    }
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166#[non_exhaustive]
167pub struct TransformBundleEmissionItemProjectionV0 {
168    inputs: Vec<EmissionItemInputV0>,
169}
170
171impl TransformBundleEmissionItemProjectionV0 {
172    pub fn inputs(&self) -> &[EmissionItemInputV0] {
173        self.inputs.as_slice()
174    }
175
176    pub(crate) fn new(inputs: Vec<EmissionItemInputV0>) -> Self {
177        Self { inputs }
178    }
179}
180
181pub(crate) fn collect_emission_items(
182    facts: &ParsedStyleFacts,
183    emission_selectors: &ParsedEmissionSelectorFactsV0,
184) -> Vec<EmissionItemV0> {
185    let mut items = facts
186        .selectors
187        .iter()
188        .map(|selector| EmissionItemV0 {
189            kind: match selector.kind {
190                ParsedSelectorFactKind::Class => EmissionItemKindV0::SelectorClass,
191                ParsedSelectorFactKind::Id => EmissionItemKindV0::SelectorId,
192                ParsedSelectorFactKind::Placeholder => EmissionItemKindV0::SelectorPlaceholder,
193            },
194            name: selector.name.clone(),
195            range_start: u32::from(selector.range.start()),
196            range_end: u32::from(selector.range.end()),
197        })
198        .collect::<Vec<_>>();
199    for selector in &emission_selectors.selectors {
200        let kind = match selector.kind {
201            ParsedEmissionSelectorFactKindV0::Element => EmissionItemKindV0::SelectorElement,
202            ParsedEmissionSelectorFactKindV0::Attribute => EmissionItemKindV0::SelectorAttribute,
203            ParsedEmissionSelectorFactKindV0::Universal => EmissionItemKindV0::SelectorUniversal,
204            ParsedEmissionSelectorFactKindV0::PseudoClass => {
205                EmissionItemKindV0::SelectorPseudoClass
206            }
207            ParsedEmissionSelectorFactKindV0::PseudoElement => {
208                EmissionItemKindV0::SelectorPseudoElement
209            }
210            _ => EmissionItemKindV0::UnknownStructuralSelector,
211        };
212        items.push(EmissionItemV0 {
213            kind,
214            name: selector.name.clone(),
215            range_start: u32::from(selector.range.start()),
216            range_end: u32::from(selector.range.end()),
217        });
218    }
219    items.extend(facts.at_rules.iter().map(|at_rule| {
220        EmissionItemV0 {
221            kind: at_rule
222                .node_kind
223                .map_or(EmissionItemKindV0::UnknownAtRule, |node_kind| {
224                    EmissionItemKindV0::AtRule {
225                        node_kind: node_kind.as_u32(),
226                    }
227                }),
228            name: at_rule.name.clone(),
229            range_start: u32::from(at_rule.range.start()),
230            range_end: u32::from(at_rule.range.end()),
231        }
232    }));
233    items.extend(facts.animations.iter().map(|animation| EmissionItemV0 {
234        kind: match animation.kind {
235            ParsedAnimationFactKind::KeyframesDeclaration => {
236                EmissionItemKindV0::KeyframesDeclaration
237            }
238            ParsedAnimationFactKind::AnimationNameReference => {
239                EmissionItemKindV0::AnimationNameReference
240            }
241        },
242        name: animation.name.clone(),
243        range_start: u32::from(animation.range.start()),
244        range_end: u32::from(animation.range.end()),
245    }));
246    items.sort_by_key(|item| {
247        (
248            item.range_start,
249            item.range_end,
250            item.kind,
251            item.name.clone(),
252        )
253    });
254    if items.is_empty() {
255        items.push(EmissionItemV0 {
256            kind: EmissionItemKindV0::ModuleBoundary,
257            name: String::new(),
258            range_start: 0,
259            range_end: 0,
260        });
261    }
262    items
263}
264
265pub(crate) fn emission_item_projection_disclosure(
266    facts: &ParsedStyleFacts,
267) -> Vec<EmissionItemProjectionDisclosureV0> {
268    let ParsedStyleFacts {
269        product: _,
270        dialect: _,
271        selector_count: _,
272        selectors: _,
273        variable_count: _,
274        variables: _,
275        sass_symbol_count: _,
276        sass_symbols: _,
277        sass_include_count: _,
278        sass_includes: _,
279        sass_module_edge_count: _,
280        sass_module_edges: _,
281        sass_placeholder_definition_count: _,
282        sass_placeholder_definitions: _,
283        extend_target_count: _,
284        extend_targets: _,
285        animation_count: _,
286        animations: _,
287        css_module_value_count: _,
288        css_module_values: _,
289        css_module_value_import_edge_count: _,
290        css_module_value_import_edges: _,
291        css_module_value_definition_edge_count: _,
292        css_module_value_definition_edges: _,
293        css_module_composes_count: _,
294        css_module_composes: _,
295        css_module_composes_edge_count: _,
296        css_module_composes_edges: _,
297        icss_count: _,
298        icss: _,
299        icss_import_edge_count: _,
300        icss_import_edges: _,
301        icss_export_edge_count: _,
302        icss_export_edges: _,
303        at_rule_count: _,
304        at_rules: _,
305        error_count: _,
306    } = facts;
307
308    use EmissionItemFactCategoryV0 as Category;
309    use EmissionItemProjectionDispositionV0 as Disposition;
310    use EmissionItemProjectionReasonV0 as Reason;
311
312    [
313        (
314            Category::NamedSelectors,
315            Disposition::Projected,
316            Reason::SourceOrderedNamedSelector,
317        ),
318        (
319            Category::EmissionSelectors,
320            Disposition::Projected,
321            Reason::SourceOrderedStructuralSelector,
322        ),
323        (
324            Category::AtRules,
325            Disposition::Projected,
326            Reason::SourceOrderedAtRule,
327        ),
328        (
329            Category::Animations,
330            Disposition::Projected,
331            Reason::SourceOrderedAnimation,
332        ),
333        (
334            Category::Variables,
335            Disposition::NotProjected,
336            Reason::NotAnEmissionBoundary,
337        ),
338        (
339            Category::SassSymbols,
340            Disposition::NotProjected,
341            Reason::NotAnEmissionBoundary,
342        ),
343        (
344            Category::SassIncludes,
345            Disposition::NotProjected,
346            Reason::NotAnEmissionBoundary,
347        ),
348        (
349            Category::SassModuleEdges,
350            Disposition::NotProjected,
351            Reason::NotAnEmissionBoundary,
352        ),
353        (
354            Category::SassPlaceholderDefinitions,
355            Disposition::NotProjected,
356            Reason::NotAnEmissionBoundary,
357        ),
358        (
359            Category::ExtendTargets,
360            Disposition::NotProjected,
361            Reason::NotAnEmissionBoundary,
362        ),
363        (
364            Category::CssModuleValues,
365            Disposition::NotProjected,
366            Reason::NotAnEmissionBoundary,
367        ),
368        (
369            Category::CssModuleValueImportEdges,
370            Disposition::NotProjected,
371            Reason::NotAnEmissionBoundary,
372        ),
373        (
374            Category::CssModuleValueDefinitionEdges,
375            Disposition::NotProjected,
376            Reason::NotAnEmissionBoundary,
377        ),
378        (
379            Category::CssModuleComposes,
380            Disposition::NotProjected,
381            Reason::NotAnEmissionBoundary,
382        ),
383        (
384            Category::CssModuleComposesEdges,
385            Disposition::NotProjected,
386            Reason::NotAnEmissionBoundary,
387        ),
388        (
389            Category::Icss,
390            Disposition::NotProjected,
391            Reason::NotAnEmissionBoundary,
392        ),
393        (
394            Category::IcssImportEdges,
395            Disposition::NotProjected,
396            Reason::NotAnEmissionBoundary,
397        ),
398        (
399            Category::IcssExportEdges,
400            Disposition::NotProjected,
401            Reason::NotAnEmissionBoundary,
402        ),
403    ]
404    .into_iter()
405    .map(
406        |(category, disposition, reason)| EmissionItemProjectionDisclosureV0 {
407            category,
408            disposition,
409            reason,
410        },
411    )
412    .collect()
413}
414
415pub(crate) fn build_emission_item_plan(
416    inputs: &[EmissionItemInputV0],
417    module_plan: &EmissionModulePlanV0,
418) -> Result<EmissionItemPlanV0, TransformBundleLinkErrorV0> {
419    let inputs_by_instance = inputs
420        .iter()
421        .map(|input| (input.module_instance.clone(), input))
422        .collect::<BTreeMap<_, _>>();
423    if inputs_by_instance.len() != inputs.len() {
424        return Err(TransformBundleLinkErrorV0::InvalidEmissionPlan {
425            reason: "emission-item inputs contain a duplicate module instance".to_string(),
426        });
427    }
428    let mut entries = Vec::new();
429    for instance in &module_plan.module_order {
430        let input = inputs_by_instance.get(instance).ok_or_else(|| {
431            TransformBundleLinkErrorV0::InvalidEmissionPlan {
432                reason: format!(
433                    "reachable module {} has no emission-item input",
434                    instance.module().as_str()
435                ),
436            }
437        })?;
438        for intra_module_ordinal in 0..input.items.len() {
439            entries.push(EmissionItemOrderKeyV0 {
440                module_instance: instance.clone(),
441                intra_module_ordinal: u32::try_from(intra_module_ordinal).map_err(|_| {
442                    TransformBundleLinkErrorV0::InvalidEmissionPlan {
443                        reason: format!(
444                            "module {} has more emission items than the order key can represent",
445                            instance.module().as_str()
446                        ),
447                    }
448                })?,
449            });
450        }
451    }
452    Ok(EmissionItemPlanV0 {
453        policy: module_plan.policy,
454        entries,
455    })
456}
457
458pub(crate) fn build_linked_emission_item_order(
459    inputs: &[EmissionItemInputV0],
460    plan: &EmissionItemPlanV0,
461) -> Result<LinkedEmissionItemOrderV0, TransformBundleLinkErrorV0> {
462    let inputs_by_instance = inputs
463        .iter()
464        .map(|input| (input.module_instance.clone(), input))
465        .collect::<BTreeMap<_, _>>();
466    let mut items = Vec::with_capacity(plan.entries.len());
467    for (global_order_index, key) in plan.entries.iter().enumerate() {
468        let input = inputs_by_instance
469            .get(&key.module_instance)
470            .ok_or_else(|| TransformBundleLinkErrorV0::InvalidEmissionPlan {
471                reason: format!(
472                    "emission-item key refers to unknown module {}",
473                    key.module_instance.module().as_str()
474                ),
475            })?;
476        let item = input
477            .items
478            .get(key.intra_module_ordinal as usize)
479            .ok_or_else(|| TransformBundleLinkErrorV0::InvalidEmissionPlan {
480                reason: format!(
481                    "emission-item key refers to missing item {} in {}",
482                    key.intra_module_ordinal,
483                    key.module_instance.module().as_str()
484                ),
485            })?;
486        items.push(LinkedEmissionItemV0 {
487            global_order_index: u32::try_from(global_order_index).map_err(|_| {
488                TransformBundleLinkErrorV0::InvalidEmissionPlan {
489                    reason: "emission-item plan exceeds the output index range".to_string(),
490                }
491            })?,
492            module_instance: key.module_instance.clone(),
493            kind: item.kind,
494            name: item.name.clone(),
495            range_start: item.range_start,
496            range_end: item.range_end,
497        });
498    }
499    Ok(LinkedEmissionItemOrderV0 { items })
500}
501
502pub(crate) fn build_global_rule_order_from_emission_items(
503    order: &LinkedEmissionItemOrderV0,
504) -> Result<GlobalRuleOrderV0, TransformBundleLinkErrorV0> {
505    let mut rules = Vec::new();
506    for item in &order.items {
507        let selector_kind = match item.kind {
508            EmissionItemKindV0::SelectorClass => "class",
509            EmissionItemKindV0::SelectorId => "id",
510            EmissionItemKindV0::SelectorPlaceholder => "placeholder",
511            _ => continue,
512        };
513        rules.push(LinkedStylesheetRuleV0 {
514            global_order_index: u32::try_from(rules.len()).map_err(|_| {
515                TransformBundleLinkErrorV0::InvalidEmissionPlan {
516                    reason: "selector projection exceeds the output index range".to_string(),
517                }
518            })?,
519            module_instance: item.module_instance.clone(),
520            selector_name: item.name.clone(),
521            selector_kind,
522            range_start: item.range_start,
523            range_end: item.range_end,
524        });
525    }
526    Ok(GlobalRuleOrderV0 { rules })
527}
528
529#[cfg(test)]
530mod tests {
531    use std::collections::BTreeSet;
532
533    use omena_parser::{StyleDialect, collect_style_fact_collection};
534
535    use super::{
536        EmissionItemFactCategoryV0, EmissionItemKindV0, collect_emission_items,
537        emission_item_projection_disclosure,
538    };
539
540    fn items_for(source: &str) -> Vec<super::EmissionItemV0> {
541        let collection = collect_style_fact_collection(source, StyleDialect::Css);
542        collect_emission_items(&collection.facts, &collection.emission_selectors)
543    }
544
545    #[test]
546    fn selector_less_stylesheets_contribute_emission_items() {
547        for source in [
548            "main { color: red; }",
549            "@font-face { font-family: Demo; src: url(demo.woff2); }",
550            "@layer reset, theme;",
551        ] {
552            assert!(
553                !items_for(source).is_empty(),
554                "expected an emission item for {source}"
555            );
556        }
557    }
558
559    #[test]
560    fn emission_items_for_empty_stylesheets_are_module_boundaries() {
561        for source in ["", "/* license */"] {
562            assert_eq!(
563                items_for(source),
564                vec![super::EmissionItemV0 {
565                    kind: EmissionItemKindV0::ModuleBoundary,
566                    name: String::new(),
567                    range_start: 0,
568                    range_end: 0,
569                }]
570            );
571        }
572    }
573
574    #[test]
575    fn emission_items_follow_source_order_across_fact_categories() {
576        let items = items_for(
577            ":root { --brand: red; }\n\
578             @layer reset;\n\
579             div, .card, [hidden], *::before { color: red; }\n\
580             @keyframes pulse { from { opacity: 0; } }",
581        );
582
583        assert!(items.windows(2).all(|pair| {
584            (pair[0].range_start, pair[0].range_end) <= (pair[1].range_start, pair[1].range_end)
585        }));
586        for kind in [
587            EmissionItemKindV0::SelectorPseudoClass,
588            EmissionItemKindV0::SelectorElement,
589            EmissionItemKindV0::SelectorClass,
590            EmissionItemKindV0::SelectorAttribute,
591            EmissionItemKindV0::SelectorUniversal,
592            EmissionItemKindV0::SelectorPseudoElement,
593            EmissionItemKindV0::KeyframesDeclaration,
594        ] {
595            assert!(
596                items.iter().any(|item| item.kind == kind),
597                "missing emission item kind {kind:?}"
598            );
599        }
600        assert!(
601            items
602                .iter()
603                .any(|item| matches!(item.kind, EmissionItemKindV0::AtRule { .. }))
604        );
605    }
606
607    #[test]
608    fn emission_items_for_supported_syntax_have_no_unknown_kinds() {
609        for source in [
610            "main, [hidden], *, :hover, ::before { color: red; }",
611            "@font-face { font-family: Demo; src: url(demo.woff2); }",
612            "@layer reset, theme; @media (width > 1px) { div { color: red; } }",
613            "@supports (display: grid) { .grid { display: grid; } }",
614            "@keyframes pulse { from { opacity: 0; } to { opacity: 1; } }",
615        ] {
616            let unknown = items_for(source)
617                .into_iter()
618                .filter(|item| {
619                    matches!(
620                        item.kind,
621                        EmissionItemKindV0::UnknownStructuralSelector
622                            | EmissionItemKindV0::UnknownAtRule
623                    )
624                })
625                .collect::<Vec<_>>();
626            assert!(
627                unknown.is_empty(),
628                "unknown emission items for {source}: {unknown:?}"
629            );
630        }
631    }
632
633    #[test]
634    fn projection_disclosure_is_total_over_fact_categories() {
635        let collection = collect_style_fact_collection(".card { color: red; }", StyleDialect::Css);
636        let disclosure = emission_item_projection_disclosure(&collection.facts);
637        let categories = disclosure
638            .iter()
639            .map(|entry| entry.category)
640            .collect::<BTreeSet<_>>();
641
642        assert_eq!(disclosure.len(), 18);
643        assert_eq!(categories.len(), disclosure.len());
644        assert!(categories.contains(&EmissionItemFactCategoryV0::NamedSelectors));
645        assert!(categories.contains(&EmissionItemFactCategoryV0::EmissionSelectors));
646        assert!(categories.contains(&EmissionItemFactCategoryV0::AtRules));
647        assert!(categories.contains(&EmissionItemFactCategoryV0::Animations));
648    }
649}