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        lines: LineCounter::new(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                lines: LineCounter::new(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                lines: LineCounter::new(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                lines: LineCounter::new(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                lines: LineCounter::new(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    lines: LineCounter<'a>,
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                let line = self.lines.line_at(span_start);
398                self.hits.push(TokenConsumerHit { token_path, line });
399            }
400        }
401    }
402}
403
404impl<'a> Visit<'a> for ConsumerCollector<'a, '_> {
405    fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
406        let mut chain = access_object_chain(&member.object);
407        if let Some((_, segments)) = chain.as_mut() {
408            segments.push(member.property.name.as_str());
409        }
410        self.record(chain, member.span().start);
411        walk::walk_static_member_expression(self, member);
412    }
413
414    fn visit_computed_member_expression(&mut self, member: &ComputedMemberExpression<'a>) {
415        // Bracket access with a STATIC string-literal key (`vars.color['gray-100']`):
416        // the only way to consume a token whose key is not a valid JS identifier
417        // (hyphenated `gray-100`, digit-leading `0x`), which design-token systems use
418        // heavily. Non-literal computed keys (`vars.color[k]`) cannot be resolved
419        // statically and are skipped (a documented lower-bound miss).
420        let mut chain = access_object_chain(&member.object);
421        if let (Some((_, segments)), Some(key)) =
422            (chain.as_mut(), string_literal_key(&member.expression))
423        {
424            segments.push(key);
425        } else {
426            chain = None;
427        }
428        self.record(chain, member.span().start);
429        walk::walk_computed_member_expression(self, member);
430    }
431}
432
433struct PandaTokenCallCollector<'a, 'b> {
434    lines: LineCounter<'a>,
435    alias: &'b str,
436    leaf_paths: &'b FxHashSet<String>,
437    hits: Vec<TokenConsumerHit>,
438}
439
440impl<'a> Visit<'a> for PandaTokenCallCollector<'a, '_> {
441    fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
442        let Expression::Identifier(callee) = &call.callee else {
443            walk::walk_call_expression(self, call);
444            return;
445        };
446        if callee.name.as_str() == self.alias
447            && let Some(Argument::StringLiteral(lit)) = call.arguments.first()
448        {
449            let token_path = lit.value.as_str();
450            if self.leaf_paths.contains(token_path) {
451                let line = self.lines.line_at(call.span().start);
452                self.hits.push(TokenConsumerHit {
453                    token_path: token_path.to_owned(),
454                    line,
455                });
456            }
457        }
458        walk::walk_call_expression(self, call);
459    }
460}
461
462struct PandaStyleValueCollector<'a, 'b> {
463    lines: LineCounter<'a>,
464    aliases: &'b FxHashSet<String>,
465    leaf_paths: &'b FxHashSet<String>,
466    hits: Vec<TokenConsumerHit>,
467}
468
469impl<'a> PandaStyleValueCollector<'a, '_> {
470    fn record_object(&mut self, obj: &ObjectExpression<'a>) {
471        for prop in &obj.properties {
472            let ObjectPropertyKind::ObjectProperty(prop) = prop else {
473                continue;
474            };
475            self.record_expression(&prop.value);
476        }
477    }
478
479    fn record_expression(&mut self, expr: &Expression<'a>) {
480        match expr {
481            Expression::StringLiteral(lit) => {
482                let token_path = lit.value.as_str();
483                if self.leaf_paths.contains(token_path) {
484                    let line = self.lines.line_at(lit.span().start);
485                    self.hits.push(TokenConsumerHit {
486                        token_path: token_path.to_owned(),
487                        line,
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    lines: LineCounter<'a>,
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(
533            &mut self.lines,
534            obj,
535            "",
536            CssInJsTokenOrigin::Theme,
537            &mut tokens,
538        );
539        if tokens.is_empty() {
540            return;
541        }
542        self.defs.push(CssInJsTokenDef {
543            binding: binding_name.to_owned(),
544            origin: CssInJsTokenOrigin::Theme,
545            tokens,
546        });
547    }
548}
549
550impl<'a> Visit<'a> for ThemeDefCollector<'a> {
551    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
552        self.process_declarator(decl);
553        walk::walk_variable_declarator(self, decl);
554    }
555}
556
557struct ThemeConsumerCollector<'a, 'b> {
558    lines: LineCounter<'a>,
559    leaf_paths: &'b FxHashSet<String>,
560    hits: Vec<TokenConsumerHit>,
561}
562
563impl<'a> ThemeConsumerCollector<'a, '_> {
564    fn record(&mut self, chain: Option<(&'a str, Vec<&'a str>)>, span_start: u32) {
565        let Some((base, segments)) = chain else {
566            return;
567        };
568        let token_segments: &[&str] = match base {
569            "theme" => &segments,
570            "props" if segments.first().copied() == Some("theme") => &segments[1..],
571            _ => return,
572        };
573        if token_segments.is_empty() {
574            return;
575        }
576        let token_path = token_segments.join(".");
577        if self.leaf_paths.contains(&token_path) {
578            let line = self.lines.line_at(span_start);
579            self.hits.push(TokenConsumerHit { token_path, line });
580        }
581    }
582}
583
584impl<'a> Visit<'a> for ThemeConsumerCollector<'a, '_> {
585    fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
586        let mut chain = access_object_chain(&member.object);
587        if let Some((_, segments)) = chain.as_mut() {
588            segments.push(member.property.name.as_str());
589        }
590        self.record(chain, member.span().start);
591        walk::walk_static_member_expression(self, member);
592    }
593
594    fn visit_computed_member_expression(&mut self, member: &ComputedMemberExpression<'a>) {
595        let mut chain = access_object_chain(&member.object);
596        if let (Some((_, segments)), Some(key)) =
597            (chain.as_mut(), string_literal_key(&member.expression))
598        {
599            segments.push(key);
600        } else {
601            chain = None;
602        }
603        self.record(chain, member.span().start);
604        walk::walk_computed_member_expression(self, member);
605    }
606}
607
608/// Reconstruct the `(base identifier, [segments])` chain of a member-access OBJECT
609/// expression, threading through both static (`a.b`) and string-literal-computed
610/// (`a['b']`) member access. `vars.color` -> `("vars", ["color"])`. Returns `None`
611/// if the chain is not rooted at a plain identifier (a call result, `this`, a
612/// non-literal computed key, etc.).
613fn access_object_chain<'a>(expr: &Expression<'a>) -> Option<(&'a str, Vec<&'a str>)> {
614    match expr {
615        Expression::Identifier(id) => Some((id.name.as_str(), Vec::new())),
616        Expression::StaticMemberExpression(inner) => {
617            let (base, mut segments) = access_object_chain(&inner.object)?;
618            segments.push(inner.property.name.as_str());
619            Some((base, segments))
620        }
621        Expression::ComputedMemberExpression(inner) => {
622            let (base, mut segments) = access_object_chain(&inner.object)?;
623            segments.push(string_literal_key(&inner.expression)?);
624            Some((base, segments))
625        }
626        _ => None,
627    }
628}
629
630/// The value of a string-literal computed-member key (`['gray-100']`), or `None`
631/// for any non-string-literal key (which cannot be resolved statically).
632fn string_literal_key<'a>(expr: &Expression<'a>) -> Option<&'a str> {
633    match expr {
634        Expression::StringLiteral(lit) => Some(lit.value.as_str()),
635        _ => None,
636    }
637}
638
639/// Where the access binding comes from for a recognized token-definition call.
640#[derive(Clone, Copy)]
641enum BindingSource {
642    /// The assigned identifier (`const vars = ...`).
643    LhsIdent,
644    /// An element of an array-destructure (`const [_, vars] = ...`).
645    TupleElement(usize),
646}
647
648/// A recognized token-definition call: where the binding comes from and which
649/// argument carries the token object.
650#[derive(Clone, Copy)]
651struct Recognized {
652    binding_source: BindingSource,
653    tokens_arg: usize,
654    origin: CssInJsTokenOrigin,
655}
656
657/// Collects token-definition sites, gated on import provenance.
658struct TokenDefCollector<'a> {
659    lines: LineCounter<'a>,
660    /// local-binding name -> (library, canonical role). Mirrors the
661    /// `css_in_js_object` provenance map but for token-definition roles.
662    imports: FxHashMap<&'a str, (Lib, &'a str)>,
663    defs: Vec<CssInJsTokenDef>,
664}
665
666impl<'a> TokenDefCollector<'a> {
667    fn new(source: &'a str) -> Self {
668        Self {
669            lines: LineCounter::new(source),
670            imports: FxHashMap::default(),
671            defs: Vec::new(),
672        }
673    }
674
675    /// Map each import binding from a recognized token library to its library +
676    /// canonical role. Named imports dispatch on the imported (canonical) name so
677    /// `import { createTheme as ct }` still fires; default / namespace bindings
678    /// (`import * as stylex`) carry the local name for member-call recognition.
679    fn build_import_map(&mut self, program: &Program<'a>) {
680        for stmt in &program.body {
681            let Statement::ImportDeclaration(decl) = stmt else {
682                continue;
683            };
684            if decl.import_kind.is_type() {
685                continue;
686            }
687            let Some(lib) = module_library(decl.source.value.as_str()) else {
688                continue;
689            };
690            let Some(specifiers) = &decl.specifiers else {
691                continue;
692            };
693            for specifier in specifiers {
694                let (local, role) = match specifier {
695                    ImportDeclarationSpecifier::ImportSpecifier(s) => {
696                        (s.local.name.as_str(), s.imported.name().as_str())
697                    }
698                    ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
699                        (s.local.name.as_str(), s.local.name.as_str())
700                    }
701                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
702                        (s.local.name.as_str(), s.local.name.as_str())
703                    }
704                };
705                self.imports.insert(local, (lib, role));
706            }
707        }
708    }
709
710    /// Resolve a call's callee to `(library, role)` if its binding is a recognized
711    /// token-library import. Handles both a named/aliased import callee
712    /// (`defineVars(...)`) and a namespace member call (`stylex.defineVars(...)`).
713    fn callee_role(&self, callee: &Expression<'a>) -> Option<(Lib, &'a str)> {
714        match callee {
715            Expression::Identifier(id) => self.imports.get(id.name.as_str()).copied(),
716            Expression::StaticMemberExpression(member) => {
717                let Expression::Identifier(obj) = &member.object else {
718                    return None;
719                };
720                let (lib, _) = *self.imports.get(obj.name.as_str())?;
721                // Member-call role is the accessed property (`stylex.defineVars`).
722                Some((lib, member.property.name.as_str()))
723            }
724            _ => None,
725        }
726    }
727
728    /// Dispatch `(library, role, arg_count)` to a recognized token-definition
729    /// form, or `None` (unrecognized, or a contract-implementation form whose
730    /// contract is the canonical definition).
731    fn recognize(lib: Lib, role: &str, arg_count: usize) -> Option<Recognized> {
732        let single = |tokens_arg, origin| {
733            Some(Recognized {
734                binding_source: BindingSource::LhsIdent,
735                tokens_arg,
736                origin,
737            })
738        };
739        match (lib, role) {
740            // `defineVars(obj)` / `createThemeContract(obj)`: binding = the assigned
741            // identifier, token object = arg 0.
742            (Lib::StyleX, "defineVars") if arg_count >= 1 => single(0, CssInJsTokenOrigin::StyleX),
743            (Lib::VanillaExtract, "createThemeContract") if arg_count >= 1 => {
744                single(0, CssInJsTokenOrigin::VanillaExtract)
745            }
746            // 1-arg createTheme returns [themeClass, vars]; tokens on the second
747            // destructure element. The 2-arg (contract, tokens) form fills an
748            // existing contract and is skipped (createThemeContract is canonical).
749            (Lib::VanillaExtract, "createTheme") if arg_count == 1 => Some(Recognized {
750                binding_source: BindingSource::TupleElement(1),
751                tokens_arg: 0,
752                origin: CssInJsTokenOrigin::VanillaExtract,
753            }),
754            // 2-arg createGlobalTheme(selector, tokens) returns the vars object;
755            // the 3-arg (selector, contract, tokens) form returns void (contract
756            // canonical), so only the 2-arg form is a definition site here.
757            (Lib::VanillaExtract, "createGlobalTheme") if arg_count == 2 => {
758                single(1, CssInJsTokenOrigin::VanillaExtract)
759            }
760            (Lib::Panda, "defineTokens") if arg_count >= 1 => single(0, CssInJsTokenOrigin::Panda),
761            _ => None,
762        }
763    }
764
765    /// Extract the access binding name from a declarator's binding pattern for the
766    /// recognized binding source.
767    fn binding_name(decl: &VariableDeclarator<'a>, source: BindingSource) -> Option<&'a str> {
768        match source {
769            BindingSource::LhsIdent => match &decl.id {
770                BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
771                _ => None,
772            },
773            BindingSource::TupleElement(index) => {
774                let BindingPattern::ArrayPattern(arr) = &decl.id else {
775                    return None;
776                };
777                let element = arr.elements.get(index)?.as_ref()?;
778                match element {
779                    BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
780                    _ => None,
781                }
782            }
783        }
784    }
785
786    fn process_declarator(&mut self, decl: &VariableDeclarator<'a>) {
787        let Some(Expression::CallExpression(call)) = &decl.init else {
788            return;
789        };
790        if self.process_panda_config_call(call) {
791            return;
792        }
793        let Some((lib, role)) = self.callee_role(&call.callee) else {
794            return;
795        };
796        let Some(recognized) = Self::recognize(lib, role, call.arguments.len()) else {
797            return;
798        };
799        let Some(binding) = Self::binding_name(decl, recognized.binding_source) else {
800            return;
801        };
802        let Some(Argument::ObjectExpression(obj)) = call.arguments.get(recognized.tokens_arg)
803        else {
804            return;
805        };
806        let mut tokens = Vec::new();
807        collect_token_leaves(&mut self.lines, obj, "", recognized.origin, &mut tokens);
808        if tokens.is_empty() {
809            return;
810        }
811        self.defs.push(CssInJsTokenDef {
812            binding: binding.to_owned(),
813            origin: recognized.origin,
814            tokens,
815        });
816    }
817
818    fn process_panda_config_call(&mut self, call: &oxc_ast::ast::CallExpression<'a>) -> bool {
819        let Some((Lib::Panda, "defineConfig")) = self.callee_role(&call.callee) else {
820            return false;
821        };
822        let Some(Argument::ObjectExpression(obj)) = call.arguments.first() else {
823            return true;
824        };
825        let mut tokens = Vec::new();
826        collect_panda_config_token_leaves(&mut self.lines, obj, &mut tokens);
827        if !tokens.is_empty() {
828            self.defs.push(CssInJsTokenDef {
829                binding: PANDA_CONFIG_BINDING.to_string(),
830                origin: CssInJsTokenOrigin::Panda,
831                tokens,
832            });
833        }
834        true
835    }
836}
837
838/// Flatten an object literal into dotted LEAF paths. An inline-object value
839/// recurses (an intermediate token GROUP, not a token); a value-producing
840/// expression (string / number / `null` contract leaf / call like
841/// `px(2 * grid)` / template / member access like `colors.red['500']`) is a LEAF
842/// token. A BARE IDENTIFIER value (`palette: tailwindPalette`) is SKIPPED: it
843/// references something whose structure is invisible here, most often an imported
844/// token GROUP (recording it as a leaf would invent a phantom token and wrongly
845/// credit every `vars.palette.<x>` access to it). Spreads and computed keys are
846/// skipped because they cannot be resolved statically.
847fn collect_token_leaves(
848    lines: &mut LineCounter<'_>,
849    obj: &ObjectExpression<'_>,
850    prefix: &str,
851    origin: CssInJsTokenOrigin,
852    out: &mut Vec<CssInJsToken>,
853) {
854    for prop in &obj.properties {
855        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
856            continue;
857        };
858        let Some(key) = prop.key.static_name() else {
859            continue;
860        };
861        let path = if prefix.is_empty() {
862            key.to_string()
863        } else {
864            format!("{prefix}.{key}")
865        };
866        match &prop.value {
867            Expression::ObjectExpression(nested)
868                if origin == CssInJsTokenOrigin::Panda
869                    && !prefix.is_empty()
870                    && object_has_static_key(nested, "value") =>
871            {
872                out.push(CssInJsToken {
873                    path,
874                    def_line: lines.line_at(prop.key.span().start),
875                    value: object_static_property_value(nested, "value"),
876                });
877            }
878            Expression::ObjectExpression(nested) => {
879                collect_token_leaves(lines, nested, &path, origin, out);
880            }
881            // A bare identifier is an unresolvable reference, usually an imported
882            // token group; do not record it as a leaf.
883            Expression::Identifier(_) => {}
884            _ => out.push(CssInJsToken {
885                value: static_token_value(&prop.value),
886                path,
887                def_line: lines.line_at(prop.key.span().start),
888            }),
889        }
890    }
891}
892
893fn object_static_property_value(obj: &ObjectExpression<'_>, wanted: &str) -> Option<String> {
894    obj.properties.iter().find_map(|prop| {
895        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
896            return None;
897        };
898        (prop.key.static_name().as_deref() == Some(wanted))
899            .then(|| static_token_value(&prop.value))
900            .flatten()
901    })
902}
903
904fn static_token_value(value: &Expression<'_>) -> Option<String> {
905    match value {
906        Expression::StringLiteral(lit) => {
907            let text = lit.value.as_str().trim();
908            (!text.is_empty()).then(|| text.to_string())
909        }
910        Expression::NumericLiteral(num) => Some(format_numeric_token(num)),
911        Expression::UnaryExpression(unary) if unary.operator == UnaryOperator::UnaryNegation => {
912            if let Expression::NumericLiteral(num) = &unary.argument {
913                Some(format!("-{}", format_numeric_token(num)))
914            } else {
915                None
916            }
917        }
918        _ => None,
919    }
920}
921
922fn format_numeric_token(num: &NumericLiteral<'_>) -> String {
923    if num.value.fract() == 0.0 {
924        format!("{:.0}", num.value)
925    } else {
926        num.value.to_string()
927    }
928}
929
930fn is_theme_binding_name(name: &str) -> bool {
931    let lower = name.to_ascii_lowercase();
932    lower == "theme" || lower.ends_with("theme")
933}
934
935fn object_has_static_key(obj: &ObjectExpression<'_>, wanted: &str) -> bool {
936    obj.properties.iter().any(|prop| {
937        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
938            return false;
939        };
940        prop.key.static_name().is_some_and(|key| key == wanted)
941    })
942}
943
944fn object_static_property_object<'a>(
945    obj: &'a ObjectExpression<'a>,
946    wanted: &str,
947) -> Option<&'a ObjectExpression<'a>> {
948    obj.properties.iter().find_map(|prop| {
949        let ObjectPropertyKind::ObjectProperty(prop) = prop else {
950            return None;
951        };
952        if prop.key.static_name().as_deref() == Some(wanted)
953            && let Expression::ObjectExpression(value) = &prop.value
954        {
955            Some(&**value)
956        } else {
957            None
958        }
959    })
960}
961
962fn collect_panda_config_token_leaves(
963    lines: &mut LineCounter<'_>,
964    obj: &ObjectExpression<'_>,
965    out: &mut Vec<CssInJsToken>,
966) {
967    let Some(theme) = object_static_property_object(obj, "theme") else {
968        return;
969    };
970    for key in ["tokens", "semanticTokens"] {
971        if let Some(tokens) = object_static_property_object(theme, key) {
972            collect_token_leaves(lines, tokens, "", CssInJsTokenOrigin::Panda, out);
973        }
974    }
975}
976
977impl<'a> Visit<'a> for TokenDefCollector<'a> {
978    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
979        self.process_declarator(decl);
980        walk::walk_variable_declarator(self, decl);
981    }
982
983    fn visit_export_default_declaration(
984        &mut self,
985        decl: &oxc_ast::ast::ExportDefaultDeclaration<'a>,
986    ) {
987        if let Some(Expression::CallExpression(call)) = decl.declaration.as_expression() {
988            self.process_panda_config_call(call);
989        }
990        walk::walk_export_default_declaration(self, decl);
991    }
992}
993
994/// Count `\n` bytes in `s` as a saturating `u32`.
995fn count_newlines_u32(s: &str) -> u32 {
996    u32::try_from(s.bytes().filter(|&b| b == b'\n').count()).unwrap_or(u32::MAX)
997}
998
999/// Incremental 1-based line-number counter over a fixed `source` (issue #1843
1000/// follow-up). The old free `line_at` counted the newlines in `source[..offset]`
1001/// from the start on every call, so a token file with M definitions cost
1002/// O(M * source-len). Definitions and consumer hits are visited in source order,
1003/// so this advances a cursor by only the newline delta since the previous query
1004/// (`source[last_offset..offset]`), making a whole walk O(source-len). A
1005/// non-monotonic query rewinds by the reverse delta, and an out-of-range or
1006/// non-char-boundary offset clamps to line 1 exactly as the previous `line_at`
1007/// did (matching `css::line_at_offset`), so the result is byte-identical to a
1008/// from-scratch count regardless of query order. Deliberately a plain cursor,
1009/// mirroring the `MAX_BINDING_PATH_DEPTH` bounded-work companions.
1010struct LineCounter<'a> {
1011    source: &'a str,
1012    /// Byte offset of the last query whose line was computed. Always a valid
1013    /// char boundary (only ever assigned a boundary-checked `end`).
1014    last_offset: usize,
1015    /// `1 + count_newlines(&source[..last_offset])`, the invariant maintained
1016    /// across queries.
1017    last_line: u32,
1018}
1019
1020impl<'a> LineCounter<'a> {
1021    fn new(source: &'a str) -> Self {
1022        Self {
1023            source,
1024            last_offset: 0,
1025            last_line: 1,
1026        }
1027    }
1028
1029    /// 1-based line number of `offset`, byte-identical to the previous
1030    /// `line_at(source, offset)`.
1031    fn line_at(&mut self, offset: u32) -> u32 {
1032        let end = (offset as usize).min(self.source.len());
1033        // Preserve the previous `.get(..end)` contract: a non-char-boundary
1034        // offset clamps to line 1 rather than panicking on the slice below.
1035        if !self.source.is_char_boundary(end) {
1036            return 1;
1037        }
1038        if end >= self.last_offset {
1039            let delta = count_newlines_u32(&self.source[self.last_offset..end]);
1040            self.last_line = self.last_line.saturating_add(delta);
1041        } else {
1042            let delta = count_newlines_u32(&self.source[end..self.last_offset]);
1043            self.last_line = self.last_line.saturating_sub(delta);
1044        }
1045        self.last_offset = end;
1046        self.last_line
1047    }
1048}
1049
1050#[cfg(all(test, not(miri)))]
1051mod tests {
1052    use super::*;
1053
1054    fn defs(source: &str) -> Vec<CssInJsTokenDef> {
1055        css_in_js_token_defs(source, Path::new("tokens.ts"))
1056    }
1057
1058    fn paths(defs: &[CssInJsTokenDef], binding: &str) -> Vec<String> {
1059        defs.iter()
1060            .find(|d| d.binding == binding)
1061            .map(|d| d.tokens.iter().map(|t| t.path.clone()).collect())
1062            .unwrap_or_default()
1063    }
1064
1065    fn token_values(defs: &[CssInJsTokenDef], binding: &str) -> Vec<(String, Option<String>)> {
1066        defs.iter()
1067            .find(|d| d.binding == binding)
1068            .map(|d| {
1069                d.tokens
1070                    .iter()
1071                    .map(|t| (t.path.clone(), t.value.clone()))
1072                    .collect()
1073            })
1074            .unwrap_or_default()
1075    }
1076
1077    fn theme_defs(source: &str) -> Vec<CssInJsTokenDef> {
1078        css_in_js_theme_token_defs(source, Path::new("theme.ts"))
1079    }
1080
1081    #[test]
1082    fn incremental_def_lines_match_source_order() {
1083        // Issue #1843 follow-up (FIX B): the incremental LineCounter must yield
1084        // the same def_line as a from-scratch newline count for every token,
1085        // across MULTIPLE definitions on multiple lines (the source-order cursor
1086        // must advance correctly between separate `defineVars` calls, not just
1087        // within one).
1088        let src = "import { defineVars } from '@stylexjs/stylex';\n\
1089export const colors = defineVars({\n\
1090primary: '#000',\n\
1091secondary: '#fff',\n\
1092});\n\
1093export const space = defineVars({\n\
1094sm: '4px',\n\
1095lg: '16px',\n\
1096});\n";
1097        let d = defs(src);
1098        let line_of = |binding: &str, path: &str| {
1099            d.iter()
1100                .find(|def| def.binding == binding)
1101                .and_then(|def| def.tokens.iter().find(|t| t.path == path))
1102                .unwrap_or_else(|| panic!("token {binding}.{path} present"))
1103                .def_line
1104        };
1105        assert_eq!(line_of("colors", "primary"), 3);
1106        assert_eq!(line_of("colors", "secondary"), 4);
1107        assert_eq!(line_of("space", "sm"), 7);
1108        assert_eq!(line_of("space", "lg"), 8);
1109    }
1110
1111    #[test]
1112    fn stylex_define_vars_flat_namespace_call() {
1113        let d = defs(
1114            r"
1115import * as stylex from '@stylexjs/stylex';
1116export const vars = stylex.defineVars({ primaryColor: '#3b82f6', spacingSm: '4px' });
1117",
1118        );
1119        assert_eq!(paths(&d, "vars"), vec!["primaryColor", "spacingSm"]);
1120        assert_eq!(
1121            token_values(&d, "vars"),
1122            vec![
1123                ("primaryColor".to_string(), Some("#3b82f6".to_string())),
1124                ("spacingSm".to_string(), Some("4px".to_string())),
1125            ]
1126        );
1127    }
1128
1129    #[test]
1130    fn stylex_define_vars_named_import_nested() {
1131        let d = defs(
1132            r"
1133import { defineVars } from '@stylexjs/stylex';
1134export const vars = defineVars({ color: { primary: '#000', secondary: '#fff' } });
1135",
1136        );
1137        assert_eq!(paths(&d, "vars"), vec!["color.primary", "color.secondary"]);
1138    }
1139
1140    #[test]
1141    fn panda_define_tokens_collapses_value_objects() {
1142        let d = defs(
1143            r"
1144import { defineTokens } from '@pandacss/dev';
1145export const tokens = defineTokens({
1146  colors: {
1147    brand: { value: '#f05a28' },
1148    accent: { value: '{colors.brand}' },
1149  },
1150  spacing: { card: { value: '1rem' } },
1151});
1152",
1153        );
1154        assert_eq!(
1155            paths(&d, "tokens"),
1156            vec!["colors.brand", "colors.accent", "spacing.card"]
1157        );
1158        assert_eq!(
1159            token_values(&d, "tokens"),
1160            vec![
1161                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1162                (
1163                    "colors.accent".to_string(),
1164                    Some("{colors.brand}".to_string())
1165                ),
1166                ("spacing.card".to_string(), Some("1rem".to_string())),
1167            ]
1168        );
1169        assert_eq!(
1170            d.iter().find(|d| d.binding == "tokens").unwrap().origin,
1171            CssInJsTokenOrigin::Panda
1172        );
1173    }
1174
1175    #[test]
1176    fn panda_define_config_extracts_tokens_and_semantic_tokens() {
1177        let d = defs(
1178            r"
1179import { defineConfig } from '@pandacss/dev';
1180
1181export default defineConfig({
1182  theme: {
1183    tokens: {
1184      colors: {
1185        brand: { value: '#f05a28' },
1186      },
1187    },
1188    semanticTokens: {
1189      colors: {
1190        surface: { value: { base: '{colors.brand}', _dark: '#111111' } },
1191      },
1192    },
1193    recipes: {
1194      card: { base: { color: 'colors.brand' } },
1195    },
1196  },
1197});
1198",
1199        );
1200        assert_eq!(
1201            paths(&d, "pandaConfig"),
1202            vec!["colors.brand", "colors.surface"]
1203        );
1204        assert_eq!(
1205            token_values(&d, "pandaConfig"),
1206            vec![
1207                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1208                ("colors.surface".to_string(), None),
1209            ]
1210        );
1211        assert_eq!(
1212            d.iter()
1213                .find(|d| d.binding == "pandaConfig")
1214                .unwrap()
1215                .origin,
1216            CssInJsTokenOrigin::Panda
1217        );
1218    }
1219
1220    #[test]
1221    fn theme_object_definitions_flatten_static_leaves() {
1222        let d = theme_defs(
1223            r"
1224export const appTheme = {
1225  colors: { brand: '#f05a28', accent: '#111' },
1226  space: { card: '1rem' },
1227  dynamic: palette,
1228};
1229",
1230        );
1231        assert_eq!(
1232            paths(&d, "appTheme"),
1233            vec!["colors.brand", "colors.accent", "space.card"]
1234        );
1235        assert_eq!(
1236            token_values(&d, "appTheme"),
1237            vec![
1238                ("colors.brand".to_string(), Some("#f05a28".to_string())),
1239                ("colors.accent".to_string(), Some("#111".to_string())),
1240                ("space.card".to_string(), Some("1rem".to_string())),
1241            ]
1242        );
1243        assert_eq!(
1244            d.iter().find(|d| d.binding == "appTheme").unwrap().origin,
1245            CssInJsTokenOrigin::Theme
1246        );
1247    }
1248
1249    #[test]
1250    fn theme_consumers_credit_props_and_destructured_theme_reads() {
1251        let leaves = ["colors.brand", "space.card"]
1252            .into_iter()
1253            .map(str::to_owned)
1254            .collect();
1255        let hits = css_in_js_theme_consumers(
1256            r"
1257import styled from 'styled-components';
1258export const Card = styled.div`
1259  color: ${({ theme }) => theme.colors.brand};
1260  margin: ${props => props.theme.space.card};
1261`;
1262",
1263            Path::new("card.tsx"),
1264            &leaves,
1265        );
1266        let mut token_paths: Vec<String> = hits.into_iter().map(|hit| hit.token_path).collect();
1267        token_paths.sort();
1268        assert_eq!(token_paths, vec!["colors.brand", "space.card"]);
1269    }
1270
1271    #[test]
1272    fn ve_create_theme_tuple_destructure_binds_element_one() {
1273        let d = defs(
1274            r"
1275import { createTheme } from '@vanilla-extract/css';
1276export const [themeClass, vars] = createTheme({
1277  color: { brand: 'red', accent: 'blue' },
1278  space: { small: '4px' },
1279});
1280",
1281        );
1282        // Token paths bind to `vars` (element 1), NOT `themeClass`.
1283        assert_eq!(
1284            paths(&d, "vars"),
1285            vec!["color.brand", "color.accent", "space.small"]
1286        );
1287        assert!(paths(&d, "themeClass").is_empty());
1288    }
1289
1290    #[test]
1291    fn ve_create_theme_contract_null_leaves() {
1292        let d = defs(
1293            r"
1294import { createThemeContract } from '@vanilla-extract/css';
1295export const vars = createThemeContract({ color: { brand: null, accent: null } });
1296",
1297        );
1298        // `null` contract leaves are tokens (the contract declares the shape).
1299        assert_eq!(paths(&d, "vars"), vec!["color.brand", "color.accent"]);
1300    }
1301
1302    #[test]
1303    fn ve_create_global_theme_two_arg_binds_lhs_tokens_in_second_arg() {
1304        let d = defs(
1305            r"
1306import { createGlobalTheme } from '@vanilla-extract/css';
1307export const vars = createGlobalTheme(':root', { color: { brand: 'red' } });
1308",
1309        );
1310        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
1311    }
1312
1313    #[test]
1314    fn ve_create_theme_two_arg_contract_impl_is_not_a_definition_site() {
1315        // The 2-arg form fills an existing contract (declared by
1316        // createThemeContract elsewhere); it must NOT introduce a binding.
1317        let d = defs(
1318            r"
1319import { createTheme } from '@vanilla-extract/css';
1320export const themeClass = createTheme(vars, { color: { brand: 'red' } });
1321",
1322        );
1323        assert!(
1324            d.is_empty(),
1325            "2-arg createTheme must not define tokens, got {d:?}"
1326        );
1327    }
1328
1329    #[test]
1330    fn ve_create_global_theme_three_arg_contract_impl_is_not_a_definition_site() {
1331        let d = defs(
1332            r"
1333import { createGlobalTheme } from '@vanilla-extract/css';
1334createGlobalTheme(':root', vars, { color: { brand: 'red' } });
1335",
1336        );
1337        assert!(
1338            d.is_empty(),
1339            "3-arg createGlobalTheme must not define tokens, got {d:?}"
1340        );
1341    }
1342
1343    #[test]
1344    fn aliased_named_import_still_fires() {
1345        let d = defs(
1346            r"
1347import { createThemeContract as ct } from '@vanilla-extract/css';
1348export const vars = ct({ color: { brand: null } });
1349",
1350        );
1351        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
1352    }
1353
1354    #[test]
1355    fn local_helper_not_from_library_does_not_fire() {
1356        // A local `defineVars` shadowing the StyleX name must not be recognized.
1357        let d = defs(
1358            r"
1359function defineVars(o) { return o; }
1360export const vars = defineVars({ color: { primary: '#000' } });
1361",
1362        );
1363        assert!(d.is_empty(), "local defineVars must not fire, got {d:?}");
1364    }
1365
1366    #[test]
1367    fn unrelated_create_theme_import_does_not_fire() {
1368        let d = defs(
1369            r"
1370import { createTheme } from '@mui/material/styles';
1371export const theme = createTheme({ palette: { primary: { main: '#000' } } });
1372",
1373        );
1374        assert!(d.is_empty(), "non-VE createTheme must not fire, got {d:?}");
1375    }
1376
1377    #[test]
1378    fn type_only_import_does_not_fire() {
1379        let d = defs(
1380            r"
1381import type { defineVars } from '@stylexjs/stylex';
1382export const vars = defineVars({ color: { primary: '#000' } });
1383",
1384        );
1385        assert!(
1386            d.is_empty(),
1387            "type-only import must not gate recognition, got {d:?}"
1388        );
1389    }
1390
1391    #[test]
1392    fn token_def_lines_are_per_leaf() {
1393        let src = "import { defineVars } from '@stylexjs/stylex';\nexport const vars = defineVars({\n  color: {\n    primary: '#000',\n    secondary: '#fff',\n  },\n});\n";
1394        let d = defs(src);
1395        let def = d.iter().find(|d| d.binding == "vars").unwrap();
1396        let primary = def
1397            .tokens
1398            .iter()
1399            .find(|t| t.path == "color.primary")
1400            .unwrap();
1401        let secondary = def
1402            .tokens
1403            .iter()
1404            .find(|t| t.path == "color.secondary")
1405            .unwrap();
1406        assert_eq!(primary.def_line, 4);
1407        assert_eq!(secondary.def_line, 5);
1408    }
1409
1410    #[test]
1411    fn spread_and_computed_keys_are_skipped() {
1412        let d = defs(
1413            r"
1414import { defineVars } from '@stylexjs/stylex';
1415const base = { a: '1' };
1416export const vars = defineVars({ ...base, ['x' + 'y']: '2', real: '#000' });
1417",
1418        );
1419        // Only the statically-resolvable `real` leaf survives.
1420        assert_eq!(paths(&d, "vars"), vec!["real"]);
1421    }
1422
1423    #[test]
1424    fn identifier_valued_key_is_not_a_leaf_but_call_and_member_values_are() {
1425        // `palette: tailwindPalette` (bare identifier, an imported group) must NOT
1426        // become a phantom `palette` leaf; `radius: px(2)` (call) and
1427        // `red: colors.red['500']` (member access) are real scalar leaves.
1428        let d = defs(
1429            r"
1430import { createGlobalTheme } from '@vanilla-extract/css';
1431export const vars = createGlobalTheme(':root', {
1432  palette: tailwindPalette,
1433  radius: px(2),
1434  red: colors.red['500'],
1435});
1436",
1437        );
1438        let p = paths(&d, "vars");
1439        assert!(
1440            !p.contains(&"palette".to_string()),
1441            "identifier-valued key must not be a leaf: {p:?}"
1442        );
1443        assert!(
1444            p.contains(&"radius".to_string()),
1445            "call-valued key is a leaf: {p:?}"
1446        );
1447        assert!(
1448            p.contains(&"red".to_string()),
1449            "member-valued key is a leaf: {p:?}"
1450        );
1451    }
1452
1453    #[test]
1454    fn no_css_in_js_import_returns_empty() {
1455        let d = defs("export const vars = { color: { primary: '#000' } };");
1456        assert!(d.is_empty());
1457    }
1458
1459    fn leaves(paths: &[&str]) -> FxHashSet<String> {
1460        paths.iter().map(|s| (*s).to_string()).collect()
1461    }
1462
1463    fn consumers(source: &str, alias: &str, paths: &[&str]) -> Vec<TokenConsumerHit> {
1464        css_in_js_token_consumers(source, Path::new("card.ts"), alias, &leaves(paths))
1465    }
1466
1467    fn panda_consumers(source: &str, alias: &str, paths: &[&str]) -> Vec<TokenConsumerHit> {
1468        panda_token_call_consumers(source, Path::new("card.ts"), alias, &leaves(paths))
1469    }
1470
1471    fn panda_style_consumers(
1472        source: &str,
1473        aliases: &[&str],
1474        paths: &[&str],
1475    ) -> Vec<TokenConsumerHit> {
1476        let aliases = aliases.iter().map(|s| (*s).to_string()).collect();
1477        panda_style_value_consumers(source, Path::new("card.ts"), &aliases, &leaves(paths))
1478    }
1479
1480    #[test]
1481    fn consumer_matches_deepest_leaf_not_intermediate_group() {
1482        // `vars.color.primary` is the leaf; `vars.color` (an intermediate group)
1483        // must NOT be counted, so exactly one hit per access site.
1484        let hits = consumers(
1485            "const a = vars.color.primary;",
1486            "vars",
1487            &["color.primary", "color.secondary"],
1488        );
1489        assert_eq!(hits.len(), 1);
1490        assert_eq!(hits[0].token_path, "color.primary");
1491        assert_eq!(hits[0].line, 1);
1492    }
1493
1494    #[test]
1495    fn consumer_aliased_receiver() {
1496        // The caller passes the local alias; member access on it is matched.
1497        let hits = consumers("const a = v.color.primary;", "v", &["color.primary"]);
1498        assert_eq!(hits.len(), 1);
1499        assert_eq!(hits[0].token_path, "color.primary");
1500    }
1501
1502    #[test]
1503    fn consumer_multiple_sites_distinct_lines() {
1504        let src = "const a = vars.color.primary;\nconst b = vars.space.sm;\nconst c = vars.color.primary;";
1505        let hits = consumers(src, "vars", &["color.primary", "space.sm"]);
1506        assert_eq!(hits.len(), 3);
1507        let lines: Vec<u32> = hits.iter().map(|h| h.line).collect();
1508        assert_eq!(lines, vec![1, 2, 3]);
1509    }
1510
1511    #[test]
1512    fn consumer_in_style_object_value_position() {
1513        // The dominant real shape: a token read inside a style-call object value.
1514        let hits = consumers(
1515            "export const s = stylex.create({ root: { color: vars.color.primary } });",
1516            "vars",
1517            &["color.primary"],
1518        );
1519        assert_eq!(hits.len(), 1);
1520        assert_eq!(hits[0].token_path, "color.primary");
1521    }
1522
1523    #[test]
1524    fn panda_token_call_consumer_matches_string_literal() {
1525        let hits = panda_consumers(
1526            "export const c = css({ color: token('colors.brand') });",
1527            "token",
1528            &["colors.brand", "colors.accent"],
1529        );
1530        assert_eq!(hits.len(), 1);
1531        assert_eq!(hits[0].token_path, "colors.brand");
1532    }
1533
1534    #[test]
1535    fn panda_style_value_consumer_matches_known_token_string() {
1536        let hits = panda_style_consumers(
1537            "export const c = css({ color: 'colors.brand', _hover: { bg: 'colors.accent' } });",
1538            &["css"],
1539            &["colors.brand", "colors.accent", "colors.unused"],
1540        );
1541        let paths: Vec<_> = hits.iter().map(|hit| hit.token_path.as_str()).collect();
1542        assert_eq!(paths, vec!["colors.brand", "colors.accent"]);
1543    }
1544
1545    #[test]
1546    fn panda_style_value_consumer_ignores_unimported_alias() {
1547        let hits = panda_style_consumers(
1548            "export const c = notPanda({ color: 'colors.brand' });",
1549            &["css"],
1550            &["colors.brand"],
1551        );
1552        assert!(hits.is_empty());
1553    }
1554
1555    #[test]
1556    fn consumer_flat_stylex_depth_one() {
1557        let hits = consumers("const a = vars.primaryColor;", "vars", &["primaryColor"]);
1558        assert_eq!(hits.len(), 1);
1559        assert_eq!(hits[0].token_path, "primaryColor");
1560    }
1561
1562    #[test]
1563    fn consumer_other_binding_not_matched() {
1564        // A same-named member access on a DIFFERENT binding must not be a hit.
1565        let hits = consumers("const a = other.color.primary;", "vars", &["color.primary"]);
1566        assert!(hits.is_empty());
1567    }
1568
1569    #[test]
1570    fn consumer_deeper_access_past_leaf_matches_leaf_subexpression_once() {
1571        // `vars.color.primary.toString()` reads the leaf `color.primary`; the outer
1572        // `.toString` chain is not a leaf, the inner `vars.color.primary` is.
1573        let hits = consumers(
1574            "const a = vars.color.primary.toString();",
1575            "vars",
1576            &["color.primary"],
1577        );
1578        assert_eq!(hits.len(), 1);
1579        assert_eq!(hits[0].token_path, "color.primary");
1580    }
1581
1582    #[test]
1583    fn consumer_undefined_path_not_matched() {
1584        let hits = consumers("const a = vars.color.tertiary;", "vars", &["color.primary"]);
1585        assert!(hits.is_empty());
1586    }
1587
1588    #[test]
1589    fn consumer_bracket_notation_hyphenated_key() {
1590        // Hyphenated / digit-leading token keys are not valid JS identifiers, so
1591        // they are consumed via bracket notation; the leaf path keeps the raw key.
1592        let hits = consumers(
1593            "const a = vars.color['gray-100'];\nconst b = vars.borderRadius['0x'];",
1594            "vars",
1595            &["color.gray-100", "borderRadius.0x"],
1596        );
1597        let paths: Vec<&str> = hits.iter().map(|h| h.token_path.as_str()).collect();
1598        assert!(paths.contains(&"color.gray-100"));
1599        assert!(paths.contains(&"borderRadius.0x"));
1600        assert_eq!(hits.len(), 2);
1601    }
1602
1603    #[test]
1604    fn consumer_mixed_dot_and_bracket_chain() {
1605        // `vars['color'].primary` and `vars.color['primary']` both reconstruct the
1606        // same `color.primary` leaf.
1607        let hits = consumers(
1608            "const a = vars['color'].primary;\nconst b = vars.color['primary'];",
1609            "vars",
1610            &["color.primary"],
1611        );
1612        assert_eq!(hits.len(), 2);
1613        assert!(hits.iter().all(|h| h.token_path == "color.primary"));
1614    }
1615
1616    #[test]
1617    fn consumer_non_literal_computed_key_not_matched() {
1618        // A dynamic computed key cannot be resolved statically (lower-bound miss).
1619        let hits = consumers(
1620            "const k = 'primary'; const a = vars.color[k];",
1621            "vars",
1622            &["color.primary"],
1623        );
1624        assert!(hits.is_empty());
1625    }
1626
1627    #[test]
1628    fn consumer_empty_inputs_short_circuit() {
1629        assert!(consumers("const a = vars.color.primary;", "", &["color.primary"]).is_empty());
1630        assert!(consumers("const a = vars.color.primary;", "vars", &[]).is_empty());
1631    }
1632
1633    #[test]
1634    fn consumer_scan_matches_individual_calls() {
1635        // One source exercising all four query kinds; the scan must return exactly
1636        // the union of the four individual functions' hits, each tagged with the
1637        // index of the query that produced it.
1638        let source = "const a = vars.color.primary;\nconst b = css({ color: token('colors.brand'), background: 'colors.accent' });\nconst c = theme.space.card;";
1639        let path = Path::new("card.tsx");
1640
1641        let member_leaves = leaves(&["color.primary"]);
1642        let panda_call_leaves = leaves(&["colors.brand"]);
1643        let panda_style_aliases = leaves(&["css"]);
1644        let panda_style_leaves = leaves(&["colors.accent"]);
1645        let theme_leaves = leaves(&["space.card"]);
1646
1647        let queries = [
1648            ConsumerQuery::MemberBinding {
1649                alias: "vars",
1650                leaf_paths: &member_leaves,
1651            },
1652            ConsumerQuery::PandaTokenCall {
1653                alias: "token",
1654                leaf_paths: &panda_call_leaves,
1655            },
1656            ConsumerQuery::PandaStyleValues {
1657                aliases: &panda_style_aliases,
1658                leaf_paths: &panda_style_leaves,
1659            },
1660            ConsumerQuery::ThemeReads {
1661                leaf_paths: &theme_leaves,
1662            },
1663        ];
1664        let scanned = css_in_js_consumer_scan(source, path, &queries);
1665
1666        let individual: Vec<(usize, TokenConsumerHit)> =
1667            css_in_js_token_consumers(source, path, "vars", &member_leaves)
1668                .into_iter()
1669                .map(|hit| (0, hit))
1670                .chain(
1671                    panda_token_call_consumers(source, path, "token", &panda_call_leaves)
1672                        .into_iter()
1673                        .map(|hit| (1, hit)),
1674                )
1675                .chain(
1676                    panda_style_value_consumers(
1677                        source,
1678                        path,
1679                        &panda_style_aliases,
1680                        &panda_style_leaves,
1681                    )
1682                    .into_iter()
1683                    .map(|hit| (2, hit)),
1684                )
1685                .chain(
1686                    css_in_js_theme_consumers(source, path, &theme_leaves)
1687                        .into_iter()
1688                        .map(|hit| (3, hit)),
1689                )
1690                .collect();
1691
1692        assert_eq!(scanned, individual);
1693        assert_eq!(scanned.len(), 4);
1694        assert_eq!(
1695            scanned[0],
1696            (
1697                0,
1698                TokenConsumerHit {
1699                    token_path: "color.primary".to_string(),
1700                    line: 1,
1701                }
1702            )
1703        );
1704        assert_eq!(
1705            scanned[3],
1706            (
1707                3,
1708                TokenConsumerHit {
1709                    token_path: "space.card".to_string(),
1710                    line: 3,
1711                }
1712            )
1713        );
1714    }
1715
1716    #[test]
1717    fn consumer_scan_empty_query_is_isolated() {
1718        // An empty-alias query short-circuits to no hits WITHOUT suppressing the
1719        // valid query that follows it.
1720        let source = "const a = vars.color.primary;";
1721        let path = Path::new("card.ts");
1722        let empty_leaves = leaves(&["color.primary"]);
1723        let valid_leaves = leaves(&["color.primary"]);
1724        let queries = [
1725            ConsumerQuery::MemberBinding {
1726                alias: "",
1727                leaf_paths: &empty_leaves,
1728            },
1729            ConsumerQuery::MemberBinding {
1730                alias: "vars",
1731                leaf_paths: &valid_leaves,
1732            },
1733        ];
1734        let scanned = css_in_js_consumer_scan(source, path, &queries);
1735        assert_eq!(scanned.len(), 1);
1736        assert_eq!(scanned[0].0, 1);
1737        assert_eq!(scanned[0].1.token_path, "color.primary");
1738    }
1739
1740    #[test]
1741    fn consumer_scan_two_member_queries_same_source() {
1742        // Two definers imported under different aliases with an overlapping leaf
1743        // path; each read attributes to the alias (query index) it used.
1744        let source = "const a = brand.color.primary;\nconst b = accent.color.primary;";
1745        let path = Path::new("card.ts");
1746        let brand_leaves = leaves(&["color.primary"]);
1747        let accent_leaves = leaves(&["color.primary"]);
1748        let queries = [
1749            ConsumerQuery::MemberBinding {
1750                alias: "brand",
1751                leaf_paths: &brand_leaves,
1752            },
1753            ConsumerQuery::MemberBinding {
1754                alias: "accent",
1755                leaf_paths: &accent_leaves,
1756            },
1757        ];
1758        let scanned = css_in_js_consumer_scan(source, path, &queries);
1759        assert_eq!(scanned.len(), 2);
1760        assert!(scanned.contains(&(
1761            0,
1762            TokenConsumerHit {
1763                token_path: "color.primary".to_string(),
1764                line: 1,
1765            }
1766        )));
1767        assert!(scanned.contains(&(
1768            1,
1769            TokenConsumerHit {
1770                token_path: "color.primary".to_string(),
1771                line: 2,
1772            }
1773        )));
1774    }
1775}