Skip to main content

omena_bundler/
lib.rs

1//! Standalone 0.x bundle planning for Omena CSS transforms.
2//!
3//! This crate is the standalone Rust entry point for the Omena bundler planning
4//! surface. It decides which bundle/module passes are required for a style
5//! source and delegates ordering to `omena-transform-passes`.
6//!
7//! The public types intentionally keep their `V0` suffix during the 0.x line.
8
9mod 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}