Skip to main content

fallow_extract/css_in_js/
tokens.rs

1//! CSS-in-JS design-token DEFINITION walker for the design-token blast-radius
2//! (CSS program Phase 3d).
3//!
4//! The zero-runtime CSS-in-JS libraries declare design tokens as a JS OBJECT
5//! passed to a library call, binding the token surface to an exported identifier
6//! that consumers read via member access (`import { vars } from './tokens';
7//! vars.color.primary`). This module is the DEFINITION half of the token
8//! blast-radius: it parses JS/TS with oxc, gates recognition on import-binding
9//! provenance (reusing the sibling `object::module_library`), and for each
10//! recognized token-definition call emits the access BINDING plus the flattened
11//! dotted LEAF token paths (with each leaf's source line). The CONSUMER half (who
12//! reads `vars.color.primary` across modules) is resolved in the analyze layer
13//! against the module graph; this walker only produces the defined-token side.
14//!
15//! Health-time-only, like the 3b/3c CSS-in-JS lifters: it runs over file SOURCE
16//! and persists nothing to the extraction cache (no `CACHE_VERSION` bump).
17//!
18//! # Recognized definition shapes
19//!
20//! Recognition is gated on the callee binding being imported from a recognized
21//! token library in THIS file (a local `defineVars` helper or an unrelated
22//! `createTheme` never fires):
23//!
24//! - StyleX `stylex.defineVars({...})` (namespace member call) or
25//!   `defineVars({...})` (named import). Binding = the assigned identifier; StyleX
26//!   token objects are typically FLAT (depth-1 paths like `primaryColor`).
27//! - vanilla-extract `createThemeContract({...})`: binding = the assigned
28//!   identifier (the contract IS the vars surface consumers read).
29//! - vanilla-extract `createTheme({...})` (1-arg): returns `[themeClass, vars]`;
30//!   binding = the SECOND array-destructure element (`vars`); `themeClass` is a
31//!   class string, not a token surface.
32//! - vanilla-extract `createGlobalTheme(selector, {...})` (2-arg): returns the
33//!   vars object; binding = the assigned identifier.
34//! - PandaCSS `defineTokens({...})`: binding = the assigned identifier; token
35//!   objects with a `value` field collapse to the token path (`colors.brand`),
36//!   matching `token('colors.brand')` consumers.
37//! - PandaCSS `defineConfig({ theme: { tokens, semanticTokens } })`: binding =
38//!   `pandaConfig`; only static token object literals are read.
39//!
40//! The two CONTRACT-IMPLEMENTATION forms are deliberately NOT definition sites
41//! here, because the contract they fill was already declared by
42//! `createThemeContract` (captured above) and that is the binding consumers read:
43//! - `createTheme(contract, {...})` (2-arg) returns a class string; tokens fill
44//!   the existing `contract`.
45//! - `createGlobalTheme(selector, contract, {...})` (3-arg) returns void.
46//!
47use std::path::Path;
48
49use oxc_allocator::Allocator;
50use oxc_ast::ast::{
51    Argument, BindingPattern, ComputedMemberExpression, Expression, ImportDeclarationSpecifier,
52    NumericLiteral, ObjectExpression, ObjectPropertyKind, Program, Statement,
53    StaticMemberExpression, UnaryOperator, VariableDeclarator,
54};
55use oxc_ast_visit::{Visit, walk};
56use oxc_parser::Parser;
57use oxc_span::{GetSpan, SourceType};
58use rustc_hash::{FxHashMap, FxHashSet};
59
60use super::object::{Lib, module_library};
61
62const PANDA_CONFIG_BINDING: &str = "pandaConfig";
63
64/// A single defined design token: its dotted LEAF path relative to the access
65/// binding (`color.primary`, or flat `primaryColor` for StyleX), the 1-based
66/// source line of its key, and the static value when the literal is recoverable.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct CssInJsToken {
69    /// Dotted leaf path relative to the binding (e.g. `color.primary`).
70    pub path: String,
71    /// 1-based line of the token's key in the defining source.
72    pub def_line: u32,
73    /// Static token value for literal definitions. Dynamic expressions and
74    /// contract-only leaves have no value.
75    pub value: Option<String>,
76}
77
78/// A CSS-in-JS token-definition site: the exported access binding consumers read
79/// through (e.g. `vars`) and the flattened leaf tokens it defines.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct CssInJsTokenDef {
82    /// The identifier the token surface is bound to (`vars`), the receiver of
83    /// cross-module member access (`vars.color.primary`).
84    pub binding: String,
85    /// Which CSS-in-JS family defined the tokens.
86    pub origin: CssInJsTokenOrigin,
87    /// The flattened leaf tokens defined on `binding`.
88    pub tokens: Vec<CssInJsToken>,
89}
90
91/// The CSS-in-JS token system that produced a token definition.
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93pub enum CssInJsTokenOrigin {
94    /// StyleX `defineVars`.
95    StyleX,
96    /// vanilla-extract `createTheme` family definitions.
97    VanillaExtract,
98    /// PandaCSS `defineTokens`.
99    Panda,
100    /// styled-components / Emotion theme object definitions.
101    Theme,
102}
103
104/// Walk a JS/TS source for CSS-in-JS design-token DEFINITIONS, returning each
105/// access binding and its flattened leaf token paths. Empty when the source has
106/// no recognized token-library import (provenance gate closed).
107#[must_use]
108pub fn css_in_js_token_defs(source: &str, path: &Path) -> Vec<CssInJsTokenDef> {
109    let source_type = SourceType::from_path(path).unwrap_or_default();
110    let allocator = Allocator::default();
111    let ret = Parser::new(&allocator, source, source_type).parse();
112
113    let mut collector = TokenDefCollector::new(source);
114    collector.build_import_map(&ret.program);
115    if collector.imports.is_empty() {
116        return Vec::new();
117    }
118    collector.visit_program(&ret.program);
119    collector.defs
120}
121
122/// One located consumer of a CSS-in-JS token: the defined LEAF token path it
123/// reads (relative to the binding, e.g. `color.primary`) and the 1-based line of
124/// the member-access site.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct TokenConsumerHit {
127    /// The defined leaf token path consumed (`color.primary`), relative to the
128    /// access binding (the leading binding segment stripped).
129    pub token_path: String,
130    /// 1-based line of the member-access site in the consuming source.
131    pub line: u32,
132}
133
134/// Walk a consuming JS/TS source for cross-module reads of a token binding,
135/// returning the located reads that resolve to a DEFINED leaf token path. The
136/// caller supplies the local `alias` the consuming file imported the token binding
137/// under (so aliased imports work) and the set of defined leaf paths. A member
138/// access `<alias>.a.b` is a hit when `a.b` is exactly a defined leaf path;
139/// intermediate groups (`<alias>.a` where only `a.b` is defined) and accesses on
140/// other bindings are not hits, so there is no double-count and no false match.
141#[must_use]
142#[expect(
143    clippy::implicit_hasher,
144    reason = "callers build an FxHashSet; std HashSet is a disallowed type here"
145)]
146pub fn css_in_js_token_consumers(
147    source: &str,
148    path: &Path,
149    alias: &str,
150    leaf_paths: &FxHashSet<String>,
151) -> Vec<TokenConsumerHit> {
152    css_in_js_consumer_scan(
153        source,
154        path,
155        &[ConsumerQuery::MemberBinding { alias, leaf_paths }],
156    )
157    .into_iter()
158    .map(|(_, hit)| hit)
159    .collect()
160}
161
162/// Walk a consuming JS/TS source for PandaCSS `token('path.to.token')` calls.
163/// The caller supplies the local alias imported from Panda's generated
164/// `styled-system` token module and the set of defined leaf paths.
165#[must_use]
166#[expect(
167    clippy::implicit_hasher,
168    reason = "callers build an FxHashSet; std HashSet is a disallowed type here"
169)]
170pub fn panda_token_call_consumers(
171    source: &str,
172    path: &Path,
173    alias: &str,
174    leaf_paths: &FxHashSet<String>,
175) -> Vec<TokenConsumerHit> {
176    css_in_js_consumer_scan(
177        source,
178        path,
179        &[ConsumerQuery::PandaTokenCall { alias, leaf_paths }],
180    )
181    .into_iter()
182    .map(|(_, hit)| hit)
183    .collect()
184}
185
186/// Walk a consuming JS/TS source for common PandaCSS style calls whose object
187/// literal values statically name token paths.
188#[must_use]
189#[expect(
190    clippy::implicit_hasher,
191    reason = "callers build FxHashSet values; std HashSet is a disallowed type here"
192)]
193pub fn panda_style_value_consumers(
194    source: &str,
195    path: &Path,
196    aliases: &FxHashSet<String>,
197    leaf_paths: &FxHashSet<String>,
198) -> Vec<TokenConsumerHit> {
199    css_in_js_consumer_scan(
200        source,
201        path,
202        &[ConsumerQuery::PandaStyleValues {
203            aliases,
204            leaf_paths,
205        }],
206    )
207    .into_iter()
208    .map(|(_, hit)| hit)
209    .collect()
210}
211
212/// Walk a JS/TS source for statically-authored theme object definitions used by
213/// styled-components and Emotion. A `theme` or `*Theme` variable with an object
214/// literal initializer becomes a token surface, with nested scalar leaves exposed
215/// as dotted paths.
216#[must_use]
217pub fn css_in_js_theme_token_defs(source: &str, path: &Path) -> Vec<CssInJsTokenDef> {
218    let source_type = SourceType::from_path(path).unwrap_or_default();
219    let allocator = Allocator::default();
220    let ret = Parser::new(&allocator, source, source_type).parse();
221
222    let mut collector = ThemeDefCollector {
223        source,
224        defs: Vec::new(),
225    };
226    collector.visit_program(&ret.program);
227    collector.defs
228}
229
230/// Walk a consuming JS/TS source for styled-components / Emotion theme reads such
231/// as `theme.colors.brand` and `props.theme.colors.brand`.
232#[must_use]
233#[expect(
234    clippy::implicit_hasher,
235    reason = "callers build an FxHashSet; std HashSet is a disallowed type here"
236)]
237pub fn css_in_js_theme_consumers(
238    source: &str,
239    path: &Path,
240    leaf_paths: &FxHashSet<String>,
241) -> Vec<TokenConsumerHit> {
242    css_in_js_consumer_scan(source, path, &[ConsumerQuery::ThemeReads { leaf_paths }])
243        .into_iter()
244        .map(|(_, hit)| hit)
245        .collect()
246}
247
248/// One attribution query to run against a single parsed consumer source. Each
249/// variant mirrors one of the single-query consumer functions above; a scan runs
250/// any mix of them against ONE parse of the source.
251pub enum ConsumerQuery<'a> {
252    /// Member-access reads `<alias>.a.b` of an imported token binding. Mirrors
253    /// [`css_in_js_token_consumers`].
254    MemberBinding {
255        /// The local identifier the token binding was imported under.
256        alias: &'a str,
257        /// The defined leaf token paths (`color.primary`).
258        leaf_paths: &'a FxHashSet<String>,
259    },
260    /// PandaCSS `token('a.b')` calls through the given alias. Mirrors
261    /// [`panda_token_call_consumers`].
262    PandaTokenCall {
263        /// The local alias imported from Panda's generated token module.
264        alias: &'a str,
265        /// The defined leaf token paths (`colors.brand`).
266        leaf_paths: &'a FxHashSet<String>,
267    },
268    /// PandaCSS style-call object values naming token paths. Mirrors
269    /// [`panda_style_value_consumers`].
270    PandaStyleValues {
271        /// The local aliases for Panda style calls (`css`, `cva`).
272        aliases: &'a FxHashSet<String>,
273        /// The defined leaf token paths (`colors.brand`).
274        leaf_paths: &'a FxHashSet<String>,
275    },
276    /// styled-components / Emotion theme reads (`theme.colors.x`). Mirrors
277    /// [`css_in_js_theme_consumers`].
278    ThemeReads {
279        /// The defined leaf token paths (`colors.brand`).
280        leaf_paths: &'a FxHashSet<String>,
281    },
282}
283
284/// Parse `source` once and run every query against the same AST, returning
285/// `(query_index, hit)` pairs so the caller can attribute each hit back to the
286/// definer that produced its query. Behavior per query is identical to the
287/// corresponding single-query function, including the empty-alias / empty-leaf
288/// short-circuits (a query that would have early-returned simply contributes no
289/// hits, without suppressing the other queries).
290#[must_use]
291pub fn css_in_js_consumer_scan(
292    source: &str,
293    path: &Path,
294    queries: &[ConsumerQuery<'_>],
295) -> Vec<(usize, TokenConsumerHit)> {
296    if queries.is_empty() {
297        return Vec::new();
298    }
299    let source_type = SourceType::from_path(path).unwrap_or_default();
300    let allocator = Allocator::default();
301    let ret = Parser::new(&allocator, source, source_type).parse();
302    let mut out = Vec::new();
303    for (idx, query) in queries.iter().enumerate() {
304        run_consumer_query(query, source, &ret.program, idx, &mut out);
305    }
306    out
307}
308
309/// Run one [`ConsumerQuery`] against an already-parsed `program`, tagging each
310/// resulting hit with `idx`. The per-variant guards mirror each single-query
311/// function's empty-input short-circuit exactly.
312fn run_consumer_query<'a>(
313    query: &ConsumerQuery<'_>,
314    source: &'a str,
315    program: &Program<'a>,
316    idx: usize,
317    out: &mut Vec<(usize, TokenConsumerHit)>,
318) {
319    match query {
320        ConsumerQuery::MemberBinding { alias, leaf_paths } => {
321            if alias.is_empty() || leaf_paths.is_empty() {
322                return;
323            }
324            let mut collector = ConsumerCollector {
325                source,
326                alias,
327                leaf_paths,
328                hits: Vec::new(),
329            };
330            collector.visit_program(program);
331            out.extend(collector.hits.into_iter().map(|hit| (idx, hit)));
332        }
333        ConsumerQuery::PandaTokenCall { alias, leaf_paths } => {
334            if alias.is_empty() || leaf_paths.is_empty() {
335                return;
336            }
337            let mut collector = PandaTokenCallCollector {
338                source,
339                alias,
340                leaf_paths,
341                hits: Vec::new(),
342            };
343            collector.visit_program(program);
344            out.extend(collector.hits.into_iter().map(|hit| (idx, hit)));
345        }
346        ConsumerQuery::PandaStyleValues {
347            aliases,
348            leaf_paths,
349        } => {
350            if aliases.is_empty() || leaf_paths.is_empty() {
351                return;
352            }
353            let mut collector = PandaStyleValueCollector {
354                source,
355                aliases,
356                leaf_paths,
357                hits: Vec::new(),
358            };
359            collector.visit_program(program);
360            out.extend(collector.hits.into_iter().map(|hit| (idx, hit)));
361        }
362        ConsumerQuery::ThemeReads { leaf_paths } => {
363            if leaf_paths.is_empty() {
364                return;
365            }
366            let mut collector = ThemeConsumerCollector {
367                source,
368                leaf_paths,
369                hits: Vec::new(),
370            };
371            collector.visit_program(program);
372            out.extend(collector.hits.into_iter().map(|hit| (idx, hit)));
373        }
374    }
375}
376
377/// Walks a consuming program for member accesses on a token binding alias.
378struct ConsumerCollector<'a, 'b> {
379    source: &'a str,
380    alias: &'b str,
381    leaf_paths: &'b FxHashSet<String>,
382    hits: Vec<TokenConsumerHit>,
383}
384
385impl<'a> ConsumerCollector<'a, '_> {
386    /// Record a hit if `(base, segments)` is exactly `<alias>.<leaf>` for a defined
387    /// leaf path. A node whose chain is `<alias>.<group>` (an intermediate group)
388    /// reconstructs a non-leaf path and is skipped, so each access site yields at
389    /// most one hit (no double count from the nested member expressions).
390    fn record(&mut self, chain: Option<(&'a str, Vec<&'a str>)>, span_start: u32) {
391        if let Some((base, segments)) = chain
392            && base == self.alias
393            && !segments.is_empty()
394        {
395            let token_path = segments.join(".");
396            if self.leaf_paths.contains(&token_path) {
397                self.hits.push(TokenConsumerHit {
398                    token_path,
399                    line: line_at(self.source, span_start),
400                });
401            }
402        }
403    }
404}
405
406impl<'a> Visit<'a> for ConsumerCollector<'a, '_> {
407    fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
408        let mut chain = access_object_chain(&member.object);
409        if let Some((_, segments)) = chain.as_mut() {
410            segments.push(member.property.name.as_str());
411        }
412        self.record(chain, member.span().start);
413        walk::walk_static_member_expression(self, member);
414    }
415
416    fn visit_computed_member_expression(&mut self, member: &ComputedMemberExpression<'a>) {
417        // Bracket access with a STATIC string-literal key (`vars.color['gray-100']`):
418        // the only way to consume a token whose key is not a valid JS identifier
419        // (hyphenated `gray-100`, digit-leading `0x`), which design-token systems use
420        // heavily. Non-literal computed keys (`vars.color[k]`) cannot be resolved
421        // statically and are skipped (a documented lower-bound miss).
422        let mut chain = access_object_chain(&member.object);
423        if let (Some((_, segments)), Some(key)) =
424            (chain.as_mut(), string_literal_key(&member.expression))
425        {
426            segments.push(key);
427        } else {
428            chain = None;
429        }
430        self.record(chain, member.span().start);
431        walk::walk_computed_member_expression(self, member);
432    }
433}
434
435struct PandaTokenCallCollector<'a, 'b> {
436    source: &'a str,
437    alias: &'b str,
438    leaf_paths: &'b FxHashSet<String>,
439    hits: Vec<TokenConsumerHit>,
440}
441
442impl<'a> Visit<'a> for PandaTokenCallCollector<'a, '_> {
443    fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
444        let Expression::Identifier(callee) = &call.callee else {
445            walk::walk_call_expression(self, call);
446            return;
447        };
448        if callee.name.as_str() == self.alias
449            && let Some(Argument::StringLiteral(lit)) = call.arguments.first()
450        {
451            let token_path = lit.value.as_str();
452            if self.leaf_paths.contains(token_path) {
453                self.hits.push(TokenConsumerHit {
454                    token_path: token_path.to_owned(),
455                    line: line_at(self.source, call.span().start),
456                });
457            }
458        }
459        walk::walk_call_expression(self, call);
460    }
461}
462
463struct PandaStyleValueCollector<'a, 'b> {
464    source: &'a str,
465    aliases: &'b FxHashSet<String>,
466    leaf_paths: &'b FxHashSet<String>,
467    hits: Vec<TokenConsumerHit>,
468}
469
470impl<'a> PandaStyleValueCollector<'a, '_> {
471    fn record_object(&mut self, obj: &ObjectExpression<'a>) {
472        for prop in &obj.properties {
473            let ObjectPropertyKind::ObjectProperty(prop) = prop else {
474                continue;
475            };
476            self.record_expression(&prop.value);
477        }
478    }
479
480    fn record_expression(&mut self, expr: &Expression<'a>) {
481        match expr {
482            Expression::StringLiteral(lit) => {
483                let token_path = lit.value.as_str();
484                if self.leaf_paths.contains(token_path) {
485                    self.hits.push(TokenConsumerHit {
486                        token_path: token_path.to_owned(),
487                        line: line_at(self.source, lit.span().start),
488                    });
489                }
490            }
491            Expression::ObjectExpression(obj) => self.record_object(obj),
492            _ => {}
493        }
494    }
495}
496
497impl<'a> Visit<'a> for PandaStyleValueCollector<'a, '_> {
498    fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
499        let Expression::Identifier(callee) = &call.callee else {
500            walk::walk_call_expression(self, call);
501            return;
502        };
503        if self.aliases.contains(callee.name.as_str()) {
504            for arg in &call.arguments {
505                if let Argument::ObjectExpression(obj) = arg {
506                    self.record_object(obj);
507                }
508            }
509        }
510        walk::walk_call_expression(self, call);
511    }
512}
513
514struct ThemeDefCollector<'a> {
515    source: &'a str,
516    defs: Vec<CssInJsTokenDef>,
517}
518
519impl<'a> ThemeDefCollector<'a> {
520    fn process_declarator(&mut self, decl: &VariableDeclarator<'a>) {
521        let BindingPattern::BindingIdentifier(binding) = &decl.id else {
522            return;
523        };
524        let binding_name = binding.name.as_str();
525        if !is_theme_binding_name(binding_name) {
526            return;
527        }
528        let Some(Expression::ObjectExpression(obj)) = &decl.init else {
529            return;
530        };
531        let mut tokens = Vec::new();
532        collect_token_leaves(self.source, obj, "", CssInJsTokenOrigin::Theme, &mut tokens);
533        if tokens.is_empty() {
534            return;
535        }
536        self.defs.push(CssInJsTokenDef {
537            binding: binding_name.to_owned(),
538            origin: CssInJsTokenOrigin::Theme,
539            tokens,
540        });
541    }
542}
543
544impl<'a> Visit<'a> for ThemeDefCollector<'a> {
545    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
546        self.process_declarator(decl);
547        walk::walk_variable_declarator(self, decl);
548    }
549}
550
551struct ThemeConsumerCollector<'a, 'b> {
552    source: &'a str,
553    leaf_paths: &'b FxHashSet<String>,
554    hits: Vec<TokenConsumerHit>,
555}
556
557impl<'a> ThemeConsumerCollector<'a, '_> {
558    fn record(&mut self, chain: Option<(&'a str, Vec<&'a str>)>, span_start: u32) {
559        let Some((base, segments)) = chain else {
560            return;
561        };
562        let token_segments: &[&str] = match base {
563            "theme" => &segments,
564            "props" if segments.first().copied() == Some("theme") => &segments[1..],
565            _ => return,
566        };
567        if token_segments.is_empty() {
568            return;
569        }
570        let token_path = token_segments.join(".");
571        if self.leaf_paths.contains(&token_path) {
572            self.hits.push(TokenConsumerHit {
573                token_path,
574                line: line_at(self.source, span_start),
575            });
576        }
577    }
578}
579
580impl<'a> Visit<'a> for ThemeConsumerCollector<'a, '_> {
581    fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
582        let mut chain = access_object_chain(&member.object);
583        if let Some((_, segments)) = chain.as_mut() {
584            segments.push(member.property.name.as_str());
585        }
586        self.record(chain, member.span().start);
587        walk::walk_static_member_expression(self, member);
588    }
589
590    fn visit_computed_member_expression(&mut self, member: &ComputedMemberExpression<'a>) {
591        let mut chain = access_object_chain(&member.object);
592        if let (Some((_, segments)), Some(key)) =
593            (chain.as_mut(), string_literal_key(&member.expression))
594        {
595            segments.push(key);
596        } else {
597            chain = None;
598        }
599        self.record(chain, member.span().start);
600        walk::walk_computed_member_expression(self, member);
601    }
602}
603
604/// Reconstruct the `(base identifier, [segments])` chain of a member-access OBJECT
605/// expression, threading through both static (`a.b`) and string-literal-computed
606/// (`a['b']`) member access. `vars.color` -> `("vars", ["color"])`. Returns `None`
607/// if the chain is not rooted at a plain identifier (a call result, `this`, a
608/// non-literal computed key, etc.).
609fn access_object_chain<'a>(expr: &Expression<'a>) -> Option<(&'a str, Vec<&'a str>)> {
610    match expr {
611        Expression::Identifier(id) => Some((id.name.as_str(), Vec::new())),
612        Expression::StaticMemberExpression(inner) => {
613            let (base, mut segments) = access_object_chain(&inner.object)?;
614            segments.push(inner.property.name.as_str());
615            Some((base, segments))
616        }
617        Expression::ComputedMemberExpression(inner) => {
618            let (base, mut segments) = access_object_chain(&inner.object)?;
619            segments.push(string_literal_key(&inner.expression)?);
620            Some((base, segments))
621        }
622        _ => None,
623    }
624}
625
626/// The value of a string-literal computed-member key (`['gray-100']`), or `None`
627/// for any non-string-literal key (which cannot be resolved statically).
628fn string_literal_key<'a>(expr: &Expression<'a>) -> Option<&'a str> {
629    match expr {
630        Expression::StringLiteral(lit) => Some(lit.value.as_str()),
631        _ => None,
632    }
633}
634
635/// Where the access binding comes from for a recognized token-definition call.
636#[derive(Clone, Copy)]
637enum BindingSource {
638    /// The assigned identifier (`const vars = ...`).
639    LhsIdent,
640    /// An element of an array-destructure (`const [_, vars] = ...`).
641    TupleElement(usize),
642}
643
644/// A recognized token-definition call: where the binding comes from and which
645/// argument carries the token object.
646#[derive(Clone, Copy)]
647struct Recognized {
648    binding_source: BindingSource,
649    tokens_arg: usize,
650    origin: CssInJsTokenOrigin,
651}
652
653/// Collects token-definition sites, gated on import provenance.
654struct TokenDefCollector<'a> {
655    source: &'a str,
656    /// local-binding name -> (library, canonical role). Mirrors the
657    /// `css_in_js_object` provenance map but for token-definition roles.
658    imports: FxHashMap<&'a str, (Lib, &'a str)>,
659    defs: Vec<CssInJsTokenDef>,
660}
661
662impl<'a> TokenDefCollector<'a> {
663    fn new(source: &'a str) -> Self {
664        Self {
665            source,
666            imports: FxHashMap::default(),
667            defs: Vec::new(),
668        }
669    }
670
671    /// Map each import binding from a recognized token library to its library +
672    /// canonical role. Named imports dispatch on the imported (canonical) name so
673    /// `import { createTheme as ct }` still fires; default / namespace bindings
674    /// (`import * as stylex`) carry the local name for member-call recognition.
675    fn build_import_map(&mut self, program: &Program<'a>) {
676        for stmt in &program.body {
677            let Statement::ImportDeclaration(decl) = stmt else {
678                continue;
679            };
680            if decl.import_kind.is_type() {
681                continue;
682            }
683            let Some(lib) = module_library(decl.source.value.as_str()) else {
684                continue;
685            };
686            let Some(specifiers) = &decl.specifiers else {
687                continue;
688            };
689            for specifier in specifiers {
690                let (local, role) = match specifier {
691                    ImportDeclarationSpecifier::ImportSpecifier(s) => {
692                        (s.local.name.as_str(), s.imported.name().as_str())
693                    }
694                    ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
695                        (s.local.name.as_str(), s.local.name.as_str())
696                    }
697                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
698                        (s.local.name.as_str(), s.local.name.as_str())
699                    }
700                };
701                self.imports.insert(local, (lib, role));
702            }
703        }
704    }
705
706    /// Resolve a call's callee to `(library, role)` if its binding is a recognized
707    /// token-library import. Handles both a named/aliased import callee
708    /// (`defineVars(...)`) and a namespace member call (`stylex.defineVars(...)`).
709    fn callee_role(&self, callee: &Expression<'a>) -> Option<(Lib, &'a str)> {
710        match callee {
711            Expression::Identifier(id) => self.imports.get(id.name.as_str()).copied(),
712            Expression::StaticMemberExpression(member) => {
713                let Expression::Identifier(obj) = &member.object else {
714                    return None;
715                };
716                let (lib, _) = *self.imports.get(obj.name.as_str())?;
717                // Member-call role is the accessed property (`stylex.defineVars`).
718                Some((lib, member.property.name.as_str()))
719            }
720            _ => None,
721        }
722    }
723
724    /// Dispatch `(library, role, arg_count)` to a recognized token-definition
725    /// form, or `None` (unrecognized, or a contract-implementation form whose
726    /// contract is the canonical definition).
727    fn recognize(lib: Lib, role: &str, arg_count: usize) -> Option<Recognized> {
728        let single = |tokens_arg, origin| {
729            Some(Recognized {
730                binding_source: BindingSource::LhsIdent,
731                tokens_arg,
732                origin,
733            })
734        };
735        match (lib, role) {
736            // `defineVars(obj)` / `createThemeContract(obj)`: binding = the assigned
737            // identifier, token object = arg 0.
738            (Lib::StyleX, "defineVars") if arg_count >= 1 => single(0, CssInJsTokenOrigin::StyleX),
739            (Lib::VanillaExtract, "createThemeContract") if arg_count >= 1 => {
740                single(0, CssInJsTokenOrigin::VanillaExtract)
741            }
742            // 1-arg createTheme returns [themeClass, vars]; tokens on the second
743            // destructure element. The 2-arg (contract, tokens) form fills an
744            // existing contract and is skipped (createThemeContract is canonical).
745            (Lib::VanillaExtract, "createTheme") if arg_count == 1 => Some(Recognized {
746                binding_source: BindingSource::TupleElement(1),
747                tokens_arg: 0,
748                origin: CssInJsTokenOrigin::VanillaExtract,
749            }),
750            // 2-arg createGlobalTheme(selector, tokens) returns the vars object;
751            // the 3-arg (selector, contract, tokens) form returns void (contract
752            // canonical), so only the 2-arg form is a definition site here.
753            (Lib::VanillaExtract, "createGlobalTheme") if arg_count == 2 => {
754                single(1, CssInJsTokenOrigin::VanillaExtract)
755            }
756            (Lib::Panda, "defineTokens") if arg_count >= 1 => single(0, CssInJsTokenOrigin::Panda),
757            _ => None,
758        }
759    }
760
761    /// Extract the access binding name from a declarator's binding pattern for the
762    /// recognized binding source.
763    fn binding_name(decl: &VariableDeclarator<'a>, source: BindingSource) -> Option<&'a str> {
764        match source {
765            BindingSource::LhsIdent => match &decl.id {
766                BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
767                _ => None,
768            },
769            BindingSource::TupleElement(index) => {
770                let BindingPattern::ArrayPattern(arr) = &decl.id else {
771                    return None;
772                };
773                let element = arr.elements.get(index)?.as_ref()?;
774                match element {
775                    BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
776                    _ => None,
777                }
778            }
779        }
780    }
781
782    fn process_declarator(&mut self, decl: &VariableDeclarator<'a>) {
783        let Some(Expression::CallExpression(call)) = &decl.init else {
784            return;
785        };
786        if self.process_panda_config_call(call) {
787            return;
788        }
789        let Some((lib, role)) = self.callee_role(&call.callee) else {
790            return;
791        };
792        let Some(recognized) = Self::recognize(lib, role, call.arguments.len()) else {
793            return;
794        };
795        let Some(binding) = Self::binding_name(decl, recognized.binding_source) else {
796            return;
797        };
798        let Some(Argument::ObjectExpression(obj)) = call.arguments.get(recognized.tokens_arg)
799        else {
800            return;
801        };
802        let mut tokens = Vec::new();
803        collect_token_leaves(self.source, obj, "", recognized.origin, &mut tokens);
804        if tokens.is_empty() {
805            return;
806        }
807        self.defs.push(CssInJsTokenDef {
808            binding: binding.to_owned(),
809            origin: recognized.origin,
810            tokens,
811        });
812    }
813
814    fn process_panda_config_call(&mut self, call: &oxc_ast::ast::CallExpression<'a>) -> bool {
815        let Some((Lib::Panda, "defineConfig")) = self.callee_role(&call.callee) else {
816            return false;
817        };
818        let Some(Argument::ObjectExpression(obj)) = call.arguments.first() else {
819            return true;
820        };
821        let mut tokens = Vec::new();
822        collect_panda_config_token_leaves(self.source, obj, &mut tokens);
823        if !tokens.is_empty() {
824            self.defs.push(CssInJsTokenDef {
825                binding: PANDA_CONFIG_BINDING.to_string(),
826                origin: CssInJsTokenOrigin::Panda,
827                tokens,
828            });
829        }
830        true
831    }
832}
833
834/// Flatten an object literal into dotted LEAF paths. An inline-object value
835/// recurses (an intermediate token GROUP, not a token); a value-producing
836/// expression (string / number / `null` contract leaf / call like
837/// `px(2 * grid)` / template / member access like `colors.red['500']`) is a LEAF
838/// token. A BARE IDENTIFIER value (`palette: tailwindPalette`) is SKIPPED: it
839/// references something whose structure is invisible here, most often an imported
840/// token GROUP (recording it as a leaf would invent a phantom token and wrongly
841/// credit every `vars.palette.<x>` access to it). Spreads and computed keys are
842/// skipped because they cannot be resolved statically.
843fn collect_token_leaves(
844    source: &str,
845    obj: &ObjectExpression<'_>,
846    prefix: &str,
847    origin: CssInJsTokenOrigin,
848    out: &mut Vec<CssInJsToken>,
849) {
850    for prop in &obj.properties {
851        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
852            continue;
853        };
854        let Some(key) = prop.key.static_name() else {
855            continue;
856        };
857        let path = if prefix.is_empty() {
858            key.to_string()
859        } else {
860            format!("{prefix}.{key}")
861        };
862        match &prop.value {
863            Expression::ObjectExpression(nested)
864                if origin == CssInJsTokenOrigin::Panda
865                    && !prefix.is_empty()
866                    && object_has_static_key(nested, "value") =>
867            {
868                out.push(CssInJsToken {
869                    path,
870                    def_line: line_at(source, prop.key.span().start),
871                    value: object_static_property_value(nested, "value"),
872                });
873            }
874            Expression::ObjectExpression(nested) => {
875                collect_token_leaves(source, nested, &path, origin, out);
876            }
877            // A bare identifier is an unresolvable reference, usually an imported
878            // token group; do not record it as a leaf.
879            Expression::Identifier(_) => {}
880            _ => out.push(CssInJsToken {
881                value: static_token_value(&prop.value),
882                path,
883                def_line: line_at(source, prop.key.span().start),
884            }),
885        }
886    }
887}
888
889fn object_static_property_value(obj: &ObjectExpression<'_>, wanted: &str) -> Option<String> {
890    obj.properties.iter().find_map(|prop| {
891        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
892            return None;
893        };
894        (prop.key.static_name().as_deref() == Some(wanted))
895            .then(|| static_token_value(&prop.value))
896            .flatten()
897    })
898}
899
900fn static_token_value(value: &Expression<'_>) -> Option<String> {
901    match value {
902        Expression::StringLiteral(lit) => {
903            let text = lit.value.as_str().trim();
904            (!text.is_empty()).then(|| text.to_string())
905        }
906        Expression::NumericLiteral(num) => Some(format_numeric_token(num)),
907        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
908            if let Expression::NumericLiteral(num) = &unary.argument {
909                Some(format!("-{}", format_numeric_token(num)))
910            } else {
911                None
912            }
913        }
914        _ => None,
915    }
916}
917
918fn format_numeric_token(num: &NumericLiteral<'_>) -> String {
919    if num.value.fract() == 0.0 {
920        format!("{:.0}", num.value)
921    } else {
922        num.value.to_string()
923    }
924}
925
926fn is_theme_binding_name(name: &str) -> bool {
927    let lower = name.to_ascii_lowercase();
928    lower == "theme" || lower.ends_with("theme")
929}
930
931fn object_has_static_key(obj: &ObjectExpression<'_>, wanted: &str) -> bool {
932    obj.properties.iter().any(|prop| {
933        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
934            return false;
935        };
936        prop.key.static_name().is_some_and(|key| key == wanted)
937    })
938}
939
940fn object_static_property_object<'a>(
941    obj: &'a ObjectExpression<'a>,
942    wanted: &str,
943) -> Option<&'a ObjectExpression<'a>> {
944    obj.properties.iter().find_map(|prop| {
945        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
946            return None;
947        };
948        if prop.key.static_name().as_deref() == Some(wanted)
949            && let Expression::ObjectExpression(value) = &prop.value
950        {
951            Some(&**value)
952        } else {
953            None
954        }
955    })
956}
957
958fn collect_panda_config_token_leaves(
959    source: &str,
960    obj: &ObjectExpression<'_>,
961    out: &mut Vec<CssInJsToken>,
962) {
963    let Some(theme) = object_static_property_object(obj, "theme") else {
964        return;
965    };
966    for key in ["tokens", "semanticTokens"] {
967        if let Some(tokens) = object_static_property_object(theme, key) {
968            collect_token_leaves(source, tokens, "", CssInJsTokenOrigin::Panda, out);
969        }
970    }
971}
972
973impl<'a> Visit<'a> for TokenDefCollector<'a> {
974    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
975        self.process_declarator(decl);
976        walk::walk_variable_declarator(self, decl);
977    }
978
979    fn visit_export_default_declaration(
980        &mut self,
981        decl: &oxc_ast::ast::ExportDefaultDeclaration<'a>,
982    ) {
983        if let Some(Expression::CallExpression(call)) = decl.declaration.as_expression() {
984            self.process_panda_config_call(call);
985        }
986        walk::walk_export_default_declaration(self, decl);
987    }
988}
989
990/// 1-based line number of a byte offset in `source`. Uses `.get(..end)` so an
991/// out-of-range or non-char-boundary offset clamps to line 1 rather than
992/// panicking (matches `css::line_at_offset`).
993fn line_at(source: &str, offset: u32) -> u32 {
994    let end = (offset as usize).min(source.len());
995    let count = source
996        .get(..end)
997        .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
998    u32::try_from(1 + count).unwrap_or(u32::MAX)
999}
1000
1001#[cfg(all(test, not(miri)))]
1002mod tests {
1003    use super::*;
1004
1005    fn defs(source: &str) -> Vec<CssInJsTokenDef> {
1006        css_in_js_token_defs(source, Path::new("tokens.ts"))
1007    }
1008
1009    fn paths(defs: &[CssInJsTokenDef], binding: &str) -> Vec<String> {
1010        defs.iter()
1011            .find(|d| d.binding == binding)
1012            .map(|d| d.tokens.iter().map(|t| t.path.clone()).collect())
1013            .unwrap_or_default()
1014    }
1015
1016    fn token_values(defs: &[CssInJsTokenDef], binding: &str) -> Vec<(String, Option<String>)> {
1017        defs.iter()
1018            .find(|d| d.binding == binding)
1019            .map(|d| {
1020                d.tokens
1021                    .iter()
1022                    .map(|t| (t.path.clone(), t.value.clone()))
1023                    .collect()
1024            })
1025            .unwrap_or_default()
1026    }
1027
1028    fn theme_defs(source: &str) -> Vec<CssInJsTokenDef> {
1029        css_in_js_theme_token_defs(source, Path::new("theme.ts"))
1030    }
1031
1032    #[test]
1033    fn stylex_define_vars_flat_namespace_call() {
1034        let d = defs(
1035            r"
1036import * as stylex from '@stylexjs/stylex';
1037export const vars = stylex.defineVars({ primaryColor: '#3b82f6', spacingSm: '4px' });
1038",
1039        );
1040        assert_eq!(paths(&d, "vars"), vec!["primaryColor", "spacingSm"]);
1041        assert_eq!(
1042            token_values(&d, "vars"),
1043            vec![
1044                ("primaryColor".to_string(), Some("#3b82f6".to_string())),
1045                ("spacingSm".to_string(), Some("4px".to_string())),
1046            ]
1047        );
1048    }
1049
1050    #[test]
1051    fn stylex_define_vars_named_import_nested() {
1052        let d = defs(
1053            r"
1054import { defineVars } from '@stylexjs/stylex';
1055export const vars = defineVars({ color: { primary: '#000', secondary: '#fff' } });
1056",
1057        );
1058        assert_eq!(paths(&d, "vars"), vec!["color.primary", "color.secondary"]);
1059    }
1060
1061    #[test]
1062    fn panda_define_tokens_collapses_value_objects() {
1063        let d = defs(
1064            r"
1065import { defineTokens } from '@pandacss/dev';
1066export const tokens = defineTokens({
1067  colors: {
1068    brand: { value: '#f05a28' },
1069    accent: { value: '{colors.brand}' },
1070  },
1071  spacing: { card: { value: '1rem' } },
1072});
1073",
1074        );
1075        assert_eq!(
1076            paths(&d, "tokens"),
1077            vec!["colors.brand", "colors.accent", "spacing.card"]
1078        );
1079        assert_eq!(
1080            token_values(&d, "tokens"),
1081            vec![
1082                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1083                (
1084                    "colors.accent".to_string(),
1085                    Some("{colors.brand}".to_string())
1086                ),
1087                ("spacing.card".to_string(), Some("1rem".to_string())),
1088            ]
1089        );
1090        assert_eq!(
1091            d.iter().find(|d| d.binding == "tokens").unwrap().origin,
1092            CssInJsTokenOrigin::Panda
1093        );
1094    }
1095
1096    #[test]
1097    fn panda_define_config_extracts_tokens_and_semantic_tokens() {
1098        let d = defs(
1099            r"
1100import { defineConfig } from '@pandacss/dev';
1101
1102export default defineConfig({
1103  theme: {
1104    tokens: {
1105      colors: {
1106        brand: { value: '#f05a28' },
1107      },
1108    },
1109    semanticTokens: {
1110      colors: {
1111        surface: { value: { base: '{colors.brand}', _dark: '#111111' } },
1112      },
1113    },
1114    recipes: {
1115      card: { base: { color: 'colors.brand' } },
1116    },
1117  },
1118});
1119",
1120        );
1121        assert_eq!(
1122            paths(&d, "pandaConfig"),
1123            vec!["colors.brand", "colors.surface"]
1124        );
1125        assert_eq!(
1126            token_values(&d, "pandaConfig"),
1127            vec![
1128                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1129                ("colors.surface".to_string(), None),
1130            ]
1131        );
1132        assert_eq!(
1133            d.iter()
1134                .find(|d| d.binding == "pandaConfig")
1135                .unwrap()
1136                .origin,
1137            CssInJsTokenOrigin::Panda
1138        );
1139    }
1140
1141    #[test]
1142    fn theme_object_definitions_flatten_static_leaves() {
1143        let d = theme_defs(
1144            r"
1145export const appTheme = {
1146  colors: { brand: '#f05a28', accent: '#111' },
1147  space: { card: '1rem' },
1148  dynamic: palette,
1149};
1150",
1151        );
1152        assert_eq!(
1153            paths(&d, "appTheme"),
1154            vec!["colors.brand", "colors.accent", "space.card"]
1155        );
1156        assert_eq!(
1157            token_values(&d, "appTheme"),
1158            vec![
1159                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1160                ("colors.accent".to_string(), Some("#111".to_string())),
1161                ("space.card".to_string(), Some("1rem".to_string())),
1162            ]
1163        );
1164        assert_eq!(
1165            d.iter().find(|d| d.binding == "appTheme").unwrap().origin,
1166            CssInJsTokenOrigin::Theme
1167        );
1168    }
1169
1170    #[test]
1171    fn theme_consumers_credit_props_and_destructured_theme_reads() {
1172        let leaves = ["colors.brand", "space.card"]
1173            .into_iter()
1174            .map(str::to_owned)
1175            .collect();
1176        let hits = css_in_js_theme_consumers(
1177            r"
1178import styled from 'styled-components';
1179export const Card = styled.div`
1180  color: ${({ theme }) => theme.colors.brand};
1181  margin: ${props => props.theme.space.card};
1182`;
1183",
1184            Path::new("card.tsx"),
1185            &leaves,
1186        );
1187        let mut token_paths: Vec<String> = hits.into_iter().map(|hit| hit.token_path).collect();
1188        token_paths.sort();
1189        assert_eq!(token_paths, vec!["colors.brand", "space.card"]);
1190    }
1191
1192    #[test]
1193    fn ve_create_theme_tuple_destructure_binds_element_one() {
1194        let d = defs(
1195            r"
1196import { createTheme } from '@vanilla-extract/css';
1197export const [themeClass, vars] = createTheme({
1198  color: { brand: 'red', accent: 'blue' },
1199  space: { small: '4px' },
1200});
1201",
1202        );
1203        // Token paths bind to `vars` (element 1), NOT `themeClass`.
1204        assert_eq!(
1205            paths(&d, "vars"),
1206            vec!["color.brand", "color.accent", "space.small"]
1207        );
1208        assert!(paths(&d, "themeClass").is_empty());
1209    }
1210
1211    #[test]
1212    fn ve_create_theme_contract_null_leaves() {
1213        let d = defs(
1214            r"
1215import { createThemeContract } from '@vanilla-extract/css';
1216export const vars = createThemeContract({ color: { brand: null, accent: null } });
1217",
1218        );
1219        // `null` contract leaves are tokens (the contract declares the shape).
1220        assert_eq!(paths(&d, "vars"), vec!["color.brand", "color.accent"]);
1221    }
1222
1223    #[test]
1224    fn ve_create_global_theme_two_arg_binds_lhs_tokens_in_second_arg() {
1225        let d = defs(
1226            r"
1227import { createGlobalTheme } from '@vanilla-extract/css';
1228export const vars = createGlobalTheme(':root', { color: { brand: 'red' } });
1229",
1230        );
1231        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
1232    }
1233
1234    #[test]
1235    fn ve_create_theme_two_arg_contract_impl_is_not_a_definition_site() {
1236        // The 2-arg form fills an existing contract (declared by
1237        // createThemeContract elsewhere); it must NOT introduce a binding.
1238        let d = defs(
1239            r"
1240import { createTheme } from '@vanilla-extract/css';
1241export const themeClass = createTheme(vars, { color: { brand: 'red' } });
1242",
1243        );
1244        assert!(
1245            d.is_empty(),
1246            "2-arg createTheme must not define tokens, got {d:?}"
1247        );
1248    }
1249
1250    #[test]
1251    fn ve_create_global_theme_three_arg_contract_impl_is_not_a_definition_site() {
1252        let d = defs(
1253            r"
1254import { createGlobalTheme } from '@vanilla-extract/css';
1255createGlobalTheme(':root', vars, { color: { brand: 'red' } });
1256",
1257        );
1258        assert!(
1259            d.is_empty(),
1260            "3-arg createGlobalTheme must not define tokens, got {d:?}"
1261        );
1262    }
1263
1264    #[test]
1265    fn aliased_named_import_still_fires() {
1266        let d = defs(
1267            r"
1268import { createThemeContract as ct } from '@vanilla-extract/css';
1269export const vars = ct({ color: { brand: null } });
1270",
1271        );
1272        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
1273    }
1274
1275    #[test]
1276    fn local_helper_not_from_library_does_not_fire() {
1277        // A local `defineVars` shadowing the StyleX name must not be recognized.
1278        let d = defs(
1279            r"
1280function defineVars(o) { return o; }
1281export const vars = defineVars({ color: { primary: '#000' } });
1282",
1283        );
1284        assert!(d.is_empty(), "local defineVars must not fire, got {d:?}");
1285    }
1286
1287    #[test]
1288    fn unrelated_create_theme_import_does_not_fire() {
1289        let d = defs(
1290            r"
1291import { createTheme } from '@mui/material/styles';
1292export const theme = createTheme({ palette: { primary: { main: '#000' } } });
1293",
1294        );
1295        assert!(d.is_empty(), "non-VE createTheme must not fire, got {d:?}");
1296    }
1297
1298    #[test]
1299    fn type_only_import_does_not_fire() {
1300        let d = defs(
1301            r"
1302import type { defineVars } from '@stylexjs/stylex';
1303export const vars = defineVars({ color: { primary: '#000' } });
1304",
1305        );
1306        assert!(
1307            d.is_empty(),
1308            "type-only import must not gate recognition, got {d:?}"
1309        );
1310    }
1311
1312    #[test]
1313    fn token_def_lines_are_per_leaf() {
1314        let src = "import { defineVars } from '@stylexjs/stylex';\nexport const vars = defineVars({\n  color: {\n    primary: '#000',\n    secondary: '#fff',\n  },\n});\n";
1315        let d = defs(src);
1316        let def = d.iter().find(|d| d.binding == "vars").unwrap();
1317        let primary = def
1318            .tokens
1319            .iter()
1320            .find(|t| t.path == "color.primary")
1321            .unwrap();
1322        let secondary = def
1323            .tokens
1324            .iter()
1325            .find(|t| t.path == "color.secondary")
1326            .unwrap();
1327        assert_eq!(primary.def_line, 4);
1328        assert_eq!(secondary.def_line, 5);
1329    }
1330
1331    #[test]
1332    fn spread_and_computed_keys_are_skipped() {
1333        let d = defs(
1334            r"
1335import { defineVars } from '@stylexjs/stylex';
1336const base = { a: '1' };
1337export const vars = defineVars({ ...base, ['x' + 'y']: '2', real: '#000' });
1338",
1339        );
1340        // Only the statically-resolvable `real` leaf survives.
1341        assert_eq!(paths(&d, "vars"), vec!["real"]);
1342    }
1343
1344    #[test]
1345    fn identifier_valued_key_is_not_a_leaf_but_call_and_member_values_are() {
1346        // `palette: tailwindPalette` (bare identifier, an imported group) must NOT
1347        // become a phantom `palette` leaf; `radius: px(2)` (call) and
1348        // `red: colors.red['500']` (member access) are real scalar leaves.
1349        let d = defs(
1350            r"
1351import { createGlobalTheme } from '@vanilla-extract/css';
1352export const vars = createGlobalTheme(':root', {
1353  palette: tailwindPalette,
1354  radius: px(2),
1355  red: colors.red['500'],
1356});
1357",
1358        );
1359        let p = paths(&d, "vars");
1360        assert!(
1361            !p.contains(&"palette".to_string()),
1362            "identifier-valued key must not be a leaf: {p:?}"
1363        );
1364        assert!(
1365            p.contains(&"radius".to_string()),
1366            "call-valued key is a leaf: {p:?}"
1367        );
1368        assert!(
1369            p.contains(&"red".to_string()),
1370            "member-valued key is a leaf: {p:?}"
1371        );
1372    }
1373
1374    #[test]
1375    fn no_css_in_js_import_returns_empty() {
1376        let d = defs("export const vars = { color: { primary: '#000' } };");
1377        assert!(d.is_empty());
1378    }
1379
1380    fn leaves(paths: &[&str]) -> FxHashSet<String> {
1381        paths.iter().map(|s| (*s).to_string()).collect()
1382    }
1383
1384    fn consumers(source: &str, alias: &str, paths: &[&str]) -> Vec<TokenConsumerHit> {
1385        css_in_js_token_consumers(source, Path::new("card.ts"), alias, &leaves(paths))
1386    }
1387
1388    fn panda_consumers(source: &str, alias: &str, paths: &[&str]) -> Vec<TokenConsumerHit> {
1389        panda_token_call_consumers(source, Path::new("card.ts"), alias, &leaves(paths))
1390    }
1391
1392    fn panda_style_consumers(
1393        source: &str,
1394        aliases: &[&str],
1395        paths: &[&str],
1396    ) -> Vec<TokenConsumerHit> {
1397        let aliases = aliases.iter().map(|s| (*s).to_string()).collect();
1398        panda_style_value_consumers(source, Path::new("card.ts"), &aliases, &leaves(paths))
1399    }
1400
1401    #[test]
1402    fn consumer_matches_deepest_leaf_not_intermediate_group() {
1403        // `vars.color.primary` is the leaf; `vars.color` (an intermediate group)
1404        // must NOT be counted, so exactly one hit per access site.
1405        let hits = consumers(
1406            "const a = vars.color.primary;",
1407            "vars",
1408            &["color.primary", "color.secondary"],
1409        );
1410        assert_eq!(hits.len(), 1);
1411        assert_eq!(hits[0].token_path, "color.primary");
1412        assert_eq!(hits[0].line, 1);
1413    }
1414
1415    #[test]
1416    fn consumer_aliased_receiver() {
1417        // The caller passes the local alias; member access on it is matched.
1418        let hits = consumers("const a = v.color.primary;", "v", &["color.primary"]);
1419        assert_eq!(hits.len(), 1);
1420        assert_eq!(hits[0].token_path, "color.primary");
1421    }
1422
1423    #[test]
1424    fn consumer_multiple_sites_distinct_lines() {
1425        let src = "const a = vars.color.primary;\nconst b = vars.space.sm;\nconst c = vars.color.primary;";
1426        let hits = consumers(src, "vars", &["color.primary", "space.sm"]);
1427        assert_eq!(hits.len(), 3);
1428        let lines: Vec<u32> = hits.iter().map(|h| h.line).collect();
1429        assert_eq!(lines, vec![1, 2, 3]);
1430    }
1431
1432    #[test]
1433    fn consumer_in_style_object_value_position() {
1434        // The dominant real shape: a token read inside a style-call object value.
1435        let hits = consumers(
1436            "export const s = stylex.create({ root: { color: vars.color.primary } });",
1437            "vars",
1438            &["color.primary"],
1439        );
1440        assert_eq!(hits.len(), 1);
1441        assert_eq!(hits[0].token_path, "color.primary");
1442    }
1443
1444    #[test]
1445    fn panda_token_call_consumer_matches_string_literal() {
1446        let hits = panda_consumers(
1447            "export const c = css({ color: token('colors.brand') });",
1448            "token",
1449            &["colors.brand", "colors.accent"],
1450        );
1451        assert_eq!(hits.len(), 1);
1452        assert_eq!(hits[0].token_path, "colors.brand");
1453    }
1454
1455    #[test]
1456    fn panda_style_value_consumer_matches_known_token_string() {
1457        let hits = panda_style_consumers(
1458            "export const c = css({ color: 'colors.brand', _hover: { bg: 'colors.accent' } });",
1459            &["css"],
1460            &["colors.brand", "colors.accent", "colors.unused"],
1461        );
1462        let paths: Vec<_> = hits.iter().map(|hit| hit.token_path.as_str()).collect();
1463        assert_eq!(paths, vec!["colors.brand", "colors.accent"]);
1464    }
1465
1466    #[test]
1467    fn panda_style_value_consumer_ignores_unimported_alias() {
1468        let hits = panda_style_consumers(
1469            "export const c = notPanda({ color: 'colors.brand' });",
1470            &["css"],
1471            &["colors.brand"],
1472        );
1473        assert!(hits.is_empty());
1474    }
1475
1476    #[test]
1477    fn consumer_flat_stylex_depth_one() {
1478        let hits = consumers("const a = vars.primaryColor;", "vars", &["primaryColor"]);
1479        assert_eq!(hits.len(), 1);
1480        assert_eq!(hits[0].token_path, "primaryColor");
1481    }
1482
1483    #[test]
1484    fn consumer_other_binding_not_matched() {
1485        // A same-named member access on a DIFFERENT binding must not be a hit.
1486        let hits = consumers("const a = other.color.primary;", "vars", &["color.primary"]);
1487        assert!(hits.is_empty());
1488    }
1489
1490    #[test]
1491    fn consumer_deeper_access_past_leaf_matches_leaf_subexpression_once() {
1492        // `vars.color.primary.toString()` reads the leaf `color.primary`; the outer
1493        // `.toString` chain is not a leaf, the inner `vars.color.primary` is.
1494        let hits = consumers(
1495            "const a = vars.color.primary.toString();",
1496            "vars",
1497            &["color.primary"],
1498        );
1499        assert_eq!(hits.len(), 1);
1500        assert_eq!(hits[0].token_path, "color.primary");
1501    }
1502
1503    #[test]
1504    fn consumer_undefined_path_not_matched() {
1505        let hits = consumers("const a = vars.color.tertiary;", "vars", &["color.primary"]);
1506        assert!(hits.is_empty());
1507    }
1508
1509    #[test]
1510    fn consumer_bracket_notation_hyphenated_key() {
1511        // Hyphenated / digit-leading token keys are not valid JS identifiers, so
1512        // they are consumed via bracket notation; the leaf path keeps the raw key.
1513        let hits = consumers(
1514            "const a = vars.color['gray-100'];\nconst b = vars.borderRadius['0x'];",
1515            "vars",
1516            &["color.gray-100", "borderRadius.0x"],
1517        );
1518        let paths: Vec<&str> = hits.iter().map(|h| h.token_path.as_str()).collect();
1519        assert!(paths.contains(&"color.gray-100"));
1520        assert!(paths.contains(&"borderRadius.0x"));
1521        assert_eq!(hits.len(), 2);
1522    }
1523
1524    #[test]
1525    fn consumer_mixed_dot_and_bracket_chain() {
1526        // `vars['color'].primary` and `vars.color['primary']` both reconstruct the
1527        // same `color.primary` leaf.
1528        let hits = consumers(
1529            "const a = vars['color'].primary;\nconst b = vars.color['primary'];",
1530            "vars",
1531            &["color.primary"],
1532        );
1533        assert_eq!(hits.len(), 2);
1534        assert!(hits.iter().all(|h| h.token_path == "color.primary"));
1535    }
1536
1537    #[test]
1538    fn consumer_non_literal_computed_key_not_matched() {
1539        // A dynamic computed key cannot be resolved statically (lower-bound miss).
1540        let hits = consumers(
1541            "const k = 'primary'; const a = vars.color[k];",
1542            "vars",
1543            &["color.primary"],
1544        );
1545        assert!(hits.is_empty());
1546    }
1547
1548    #[test]
1549    fn consumer_empty_inputs_short_circuit() {
1550        assert!(consumers("const a = vars.color.primary;", "", &["color.primary"]).is_empty());
1551        assert!(consumers("const a = vars.color.primary;", "vars", &[]).is_empty());
1552    }
1553
1554    #[test]
1555    fn consumer_scan_matches_individual_calls() {
1556        // One source exercising all four query kinds; the scan must return exactly
1557        // the union of the four individual functions' hits, each tagged with the
1558        // index of the query that produced it.
1559        let source = "const a = vars.color.primary;\nconst b = css({ color: token('colors.brand'), background: 'colors.accent' });\nconst c = theme.space.card;";
1560        let path = Path::new("card.tsx");
1561
1562        let member_leaves = leaves(&["color.primary"]);
1563        let panda_call_leaves = leaves(&["colors.brand"]);
1564        let panda_style_aliases = leaves(&["css"]);
1565        let panda_style_leaves = leaves(&["colors.accent"]);
1566        let theme_leaves = leaves(&["space.card"]);
1567
1568        let queries = [
1569            ConsumerQuery::MemberBinding {
1570                alias: "vars",
1571                leaf_paths: &member_leaves,
1572            },
1573            ConsumerQuery::PandaTokenCall {
1574                alias: "token",
1575                leaf_paths: &panda_call_leaves,
1576            },
1577            ConsumerQuery::PandaStyleValues {
1578                aliases: &panda_style_aliases,
1579                leaf_paths: &panda_style_leaves,
1580            },
1581            ConsumerQuery::ThemeReads {
1582                leaf_paths: &theme_leaves,
1583            },
1584        ];
1585        let scanned = css_in_js_consumer_scan(source, path, &queries);
1586
1587        let individual: Vec<(usize, TokenConsumerHit)> =
1588            css_in_js_token_consumers(source, path, "vars", &member_leaves)
1589                .into_iter()
1590                .map(|hit| (0, hit))
1591                .chain(
1592                    panda_token_call_consumers(source, path, "token", &panda_call_leaves)
1593                        .into_iter()
1594                        .map(|hit| (1, hit)),
1595                )
1596                .chain(
1597                    panda_style_value_consumers(
1598                        source,
1599                        path,
1600                        &panda_style_aliases,
1601                        &panda_style_leaves,
1602                    )
1603                    .into_iter()
1604                    .map(|hit| (2, hit)),
1605                )
1606                .chain(
1607                    css_in_js_theme_consumers(source, path, &theme_leaves)
1608                        .into_iter()
1609                        .map(|hit| (3, hit)),
1610                )
1611                .collect();
1612
1613        assert_eq!(scanned, individual);
1614        assert_eq!(scanned.len(), 4);
1615        assert_eq!(
1616            scanned[0],
1617            (
1618                0,
1619                TokenConsumerHit {
1620                    token_path: "color.primary".to_string(),
1621                    line: 1,
1622                }
1623            )
1624        );
1625        assert_eq!(
1626            scanned[3],
1627            (
1628                3,
1629                TokenConsumerHit {
1630                    token_path: "space.card".to_string(),
1631                    line: 3,
1632                }
1633            )
1634        );
1635    }
1636
1637    #[test]
1638    fn consumer_scan_empty_query_is_isolated() {
1639        // An empty-alias query short-circuits to no hits WITHOUT suppressing the
1640        // valid query that follows it.
1641        let source = "const a = vars.color.primary;";
1642        let path = Path::new("card.ts");
1643        let empty_leaves = leaves(&["color.primary"]);
1644        let valid_leaves = leaves(&["color.primary"]);
1645        let queries = [
1646            ConsumerQuery::MemberBinding {
1647                alias: "",
1648                leaf_paths: &empty_leaves,
1649            },
1650            ConsumerQuery::MemberBinding {
1651                alias: "vars",
1652                leaf_paths: &valid_leaves,
1653            },
1654        ];
1655        let scanned = css_in_js_consumer_scan(source, path, &queries);
1656        assert_eq!(scanned.len(), 1);
1657        assert_eq!(scanned[0].0, 1);
1658        assert_eq!(scanned[0].1.token_path, "color.primary");
1659    }
1660
1661    #[test]
1662    fn consumer_scan_two_member_queries_same_source() {
1663        // Two definers imported under different aliases with an overlapping leaf
1664        // path; each read attributes to the alias (query index) it used.
1665        let source = "const a = brand.color.primary;\nconst b = accent.color.primary;";
1666        let path = Path::new("card.ts");
1667        let brand_leaves = leaves(&["color.primary"]);
1668        let accent_leaves = leaves(&["color.primary"]);
1669        let queries = [
1670            ConsumerQuery::MemberBinding {
1671                alias: "brand",
1672                leaf_paths: &brand_leaves,
1673            },
1674            ConsumerQuery::MemberBinding {
1675                alias: "accent",
1676                leaf_paths: &accent_leaves,
1677            },
1678        ];
1679        let scanned = css_in_js_consumer_scan(source, path, &queries);
1680        assert_eq!(scanned.len(), 2);
1681        assert!(scanned.contains(&(
1682            0,
1683            TokenConsumerHit {
1684                token_path: "color.primary".to_string(),
1685                line: 1,
1686            }
1687        )));
1688        assert!(scanned.contains(&(
1689            1,
1690            TokenConsumerHit {
1691                token_path: "color.primary".to_string(),
1692                line: 2,
1693            }
1694        )));
1695    }
1696}