Skip to main content

stylex_css_parser/at_queries/
media_query_transform.rs

1/*!
2Media query transformation functionality.
3
4Implements the "last media query wins" transformation logic for CSS-in-JS.
5This ensures proper specificity handling when multiple media queries target the same properties.
6
7This implementation provides media query transformation:
81. DFS traversal of the style object
92. At depth >= 1, apply negation-based media query transformation
103. Use pure AST manipulation, not range-based logic
11*/
12
13use super::media_query::{
14  MediaAndRules, MediaNotRule, MediaOrRules, MediaQuery, MediaQueryRule, MediaRuleValue,
15};
16use rustc_hash::FxHashMap;
17use swc_core::atoms::Wtf8Atom;
18use swc_core::common::DUMMY_SP;
19use swc_core::ecma::ast::{Expr, KeyValueProp, ObjectLit, Prop, PropName, PropOrSpread, Str};
20
21/// Helper function to extract key as string from KeyValueProp
22fn key_value_to_str(key_value: &KeyValueProp) -> String {
23  match &key_value.key {
24    PropName::Str(s) => s
25      .value
26      .as_atom()
27      .expect("Failed to convert Str to Atom")
28      .to_string(),
29    PropName::Ident(id) => id.sym.to_string(),
30    _ => String::new(),
31  }
32}
33
34/// Main entry point - equivalent to lastMediaQueryWinsTransform in JS
35pub fn last_media_query_wins_transform(styles: &[KeyValueProp]) -> Vec<KeyValueProp> {
36  dfs_process_queries_with_depth(styles, 0)
37}
38
39/// Internal helper function for backwards compatibility with existing tests
40/// This preserves the old Vec<MediaQuery> -> Vec<MediaQuery> signature for internal use
41pub fn last_media_query_wins_transform_internal(queries: Vec<MediaQuery>) -> Vec<MediaQuery> {
42  // For now, just return the queries unchanged since the main tests are using KeyValueProp
43  // The real transformation happens in last_media_query_wins_transform with KeyValueProp input
44  queries
45}
46
47/// Helper function to create ObjectLit from key-value pairs
48fn create_object_from_key_values(key_values: Vec<KeyValueProp>) -> ObjectLit {
49  let props = key_values
50    .into_iter()
51    .map(|kv| PropOrSpread::Prop(Box::new(Prop::KeyValue(kv))))
52    .collect();
53
54  ObjectLit {
55    span: DUMMY_SP,
56    props,
57  }
58}
59
60/// DFS traversal with depth tracking - matches JS dfsProcessQueries exactly
61fn dfs_process_queries_with_depth(obj: &[KeyValueProp], depth: u32) -> Vec<KeyValueProp> {
62  let mut result = Vec::new();
63
64  for prop in obj {
65    match &*prop.value {
66      Expr::Array(_) => {
67        // Ignore `firstThatWorks` arrays - pass through unchanged
68        result.push(prop.clone());
69      },
70      Expr::Object(obj_lit) => {
71        // Extract key-value pairs from the object
72        let mut key_values = Vec::new();
73        for obj_prop in &obj_lit.props {
74          if let PropOrSpread::Prop(p) = obj_prop
75            && let Prop::KeyValue(kv) = &**p
76          {
77            key_values.push(kv.clone());
78          }
79        }
80
81        // Recursively process the object at depth + 1
82        let processed_values = dfs_process_queries_with_depth(&key_values, depth + 1);
83        let transformed_obj = create_object_from_key_values(processed_values);
84
85        result.push(KeyValueProp {
86          key: prop.key.clone(),
87          value: Box::new(Expr::Object(transformed_obj)),
88        });
89      },
90      _ => {
91        // Non-object values pass through unchanged
92        result.push(prop.clone());
93      },
94    }
95  }
96
97  // Apply media query transformation if at depth >= 1
98  if depth >= 1 {
99    transform_media_queries_in_result(result)
100  } else {
101    result
102  }
103}
104
105/// Transform media queries in the result object - matches JS logic exactly
106fn transform_media_queries_in_result(result: Vec<KeyValueProp>) -> Vec<KeyValueProp> {
107  // Check if we have any media queries
108  let has_media_queries = result.iter().any(|kv| {
109    let key = key_value_to_str(kv);
110    key.starts_with("@media ")
111  });
112
113  if !has_media_queries {
114    return result;
115  }
116
117  // Collect all media query keys
118  let media_keys: Vec<String> = result
119    .iter()
120    .filter_map(|kv| {
121      let key = key_value_to_str(kv);
122      if key.starts_with("@media ") {
123        Some(key)
124      } else {
125        None
126      }
127    })
128    .collect();
129
130  if media_keys.len() <= 1 {
131    return result;
132  }
133
134  // Check if all media queries are disjoint ranges - if so, just normalize syntax
135  if are_media_queries_disjoint(&media_keys) {
136    return normalize_media_query_syntax(result);
137  }
138
139  // Build negations array - JS logic: for i from length-1 down to 1
140  let mut negations = Vec::new();
141  let mut accumulated_negations: Vec<Vec<MediaQuery>> = Vec::new();
142
143  for i in (1..media_keys.len()).rev() {
144    if let Ok(mq) = MediaQuery::parser().parse_to_end(&media_keys[i]) {
145      negations.push(mq);
146      accumulated_negations.push(negations.clone());
147    }
148  }
149  accumulated_negations.reverse();
150  accumulated_negations.push(Vec::new()); // Empty negations for the last query
151
152  // Transform each media query
153  let mut result_map = FxHashMap::default();
154
155  // First, build a map of existing non-media properties
156  for kv in &result {
157    let key = key_value_to_str(kv);
158    if !key.starts_with("@media ") {
159      result_map.insert(key, kv.clone());
160    }
161  }
162
163  // Process media queries with negations
164  for (i, media_key) in media_keys.iter().enumerate() {
165    if let Some(original_kv) = result.iter().find(|kv| key_value_to_str(kv) == *media_key)
166      && let Ok(base_mq) = MediaQuery::parser().parse_to_end(media_key)
167    {
168      let mut reversed_negations = accumulated_negations[i].clone();
169      reversed_negations.reverse();
170
171      let combined_query = combine_media_query_with_negations(base_mq, reversed_negations);
172      let new_media_key = combined_query.to_string();
173
174      result_map.insert(
175        new_media_key,
176        KeyValueProp {
177          key: PropName::Str(Str {
178            span: DUMMY_SP,
179            value: Wtf8Atom::from(combined_query.to_string()),
180            raw: None,
181          }),
182          value: original_kv.value.clone(),
183        },
184      );
185    }
186  }
187
188  // Convert back to Vec, preserving order (non-media first, then media)
189  let mut final_result = Vec::new();
190
191  // Add non-media properties first
192  for kv in &result {
193    let key = key_value_to_str(kv);
194    if !key.starts_with("@media ") {
195      final_result.push(kv.clone());
196    }
197  }
198
199  // Add transformed media queries
200  for media_key in &media_keys {
201    if let Ok(base_mq) = MediaQuery::parser().parse_to_end(media_key) {
202      let i = media_keys.iter().position(|k| k == media_key).unwrap();
203      let mut reversed_negations = accumulated_negations[i].clone();
204      reversed_negations.reverse();
205
206      let combined_query = combine_media_query_with_negations(base_mq, reversed_negations);
207      let new_media_key = combined_query.to_string();
208
209      if let Some(original_kv) = result.iter().find(|kv| key_value_to_str(kv) == *media_key) {
210        final_result.push(KeyValueProp {
211          key: PropName::Str(Str {
212            span: DUMMY_SP,
213            value: Wtf8Atom::from(new_media_key),
214            raw: None,
215          }),
216          value: original_kv.value.clone(),
217        });
218      }
219    }
220  }
221
222  final_result
223}
224
225/// Combine media query with negations - matches JS combineMediaQueryWithNegations exactly
226fn combine_media_query_with_negations(
227  current: MediaQuery,
228  negations: Vec<MediaQuery>,
229) -> MediaQuery {
230  if negations.is_empty() {
231    return current;
232  }
233
234  // Create NOT rules from negations - matches JS: negations.map((mq) => ({ type: 'not', rule: mq.queries }))
235  let not_rules: Vec<MediaQueryRule> = negations
236    .into_iter()
237    .map(|mq| MediaQueryRule::Not(MediaNotRule::new(mq.queries)))
238    .collect();
239
240  // Combine media query with negations
241  let combined_ast = match current.queries {
242    MediaQueryRule::Or(or_rules) => {
243      let new_rules = or_rules
244        .rules
245        .into_iter()
246        .map(|rule| {
247          let mut and_rules = vec![rule];
248          and_rules.extend(not_rules.clone());
249          MediaQueryRule::And(MediaAndRules::new(and_rules))
250        })
251        .collect();
252      MediaQueryRule::Or(MediaOrRules::new(new_rules))
253    },
254    other => {
255      let mut rules = vec![other];
256      rules.extend(not_rules);
257      MediaQueryRule::And(MediaAndRules::new(rules))
258    },
259  };
260
261  MediaQuery::new_from_rule(combined_ast)
262}
263
264/// Check if all media queries represent disjoint width/height ranges
265fn are_media_queries_disjoint(media_keys: &[String]) -> bool {
266  let mut ranges = Vec::new();
267
268  for media_key in media_keys {
269    if let Ok(mq) = MediaQuery::parser().parse_to_end(media_key) {
270      if let Some(range) = extract_width_height_range(&mq) {
271        ranges.push(range);
272      } else {
273        // If any query is not a simple width/height range, don't apply disjoint logic
274        return false;
275      }
276    } else {
277      return false;
278    }
279  }
280
281  // Check if all ranges are disjoint (no overlaps)
282  for i in 0..ranges.len() {
283    for j in (i + 1)..ranges.len() {
284      if ranges_overlap(&ranges[i], &ranges[j]) {
285        return false;
286      }
287    }
288  }
289
290  true
291}
292
293/// Extract width/height range from a media query if it's a simple range
294fn extract_width_height_range(mq: &MediaQuery) -> Option<(String, f32, f32)> {
295  match &mq.queries {
296    MediaQueryRule::And(and_rules) if and_rules.rules.len() == 2 => {
297      let mut min_val = None;
298      let mut max_val = None;
299      let mut dimension = None;
300
301      for rule in &and_rules.rules {
302        if let MediaQueryRule::Pair(pair) = rule {
303          if pair.key.starts_with("min-width") || pair.key.starts_with("max-width") {
304            if dimension.is_none() {
305              dimension = Some("width".to_string());
306            } else if dimension.as_ref() != Some(&"width".to_string()) {
307              return None; // Mixed dimensions
308            }
309
310            if let MediaRuleValue::Length(length) = &pair.value {
311              if pair.key.starts_with("min-") {
312                min_val = Some(length.value);
313              } else {
314                max_val = Some(length.value);
315              }
316            } else {
317              return None; // Non-length value
318            }
319          } else if pair.key.starts_with("min-height") || pair.key.starts_with("max-height") {
320            if dimension.is_none() {
321              dimension = Some("height".to_string());
322            } else if dimension.as_ref() != Some(&"height".to_string()) {
323              return None; // Mixed dimensions
324            }
325
326            if let MediaRuleValue::Length(length) = &pair.value {
327              if pair.key.starts_with("min-") {
328                min_val = Some(length.value);
329              } else {
330                max_val = Some(length.value);
331              }
332            } else {
333              return None; // Non-length value
334            }
335          } else {
336            return None; // Not a width/height rule
337          }
338        } else {
339          return None; // Not a simple pair rule
340        }
341      }
342
343      if let (Some(dim), Some(min), Some(max)) = (dimension, min_val, max_val) {
344        Some((dim, min, max))
345      } else {
346        None
347      }
348    },
349    _ => None,
350  }
351}
352
353/// Check if two ranges overlap
354fn ranges_overlap(range1: &(String, f32, f32), range2: &(String, f32, f32)) -> bool {
355  // Only compare ranges of the same dimension
356  if range1.0 != range2.0 {
357    return false;
358  }
359
360  let (_, min1, max1) = range1;
361  let (_, min2, max2) = range2;
362
363  // Two ranges [min1, max1] and [min2, max2] overlap if:
364  // min1 <= max2 && min2 <= max1
365  min1 <= max2 && min2 <= max1
366}
367
368/// Just normalize media query syntax without applying negation logic
369fn normalize_media_query_syntax(result: Vec<KeyValueProp>) -> Vec<KeyValueProp> {
370  result
371    .into_iter()
372    .map(|kv| {
373      let key = key_value_to_str(&kv);
374      if key.starts_with("@media ") {
375        if let Ok(mq) = MediaQuery::parser().parse_to_end(&key) {
376          let normalized_key = mq.to_string();
377          KeyValueProp {
378            key: PropName::Str(Str {
379              span: DUMMY_SP,
380              value: Wtf8Atom::from(normalized_key),
381              raw: None,
382            }),
383            value: kv.value,
384          }
385        } else {
386          kv
387        }
388      } else {
389        kv
390      }
391    })
392    .collect()
393}