Skip to main content

omena_transform_bundle/
lib.rs

1//! Source-fact backed bundle planning for Omena CSS transforms.
2//!
3//! This crate is the bridge from parser facts into the transform DAG. It
4//! decides which bundle/module passes are required for a style source and
5//! delegates ordering to `omena-transform-passes`.
6
7use omena_parser::{
8    ParsedCssModuleComposesEdgeKind, ParsedSassModuleEdgeFactKind, StyleDialect,
9    collect_style_facts,
10};
11use omena_transform_cst::TransformPassKind;
12use omena_transform_passes::{TransformPassPlanV0, plan_transform_passes};
13use serde::Serialize;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
16#[serde(rename_all = "camelCase")]
17pub enum TransformBundleEdgeKind {
18    SassUse,
19    SassForward,
20    SassImport,
21    CssImport,
22    LessImport,
23    CssModuleValueImport,
24    CssModuleComposesLocal,
25    CssModuleComposesExternal,
26    IcssImport,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct TransformBundleEdgeV0 {
32    pub kind: TransformBundleEdgeKind,
33    pub source_path: String,
34    pub import_source: Option<String>,
35    pub namespace: Option<String>,
36    pub local_names: Vec<String>,
37    pub remote_names: Vec<String>,
38    pub range_start: u32,
39    pub range_end: u32,
40    pub provenance_required: bool,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct TransformBundleSourceSummaryV0 {
46    pub schema_version: &'static str,
47    pub product: &'static str,
48    pub source_path: String,
49    pub dialect: &'static str,
50    pub bundle_edges: Vec<TransformBundleEdgeV0>,
51    pub required_pass_ids: Vec<&'static str>,
52    pub planned_pass_ids: Vec<&'static str>,
53    pub import_inline_required: bool,
54    pub module_evaluation_required: bool,
55    pub css_modules_resolution_required: bool,
56    pub class_hashing_required: bool,
57    pub value_resolution_required: bool,
58    pub pass_plan: TransformPassPlanV0,
59}
60
61pub fn summarize_omena_transform_bundle_from_source(
62    source_path: impl Into<String>,
63    source: &str,
64    dialect: StyleDialect,
65) -> TransformBundleSourceSummaryV0 {
66    let source_path = source_path.into();
67    let facts = collect_style_facts(source, dialect);
68    let bundle_edges = collect_bundle_edges_from_facts(&source_path, dialect, &facts);
69    let mut required_passes =
70        required_passes_for_source(&source_path, dialect, &facts, &bundle_edges);
71    required_passes.sort_by_key(|pass| pass.ordinal());
72    required_passes.dedup();
73    let pass_plan = plan_transform_passes(&required_passes);
74    let planned_pass_ids = pass_plan.ordered_pass_ids.clone();
75    let required_pass_ids = required_passes
76        .iter()
77        .map(|pass| pass.id())
78        .collect::<Vec<_>>();
79
80    TransformBundleSourceSummaryV0 {
81        schema_version: "0",
82        product: "omena-transform-bundle.source",
83        source_path,
84        dialect: dialect_label(dialect),
85        bundle_edges,
86        required_pass_ids,
87        planned_pass_ids,
88        import_inline_required: required_passes.contains(&TransformPassKind::ImportInline),
89        module_evaluation_required: required_passes.iter().any(|pass| {
90            matches!(
91                pass,
92                TransformPassKind::ScssModuleEvaluate | TransformPassKind::LessModuleEvaluate
93            )
94        }),
95        css_modules_resolution_required: required_passes.iter().any(|pass| {
96            matches!(
97                pass,
98                TransformPassKind::HashCssModuleClassNames
99                    | TransformPassKind::ResolveCssModulesComposes
100            )
101        }),
102        class_hashing_required: required_passes
103            .contains(&TransformPassKind::HashCssModuleClassNames),
104        value_resolution_required: required_passes.contains(&TransformPassKind::ValueResolution),
105        pass_plan,
106    }
107}
108
109fn collect_bundle_edges_from_facts(
110    source_path: &str,
111    dialect: StyleDialect,
112    facts: &omena_parser::ParsedStyleFacts,
113) -> Vec<TransformBundleEdgeV0> {
114    let mut edges = Vec::new();
115
116    for edge in &facts.sass_module_edges {
117        let kind = match edge.kind {
118            ParsedSassModuleEdgeFactKind::Use => TransformBundleEdgeKind::SassUse,
119            ParsedSassModuleEdgeFactKind::Forward => TransformBundleEdgeKind::SassForward,
120            ParsedSassModuleEdgeFactKind::Import => import_edge_kind_for_dialect(dialect),
121        };
122        edges.push(TransformBundleEdgeV0 {
123            kind,
124            source_path: source_path.to_string(),
125            import_source: Some(edge.source.clone()),
126            namespace: edge.namespace.clone(),
127            local_names: Vec::new(),
128            remote_names: Vec::new(),
129            range_start: u32::from(edge.range.start()),
130            range_end: u32::from(edge.range.end()),
131            provenance_required: true,
132        });
133    }
134
135    for edge in &facts.css_module_value_import_edges {
136        edges.push(TransformBundleEdgeV0 {
137            kind: TransformBundleEdgeKind::CssModuleValueImport,
138            source_path: source_path.to_string(),
139            import_source: Some(edge.import_source.clone()),
140            namespace: None,
141            local_names: vec![edge.local_name.clone()],
142            remote_names: vec![edge.remote_name.clone()],
143            range_start: u32::from(edge.range.start()),
144            range_end: u32::from(edge.range.end()),
145            provenance_required: true,
146        });
147    }
148
149    for edge in &facts.css_module_composes_edges {
150        let kind = match edge.kind {
151            ParsedCssModuleComposesEdgeKind::External => {
152                TransformBundleEdgeKind::CssModuleComposesExternal
153            }
154            ParsedCssModuleComposesEdgeKind::Local | ParsedCssModuleComposesEdgeKind::Global => {
155                TransformBundleEdgeKind::CssModuleComposesLocal
156            }
157        };
158        edges.push(TransformBundleEdgeV0 {
159            kind,
160            source_path: source_path.to_string(),
161            import_source: edge.import_source.clone(),
162            namespace: None,
163            local_names: edge.owner_selector_names.clone(),
164            remote_names: edge.target_names.clone(),
165            range_start: u32::from(edge.range.start()),
166            range_end: u32::from(edge.range.end()),
167            provenance_required: true,
168        });
169    }
170
171    for edge in &facts.icss_import_edges {
172        edges.push(TransformBundleEdgeV0 {
173            kind: TransformBundleEdgeKind::IcssImport,
174            source_path: source_path.to_string(),
175            import_source: Some(edge.import_source.clone()),
176            namespace: None,
177            local_names: vec![edge.local_name.clone()],
178            remote_names: vec![edge.remote_name.clone()],
179            range_start: u32::from(edge.range.start()),
180            range_end: u32::from(edge.range.end()),
181            provenance_required: true,
182        });
183    }
184
185    edges
186}
187
188fn import_edge_kind_for_dialect(dialect: StyleDialect) -> TransformBundleEdgeKind {
189    match dialect {
190        StyleDialect::Css => TransformBundleEdgeKind::CssImport,
191        StyleDialect::Less => TransformBundleEdgeKind::LessImport,
192        StyleDialect::Scss | StyleDialect::Sass => TransformBundleEdgeKind::SassImport,
193    }
194}
195
196fn required_passes_for_source(
197    source_path: &str,
198    dialect: StyleDialect,
199    facts: &omena_parser::ParsedStyleFacts,
200    bundle_edges: &[TransformBundleEdgeV0],
201) -> Vec<TransformPassKind> {
202    let mut passes = Vec::new();
203
204    if bundle_edges.iter().any(|edge| {
205        matches!(
206            edge.kind,
207            TransformBundleEdgeKind::SassImport
208                | TransformBundleEdgeKind::CssImport
209                | TransformBundleEdgeKind::LessImport
210                | TransformBundleEdgeKind::CssModuleValueImport
211                | TransformBundleEdgeKind::CssModuleComposesExternal
212                | TransformBundleEdgeKind::IcssImport
213        )
214    }) {
215        passes.push(TransformPassKind::ImportInline);
216    }
217
218    if matches!(dialect, StyleDialect::Scss | StyleDialect::Sass) {
219        passes.push(TransformPassKind::ScssModuleEvaluate);
220    }
221
222    if matches!(dialect, StyleDialect::Less) {
223        passes.push(TransformPassKind::LessModuleEvaluate);
224    }
225
226    if is_css_module_path(source_path) && facts.selector_count > 0 {
227        passes.push(TransformPassKind::HashCssModuleClassNames);
228    }
229
230    if facts.css_module_composes_edge_count > 0 {
231        passes.push(TransformPassKind::ResolveCssModulesComposes);
232    }
233
234    if facts.css_module_value_count > 0 || facts.css_module_value_import_edge_count > 0 {
235        passes.push(TransformPassKind::ValueResolution);
236    }
237
238    passes
239}
240
241fn is_css_module_path(source_path: &str) -> bool {
242    let file_name = source_path
243        .rsplit(['/', '\\'])
244        .next()
245        .unwrap_or(source_path)
246        .to_ascii_lowercase();
247    let Some((stem, extension)) = file_name.rsplit_once('.') else {
248        return false;
249    };
250    matches!(extension, "css" | "scss" | "sass" | "less") && stem.ends_with(".module")
251}
252
253fn dialect_label(dialect: StyleDialect) -> &'static str {
254    match dialect {
255        StyleDialect::Css => "css",
256        StyleDialect::Scss => "scss",
257        StyleDialect::Sass => "sass",
258        StyleDialect::Less => "less",
259    }
260}
261
262#[cfg(test)]
263mod tests {
264    use super::{TransformBundleEdgeKind, summarize_omena_transform_bundle_from_source};
265    use omena_parser::StyleDialect;
266
267    #[test]
268    fn builds_bundle_plan_from_scss_and_css_modules_parser_facts() {
269        let source = r#"
270@use "./tokens" as tokens;
271@forward "./theme";
272@value primary from "./colors.module.css";
273.button {
274  composes: reset from "./reset.module.css";
275  color: tokens.$brand;
276}
277"#;
278        let summary = summarize_omena_transform_bundle_from_source(
279            "Button.module.scss",
280            source,
281            StyleDialect::Scss,
282        );
283
284        assert_eq!(summary.product, "omena-transform-bundle.source");
285        assert_eq!(summary.dialect, "scss");
286        assert!(summary.import_inline_required);
287        assert!(summary.module_evaluation_required);
288        assert!(summary.css_modules_resolution_required);
289        assert!(summary.class_hashing_required);
290        assert!(summary.value_resolution_required);
291        assert!(summary.pass_plan.violated_dag_edge_count == 0);
292        assert!(summary.bundle_edges.iter().any(|edge| {
293            edge.kind == TransformBundleEdgeKind::CssModuleComposesExternal
294                && edge.import_source.as_deref() == Some("./reset.module.css")
295        }));
296        assert_eq!(
297            summary.planned_pass_ids,
298            vec![
299                "import-inline",
300                "scss-module-evaluate",
301                "composes-resolution",
302                "css-modules-class-hashing",
303                "value-resolution"
304            ]
305        );
306    }
307
308    #[test]
309    fn recognizes_less_module_evaluation_from_dialect() {
310        let summary = summarize_omena_transform_bundle_from_source(
311            "Theme.module.less",
312            r#"@import (reference) "tokens.less"; .card { color: @brand; }"#,
313            StyleDialect::Less,
314        );
315
316        assert!(summary.module_evaluation_required);
317        assert!(summary.import_inline_required);
318        assert!(
319            summary
320                .bundle_edges
321                .iter()
322                .any(|edge| edge.kind == TransformBundleEdgeKind::LessImport)
323        );
324        assert!(summary.required_pass_ids.contains(&"less-module-evaluate"));
325        assert!(!summary.required_pass_ids.contains(&"scss-module-evaluate"));
326        assert!(
327            summary
328                .required_pass_ids
329                .contains(&"css-modules-class-hashing")
330        );
331    }
332
333    #[test]
334    fn plans_plain_css_import_inline_without_scss_module_evaluation() {
335        let summary = summarize_omena_transform_bundle_from_source(
336            "App.css",
337            r#"@import "./tokens.css"; .button { color: red; }"#,
338            StyleDialect::Css,
339        );
340
341        assert!(summary.import_inline_required);
342        assert!(!summary.module_evaluation_required);
343        assert_eq!(summary.required_pass_ids, vec!["import-inline"]);
344        assert_eq!(summary.planned_pass_ids, vec!["import-inline"]);
345        assert!(
346            summary
347                .bundle_edges
348                .iter()
349                .any(|edge| edge.kind == TransformBundleEdgeKind::CssImport)
350        );
351        assert!(
352            !summary
353                .bundle_edges
354                .iter()
355                .any(|edge| edge.kind == TransformBundleEdgeKind::SassImport)
356        );
357    }
358
359    #[test]
360    fn rejects_module_substring_false_positive_paths() {
361        let source = ".button { color: red; }";
362        let backup_summary = summarize_omena_transform_bundle_from_source(
363            "Button.module.backup.scss",
364            source,
365            StyleDialect::Scss,
366        );
367        let unrelated_summary = summarize_omena_transform_bundle_from_source(
368            "module/Button.scss",
369            source,
370            StyleDialect::Scss,
371        );
372
373        assert!(!backup_summary.class_hashing_required);
374        assert!(!unrelated_summary.class_hashing_required);
375        assert!(
376            !backup_summary
377                .required_pass_ids
378                .contains(&"css-modules-class-hashing")
379        );
380        assert!(
381            !unrelated_summary
382                .required_pass_ids
383                .contains(&"css-modules-class-hashing")
384        );
385    }
386
387    #[test]
388    fn recognizes_css_module_path_by_final_stem_and_supported_extension() {
389        let summary = summarize_omena_transform_bundle_from_source(
390            "components\\Button.MODULE.SCSS",
391            ".button { color: red; }",
392            StyleDialect::Scss,
393        );
394
395        assert!(summary.class_hashing_required);
396        assert!(
397            summary
398                .required_pass_ids
399                .contains(&"css-modules-class-hashing")
400        );
401    }
402}