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    if alias.is_empty() || leaf_paths.is_empty() {
153        return Vec::new();
154    }
155    let source_type = SourceType::from_path(path).unwrap_or_default();
156    let allocator = Allocator::default();
157    let ret = Parser::new(&allocator, source, source_type).parse();
158    let mut collector = ConsumerCollector {
159        source,
160        alias,
161        leaf_paths,
162        hits: Vec::new(),
163    };
164    collector.visit_program(&ret.program);
165    collector.hits
166}
167
168/// Walk a consuming JS/TS source for PandaCSS `token('path.to.token')` calls.
169/// The caller supplies the local alias imported from Panda's generated
170/// `styled-system` token module and the set of defined leaf paths.
171#[must_use]
172#[expect(
173    clippy::implicit_hasher,
174    reason = "callers build an FxHashSet; std HashSet is a disallowed type here"
175)]
176pub fn panda_token_call_consumers(
177    source: &str,
178    path: &Path,
179    alias: &str,
180    leaf_paths: &FxHashSet<String>,
181) -> Vec<TokenConsumerHit> {
182    if alias.is_empty() || leaf_paths.is_empty() {
183        return Vec::new();
184    }
185    let source_type = SourceType::from_path(path).unwrap_or_default();
186    let allocator = Allocator::default();
187    let ret = Parser::new(&allocator, source, source_type).parse();
188    let mut collector = PandaTokenCallCollector {
189        source,
190        alias,
191        leaf_paths,
192        hits: Vec::new(),
193    };
194    collector.visit_program(&ret.program);
195    collector.hits
196}
197
198/// Walk a consuming JS/TS source for common PandaCSS style calls whose object
199/// literal values statically name token paths.
200#[must_use]
201#[expect(
202    clippy::implicit_hasher,
203    reason = "callers build FxHashSet values; std HashSet is a disallowed type here"
204)]
205pub fn panda_style_value_consumers(
206    source: &str,
207    path: &Path,
208    aliases: &FxHashSet<String>,
209    leaf_paths: &FxHashSet<String>,
210) -> Vec<TokenConsumerHit> {
211    if aliases.is_empty() || leaf_paths.is_empty() {
212        return Vec::new();
213    }
214    let source_type = SourceType::from_path(path).unwrap_or_default();
215    let allocator = Allocator::default();
216    let ret = Parser::new(&allocator, source, source_type).parse();
217    let mut collector = PandaStyleValueCollector {
218        source,
219        aliases,
220        leaf_paths,
221        hits: Vec::new(),
222    };
223    collector.visit_program(&ret.program);
224    collector.hits
225}
226
227/// Walk a JS/TS source for statically-authored theme object definitions used by
228/// styled-components and Emotion. A `theme` or `*Theme` variable with an object
229/// literal initializer becomes a token surface, with nested scalar leaves exposed
230/// as dotted paths.
231#[must_use]
232pub fn css_in_js_theme_token_defs(source: &str, path: &Path) -> Vec<CssInJsTokenDef> {
233    let source_type = SourceType::from_path(path).unwrap_or_default();
234    let allocator = Allocator::default();
235    let ret = Parser::new(&allocator, source, source_type).parse();
236
237    let mut collector = ThemeDefCollector {
238        source,
239        defs: Vec::new(),
240    };
241    collector.visit_program(&ret.program);
242    collector.defs
243}
244
245/// Walk a consuming JS/TS source for styled-components / Emotion theme reads such
246/// as `theme.colors.brand` and `props.theme.colors.brand`.
247#[must_use]
248#[expect(
249    clippy::implicit_hasher,
250    reason = "callers build an FxHashSet; std HashSet is a disallowed type here"
251)]
252pub fn css_in_js_theme_consumers(
253    source: &str,
254    path: &Path,
255    leaf_paths: &FxHashSet<String>,
256) -> Vec<TokenConsumerHit> {
257    if leaf_paths.is_empty() {
258        return Vec::new();
259    }
260    let source_type = SourceType::from_path(path).unwrap_or_default();
261    let allocator = Allocator::default();
262    let ret = Parser::new(&allocator, source, source_type).parse();
263    let mut collector = ThemeConsumerCollector {
264        source,
265        leaf_paths,
266        hits: Vec::new(),
267    };
268    collector.visit_program(&ret.program);
269    collector.hits
270}
271
272/// Walks a consuming program for member accesses on a token binding alias.
273struct ConsumerCollector<'a, 'b> {
274    source: &'a str,
275    alias: &'b str,
276    leaf_paths: &'b FxHashSet<String>,
277    hits: Vec<TokenConsumerHit>,
278}
279
280impl<'a> ConsumerCollector<'a, '_> {
281    /// Record a hit if `(base, segments)` is exactly `<alias>.<leaf>` for a defined
282    /// leaf path. A node whose chain is `<alias>.<group>` (an intermediate group)
283    /// reconstructs a non-leaf path and is skipped, so each access site yields at
284    /// most one hit (no double count from the nested member expressions).
285    fn record(&mut self, chain: Option<(&'a str, Vec<&'a str>)>, span_start: u32) {
286        if let Some((base, segments)) = chain
287            && base == self.alias
288            && !segments.is_empty()
289        {
290            let token_path = segments.join(".");
291            if self.leaf_paths.contains(&token_path) {
292                self.hits.push(TokenConsumerHit {
293                    token_path,
294                    line: line_at(self.source, span_start),
295                });
296            }
297        }
298    }
299}
300
301impl<'a> Visit<'a> for ConsumerCollector<'a, '_> {
302    fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
303        let mut chain = access_object_chain(&member.object);
304        if let Some((_, segments)) = chain.as_mut() {
305            segments.push(member.property.name.as_str());
306        }
307        self.record(chain, member.span().start);
308        walk::walk_static_member_expression(self, member);
309    }
310
311    fn visit_computed_member_expression(&mut self, member: &ComputedMemberExpression<'a>) {
312        // Bracket access with a STATIC string-literal key (`vars.color['gray-100']`):
313        // the only way to consume a token whose key is not a valid JS identifier
314        // (hyphenated `gray-100`, digit-leading `0x`), which design-token systems use
315        // heavily. Non-literal computed keys (`vars.color[k]`) cannot be resolved
316        // statically and are skipped (a documented lower-bound miss).
317        let mut chain = access_object_chain(&member.object);
318        if let (Some((_, segments)), Some(key)) =
319            (chain.as_mut(), string_literal_key(&member.expression))
320        {
321            segments.push(key);
322        } else {
323            chain = None;
324        }
325        self.record(chain, member.span().start);
326        walk::walk_computed_member_expression(self, member);
327    }
328}
329
330struct PandaTokenCallCollector<'a, 'b> {
331    source: &'a str,
332    alias: &'b str,
333    leaf_paths: &'b FxHashSet<String>,
334    hits: Vec<TokenConsumerHit>,
335}
336
337impl<'a> Visit<'a> for PandaTokenCallCollector<'a, '_> {
338    fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
339        let Expression::Identifier(callee) = &call.callee else {
340            walk::walk_call_expression(self, call);
341            return;
342        };
343        if callee.name.as_str() == self.alias
344            && let Some(Argument::StringLiteral(lit)) = call.arguments.first()
345        {
346            let token_path = lit.value.as_str();
347            if self.leaf_paths.contains(token_path) {
348                self.hits.push(TokenConsumerHit {
349                    token_path: token_path.to_owned(),
350                    line: line_at(self.source, call.span().start),
351                });
352            }
353        }
354        walk::walk_call_expression(self, call);
355    }
356}
357
358struct PandaStyleValueCollector<'a, 'b> {
359    source: &'a str,
360    aliases: &'b FxHashSet<String>,
361    leaf_paths: &'b FxHashSet<String>,
362    hits: Vec<TokenConsumerHit>,
363}
364
365impl<'a> PandaStyleValueCollector<'a, '_> {
366    fn record_object(&mut self, obj: &ObjectExpression<'a>) {
367        for prop in &obj.properties {
368            let ObjectPropertyKind::ObjectProperty(prop) = prop else {
369                continue;
370            };
371            self.record_expression(&prop.value);
372        }
373    }
374
375    fn record_expression(&mut self, expr: &Expression<'a>) {
376        match expr {
377            Expression::StringLiteral(lit) => {
378                let token_path = lit.value.as_str();
379                if self.leaf_paths.contains(token_path) {
380                    self.hits.push(TokenConsumerHit {
381                        token_path: token_path.to_owned(),
382                        line: line_at(self.source, lit.span().start),
383                    });
384                }
385            }
386            Expression::ObjectExpression(obj) => self.record_object(obj),
387            _ => {}
388        }
389    }
390}
391
392impl<'a> Visit<'a> for PandaStyleValueCollector<'a, '_> {
393    fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
394        let Expression::Identifier(callee) = &call.callee else {
395            walk::walk_call_expression(self, call);
396            return;
397        };
398        if self.aliases.contains(callee.name.as_str()) {
399            for arg in &call.arguments {
400                if let Argument::ObjectExpression(obj) = arg {
401                    self.record_object(obj);
402                }
403            }
404        }
405        walk::walk_call_expression(self, call);
406    }
407}
408
409struct ThemeDefCollector<'a> {
410    source: &'a str,
411    defs: Vec<CssInJsTokenDef>,
412}
413
414impl<'a> ThemeDefCollector<'a> {
415    fn process_declarator(&mut self, decl: &VariableDeclarator<'a>) {
416        let BindingPattern::BindingIdentifier(binding) = &decl.id else {
417            return;
418        };
419        let binding_name = binding.name.as_str();
420        if !is_theme_binding_name(binding_name) {
421            return;
422        }
423        let Some(Expression::ObjectExpression(obj)) = &decl.init else {
424            return;
425        };
426        let mut tokens = Vec::new();
427        collect_token_leaves(self.source, obj, "", CssInJsTokenOrigin::Theme, &mut tokens);
428        if tokens.is_empty() {
429            return;
430        }
431        self.defs.push(CssInJsTokenDef {
432            binding: binding_name.to_owned(),
433            origin: CssInJsTokenOrigin::Theme,
434            tokens,
435        });
436    }
437}
438
439impl<'a> Visit<'a> for ThemeDefCollector<'a> {
440    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
441        self.process_declarator(decl);
442        walk::walk_variable_declarator(self, decl);
443    }
444}
445
446struct ThemeConsumerCollector<'a, 'b> {
447    source: &'a str,
448    leaf_paths: &'b FxHashSet<String>,
449    hits: Vec<TokenConsumerHit>,
450}
451
452impl<'a> ThemeConsumerCollector<'a, '_> {
453    fn record(&mut self, chain: Option<(&'a str, Vec<&'a str>)>, span_start: u32) {
454        let Some((base, segments)) = chain else {
455            return;
456        };
457        let token_segments: &[&str] = match base {
458            "theme" => &segments,
459            "props" if segments.first().copied() == Some("theme") => &segments[1..],
460            _ => return,
461        };
462        if token_segments.is_empty() {
463            return;
464        }
465        let token_path = token_segments.join(".");
466        if self.leaf_paths.contains(&token_path) {
467            self.hits.push(TokenConsumerHit {
468                token_path,
469                line: line_at(self.source, span_start),
470            });
471        }
472    }
473}
474
475impl<'a> Visit<'a> for ThemeConsumerCollector<'a, '_> {
476    fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
477        let mut chain = access_object_chain(&member.object);
478        if let Some((_, segments)) = chain.as_mut() {
479            segments.push(member.property.name.as_str());
480        }
481        self.record(chain, member.span().start);
482        walk::walk_static_member_expression(self, member);
483    }
484
485    fn visit_computed_member_expression(&mut self, member: &ComputedMemberExpression<'a>) {
486        let mut chain = access_object_chain(&member.object);
487        if let (Some((_, segments)), Some(key)) =
488            (chain.as_mut(), string_literal_key(&member.expression))
489        {
490            segments.push(key);
491        } else {
492            chain = None;
493        }
494        self.record(chain, member.span().start);
495        walk::walk_computed_member_expression(self, member);
496    }
497}
498
499/// Reconstruct the `(base identifier, [segments])` chain of a member-access OBJECT
500/// expression, threading through both static (`a.b`) and string-literal-computed
501/// (`a['b']`) member access. `vars.color` -> `("vars", ["color"])`. Returns `None`
502/// if the chain is not rooted at a plain identifier (a call result, `this`, a
503/// non-literal computed key, etc.).
504fn access_object_chain<'a>(expr: &Expression<'a>) -> Option<(&'a str, Vec<&'a str>)> {
505    match expr {
506        Expression::Identifier(id) => Some((id.name.as_str(), Vec::new())),
507        Expression::StaticMemberExpression(inner) => {
508            let (base, mut segments) = access_object_chain(&inner.object)?;
509            segments.push(inner.property.name.as_str());
510            Some((base, segments))
511        }
512        Expression::ComputedMemberExpression(inner) => {
513            let (base, mut segments) = access_object_chain(&inner.object)?;
514            segments.push(string_literal_key(&inner.expression)?);
515            Some((base, segments))
516        }
517        _ => None,
518    }
519}
520
521/// The value of a string-literal computed-member key (`['gray-100']`), or `None`
522/// for any non-string-literal key (which cannot be resolved statically).
523fn string_literal_key<'a>(expr: &Expression<'a>) -> Option<&'a str> {
524    match expr {
525        Expression::StringLiteral(lit) => Some(lit.value.as_str()),
526        _ => None,
527    }
528}
529
530/// Where the access binding comes from for a recognized token-definition call.
531#[derive(Clone, Copy)]
532enum BindingSource {
533    /// The assigned identifier (`const vars = ...`).
534    LhsIdent,
535    /// An element of an array-destructure (`const [_, vars] = ...`).
536    TupleElement(usize),
537}
538
539/// A recognized token-definition call: where the binding comes from and which
540/// argument carries the token object.
541#[derive(Clone, Copy)]
542struct Recognized {
543    binding_source: BindingSource,
544    tokens_arg: usize,
545    origin: CssInJsTokenOrigin,
546}
547
548/// Collects token-definition sites, gated on import provenance.
549struct TokenDefCollector<'a> {
550    source: &'a str,
551    /// local-binding name -> (library, canonical role). Mirrors the
552    /// `css_in_js_object` provenance map but for token-definition roles.
553    imports: FxHashMap<&'a str, (Lib, &'a str)>,
554    defs: Vec<CssInJsTokenDef>,
555}
556
557impl<'a> TokenDefCollector<'a> {
558    fn new(source: &'a str) -> Self {
559        Self {
560            source,
561            imports: FxHashMap::default(),
562            defs: Vec::new(),
563        }
564    }
565
566    /// Map each import binding from a recognized token library to its library +
567    /// canonical role. Named imports dispatch on the imported (canonical) name so
568    /// `import { createTheme as ct }` still fires; default / namespace bindings
569    /// (`import * as stylex`) carry the local name for member-call recognition.
570    fn build_import_map(&mut self, program: &Program<'a>) {
571        for stmt in &program.body {
572            let Statement::ImportDeclaration(decl) = stmt else {
573                continue;
574            };
575            if decl.import_kind.is_type() {
576                continue;
577            }
578            let Some(lib) = module_library(decl.source.value.as_str()) else {
579                continue;
580            };
581            let Some(specifiers) = &decl.specifiers else {
582                continue;
583            };
584            for specifier in specifiers {
585                let (local, role) = match specifier {
586                    ImportDeclarationSpecifier::ImportSpecifier(s) => {
587                        (s.local.name.as_str(), s.imported.name().as_str())
588                    }
589                    ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
590                        (s.local.name.as_str(), s.local.name.as_str())
591                    }
592                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
593                        (s.local.name.as_str(), s.local.name.as_str())
594                    }
595                };
596                self.imports.insert(local, (lib, role));
597            }
598        }
599    }
600
601    /// Resolve a call's callee to `(library, role)` if its binding is a recognized
602    /// token-library import. Handles both a named/aliased import callee
603    /// (`defineVars(...)`) and a namespace member call (`stylex.defineVars(...)`).
604    fn callee_role(&self, callee: &Expression<'a>) -> Option<(Lib, &'a str)> {
605        match callee {
606            Expression::Identifier(id) => self.imports.get(id.name.as_str()).copied(),
607            Expression::StaticMemberExpression(member) => {
608                let Expression::Identifier(obj) = &member.object else {
609                    return None;
610                };
611                let (lib, _) = *self.imports.get(obj.name.as_str())?;
612                // Member-call role is the accessed property (`stylex.defineVars`).
613                Some((lib, member.property.name.as_str()))
614            }
615            _ => None,
616        }
617    }
618
619    /// Dispatch `(library, role, arg_count)` to a recognized token-definition
620    /// form, or `None` (unrecognized, or a contract-implementation form whose
621    /// contract is the canonical definition).
622    fn recognize(lib: Lib, role: &str, arg_count: usize) -> Option<Recognized> {
623        let single = |tokens_arg, origin| {
624            Some(Recognized {
625                binding_source: BindingSource::LhsIdent,
626                tokens_arg,
627                origin,
628            })
629        };
630        match (lib, role) {
631            // `defineVars(obj)` / `createThemeContract(obj)`: binding = the assigned
632            // identifier, token object = arg 0.
633            (Lib::StyleX, "defineVars") if arg_count >= 1 => single(0, CssInJsTokenOrigin::StyleX),
634            (Lib::VanillaExtract, "createThemeContract") if arg_count >= 1 => {
635                single(0, CssInJsTokenOrigin::VanillaExtract)
636            }
637            // 1-arg createTheme returns [themeClass, vars]; tokens on the second
638            // destructure element. The 2-arg (contract, tokens) form fills an
639            // existing contract and is skipped (createThemeContract is canonical).
640            (Lib::VanillaExtract, "createTheme") if arg_count == 1 => Some(Recognized {
641                binding_source: BindingSource::TupleElement(1),
642                tokens_arg: 0,
643                origin: CssInJsTokenOrigin::VanillaExtract,
644            }),
645            // 2-arg createGlobalTheme(selector, tokens) returns the vars object;
646            // the 3-arg (selector, contract, tokens) form returns void (contract
647            // canonical), so only the 2-arg form is a definition site here.
648            (Lib::VanillaExtract, "createGlobalTheme") if arg_count == 2 => {
649                single(1, CssInJsTokenOrigin::VanillaExtract)
650            }
651            (Lib::Panda, "defineTokens") if arg_count >= 1 => single(0, CssInJsTokenOrigin::Panda),
652            _ => None,
653        }
654    }
655
656    /// Extract the access binding name from a declarator's binding pattern for the
657    /// recognized binding source.
658    fn binding_name(decl: &VariableDeclarator<'a>, source: BindingSource) -> Option<&'a str> {
659        match source {
660            BindingSource::LhsIdent => match &decl.id {
661                BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
662                _ => None,
663            },
664            BindingSource::TupleElement(index) => {
665                let BindingPattern::ArrayPattern(arr) = &decl.id else {
666                    return None;
667                };
668                let element = arr.elements.get(index)?.as_ref()?;
669                match element {
670                    BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
671                    _ => None,
672                }
673            }
674        }
675    }
676
677    fn process_declarator(&mut self, decl: &VariableDeclarator<'a>) {
678        let Some(Expression::CallExpression(call)) = &decl.init else {
679            return;
680        };
681        if self.process_panda_config_call(call) {
682            return;
683        }
684        let Some((lib, role)) = self.callee_role(&call.callee) else {
685            return;
686        };
687        let Some(recognized) = Self::recognize(lib, role, call.arguments.len()) else {
688            return;
689        };
690        let Some(binding) = Self::binding_name(decl, recognized.binding_source) else {
691            return;
692        };
693        let Some(Argument::ObjectExpression(obj)) = call.arguments.get(recognized.tokens_arg)
694        else {
695            return;
696        };
697        let mut tokens = Vec::new();
698        collect_token_leaves(self.source, obj, "", recognized.origin, &mut tokens);
699        if tokens.is_empty() {
700            return;
701        }
702        self.defs.push(CssInJsTokenDef {
703            binding: binding.to_owned(),
704            origin: recognized.origin,
705            tokens,
706        });
707    }
708
709    fn process_panda_config_call(&mut self, call: &oxc_ast::ast::CallExpression<'a>) -> bool {
710        let Some((Lib::Panda, "defineConfig")) = self.callee_role(&call.callee) else {
711            return false;
712        };
713        let Some(Argument::ObjectExpression(obj)) = call.arguments.first() else {
714            return true;
715        };
716        let mut tokens = Vec::new();
717        collect_panda_config_token_leaves(self.source, obj, &mut tokens);
718        if !tokens.is_empty() {
719            self.defs.push(CssInJsTokenDef {
720                binding: PANDA_CONFIG_BINDING.to_string(),
721                origin: CssInJsTokenOrigin::Panda,
722                tokens,
723            });
724        }
725        true
726    }
727}
728
729/// Flatten an object literal into dotted LEAF paths. An inline-object value
730/// recurses (an intermediate token GROUP, not a token); a value-producing
731/// expression (string / number / `null` contract leaf / call like
732/// `px(2 * grid)` / template / member access like `colors.red['500']`) is a LEAF
733/// token. A BARE IDENTIFIER value (`palette: tailwindPalette`) is SKIPPED: it
734/// references something whose structure is invisible here, most often an imported
735/// token GROUP (recording it as a leaf would invent a phantom token and wrongly
736/// credit every `vars.palette.<x>` access to it). Spreads and computed keys are
737/// skipped because they cannot be resolved statically.
738fn collect_token_leaves(
739    source: &str,
740    obj: &ObjectExpression<'_>,
741    prefix: &str,
742    origin: CssInJsTokenOrigin,
743    out: &mut Vec<CssInJsToken>,
744) {
745    for prop in &obj.properties {
746        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
747            continue;
748        };
749        let Some(key) = prop.key.static_name() else {
750            continue;
751        };
752        let path = if prefix.is_empty() {
753            key.to_string()
754        } else {
755            format!("{prefix}.{key}")
756        };
757        match &prop.value {
758            Expression::ObjectExpression(nested)
759                if origin == CssInJsTokenOrigin::Panda
760                    && !prefix.is_empty()
761                    && object_has_static_key(nested, "value") =>
762            {
763                out.push(CssInJsToken {
764                    path,
765                    def_line: line_at(source, prop.key.span().start),
766                    value: object_static_property_value(nested, "value"),
767                });
768            }
769            Expression::ObjectExpression(nested) => {
770                collect_token_leaves(source, nested, &path, origin, out);
771            }
772            // A bare identifier is an unresolvable reference, usually an imported
773            // token group; do not record it as a leaf.
774            Expression::Identifier(_) => {}
775            _ => out.push(CssInJsToken {
776                value: static_token_value(&prop.value),
777                path,
778                def_line: line_at(source, prop.key.span().start),
779            }),
780        }
781    }
782}
783
784fn object_static_property_value(obj: &ObjectExpression<'_>, wanted: &str) -> Option<String> {
785    obj.properties.iter().find_map(|prop| {
786        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
787            return None;
788        };
789        (prop.key.static_name().as_deref() == Some(wanted))
790            .then(|| static_token_value(&prop.value))
791            .flatten()
792    })
793}
794
795fn static_token_value(value: &Expression<'_>) -> Option<String> {
796    match value {
797        Expression::StringLiteral(lit) => {
798            let text = lit.value.as_str().trim();
799            (!text.is_empty()).then(|| text.to_string())
800        }
801        Expression::NumericLiteral(num) => Some(format_numeric_token(num)),
802        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
803            if let Expression::NumericLiteral(num) = &unary.argument {
804                Some(format!("-{}", format_numeric_token(num)))
805            } else {
806                None
807            }
808        }
809        _ => None,
810    }
811}
812
813fn format_numeric_token(num: &NumericLiteral<'_>) -> String {
814    if num.value.fract() == 0.0 {
815        format!("{:.0}", num.value)
816    } else {
817        num.value.to_string()
818    }
819}
820
821fn is_theme_binding_name(name: &str) -> bool {
822    let lower = name.to_ascii_lowercase();
823    lower == "theme" || lower.ends_with("theme")
824}
825
826fn object_has_static_key(obj: &ObjectExpression<'_>, wanted: &str) -> bool {
827    obj.properties.iter().any(|prop| {
828        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
829            return false;
830        };
831        prop.key.static_name().is_some_and(|key| key == wanted)
832    })
833}
834
835fn object_static_property_object<'a>(
836    obj: &'a ObjectExpression<'a>,
837    wanted: &str,
838) -> Option<&'a ObjectExpression<'a>> {
839    obj.properties.iter().find_map(|prop| {
840        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
841            return None;
842        };
843        if prop.key.static_name().as_deref() == Some(wanted)
844            && let Expression::ObjectExpression(value) = &prop.value
845        {
846            Some(&**value)
847        } else {
848            None
849        }
850    })
851}
852
853fn collect_panda_config_token_leaves(
854    source: &str,
855    obj: &ObjectExpression<'_>,
856    out: &mut Vec<CssInJsToken>,
857) {
858    let Some(theme) = object_static_property_object(obj, "theme") else {
859        return;
860    };
861    for key in ["tokens", "semanticTokens"] {
862        if let Some(tokens) = object_static_property_object(theme, key) {
863            collect_token_leaves(source, tokens, "", CssInJsTokenOrigin::Panda, out);
864        }
865    }
866}
867
868impl<'a> Visit<'a> for TokenDefCollector<'a> {
869    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
870        self.process_declarator(decl);
871        walk::walk_variable_declarator(self, decl);
872    }
873
874    fn visit_export_default_declaration(
875        &mut self,
876        decl: &oxc_ast::ast::ExportDefaultDeclaration<'a>,
877    ) {
878        if let Some(Expression::CallExpression(call)) = decl.declaration.as_expression() {
879            self.process_panda_config_call(call);
880        }
881        walk::walk_export_default_declaration(self, decl);
882    }
883}
884
885/// 1-based line number of a byte offset in `source`. Uses `.get(..end)` so an
886/// out-of-range or non-char-boundary offset clamps to line 1 rather than
887/// panicking (matches `css::line_at_offset`).
888fn line_at(source: &str, offset: u32) -> u32 {
889    let end = (offset as usize).min(source.len());
890    let count = source
891        .get(..end)
892        .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
893    u32::try_from(1 + count).unwrap_or(u32::MAX)
894}
895
896#[cfg(all(test, not(miri)))]
897mod tests {
898    use super::*;
899
900    fn defs(source: &str) -> Vec<CssInJsTokenDef> {
901        css_in_js_token_defs(source, Path::new("tokens.ts"))
902    }
903
904    fn paths(defs: &[CssInJsTokenDef], binding: &str) -> Vec<String> {
905        defs.iter()
906            .find(|d| d.binding == binding)
907            .map(|d| d.tokens.iter().map(|t| t.path.clone()).collect())
908            .unwrap_or_default()
909    }
910
911    fn token_values(defs: &[CssInJsTokenDef], binding: &str) -> Vec<(String, Option<String>)> {
912        defs.iter()
913            .find(|d| d.binding == binding)
914            .map(|d| {
915                d.tokens
916                    .iter()
917                    .map(|t| (t.path.clone(), t.value.clone()))
918                    .collect()
919            })
920            .unwrap_or_default()
921    }
922
923    fn theme_defs(source: &str) -> Vec<CssInJsTokenDef> {
924        css_in_js_theme_token_defs(source, Path::new("theme.ts"))
925    }
926
927    #[test]
928    fn stylex_define_vars_flat_namespace_call() {
929        let d = defs(
930            r"
931import * as stylex from '@stylexjs/stylex';
932export const vars = stylex.defineVars({ primaryColor: '#3b82f6', spacingSm: '4px' });
933",
934        );
935        assert_eq!(paths(&d, "vars"), vec!["primaryColor", "spacingSm"]);
936        assert_eq!(
937            token_values(&d, "vars"),
938            vec![
939                ("primaryColor".to_string(), Some("#3b82f6".to_string())),
940                ("spacingSm".to_string(), Some("4px".to_string())),
941            ]
942        );
943    }
944
945    #[test]
946    fn stylex_define_vars_named_import_nested() {
947        let d = defs(
948            r"
949import { defineVars } from '@stylexjs/stylex';
950export const vars = defineVars({ color: { primary: '#000', secondary: '#fff' } });
951",
952        );
953        assert_eq!(paths(&d, "vars"), vec!["color.primary", "color.secondary"]);
954    }
955
956    #[test]
957    fn panda_define_tokens_collapses_value_objects() {
958        let d = defs(
959            r"
960import { defineTokens } from '@pandacss/dev';
961export const tokens = defineTokens({
962  colors: {
963    brand: { value: '#f05a28' },
964    accent: { value: '{colors.brand}' },
965  },
966  spacing: { card: { value: '1rem' } },
967});
968",
969        );
970        assert_eq!(
971            paths(&d, "tokens"),
972            vec!["colors.brand", "colors.accent", "spacing.card"]
973        );
974        assert_eq!(
975            token_values(&d, "tokens"),
976            vec![
977                ("colors.brand".to_string(), Some("#f05a28".to_string())),
978                (
979                    "colors.accent".to_string(),
980                    Some("{colors.brand}".to_string())
981                ),
982                ("spacing.card".to_string(), Some("1rem".to_string())),
983            ]
984        );
985        assert_eq!(
986            d.iter().find(|d| d.binding == "tokens").unwrap().origin,
987            CssInJsTokenOrigin::Panda
988        );
989    }
990
991    #[test]
992    fn panda_define_config_extracts_tokens_and_semantic_tokens() {
993        let d = defs(
994            r"
995import { defineConfig } from '@pandacss/dev';
996
997export default defineConfig({
998  theme: {
999    tokens: {
1000      colors: {
1001        brand: { value: '#f05a28' },
1002      },
1003    },
1004    semanticTokens: {
1005      colors: {
1006        surface: { value: { base: '{colors.brand}', _dark: '#111111' } },
1007      },
1008    },
1009    recipes: {
1010      card: { base: { color: 'colors.brand' } },
1011    },
1012  },
1013});
1014",
1015        );
1016        assert_eq!(
1017            paths(&d, "pandaConfig"),
1018            vec!["colors.brand", "colors.surface"]
1019        );
1020        assert_eq!(
1021            token_values(&d, "pandaConfig"),
1022            vec![
1023                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1024                ("colors.surface".to_string(), None),
1025            ]
1026        );
1027        assert_eq!(
1028            d.iter()
1029                .find(|d| d.binding == "pandaConfig")
1030                .unwrap()
1031                .origin,
1032            CssInJsTokenOrigin::Panda
1033        );
1034    }
1035
1036    #[test]
1037    fn theme_object_definitions_flatten_static_leaves() {
1038        let d = theme_defs(
1039            r"
1040export const appTheme = {
1041  colors: { brand: '#f05a28', accent: '#111' },
1042  space: { card: '1rem' },
1043  dynamic: palette,
1044};
1045",
1046        );
1047        assert_eq!(
1048            paths(&d, "appTheme"),
1049            vec!["colors.brand", "colors.accent", "space.card"]
1050        );
1051        assert_eq!(
1052            token_values(&d, "appTheme"),
1053            vec![
1054                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1055                ("colors.accent".to_string(), Some("#111".to_string())),
1056                ("space.card".to_string(), Some("1rem".to_string())),
1057            ]
1058        );
1059        assert_eq!(
1060            d.iter().find(|d| d.binding == "appTheme").unwrap().origin,
1061            CssInJsTokenOrigin::Theme
1062        );
1063    }
1064
1065    #[test]
1066    fn theme_consumers_credit_props_and_destructured_theme_reads() {
1067        let leaves = ["colors.brand", "space.card"]
1068            .into_iter()
1069            .map(str::to_owned)
1070            .collect();
1071        let hits = css_in_js_theme_consumers(
1072            r"
1073import styled from 'styled-components';
1074export const Card = styled.div`
1075  color: ${({ theme }) => theme.colors.brand};
1076  margin: ${props => props.theme.space.card};
1077`;
1078",
1079            Path::new("card.tsx"),
1080            &leaves,
1081        );
1082        let mut token_paths: Vec<String> = hits.into_iter().map(|hit| hit.token_path).collect();
1083        token_paths.sort();
1084        assert_eq!(token_paths, vec!["colors.brand", "space.card"]);
1085    }
1086
1087    #[test]
1088    fn ve_create_theme_tuple_destructure_binds_element_one() {
1089        let d = defs(
1090            r"
1091import { createTheme } from '@vanilla-extract/css';
1092export const [themeClass, vars] = createTheme({
1093  color: { brand: 'red', accent: 'blue' },
1094  space: { small: '4px' },
1095});
1096",
1097        );
1098        // Token paths bind to `vars` (element 1), NOT `themeClass`.
1099        assert_eq!(
1100            paths(&d, "vars"),
1101            vec!["color.brand", "color.accent", "space.small"]
1102        );
1103        assert!(paths(&d, "themeClass").is_empty());
1104    }
1105
1106    #[test]
1107    fn ve_create_theme_contract_null_leaves() {
1108        let d = defs(
1109            r"
1110import { createThemeContract } from '@vanilla-extract/css';
1111export const vars = createThemeContract({ color: { brand: null, accent: null } });
1112",
1113        );
1114        // `null` contract leaves are tokens (the contract declares the shape).
1115        assert_eq!(paths(&d, "vars"), vec!["color.brand", "color.accent"]);
1116    }
1117
1118    #[test]
1119    fn ve_create_global_theme_two_arg_binds_lhs_tokens_in_second_arg() {
1120        let d = defs(
1121            r"
1122import { createGlobalTheme } from '@vanilla-extract/css';
1123export const vars = createGlobalTheme(':root', { color: { brand: 'red' } });
1124",
1125        );
1126        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
1127    }
1128
1129    #[test]
1130    fn ve_create_theme_two_arg_contract_impl_is_not_a_definition_site() {
1131        // The 2-arg form fills an existing contract (declared by
1132        // createThemeContract elsewhere); it must NOT introduce a binding.
1133        let d = defs(
1134            r"
1135import { createTheme } from '@vanilla-extract/css';
1136export const themeClass = createTheme(vars, { color: { brand: 'red' } });
1137",
1138        );
1139        assert!(
1140            d.is_empty(),
1141            "2-arg createTheme must not define tokens, got {d:?}"
1142        );
1143    }
1144
1145    #[test]
1146    fn ve_create_global_theme_three_arg_contract_impl_is_not_a_definition_site() {
1147        let d = defs(
1148            r"
1149import { createGlobalTheme } from '@vanilla-extract/css';
1150createGlobalTheme(':root', vars, { color: { brand: 'red' } });
1151",
1152        );
1153        assert!(
1154            d.is_empty(),
1155            "3-arg createGlobalTheme must not define tokens, got {d:?}"
1156        );
1157    }
1158
1159    #[test]
1160    fn aliased_named_import_still_fires() {
1161        let d = defs(
1162            r"
1163import { createThemeContract as ct } from '@vanilla-extract/css';
1164export const vars = ct({ color: { brand: null } });
1165",
1166        );
1167        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
1168    }
1169
1170    #[test]
1171    fn local_helper_not_from_library_does_not_fire() {
1172        // A local `defineVars` shadowing the StyleX name must not be recognized.
1173        let d = defs(
1174            r"
1175function defineVars(o) { return o; }
1176export const vars = defineVars({ color: { primary: '#000' } });
1177",
1178        );
1179        assert!(d.is_empty(), "local defineVars must not fire, got {d:?}");
1180    }
1181
1182    #[test]
1183    fn unrelated_create_theme_import_does_not_fire() {
1184        let d = defs(
1185            r"
1186import { createTheme } from '@mui/material/styles';
1187export const theme = createTheme({ palette: { primary: { main: '#000' } } });
1188",
1189        );
1190        assert!(d.is_empty(), "non-VE createTheme must not fire, got {d:?}");
1191    }
1192
1193    #[test]
1194    fn type_only_import_does_not_fire() {
1195        let d = defs(
1196            r"
1197import type { defineVars } from '@stylexjs/stylex';
1198export const vars = defineVars({ color: { primary: '#000' } });
1199",
1200        );
1201        assert!(
1202            d.is_empty(),
1203            "type-only import must not gate recognition, got {d:?}"
1204        );
1205    }
1206
1207    #[test]
1208    fn token_def_lines_are_per_leaf() {
1209        let src = "import { defineVars } from '@stylexjs/stylex';\nexport const vars = defineVars({\n  color: {\n    primary: '#000',\n    secondary: '#fff',\n  },\n});\n";
1210        let d = defs(src);
1211        let def = d.iter().find(|d| d.binding == "vars").unwrap();
1212        let primary = def
1213            .tokens
1214            .iter()
1215            .find(|t| t.path == "color.primary")
1216            .unwrap();
1217        let secondary = def
1218            .tokens
1219            .iter()
1220            .find(|t| t.path == "color.secondary")
1221            .unwrap();
1222        assert_eq!(primary.def_line, 4);
1223        assert_eq!(secondary.def_line, 5);
1224    }
1225
1226    #[test]
1227    fn spread_and_computed_keys_are_skipped() {
1228        let d = defs(
1229            r"
1230import { defineVars } from '@stylexjs/stylex';
1231const base = { a: '1' };
1232export const vars = defineVars({ ...base, ['x' + 'y']: '2', real: '#000' });
1233",
1234        );
1235        // Only the statically-resolvable `real` leaf survives.
1236        assert_eq!(paths(&d, "vars"), vec!["real"]);
1237    }
1238
1239    #[test]
1240    fn identifier_valued_key_is_not_a_leaf_but_call_and_member_values_are() {
1241        // `palette: tailwindPalette` (bare identifier, an imported group) must NOT
1242        // become a phantom `palette` leaf; `radius: px(2)` (call) and
1243        // `red: colors.red['500']` (member access) are real scalar leaves.
1244        let d = defs(
1245            r"
1246import { createGlobalTheme } from '@vanilla-extract/css';
1247export const vars = createGlobalTheme(':root', {
1248  palette: tailwindPalette,
1249  radius: px(2),
1250  red: colors.red['500'],
1251});
1252",
1253        );
1254        let p = paths(&d, "vars");
1255        assert!(
1256            !p.contains(&"palette".to_string()),
1257            "identifier-valued key must not be a leaf: {p:?}"
1258        );
1259        assert!(
1260            p.contains(&"radius".to_string()),
1261            "call-valued key is a leaf: {p:?}"
1262        );
1263        assert!(
1264            p.contains(&"red".to_string()),
1265            "member-valued key is a leaf: {p:?}"
1266        );
1267    }
1268
1269    #[test]
1270    fn no_css_in_js_import_returns_empty() {
1271        let d = defs("export const vars = { color: { primary: '#000' } };");
1272        assert!(d.is_empty());
1273    }
1274
1275    fn leaves(paths: &[&str]) -> FxHashSet<String> {
1276        paths.iter().map(|s| (*s).to_string()).collect()
1277    }
1278
1279    fn consumers(source: &str, alias: &str, paths: &[&str]) -> Vec<TokenConsumerHit> {
1280        css_in_js_token_consumers(source, Path::new("card.ts"), alias, &leaves(paths))
1281    }
1282
1283    fn panda_consumers(source: &str, alias: &str, paths: &[&str]) -> Vec<TokenConsumerHit> {
1284        panda_token_call_consumers(source, Path::new("card.ts"), alias, &leaves(paths))
1285    }
1286
1287    fn panda_style_consumers(
1288        source: &str,
1289        aliases: &[&str],
1290        paths: &[&str],
1291    ) -> Vec<TokenConsumerHit> {
1292        let aliases = aliases.iter().map(|s| (*s).to_string()).collect();
1293        panda_style_value_consumers(source, Path::new("card.ts"), &aliases, &leaves(paths))
1294    }
1295
1296    #[test]
1297    fn consumer_matches_deepest_leaf_not_intermediate_group() {
1298        // `vars.color.primary` is the leaf; `vars.color` (an intermediate group)
1299        // must NOT be counted, so exactly one hit per access site.
1300        let hits = consumers(
1301            "const a = vars.color.primary;",
1302            "vars",
1303            &["color.primary", "color.secondary"],
1304        );
1305        assert_eq!(hits.len(), 1);
1306        assert_eq!(hits[0].token_path, "color.primary");
1307        assert_eq!(hits[0].line, 1);
1308    }
1309
1310    #[test]
1311    fn consumer_aliased_receiver() {
1312        // The caller passes the local alias; member access on it is matched.
1313        let hits = consumers("const a = v.color.primary;", "v", &["color.primary"]);
1314        assert_eq!(hits.len(), 1);
1315        assert_eq!(hits[0].token_path, "color.primary");
1316    }
1317
1318    #[test]
1319    fn consumer_multiple_sites_distinct_lines() {
1320        let src = "const a = vars.color.primary;\nconst b = vars.space.sm;\nconst c = vars.color.primary;";
1321        let hits = consumers(src, "vars", &["color.primary", "space.sm"]);
1322        assert_eq!(hits.len(), 3);
1323        let lines: Vec<u32> = hits.iter().map(|h| h.line).collect();
1324        assert_eq!(lines, vec![1, 2, 3]);
1325    }
1326
1327    #[test]
1328    fn consumer_in_style_object_value_position() {
1329        // The dominant real shape: a token read inside a style-call object value.
1330        let hits = consumers(
1331            "export const s = stylex.create({ root: { color: vars.color.primary } });",
1332            "vars",
1333            &["color.primary"],
1334        );
1335        assert_eq!(hits.len(), 1);
1336        assert_eq!(hits[0].token_path, "color.primary");
1337    }
1338
1339    #[test]
1340    fn panda_token_call_consumer_matches_string_literal() {
1341        let hits = panda_consumers(
1342            "export const c = css({ color: token('colors.brand') });",
1343            "token",
1344            &["colors.brand", "colors.accent"],
1345        );
1346        assert_eq!(hits.len(), 1);
1347        assert_eq!(hits[0].token_path, "colors.brand");
1348    }
1349
1350    #[test]
1351    fn panda_style_value_consumer_matches_known_token_string() {
1352        let hits = panda_style_consumers(
1353            "export const c = css({ color: 'colors.brand', _hover: { bg: 'colors.accent' } });",
1354            &["css"],
1355            &["colors.brand", "colors.accent", "colors.unused"],
1356        );
1357        let paths: Vec<_> = hits.iter().map(|hit| hit.token_path.as_str()).collect();
1358        assert_eq!(paths, vec!["colors.brand", "colors.accent"]);
1359    }
1360
1361    #[test]
1362    fn panda_style_value_consumer_ignores_unimported_alias() {
1363        let hits = panda_style_consumers(
1364            "export const c = notPanda({ color: 'colors.brand' });",
1365            &["css"],
1366            &["colors.brand"],
1367        );
1368        assert!(hits.is_empty());
1369    }
1370
1371    #[test]
1372    fn consumer_flat_stylex_depth_one() {
1373        let hits = consumers("const a = vars.primaryColor;", "vars", &["primaryColor"]);
1374        assert_eq!(hits.len(), 1);
1375        assert_eq!(hits[0].token_path, "primaryColor");
1376    }
1377
1378    #[test]
1379    fn consumer_other_binding_not_matched() {
1380        // A same-named member access on a DIFFERENT binding must not be a hit.
1381        let hits = consumers("const a = other.color.primary;", "vars", &["color.primary"]);
1382        assert!(hits.is_empty());
1383    }
1384
1385    #[test]
1386    fn consumer_deeper_access_past_leaf_matches_leaf_subexpression_once() {
1387        // `vars.color.primary.toString()` reads the leaf `color.primary`; the outer
1388        // `.toString` chain is not a leaf, the inner `vars.color.primary` is.
1389        let hits = consumers(
1390            "const a = vars.color.primary.toString();",
1391            "vars",
1392            &["color.primary"],
1393        );
1394        assert_eq!(hits.len(), 1);
1395        assert_eq!(hits[0].token_path, "color.primary");
1396    }
1397
1398    #[test]
1399    fn consumer_undefined_path_not_matched() {
1400        let hits = consumers("const a = vars.color.tertiary;", "vars", &["color.primary"]);
1401        assert!(hits.is_empty());
1402    }
1403
1404    #[test]
1405    fn consumer_bracket_notation_hyphenated_key() {
1406        // Hyphenated / digit-leading token keys are not valid JS identifiers, so
1407        // they are consumed via bracket notation; the leaf path keeps the raw key.
1408        let hits = consumers(
1409            "const a = vars.color['gray-100'];\nconst b = vars.borderRadius['0x'];",
1410            "vars",
1411            &["color.gray-100", "borderRadius.0x"],
1412        );
1413        let paths: Vec<&str> = hits.iter().map(|h| h.token_path.as_str()).collect();
1414        assert!(paths.contains(&"color.gray-100"));
1415        assert!(paths.contains(&"borderRadius.0x"));
1416        assert_eq!(hits.len(), 2);
1417    }
1418
1419    #[test]
1420    fn consumer_mixed_dot_and_bracket_chain() {
1421        // `vars['color'].primary` and `vars.color['primary']` both reconstruct the
1422        // same `color.primary` leaf.
1423        let hits = consumers(
1424            "const a = vars['color'].primary;\nconst b = vars.color['primary'];",
1425            "vars",
1426            &["color.primary"],
1427        );
1428        assert_eq!(hits.len(), 2);
1429        assert!(hits.iter().all(|h| h.token_path == "color.primary"));
1430    }
1431
1432    #[test]
1433    fn consumer_non_literal_computed_key_not_matched() {
1434        // A dynamic computed key cannot be resolved statically (lower-bound miss).
1435        let hits = consumers(
1436            "const k = 'primary'; const a = vars.color[k];",
1437            "vars",
1438            &["color.primary"],
1439        );
1440        assert!(hits.is_empty());
1441    }
1442
1443    #[test]
1444    fn consumer_empty_inputs_short_circuit() {
1445        assert!(consumers("const a = vars.color.primary;", "", &["color.primary"]).is_empty());
1446        assert!(consumers("const a = vars.color.primary;", "vars", &[]).is_empty());
1447    }
1448}