1use 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}