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