Skip to main content

omena_query/style/transform/
context.rs

1use super::css_modules::{
2    derive_class_name_rewrites_for_transform_context,
3    derive_css_module_composes_resolutions_for_transform_context,
4    derive_css_module_value_resolutions_for_transform_context,
5};
6use super::design_tokens::derive_design_token_routes_for_transform_context;
7use super::imports::derive_import_inlines_for_transform_context;
8use super::static_stylesheet::{
9    derive_static_scss_module_use_evaluations_for_transform_context,
10    derive_static_stylesheet_module_evaluation_for_transform_context,
11};
12use super::*;
13use crate::types::{
14    OmenaQueryEngineInputModuleAttributionV0, normalize_omena_query_style_path,
15    resolve_omena_query_style_path_against_known,
16};
17use omena_syntax::ident::ClassNameV0;
18use std::collections::{BTreeMap, BTreeSet};
19
20#[derive(Clone, Copy)]
21pub(super) struct TransformResolutionContext<'a> {
22    pub(super) package_manifests: &'a [OmenaQueryStylePackageManifestV0],
23    pub(super) bundler_path_mappings: &'a [OmenaResolverBundlerPathAliasMappingV0],
24    pub(super) tsconfig_path_mappings: &'a [OmenaResolverTsconfigPathMappingV0],
25    pub(super) disk_style_path_identities: &'a [OmenaResolverStyleModuleDiskCandidateIdentityV0],
26}
27
28impl<'a> TransformResolutionContext<'a> {
29    pub(super) fn from_resolution_inputs(
30        resolution_inputs: &'a OmenaQueryStyleResolutionInputsV0,
31    ) -> Self {
32        Self {
33            package_manifests: resolution_inputs.package_manifests.as_slice(),
34            bundler_path_mappings: resolution_inputs.bundler_path_mappings.as_slice(),
35            tsconfig_path_mappings: resolution_inputs.tsconfig_path_mappings.as_slice(),
36            disk_style_path_identities: resolution_inputs.disk_style_path_identities.as_slice(),
37        }
38    }
39
40    pub(super) fn resolve_style_module_source(
41        self,
42        from_style_path: &str,
43        source: &str,
44        available_style_paths: &BTreeSet<&str>,
45    ) -> Option<String> {
46        self.resolve_style_module(from_style_path, source, available_style_paths)
47            .resolved_style_path
48    }
49
50    pub(super) fn resolve_style_module(
51        self,
52        from_style_path: &str,
53        source: &str,
54        available_style_paths: &BTreeSet<&str>,
55    ) -> OmenaResolverStyleModuleResolutionV0 {
56        let load_path_roots = super::super::collect_load_path_roots(available_style_paths);
57        let load_path_root_refs = load_path_roots
58            .iter()
59            .map(String::as_str)
60            .collect::<Vec<_>>();
61        let resolver_package_manifests = self
62            .package_manifests
63            .iter()
64            .map(|manifest| OmenaResolverStylePackageManifestV0 {
65                package_json_path: manifest.package_json_path.clone(),
66                package_json_source: manifest.package_json_source.clone(),
67            })
68            .collect::<Vec<_>>();
69        summarize_omena_resolver_style_module_resolution_with_confirmation_inputs(
70            from_style_path,
71            source,
72            available_style_paths,
73            self.disk_style_path_identities,
74            &resolver_package_manifests,
75            self.bundler_path_mappings,
76            self.tsconfig_path_mappings,
77            &load_path_root_refs,
78            OmenaResolverStyleModuleConfirmationOptionsV0 {
79                allow_disk_confirmation: true,
80                ..OmenaResolverStyleModuleConfirmationOptionsV0::default()
81            },
82        )
83    }
84}
85
86pub(super) fn merge_transform_context(
87    mut merged: TransformExecutionContextV0,
88    context: &TransformExecutionContextV0,
89) -> TransformExecutionContextV0 {
90    merged.drop_dark_mode_media_queries =
91        merged.drop_dark_mode_media_queries || context.drop_dark_mode_media_queries;
92    if context.vendor_prefix_policy.is_some() {
93        merged.vendor_prefix_policy = context.vendor_prefix_policy;
94    }
95    if context.supports_target_capability.is_some() {
96        merged.supports_target_capability = context.supports_target_capability;
97    }
98    merge_context_list(
99        &mut merged.reachable_class_names,
100        &context.reachable_class_names,
101    );
102    merge_context_list(
103        &mut merged.reachable_keyframe_names,
104        &context.reachable_keyframe_names,
105    );
106    merge_context_list(
107        &mut merged.reachable_value_names,
108        &context.reachable_value_names,
109    );
110    merge_context_list(
111        &mut merged.reachable_custom_property_names,
112        &context.reachable_custom_property_names,
113    );
114
115    if context.scss_module_evaluation.is_some() {
116        merged.scss_module_evaluation = context.scss_module_evaluation.clone();
117    }
118    if context.less_module_evaluation.is_some() {
119        merged.less_module_evaluation = context.less_module_evaluation.clone();
120    }
121    if !context.import_inlines.is_empty() {
122        merge_context_records_by_key(
123            &mut merged.import_inlines,
124            &context.import_inlines,
125            |inline| inline.import_source.as_str(),
126        );
127    }
128    if !context.class_name_rewrites.is_empty() {
129        merge_class_context_records_by_key(
130            &mut merged.class_name_rewrites,
131            &context.class_name_rewrites,
132            |rewrite| rewrite.original_name.as_str(),
133        );
134    }
135    if !context.css_module_composes_resolutions.is_empty() {
136        merge_class_context_records_by_key(
137            &mut merged.css_module_composes_resolutions,
138            &context.css_module_composes_resolutions,
139            |resolution| resolution.local_class_name.as_str(),
140        );
141    }
142    if !context.css_module_value_resolutions.is_empty() {
143        merge_context_records_by_key(
144            &mut merged.css_module_value_resolutions,
145            &context.css_module_value_resolutions,
146            |resolution| resolution.local_name.as_str(),
147        );
148    }
149    if !context.design_token_routes.is_empty() {
150        merge_context_records_by_key(
151            &mut merged.design_token_routes,
152            &context.design_token_routes,
153            |route| route.token_name.as_str(),
154        );
155    }
156    if context.cascade_environment.is_some() {
157        merged.cascade_environment = context.cascade_environment.clone();
158    }
159
160    expand_reachable_class_names_through_composes(&mut merged);
161    merged
162}
163
164fn expand_reachable_class_names_through_composes(context: &mut TransformExecutionContextV0) {
165    let mut changed = true;
166    while changed {
167        changed = false;
168        for resolution in &context.css_module_composes_resolutions {
169            if !class_name_is_reachable(
170                &resolution.local_class_name,
171                &context.reachable_class_names,
172            ) {
173                continue;
174            }
175            for exported_class_name in &resolution.exported_class_names {
176                if !class_name_is_reachable(exported_class_name, &context.reachable_class_names) {
177                    context
178                        .reachable_class_names
179                        .push(exported_class_name.clone());
180                    changed = true;
181                }
182            }
183        }
184    }
185    context.reachable_class_names.sort();
186    context.reachable_class_names.dedup();
187}
188
189fn class_name_is_reachable(class_name: &str, reachable_class_names: &[String]) -> bool {
190    let Some(normalized_class_name) = normalize_reachable_class_name(class_name) else {
191        return false;
192    };
193    reachable_class_names
194        .iter()
195        .filter_map(|name| normalize_reachable_class_name(name))
196        .any(|name| css_identifier_names_match(name, normalized_class_name))
197}
198
199fn normalize_reachable_class_name(name: &str) -> Option<&str> {
200    let name = name.trim().strip_prefix('.').unwrap_or(name.trim());
201    (!name.is_empty()).then_some(name)
202}
203
204pub(super) fn css_identifier_names_match(left: &str, right: &str) -> bool {
205    ClassNameV0::new(left).same_as(&ClassNameV0::new(right))
206}
207
208pub(super) fn merge_target_options_transform_context(
209    context: &TransformExecutionContextV0,
210    target_options: OmenaQueryTargetTransformOptionsV0,
211) -> TransformExecutionContextV0 {
212    let mut merged = context.clone();
213    if target_options.drop_dark_mode_media_queries {
214        merged.drop_dark_mode_media_queries = true;
215    }
216    merged
217}
218
219pub(super) fn find_target_style_source<'a>(
220    target_style_path: &str,
221    style_sources: &'a [OmenaQueryStyleSourceInputV0],
222) -> Option<&'a str> {
223    style_sources
224        .iter()
225        .find(|source| source.style_path == target_style_path)
226        .map(|source| source.style_source.as_str())
227}
228
229pub(super) fn summarize_omena_query_transform_context_from_sources_with_resolution_context<'a>(
230    target_style_path: &str,
231    styles: impl IntoIterator<Item = (&'a str, &'a str)>,
232    resolution_context: TransformResolutionContext<'_>,
233) -> OmenaQueryTransformContextFromSourcesSummaryV0 {
234    derive_omena_query_transform_context_from_sources_with_resolution_context(
235        target_style_path,
236        styles,
237        resolution_context,
238    )
239    .summary
240}
241
242pub(super) struct OmenaQueryTransformContextFromSourcesDerivationV0 {
243    pub(super) summary: OmenaQueryTransformContextFromSourcesSummaryV0,
244    pub(super) style_fact_entries: Vec<OmenaQueryStyleFactEntry>,
245}
246
247pub(super) fn derive_omena_query_transform_context_from_sources_with_resolution_context<'a>(
248    target_style_path: &str,
249    styles: impl IntoIterator<Item = (&'a str, &'a str)>,
250    resolution_context: TransformResolutionContext<'_>,
251) -> OmenaQueryTransformContextFromSourcesDerivationV0 {
252    let style_sources = styles.into_iter().collect::<Vec<_>>();
253    let style_count = style_sources.len();
254    let style_fact_entries = collect_omena_query_style_fact_entries(style_sources.as_slice());
255    let source_by_path = style_sources
256        .iter()
257        .map(|(style_path, style_source)| ((*style_path).to_string(), (*style_source).to_string()))
258        .collect::<BTreeMap<_, _>>();
259    let available_style_paths = style_fact_entries
260        .iter()
261        .map(|entry| entry.style_path.as_str())
262        .collect::<BTreeSet<_>>();
263    let known_style_paths = available_style_paths
264        .iter()
265        .map(|path| normalize_omena_query_style_path(path))
266        .collect::<Vec<_>>();
267    let canonical_target_style_path =
268        resolve_omena_query_style_path_against_known(target_style_path, &known_style_paths)
269            .unwrap_or_else(|| normalize_omena_query_style_path(target_style_path));
270    let target_entry = style_fact_entries.iter().find(|entry| {
271        normalize_omena_query_style_path(entry.style_path.as_str()) == canonical_target_style_path
272    });
273
274    let mut context = TransformExecutionContextV0::default();
275
276    if let Some(entry) = target_entry {
277        context.import_inlines = derive_import_inlines_for_transform_context(
278            entry,
279            &style_fact_entries,
280            &available_style_paths,
281            &source_by_path,
282            resolution_context,
283        );
284        let scss_module_uses = derive_static_scss_module_use_evaluations_for_transform_context(
285            entry,
286            &available_style_paths,
287            &source_by_path,
288            resolution_context,
289        );
290        match omena_parser_dialect_for_style_path(entry.style_path.as_str()) {
291            OmenaParserStyleDialect::Scss | OmenaParserStyleDialect::Sass => {
292                let dialect = omena_parser_dialect_for_style_path(entry.style_path.as_str());
293                context.scss_module_evaluation =
294                    derive_static_stylesheet_module_evaluation_for_transform_context(
295                        entry.style_source.as_str(),
296                        dialect,
297                        &context.import_inlines,
298                        &scss_module_uses,
299                    );
300            }
301            OmenaParserStyleDialect::Less => {
302                context.less_module_evaluation =
303                    derive_static_stylesheet_module_evaluation_for_transform_context(
304                        entry.style_source.as_str(),
305                        OmenaParserStyleDialect::Less,
306                        &context.import_inlines,
307                        &[],
308                    );
309            }
310            OmenaParserStyleDialect::Css => {}
311        }
312        context.class_name_rewrites = derive_class_name_rewrites_for_transform_context(entry);
313        context.css_module_composes_resolutions =
314            derive_css_module_composes_resolutions_for_transform_context(
315                entry,
316                &style_fact_entries,
317                &available_style_paths,
318                resolution_context,
319            );
320        context.css_module_value_resolutions =
321            derive_css_module_value_resolutions_for_transform_context(
322                entry,
323                &style_fact_entries,
324                &available_style_paths,
325                &source_by_path,
326                resolution_context,
327            );
328        context.design_token_routes = derive_design_token_routes_for_transform_context(
329            entry,
330            &style_fact_entries,
331            resolution_context,
332        );
333    }
334
335    let summary = OmenaQueryTransformContextFromSourcesSummaryV0 {
336        schema_version: "0",
337        product: "omena-query.transform-context",
338        target_style_path: target_style_path.to_string(),
339        style_count,
340        import_inline_count: context.import_inlines.len(),
341        class_name_rewrite_count: context.class_name_rewrites.len(),
342        css_module_composes_resolution_count: context.css_module_composes_resolutions.len(),
343        css_module_value_resolution_count: context.css_module_value_resolutions.len(),
344        design_token_route_count: context.design_token_routes.len(),
345        reachable_class_name_count: context.reachable_class_names.len(),
346        reachable_keyframe_name_count: context.reachable_keyframe_names.len(),
347        reachable_value_name_count: context.reachable_value_names.len(),
348        reachable_custom_property_name_count: context.reachable_custom_property_names.len(),
349        context,
350        ready_surfaces: vec![
351            "transformContextProducer",
352            "stylesheetModuleEvaluationProducer",
353            "cssModuleClassRewriteProducer",
354            "cssModuleComposesResolutionProducer",
355            "cssModuleValueResolutionProducer",
356            "designTokenRouteProducer",
357            "transitiveImportInlineProducer",
358        ],
359    };
360    OmenaQueryTransformContextFromSourcesDerivationV0 {
361        summary,
362        style_fact_entries,
363    }
364}
365
366pub(super) struct OmenaQueryEngineInputTransformContextDerivationV0 {
367    pub(super) module_reachability: OmenaQueryEngineInputModuleReachabilityV0,
368    pub(super) reachability_precision: Option<FactPrecision>,
369    pub(super) closed_set_enumeration_candidate: bool,
370}
371
372pub fn summarize_omena_query_transform_context_from_engine_input(
373    input: &EngineInputV2,
374    target_style_path: &str,
375    closed_world_requested: bool,
376) -> OmenaQueryTransformContextFromEngineInputSummaryV0 {
377    derive_omena_query_transform_context_from_engine_input(
378        input,
379        target_style_path,
380        closed_world_requested,
381    )
382    .module_reachability
383    .into_summary()
384}
385
386pub fn derive_omena_query_module_reachability_from_engine_input(
387    input: &EngineInputV2,
388    target_style_path: &str,
389    closed_world_requested: bool,
390) -> OmenaQueryEngineInputModuleReachabilityV0 {
391    derive_omena_query_transform_context_from_engine_input(
392        input,
393        target_style_path,
394        closed_world_requested,
395    )
396    .module_reachability
397}
398
399pub(super) fn derive_omena_query_transform_context_from_engine_input(
400    input: &EngineInputV2,
401    target_style_path: &str,
402    closed_world_requested: bool,
403) -> OmenaQueryEngineInputTransformContextDerivationV0 {
404    let mut known_style_paths = input
405        .styles
406        .iter()
407        .map(|style| normalize_omena_query_style_path(style.file_path.as_str()))
408        .collect::<Vec<_>>();
409    known_style_paths.sort();
410    known_style_paths.dedup();
411    let canonical_target_style_path = resolve_omena_query_style_path_against_known(
412        target_style_path,
413        known_style_paths.as_slice(),
414    )
415    .unwrap_or_else(|| normalize_omena_query_style_path(target_style_path));
416    let (projection_summary, projection_precisions) =
417        omena_query_core::summarize_omena_query_expression_domain_selector_projection_with_precision_and_style_path_resolver(
418            input,
419            resolve_omena_query_style_path_against_known,
420        );
421    let precision_by_projection = projection_precisions
422        .iter()
423        .map(|entry| {
424            (
425                (entry.graph_id.as_str(), entry.node_id.as_str()),
426                entry.precision,
427            )
428        })
429        .collect::<BTreeMap<_, _>>();
430    let mut reachable_class_names = BTreeSet::new();
431    let mut reachability_sources = Vec::new();
432    let mut reachability_precision_ceiling: Option<FactPrecision> = None;
433    let mut closed_set_enumeration_candidate = true;
434    let mut selected_projection_count = 0_usize;
435    let mut targeted_class_names_by_style_path = BTreeMap::<String, BTreeSet<String>>::new();
436    let mut targeted_projection_count_by_style_path = BTreeMap::<String, usize>::new();
437    let mut unattributed_class_names = BTreeSet::new();
438    let mut unattributed_projection_count = 0_usize;
439    let mut projected_class_names = BTreeSet::new();
440
441    for projection in &projection_summary.projections {
442        projected_class_names.extend(projection.selector_names.iter().cloned());
443        if projection.target_style_paths.is_empty() {
444            unattributed_projection_count += 1;
445            unattributed_class_names.extend(projection.selector_names.iter().cloned());
446        } else {
447            let mut has_unresolved_target = false;
448            for target_style_path in projection
449                .target_style_paths
450                .iter()
451                .collect::<BTreeSet<_>>()
452            {
453                let Some(target_style_path) = resolve_omena_query_style_path_against_known(
454                    target_style_path,
455                    known_style_paths.as_slice(),
456                ) else {
457                    let target_style_path = normalize_omena_query_style_path(target_style_path);
458                    *targeted_projection_count_by_style_path
459                        .entry(target_style_path.clone())
460                        .or_default() += 1;
461                    targeted_class_names_by_style_path
462                        .entry(target_style_path)
463                        .or_default()
464                        .extend(projection.selector_names.iter().cloned());
465                    has_unresolved_target = true;
466                    continue;
467                };
468                *targeted_projection_count_by_style_path
469                    .entry(target_style_path.clone())
470                    .or_default() += 1;
471                targeted_class_names_by_style_path
472                    .entry(target_style_path)
473                    .or_default()
474                    .extend(projection.selector_names.iter().cloned());
475            }
476            if has_unresolved_target {
477                unattributed_projection_count += 1;
478                unattributed_class_names.extend(projection.selector_names.iter().cloned());
479            }
480        }
481        let projection_targets_current_style = projection.target_style_paths.is_empty()
482            || projection.target_style_paths.iter().any(|path| {
483                resolve_omena_query_style_path_against_known(path, known_style_paths.as_slice())
484                    .is_none_or(|path| path == canonical_target_style_path)
485            });
486        if projection_targets_current_style {
487            selected_projection_count += 1;
488            closed_set_enumeration_candidate &=
489                matches!(projection.value_kind, "bottom" | "exact" | "finiteSet");
490            reachable_class_names.extend(projection.selector_names.iter().cloned());
491            let projection_precision = precision_by_projection
492                .get(&(projection.graph_id.as_str(), projection.node_id.as_str()))
493                .copied()
494                .unwrap_or(FactPrecision::Unknown);
495            reachability_precision_ceiling = Some(
496                reachability_precision_ceiling.map_or(projection_precision, |current| {
497                    current.bounded_by(projection_precision)
498                }),
499            );
500            reachability_sources.push(OmenaQuerySemanticReachabilitySourceV0 {
501                graph_id: projection.graph_id.clone(),
502                file_path: projection.file_path.clone(),
503                node_id: projection.node_id.clone(),
504                target_style_paths: projection.target_style_paths.clone(),
505                value_kind: projection.value_kind,
506                reduced_product: projection.reduced_product.clone(),
507                selector_names: projection.selector_names.clone(),
508                certainty: projection.certainty,
509            });
510        }
511    }
512
513    let semantic_context = TransformExecutionContextV0 {
514        reachable_class_names: reachable_class_names.into_iter().collect(),
515        ..TransformExecutionContextV0::default()
516    };
517    let style_sources = input
518        .styles
519        .iter()
520        .filter_map(|style| {
521            style
522                .source
523                .as_deref()
524                .map(|source| (style.file_path.as_str(), source))
525        })
526        .collect::<Vec<_>>();
527    let source_context_summary = (!style_sources.is_empty()).then(|| {
528        super::summarize_omena_query_transform_context_from_sources(
529            target_style_path,
530            style_sources,
531            &[],
532        )
533    });
534    let context = if let Some(source_context_summary) = &source_context_summary {
535        merge_transform_context(source_context_summary.context.clone(), &semantic_context)
536    } else {
537        semantic_context
538    };
539
540    let mut ready_surfaces = vec![
541        "expressionDomainSelectorProjection",
542        "semanticReachabilityTransformContext",
543    ];
544    if source_context_summary.is_some() {
545        ready_surfaces.push("engineInputStyleSourceTransformContext");
546    }
547
548    let summary = OmenaQueryTransformContextFromEngineInputSummaryV0 {
549        schema_version: "0",
550        product: "omena-query.transform-context-from-engine-input",
551        input_version: input.version.clone(),
552        target_style_path: target_style_path.to_string(),
553        closed_world_requested,
554        style_source_count: source_context_summary
555            .as_ref()
556            .map_or(0, |summary| summary.style_count),
557        projection_count: projection_summary.projection_count,
558        selected_projection_count: reachability_sources.len(),
559        import_inline_count: context.import_inlines.len(),
560        class_name_rewrite_count: context.class_name_rewrites.len(),
561        css_module_composes_resolution_count: context.css_module_composes_resolutions.len(),
562        css_module_value_resolution_count: context.css_module_value_resolutions.len(),
563        design_token_route_count: context.design_token_routes.len(),
564        reachable_class_name_count: context.reachable_class_names.len(),
565        reachable_keyframe_name_count: context.reachable_keyframe_names.len(),
566        reachable_value_name_count: context.reachable_value_names.len(),
567        reachable_custom_property_name_count: context.reachable_custom_property_names.len(),
568        reachability_sources,
569        context,
570        ready_surfaces,
571    };
572    let targeted_class_names_by_style_path = targeted_class_names_by_style_path
573        .into_iter()
574        .map(|(path, names)| (path, names.into_iter().collect()))
575        .collect();
576    let declared_class_names_by_style_path = input
577        .styles
578        .iter()
579        .map(|style| {
580            let mut class_names = style
581                .document
582                .selectors
583                .iter()
584                .filter_map(|selector| {
585                    let name = selector
586                        .canonical_name
587                        .as_deref()
588                        .unwrap_or(selector.name.as_str())
589                        .trim();
590                    let name = name.strip_prefix('.').unwrap_or(name);
591                    (!name.is_empty()).then(|| name.to_string())
592                })
593                .collect::<Vec<_>>();
594            class_names.sort();
595            class_names.dedup();
596            (
597                normalize_omena_query_style_path(style.file_path.as_str()),
598                class_names,
599            )
600        })
601        .collect::<BTreeMap<_, _>>();
602    let module_reachability = OmenaQueryEngineInputModuleReachabilityV0::new(
603        summary,
604        known_style_paths,
605        OmenaQueryEngineInputModuleAttributionV0::new(
606            declared_class_names_by_style_path,
607            targeted_class_names_by_style_path,
608            targeted_projection_count_by_style_path,
609        ),
610        unattributed_class_names.into_iter().collect(),
611        unattributed_projection_count,
612        projected_class_names.into_iter().collect(),
613    );
614
615    OmenaQueryEngineInputTransformContextDerivationV0 {
616        reachability_precision: reachability_precision_ceiling,
617        closed_set_enumeration_candidate: selected_projection_count > 0
618            && closed_set_enumeration_candidate,
619        module_reachability,
620    }
621}
622
623fn merge_context_list(target: &mut Vec<String>, additional: &[String]) {
624    for item in additional {
625        if !target.contains(item) {
626            target.push(item.clone());
627        }
628    }
629    target.sort();
630}
631
632fn merge_context_records_by_key<T, F>(target: &mut Vec<T>, overrides: &[T], key: F)
633where
634    T: Clone,
635    F: Fn(&T) -> &str,
636{
637    for item in overrides {
638        let item_key = key(item);
639        if let Some(existing) = target.iter_mut().find(|existing| key(existing) == item_key) {
640            *existing = item.clone();
641        } else {
642            target.push(item.clone());
643        }
644    }
645    target.sort_by(|left, right| key(left).cmp(key(right)));
646}
647
648fn merge_class_context_records_by_key<T, F>(target: &mut Vec<T>, overrides: &[T], key: F)
649where
650    T: Clone,
651    F: Fn(&T) -> &str,
652{
653    // Precedence follows the caller's module partition and then source declaration order.
654    // An explicit record replaces a derived record under the same canonical class key;
655    // raw spelling order never selects a winner. A future module-qualified compound key
656    // adds module identity to this normalized name component rather than decoding it again.
657    let mut merged = Vec::with_capacity(target.len() + overrides.len());
658    append_class_context_records_first_witness(&mut merged, overrides, &key);
659    append_class_context_records_first_witness(&mut merged, target, &key);
660    *target = merged;
661}
662
663fn append_class_context_records_first_witness<T, F>(target: &mut Vec<T>, candidates: &[T], key: &F)
664where
665    T: Clone,
666    F: Fn(&T) -> &str,
667{
668    let occupied_canonical_keys = target
669        .iter()
670        .map(|existing| ClassNameV0::new(key(existing)).canonical_key())
671        .collect::<BTreeSet<_>>();
672    let mut admitted_raw_keys = target
673        .iter()
674        .map(|existing| key(existing).to_string())
675        .collect::<BTreeSet<_>>();
676    for item in candidates {
677        let item_key = ClassNameV0::new(key(item));
678        if !occupied_canonical_keys.contains(&item_key.canonical_key())
679            && admitted_raw_keys.insert(key(item).to_string())
680        {
681            target.push(item.clone());
682        }
683    }
684}
685
686pub(super) fn merge_module_css_module_contexts_first_witness(
687    left: &[TransformModuleCssModuleContextV0],
688    right: &[TransformModuleCssModuleContextV0],
689) -> Vec<TransformModuleCssModuleContextV0> {
690    let mut merged = Vec::<TransformModuleCssModuleContextV0>::new();
691    for context in left.iter().chain(right) {
692        let target = if let Some(index) = merged
693            .iter()
694            .position(|candidate| candidate.module_instance == context.module_instance)
695        {
696            &mut merged[index]
697        } else {
698            let index = merged.len();
699            merged.push(TransformModuleCssModuleContextV0::new(
700                context.module_instance.clone(),
701            ));
702            &mut merged[index]
703        };
704        append_class_context_records_first_witness(
705            &mut target.class_name_rewrites,
706            context.class_name_rewrites.as_slice(),
707            &|rewrite: &TransformClassNameRewriteV0| rewrite.original_name.as_str(),
708        );
709        append_class_context_records_first_witness(
710            &mut target.composes_resolutions,
711            context.composes_resolutions.as_slice(),
712            &|resolution: &TransformCssModuleComposesResolutionV0| {
713                resolution.local_class_name.as_str()
714            },
715        );
716    }
717    merged
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723    use omena_query_transform_runner::{
724        TransformClassNameRewriteV0, TransformCssModuleComposesResolutionV0,
725        TransformCssModuleValueResolutionV0, TransformDesignTokenRouteV0, TransformImportInlineV0,
726    };
727
728    fn class_rewrite(original_name: &str, rewritten_name: &str) -> TransformClassNameRewriteV0 {
729        TransformClassNameRewriteV0 {
730            original_name: original_name.to_string(),
731            rewritten_name: rewritten_name.to_string(),
732        }
733    }
734
735    #[test]
736    fn explicit_class_rewrite_precedence_ignores_escape_spelling_order() {
737        for (derived_name, explicit_name) in [(r#"\E9 tat"#, "état"), ("état", r#"\E9 tat"#)] {
738            let derived = TransformExecutionContextV0 {
739                class_name_rewrites: vec![class_rewrite(derived_name, "_derived")],
740                css_module_composes_resolutions: vec![TransformCssModuleComposesResolutionV0 {
741                    local_class_name: derived_name.to_string(),
742                    exported_class_names: vec!["derived".to_string()],
743                }],
744                ..TransformExecutionContextV0::default()
745            };
746            let explicit = TransformExecutionContextV0 {
747                class_name_rewrites: vec![class_rewrite(explicit_name, "_explicit")],
748                css_module_composes_resolutions: vec![TransformCssModuleComposesResolutionV0 {
749                    local_class_name: explicit_name.to_string(),
750                    exported_class_names: vec!["explicit".to_string()],
751                }],
752                ..TransformExecutionContextV0::default()
753            };
754
755            let merged = merge_transform_context(derived, &explicit);
756
757            assert_eq!(
758                merged.class_name_rewrites,
759                vec![class_rewrite(explicit_name, "_explicit")]
760            );
761            assert_eq!(
762                merged.css_module_composes_resolutions,
763                vec![TransformCssModuleComposesResolutionV0 {
764                    local_class_name: explicit_name.to_string(),
765                    exported_class_names: vec!["explicit".to_string()],
766                }]
767            );
768            println!(
769                "canonical-merge derived={derived_name:?} explicit={explicit_name:?} winner={:?}",
770                merged.class_name_rewrites
771            );
772        }
773    }
774
775    #[test]
776    fn class_merge_normalization_does_not_apply_to_other_context_keys() {
777        let derived = TransformExecutionContextV0 {
778            import_inlines: vec![TransformImportInlineV0 {
779                import_source: r#"\E9 tat"#.to_string(),
780                replacement_css: "derived".to_string(),
781            }],
782            css_module_value_resolutions: vec![TransformCssModuleValueResolutionV0 {
783                local_name: r#"\E9 tat"#.to_string(),
784                resolved_value: "derived".to_string(),
785            }],
786            design_token_routes: vec![TransformDesignTokenRouteV0 {
787                token_name: r#"\E9 tat"#.to_string(),
788                routed_value: "derived".to_string(),
789            }],
790            ..TransformExecutionContextV0::default()
791        };
792        let explicit = TransformExecutionContextV0 {
793            import_inlines: vec![TransformImportInlineV0 {
794                import_source: "état".to_string(),
795                replacement_css: "explicit".to_string(),
796            }],
797            css_module_value_resolutions: vec![TransformCssModuleValueResolutionV0 {
798                local_name: "état".to_string(),
799                resolved_value: "explicit".to_string(),
800            }],
801            design_token_routes: vec![TransformDesignTokenRouteV0 {
802                token_name: "état".to_string(),
803                routed_value: "explicit".to_string(),
804            }],
805            ..TransformExecutionContextV0::default()
806        };
807
808        let merged = merge_transform_context(derived, &explicit);
809
810        assert_eq!(merged.import_inlines.len(), 2);
811        assert_eq!(merged.css_module_value_resolutions.len(), 2);
812        assert_eq!(merged.design_token_routes.len(), 2);
813    }
814
815    fn module_context(
816        module: &str,
817        original_name: &str,
818        rewritten_name: &str,
819    ) -> TransformModuleCssModuleContextV0 {
820        TransformModuleCssModuleContextV0::new(omena_parser::ModuleInstanceKeyV0::unconfigured(
821            omena_parser::ModuleIdV0::new(module),
822        ))
823        .with_class_name_rewrites(vec![class_rewrite(original_name, rewritten_name)])
824    }
825
826    fn merge_module_css_module_contexts_last_witness(
827        left: &[TransformModuleCssModuleContextV0],
828        right: &[TransformModuleCssModuleContextV0],
829    ) -> Vec<TransformModuleCssModuleContextV0> {
830        merge_module_css_module_contexts_first_witness(right, left)
831    }
832
833    #[test]
834    fn module_context_explicit_precedence_ignores_escape_spelling_order() {
835        for (derived_name, explicit_name) in [(r#"\E9 tat"#, "état"), ("état", r#"\E9 tat"#)] {
836            let explicit = vec![module_context(
837                "src/app.module.css",
838                explicit_name,
839                "_explicit",
840            )];
841            let derived = vec![module_context(
842                "src/app.module.css",
843                derived_name,
844                "_derived",
845            )];
846
847            let merged = merge_module_css_module_contexts_first_witness(&explicit, &derived);
848
849            // FALSIFIER: raw-spelling equality admits both records, or a
850            // derived-first merge selects `_derived`. Both states are emitted
851            // by these two module-context inputs, so the arm is not structural.
852            assert_eq!(
853                merged,
854                vec![module_context(
855                    "src/app.module.css",
856                    explicit_name,
857                    "_explicit",
858                )]
859            );
860            println!(
861                "module-canonical-merge derived={derived_name:?} explicit={explicit_name:?} winner={:?}",
862                merged[0].class_name_rewrites
863            );
864        }
865    }
866
867    #[test]
868    fn module_context_first_witness_merge_obeys_left_regular_band_laws() {
869        let u = vec![
870            module_context("src/a.module.css", "shared", "_u"),
871            module_context("src/b.module.css", "own", "_b"),
872        ];
873        let v = vec![
874            module_context("src/a.module.css", "shared", "_v"),
875            module_context("src/c.module.css", "own", "_c"),
876        ];
877        let w = vec![module_context("src/a.module.css", "third", "_w")];
878
879        let uv = merge_module_css_module_contexts_first_witness(&u, &v);
880        assert_eq!(
881            merge_module_css_module_contexts_first_witness(&uv, &w),
882            merge_module_css_module_contexts_first_witness(
883                &u,
884                &merge_module_css_module_contexts_first_witness(&v, &w),
885            ),
886            "associativity"
887        );
888        assert_eq!(
889            merge_module_css_module_contexts_first_witness(&u, &u),
890            u,
891            "idempotence"
892        );
893        assert_eq!(
894            merge_module_css_module_contexts_first_witness(&uv, &u),
895            uv,
896            "left-regular-band absorption"
897        );
898
899        let last_wins_uv = merge_module_css_module_contexts_last_witness(&u, &v);
900        let last_wins_uv_then_u = merge_module_css_module_contexts_last_witness(&last_wins_uv, &u);
901        assert_ne!(
902            last_wins_uv_then_u, last_wins_uv,
903            "a last-wins variant must fail the absorption control"
904        );
905    }
906}