Skip to main content

fallow_extract/css_in_js/
object.rs

1//! CSS-in-JS OBJECT-notation lifter for the styling-health analytics pipeline
2//! (CSS program Phase 3c).
3//!
4//! The object-only / zero-runtime camp of CSS-in-JS (vanilla-extract, StyleX,
5//! Panda, plus emotion's object form) writes its CSS as a JS OBJECT LITERAL
6//! passed to a library call (`style({ color: 'red' })`,
7//! `stylex.create({ root: {...} })`, `css({...})`, `styled.div({...})`) rather
8//! than a tagged template. The Phase 3b lexical lifter
9//! ([`crate::css_in_js::css_in_js_virtual_stylesheet`]) only handles the template
10//! form, so an object-notation app (the libraries every new RSC / compile-time
11//! project picks) got `null` styling analytics. This module is the object-form
12//! analogue: it parses the JS/TS with oxc, walks the AST for import-gated
13//! object-literal style calls, and SERIALIZES each style bucket into the SAME
14//! blank-line-padded virtual stylesheet 3b emits, so both forms converge on one
15//! [`crate::compute_css_analytics`] + styling-health pipeline (no forked metric
16//! logic). The object -> CSS transform is unavoidable (it happens in the bundler
17//! fallow does not run); the AST just removes the lexing pain and hands us a
18//! structured object.
19//!
20//! It is health-time-only, like 3b: it runs over file SOURCE in the engine's CSS
21//! walk and persists nothing to the extraction cache (no `CACHE_VERSION` bump).
22//! The second oxc parse it costs (the extraction pass already parsed the file,
23//! but that AST is ephemeral and unreachable in the health walk) is bounded by
24//! the same dep gate + `--css` gate 3b uses.
25//!
26//! # Provenance: import-binding, not name (no false positives)
27//!
28//! `style` / `css` / `cva` are generic names a project may define locally or
29//! import from an UNRELATED library (`cva` from `class-variance-authority` is a
30//! class-string helper, not CSS). Recognition is therefore gated on IMPORT
31//! BINDING: a call only serializes when its callee name was imported from a
32//! recognized CSS-in-JS module in THIS file. A local `const style = ...` or a
33//! `css` / `cva` from an unrelated package never fires.
34//!
35//! # Static-only serialization
36//!
37//! Only static string / number values are emitted (camelCase -> kebab-case,
38//! implicit `px` on numbers outside the unitless set, selector-shaped keys become
39//! nested rules). DYNAMIC values (identifier / member / call), SPREAD, COMPUTED
40//! keys, and objects under a NON-selector key (a `cva` `variants` map, not a
41//! style block) are DROPPED, never guessed. StyleX condition maps are the narrow
42//! exception: literal `default` / at-rule keys and immutable string aliases are
43//! recoverable without evaluation. A `color: theme.primary` contributes nothing
44//! rather than a fabricated token. A bucket that drops to zero static
45//! declarations is omitted entirely (no empty synthetic rule).
46//!
47//! # Three sheets: atomic / structural-partial / structural
48//!
49//! StyleX and Panda compile to ATOMIC CSS (one declaration per class, flat by
50//! construction), so the structure of their lifted source rules is not
51//! representative: a flat synthetic rule would trivially score a structural A and
52//! dilute a mixed project's `!important` / nesting density. Separately, a bucket
53//! that DROPPED a dynamic declaration could collapse onto another bucket's
54//! fingerprint (the dropped declaration is exactly what distinguished them), a
55//! false duplicate. The serializer therefore returns THREE virtual stylesheets so
56//! the engine can apply the right policy to each:
57//!
58//! - [`CssInJsObjectSheets::structural`]: vanilla-extract + emotion buckets with
59//!   NO dropped declarations. Full analytics, including duplicate-block
60//!   fingerprints and the styling-health structural grade inputs.
61//! - [`CssInJsObjectSheets::structural_partial`]: vanilla-extract + emotion
62//!   buckets that dropped a dynamic / spread / computed declaration. Their tokens
63//!   and metrics still count, but the engine suppresses their duplicate-block
64//!   fingerprints (a dropped declaration could have distinguished two otherwise
65//!   identical blocks).
66//! - [`CssInJsObjectSheets::atomic`]: StyleX + Panda buckets. Token-sprawl only;
67//!   the engine excludes them from the structural grade inputs and from
68//!   duplicate-block fingerprints (flat by construction; their structure is a
69//!   build-output property, not authored).
70//!
71//! Note: numeric values outside the unitless set gain a synthetic `px` the author
72//! did not literally type (`fontSize: 14` -> `font-size: 14px`); this is correct
73//! for the font-size-unit-MIX smell (a unit IS implied) but the synthesized unit
74//! is an analytic convenience, not authored text.
75
76use std::path::Path;
77
78use oxc_allocator::Allocator;
79use oxc_ast::ast::{
80    Argument, BindingPattern, Expression, IdentifierReference, ImportDeclarationSpecifier,
81    NumericLiteral, ObjectExpression, ObjectPropertyKind, Program, PropertyKey, Statement,
82    UnaryOperator, VariableDeclaration, VariableDeclarationKind,
83};
84use oxc_ast_visit::{Visit, walk};
85use oxc_parser::Parser;
86use oxc_semantic::{ReferenceId, Scoping, SemanticBuilder};
87use oxc_span::{GetSpan, SourceType};
88use rustc_hash::FxHashMap;
89
90use super::shared::{WRAPPER, count_newlines};
91
92/// CSS property names (camelCase) whose numeric values are UNITLESS: a bare
93/// number is the value, not a `px` length. Mirrors React's well-known unitless
94/// set (`CSSProperty.js`), so `lineHeight: 1.5` -> `line-height: 1.5` while
95/// `padding: 8` -> `padding: 8px`. Comparison is against the camelCase key as
96/// authored (before kebab conversion).
97const UNITLESS_PROPERTIES: &[&str] = &[
98    "animationIterationCount",
99    "aspectRatio",
100    "borderImageOutset",
101    "borderImageSlice",
102    "borderImageWidth",
103    "boxFlex",
104    "boxFlexGroup",
105    "boxOrdinalGroup",
106    "columnCount",
107    "columns",
108    "flex",
109    "flexGrow",
110    "flexPositive",
111    "flexShrink",
112    "flexNegative",
113    "flexOrder",
114    "gridArea",
115    "gridRow",
116    "gridRowEnd",
117    "gridRowSpan",
118    "gridRowStart",
119    "gridColumn",
120    "gridColumnEnd",
121    "gridColumnSpan",
122    "gridColumnStart",
123    "fontWeight",
124    "lineClamp",
125    "lineHeight",
126    "opacity",
127    "order",
128    "orphans",
129    "scale",
130    "tabSize",
131    "widows",
132    "zIndex",
133    "zoom",
134    "fillOpacity",
135    "floodOpacity",
136    "stopOpacity",
137    "strokeDasharray",
138    "strokeDashoffset",
139    "strokeMiterlimit",
140    "strokeOpacity",
141    "strokeWidth",
142];
143
144/// The recognized object-notation CSS-in-JS libraries. The atomic split drives
145/// whether a library's synthetic rules count toward the styling-health structural
146/// grade and duplicate-block fingerprints.
147#[derive(Clone, Copy, PartialEq, Eq)]
148pub(super) enum Lib {
149    /// vanilla-extract (`@vanilla-extract/css` / `/recipes`): real selectors via
150    /// `globalStyle` / `selectors`, structure is meaningful.
151    VanillaExtract,
152    /// emotion `css(...)` object form (`@emotion/react` / `@emotion/css`).
153    Emotion,
154    /// emotion `styled.div(...)` object form (`@emotion/styled`); member calls.
155    EmotionStyled,
156    /// StyleX (`@stylexjs/stylex`): compile-time atomic CSS, flat by construction.
157    StyleX,
158    /// Panda (`styled-system` codegen, gated on `@pandacss/dev`): atomic CSS.
159    Panda,
160}
161
162impl Lib {
163    /// Whether the library compiles to flat atomic CSS whose source-rule
164    /// structure is not representative (excluded from the styling-health
165    /// structural grade and duplicate fingerprints).
166    const fn is_atomic(self) -> bool {
167        matches!(self, Self::StyleX | Self::Panda)
168    }
169}
170
171/// The three virtual stylesheets lifted from a source's object-notation
172/// CSS-in-JS, each blank-line-padded so CSS metric line numbers map back onto the
173/// real source. Each is `None` when the source has no object CSS-in-JS of that
174/// class (so callers skip it; no `files_analyzed` inflation). See the module docs
175/// for the per-sheet engine policy.
176#[derive(Debug, Default, PartialEq, Eq)]
177pub struct CssInJsObjectSheets {
178    /// vanilla-extract + emotion buckets with no dropped declarations: full
179    /// analytics incl. duplicate fingerprints + structural grade inputs.
180    pub structural: Option<String>,
181    /// vanilla-extract + emotion buckets that dropped a dynamic declaration:
182    /// tokens + metrics count, duplicate fingerprints suppressed by the engine.
183    pub structural_partial: Option<String>,
184    /// StyleX + Panda atomic buckets: token-sprawl only; excluded from the
185    /// structural grade inputs and duplicate fingerprints.
186    pub atomic: Option<String>,
187}
188
189impl CssInJsObjectSheets {
190    /// Whether all three sheets are empty (no recognized object CSS-in-JS).
191    #[must_use]
192    pub const fn is_empty(&self) -> bool {
193        self.structural.is_none() && self.structural_partial.is_none() && self.atomic.is_none()
194    }
195}
196
197/// Which sheet a lifted bucket belongs to.
198#[derive(Clone, Copy, PartialEq, Eq)]
199enum Stream {
200    Structural,
201    StructuralPartial,
202    Atomic,
203}
204
205/// A single lifted style bucket awaiting emission: the byte offset to pad to (the
206/// property key for a multi-bucket call, so duplicate / notable findings land on
207/// the right line), the serialized rule (`<selector>{<decls>}`), and its sheet.
208struct Bucket {
209    offset: u32,
210    rule: String,
211    stream: Stream,
212}
213
214/// Lift the object-notation CSS-in-JS in a JS/TS source into the structural /
215/// structural-partial / atomic virtual stylesheets. Parses with oxc (source type
216/// inferred from `path`), maps import bindings to recognized libraries, walks for
217/// style calls, serializes each bucket, and pads each to its source line. All
218/// sheets are `None` when the source has no recognized object CSS-in-JS import.
219#[must_use]
220pub fn css_in_js_object_sheets(source: &str, path: &Path) -> CssInJsObjectSheets {
221    let source_type = SourceType::from_path(path).unwrap_or_default();
222    let allocator = Allocator::default();
223    // A best-effort parse: even with recoverable syntax errors oxc returns a
224    // partial program, and the walk lifts whatever object styles it can reach
225    // (matching `compute_css_analytics`'s error-recovery philosophy).
226    let ret = Parser::new(&allocator, source, source_type).parse();
227    if !has_recognized_value_import(&ret.program) {
228        return CssInJsObjectSheets::default();
229    }
230    let semantic_ret = SemanticBuilder::new().build(&ret.program);
231    let scoping = semantic_ret.semantic.scoping();
232
233    let mut collector = ObjectStyleCollector::new(source);
234    collector.build_import_map(&ret.program, scoping);
235    if collector.imports.is_empty() {
236        // No recognized CSS-in-JS import binding: provenance gate is closed, so
237        // nothing can fire. Cheap exit before the call walk.
238        return CssInJsObjectSheets::default();
239    }
240    if collector
241        .imports
242        .values()
243        .any(|(library, _)| *library == Lib::StyleX)
244    {
245        collector.build_const_string_map(&ret.program, scoping);
246    }
247    collector.visit_program(&ret.program);
248    collector.finish()
249}
250
251fn has_recognized_value_import(program: &Program<'_>) -> bool {
252    program.body.iter().any(|statement| {
253        let Statement::ImportDeclaration(declaration) = statement else {
254            return false;
255        };
256        !declaration.import_kind.is_type()
257            && module_library(declaration.source.value.as_str()).is_some()
258            && declaration.specifiers.as_ref().is_some_and(|specifiers| {
259                specifiers.iter().any(|specifier| {
260                    !matches!(
261                        specifier,
262                        ImportDeclarationSpecifier::ImportSpecifier(specifier)
263                            if specifier.import_kind.is_type()
264                    )
265                })
266            })
267    })
268}
269
270/// Walks a parsed program collecting object-notation style buckets, gated on
271/// import provenance.
272struct ObjectStyleCollector<'a> {
273    source: &'a str,
274    /// Resolved import reference -> (library, canonical function role). Keying
275    /// by semantic reference rather than spelling makes recognition respect
276    /// parameters, lexical scopes, loop bindings, catch bindings, and TDZ.
277    imports: FxHashMap<ReferenceId, (Lib, &'a str)>,
278    /// Resolved reference -> the value of its immutable string binding. This is
279    /// used for StyleX computed condition keys without confusing a shadowed
280    /// binding with a same-named module constant.
281    const_strings: FxHashMap<ReferenceId, (u32, &'a str)>,
282    buckets: Vec<Bucket>,
283}
284
285impl<'a> ObjectStyleCollector<'a> {
286    fn new(source: &'a str) -> Self {
287        Self {
288            source,
289            imports: FxHashMap::default(),
290            const_strings: FxHashMap::default(),
291            buckets: Vec::new(),
292        }
293    }
294
295    /// Map each resolved reference to an import binding from a recognized
296    /// CSS-in-JS module. Named aliases and default / namespace bindings retain
297    /// their canonical role, while same-named local bindings never enter the
298    /// map.
299    fn build_import_map(&mut self, program: &Program<'a>, scoping: &Scoping) {
300        for stmt in &program.body {
301            let Statement::ImportDeclaration(decl) = stmt else {
302                continue;
303            };
304            if decl.import_kind.is_type() {
305                continue;
306            }
307            let Some(lib) = module_library(decl.source.value.as_str()) else {
308                continue;
309            };
310            let Some(specifiers) = &decl.specifiers else {
311                continue;
312            };
313            for specifier in specifiers {
314                let (binding, role) = match specifier {
315                    // A named import dispatches on its CANONICAL imported name, so
316                    // `import { style as s }` still matches the `style` arm.
317                    ImportDeclarationSpecifier::ImportSpecifier(s) if !s.import_kind.is_type() => {
318                        (&s.local, s.imported.name().as_str())
319                    }
320                    ImportDeclarationSpecifier::ImportSpecifier(_) => continue,
321                    // A default import routes through the member-call / call arms.
322                    // For emotion the default export IS the `css` function, so
323                    // canonicalize its role to `css` and let any local alias fire
324                    // (mirrors how EmotionStyled member calls ignore the binding
325                    // name). Other libs keep the local name for member-call
326                    // recognition (`import stylex from ...` -> `stylex.create`).
327                    ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
328                        let role = if lib == Lib::Emotion {
329                            "css"
330                        } else {
331                            s.local.name.as_str()
332                        };
333                        (&s.local, role)
334                    }
335                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
336                        (&s.local, s.local.name.as_str())
337                    }
338                };
339                let Some(symbol_id) = binding.symbol_id.get() else {
340                    continue;
341                };
342                self.imports.extend(
343                    scoping
344                        .get_resolved_reference_ids(symbol_id)
345                        .iter()
346                        .copied()
347                        .map(|reference_id| (reference_id, (lib, role))),
348                );
349            }
350        }
351    }
352
353    fn build_const_string_map(&mut self, program: &Program<'a>, scoping: &Scoping) {
354        let mut collector = ConstStringCollector {
355            scoping,
356            values: &mut self.const_strings,
357        };
358        collector.visit_program(program);
359    }
360
361    fn imported_binding(&self, id: &IdentifierReference<'_>) -> Option<(Lib, &'a str)> {
362        let reference_id = id.reference_id.get()?;
363        self.imports.get(&reference_id).copied()
364    }
365
366    fn finish(self) -> CssInJsObjectSheets {
367        let source = self.source;
368        let mut buckets = self.buckets;
369        // Emit in source order so the incremental blank-line padding only ever
370        // moves forward (the AST walk can surface a nested call before an earlier
371        // sibling depending on tree shape).
372        buckets.sort_by_key(|b| b.offset);
373        CssInJsObjectSheets {
374            structural: render(source, &buckets, Stream::Structural),
375            structural_partial: render(source, &buckets, Stream::StructuralPartial),
376            atomic: render(source, &buckets, Stream::Atomic),
377        }
378    }
379
380    /// Resolve a call's callee to `(library, kind)` if it is a recognized
381    /// object-notation style call. `kind` selects how the arguments become
382    /// buckets.
383    fn recognize(&self, callee: &Expression<'a>) -> Option<(Lib, CallKind)> {
384        match callee {
385            Expression::Identifier(id) => {
386                let (lib, role) = self.imported_binding(id)?;
387                let kind = match (lib, role) {
388                    // `style(obj)` / `css(obj)`: one object -> one bucket.
389                    (Lib::VanillaExtract, "style") | (Lib::Emotion | Lib::Panda, "css") => {
390                        CallKind::SingleObject
391                    }
392                    // `styleVariants({ k: obj })` / `create({ k: obj })`: one bucket per key.
393                    (Lib::VanillaExtract, "styleVariants") | (Lib::StyleX, "create") => {
394                        CallKind::ObjectOfObjects
395                    }
396                    // `globalStyle('sel', obj)`: real-selector rule.
397                    (Lib::VanillaExtract, "globalStyle") => CallKind::GlobalStyle,
398                    // `recipe({ base, variants })` / `cva({...})`: lift `base` only.
399                    (Lib::VanillaExtract, "recipe") | (Lib::Panda, "cva") => CallKind::RecipeBase,
400                    _ => return None,
401                };
402                Some((lib, kind))
403            }
404            // `styled.div({...})` / `stylex.create({...})`: member call on a bound
405            // namespace / default import.
406            Expression::StaticMemberExpression(member) => {
407                let Expression::Identifier(obj) = &member.object else {
408                    return None;
409                };
410                let (lib, _) = self.imported_binding(obj)?;
411                let kind = match (lib, member.property.name.as_str()) {
412                    (Lib::EmotionStyled, _) => CallKind::SingleObject,
413                    (Lib::StyleX, "create") => CallKind::ObjectOfObjects,
414                    _ => return None,
415                };
416                Some((lib, kind))
417            }
418            // `styled(Component)({...})`: callee is itself a `styled(...)` call.
419            Expression::CallExpression(inner) => {
420                let Expression::Identifier(id) = &inner.callee else {
421                    return None;
422                };
423                matches!(self.imported_binding(id), Some((Lib::EmotionStyled, _)))
424                    .then_some((Lib::EmotionStyled, CallKind::SingleObject))
425            }
426            _ => None,
427        }
428    }
429
430    /// Turn a recognized call's arguments into buckets and record them.
431    fn collect_call(&mut self, callee: &Expression<'a>, args: &[Argument<'a>]) {
432        let Some((lib, kind)) = self.recognize(callee) else {
433            return;
434        };
435        match kind {
436            CallKind::SingleObject => {
437                if let Some(obj) = object_arg(args, 0) {
438                    self.push_bucket(obj, WRAPPER, lib, obj.span().start);
439                }
440            }
441            CallKind::ObjectOfObjects => {
442                // `stylex.create({ root: {...} })` / `styleVariants({ a: {...} })`:
443                // one bucket per key (padded to the key line). Only the
444                // single-object form; the functional `styleVariants(data, fn)`
445                // overload returns styles dynamically and is skipped.
446                if args.len() != 1 {
447                    return;
448                }
449                let Some(obj) = object_arg(args, 0) else {
450                    return;
451                };
452                for prop in &obj.properties {
453                    if let ObjectPropertyKind::ObjectProperty(p) = prop
454                        && let Some(inner) = object_expression(&p.value)
455                    {
456                        self.push_bucket(inner, WRAPPER, lib, p.key.span().start);
457                    }
458                }
459            }
460            CallKind::RecipeBase => {
461                // `recipe({ base: {...}, variants: {...} })` / `cva({...})`: only
462                // the `base` style object is plain declarations; `variants` /
463                // `compoundVariants` / `defaultVariants` are config maps, not style
464                // blocks, and are skipped (deferred).
465                let Some(obj) = object_arg(args, 0) else {
466                    return;
467                };
468                for prop in &obj.properties {
469                    if let ObjectPropertyKind::ObjectProperty(p) = prop
470                        && static_key(&p.key).as_deref() == Some("base")
471                        && let Some(inner) = object_expression(&p.value)
472                    {
473                        self.push_bucket(inner, WRAPPER, lib, p.key.span().start);
474                    }
475                }
476            }
477            CallKind::GlobalStyle => {
478                // `globalStyle('selector', { ... })`: real selector, structural.
479                let (Some(selector), Some(obj)) = (string_arg(args, 0), object_arg(args, 1)) else {
480                    return;
481                };
482                let selector = sanitize_selector(&selector);
483                if !selector.is_empty() {
484                    self.push_bucket(obj, &selector, lib, obj.span().start);
485                }
486            }
487        }
488    }
489
490    /// Serialize one object literal into a `<selector>{<decls>}` rule and record
491    /// it, dropping the bucket when no static declaration survives and routing it
492    /// to the right sheet (atomic, or structural / structural-partial by whether
493    /// any declaration was dropped).
494    fn push_bucket(&mut self, obj: &ObjectExpression<'a>, selector: &str, lib: Lib, offset: u32) {
495        let mut body = String::new();
496        let mut dropped = false;
497        let context = ObjectSerializationContext {
498            stylex: lib == Lib::StyleX,
499            const_strings: &self.const_strings,
500            before: offset,
501        };
502        serialize_object_body(obj, &mut body, &mut dropped, &context);
503        if body.is_empty() {
504            return;
505        }
506        let stream = if lib.is_atomic() {
507            Stream::Atomic
508        } else if dropped {
509            Stream::StructuralPartial
510        } else {
511            Stream::Structural
512        };
513        self.buckets.push(Bucket {
514            offset,
515            rule: format!("{selector}{{{body}}}"),
516            stream,
517        });
518    }
519}
520
521impl<'a> Visit<'a> for ObjectStyleCollector<'a> {
522    fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
523        self.collect_call(&call.callee, &call.arguments);
524        walk::walk_call_expression(self, call);
525    }
526}
527
528struct ConstStringCollector<'a, 's, 'm> {
529    scoping: &'s Scoping,
530    values: &'m mut FxHashMap<ReferenceId, (u32, &'a str)>,
531}
532
533impl<'a> Visit<'a> for ConstStringCollector<'a, '_, '_> {
534    fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) {
535        if declaration.kind == VariableDeclarationKind::Const {
536            for declarator in &declaration.declarations {
537                let BindingPattern::BindingIdentifier(binding) = &declarator.id else {
538                    continue;
539                };
540                let Some(Expression::StringLiteral(value)) = declarator
541                    .init
542                    .as_ref()
543                    .map(Expression::get_inner_expression)
544                else {
545                    continue;
546                };
547                let Some(symbol_id) = binding.symbol_id.get() else {
548                    continue;
549                };
550                self.values.extend(
551                    self.scoping
552                        .get_resolved_reference_ids(symbol_id)
553                        .iter()
554                        .copied()
555                        .map(|reference_id| {
556                            (reference_id, (declarator.span.start, value.value.as_str()))
557                        }),
558                );
559            }
560        }
561        walk::walk_variable_declaration(self, declaration);
562    }
563}
564
565/// Render the buckets of one stream into a blank-line-padded sheet, or `None` if
566/// there are none. Each bucket is padded to its source line so CSS metric line
567/// numbers map back onto the source.
568fn render(source: &str, buckets: &[Bucket], stream: Stream) -> Option<String> {
569    let mut out = String::new();
570    let mut current_line: usize = 1;
571    let mut found = false;
572    for bucket in buckets.iter().filter(|b| b.stream == stream) {
573        let block_line = 1 + count_newlines(&source[..bucket.offset as usize]);
574        while current_line < block_line {
575            out.push('\n');
576            current_line += 1;
577        }
578        out.push_str(&bucket.rule);
579        current_line += count_newlines(&bucket.rule);
580        found = true;
581    }
582    found.then_some(out)
583}
584
585/// How a recognized call's arguments map to style buckets.
586enum CallKind {
587    /// The first object argument is one style bucket (`style(obj)`, `css(obj)`,
588    /// `styled.div(obj)`).
589    SingleObject,
590    /// The first object argument is a map of key -> style object; each value
591    /// object is its own bucket (`stylex.create({...})`, `styleVariants({...})`).
592    ObjectOfObjects,
593    /// The first object argument is a recipe (`{ base, variants, ... }`); only
594    /// `base` is a style bucket (`recipe({...})`, `cva({...})`).
595    RecipeBase,
596    /// `globalStyle('selector', obj)`: the second arg is a style bucket emitted
597    /// under the real first-arg selector.
598    GlobalStyle,
599}
600
601/// The recognized library for an import module specifier, or `None`. Panda's
602/// runtime `css` / `cva` is imported from a generated `styled-system` path rather
603/// than a package name, so any specifier whose path contains a `styled-system`
604/// segment is treated as Panda (still behind the engine's `@pandacss/dev` dep
605/// gate, which decides whether the file is scanned at all).
606pub(super) fn module_library(specifier: &str) -> Option<Lib> {
607    match specifier {
608        "@pandacss/dev" => Some(Lib::Panda),
609        "@vanilla-extract/css" | "@vanilla-extract/recipes" => Some(Lib::VanillaExtract),
610        "@emotion/react" | "@emotion/css" => Some(Lib::Emotion),
611        "@emotion/styled" => Some(Lib::EmotionStyled),
612        "@stylexjs/stylex" | "stylex" => Some(Lib::StyleX),
613        _ if specifier
614            .split(['/', '\\'])
615            .any(|segment| segment == "styled-system") =>
616        {
617            Some(Lib::Panda)
618        }
619        _ => None,
620    }
621}
622
623/// The object-expression argument at `index`, if present and an object literal.
624fn object_arg<'a: 'b, 'b>(
625    args: &'b [Argument<'a>],
626    index: usize,
627) -> Option<&'b ObjectExpression<'a>> {
628    object_expression(args.get(index)?.as_expression()?)
629}
630
631/// An object literal after stripping syntax-only JavaScript / TypeScript
632/// wrappers such as parentheses, `as const`, `satisfies`, non-null assertions,
633/// type assertions, and instantiation expressions.
634fn object_expression<'a: 'b, 'b>(
635    expression: &'b Expression<'a>,
636) -> Option<&'b ObjectExpression<'a>> {
637    match expression.get_inner_expression() {
638        Expression::ObjectExpression(object) => Some(object),
639        _ => None,
640    }
641}
642
643/// The string-literal argument at `index`, if present.
644fn string_arg(args: &[Argument<'_>], index: usize) -> Option<String> {
645    match args.get(index)?.as_expression()?.get_inner_expression() {
646        Expression::StringLiteral(lit) => Some(lit.value.to_string()),
647        _ => None,
648    }
649}
650
651/// Serialize an object literal's static declarations into a CSS rule body. A
652/// selector-shaped key with an object value (`:hover`, `&:hover`, `@media ...`,
653/// vanilla-extract `selectors: {...}`) becomes a nested rule and recurses through
654/// further selector-shaped keys, so authored selector nesting depth is reflected
655/// (a real structural signal); dynamic values, spreads, computed keys, and
656/// objects under a NON-selector key (a `cva` `variants` map) are dropped and flip
657/// `dropped`.
658struct ObjectSerializationContext<'maps, 'ast> {
659    stylex: bool,
660    const_strings: &'maps FxHashMap<ReferenceId, (u32, &'ast str)>,
661    before: u32,
662}
663
664fn serialize_object_body(
665    obj: &ObjectExpression<'_>,
666    out: &mut String,
667    dropped: &mut bool,
668    context: &ObjectSerializationContext<'_, '_>,
669) {
670    for prop in &obj.properties {
671        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
672            // Spread (`...base`) carries no statically-known declarations.
673            *dropped = true;
674            continue;
675        };
676        let Some(key) = static_key(&prop.key) else {
677            // Computed key (`[prop]: v`): cannot be resolved statically.
678            *dropped = true;
679            continue;
680        };
681        let value = prop.value.get_inner_expression();
682        match value {
683            Expression::ObjectExpression(nested) if is_selector_key(&key) => {
684                serialize_nested(&key, nested, out, dropped, context);
685            }
686            Expression::ObjectExpression(nested) if context.stylex => {
687                let mut conditional_body = String::new();
688                if serialize_stylex_conditional_values(&key, nested, &mut conditional_body, context)
689                {
690                    out.push_str(&conditional_body);
691                } else {
692                    *dropped = true;
693                }
694            }
695            Expression::ObjectExpression(_) => {
696                *dropped = true;
697            }
698            value => {
699                if let Some(rendered) = serialize_value(&key, value) {
700                    out.push_str(&rendered);
701                } else {
702                    *dropped = true;
703                }
704            }
705        }
706    }
707}
708
709/// Serialize a nested object under a selector- or at-rule-shaped key into a
710/// nested rule (one level). vanilla-extract's `selectors: { '&:hover': {...} }`
711/// wrapper is unwrapped so each inner selector becomes its own nested rule.
712fn serialize_nested(
713    key: &str,
714    nested: &ObjectExpression<'_>,
715    out: &mut String,
716    dropped: &mut bool,
717    context: &ObjectSerializationContext<'_, '_>,
718) {
719    // `selectors: { '&:hover': {...}, ... }` is a wrapper, not a selector: emit
720    // each inner key as its own nested rule.
721    if key == "selectors" {
722        for prop in &nested.properties {
723            match prop {
724                ObjectPropertyKind::ObjectProperty(p) => {
725                    if let (Some(inner_key), Some(inner)) =
726                        (static_key(&p.key), object_expression(&p.value))
727                    {
728                        serialize_nested(&inner_key, inner, out, dropped, context);
729                    } else {
730                        *dropped = true;
731                    }
732                }
733                ObjectPropertyKind::SpreadProperty(_) => *dropped = true,
734            }
735        }
736        return;
737    }
738
739    let mut body = String::new();
740    serialize_object_body(nested, &mut body, dropped, context);
741    if body.is_empty() {
742        return;
743    }
744    out.push_str(&nested_selector(key));
745    out.push('{');
746    out.push_str(&body);
747    out.push('}');
748}
749
750fn serialize_stylex_conditional_values(
751    property: &str,
752    conditional: &ObjectExpression<'_>,
753    out: &mut String,
754    context: &ObjectSerializationContext<'_, '_>,
755) -> bool {
756    for entry in &conditional.properties {
757        let ObjectPropertyKind::ObjectProperty(entry) = entry else {
758            return false;
759        };
760        if !is_static_stylex_condition(entry, context) {
761            return false;
762        }
763        match entry.value.get_inner_expression() {
764            Expression::ObjectExpression(nested) => {
765                if !serialize_stylex_conditional_values(property, nested, out, context) {
766                    return false;
767                }
768            }
769            value => {
770                if let Some(rendered) = serialize_value(property, value) {
771                    out.push_str(&rendered);
772                } else {
773                    return false;
774                }
775            }
776        }
777    }
778    true
779}
780
781fn is_static_stylex_condition(
782    property: &oxc_ast::ast::ObjectProperty<'_>,
783    context: &ObjectSerializationContext<'_, '_>,
784) -> bool {
785    if !property.computed {
786        return property
787            .key
788            .static_name()
789            .is_some_and(|key| is_stylex_condition(&key));
790    }
791    let Some(expression) = property.key.as_expression() else {
792        return false;
793    };
794    match expression.get_inner_expression() {
795        Expression::StringLiteral(value) => is_stylex_condition(value.value.as_str()),
796        Expression::Identifier(id) => id
797            .reference_id
798            .get()
799            .and_then(|reference_id| context.const_strings.get(&reference_id))
800            .is_some_and(|(declaration_start, condition)| {
801                *declaration_start < context.before && is_stylex_condition(condition)
802            }),
803        _ => false,
804    }
805}
806
807fn is_stylex_condition(value: &str) -> bool {
808    value == "default" || value.starts_with(':') || value.starts_with('@') || value.starts_with('[')
809}
810
811/// Whether an object-property key introduces a nested SELECTOR / at-rule (so its
812/// object value is a nested rule) rather than a CSS property. Selector-shaped:
813/// the vanilla-extract `selectors` wrapper, an at-rule (`@media`), or a key
814/// starting with a selector character (`:`, `&`, a combinator, `.`, `#`, `[`, `*`,
815/// or a leading space for a descendant). A plain CSS property name (`color`,
816/// `backgroundColor`, `--custom`) is NOT a selector.
817fn is_selector_key(key: &str) -> bool {
818    if key == "selectors" {
819        return true;
820    }
821    matches!(
822        key.trim_start().chars().next(),
823        Some(':' | '&' | '@' | '>' | '+' | '~' | '.' | '#' | '[' | '*')
824    ) || key.starts_with(' ')
825}
826
827/// Map a nested object key to a CSS nested-rule prelude. At-rule keys
828/// (`@media ...`) and `&`-anchored selectors pass through; a bare pseudo /
829/// selector is prefixed with `&` so it parses as relative nesting.
830fn nested_selector(key: &str) -> String {
831    let trimmed = key.trim();
832    if trimmed.starts_with('@') || trimmed.starts_with('&') {
833        return trimmed.to_string();
834    }
835    format!("&{trimmed}")
836}
837
838/// Render a single static declaration `key: value` (with trailing `;`), or `None`
839/// when the value is not a static string / number (dynamic values are dropped).
840fn serialize_value(key: &str, value: &Expression<'_>) -> Option<String> {
841    let rendered = static_value(key, value)?;
842    Some(format!("{}:{rendered};", kebab_case(key)))
843}
844
845/// The CSS text of a static string / number expression for property `key`, or
846/// `None` for any dynamic / non-literal value. Numbers outside the unitless set
847/// gain an implicit `px`; negative numbers (`-8` as a unary minus) are handled.
848fn static_value(key: &str, value: &Expression<'_>) -> Option<String> {
849    match value.get_inner_expression() {
850        Expression::StringLiteral(lit) => {
851            let text = lit.value.as_str().trim();
852            (!text.is_empty()).then(|| text.to_string())
853        }
854        Expression::NumericLiteral(num) => Some(render_number(key, num)),
855        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
856            if let Expression::NumericLiteral(num) = &unary.argument {
857                Some(format!("-{}", render_number(key, num)))
858            } else {
859                None
860            }
861        }
862        _ => None,
863    }
864}
865
866/// Render a numeric literal for property `key`, appending `px` unless the
867/// property is unitless, a custom property, or the value is zero. The number is
868/// rendered from its PARSED value, not the raw source text, so a hex / octal /
869/// binary / scientific literal (`0xFF`, `1e3`) becomes a valid CSS decimal
870/// (`255`, `1000`) rather than a non-CSS token; `format_f64` preserves `1.5` /
871/// `700` exactly. The custom-property carve-out matches the real compiled output:
872/// emotion (`!isCustomProperty(key)` in `@emotion/serialize`) and React both
873/// leave a numeric `--x` value unitless, so adding `px` would fabricate a unit
874/// the bundler never emits.
875fn render_number(key: &str, num: &NumericLiteral<'_>) -> String {
876    let value = format_f64(num.value);
877    if is_unitless(key) || key.starts_with("--") || num.value == 0.0 {
878        value
879    } else {
880        format!("{value}px")
881    }
882}
883
884fn format_f64(value: f64) -> String {
885    if value.fract() == 0.0 {
886        format!("{value:.0}")
887    } else {
888        value.to_string()
889    }
890}
891
892/// Whether `key` (camelCase) is a unitless CSS property.
893fn is_unitless(key: &str) -> bool {
894    UNITLESS_PROPERTIES.contains(&key)
895}
896
897/// The static name of an object-property key (string literal or identifier), or
898/// `None` for a computed / dynamic key.
899fn static_key(key: &PropertyKey<'_>) -> Option<String> {
900    key.static_name().map(|name| name.to_string())
901}
902
903/// Convert a camelCase CSS property name to kebab-case. A leading uppercase
904/// (vendor prefix `WebkitBoxShadow`) becomes a leading `-` (`-webkit-box-shadow`),
905/// and the lowercase `ms` Microsoft prefix (`msFlexAlign`, the one React/emotion
906/// write lowercase) becomes `-ms-`. Custom properties (`--x`) and already-kebab
907/// names pass through unchanged.
908fn kebab_case(name: &str) -> String {
909    if name.starts_with("--") || name.contains('-') {
910        return name.to_string();
911    }
912    let mut out = String::with_capacity(name.len() + 2);
913    // The `ms` vendor prefix is authored lowercase (unlike `Webkit`/`Moz`/`O`),
914    // so prepend the leading `-` an uppercase boundary would otherwise add
915    // (`msTransform` -> `-ms-transform`).
916    if let Some(rest) = name.strip_prefix("ms")
917        && rest.chars().next().is_some_and(|c| c.is_ascii_uppercase())
918    {
919        out.push('-');
920    }
921    for ch in name.chars() {
922        if ch.is_ascii_uppercase() {
923            out.push('-');
924            out.push(ch.to_ascii_lowercase());
925        } else {
926            out.push(ch);
927        }
928    }
929    out
930}
931
932/// Drop any character from a `globalStyle` selector that could break out of the
933/// synthetic rule context (`{`, `}`) or split a declaration (`;`). The selector
934/// is authored CSS, kept as-is otherwise so its specificity / complexity are
935/// measured for real.
936fn sanitize_selector(selector: &str) -> String {
937    selector
938        .chars()
939        .filter(|&c| c != '{' && c != '}' && c != ';')
940        .collect::<String>()
941        .trim()
942        .to_string()
943}
944
945#[cfg(all(test, not(miri)))]
946mod tests {
947    use super::*;
948    use crate::compute_css_analytics;
949
950    fn sheets(source: &str) -> CssInJsObjectSheets {
951        css_in_js_object_sheets(source, Path::new("styles.ts"))
952    }
953
954    #[test]
955    fn vanilla_extract_style_lifts_to_parseable_css() {
956        let src = "import { style } from '@vanilla-extract/css';\n\
957                   export const box = style({\n\
958                   backgroundColor: 'red',\n\
959                   padding: 8,\n\
960                   });\n";
961        let s = sheets(src);
962        let css = s.structural.expect("vanilla-extract style is structural");
963        // camelCase -> kebab, implicit px on the numeric value.
964        assert!(css.contains("background-color:red;"), "css={css:?}");
965        assert!(css.contains("padding:8px;"), "px default: css={css:?}");
966        let a = compute_css_analytics(&css).expect("lifted CSS parses");
967        assert!(a.total_declarations >= 2, "declarations counted: {a:?}");
968        assert!(s.atomic.is_none(), "vanilla-extract is not atomic");
969    }
970
971    #[test]
972    fn unitless_properties_keep_bare_number() {
973        let src = "import { style } from '@vanilla-extract/css';\n\
974                   const x = style({ lineHeight: 1.5, zIndex: 10, fontWeight: 700, padding: 4 });\n";
975        let css = sheets(src).structural.expect("structural");
976        assert!(css.contains("line-height:1.5;"), "css={css:?}");
977        assert!(css.contains("z-index:10;"), "css={css:?}");
978        assert!(css.contains("font-weight:700;"), "css={css:?}");
979        assert!(css.contains("padding:4px;"), "css={css:?}");
980    }
981
982    #[test]
983    fn one_level_nesting_via_relative_selector() {
984        let src = "import { style } from '@vanilla-extract/css';\n\
985                   const x = style({ color: 'red', ':hover': { color: 'blue' } });\n";
986        let css = sheets(src).structural.expect("structural");
987        assert!(
988            css.contains("&:hover{color:blue;}"),
989            "nested rule: css={css:?}"
990        );
991        let a = compute_css_analytics(&css).expect("nested parses");
992        assert!(a.rule_count >= 2, "nested rule counted: {a:?}");
993    }
994
995    #[test]
996    fn vanilla_extract_selectors_wrapper_unwrapped() {
997        let src = "import { style } from '@vanilla-extract/css';\n\
998                   const x = style({ color: 'red', selectors: { '&:hover': { color: 'blue' } } });\n";
999        let css = sheets(src).structural.expect("structural");
1000        assert!(
1001            css.contains("&:hover{color:blue;}"),
1002            "selectors wrapper unwrapped: css={css:?}"
1003        );
1004        // The `selectors` key itself must NOT become a `&selectors{}` rule.
1005        assert!(
1006            !css.contains("selectors{"),
1007            "no literal selectors rule: css={css:?}"
1008        );
1009    }
1010
1011    #[test]
1012    fn global_style_keeps_real_selector() {
1013        let src = "import { globalStyle } from '@vanilla-extract/css';\n\
1014                   globalStyle('html, body', { margin: 0 });\n";
1015        let css = sheets(src).structural.expect("structural");
1016        assert!(
1017            css.contains("html, body{margin:0;}"),
1018            "real selector: css={css:?}"
1019        );
1020        let a = compute_css_analytics(&css).expect("parses");
1021        assert_eq!(a.rule_count, 1);
1022    }
1023
1024    #[test]
1025    fn stylex_create_is_atomic_one_bucket_per_key() {
1026        let src = "import * as stylex from '@stylexjs/stylex';\n\
1027                   export const styles = stylex.create({\n\
1028                   root: { color: 'red', padding: 16 },\n\
1029                   card: { color: 'blue' },\n\
1030                   });\n";
1031        let s = sheets(src);
1032        assert!(s.structural.is_none(), "stylex is atomic, not structural");
1033        let css = s.atomic.expect("stylex.create is atomic");
1034        assert!(css.contains("color:red;"), "css={css:?}");
1035        assert!(css.contains("padding:16px;"), "css={css:?}");
1036        assert!(css.contains("color:blue;"), "second bucket: css={css:?}");
1037        let a = compute_css_analytics(&css).expect("parses");
1038        assert!(a.rule_count >= 2, "two buckets: {a:?}");
1039    }
1040
1041    #[test]
1042    fn stylex_named_create_and_conditional_values_feed_atomic_vocabulary() {
1043        let src = "import { create as makeStyles } from 'stylex';\n\
1044                   const DARK = '@media (prefers-color-scheme: dark)';\n\
1045                   export const styles = makeStyles({\n\
1046                   root: { color: { default: '#111', [DARK]: '#eee' } },\n\
1047                   });\n";
1048        let sheets = sheets(src);
1049        assert!(sheets.structural.is_none());
1050        let css = sheets.atomic.expect("StyleX named create is atomic");
1051        assert!(css.contains("color:#111;"), "default value: {css:?}");
1052        assert!(css.contains("color:#eee;"), "conditional value: {css:?}");
1053        let analytics = compute_css_analytics(&css).expect("conditional sheet parses");
1054        assert!(analytics.colors.iter().any(|color| color == "#111"));
1055        assert!(analytics.colors.iter().any(|color| color == "#eee"));
1056    }
1057
1058    #[test]
1059    fn stylex_dynamic_conditional_key_does_not_feed_atomic_vocabulary() {
1060        let src = "import * as stylex from '@stylexjs/stylex';\n\
1061                   export const styles = stylex.create({\n\
1062                   root: { color: { default: '#111', [getCondition()]: '#eee' } },\n\
1063                   });\n";
1064        assert!(sheets(src).is_empty());
1065    }
1066
1067    #[test]
1068    fn stylex_static_computed_conditional_keys_feed_atomic_vocabulary() {
1069        let src = "import * as stylex from '@stylexjs/stylex';\n\
1070                   export const styles = stylex.create({\n\
1071                   root: { color: { ['default']: '#111', ['@media (prefers-color-scheme: dark)']: '#eee' } },\n\
1072                   });\n";
1073        let css = sheets(src)
1074            .atomic
1075            .expect("static computed StyleX conditions are recovered");
1076        assert!(css.contains("color:#111;"), "default value: {css:?}");
1077        assert!(css.contains("color:#eee;"), "media value: {css:?}");
1078    }
1079
1080    #[test]
1081    fn stylex_computed_condition_declared_after_create_abstains() {
1082        let src = "import * as stylex from '@stylexjs/stylex';\n\
1083                   export const styles = stylex.create({\n\
1084                   root: { color: { default: '#111', [DARK]: '#eee' } },\n\
1085                   });\n\
1086                   const DARK = '@media (prefers-color-scheme: dark)';\n";
1087        assert!(sheets(src).is_empty());
1088    }
1089
1090    #[test]
1091    fn stylex_pseudo_and_selector_conditions_feed_atomic_vocabulary() {
1092        let src = "import * as stylex from '@stylexjs/stylex';\n\
1093                   export const styles = stylex.create({ root: { color: {\n\
1094                   default: 'red', ':hover': 'blue', '[data-active]': 'green',\n\
1095                   '@media (width > 10px)': { ':active': 'black' },\n\
1096                   } } });\n";
1097        let css = sheets(src)
1098            .atomic
1099            .expect("static StyleX pseudo conditions are recovered");
1100        for value in ["red", "blue", "green", "black"] {
1101            assert!(css.contains(&format!("color:{value};")), "value: {css:?}");
1102        }
1103    }
1104
1105    #[test]
1106    fn stylex_shadowed_namespace_abstains_in_every_lexical_scope() {
1107        let src = "import * as stylex from '@stylexjs/stylex';\n\
1108                   const valid = stylex.create({ root: { color: 'red' } });\n\
1109                   function parameter(stylex) { stylex.create({ root: { color: 'blue' } }); }\n\
1110                   { stylex.create({ root: { color: 'green' } }); const stylex = local; }\n\
1111                   for (const stylex of libraries) { stylex.create({ root: { color: 'pink' } }); }\n\
1112                   try {} catch (stylex) { stylex.create({ root: { color: 'orange' } }); }\n";
1113        let css = sheets(src).atomic.expect("unshadowed StyleX call survives");
1114        assert!(css.contains("color:red;"), "module import call: {css:?}");
1115        for shadowed in ["blue", "green", "pink", "orange"] {
1116            assert!(
1117                !css.contains(shadowed),
1118                "shadowed namespace must abstain for {shadowed}: {css:?}"
1119            );
1120        }
1121    }
1122
1123    #[test]
1124    fn stylex_shadowed_named_create_abstains_in_every_lexical_scope() {
1125        let src = "import { create as makeStyles } from '@stylexjs/stylex';\n\
1126                   const valid = makeStyles({ root: { color: 'red' } });\n\
1127                   function parameter(makeStyles) { makeStyles({ root: { color: 'blue' } }); }\n\
1128                   { makeStyles({ root: { color: 'green' } }); const makeStyles = local; }\n\
1129                   for (const makeStyles of factories) { makeStyles({ root: { color: 'pink' } }); }\n\
1130                   try {} catch (makeStyles) { makeStyles({ root: { color: 'orange' } }); }\n";
1131        let css = sheets(src)
1132            .atomic
1133            .expect("unshadowed named create call survives");
1134        assert!(css.contains("color:red;"), "module import call: {css:?}");
1135        for shadowed in ["blue", "green", "pink", "orange"] {
1136            assert!(
1137                !css.contains(shadowed),
1138                "shadowed create alias must abstain for {shadowed}: {css:?}"
1139            );
1140        }
1141    }
1142
1143    #[test]
1144    fn stylex_transparent_typescript_wrappers_preserve_static_objects() {
1145        let src = "import * as stylex from '@stylexjs/stylex';\n\
1146                   export const styles = stylex.create((({\n\
1147                   root: ({ color: ('red' as const), padding: (8 satisfies number) } satisfies Record<string, unknown>),\n\
1148                   } as const) satisfies Record<string, unknown>));\n";
1149        let css = sheets(src)
1150            .atomic
1151            .expect("wrapped static StyleX object is recovered");
1152        assert!(css.contains("color:red;"), "wrapped string: {css:?}");
1153        assert!(css.contains("padding:8px;"), "wrapped number: {css:?}");
1154    }
1155
1156    #[test]
1157    fn stylex_shadowed_computed_condition_binding_abstains() {
1158        let src = "import * as stylex from '@stylexjs/stylex';\n\
1159                   const CONDITION = '@media (width > 10px)';\n\
1160                   function styles() {\n\
1161                   const CONDITION = getCondition();\n\
1162                   return stylex.create({ root: { color: { default: 'red', [CONDITION]: 'blue' } } });\n\
1163                   }\n";
1164        assert!(sheets(src).is_empty());
1165    }
1166
1167    #[test]
1168    fn stylex_type_only_named_create_does_not_open_gate() {
1169        let src = "import { type create } from '@stylexjs/stylex';\n\
1170                   const styles = create({ root: { color: 'red' } });\n";
1171        assert!(sheets(src).is_empty());
1172    }
1173
1174    #[test]
1175    fn panda_css_from_styled_system_is_atomic() {
1176        let src = "import { css } from '../styled-system/css';\n\
1177                   const c = css({ display: 'flex', gap: 8 });\n";
1178        let s = sheets(src);
1179        let css = s.atomic.expect("panda css is atomic");
1180        assert!(css.contains("display:flex;"), "css={css:?}");
1181        assert!(css.contains("gap:8px;"), "css={css:?}");
1182    }
1183
1184    #[test]
1185    fn emotion_css_and_styled_are_structural() {
1186        let src = "import { css } from '@emotion/react';\n\
1187                   import styled from '@emotion/styled';\n\
1188                   const a = css({ color: 'red' });\n\
1189                   const B = styled.div({ fontWeight: 700 });\n";
1190        let css = sheets(src).structural.expect("emotion is structural");
1191        assert!(css.contains("color:red;"), "css={css:?}");
1192        assert!(css.contains("font-weight:700;"), "styled.div: css={css:?}");
1193    }
1194
1195    #[test]
1196    fn styled_call_form_is_lifted() {
1197        let src = "import styled from '@emotion/styled';\n\
1198                   const Primary = styled(Button)({ fontWeight: 700 });\n";
1199        let css = sheets(src)
1200            .structural
1201            .expect("styled(Component)({}) lifted");
1202        assert!(css.contains("font-weight:700;"), "css={css:?}");
1203    }
1204
1205    #[test]
1206    fn dynamic_value_is_dropped_to_structural_partial() {
1207        let src = "import { style } from '@vanilla-extract/css';\n\
1208                   import { theme } from './theme';\n\
1209                   const x = style({ color: theme.primary, padding: 8, margin: 4, top: 1, left: 2 });\n";
1210        let s = sheets(src);
1211        // The dynamic `color` is dropped; the bucket has a dropped decl so it
1212        // lands in structural_partial (duplicate-fingerprint suppressed by the
1213        // engine), NOT the clean structural sheet.
1214        assert!(s.structural.is_none(), "bucket had a drop: {s:?}");
1215        let css = s.structural_partial.expect("partial");
1216        assert!(
1217            !css.contains("fallowinterp"),
1218            "no placeholder, value dropped: {css:?}"
1219        );
1220        assert!(
1221            !css.contains("primary"),
1222            "dynamic member not serialized: {css:?}"
1223        );
1224        assert!(css.contains("padding:8px;"), "static survives: {css:?}");
1225        let a = compute_css_analytics(&css).expect("must parse, not None");
1226        assert_eq!(a.important_declarations, 0, "no invented !important: {a:?}");
1227    }
1228
1229    #[test]
1230    fn spread_and_computed_key_dropped() {
1231        let src = "import { style } from '@vanilla-extract/css';\n\
1232                   const base = {};\n\
1233                   const k = 'color';\n\
1234                   const x = style({ ...base, [k]: 'red', padding: 8, margin: 4, top: 1 });\n";
1235        let s = sheets(src);
1236        // Spread + computed key are drops -> structural_partial.
1237        let css = s.structural_partial.expect("partial");
1238        assert!(css.contains("padding:8px;"), "static survives: {css:?}");
1239    }
1240
1241    #[test]
1242    fn cva_variants_map_is_not_serialized_as_css() {
1243        // `cva` from class-variance-authority is NOT a recognized CSS-in-JS
1244        // import, so it must not fire at all even though `cva` is a Panda name.
1245        let cva = "import { cva } from 'class-variance-authority';\n\
1246                   const button = cva('base', { variants: { size: { sm: 'text-sm' } } });\n";
1247        assert!(
1248            sheets(cva).is_empty(),
1249            "unrelated cva must not fire: {:?}",
1250            sheets(cva)
1251        );
1252
1253        // Panda `cva` from styled-system: only `base` is CSS; `variants` (a config
1254        // map of class objects) must be dropped, never serialized as garbage.
1255        let panda = "import { cva } from '../styled-system/css';\n\
1256                     const button = cva({ base: { color: 'red', padding: 8, margin: 4, top: 1 }, variants: { size: { sm: { fontSize: 12 } } } });\n";
1257        let s = sheets(panda);
1258        let css = s.atomic.expect("panda cva base is atomic");
1259        assert!(css.contains("color:red;"), "base serialized: {css:?}");
1260        assert!(
1261            !css.contains("size"),
1262            "variants config not serialized: {css:?}"
1263        );
1264        let a = compute_css_analytics(&css).expect("parses cleanly");
1265        assert!(
1266            a.notable_rules.is_empty(),
1267            "no garbled structural finding: {a:?}"
1268        );
1269    }
1270
1271    #[test]
1272    fn panda_cva_and_class_variance_authority_cva_coexist() {
1273        // Both `cva` names can appear in one file only under distinct local
1274        // aliases (duplicate bindings are a JS error). class-variance-authority is
1275        // filtered out before the import map, so only Panda's binding is tracked:
1276        // its `base` lifts to atomic CSS while the class-name builder stays inert.
1277        let src = "import { cva } from '../styled-system/css';\n\
1278                   import { cva as cn } from 'class-variance-authority';\n\
1279                   const a = cva({ base: { color: 'red' } });\n\
1280                   const b = cn('base', { variants: { size: { sm: 'text-sm' } } });\n";
1281        let css = sheets(src).atomic.expect("panda cva base is atomic");
1282        assert!(css.contains("color:red;"), "panda base lifted: {css:?}");
1283        assert!(!css.contains("text-sm"), "cva-lib not serialized: {css:?}");
1284    }
1285
1286    #[test]
1287    fn local_helper_with_recognized_name_does_not_fire() {
1288        // A local `const css = ...` with no recognized import must never fire,
1289        // even though `css` is a recognized library call name.
1290        let src = "const css = (o) => o;\n\
1291                   const x = css({ color: 'red', padding: 8 });\n";
1292        assert!(
1293            sheets(src).is_empty(),
1294            "local css helper must not fire: {:?}",
1295            sheets(src)
1296        );
1297    }
1298
1299    #[test]
1300    fn type_only_import_does_not_open_the_gate() {
1301        let src = "import type { style } from '@vanilla-extract/css';\n\
1302                   const x = style({ color: 'red' });\n";
1303        assert!(
1304            sheets(src).is_empty(),
1305            "type-only import must not open provenance: {:?}",
1306            sheets(src)
1307        );
1308    }
1309
1310    #[test]
1311    fn all_dynamic_bucket_emits_no_empty_rule() {
1312        let src = "import { style } from '@vanilla-extract/css';\n\
1313                   import { v } from './v';\n\
1314                   const x = style({ color: v.a, background: v.b });\n";
1315        let s = sheets(src);
1316        // Every value dynamic -> body empty -> bucket dropped entirely, no empty
1317        // `.fallow-css-in-js{}` rule in any sheet.
1318        assert!(s.is_empty(), "all-dynamic bucket dropped entirely: {s:?}");
1319    }
1320
1321    #[test]
1322    fn aliased_named_import_still_recognized() {
1323        // `import { style as s }` dispatches on the canonical name, not the alias.
1324        let src = "import { style as s, globalStyle as gs } from '@vanilla-extract/css';\n\
1325                   export const a = s({ color: 'red' });\n\
1326                   gs('html', { margin: 0 });\n";
1327        let s = sheets(src);
1328        let css = s.structural.expect("aliased style/globalStyle recognized");
1329        assert!(css.contains("color:red;"), "aliased style fired: {css:?}");
1330        assert!(
1331            css.contains("html{margin:0;}"),
1332            "aliased globalStyle fired: {css:?}"
1333        );
1334    }
1335
1336    #[test]
1337    fn emotion_css_default_import_recognized() {
1338        // `@emotion/css` default export IS the css function.
1339        let src = "import css from '@emotion/css';\n\
1340                   const a = css({ color: 'red' });\n";
1341        let css = sheets(src)
1342            .structural
1343            .expect("default css import recognized");
1344        assert!(css.contains("color:red;"), "css={css:?}");
1345    }
1346
1347    #[test]
1348    fn emotion_css_default_import_aliased_recognized() {
1349        // The `@emotion/css` default css function fires under ANY local alias, not
1350        // only the conventional `css` name (canonical-role dispatch on the lib).
1351        let src = "import emo from '@emotion/css';\n\
1352                   const a = emo({ color: 'red' });\n";
1353        let css = sheets(src)
1354            .structural
1355            .expect("aliased default css import recognized");
1356        assert!(css.contains("color:red;"), "css={css:?}");
1357    }
1358
1359    #[test]
1360    fn non_decimal_numeric_literals_become_valid_css() {
1361        // Hex / scientific literals render from their parsed value, never the raw
1362        // `0xFF` / `1e3` source text (which the CSS parser would reject).
1363        let src = "import { style } from '@vanilla-extract/css';\n\
1364                   const x = style({ padding: 0xFF, zIndex: 1e3 });\n";
1365        let css = sheets(src).structural.expect("structural");
1366        assert!(
1367            css.contains("padding:255px;"),
1368            "hex -> decimal px: css={css:?}"
1369        );
1370        assert!(
1371            css.contains("z-index:1000;"),
1372            "scientific -> decimal: css={css:?}"
1373        );
1374        assert!(compute_css_analytics(&css).is_some(), "valid CSS");
1375    }
1376
1377    #[test]
1378    fn custom_property_numeric_value_keeps_no_unit() {
1379        // A numeric custom-property value must NOT gain an implicit `px`: emotion
1380        // (`!isCustomProperty`) and React both leave `--x: 8` unitless, so adding
1381        // `px` would fabricate a unit the bundler never emits.
1382        let src = "import { css } from '@emotion/react';\n\
1383                   const g = css({ ':root': { '--space': 8, '--ratio': 1.5 }, padding: 8 });\n";
1384        // `:root` is a selector key -> nested rule with the custom properties.
1385        let sheet = sheets(src)
1386            .structural
1387            .or_else(|| sheets(src).structural_partial)
1388            .expect("structural output");
1389        assert!(
1390            sheet.contains("--space:8;"),
1391            "custom prop keeps no unit: {sheet:?}"
1392        );
1393        assert!(
1394            sheet.contains("--ratio:1.5;"),
1395            "custom prop float unchanged: {sheet:?}"
1396        );
1397        // A normal property still gets px.
1398        assert!(
1399            sheet.contains("padding:8px;"),
1400            "normal prop still px: {sheet:?}"
1401        );
1402    }
1403
1404    #[test]
1405    fn ms_vendor_prefix_kebabs_with_leading_dash() {
1406        assert_eq!(kebab_case("msFlexAlign"), "-ms-flex-align");
1407        assert_eq!(kebab_case("WebkitBoxShadow"), "-webkit-box-shadow");
1408        assert_eq!(kebab_case("backgroundColor"), "background-color");
1409        // `msg`-prefixed non-vendor names are not mangled.
1410        assert_eq!(kebab_case("msgType"), "msg-type");
1411    }
1412
1413    #[test]
1414    fn negative_numbers_handled() {
1415        let src = "import { style } from '@vanilla-extract/css';\n\
1416                   const x = style({ marginTop: -8, zIndex: -1 });\n";
1417        let css = sheets(src).structural.expect("structural");
1418        assert!(css.contains("margin-top:-8px;"), "css={css:?}");
1419        assert!(
1420            css.contains("z-index:-1;"),
1421            "unitless negative: css={css:?}"
1422        );
1423    }
1424
1425    #[test]
1426    fn none_without_any_object_css_in_js() {
1427        assert!(sheets("const x = 1; function f() {}").is_empty());
1428        assert!(sheets("import React from 'react'; const x = <div/>;").is_empty());
1429    }
1430
1431    #[test]
1432    fn line_numbers_map_back_to_source() {
1433        // The `color` declaration's bucket is the `style({...})` object starting on
1434        // source line 3; the lifted sheet must keep a non-blank token at line 3.
1435        let src = "import { style } from '@vanilla-extract/css';\n\
1436                   \n\
1437                   const a = style({\n\
1438                   color: 'red',\n\
1439                   });\n";
1440        let css = sheets(src).structural.expect("structural");
1441        let pos = css.find("color").expect("color present");
1442        let css_line = 1 + css[..pos].bytes().filter(|&b| b == b'\n').count();
1443        assert_eq!(
1444            css_line, 3,
1445            "bucket maps to the style() object line: css={css:?}"
1446        );
1447    }
1448
1449    #[test]
1450    fn multibyte_content_value_preserved() {
1451        let src = "import { style } from '@vanilla-extract/css';\n\
1452                   const x = style({ content: '\"café 日本 €\"', fontFamily: '\"Ñoño\"' });\n";
1453        let css = sheets(src).structural.expect("structural");
1454        assert!(
1455            css.contains("café 日本 €"),
1456            "multibyte preserved: css={css:?}"
1457        );
1458        assert!(
1459            compute_css_analytics(&css).is_some(),
1460            "valid UTF-8 / parses"
1461        );
1462    }
1463
1464    #[test]
1465    fn distinct_colors_fall_out_of_object_styles() {
1466        let src = "import * as stylex from '@stylexjs/stylex';\n\
1467                   const s = stylex.create({ a: { color: 'red' }, b: { color: 'blue' }, c: { color: 'red' } });\n";
1468        let css = sheets(src).atomic.expect("atomic");
1469        let a = compute_css_analytics(&css).expect("parses");
1470        assert_eq!(a.colors.len(), 2, "distinct colors counted: {:?}", a.colors);
1471    }
1472
1473    #[test]
1474    fn multi_bucket_padding_uses_key_line() {
1475        // Each stylex.create bucket pads to its KEY line, so two buckets do not
1476        // collapse onto the call line.
1477        let src = "import * as stylex from '@stylexjs/stylex';\n\
1478                   const s = stylex.create({\n\
1479                   root: { color: 'red' },\n\
1480                   card: { color: 'blue' },\n\
1481                   });\n";
1482        let css = sheets(src).atomic.expect("atomic");
1483        let red = css.find("color:red").expect("root present");
1484        let blue = css.find("color:blue").expect("card present");
1485        let red_line = 1 + css[..red].bytes().filter(|&b| b == b'\n').count();
1486        let blue_line = 1 + css[..blue].bytes().filter(|&b| b == b'\n').count();
1487        assert_eq!(red_line, 3, "root on its key line: css={css:?}");
1488        assert_eq!(blue_line, 4, "card on its own key line: css={css:?}");
1489    }
1490}