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