1mod emission_order;
10
11pub use emission_order::{
12 EmissionCycleClassV0, EmissionCycleGroupV0, EmissionCyclePolicyV0, EmissionDependencyFactV0,
13 EmissionOrderKeyV0, EmissionOrderingPolicyV0, EmissionPlanV0,
14};
15
16use omena_cascade::{CascadeKey, CascadeLevel, LayerRank, ModuleRank, Specificity};
17use omena_cross_file_summary::{EdgeOrderRelevanceV0, OmenaCrossFileSummaryRawEdgeKindV0};
18use omena_parser::{
19 ClosedWorldBundleBuildErrorV0, ClosedWorldBundleV0, ClosedWorldLinkedModuleV0,
20 ClosedWorldModuleMetadataV0, ConfigurationHashV0, ModuleIdV0, ModuleInstanceKeyV0,
21 ParsedAnimationFactKind, ParsedCssModuleComposesEdgeKind, ParsedCssModuleValueFactKind,
22 ParsedSassModuleEdgeFactKind, ParsedSelectorFactKind, ParsedVariableFactKind, StyleDialect,
23 collect_style_facts,
24};
25use omena_transform_cst::{
26 IrNodeKindV0, TransformPassKind, lower_transform_ir_from_source, transform_pass_sort_ordinal,
27};
28use omena_transform_passes::{TransformPassPlanV0, plan_transform_passes};
29use serde::Serialize;
30use std::{
31 collections::{BTreeMap, BTreeSet},
32 path::{Component, Path, PathBuf},
33};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
36#[serde(rename_all = "camelCase")]
37pub enum TransformBundleEdgeKind {
38 SassUse,
39 SassForward,
40 SassImport,
41 CssImport,
42 LessImport,
43 CssModuleValueImport,
44 CssModuleComposesLocal,
45 CssModuleComposesExternal,
46 IcssImport,
47}
48
49pub const TRANSFORM_BUNDLE_EDGE_KIND_VARIANTS_V0: [TransformBundleEdgeKind; 9] = [
50 TransformBundleEdgeKind::SassUse,
51 TransformBundleEdgeKind::SassForward,
52 TransformBundleEdgeKind::SassImport,
53 TransformBundleEdgeKind::CssImport,
54 TransformBundleEdgeKind::LessImport,
55 TransformBundleEdgeKind::CssModuleValueImport,
56 TransformBundleEdgeKind::CssModuleComposesLocal,
57 TransformBundleEdgeKind::CssModuleComposesExternal,
58 TransformBundleEdgeKind::IcssImport,
59];
60
61impl TransformBundleEdgeKind {
62 pub const fn as_wire_label(self) -> &'static str {
63 match self {
64 Self::SassUse => "sassUse",
65 Self::SassForward => "sassForward",
66 Self::SassImport => "sassImport",
67 Self::CssImport => "cssImport",
68 Self::LessImport => "lessImport",
69 Self::CssModuleValueImport => "cssModuleValueImport",
70 Self::CssModuleComposesLocal => "cssModuleComposesLocal",
71 Self::CssModuleComposesExternal => "cssModuleComposesExternal",
72 Self::IcssImport => "icssImport",
73 }
74 }
75
76 pub const fn order_relevance(self) -> EdgeOrderRelevanceV0 {
77 self.raw_edge_kind().order_relevance()
78 }
79
80 pub const fn order_relevance_reason(self) -> &'static str {
81 match self {
82 Self::SassUse => "Sass module use sequence participates in evaluation order",
83 Self::SassForward => "Sass forwarding sequence participates in module exposure order",
84 Self::SassImport => "Sass import sequence participates in emitted rule order",
85 Self::CssImport => "CSS import sequence participates in emitted rule order",
86 Self::LessImport => "Less import sequence participates in evaluation order",
87 Self::CssModuleValueImport => {
88 "CSS Modules value imports participate in dependency evaluation order"
89 }
90 Self::CssModuleComposesLocal => "local composition preserves selector dependency order",
91 Self::CssModuleComposesExternal => {
92 "external composition preserves module dependency order"
93 }
94 Self::IcssImport => "ICSS imports participate in dependency evaluation order",
95 }
96 }
97
98 const fn raw_edge_kind(self) -> OmenaCrossFileSummaryRawEdgeKindV0 {
99 match self {
100 Self::SassUse => OmenaCrossFileSummaryRawEdgeKindV0::SassUse,
101 Self::SassForward => OmenaCrossFileSummaryRawEdgeKindV0::SassForward,
102 Self::SassImport => OmenaCrossFileSummaryRawEdgeKindV0::SassImport,
103 Self::CssImport => OmenaCrossFileSummaryRawEdgeKindV0::CssModulesImport,
104 Self::LessImport => OmenaCrossFileSummaryRawEdgeKindV0::LessImport,
105 Self::CssModuleValueImport => OmenaCrossFileSummaryRawEdgeKindV0::CssModulesValueImport,
106 Self::CssModuleComposesLocal => OmenaCrossFileSummaryRawEdgeKindV0::ComposesLocal,
107 Self::CssModuleComposesExternal => OmenaCrossFileSummaryRawEdgeKindV0::ComposesExternal,
108 Self::IcssImport => OmenaCrossFileSummaryRawEdgeKindV0::CssModulesIcssImport,
109 }
110 }
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114#[serde(rename_all = "camelCase")]
115pub struct TransformBundleEdgeV0 {
116 pub kind: TransformBundleEdgeKind,
117 pub source_path: String,
118 pub import_source: Option<String>,
119 #[serde(skip_serializing_if = "Option::is_none")]
120 pub import_ordinal: Option<u32>,
121 pub namespace: Option<String>,
122 pub local_names: Vec<String>,
123 pub remote_names: Vec<String>,
124 pub range_start: u32,
125 pub range_end: u32,
126 pub provenance_required: bool,
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
130#[serde(rename_all = "camelCase")]
131pub enum TransformBundleAssetUrlKind {
132 Relative,
133 AbsolutePath,
134 External,
135 Data,
136 Fragment,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
140#[serde(rename_all = "camelCase")]
141pub struct TransformBundleAssetUrlV0 {
142 pub source_path: String,
143 pub raw_url: String,
144 pub normalized_url: String,
145 pub kind: TransformBundleAssetUrlKind,
146 pub resolved_path: Option<String>,
147 pub range_start: u32,
148 pub range_end: u32,
149 pub bundler_resolution_required: bool,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "camelCase")]
154pub struct TransformBundleAssetUrlRewriteSummaryV0 {
155 pub schema_version: &'static str,
156 pub product: &'static str,
157 pub source_path: String,
158 pub asset_url_count: usize,
159 pub rewrite_count: usize,
160 pub output_css: String,
161 pub rewritten_asset_urls: Vec<TransformBundleAssetUrlV0>,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
165#[serde(rename_all = "camelCase")]
166pub enum TransformBundleChunkKind {
167 Entry,
168 StyleImport,
169 Asset,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
173#[serde(rename_all = "camelCase")]
174pub struct TransformBundleChunkV0 {
175 pub chunk_id: String,
176 pub kind: TransformBundleChunkKind,
177 pub source_path: String,
178 pub import_source: Option<String>,
179 pub asset_url: Option<String>,
180 pub resolved_path: Option<String>,
181 pub depends_on: Vec<String>,
182 pub split_boundary: &'static str,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
186#[serde(rename_all = "camelCase")]
187pub struct TransformBundleSourceSummaryV0 {
188 pub schema_version: &'static str,
189 pub product: &'static str,
190 pub source_path: String,
191 pub dialect: &'static str,
192 pub bundle_edges: Vec<TransformBundleEdgeV0>,
193 pub asset_urls: Vec<TransformBundleAssetUrlV0>,
194 pub code_split_chunks: Vec<TransformBundleChunkV0>,
195 pub required_pass_ids: Vec<&'static str>,
196 pub planned_pass_ids: Vec<&'static str>,
197 pub import_inline_required: bool,
198 pub module_evaluation_required: bool,
199 pub css_modules_resolution_required: bool,
200 pub class_hashing_required: bool,
201 pub value_resolution_required: bool,
202 pub code_splitting_required: bool,
203 pub pass_plan: TransformPassPlanV0,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub struct TransformBundleModuleInputV0 {
208 pub source_path: String,
209 pub source: String,
210 pub dialect: StyleDialect,
211 pub configuration_hash: ConfigurationHashV0,
212}
213
214impl TransformBundleModuleInputV0 {
215 pub fn new(
216 source_path: impl Into<String>,
217 source: impl Into<String>,
218 dialect: StyleDialect,
219 ) -> Self {
220 Self {
221 source_path: source_path.into(),
222 source: source.into(),
223 dialect,
224 configuration_hash: ConfigurationHashV0::none(),
225 }
226 }
227
228 pub fn with_configuration_hash(mut self, configuration_hash: ConfigurationHashV0) -> Self {
229 self.configuration_hash = configuration_hash;
230 self
231 }
232
233 pub fn module_instance_key(&self) -> ModuleInstanceKeyV0 {
234 ModuleInstanceKeyV0::new(
235 ModuleIdV0::new(normalize_bundle_path(PathBuf::from(&self.source_path))),
236 self.configuration_hash.clone(),
237 )
238 }
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq)]
242pub struct TransformBundleSemanticReachabilityInputV0 {
243 pub source_path: String,
244 pub class_names: Vec<String>,
245 pub keyframe_names: Vec<String>,
246 pub value_names: Vec<String>,
247 pub custom_property_names: Vec<String>,
248}
249
250impl TransformBundleSemanticReachabilityInputV0 {
251 pub fn new(source_path: impl Into<String>) -> Self {
252 Self {
253 source_path: source_path.into(),
254 ..Self::default()
255 }
256 }
257
258 pub fn has_reachable_symbols(&self) -> bool {
259 !self.class_names.is_empty()
260 || !self.keyframe_names.is_empty()
261 || !self.value_names.is_empty()
262 || !self.custom_property_names.is_empty()
263 }
264}
265
266#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
267#[serde(rename_all = "camelCase")]
268pub struct LinkerDependencyEdgeV0 {
269 pub kind: TransformBundleEdgeKind,
270 pub import_source: String,
271 pub import_ordinal: Option<u32>,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
275#[serde(rename_all = "camelCase")]
276pub struct LinkerRuleV0 {
277 pub selector_name: String,
278 #[serde(serialize_with = "serialize_selector_fact_kind")]
279 pub selector_kind: ParsedSelectorFactKind,
280 pub range_start: u32,
281 pub range_end: u32,
282}
283
284#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
285#[serde(rename_all = "camelCase")]
286pub struct LinkerInputV0 {
287 pub source_path: String,
288 pub instance: ModuleInstanceKeyV0,
289 pub dependency_edges: Vec<LinkerDependencyEdgeV0>,
290 pub class_names: Vec<String>,
291 pub keyframe_names: Vec<String>,
292 pub value_names: Vec<String>,
293 pub custom_property_names: Vec<String>,
294 pub ordered_rules: Vec<LinkerRuleV0>,
295}
296
297#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
298#[serde(rename_all = "camelCase")]
299pub struct TransformBundleLinkOptionsV0 {
300 pub emission_ordering_policy: EmissionOrderingPolicyV0,
301}
302
303#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
304#[serde(rename_all = "camelCase")]
305pub struct LinkedStylesheetRuleV0 {
306 pub global_order_index: u32,
307 pub module_instance: ModuleInstanceKeyV0,
308 pub selector_name: String,
309 pub selector_kind: &'static str,
310 pub range_start: u32,
311 pub range_end: u32,
312}
313
314impl LinkedStylesheetRuleV0 {
315 pub fn cascade_key_with_global_source_order(
316 &self,
317 level: CascadeLevel,
318 layer_rank: LayerRank,
319 scope_proximity: u32,
320 specificity: Specificity,
321 module_rank: ModuleRank,
322 ) -> CascadeKey {
323 CascadeKey::new(
324 level,
325 layer_rank,
326 scope_proximity,
327 specificity,
328 module_rank,
329 self.global_order_index,
330 )
331 }
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
335#[serde(rename_all = "camelCase")]
336pub struct GlobalRuleOrderV0 {
337 pub rules: Vec<LinkedStylesheetRuleV0>,
338}
339
340#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
341#[serde(rename_all = "camelCase")]
342pub struct LinkedStylesheetV0 {
343 pub schema_version: &'static str,
344 pub product: &'static str,
345 pub entrypoints: Vec<ModuleInstanceKeyV0>,
346 pub module_instances: Vec<ModuleInstanceKeyV0>,
347 #[serde(skip_serializing)]
348 pub emission_plan: EmissionPlanV0,
349 pub global_rule_order: GlobalRuleOrderV0,
350 pub closed_world_bundle: ClosedWorldBundleV0,
351}
352
353#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
354#[serde(rename_all = "camelCase")]
355pub struct TransformBundleTransformedModuleV0 {
356 pub module_instance: ModuleInstanceKeyV0,
357 pub output_css: String,
358 pub non_empty_import_replacement_count: usize,
359}
360
361impl TransformBundleTransformedModuleV0 {
362 pub fn new(module_instance: ModuleInstanceKeyV0, output_css: impl Into<String>) -> Self {
363 Self {
364 module_instance,
365 output_css: output_css.into(),
366 non_empty_import_replacement_count: 0,
367 }
368 }
369
370 pub const fn with_non_empty_import_replacement_count(mut self, count: usize) -> Self {
371 self.non_empty_import_replacement_count = count;
372 self
373 }
374}
375
376#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
377#[serde(rename_all = "camelCase")]
378pub struct LinkedEmissionModuleRegionV0 {
379 pub module_instance: ModuleInstanceKeyV0,
380 pub first_global_order_index: Option<u32>,
381 pub generated_start: usize,
382 pub generated_end: usize,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
386#[serde(rename_all = "camelCase")]
387pub struct LinkedEmissionOrderEntryRegionV0 {
388 pub global_order_index: u32,
389 pub module_instance: ModuleInstanceKeyV0,
390 pub generated_start: usize,
391 pub generated_end: usize,
392}
393
394#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
395#[serde(rename_all = "camelCase")]
396pub struct LinkedEmissionArtifactV0 {
397 pub schema_version: &'static str,
398 pub product: &'static str,
399 pub output_css: String,
400 pub module_regions: Vec<LinkedEmissionModuleRegionV0>,
401 pub order_entry_regions: Vec<LinkedEmissionOrderEntryRegionV0>,
402 pub emitted_module_count: usize,
403 pub global_order_entry_count: usize,
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
407#[serde(rename_all = "camelCase")]
408pub enum LinkedEmissionMaterializationErrorV0 {
409 DuplicateTransformedModule {
410 module_instance: ModuleInstanceKeyV0,
411 },
412 MissingTransformedModule {
413 module_instance: ModuleInstanceKeyV0,
414 },
415 UnexpectedTransformedModule {
416 module_instance: ModuleInstanceKeyV0,
417 },
418 ImportReplacementWouldDuplicateModule {
419 module_instance: ModuleInstanceKeyV0,
420 replacement_count: usize,
421 },
422 InvalidGlobalOrderIndex {
423 expected: u32,
424 actual: u32,
425 },
426}
427
428#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
429#[serde(rename_all = "camelCase")]
430pub struct EmissionPolicyDifferenceV0 {
431 pub output_index: u32,
432 pub module_id_legacy_module: Option<ModuleInstanceKeyV0>,
433 pub module_id_legacy_selector: Option<String>,
434 pub import_order_module: Option<ModuleInstanceKeyV0>,
435 pub import_order_selector: Option<String>,
436}
437
438#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
439#[serde(rename_all = "camelCase")]
440pub struct EmissionPolicyDifferentialReportV0 {
441 pub schema_version: &'static str,
442 pub product: &'static str,
443 pub module_id_legacy_rule_count: usize,
444 pub import_order_rule_count: usize,
445 pub difference_count: usize,
446 pub equivalent: bool,
447 pub differences: Vec<EmissionPolicyDifferenceV0>,
448}
449
450#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
451#[serde(rename_all = "camelCase")]
452pub enum TransformBundleLinkErrorV0 {
453 MissingEntrypoint {
454 source_path: String,
455 },
456 AmbiguousModulePath {
457 source_path: String,
458 },
459 MissingDependency {
460 source_path: String,
461 import_source: String,
462 },
463 ClosedWorldBundle {
464 error: ClosedWorldBundleBuildErrorV0,
465 },
466 InvalidEmissionPlan {
467 reason: String,
468 },
469 UnsupportedEmissionCycle {
470 edge_kind: TransformBundleEdgeKind,
471 },
472}
473
474pub fn summarize_omena_transform_bundle_from_source(
475 source_path: impl Into<String>,
476 source: &str,
477 dialect: StyleDialect,
478) -> TransformBundleSourceSummaryV0 {
479 let source_path = source_path.into();
480 let facts = collect_style_facts(source, dialect);
481 let bundle_edges = collect_bundle_edges_from_facts(&source_path, dialect, &facts);
482 let asset_urls = collect_transform_ir_bundle_asset_urls(&source_path, source, dialect);
483 let code_split_chunks = plan_bundle_code_split_chunks(&source_path, &bundle_edges, &asset_urls);
484 let mut required_passes =
485 required_passes_for_source(&source_path, dialect, &facts, &bundle_edges);
486 required_passes.sort_by_key(|pass| transform_pass_sort_ordinal(*pass));
487 required_passes.dedup();
488 let pass_plan = plan_transform_passes(&required_passes);
489 let planned_pass_ids = pass_plan.ordered_pass_ids.clone();
490 let required_pass_ids = required_passes
491 .iter()
492 .map(|pass| pass.id())
493 .collect::<Vec<_>>();
494
495 TransformBundleSourceSummaryV0 {
496 schema_version: "0",
497 product: "omena-transform-bundle.source",
498 source_path,
499 dialect: dialect_label(dialect),
500 bundle_edges,
501 asset_urls,
502 code_splitting_required: code_split_chunks.len() > 1,
503 code_split_chunks,
504 required_pass_ids,
505 planned_pass_ids,
506 import_inline_required: required_passes.contains(&TransformPassKind::ImportInline),
507 module_evaluation_required: required_passes.iter().any(|pass| {
508 matches!(
509 pass,
510 TransformPassKind::ScssModuleEvaluate | TransformPassKind::LessModuleEvaluate
511 )
512 }),
513 css_modules_resolution_required: required_passes.iter().any(|pass| {
514 matches!(
515 pass,
516 TransformPassKind::HashCssModuleClassNames
517 | TransformPassKind::ResolveCssModulesComposes
518 )
519 }),
520 class_hashing_required: required_passes
521 .contains(&TransformPassKind::HashCssModuleClassNames),
522 value_resolution_required: required_passes.contains(&TransformPassKind::ValueResolution),
523 pass_plan,
524 }
525}
526
527pub fn link_omena_transform_bundle_modules<P: AsRef<str>>(
528 entrypoint_paths: &[P],
529 modules: &[TransformBundleModuleInputV0],
530) -> Result<LinkedStylesheetV0, TransformBundleLinkErrorV0> {
531 link_omena_transform_bundle_modules_with_semantic_reachability(entrypoint_paths, modules, &[])
532}
533
534pub fn link_omena_transform_bundle_modules_with_semantic_reachability<P: AsRef<str>>(
535 entrypoint_paths: &[P],
536 modules: &[TransformBundleModuleInputV0],
537 reachability_inputs: &[TransformBundleSemanticReachabilityInputV0],
538) -> Result<LinkedStylesheetV0, TransformBundleLinkErrorV0> {
539 link_omena_transform_bundle_modules_with_semantic_reachability_and_metadata(
540 entrypoint_paths,
541 modules,
542 reachability_inputs,
543 &[],
544 )
545}
546
547pub fn link_omena_transform_bundle_modules_with_semantic_reachability_and_metadata<
548 P: AsRef<str>,
549>(
550 entrypoint_paths: &[P],
551 modules: &[TransformBundleModuleInputV0],
552 reachability_inputs: &[TransformBundleSemanticReachabilityInputV0],
553 module_metadata: &[ClosedWorldModuleMetadataV0],
554) -> Result<LinkedStylesheetV0, TransformBundleLinkErrorV0> {
555 link_omena_transform_bundle_modules_with_options(
556 entrypoint_paths,
557 modules,
558 reachability_inputs,
559 module_metadata,
560 TransformBundleLinkOptionsV0::default(),
561 )
562}
563
564pub fn link_omena_transform_bundle_modules_with_options<P: AsRef<str>>(
565 entrypoint_paths: &[P],
566 modules: &[TransformBundleModuleInputV0],
567 reachability_inputs: &[TransformBundleSemanticReachabilityInputV0],
568 module_metadata: &[ClosedWorldModuleMetadataV0],
569 options: TransformBundleLinkOptionsV0,
570) -> Result<LinkedStylesheetV0, TransformBundleLinkErrorV0> {
571 let module_records = modules
572 .iter()
573 .map(TransformBundleModuleRecordV0::from_input)
574 .collect::<Vec<_>>();
575 let linker_inputs =
576 project_linker_inputs_from_module_records(module_records.as_slice(), reachability_inputs);
577 let entrypoint_paths = entrypoint_paths
578 .iter()
579 .map(|path| path.as_ref())
580 .collect::<Vec<_>>();
581
582 link_stylesheet_from_projection_with_metadata_and_options(
583 entrypoint_paths.as_slice(),
584 linker_inputs.as_slice(),
585 module_metadata,
586 options,
587 )
588}
589
590pub fn compare_omena_transform_bundle_emission_policies<P: AsRef<str>>(
591 entrypoint_paths: &[P],
592 modules: &[TransformBundleModuleInputV0],
593) -> Result<EmissionPolicyDifferentialReportV0, TransformBundleLinkErrorV0> {
594 let module_id_legacy = link_omena_transform_bundle_modules_with_options(
595 entrypoint_paths,
596 modules,
597 &[],
598 &[],
599 TransformBundleLinkOptionsV0 {
600 emission_ordering_policy: EmissionOrderingPolicyV0::ModuleIdLegacy,
601 },
602 )?;
603 let import_order = link_omena_transform_bundle_modules_with_options(
604 entrypoint_paths,
605 modules,
606 &[],
607 &[],
608 TransformBundleLinkOptionsV0 {
609 emission_ordering_policy: EmissionOrderingPolicyV0::ImportOrderPreserving,
610 },
611 )?;
612 let module_id_legacy_rules = &module_id_legacy.global_rule_order.rules;
613 let import_order_rules = &import_order.global_rule_order.rules;
614 let mut differences = Vec::new();
615 for output_index in 0..module_id_legacy_rules.len().max(import_order_rules.len()) {
616 let module_id_legacy_rule = module_id_legacy_rules.get(output_index);
617 let import_order_rule = import_order_rules.get(output_index);
618 if module_id_legacy_rule == import_order_rule {
619 continue;
620 }
621 differences.push(EmissionPolicyDifferenceV0 {
622 output_index: u32::try_from(output_index).map_err(|_| {
623 TransformBundleLinkErrorV0::InvalidEmissionPlan {
624 reason: "policy differential has more rows than the output index can represent"
625 .to_string(),
626 }
627 })?,
628 module_id_legacy_module: module_id_legacy_rule.map(|rule| rule.module_instance.clone()),
629 module_id_legacy_selector: module_id_legacy_rule.map(|rule| rule.selector_name.clone()),
630 import_order_module: import_order_rule.map(|rule| rule.module_instance.clone()),
631 import_order_selector: import_order_rule.map(|rule| rule.selector_name.clone()),
632 });
633 }
634 let difference_count = differences.len();
635 Ok(EmissionPolicyDifferentialReportV0 {
636 schema_version: "0",
637 product: "omena-bundler.emission-policy-differential",
638 module_id_legacy_rule_count: module_id_legacy_rules.len(),
639 import_order_rule_count: import_order_rules.len(),
640 difference_count,
641 equivalent: difference_count == 0,
642 differences,
643 })
644}
645
646pub fn materialize_omena_transform_bundle_linked_stylesheet(
647 linked: &LinkedStylesheetV0,
648 transformed_modules: &[TransformBundleTransformedModuleV0],
649) -> Result<LinkedEmissionArtifactV0, LinkedEmissionMaterializationErrorV0> {
650 let linked_modules = linked
651 .module_instances
652 .iter()
653 .cloned()
654 .collect::<BTreeSet<_>>();
655 let mut transformed_by_instance = BTreeMap::new();
656 for transformed in transformed_modules {
657 if !linked_modules.contains(&transformed.module_instance) {
658 return Err(
659 LinkedEmissionMaterializationErrorV0::UnexpectedTransformedModule {
660 module_instance: transformed.module_instance.clone(),
661 },
662 );
663 }
664 if transformed.non_empty_import_replacement_count > 0 {
665 return Err(
666 LinkedEmissionMaterializationErrorV0::ImportReplacementWouldDuplicateModule {
667 module_instance: transformed.module_instance.clone(),
668 replacement_count: transformed.non_empty_import_replacement_count,
669 },
670 );
671 }
672 if transformed_by_instance
673 .insert(transformed.module_instance.clone(), transformed)
674 .is_some()
675 {
676 return Err(
677 LinkedEmissionMaterializationErrorV0::DuplicateTransformedModule {
678 module_instance: transformed.module_instance.clone(),
679 },
680 );
681 }
682 }
683
684 for module_instance in &linked.module_instances {
685 if !transformed_by_instance.contains_key(module_instance) {
686 return Err(
687 LinkedEmissionMaterializationErrorV0::MissingTransformedModule {
688 module_instance: module_instance.clone(),
689 },
690 );
691 }
692 }
693
694 let mut first_order_index_by_instance = BTreeMap::new();
695 let mut module_order = Vec::new();
696 for (expected_index, rule) in linked.global_rule_order.rules.iter().enumerate() {
697 let expected_index = u32::try_from(expected_index).unwrap_or(u32::MAX);
698 if rule.global_order_index != expected_index {
699 return Err(
700 LinkedEmissionMaterializationErrorV0::InvalidGlobalOrderIndex {
701 expected: expected_index,
702 actual: rule.global_order_index,
703 },
704 );
705 }
706 if first_order_index_by_instance
707 .insert(rule.module_instance.clone(), rule.global_order_index)
708 .is_none()
709 {
710 module_order.push(rule.module_instance.clone());
711 }
712 }
713 for module_instance in &linked.module_instances {
714 if !first_order_index_by_instance.contains_key(module_instance) {
715 module_order.push(module_instance.clone());
716 }
717 }
718
719 let mut output_css = String::new();
720 let mut module_regions = Vec::with_capacity(module_order.len());
721 let mut generated_region_by_instance = BTreeMap::new();
722 for module_instance in module_order {
723 let Some(transformed) = transformed_by_instance.get(&module_instance) else {
724 return Err(
725 LinkedEmissionMaterializationErrorV0::MissingTransformedModule { module_instance },
726 );
727 };
728 if !output_css.is_empty()
729 && !output_css.ends_with('\n')
730 && !transformed.output_css.is_empty()
731 {
732 output_css.push('\n');
733 }
734 let generated_start = output_css.len();
735 output_css.push_str(&transformed.output_css);
736 let generated_end = output_css.len();
737 generated_region_by_instance
738 .insert(module_instance.clone(), (generated_start, generated_end));
739 module_regions.push(LinkedEmissionModuleRegionV0 {
740 first_global_order_index: first_order_index_by_instance.get(&module_instance).copied(),
741 module_instance,
742 generated_start,
743 generated_end,
744 });
745 }
746
747 let mut order_entry_regions = Vec::with_capacity(linked.global_rule_order.rules.len());
748 for rule in &linked.global_rule_order.rules {
749 let Some((generated_start, generated_end)) = generated_region_by_instance
750 .get(&rule.module_instance)
751 .copied()
752 else {
753 return Err(
754 LinkedEmissionMaterializationErrorV0::MissingTransformedModule {
755 module_instance: rule.module_instance.clone(),
756 },
757 );
758 };
759 order_entry_regions.push(LinkedEmissionOrderEntryRegionV0 {
760 global_order_index: rule.global_order_index,
761 module_instance: rule.module_instance.clone(),
762 generated_start,
763 generated_end,
764 });
765 }
766
767 Ok(LinkedEmissionArtifactV0 {
768 schema_version: "0",
769 product: "omena-transform-bundle.linked-emission",
770 emitted_module_count: module_regions.len(),
771 global_order_entry_count: order_entry_regions.len(),
772 output_css,
773 module_regions,
774 order_entry_regions,
775 })
776}
777
778pub fn link_stylesheet_from_projection(
779 entrypoint_paths: &[&str],
780 inputs: &[LinkerInputV0],
781) -> Result<LinkedStylesheetV0, TransformBundleLinkErrorV0> {
782 link_stylesheet_from_projection_with_options(
783 entrypoint_paths,
784 inputs,
785 TransformBundleLinkOptionsV0::default(),
786 )
787}
788
789pub fn link_stylesheet_from_projection_with_options(
790 entrypoint_paths: &[&str],
791 inputs: &[LinkerInputV0],
792 options: TransformBundleLinkOptionsV0,
793) -> Result<LinkedStylesheetV0, TransformBundleLinkErrorV0> {
794 link_stylesheet_from_projection_with_metadata_and_options(
795 entrypoint_paths,
796 inputs,
797 &[],
798 options,
799 )
800}
801
802fn link_stylesheet_from_projection_with_metadata_and_options(
803 entrypoint_paths: &[&str],
804 inputs: &[LinkerInputV0],
805 module_metadata: &[ClosedWorldModuleMetadataV0],
806 options: TransformBundleLinkOptionsV0,
807) -> Result<LinkedStylesheetV0, TransformBundleLinkErrorV0> {
808 let instances_by_path = module_instances_by_linker_path(inputs);
809 let entrypoints = entrypoint_paths
810 .iter()
811 .map(|path| {
812 resolve_module_instance_by_path(path, &instances_by_path).ok_or_else(|| {
813 TransformBundleLinkErrorV0::MissingEntrypoint {
814 source_path: normalize_bundle_path(PathBuf::from(*path)),
815 }
816 })
817 })
818 .collect::<Result<Vec<_>, _>>()?;
819 let linked_modules =
820 collect_closed_world_linked_modules_from_projection(inputs, &instances_by_path)?;
821 let closed_world_bundle = ClosedWorldBundleV0::try_from_linked_modules_with_metadata(
822 entrypoints.clone(),
823 linked_modules,
824 module_metadata.to_vec(),
825 )
826 .map_err(|error| TransformBundleLinkErrorV0::ClosedWorldBundle { error })?;
827 let emission_plan = emission_order::build_emission_plan(
828 inputs,
829 closed_world_bundle.linked_modules(),
830 &entrypoints,
831 options.emission_ordering_policy,
832 )?;
833 let global_rule_order =
834 emission_order::build_global_rule_order_from_plan(inputs, &emission_plan)?;
835
836 Ok(LinkedStylesheetV0 {
837 schema_version: "0",
838 product: "omena-transform-bundle.linked-stylesheet",
839 entrypoints,
840 module_instances: closed_world_bundle.linked_modules().to_vec(),
841 emission_plan,
842 global_rule_order,
843 closed_world_bundle,
844 })
845}
846
847pub fn rewrite_omena_transform_bundle_asset_urls_in_source(
848 source_path: impl Into<String>,
849 source: &str,
850) -> TransformBundleAssetUrlRewriteSummaryV0 {
851 let source_path = source_path.into();
852 let asset_urls = collect_transform_ir_bundle_asset_urls(
853 &source_path,
854 source,
855 dialect_for_bundle_source_path(&source_path),
856 );
857 let mut output_css = source.to_string();
858 let mut rewritten_asset_urls = Vec::new();
859
860 for asset in asset_urls.iter().rev() {
861 let Some(resolved_path) = asset.resolved_path.as_deref() else {
862 continue;
863 };
864 if !asset.bundler_resolution_required || asset.normalized_url == resolved_path {
865 continue;
866 }
867 let range_start = asset.range_start as usize;
868 let range_end = asset.range_end as usize;
869 if range_start > range_end || range_end > output_css.len() {
870 continue;
871 }
872 output_css.replace_range(range_start..range_end, &format!("url(\"{resolved_path}\")"));
873 rewritten_asset_urls.push(asset.clone());
874 }
875
876 rewritten_asset_urls.reverse();
877 TransformBundleAssetUrlRewriteSummaryV0 {
878 schema_version: "0",
879 product: "omena-transform-bundle.asset-url-rewrite",
880 source_path,
881 asset_url_count: asset_urls.len(),
882 rewrite_count: rewritten_asset_urls.len(),
883 output_css,
884 rewritten_asset_urls,
885 }
886}
887
888struct TransformBundleModuleRecordV0 {
889 source_path: String,
890 instance: ModuleInstanceKeyV0,
891 facts: omena_parser::ParsedStyleFacts,
892 bundle_edges: Vec<TransformBundleEdgeV0>,
893}
894
895impl TransformBundleModuleRecordV0 {
896 fn from_input(input: &TransformBundleModuleInputV0) -> Self {
897 let source_path = normalize_bundle_path(PathBuf::from(input.source_path.as_str()));
898 let facts = collect_style_facts(input.source.as_str(), input.dialect);
899 let bundle_edges = collect_bundle_edges_from_facts(&source_path, input.dialect, &facts);
900 let instance = ModuleInstanceKeyV0::new(
901 ModuleIdV0::new(source_path.clone()),
902 input.configuration_hash.clone(),
903 );
904 Self {
905 source_path,
906 instance,
907 facts,
908 bundle_edges,
909 }
910 }
911}
912
913fn project_linker_inputs_from_module_records(
914 records: &[TransformBundleModuleRecordV0],
915 reachability_inputs: &[TransformBundleSemanticReachabilityInputV0],
916) -> Vec<LinkerInputV0> {
917 let mut inputs = records
918 .iter()
919 .map(linker_input_from_module_record)
920 .collect::<Vec<_>>();
921 apply_semantic_reachability_to_linker_inputs(inputs.as_mut_slice(), reachability_inputs);
922 inputs
923}
924
925fn linker_input_from_module_record(record: &TransformBundleModuleRecordV0) -> LinkerInputV0 {
926 LinkerInputV0 {
927 source_path: record.source_path.clone(),
928 instance: record.instance.clone(),
929 dependency_edges: record
930 .bundle_edges
931 .iter()
932 .filter(|edge| bundle_edge_is_module_dependency(edge.kind))
933 .filter_map(|edge| {
934 edge.import_source
935 .as_ref()
936 .map(|import_source| LinkerDependencyEdgeV0 {
937 kind: edge.kind,
938 import_source: import_source.clone(),
939 import_ordinal: edge.import_ordinal,
940 })
941 })
942 .collect(),
943 class_names: dedupe_names(
944 record
945 .facts
946 .selectors
947 .iter()
948 .filter(|selector| selector.kind == ParsedSelectorFactKind::Class)
949 .map(|selector| selector.name.clone()),
950 ),
951 keyframe_names: dedupe_names(
952 record
953 .facts
954 .animations
955 .iter()
956 .filter(|animation| animation.kind == ParsedAnimationFactKind::KeyframesDeclaration)
957 .map(|animation| animation.name.clone()),
958 ),
959 value_names: dedupe_names(
960 record
961 .facts
962 .css_module_values
963 .iter()
964 .filter(|value| value.kind == ParsedCssModuleValueFactKind::Definition)
965 .map(|value| value.name.clone()),
966 ),
967 custom_property_names: dedupe_names(
968 record
969 .facts
970 .variables
971 .iter()
972 .filter(|variable| {
973 variable.kind == ParsedVariableFactKind::CustomPropertyDeclaration
974 })
975 .map(|variable| variable.name.clone()),
976 ),
977 ordered_rules: collect_ordered_linker_rules(record),
978 }
979}
980
981fn collect_ordered_linker_rules(record: &TransformBundleModuleRecordV0) -> Vec<LinkerRuleV0> {
982 let mut selectors = record.facts.selectors.clone();
983 selectors.sort_by_key(|selector| {
984 (
985 u32::from(selector.range.start()),
986 u32::from(selector.range.end()),
987 selector.kind,
988 selector.name.clone(),
989 )
990 });
991 selectors
992 .into_iter()
993 .map(|selector| LinkerRuleV0 {
994 selector_name: selector.name,
995 selector_kind: selector.kind,
996 range_start: u32::from(selector.range.start()),
997 range_end: u32::from(selector.range.end()),
998 })
999 .collect()
1000}
1001
1002fn apply_semantic_reachability_to_linker_inputs(
1003 inputs: &mut [LinkerInputV0],
1004 reachability_inputs: &[TransformBundleSemanticReachabilityInputV0],
1005) {
1006 if reachability_inputs.is_empty() {
1007 return;
1008 }
1009
1010 let instances_by_path = module_instances_by_linker_path(inputs);
1011 let module_index_by_instance = inputs
1012 .iter()
1013 .enumerate()
1014 .map(|(index, input)| (input.instance.clone(), index))
1015 .collect::<BTreeMap<_, _>>();
1016
1017 for input in reachability_inputs {
1018 if !input.has_reachable_symbols() {
1019 continue;
1020 }
1021 let Some(instance) =
1022 resolve_module_instance_by_path(&input.source_path, &instances_by_path)
1023 else {
1024 continue;
1025 };
1026 let Some(index) = module_index_by_instance.get(&instance).copied() else {
1027 continue;
1028 };
1029 inputs[index].class_names = dedupe_names(input.class_names.iter().cloned());
1030 inputs[index].keyframe_names = dedupe_names(input.keyframe_names.iter().cloned());
1031 inputs[index].value_names = dedupe_names(input.value_names.iter().cloned());
1032 inputs[index].custom_property_names =
1033 dedupe_names(input.custom_property_names.iter().cloned());
1034 }
1035}
1036
1037pub(crate) fn module_instances_by_linker_path(
1038 inputs: &[LinkerInputV0],
1039) -> BTreeMap<String, Vec<ModuleInstanceKeyV0>> {
1040 let mut by_path = BTreeMap::<String, Vec<ModuleInstanceKeyV0>>::new();
1041 for input in inputs {
1042 by_path
1043 .entry(input.source_path.clone())
1044 .or_default()
1045 .push(input.instance.clone());
1046 }
1047 for instances in by_path.values_mut() {
1048 instances.sort();
1049 instances.dedup();
1050 }
1051 by_path
1052}
1053
1054fn resolve_module_instance_by_path(
1055 source_path: &str,
1056 instances_by_path: &BTreeMap<String, Vec<ModuleInstanceKeyV0>>,
1057) -> Option<ModuleInstanceKeyV0> {
1058 let normalized = normalize_bundle_path(PathBuf::from(source_path));
1059 let instances = instances_by_path.get(&normalized)?;
1060 if instances.len() == 1 {
1061 instances.first().cloned()
1062 } else {
1063 None
1064 }
1065}
1066
1067fn collect_closed_world_linked_modules_from_projection(
1068 inputs: &[LinkerInputV0],
1069 instances_by_path: &BTreeMap<String, Vec<ModuleInstanceKeyV0>>,
1070) -> Result<Vec<ClosedWorldLinkedModuleV0>, TransformBundleLinkErrorV0> {
1071 inputs
1072 .iter()
1073 .map(|input| {
1074 let mut linked = ClosedWorldLinkedModuleV0::new(input.instance.clone());
1075 for edge in &input.dependency_edges {
1076 let dependency = resolve_imported_module_instance(
1077 input.source_path.as_str(),
1078 edge.import_source.as_str(),
1079 instances_by_path,
1080 )?
1081 .ok_or_else(|| TransformBundleLinkErrorV0::MissingDependency {
1082 source_path: input.source_path.clone(),
1083 import_source: edge.import_source.clone(),
1084 })?;
1085 linked = linked.with_dependency(dependency);
1086 }
1087 for name in dedupe_names(input.class_names.iter().cloned()) {
1088 linked = linked.with_class_name(name);
1089 }
1090 for name in dedupe_names(input.keyframe_names.iter().cloned()) {
1091 linked = linked.with_keyframe_name(name);
1092 }
1093 for name in dedupe_names(input.value_names.iter().cloned()) {
1094 linked = linked.with_value_name(name);
1095 }
1096 for name in dedupe_names(input.custom_property_names.iter().cloned()) {
1097 linked = linked.with_custom_property_name(name);
1098 }
1099 linked.dependencies.sort();
1100 linked.dependencies.dedup();
1101 Ok(linked)
1102 })
1103 .collect()
1104}
1105
1106fn bundle_edge_is_module_dependency(kind: TransformBundleEdgeKind) -> bool {
1107 matches!(
1108 kind,
1109 TransformBundleEdgeKind::SassUse
1110 | TransformBundleEdgeKind::SassForward
1111 | TransformBundleEdgeKind::SassImport
1112 | TransformBundleEdgeKind::CssImport
1113 | TransformBundleEdgeKind::LessImport
1114 | TransformBundleEdgeKind::CssModuleValueImport
1115 | TransformBundleEdgeKind::CssModuleComposesExternal
1116 | TransformBundleEdgeKind::IcssImport
1117 )
1118}
1119
1120pub(crate) fn resolve_imported_module_instance(
1121 source_path: &str,
1122 import_source: &str,
1123 instances_by_path: &BTreeMap<String, Vec<ModuleInstanceKeyV0>>,
1124) -> Result<Option<ModuleInstanceKeyV0>, TransformBundleLinkErrorV0> {
1125 for candidate in import_path_candidates(source_path, import_source) {
1126 if let Some(instances) = instances_by_path.get(candidate.as_str()) {
1127 return match instances.as_slice() {
1128 [instance] => Ok(Some(instance.clone())),
1129 _ => Err(TransformBundleLinkErrorV0::AmbiguousModulePath {
1130 source_path: candidate,
1131 }),
1132 };
1133 }
1134 }
1135 Ok(None)
1136}
1137
1138fn import_path_candidates(source_path: &str, import_source: &str) -> Vec<String> {
1139 let base = if import_source.starts_with('/') {
1140 PathBuf::from(import_source)
1141 } else {
1142 Path::new(source_path)
1143 .parent()
1144 .unwrap_or_else(|| Path::new(""))
1145 .join(import_source)
1146 };
1147 let normalized = normalize_bundle_path(base);
1148 let mut candidates = vec![normalized.clone()];
1149 if Path::new(&normalized).extension().is_none() {
1150 for extension in ["css", "scss", "sass", "less"] {
1151 candidates.push(format!("{normalized}.{extension}"));
1152 }
1153 let path = Path::new(&normalized);
1154 if let Some(file_name) = path.file_name().and_then(|name| name.to_str()) {
1155 let mut partial = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
1156 partial.push(format!("_{file_name}"));
1157 let partial = normalize_bundle_path(partial);
1158 for extension in ["scss", "sass"] {
1159 candidates.push(format!("{partial}.{extension}"));
1160 }
1161 }
1162 }
1163 candidates.sort();
1164 candidates.dedup();
1165 candidates
1166}
1167
1168pub(crate) fn selector_kind_label(kind: ParsedSelectorFactKind) -> &'static str {
1169 match kind {
1170 ParsedSelectorFactKind::Class => "class",
1171 ParsedSelectorFactKind::Id => "id",
1172 ParsedSelectorFactKind::Placeholder => "placeholder",
1173 }
1174}
1175
1176fn serialize_selector_fact_kind<S>(
1177 kind: &ParsedSelectorFactKind,
1178 serializer: S,
1179) -> Result<S::Ok, S::Error>
1180where
1181 S: serde::Serializer,
1182{
1183 serializer.serialize_str(selector_kind_label(*kind))
1184}
1185
1186fn dedupe_names(names: impl IntoIterator<Item = String>) -> Vec<String> {
1187 names
1188 .into_iter()
1189 .collect::<BTreeSet<_>>()
1190 .into_iter()
1191 .collect()
1192}
1193
1194fn collect_bundle_edges_from_facts(
1195 source_path: &str,
1196 dialect: StyleDialect,
1197 facts: &omena_parser::ParsedStyleFacts,
1198) -> Vec<TransformBundleEdgeV0> {
1199 let mut edges = Vec::new();
1200
1201 for edge in &facts.sass_module_edges {
1202 let kind = match edge.kind {
1203 ParsedSassModuleEdgeFactKind::Use => TransformBundleEdgeKind::SassUse,
1204 ParsedSassModuleEdgeFactKind::Forward => TransformBundleEdgeKind::SassForward,
1205 ParsedSassModuleEdgeFactKind::Import => import_edge_kind_for_dialect(dialect),
1206 };
1207 edges.push(TransformBundleEdgeV0 {
1208 kind,
1209 source_path: source_path.to_string(),
1210 import_source: Some(edge.source.clone()),
1211 import_ordinal: None,
1212 namespace: edge.namespace.clone(),
1213 local_names: Vec::new(),
1214 remote_names: Vec::new(),
1215 range_start: u32::from(edge.range.start()),
1216 range_end: u32::from(edge.range.end()),
1217 provenance_required: true,
1218 });
1219 }
1220
1221 for edge in &facts.css_module_value_import_edges {
1222 edges.push(TransformBundleEdgeV0 {
1223 kind: TransformBundleEdgeKind::CssModuleValueImport,
1224 source_path: source_path.to_string(),
1225 import_source: Some(edge.import_source.clone()),
1226 import_ordinal: None,
1227 namespace: None,
1228 local_names: vec![edge.local_name.clone()],
1229 remote_names: vec![edge.remote_name.clone()],
1230 range_start: u32::from(edge.range.start()),
1231 range_end: u32::from(edge.range.end()),
1232 provenance_required: true,
1233 });
1234 }
1235
1236 for edge in &facts.css_module_composes_edges {
1237 let kind = match edge.kind {
1238 ParsedCssModuleComposesEdgeKind::External => {
1239 TransformBundleEdgeKind::CssModuleComposesExternal
1240 }
1241 ParsedCssModuleComposesEdgeKind::Local | ParsedCssModuleComposesEdgeKind::Global => {
1242 TransformBundleEdgeKind::CssModuleComposesLocal
1243 }
1244 };
1245 edges.push(TransformBundleEdgeV0 {
1246 kind,
1247 source_path: source_path.to_string(),
1248 import_source: edge.import_source.clone(),
1249 import_ordinal: None,
1250 namespace: None,
1251 local_names: edge.owner_selector_names.clone(),
1252 remote_names: edge.target_names.clone(),
1253 range_start: u32::from(edge.range.start()),
1254 range_end: u32::from(edge.range.end()),
1255 provenance_required: true,
1256 });
1257 }
1258
1259 for edge in &facts.icss_import_edges {
1260 edges.push(TransformBundleEdgeV0 {
1261 kind: TransformBundleEdgeKind::IcssImport,
1262 source_path: source_path.to_string(),
1263 import_source: Some(edge.import_source.clone()),
1264 import_ordinal: None,
1265 namespace: None,
1266 local_names: vec![edge.local_name.clone()],
1267 remote_names: vec![edge.remote_name.clone()],
1268 range_start: u32::from(edge.range.start()),
1269 range_end: u32::from(edge.range.end()),
1270 provenance_required: true,
1271 });
1272 }
1273
1274 assign_parser_origin_import_ordinals(&mut edges);
1275 edges
1276}
1277
1278fn assign_parser_origin_import_ordinals(edges: &mut [TransformBundleEdgeV0]) {
1279 let mut order_bearing_indices = edges
1280 .iter()
1281 .enumerate()
1282 .filter(|(_, edge)| {
1283 edge.import_source.is_some()
1284 && edge.kind.order_relevance() == EdgeOrderRelevanceV0::OrderBearing
1285 })
1286 .map(|(index, edge)| (index, edge.range_start, edge.range_end))
1287 .collect::<Vec<_>>();
1288 order_bearing_indices
1289 .sort_by_key(|(index, range_start, range_end)| (*range_start, *range_end, *index));
1290 for (ordinal, (index, _, _)) in order_bearing_indices.into_iter().enumerate() {
1291 edges[index].import_ordinal = u32::try_from(ordinal).ok();
1292 }
1293}
1294
1295fn import_edge_kind_for_dialect(dialect: StyleDialect) -> TransformBundleEdgeKind {
1296 match dialect {
1297 StyleDialect::Css => TransformBundleEdgeKind::CssImport,
1298 StyleDialect::Less => TransformBundleEdgeKind::LessImport,
1299 StyleDialect::Scss | StyleDialect::Sass => TransformBundleEdgeKind::SassImport,
1300 }
1301}
1302
1303fn collect_transform_ir_bundle_asset_urls(
1304 source_path: &str,
1305 source: &str,
1306 dialect: StyleDialect,
1307) -> Vec<TransformBundleAssetUrlV0> {
1308 let ir = lower_transform_ir_from_source(source, dialect, source_path);
1309 ir.nodes
1310 .iter()
1311 .filter(|node| !node.deleted && node.kind == IrNodeKindV0::UrlValue)
1312 .filter_map(|url_value| {
1313 let start = url_value.source_span_start;
1314 let end = url_value.source_span_end;
1315 if start >= end
1316 || end > source.len()
1317 || !source.is_char_boundary(start)
1318 || !source.is_char_boundary(end)
1319 {
1320 return None;
1321 }
1322 let (raw_url, normalized_url, parsed_end) = parse_bundle_url_function(source, start)?;
1323 if parsed_end != end {
1324 return None;
1325 }
1326 let (kind, resolved_path) = classify_bundle_asset_url(source_path, &normalized_url);
1327 Some(TransformBundleAssetUrlV0 {
1328 source_path: source_path.to_string(),
1329 raw_url,
1330 normalized_url,
1331 kind,
1332 resolved_path,
1333 range_start: start as u32,
1334 range_end: parsed_end as u32,
1335 bundler_resolution_required: matches!(
1336 kind,
1337 TransformBundleAssetUrlKind::Relative
1338 | TransformBundleAssetUrlKind::AbsolutePath
1339 ),
1340 })
1341 })
1342 .collect()
1343}
1344
1345#[cfg(test)]
1346fn raw_scan_bundle_asset_urls_for_oracle(
1347 source_path: &str,
1348 source: &str,
1349) -> Vec<TransformBundleAssetUrlV0> {
1350 let bytes = source.as_bytes();
1351 let mut urls = Vec::new();
1352 let mut index = 0usize;
1353
1354 while index + 4 <= bytes.len() {
1355 if !bytes[index].eq_ignore_ascii_case(&b'u')
1356 || !bytes[index + 1].eq_ignore_ascii_case(&b'r')
1357 || !bytes[index + 2].eq_ignore_ascii_case(&b'l')
1358 || bytes[index + 3] != b'('
1359 {
1360 index += 1;
1361 continue;
1362 }
1363 let Some((raw_url, normalized_url, end)) = parse_bundle_url_function(source, index) else {
1364 index += 4;
1365 continue;
1366 };
1367 let (kind, resolved_path) = classify_bundle_asset_url(source_path, &normalized_url);
1368 urls.push(TransformBundleAssetUrlV0 {
1369 source_path: source_path.to_string(),
1370 raw_url,
1371 normalized_url,
1372 kind,
1373 resolved_path,
1374 range_start: index as u32,
1375 range_end: end as u32,
1376 bundler_resolution_required: matches!(
1377 kind,
1378 TransformBundleAssetUrlKind::Relative | TransformBundleAssetUrlKind::AbsolutePath
1379 ),
1380 });
1381 index = end;
1382 }
1383
1384 urls
1385}
1386
1387fn dialect_for_bundle_source_path(source_path: &str) -> StyleDialect {
1388 let extension = Path::new(source_path)
1389 .extension()
1390 .and_then(|extension| extension.to_str())
1391 .unwrap_or_default()
1392 .to_ascii_lowercase();
1393 match extension.as_str() {
1394 "scss" => StyleDialect::Scss,
1395 "sass" => StyleDialect::Sass,
1396 "less" => StyleDialect::Less,
1397 _ => StyleDialect::Css,
1398 }
1399}
1400
1401fn parse_bundle_url_function(source: &str, start: usize) -> Option<(String, String, usize)> {
1402 let open_end = start.checked_add(4)?;
1403 let mut index = open_end;
1404 let mut quote = None;
1405 let mut escaped = false;
1406
1407 while index < source.len() {
1408 let ch = source[index..].chars().next()?;
1409 let next = index + ch.len_utf8();
1410 if let Some(active_quote) = quote {
1411 if escaped {
1412 escaped = false;
1413 } else if ch == '\\' {
1414 escaped = true;
1415 } else if ch == active_quote {
1416 quote = None;
1417 }
1418 index = next;
1419 continue;
1420 }
1421
1422 match ch {
1423 '"' | '\'' => quote = Some(ch),
1424 ')' => {
1425 let raw_url = source[start..next].to_string();
1426 let inner = source[open_end..index].trim();
1427 let normalized_url = unquote_bundle_url_inner(inner)?;
1428 return Some((raw_url, normalized_url, next));
1429 }
1430 _ => {}
1431 }
1432 index = next;
1433 }
1434
1435 None
1436}
1437
1438fn unquote_bundle_url_inner(inner: &str) -> Option<String> {
1439 if inner.is_empty() {
1440 return None;
1441 }
1442 let bytes = inner.as_bytes();
1443 if bytes.len() >= 2
1444 && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1445 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
1446 {
1447 return Some(inner[1..inner.len() - 1].to_string());
1448 }
1449 Some(inner.to_string())
1450}
1451
1452fn classify_bundle_asset_url(
1453 source_path: &str,
1454 normalized_url: &str,
1455) -> (TransformBundleAssetUrlKind, Option<String>) {
1456 let lower = normalized_url.to_ascii_lowercase();
1457 if lower.starts_with("data:") {
1458 return (TransformBundleAssetUrlKind::Data, None);
1459 }
1460 if normalized_url.starts_with('#') {
1461 return (TransformBundleAssetUrlKind::Fragment, None);
1462 }
1463 if lower.starts_with("http://")
1464 || lower.starts_with("https://")
1465 || normalized_url.starts_with("//")
1466 {
1467 return (TransformBundleAssetUrlKind::External, None);
1468 }
1469 if normalized_url.starts_with('/') {
1470 return (
1471 TransformBundleAssetUrlKind::AbsolutePath,
1472 Some(normalized_url.to_string()),
1473 );
1474 }
1475
1476 (
1477 TransformBundleAssetUrlKind::Relative,
1478 Some(resolve_relative_bundle_asset_path(
1479 source_path,
1480 normalized_url,
1481 )),
1482 )
1483}
1484
1485fn resolve_relative_bundle_asset_path(source_path: &str, normalized_url: &str) -> String {
1486 let base = Path::new(source_path)
1487 .parent()
1488 .unwrap_or_else(|| Path::new(""));
1489 normalize_bundle_path(base.join(normalized_url))
1490}
1491
1492fn normalize_bundle_path(path: PathBuf) -> String {
1493 let mut normalized = PathBuf::new();
1494 for component in path.components() {
1495 match component {
1496 Component::CurDir => {}
1497 Component::ParentDir => match normalized.components().next_back() {
1498 Some(Component::Normal(_)) => {
1499 normalized.pop();
1500 }
1501 Some(Component::RootDir) => {}
1502 _ => normalized.push(".."),
1503 },
1504 _ => normalized.push(component.as_os_str()),
1505 }
1506 }
1507 normalized.to_string_lossy().into_owned()
1508}
1509
1510fn plan_bundle_code_split_chunks(
1511 source_path: &str,
1512 bundle_edges: &[TransformBundleEdgeV0],
1513 asset_urls: &[TransformBundleAssetUrlV0],
1514) -> Vec<TransformBundleChunkV0> {
1515 let mut chunks: Vec<TransformBundleChunkV0> = Vec::new();
1516 let mut entry_dependencies = Vec::new();
1517
1518 for edge in bundle_edges {
1519 let Some(import_source) = edge.import_source.as_ref() else {
1520 continue;
1521 };
1522 let chunk_id = bundle_chunk_id("style", source_path, import_source);
1523 if !entry_dependencies.contains(&chunk_id) {
1524 entry_dependencies.push(chunk_id.clone());
1525 }
1526 if chunks.iter().any(|chunk| chunk.chunk_id == chunk_id) {
1527 continue;
1528 }
1529 chunks.push(TransformBundleChunkV0 {
1530 chunk_id,
1531 kind: TransformBundleChunkKind::StyleImport,
1532 source_path: source_path.to_string(),
1533 import_source: Some(import_source.clone()),
1534 asset_url: None,
1535 resolved_path: None,
1536 depends_on: Vec::new(),
1537 split_boundary: "styleDependency",
1538 });
1539 }
1540
1541 for asset in asset_urls {
1542 if !asset.bundler_resolution_required {
1543 continue;
1544 }
1545 let chunk_id = bundle_chunk_id("asset", source_path, asset.normalized_url.as_str());
1546 if !entry_dependencies.contains(&chunk_id) {
1547 entry_dependencies.push(chunk_id.clone());
1548 }
1549 if chunks.iter().any(|chunk| chunk.chunk_id == chunk_id) {
1550 continue;
1551 }
1552 chunks.push(TransformBundleChunkV0 {
1553 chunk_id,
1554 kind: TransformBundleChunkKind::Asset,
1555 source_path: source_path.to_string(),
1556 import_source: None,
1557 asset_url: Some(asset.normalized_url.clone()),
1558 resolved_path: asset.resolved_path.clone(),
1559 depends_on: Vec::new(),
1560 split_boundary: "assetDependency",
1561 });
1562 }
1563
1564 entry_dependencies.sort();
1565 chunks.sort_by(|left, right| left.chunk_id.cmp(&right.chunk_id));
1566 let mut ordered = vec![TransformBundleChunkV0 {
1567 chunk_id: bundle_chunk_id("entry", source_path, source_path),
1568 kind: TransformBundleChunkKind::Entry,
1569 source_path: source_path.to_string(),
1570 import_source: None,
1571 asset_url: None,
1572 resolved_path: Some(source_path.to_string()),
1573 depends_on: entry_dependencies,
1574 split_boundary: "entry",
1575 }];
1576 ordered.extend(chunks);
1577 ordered
1578}
1579
1580fn bundle_chunk_id(kind: &str, source_path: &str, target: &str) -> String {
1581 format!(
1582 "{kind}:{}:{}",
1583 sanitize_bundle_chunk_id_part(source_path),
1584 sanitize_bundle_chunk_id_part(target)
1585 )
1586}
1587
1588fn sanitize_bundle_chunk_id_part(value: &str) -> String {
1589 let mut sanitized = String::with_capacity(value.len());
1590 for ch in value.chars() {
1591 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
1592 sanitized.push(ch);
1593 } else {
1594 sanitized.push('-');
1595 }
1596 }
1597 sanitized.trim_matches('-').to_string()
1598}
1599
1600fn required_passes_for_source(
1601 source_path: &str,
1602 dialect: StyleDialect,
1603 facts: &omena_parser::ParsedStyleFacts,
1604 bundle_edges: &[TransformBundleEdgeV0],
1605) -> Vec<TransformPassKind> {
1606 let mut passes = Vec::new();
1607
1608 if bundle_edges.iter().any(|edge| {
1609 matches!(
1610 edge.kind,
1611 TransformBundleEdgeKind::SassImport
1612 | TransformBundleEdgeKind::CssImport
1613 | TransformBundleEdgeKind::LessImport
1614 | TransformBundleEdgeKind::CssModuleValueImport
1615 | TransformBundleEdgeKind::CssModuleComposesExternal
1616 | TransformBundleEdgeKind::IcssImport
1617 )
1618 }) {
1619 passes.push(TransformPassKind::ImportInline);
1620 }
1621
1622 if matches!(dialect, StyleDialect::Scss | StyleDialect::Sass) {
1623 passes.push(TransformPassKind::ScssModuleEvaluate);
1624 }
1625
1626 if matches!(dialect, StyleDialect::Less) {
1627 passes.push(TransformPassKind::LessModuleEvaluate);
1628 }
1629
1630 if is_css_module_path(source_path) && facts.selector_count > 0 {
1631 passes.push(TransformPassKind::HashCssModuleClassNames);
1632 }
1633
1634 if facts.css_module_composes_edge_count > 0 {
1635 passes.push(TransformPassKind::ResolveCssModulesComposes);
1636 }
1637
1638 if facts.css_module_value_count > 0 || facts.css_module_value_import_edge_count > 0 {
1639 passes.push(TransformPassKind::ValueResolution);
1640 }
1641
1642 passes
1643}
1644
1645fn is_css_module_path(source_path: &str) -> bool {
1646 let file_name = source_path
1647 .rsplit(['/', '\\'])
1648 .next()
1649 .unwrap_or(source_path)
1650 .to_ascii_lowercase();
1651 let Some((stem, extension)) = file_name.rsplit_once('.') else {
1652 return false;
1653 };
1654 matches!(extension, "css" | "scss" | "sass" | "less") && stem.ends_with(".module")
1655}
1656
1657fn dialect_label(dialect: StyleDialect) -> &'static str {
1658 match dialect {
1659 StyleDialect::Css => "css",
1660 StyleDialect::Scss => "scss",
1661 StyleDialect::Sass => "sass",
1662 StyleDialect::Less => "less",
1663 }
1664}
1665
1666#[cfg(test)]
1667mod tests {
1668 use super::{
1669 LinkerDependencyEdgeV0, LinkerInputV0, LinkerRuleV0,
1670 TRANSFORM_BUNDLE_EDGE_KIND_VARIANTS_V0, TransformBundleAssetUrlKind,
1671 TransformBundleChunkKind, TransformBundleEdgeKind, TransformBundleLinkErrorV0,
1672 TransformBundleLinkOptionsV0, TransformBundleModuleInputV0,
1673 TransformBundleSemanticReachabilityInputV0, TransformBundleTransformedModuleV0,
1674 collect_transform_ir_bundle_asset_urls, compare_omena_transform_bundle_emission_policies,
1675 link_omena_transform_bundle_modules, link_omena_transform_bundle_modules_with_options,
1676 link_omena_transform_bundle_modules_with_semantic_reachability,
1677 link_stylesheet_from_projection, materialize_omena_transform_bundle_linked_stylesheet,
1678 raw_scan_bundle_asset_urls_for_oracle, rewrite_omena_transform_bundle_asset_urls_in_source,
1679 summarize_omena_transform_bundle_from_source,
1680 };
1681 use omena_cross_file_summary::EdgeOrderRelevanceV0;
1682 use omena_parser::{
1683 ConfigurationHashV0, ModuleIdV0, ModuleInstanceKeyV0, ParsedSelectorFactKind, StyleDialect,
1684 };
1685
1686 #[test]
1687 fn builds_bundle_plan_from_scss_and_css_modules_parser_facts() {
1688 let source = r#"
1689@use "./tokens" as tokens;
1690@forward "./theme";
1691@value primary from "./colors.module.css";
1692.button {
1693 composes: reset from "./reset.module.css";
1694 color: tokens.$brand;
1695}
1696"#;
1697 let summary = summarize_omena_transform_bundle_from_source(
1698 "Button.module.scss",
1699 source,
1700 StyleDialect::Scss,
1701 );
1702
1703 assert_eq!(summary.product, "omena-transform-bundle.source");
1704 assert_eq!(summary.dialect, "scss");
1705 assert!(summary.import_inline_required);
1706 assert!(summary.module_evaluation_required);
1707 assert!(summary.css_modules_resolution_required);
1708 assert!(summary.class_hashing_required);
1709 assert!(summary.value_resolution_required);
1710 assert!(summary.pass_plan.violated_dag_edge_count == 0);
1711 assert!(summary.bundle_edges.iter().any(|edge| {
1712 edge.kind == TransformBundleEdgeKind::CssModuleComposesExternal
1713 && edge.import_source.as_deref() == Some("./reset.module.css")
1714 }));
1715 assert_eq!(
1716 summary.planned_pass_ids,
1717 vec![
1718 "import-inline",
1719 "scss-module-evaluate",
1720 "composes-resolution",
1721 "css-modules-class-hashing",
1722 "value-resolution"
1723 ]
1724 );
1725 }
1726
1727 #[test]
1728 fn bundle_edge_catalog_has_total_order_relevance() {
1729 assert_eq!(TRANSFORM_BUNDLE_EDGE_KIND_VARIANTS_V0.len(), 9);
1730 assert!(TRANSFORM_BUNDLE_EDGE_KIND_VARIANTS_V0.iter().all(|kind| {
1731 kind.order_relevance() == EdgeOrderRelevanceV0::OrderBearing
1732 && !kind.order_relevance_reason().is_empty()
1733 }));
1734 }
1735
1736 #[test]
1737 fn recognizes_less_module_evaluation_from_dialect() {
1738 let summary = summarize_omena_transform_bundle_from_source(
1739 "Theme.module.less",
1740 r#"@import (reference) "tokens.less"; .card { color: @brand; }"#,
1741 StyleDialect::Less,
1742 );
1743
1744 assert!(summary.module_evaluation_required);
1745 assert!(summary.import_inline_required);
1746 assert!(
1747 summary
1748 .bundle_edges
1749 .iter()
1750 .any(|edge| edge.kind == TransformBundleEdgeKind::LessImport)
1751 );
1752 assert!(summary.required_pass_ids.contains(&"less-module-evaluate"));
1753 assert!(!summary.required_pass_ids.contains(&"scss-module-evaluate"));
1754 assert!(
1755 summary
1756 .required_pass_ids
1757 .contains(&"css-modules-class-hashing")
1758 );
1759 }
1760
1761 #[test]
1762 fn plans_plain_css_import_inline_without_scss_module_evaluation() {
1763 let summary = summarize_omena_transform_bundle_from_source(
1764 "App.css",
1765 r#"@import "./tokens.css"; .button { color: red; }"#,
1766 StyleDialect::Css,
1767 );
1768
1769 assert!(summary.import_inline_required);
1770 assert!(!summary.module_evaluation_required);
1771 assert_eq!(summary.required_pass_ids, vec!["import-inline"]);
1772 assert_eq!(summary.planned_pass_ids, vec!["import-inline"]);
1773 assert!(
1774 summary
1775 .bundle_edges
1776 .iter()
1777 .any(|edge| edge.kind == TransformBundleEdgeKind::CssImport)
1778 );
1779 assert!(
1780 !summary
1781 .bundle_edges
1782 .iter()
1783 .any(|edge| edge.kind == TransformBundleEdgeKind::SassImport)
1784 );
1785 }
1786
1787 #[test]
1788 fn rejects_module_substring_false_positive_paths() {
1789 let source = ".button { color: red; }";
1790 let backup_summary = summarize_omena_transform_bundle_from_source(
1791 "Button.module.backup.scss",
1792 source,
1793 StyleDialect::Scss,
1794 );
1795 let unrelated_summary = summarize_omena_transform_bundle_from_source(
1796 "module/Button.scss",
1797 source,
1798 StyleDialect::Scss,
1799 );
1800
1801 assert!(!backup_summary.class_hashing_required);
1802 assert!(!unrelated_summary.class_hashing_required);
1803 assert!(
1804 !backup_summary
1805 .required_pass_ids
1806 .contains(&"css-modules-class-hashing")
1807 );
1808 assert!(
1809 !unrelated_summary
1810 .required_pass_ids
1811 .contains(&"css-modules-class-hashing")
1812 );
1813 }
1814
1815 #[test]
1816 fn recognizes_css_module_path_by_final_stem_and_supported_extension() {
1817 let summary = summarize_omena_transform_bundle_from_source(
1818 "components\\Button.MODULE.SCSS",
1819 ".button { color: red; }",
1820 StyleDialect::Scss,
1821 );
1822
1823 assert!(summary.class_hashing_required);
1824 assert!(
1825 summary
1826 .required_pass_ids
1827 .contains(&"css-modules-class-hashing")
1828 );
1829 }
1830
1831 #[test]
1832 fn resolves_relative_asset_urls_from_source_path() {
1833 let summary = summarize_omena_transform_bundle_from_source(
1834 "src/components/Button.module.css",
1835 r#".button { background: url("../assets/icon.svg"); mask: url(/static/mask.svg); cursor: url(data:image/svg+xml,abc); filter: url(#shadow); border-image-source: URL(https://cdn.example.com/frame.png); }"#,
1836 StyleDialect::Css,
1837 );
1838
1839 assert_eq!(summary.asset_urls.len(), 5);
1840 assert!(summary.asset_urls.iter().any(|asset| {
1841 asset.normalized_url == "../assets/icon.svg"
1842 && asset.kind == TransformBundleAssetUrlKind::Relative
1843 && asset.resolved_path.as_deref() == Some("src/assets/icon.svg")
1844 && asset.bundler_resolution_required
1845 }));
1846 assert!(summary.asset_urls.iter().any(|asset| {
1847 asset.normalized_url == "/static/mask.svg"
1848 && asset.kind == TransformBundleAssetUrlKind::AbsolutePath
1849 && asset.resolved_path.as_deref() == Some("/static/mask.svg")
1850 && asset.bundler_resolution_required
1851 }));
1852
1853 assert!(summary.asset_urls.iter().any(|asset| {
1854 asset.kind == TransformBundleAssetUrlKind::Data && !asset.bundler_resolution_required
1855 }));
1856 assert!(summary.asset_urls.iter().any(|asset| {
1857 asset.kind == TransformBundleAssetUrlKind::Fragment
1858 && !asset.bundler_resolution_required
1859 }));
1860 assert!(summary.asset_urls.iter().any(|asset| {
1861 asset.kind == TransformBundleAssetUrlKind::External
1862 && !asset.bundler_resolution_required
1863 }));
1864 }
1865
1866 #[test]
1867 fn value_ir_asset_urls_match_raw_scan_byte_identical() {
1868 let corpus = [
1869 (
1870 "src/components/Button.module.css",
1871 StyleDialect::Css,
1872 r#".button { background: url("../assets/icon.svg"); mask: url(/static/mask.svg); cursor: url(data:image/svg+xml,abc); filter: url(#shadow); border-image-source: URL(https://cdn.example.com/frame.png); }"#,
1873 ),
1874 (
1875 "src/components/Card.module.scss",
1876 StyleDialect::Scss,
1877 r#".카드 { background-image: url(./img/아이콘.svg); }"#,
1878 ),
1879 (
1880 "src/components/Theme.module.less",
1881 StyleDialect::Less,
1882 r#".theme { background: url('../assets/theme.svg'); }"#,
1883 ),
1884 ];
1885
1886 for (source_path, dialect, source) in corpus {
1887 let transform_ir_urls =
1888 collect_transform_ir_bundle_asset_urls(source_path, source, dialect);
1889 let raw_urls = raw_scan_bundle_asset_urls_for_oracle(source_path, source);
1890 assert_eq!(transform_ir_urls, raw_urls, "{source_path}");
1891 }
1892 }
1893
1894 #[test]
1895 fn plans_code_split_chunks_for_style_and_asset_dependencies() {
1896 let summary = summarize_omena_transform_bundle_from_source(
1897 "src/components/Button.module.css",
1898 r#"@import "../theme.css"; .button { background: url("../assets/icon.svg"); }"#,
1899 StyleDialect::Css,
1900 );
1901
1902 assert!(summary.code_splitting_required);
1903 assert_eq!(summary.code_split_chunks.len(), 3);
1904 let entry_chunk_id = summary
1905 .code_split_chunks
1906 .iter()
1907 .find(|chunk| chunk.kind == TransformBundleChunkKind::Entry)
1908 .map(|chunk| {
1909 assert_eq!(chunk.split_boundary, "entry");
1910 assert_eq!(chunk.depends_on.len(), 2);
1911 chunk.chunk_id.clone()
1912 });
1913 assert!(entry_chunk_id.is_some());
1914
1915 let style_chunk_id = summary
1916 .code_split_chunks
1917 .iter()
1918 .find(|chunk| chunk.kind == TransformBundleChunkKind::StyleImport)
1919 .map(|chunk| {
1920 assert_eq!(chunk.import_source.as_deref(), Some("../theme.css"));
1921 assert_eq!(chunk.split_boundary, "styleDependency");
1922 chunk.chunk_id.clone()
1923 });
1924 assert!(style_chunk_id.is_some());
1925
1926 let asset_chunk_id = summary
1927 .code_split_chunks
1928 .iter()
1929 .find(|chunk| chunk.kind == TransformBundleChunkKind::Asset)
1930 .map(|chunk| {
1931 assert_eq!(chunk.asset_url.as_deref(), Some("../assets/icon.svg"));
1932 assert_eq!(chunk.resolved_path.as_deref(), Some("src/assets/icon.svg"));
1933 assert_eq!(chunk.split_boundary, "assetDependency");
1934 chunk.chunk_id.clone()
1935 });
1936 assert!(asset_chunk_id.is_some());
1937 let entry_dependencies = summary
1938 .code_split_chunks
1939 .iter()
1940 .find(|chunk| chunk.kind == TransformBundleChunkKind::Entry)
1941 .map(|chunk| chunk.depends_on.as_slice())
1942 .unwrap_or(&[]);
1943 assert!(style_chunk_id.is_some_and(|chunk_id| entry_dependencies.contains(&chunk_id)));
1944 assert!(asset_chunk_id.is_some_and(|chunk_id| entry_dependencies.contains(&chunk_id)));
1945 }
1946
1947 #[test]
1948 fn resolves_asset_urls_after_non_ascii_source_text() {
1949 let summary = summarize_omena_transform_bundle_from_source(
1950 "src/카드.module.css",
1951 ".카드 { background-image: url(./img/아이콘.svg); }",
1952 StyleDialect::Css,
1953 );
1954
1955 assert_eq!(summary.asset_urls.len(), 1);
1956 let asset = &summary.asset_urls[0];
1957 assert_eq!(asset.kind, TransformBundleAssetUrlKind::Relative);
1958 assert_eq!(asset.normalized_url, "./img/아이콘.svg");
1959 assert_eq!(asset.resolved_path.as_deref(), Some("src/img/아이콘.svg"));
1960 }
1961
1962 #[test]
1963 fn preserves_leading_parent_segments_without_source_parent() {
1964 let summary = summarize_omena_transform_bundle_from_source(
1965 "Button.module.css",
1966 ".button { background-image: url(../assets/icon.svg); }",
1967 StyleDialect::Css,
1968 );
1969
1970 assert_eq!(
1971 summary.asset_urls[0].resolved_path.as_deref(),
1972 Some("../assets/icon.svg")
1973 );
1974 }
1975
1976 #[test]
1977 fn rewrites_relative_asset_urls_to_resolved_bundle_paths() {
1978 let summary = rewrite_omena_transform_bundle_asset_urls_in_source(
1979 "src/components/Button.module.css",
1980 r#".button { background: url("../assets/icon.svg"); mask: url(/static/mask.svg); filter: url(#shadow); }"#,
1981 );
1982
1983 assert_eq!(summary.product, "omena-transform-bundle.asset-url-rewrite");
1984 assert_eq!(summary.asset_url_count, 3);
1985 assert_eq!(summary.rewrite_count, 1);
1986 assert!(summary.output_css.contains(r#"url("src/assets/icon.svg")"#));
1987 assert!(summary.output_css.contains("url(/static/mask.svg)"));
1988 assert!(summary.output_css.contains("url(#shadow)"));
1989 assert_eq!(
1990 summary
1991 .rewritten_asset_urls
1992 .first()
1993 .and_then(|asset| asset.resolved_path.as_deref()),
1994 Some("src/assets/icon.svg")
1995 );
1996 }
1997
1998 #[test]
1999 fn linker_global_rule_order_is_a_total_order_over_linked_rules() -> Result<(), String> {
2000 let modules = vec![
2001 TransformBundleModuleInputV0::new(
2002 "src/app.module.css",
2003 r#"@import "./theme.css"; .button { color: var(--brand); }"#,
2004 StyleDialect::Css,
2005 ),
2006 TransformBundleModuleInputV0::new(
2007 "src/theme.css",
2008 r#":root { --brand: red; } .theme { color: red; }"#,
2009 StyleDialect::Css,
2010 ),
2011 ];
2012
2013 let linked = link_omena_transform_bundle_modules(&["src/app.module.css"], &modules)
2014 .map_err(|err| format!("{err:?}"))?;
2015
2016 assert_eq!(linked.product, "omena-transform-bundle.linked-stylesheet");
2017 assert_eq!(linked.entrypoints.len(), 1);
2018 assert_eq!(linked.module_instances.len(), 2);
2019 assert_eq!(
2020 linked
2021 .global_rule_order
2022 .rules
2023 .iter()
2024 .map(|rule| rule.global_order_index)
2025 .collect::<Vec<_>>(),
2026 vec![0, 1]
2027 );
2028 assert!(
2029 linked
2030 .global_rule_order
2031 .rules
2032 .iter()
2033 .any(|rule| rule.selector_name == "button")
2034 );
2035 assert!(
2036 linked
2037 .closed_world_bundle
2038 .reachability()
2039 .class_names()
2040 .contains(&"theme".to_string())
2041 );
2042 assert!(
2043 linked
2044 .closed_world_bundle
2045 .reachability()
2046 .custom_property_names()
2047 .contains(&"--brand".to_string())
2048 );
2049 Ok(())
2050 }
2051
2052 #[test]
2053 fn emission_plan_is_the_only_rule_order_authority() -> Result<(), String> {
2054 let first = ModuleInstanceKeyV0::unconfigured(ModuleIdV0::new("a.css"));
2055 let second = ModuleInstanceKeyV0::unconfigured(ModuleIdV0::new("b.css"));
2056 let inputs = [
2057 LinkerInputV0 {
2058 source_path: "a.css".to_string(),
2059 instance: first.clone(),
2060 dependency_edges: Vec::new(),
2061 class_names: vec!["first".to_string()],
2062 keyframe_names: Vec::new(),
2063 value_names: Vec::new(),
2064 custom_property_names: Vec::new(),
2065 ordered_rules: vec![LinkerRuleV0 {
2066 selector_name: "first".to_string(),
2067 selector_kind: ParsedSelectorFactKind::Class,
2068 range_start: 0,
2069 range_end: 6,
2070 }],
2071 },
2072 LinkerInputV0 {
2073 source_path: "b.css".to_string(),
2074 instance: second.clone(),
2075 dependency_edges: Vec::new(),
2076 class_names: vec!["second".to_string()],
2077 keyframe_names: Vec::new(),
2078 value_names: Vec::new(),
2079 custom_property_names: Vec::new(),
2080 ordered_rules: vec![LinkerRuleV0 {
2081 selector_name: "second".to_string(),
2082 selector_kind: ParsedSelectorFactKind::Class,
2083 range_start: 0,
2084 range_end: 7,
2085 }],
2086 },
2087 ];
2088 let mut plan = super::emission_order::build_emission_plan(
2089 &inputs,
2090 &[first.clone(), second],
2091 &[first],
2092 super::EmissionOrderingPolicyV0::ModuleIdLegacy,
2093 )
2094 .map_err(|error| format!("{error:?}"))?;
2095 let original = super::emission_order::build_global_rule_order_from_plan(&inputs, &plan)
2096 .map_err(|error| format!("{error:?}"))?;
2097
2098 plan.entries.swap(0, 1);
2099 let perturbed = super::emission_order::build_global_rule_order_from_plan(&inputs, &plan)
2100 .map_err(|error| format!("{error:?}"))?;
2101
2102 assert_eq!(original.rules[0].selector_name, "first");
2103 assert_eq!(perturbed.rules[0].selector_name, "second");
2104 assert_ne!(original, perturbed);
2105 Ok(())
2106 }
2107
2108 #[test]
2109 fn parser_import_order_is_recorded_without_changing_default_output() -> Result<(), String> {
2110 fn link(imports: &str) -> Result<super::LinkedStylesheetV0, String> {
2111 link_omena_transform_bundle_modules(
2112 &["src/app.css"],
2113 &[
2114 TransformBundleModuleInputV0::new(
2115 "src/app.css",
2116 format!("{imports} .app {{ color: red; }}"),
2117 StyleDialect::Css,
2118 ),
2119 TransformBundleModuleInputV0::new(
2120 "src/a.css",
2121 ".a { color: blue; }",
2122 StyleDialect::Css,
2123 ),
2124 TransformBundleModuleInputV0::new(
2125 "src/z.css",
2126 ".z { color: green; }",
2127 StyleDialect::Css,
2128 ),
2129 ],
2130 )
2131 .map_err(|error| format!("{error:?}"))
2132 }
2133
2134 let a_then_z = link(r#"@import "./a.css"; @import "./z.css";"#)?;
2135 let z_then_a = link(r#"@import "./z.css"; @import "./a.css";"#)?;
2136 let targets = |linked: &super::LinkedStylesheetV0| {
2137 linked
2138 .emission_plan
2139 .dependency_facts
2140 .iter()
2141 .map(|fact| fact.to_module.module().as_str().to_string())
2142 .collect::<Vec<_>>()
2143 };
2144
2145 assert_eq!(targets(&a_then_z), vec!["src/a.css", "src/z.css"]);
2146 assert_eq!(targets(&z_then_a), vec!["src/z.css", "src/a.css"]);
2147 assert_eq!(
2148 serde_json::to_vec(&a_then_z).map_err(|error| format!("{error:?}"))?,
2149 serde_json::to_vec(&z_then_a).map_err(|error| format!("{error:?}"))?
2150 );
2151 Ok(())
2152 }
2153
2154 #[test]
2155 fn default_emission_policy_is_pinned_to_legacy_module_id_order() -> Result<(), String> {
2156 let modules = [
2157 TransformBundleModuleInputV0::new(
2158 "src/app.css",
2159 r#"@import "./z.css"; .app { color: red; }"#,
2160 StyleDialect::Css,
2161 ),
2162 TransformBundleModuleInputV0::new(
2163 "src/z.css",
2164 ".z { color: green; }",
2165 StyleDialect::Css,
2166 ),
2167 ];
2168 let implicit = link_omena_transform_bundle_modules(&["src/app.css"], &modules)
2169 .map_err(|error| format!("{error:?}"))?;
2170 let explicit = link_omena_transform_bundle_modules_with_options(
2171 &["src/app.css"],
2172 &modules,
2173 &[],
2174 &[],
2175 TransformBundleLinkOptionsV0 {
2176 emission_ordering_policy: super::EmissionOrderingPolicyV0::ModuleIdLegacy,
2177 },
2178 )
2179 .map_err(|error| format!("{error:?}"))?;
2180
2181 assert_eq!(
2182 implicit.emission_plan.policy,
2183 super::EmissionOrderingPolicyV0::ModuleIdLegacy
2184 );
2185 assert_eq!(
2186 serde_json::to_vec(&implicit).map_err(|error| format!("{error:?}"))?,
2187 serde_json::to_vec(&explicit).map_err(|error| format!("{error:?}"))?
2188 );
2189 Ok(())
2190 }
2191
2192 #[test]
2193 fn import_order_policy_reports_real_output_differences() -> Result<(), String> {
2194 let modules = [
2195 TransformBundleModuleInputV0::new(
2196 "src/app.css",
2197 r#"@import "./z.css"; @import "./a.css"; .app { color: red; }"#,
2198 StyleDialect::Css,
2199 ),
2200 TransformBundleModuleInputV0::new(
2201 "src/a.css",
2202 ".a { color: blue; }",
2203 StyleDialect::Css,
2204 ),
2205 TransformBundleModuleInputV0::new(
2206 "src/z.css",
2207 ".z { color: green; }",
2208 StyleDialect::Css,
2209 ),
2210 ];
2211 let linked = link_omena_transform_bundle_modules_with_options(
2212 &["src/app.css"],
2213 &modules,
2214 &[],
2215 &[],
2216 TransformBundleLinkOptionsV0 {
2217 emission_ordering_policy: super::EmissionOrderingPolicyV0::ImportOrderPreserving,
2218 },
2219 )
2220 .map_err(|error| format!("{error:?}"))?;
2221 let report = compare_omena_transform_bundle_emission_policies(&["src/app.css"], &modules)
2222 .map_err(|error| format!("{error:?}"))?;
2223
2224 assert_eq!(
2225 linked
2226 .global_rule_order
2227 .rules
2228 .iter()
2229 .map(|rule| rule.selector_name.as_str())
2230 .collect::<Vec<_>>(),
2231 vec!["z", "a", "app"]
2232 );
2233 assert!(!report.equivalent);
2234 assert_eq!(report.difference_count, report.differences.len());
2235 assert!(report.difference_count >= 2);
2236 Ok(())
2237 }
2238
2239 #[test]
2240 fn linked_emission_materializes_the_global_module_order() -> Result<(), String> {
2241 let modules = [
2242 TransformBundleModuleInputV0::new(
2243 "src/app.css",
2244 r#"@import "./z.css"; @import "./a.css"; .app { color: red; }"#,
2245 StyleDialect::Css,
2246 ),
2247 TransformBundleModuleInputV0::new(
2248 "src/a.css",
2249 ".a { color: blue; }",
2250 StyleDialect::Css,
2251 ),
2252 TransformBundleModuleInputV0::new(
2253 "src/z.css",
2254 ".z { color: green; }",
2255 StyleDialect::Css,
2256 ),
2257 ];
2258 let link = |policy| {
2259 link_omena_transform_bundle_modules_with_options(
2260 &["src/app.css"],
2261 &modules,
2262 &[],
2263 &[],
2264 TransformBundleLinkOptionsV0 {
2265 emission_ordering_policy: policy,
2266 },
2267 )
2268 .map_err(|error| format!("{error:?}"))
2269 };
2270 let legacy = link(super::EmissionOrderingPolicyV0::ModuleIdLegacy)?;
2271 let import_order = link(super::EmissionOrderingPolicyV0::ImportOrderPreserving)?;
2272 let transformed_modules = legacy
2273 .module_instances
2274 .iter()
2275 .cloned()
2276 .map(|module_instance| {
2277 let marker = module_instance.module().as_str().replace(['/', '.'], "-");
2278 TransformBundleTransformedModuleV0::new(
2279 module_instance,
2280 format!(".{marker} {{ order: linked; }}"),
2281 )
2282 })
2283 .collect::<Vec<_>>();
2284
2285 let legacy_output =
2286 materialize_omena_transform_bundle_linked_stylesheet(&legacy, &transformed_modules)
2287 .map_err(|error| format!("{error:?}"))?;
2288 let import_order_output = materialize_omena_transform_bundle_linked_stylesheet(
2289 &import_order,
2290 &transformed_modules,
2291 )
2292 .map_err(|error| format!("{error:?}"))?;
2293
2294 assert_ne!(legacy_output.output_css, import_order_output.output_css);
2295 assert_eq!(
2296 import_order_output
2297 .module_regions
2298 .iter()
2299 .map(|region| region.module_instance.module().as_str())
2300 .collect::<Vec<_>>(),
2301 vec!["src/z.css", "src/a.css", "src/app.css"]
2302 );
2303 assert_eq!(import_order_output.emitted_module_count, 3);
2304 assert_eq!(
2305 import_order_output.global_order_entry_count,
2306 import_order.global_rule_order.rules.len()
2307 );
2308 for transformed in &transformed_modules {
2309 assert_eq!(
2310 import_order_output
2311 .output_css
2312 .matches(&transformed.output_css)
2313 .count(),
2314 1,
2315 "each transformed module must be emitted exactly once"
2316 );
2317 }
2318 for entry_region in &import_order_output.order_entry_regions {
2319 let module_region = import_order_output
2320 .module_regions
2321 .iter()
2322 .find(|region| region.module_instance == entry_region.module_instance)
2323 .ok_or_else(|| "ordered entry has no generated module region".to_string())?;
2324 assert_eq!(entry_region.generated_start, module_region.generated_start);
2325 assert_eq!(entry_region.generated_end, module_region.generated_end);
2326 }
2327 Ok(())
2328 }
2329
2330 #[test]
2331 fn linked_emission_rejects_preinlined_module_bytes() -> Result<(), String> {
2332 let modules = [
2333 TransformBundleModuleInputV0::new(
2334 "src/app.css",
2335 r#"@import "./theme.css"; .app { color: red; }"#,
2336 StyleDialect::Css,
2337 ),
2338 TransformBundleModuleInputV0::new(
2339 "src/theme.css",
2340 ".theme { color: blue; }",
2341 StyleDialect::Css,
2342 ),
2343 ];
2344 let linked = link_omena_transform_bundle_modules(&["src/app.css"], &modules)
2345 .map_err(|error| format!("{error:?}"))?;
2346 let transformed_modules = linked
2347 .module_instances
2348 .iter()
2349 .cloned()
2350 .enumerate()
2351 .map(|(index, module_instance)| {
2352 TransformBundleTransformedModuleV0::new(
2353 module_instance,
2354 format!(".module-{index} {{ order: linked; }}"),
2355 )
2356 .with_non_empty_import_replacement_count(usize::from(index == 0))
2357 })
2358 .collect::<Vec<_>>();
2359
2360 let result =
2361 materialize_omena_transform_bundle_linked_stylesheet(&linked, &transformed_modules);
2362
2363 assert!(matches!(
2364 result,
2365 Err(
2366 super::LinkedEmissionMaterializationErrorV0::ImportReplacementWouldDuplicateModule {
2367 replacement_count: 1,
2368 ..
2369 }
2370 )
2371 ));
2372 Ok(())
2373 }
2374
2375 #[test]
2376 fn dependency_cycles_are_recorded_with_an_explicit_policy() -> Result<(), String> {
2377 let first = ModuleInstanceKeyV0::unconfigured(ModuleIdV0::new("a.css"));
2378 let second = ModuleInstanceKeyV0::unconfigured(ModuleIdV0::new("b.css"));
2379 let input = |source_path: &str,
2380 instance: ModuleInstanceKeyV0,
2381 import_source: &str,
2382 selector: &str| LinkerInputV0 {
2383 source_path: source_path.to_string(),
2384 instance,
2385 dependency_edges: vec![LinkerDependencyEdgeV0 {
2386 kind: TransformBundleEdgeKind::CssImport,
2387 import_source: import_source.to_string(),
2388 import_ordinal: Some(0),
2389 }],
2390 class_names: vec![selector.to_string()],
2391 keyframe_names: Vec::new(),
2392 value_names: Vec::new(),
2393 custom_property_names: Vec::new(),
2394 ordered_rules: vec![LinkerRuleV0 {
2395 selector_name: selector.to_string(),
2396 selector_kind: ParsedSelectorFactKind::Class,
2397 range_start: 0,
2398 range_end: selector.len() as u32,
2399 }],
2400 };
2401 let linked = link_stylesheet_from_projection(
2402 &["a.css"],
2403 &[
2404 input("a.css", first, "./b.css", "a"),
2405 input("b.css", second, "./a.css", "b"),
2406 ],
2407 )
2408 .map_err(|error| format!("{error:?}"))?;
2409
2410 assert_eq!(linked.emission_plan.cycle_groups.len(), 1);
2411 let group = &linked.emission_plan.cycle_groups[0];
2412 assert_eq!(group.class, super::EmissionCycleClassV0::Import);
2413 assert_eq!(group.policy, super::EmissionCyclePolicyV0::ModuleIdentity);
2414 assert_eq!(group.members, group.chosen_order);
2415 Ok(())
2416 }
2417
2418 #[test]
2419 fn unsupported_module_cycle_edge_fails_closed() {
2420 let first = ModuleInstanceKeyV0::unconfigured(ModuleIdV0::new("a.css"));
2421 let second = ModuleInstanceKeyV0::unconfigured(ModuleIdV0::new("b.css"));
2422 let input =
2423 |source_path: &str, instance: ModuleInstanceKeyV0, import_source: &str| LinkerInputV0 {
2424 source_path: source_path.to_string(),
2425 instance,
2426 dependency_edges: vec![LinkerDependencyEdgeV0 {
2427 kind: TransformBundleEdgeKind::CssModuleComposesLocal,
2428 import_source: import_source.to_string(),
2429 import_ordinal: Some(0),
2430 }],
2431 class_names: Vec::new(),
2432 keyframe_names: Vec::new(),
2433 value_names: Vec::new(),
2434 custom_property_names: Vec::new(),
2435 ordered_rules: Vec::new(),
2436 };
2437
2438 let result = link_stylesheet_from_projection(
2439 &["a.css"],
2440 &[
2441 input("a.css", first, "./b.css"),
2442 input("b.css", second, "./a.css"),
2443 ],
2444 );
2445
2446 assert_eq!(
2447 result,
2448 Err(TransformBundleLinkErrorV0::UnsupportedEmissionCycle {
2449 edge_kind: TransformBundleEdgeKind::CssModuleComposesLocal,
2450 })
2451 );
2452 }
2453
2454 #[test]
2455 fn cascade_source_order_is_fed_by_global_rule_order() -> Result<(), String> {
2456 let modules = vec![
2457 TransformBundleModuleInputV0::new(
2458 "src/app.module.css",
2459 r#"@import "./theme.css"; .button { color: red; }"#,
2460 StyleDialect::Css,
2461 ),
2462 TransformBundleModuleInputV0::new(
2463 "src/theme.css",
2464 r#".button { color: blue; }"#,
2465 StyleDialect::Css,
2466 ),
2467 ];
2468
2469 let linked = link_omena_transform_bundle_modules(&["src/app.module.css"], &modules)
2470 .map_err(|err| format!("{err:?}"))?;
2471 let button_rules = linked
2472 .global_rule_order
2473 .rules
2474 .iter()
2475 .filter(|rule| rule.selector_name == "button")
2476 .collect::<Vec<_>>();
2477
2478 assert_eq!(button_rules.len(), 2);
2479 assert_eq!(
2480 button_rules
2481 .iter()
2482 .map(|rule| rule.global_order_index)
2483 .collect::<Vec<_>>(),
2484 vec![0, 1]
2485 );
2486
2487 let declarations = button_rules
2488 .iter()
2489 .map(|rule| {
2490 let value = if rule.global_order_index == 0 {
2491 "red"
2492 } else {
2493 "blue"
2494 };
2495 omena_cascade::CascadeDeclaration {
2496 id: format!(
2497 "{}:{}",
2498 rule.module_instance.module().as_str(),
2499 rule.global_order_index
2500 ),
2501 property: "color".to_string(),
2502 value: omena_cascade::CascadeValue::Literal(value.to_string()),
2503 key: rule.cascade_key_with_global_source_order(
2504 omena_cascade::CascadeLevel::AuthorNormal,
2505 omena_cascade::LayerRank(0),
2506 0,
2507 omena_cascade::Specificity::new(0, 1, 0),
2508 if rule.global_order_index == 0 {
2509 omena_cascade::ModuleRank::new(u32::MAX, u32::MAX, u32::MAX)
2510 } else {
2511 omena_cascade::ModuleRank::ZERO
2512 },
2513 ),
2514 specificity_exactness: omena_cascade::SpecificityExactnessV0::Exact,
2515 }
2516 })
2517 .collect::<Vec<_>>();
2518
2519 let outcome = omena_cascade::cascade_property(declarations, "color");
2520 let omena_cascade::CascadeOutcome::Definite { winner, proof, .. } = outcome else {
2521 return Err("expected definite cascade winner".to_string());
2522 };
2523 assert_eq!(
2524 winner.value,
2525 omena_cascade::CascadeValue::Literal("blue".to_string())
2526 );
2527 assert_eq!(winner.key.source_order, 1);
2528 assert_eq!(proof.source_order, 1);
2529 Ok(())
2530 }
2531
2532 #[test]
2533 fn cascade_closed_world_order_matches_module_rank_key_byte_identical() -> Result<(), String> {
2534 let modules = vec![
2535 TransformBundleModuleInputV0::new(
2536 "src/app.module.css",
2537 r#"@import "./theme.css"; .button { color: red; }"#,
2538 StyleDialect::Css,
2539 ),
2540 TransformBundleModuleInputV0::new(
2541 "src/theme.css",
2542 r#".button { color: blue; }"#,
2543 StyleDialect::Css,
2544 ),
2545 ];
2546
2547 let linked = link_omena_transform_bundle_modules(&["src/app.module.css"], &modules)
2548 .map_err(|err| format!("{err:?}"))?;
2549 let declarations = linked
2550 .global_rule_order
2551 .rules
2552 .iter()
2553 .filter(|rule| rule.selector_name == "button")
2554 .map(|rule| {
2555 let linked_later = rule.global_order_index == 1;
2556 omena_cascade::CascadeDeclaration {
2557 id: format!(
2558 "{}:{}",
2559 rule.module_instance.module().as_str(),
2560 rule.global_order_index
2561 ),
2562 property: "color".to_string(),
2563 value: omena_cascade::CascadeValue::Literal(if linked_later {
2564 "blue".to_string()
2565 } else {
2566 "red".to_string()
2567 }),
2568 key: rule.cascade_key_with_global_source_order(
2569 omena_cascade::CascadeLevel::AuthorNormal,
2570 omena_cascade::LayerRank(0),
2571 0,
2572 omena_cascade::Specificity::new(0, 1, 0),
2573 if linked_later {
2574 omena_cascade::ModuleRank::new(u32::MAX, u32::MAX, u32::MAX)
2575 } else {
2576 omena_cascade::ModuleRank::ZERO
2577 },
2578 ),
2579 specificity_exactness: omena_cascade::SpecificityExactnessV0::Exact,
2580 }
2581 })
2582 .collect::<Vec<_>>();
2583
2584 let linked_order_css = definite_color_css(omena_cascade::cascade_property(
2585 declarations.clone(),
2586 "color",
2587 ))?;
2588 let module_rank_keyed_css = legacy_module_rank_keyed_color_css(&declarations)?;
2589
2590 assert_eq!(
2591 linked_order_css.as_bytes(),
2592 module_rank_keyed_css.as_bytes()
2593 );
2594 Ok(())
2595 }
2596
2597 fn definite_color_css(outcome: omena_cascade::CascadeOutcome) -> Result<String, String> {
2598 let omena_cascade::CascadeOutcome::Definite { winner, .. } = outcome else {
2599 return Err("expected definite cascade winner".to_string());
2600 };
2601 let omena_cascade::CascadeValue::Literal(value) = winner.value else {
2602 return Err("expected literal cascade value".to_string());
2603 };
2604 Ok(format!("color:{value};"))
2605 }
2606
2607 fn legacy_module_rank_keyed_color_css(
2608 declarations: &[omena_cascade::CascadeDeclaration],
2609 ) -> Result<String, String> {
2610 let mut matching = declarations.to_vec();
2611 matching.sort_by(|left, right| {
2612 legacy_module_rank_key(right)
2613 .cmp(&legacy_module_rank_key(left))
2614 .then_with(|| right.key.source_order.cmp(&left.key.source_order))
2615 });
2616 let Some(winner) = matching.first() else {
2617 return Err("expected cascade declarations".to_string());
2618 };
2619 let omena_cascade::CascadeValue::Literal(value) = &winner.value else {
2620 return Err("expected literal cascade value".to_string());
2621 };
2622 Ok(format!("color:{value};"))
2623 }
2624
2625 fn legacy_module_rank_key(
2626 declaration: &omena_cascade::CascadeDeclaration,
2627 ) -> (
2628 omena_cascade::CascadeLevel,
2629 omena_cascade::LayerRank,
2630 std::cmp::Reverse<u32>,
2631 omena_cascade::Specificity,
2632 omena_cascade::ModuleRank,
2633 ) {
2634 (
2635 declaration.key.level,
2636 declaration.key.layer_rank,
2637 std::cmp::Reverse(declaration.key.scope_proximity),
2638 declaration.key.specificity,
2639 declaration.key.module_rank,
2640 )
2641 }
2642
2643 #[test]
2644 fn linker_distinguishes_configured_module_instances() {
2645 use omena_parser::{ConfigurationHashV0, ModuleIdV0, ModuleInstanceKeyV0};
2646
2647 let module = ModuleIdV0::new("src/theme.scss");
2648 let blue =
2649 ModuleInstanceKeyV0::new(module.clone(), ConfigurationHashV0::new("with:brand=blue"));
2650 let red = ModuleInstanceKeyV0::new(module, ConfigurationHashV0::new("with:brand=red"));
2651
2652 assert_ne!(blue, red);
2653 assert_eq!(blue.module(), red.module());
2654 assert_ne!(blue.configuration(), red.configuration());
2655 }
2656
2657 #[test]
2658 fn semantic_reachability_input_feeds_closed_world_bundle() -> Result<(), String> {
2659 let modules = vec![TransformBundleModuleInputV0::new(
2660 "Button.module.css",
2661 ".used { color: blue; } .dead { color: red; }",
2662 StyleDialect::Css,
2663 )];
2664 let mut reachability = TransformBundleSemanticReachabilityInputV0::new("Button.module.css");
2665 reachability.class_names.push("used".to_string());
2666
2667 let linked = link_omena_transform_bundle_modules_with_semantic_reachability(
2668 &["Button.module.css"],
2669 &modules,
2670 &[reachability],
2671 )
2672 .map_err(|err| format!("semantic reachability bundle should link: {err:?}"))?;
2673
2674 assert_eq!(
2675 linked.closed_world_bundle.reachability().class_names(),
2676 &["used".to_string()]
2677 );
2678 Ok(())
2679 }
2680
2681 #[test]
2682 fn projection_linker_core_links_without_module_sources() -> Result<(), String> {
2683 let app = ModuleInstanceKeyV0::new(
2684 ModuleIdV0::new("src/app.module.css"),
2685 ConfigurationHashV0::none(),
2686 );
2687 let theme = ModuleInstanceKeyV0::new(
2688 ModuleIdV0::new("src/theme.css"),
2689 ConfigurationHashV0::none(),
2690 );
2691 let linked = link_stylesheet_from_projection(
2692 &["src/app.module.css"],
2693 &[
2694 LinkerInputV0 {
2695 source_path: "src/app.module.css".to_string(),
2696 instance: app.clone(),
2697 dependency_edges: vec![LinkerDependencyEdgeV0 {
2698 kind: TransformBundleEdgeKind::CssImport,
2699 import_source: "./theme.css".to_string(),
2700 import_ordinal: Some(0),
2701 }],
2702 class_names: vec!["app".to_string()],
2703 keyframe_names: Vec::new(),
2704 value_names: Vec::new(),
2705 custom_property_names: Vec::new(),
2706 ordered_rules: vec![LinkerRuleV0 {
2707 selector_name: "app".to_string(),
2708 selector_kind: ParsedSelectorFactKind::Class,
2709 range_start: 0,
2710 range_end: 4,
2711 }],
2712 },
2713 LinkerInputV0 {
2714 source_path: "src/theme.css".to_string(),
2715 instance: theme,
2716 dependency_edges: Vec::new(),
2717 class_names: vec!["theme".to_string()],
2718 keyframe_names: Vec::new(),
2719 value_names: Vec::new(),
2720 custom_property_names: vec!["--brand".to_string()],
2721 ordered_rules: vec![LinkerRuleV0 {
2722 selector_name: "theme".to_string(),
2723 selector_kind: ParsedSelectorFactKind::Class,
2724 range_start: 0,
2725 range_end: 6,
2726 }],
2727 },
2728 ],
2729 )
2730 .map_err(|err| format!("{err:?}"))?;
2731
2732 assert_eq!(linked.module_instances.len(), 2);
2733 assert_eq!(
2734 linked
2735 .global_rule_order
2736 .rules
2737 .iter()
2738 .map(|rule| rule.selector_name.as_str())
2739 .collect::<Vec<_>>(),
2740 vec!["app", "theme"]
2741 );
2742 assert!(
2743 linked
2744 .closed_world_bundle
2745 .reachability()
2746 .custom_property_names()
2747 .contains(&"--brand".to_string())
2748 );
2749 Ok(())
2750 }
2751
2752 #[test]
2753 fn linker_reports_missing_module_dependency() {
2754 let modules = vec![TransformBundleModuleInputV0::new(
2755 "src/app.css",
2756 r#"@import "./missing.css"; .button { color: red; }"#,
2757 StyleDialect::Css,
2758 )];
2759
2760 let err = link_omena_transform_bundle_modules(&["src/app.css"], &modules);
2761
2762 assert_eq!(
2763 err,
2764 Err(TransformBundleLinkErrorV0::MissingDependency {
2765 source_path: "src/app.css".to_string(),
2766 import_source: "./missing.css".to_string(),
2767 })
2768 );
2769 }
2770}