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 (cut 1: StyleX + vanilla-extract)
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//!
35//! The two CONTRACT-IMPLEMENTATION forms are deliberately NOT definition sites
36//! here, because the contract they fill was already declared by
37//! `createThemeContract` (captured above) and that is the binding consumers read:
38//! - `createTheme(contract, {...})` (2-arg) returns a class string; tokens fill
39//!   the existing `contract`.
40//! - `createGlobalTheme(selector, contract, {...})` (3-arg) returns void.
41//!
42//! Panda (`defineTokens` / `token('...')`) is deferred to a 3e follow-on (its
43//! dominant consumption is bare-string-in-style-value, a different consumer scan).
44
45use std::path::Path;
46
47use oxc_allocator::Allocator;
48use oxc_ast::ast::{
49    Argument, BindingPattern, ComputedMemberExpression, Expression, ImportDeclarationSpecifier,
50    ObjectExpression, ObjectPropertyKind, Program, Statement, StaticMemberExpression,
51    VariableDeclarator,
52};
53use oxc_ast_visit::{Visit, walk};
54use oxc_parser::Parser;
55use oxc_span::{GetSpan, SourceType};
56use rustc_hash::{FxHashMap, FxHashSet};
57
58use super::object::{Lib, module_library};
59
60/// A single defined design token: its dotted LEAF path relative to the access
61/// binding (`color.primary`, or flat `primaryColor` for StyleX), and the 1-based
62/// source line of its key.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CssInJsToken {
65    /// Dotted leaf path relative to the binding (e.g. `color.primary`).
66    pub path: String,
67    /// 1-based line of the token's key in the defining source.
68    pub def_line: u32,
69}
70
71/// A CSS-in-JS token-definition site: the exported access binding consumers read
72/// through (e.g. `vars`) and the flattened leaf tokens it defines.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct CssInJsTokenDef {
75    /// The identifier the token surface is bound to (`vars`), the receiver of
76    /// cross-module member access (`vars.color.primary`).
77    pub binding: String,
78    /// The flattened leaf tokens defined on `binding`.
79    pub tokens: Vec<CssInJsToken>,
80}
81
82/// Walk a JS/TS source for CSS-in-JS design-token DEFINITIONS, returning each
83/// access binding and its flattened leaf token paths. Empty when the source has
84/// no recognized token-library import (provenance gate closed).
85#[must_use]
86pub fn css_in_js_token_defs(source: &str, path: &Path) -> Vec<CssInJsTokenDef> {
87    let source_type = SourceType::from_path(path).unwrap_or_default();
88    let allocator = Allocator::default();
89    let ret = Parser::new(&allocator, source, source_type).parse();
90
91    let mut collector = TokenDefCollector::new(source);
92    collector.build_import_map(&ret.program);
93    if collector.imports.is_empty() {
94        return Vec::new();
95    }
96    collector.visit_program(&ret.program);
97    collector.defs
98}
99
100/// One located consumer of a CSS-in-JS token: the defined LEAF token path it
101/// reads (relative to the binding, e.g. `color.primary`) and the 1-based line of
102/// the member-access site.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct TokenConsumerHit {
105    /// The defined leaf token path consumed (`color.primary`), relative to the
106    /// access binding (the leading binding segment stripped).
107    pub token_path: String,
108    /// 1-based line of the member-access site in the consuming source.
109    pub line: u32,
110}
111
112/// Walk a consuming JS/TS source for cross-module reads of a token binding,
113/// returning the located reads that resolve to a DEFINED leaf token path. The
114/// caller supplies the local `alias` the consuming file imported the token binding
115/// under (so aliased imports work) and the set of defined leaf paths. A member
116/// access `<alias>.a.b` is a hit when `a.b` is exactly a defined leaf path;
117/// intermediate groups (`<alias>.a` where only `a.b` is defined) and accesses on
118/// other bindings are not hits, so there is no double-count and no false match.
119#[must_use]
120#[expect(
121    clippy::implicit_hasher,
122    reason = "callers build an FxHashSet; std HashSet is a disallowed type here"
123)]
124pub fn css_in_js_token_consumers(
125    source: &str,
126    path: &Path,
127    alias: &str,
128    leaf_paths: &FxHashSet<String>,
129) -> Vec<TokenConsumerHit> {
130    if alias.is_empty() || leaf_paths.is_empty() {
131        return Vec::new();
132    }
133    let source_type = SourceType::from_path(path).unwrap_or_default();
134    let allocator = Allocator::default();
135    let ret = Parser::new(&allocator, source, source_type).parse();
136    let mut collector = ConsumerCollector {
137        source,
138        alias,
139        leaf_paths,
140        hits: Vec::new(),
141    };
142    collector.visit_program(&ret.program);
143    collector.hits
144}
145
146/// Walks a consuming program for member accesses on a token binding alias.
147struct ConsumerCollector<'a, 'b> {
148    source: &'a str,
149    alias: &'b str,
150    leaf_paths: &'b FxHashSet<String>,
151    hits: Vec<TokenConsumerHit>,
152}
153
154impl<'a> ConsumerCollector<'a, '_> {
155    /// Record a hit if `(base, segments)` is exactly `<alias>.<leaf>` for a defined
156    /// leaf path. A node whose chain is `<alias>.<group>` (an intermediate group)
157    /// reconstructs a non-leaf path and is skipped, so each access site yields at
158    /// most one hit (no double count from the nested member expressions).
159    fn record(&mut self, chain: Option<(&'a str, Vec<&'a str>)>, span_start: u32) {
160        if let Some((base, segments)) = chain
161            && base == self.alias
162            && !segments.is_empty()
163        {
164            let token_path = segments.join(".");
165            if self.leaf_paths.contains(&token_path) {
166                self.hits.push(TokenConsumerHit {
167                    token_path,
168                    line: line_at(self.source, span_start),
169                });
170            }
171        }
172    }
173}
174
175impl<'a> Visit<'a> for ConsumerCollector<'a, '_> {
176    fn visit_static_member_expression(&mut self, member: &StaticMemberExpression<'a>) {
177        let mut chain = access_object_chain(&member.object);
178        if let Some((_, segments)) = chain.as_mut() {
179            segments.push(member.property.name.as_str());
180        }
181        self.record(chain, member.span().start);
182        walk::walk_static_member_expression(self, member);
183    }
184
185    fn visit_computed_member_expression(&mut self, member: &ComputedMemberExpression<'a>) {
186        // Bracket access with a STATIC string-literal key (`vars.color['gray-100']`):
187        // the only way to consume a token whose key is not a valid JS identifier
188        // (hyphenated `gray-100`, digit-leading `0x`), which design-token systems use
189        // heavily. Non-literal computed keys (`vars.color[k]`) cannot be resolved
190        // statically and are skipped (a documented lower-bound miss).
191        let mut chain = access_object_chain(&member.object);
192        if let (Some((_, segments)), Some(key)) =
193            (chain.as_mut(), string_literal_key(&member.expression))
194        {
195            segments.push(key);
196        } else {
197            chain = None;
198        }
199        self.record(chain, member.span().start);
200        walk::walk_computed_member_expression(self, member);
201    }
202}
203
204/// Reconstruct the `(base identifier, [segments])` chain of a member-access OBJECT
205/// expression, threading through both static (`a.b`) and string-literal-computed
206/// (`a['b']`) member access. `vars.color` -> `("vars", ["color"])`. Returns `None`
207/// if the chain is not rooted at a plain identifier (a call result, `this`, a
208/// non-literal computed key, etc.).
209fn access_object_chain<'a>(expr: &Expression<'a>) -> Option<(&'a str, Vec<&'a str>)> {
210    match expr {
211        Expression::Identifier(id) => Some((id.name.as_str(), Vec::new())),
212        Expression::StaticMemberExpression(inner) => {
213            let (base, mut segments) = access_object_chain(&inner.object)?;
214            segments.push(inner.property.name.as_str());
215            Some((base, segments))
216        }
217        Expression::ComputedMemberExpression(inner) => {
218            let (base, mut segments) = access_object_chain(&inner.object)?;
219            segments.push(string_literal_key(&inner.expression)?);
220            Some((base, segments))
221        }
222        _ => None,
223    }
224}
225
226/// The value of a string-literal computed-member key (`['gray-100']`), or `None`
227/// for any non-string-literal key (which cannot be resolved statically).
228fn string_literal_key<'a>(expr: &Expression<'a>) -> Option<&'a str> {
229    match expr {
230        Expression::StringLiteral(lit) => Some(lit.value.as_str()),
231        _ => None,
232    }
233}
234
235/// Where the access binding comes from for a recognized token-definition call.
236#[derive(Clone, Copy)]
237enum BindingSource {
238    /// The assigned identifier (`const vars = ...`).
239    LhsIdent,
240    /// An element of an array-destructure (`const [_, vars] = ...`).
241    TupleElement(usize),
242}
243
244/// A recognized token-definition call: where the binding comes from and which
245/// argument carries the token object.
246#[derive(Clone, Copy)]
247struct Recognized {
248    binding_source: BindingSource,
249    tokens_arg: usize,
250}
251
252/// Collects token-definition sites, gated on import provenance.
253struct TokenDefCollector<'a> {
254    source: &'a str,
255    /// local-binding name -> (library, canonical role). Mirrors the
256    /// `css_in_js_object` provenance map but for token-definition roles.
257    imports: FxHashMap<&'a str, (Lib, &'a str)>,
258    defs: Vec<CssInJsTokenDef>,
259}
260
261impl<'a> TokenDefCollector<'a> {
262    fn new(source: &'a str) -> Self {
263        Self {
264            source,
265            imports: FxHashMap::default(),
266            defs: Vec::new(),
267        }
268    }
269
270    /// Map each import binding from a recognized token library to its library +
271    /// canonical role. Named imports dispatch on the imported (canonical) name so
272    /// `import { createTheme as ct }` still fires; default / namespace bindings
273    /// (`import * as stylex`) carry the local name for member-call recognition.
274    fn build_import_map(&mut self, program: &Program<'a>) {
275        for stmt in &program.body {
276            let Statement::ImportDeclaration(decl) = stmt else {
277                continue;
278            };
279            if decl.import_kind.is_type() {
280                continue;
281            }
282            let Some(lib) = module_library(decl.source.value.as_str()) else {
283                continue;
284            };
285            let Some(specifiers) = &decl.specifiers else {
286                continue;
287            };
288            for specifier in specifiers {
289                let (local, role) = match specifier {
290                    ImportDeclarationSpecifier::ImportSpecifier(s) => {
291                        (s.local.name.as_str(), s.imported.name().as_str())
292                    }
293                    ImportDeclarationSpecifier::ImportDefaultSpecifier(s) => {
294                        (s.local.name.as_str(), s.local.name.as_str())
295                    }
296                    ImportDeclarationSpecifier::ImportNamespaceSpecifier(s) => {
297                        (s.local.name.as_str(), s.local.name.as_str())
298                    }
299                };
300                self.imports.insert(local, (lib, role));
301            }
302        }
303    }
304
305    /// Resolve a call's callee to `(library, role)` if its binding is a recognized
306    /// token-library import. Handles both a named/aliased import callee
307    /// (`defineVars(...)`) and a namespace member call (`stylex.defineVars(...)`).
308    fn callee_role(&self, callee: &Expression<'a>) -> Option<(Lib, &'a str)> {
309        match callee {
310            Expression::Identifier(id) => self.imports.get(id.name.as_str()).copied(),
311            Expression::StaticMemberExpression(member) => {
312                let Expression::Identifier(obj) = &member.object else {
313                    return None;
314                };
315                let (lib, _) = *self.imports.get(obj.name.as_str())?;
316                // Member-call role is the accessed property (`stylex.defineVars`).
317                Some((lib, member.property.name.as_str()))
318            }
319            _ => None,
320        }
321    }
322
323    /// Dispatch `(library, role, arg_count)` to a recognized token-definition
324    /// form, or `None` (unrecognized, or a contract-implementation form whose
325    /// contract is the canonical definition).
326    fn recognize(lib: Lib, role: &str, arg_count: usize) -> Option<Recognized> {
327        let single = |tokens_arg| {
328            Some(Recognized {
329                binding_source: BindingSource::LhsIdent,
330                tokens_arg,
331            })
332        };
333        match (lib, role) {
334            // `defineVars(obj)` / `createThemeContract(obj)`: binding = the assigned
335            // identifier, token object = arg 0.
336            (Lib::StyleX, "defineVars") | (Lib::VanillaExtract, "createThemeContract")
337                if arg_count >= 1 =>
338            {
339                single(0)
340            }
341            // 1-arg createTheme returns [themeClass, vars]; tokens on the second
342            // destructure element. The 2-arg (contract, tokens) form fills an
343            // existing contract and is skipped (createThemeContract is canonical).
344            (Lib::VanillaExtract, "createTheme") if arg_count == 1 => Some(Recognized {
345                binding_source: BindingSource::TupleElement(1),
346                tokens_arg: 0,
347            }),
348            // 2-arg createGlobalTheme(selector, tokens) returns the vars object;
349            // the 3-arg (selector, contract, tokens) form returns void (contract
350            // canonical), so only the 2-arg form is a definition site here.
351            (Lib::VanillaExtract, "createGlobalTheme") if arg_count == 2 => single(1),
352            _ => None,
353        }
354    }
355
356    /// Extract the access binding name from a declarator's binding pattern for the
357    /// recognized binding source.
358    fn binding_name(decl: &VariableDeclarator<'a>, source: BindingSource) -> Option<&'a str> {
359        match source {
360            BindingSource::LhsIdent => match &decl.id {
361                BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
362                _ => None,
363            },
364            BindingSource::TupleElement(index) => {
365                let BindingPattern::ArrayPattern(arr) = &decl.id else {
366                    return None;
367                };
368                let element = arr.elements.get(index)?.as_ref()?;
369                match element {
370                    BindingPattern::BindingIdentifier(id) => Some(id.name.as_str()),
371                    _ => None,
372                }
373            }
374        }
375    }
376
377    fn process_declarator(&mut self, decl: &VariableDeclarator<'a>) {
378        let Some(Expression::CallExpression(call)) = &decl.init else {
379            return;
380        };
381        let Some((lib, role)) = self.callee_role(&call.callee) else {
382            return;
383        };
384        let Some(recognized) = Self::recognize(lib, role, call.arguments.len()) else {
385            return;
386        };
387        let Some(binding) = Self::binding_name(decl, recognized.binding_source) else {
388            return;
389        };
390        let Some(Argument::ObjectExpression(obj)) = call.arguments.get(recognized.tokens_arg)
391        else {
392            return;
393        };
394        let mut tokens = Vec::new();
395        self.collect_leaves(obj, "", &mut tokens);
396        if tokens.is_empty() {
397            return;
398        }
399        self.defs.push(CssInJsTokenDef {
400            binding: binding.to_owned(),
401            tokens,
402        });
403    }
404
405    /// Flatten an object literal into dotted LEAF paths. An inline-object value
406    /// recurses (an intermediate token GROUP, not a token); a value-producing
407    /// expression (string / number / `null` contract leaf / call like
408    /// `px(2 * grid)` / template / member access like `colors.red['500']`) is a
409    /// LEAF token. A BARE IDENTIFIER value (`palette: tailwindPalette`) is SKIPPED:
410    /// it references something whose structure is invisible here, most often an
411    /// imported token GROUP (recording it as a leaf would invent a phantom token
412    /// and wrongly credit every `vars.palette.<x>` access to it); the rarer
413    /// identifier-scalar leaf is a lower-bound miss. Spreads and computed keys are
414    /// skipped (cannot resolve statically).
415    fn collect_leaves(
416        &self,
417        obj: &ObjectExpression<'a>,
418        prefix: &str,
419        out: &mut Vec<CssInJsToken>,
420    ) {
421        for prop in &obj.properties {
422            let ObjectPropertyKind::ObjectProperty(prop) = prop else {
423                continue;
424            };
425            let Some(key) = prop.key.static_name() else {
426                continue;
427            };
428            let path = if prefix.is_empty() {
429                key.to_string()
430            } else {
431                format!("{prefix}.{key}")
432            };
433            match &prop.value {
434                Expression::ObjectExpression(nested) => self.collect_leaves(nested, &path, out),
435                // A bare identifier is an unresolvable reference, usually an imported
436                // token group; do not record it as a leaf (avoids a phantom token).
437                Expression::Identifier(_) => {}
438                _ => out.push(CssInJsToken {
439                    path,
440                    def_line: line_at(self.source, prop.key.span().start),
441                }),
442            }
443        }
444    }
445}
446
447impl<'a> Visit<'a> for TokenDefCollector<'a> {
448    fn visit_variable_declarator(&mut self, decl: &VariableDeclarator<'a>) {
449        self.process_declarator(decl);
450        walk::walk_variable_declarator(self, decl);
451    }
452}
453
454/// 1-based line number of a byte offset in `source`. Uses `.get(..end)` so an
455/// out-of-range or non-char-boundary offset clamps to line 1 rather than
456/// panicking (matches `css::line_at_offset`).
457fn line_at(source: &str, offset: u32) -> u32 {
458    let end = (offset as usize).min(source.len());
459    let count = source
460        .get(..end)
461        .map_or(0, |s| s.bytes().filter(|&b| b == b'\n').count());
462    u32::try_from(1 + count).unwrap_or(u32::MAX)
463}
464
465#[cfg(all(test, not(miri)))]
466mod tests {
467    use super::*;
468
469    fn defs(source: &str) -> Vec<CssInJsTokenDef> {
470        css_in_js_token_defs(source, Path::new("tokens.ts"))
471    }
472
473    fn paths(defs: &[CssInJsTokenDef], binding: &str) -> Vec<String> {
474        defs.iter()
475            .find(|d| d.binding == binding)
476            .map(|d| d.tokens.iter().map(|t| t.path.clone()).collect())
477            .unwrap_or_default()
478    }
479
480    #[test]
481    fn stylex_define_vars_flat_namespace_call() {
482        let d = defs(
483            r"
484import * as stylex from '@stylexjs/stylex';
485export const vars = stylex.defineVars({ primaryColor: '#3b82f6', spacingSm: '4px' });
486",
487        );
488        assert_eq!(paths(&d, "vars"), vec!["primaryColor", "spacingSm"]);
489    }
490
491    #[test]
492    fn stylex_define_vars_named_import_nested() {
493        let d = defs(
494            r"
495import { defineVars } from '@stylexjs/stylex';
496export const vars = defineVars({ color: { primary: '#000', secondary: '#fff' } });
497",
498        );
499        assert_eq!(paths(&d, "vars"), vec!["color.primary", "color.secondary"]);
500    }
501
502    #[test]
503    fn ve_create_theme_tuple_destructure_binds_element_one() {
504        let d = defs(
505            r"
506import { createTheme } from '@vanilla-extract/css';
507export const [themeClass, vars] = createTheme({
508  color: { brand: 'red', accent: 'blue' },
509  space: { small: '4px' },
510});
511",
512        );
513        // Token paths bind to `vars` (element 1), NOT `themeClass`.
514        assert_eq!(
515            paths(&d, "vars"),
516            vec!["color.brand", "color.accent", "space.small"]
517        );
518        assert!(paths(&d, "themeClass").is_empty());
519    }
520
521    #[test]
522    fn ve_create_theme_contract_null_leaves() {
523        let d = defs(
524            r"
525import { createThemeContract } from '@vanilla-extract/css';
526export const vars = createThemeContract({ color: { brand: null, accent: null } });
527",
528        );
529        // `null` contract leaves are tokens (the contract declares the shape).
530        assert_eq!(paths(&d, "vars"), vec!["color.brand", "color.accent"]);
531    }
532
533    #[test]
534    fn ve_create_global_theme_two_arg_binds_lhs_tokens_in_second_arg() {
535        let d = defs(
536            r"
537import { createGlobalTheme } from '@vanilla-extract/css';
538export const vars = createGlobalTheme(':root', { color: { brand: 'red' } });
539",
540        );
541        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
542    }
543
544    #[test]
545    fn ve_create_theme_two_arg_contract_impl_is_not_a_definition_site() {
546        // The 2-arg form fills an existing contract (declared by
547        // createThemeContract elsewhere); it must NOT introduce a binding.
548        let d = defs(
549            r"
550import { createTheme } from '@vanilla-extract/css';
551export const themeClass = createTheme(vars, { color: { brand: 'red' } });
552",
553        );
554        assert!(
555            d.is_empty(),
556            "2-arg createTheme must not define tokens, got {d:?}"
557        );
558    }
559
560    #[test]
561    fn ve_create_global_theme_three_arg_contract_impl_is_not_a_definition_site() {
562        let d = defs(
563            r"
564import { createGlobalTheme } from '@vanilla-extract/css';
565createGlobalTheme(':root', vars, { color: { brand: 'red' } });
566",
567        );
568        assert!(
569            d.is_empty(),
570            "3-arg createGlobalTheme must not define tokens, got {d:?}"
571        );
572    }
573
574    #[test]
575    fn aliased_named_import_still_fires() {
576        let d = defs(
577            r"
578import { createThemeContract as ct } from '@vanilla-extract/css';
579export const vars = ct({ color: { brand: null } });
580",
581        );
582        assert_eq!(paths(&d, "vars"), vec!["color.brand"]);
583    }
584
585    #[test]
586    fn local_helper_not_from_library_does_not_fire() {
587        // A local `defineVars` shadowing the StyleX name must not be recognized.
588        let d = defs(
589            r"
590function defineVars(o) { return o; }
591export const vars = defineVars({ color: { primary: '#000' } });
592",
593        );
594        assert!(d.is_empty(), "local defineVars must not fire, got {d:?}");
595    }
596
597    #[test]
598    fn unrelated_create_theme_import_does_not_fire() {
599        let d = defs(
600            r"
601import { createTheme } from '@mui/material/styles';
602export const theme = createTheme({ palette: { primary: { main: '#000' } } });
603",
604        );
605        assert!(d.is_empty(), "non-VE createTheme must not fire, got {d:?}");
606    }
607
608    #[test]
609    fn type_only_import_does_not_fire() {
610        let d = defs(
611            r"
612import type { defineVars } from '@stylexjs/stylex';
613export const vars = defineVars({ color: { primary: '#000' } });
614",
615        );
616        assert!(
617            d.is_empty(),
618            "type-only import must not gate recognition, got {d:?}"
619        );
620    }
621
622    #[test]
623    fn token_def_lines_are_per_leaf() {
624        let src = "import { defineVars } from '@stylexjs/stylex';\nexport const vars = defineVars({\n  color: {\n    primary: '#000',\n    secondary: '#fff',\n  },\n});\n";
625        let d = defs(src);
626        let def = d.iter().find(|d| d.binding == "vars").unwrap();
627        let primary = def
628            .tokens
629            .iter()
630            .find(|t| t.path == "color.primary")
631            .unwrap();
632        let secondary = def
633            .tokens
634            .iter()
635            .find(|t| t.path == "color.secondary")
636            .unwrap();
637        assert_eq!(primary.def_line, 4);
638        assert_eq!(secondary.def_line, 5);
639    }
640
641    #[test]
642    fn spread_and_computed_keys_are_skipped() {
643        let d = defs(
644            r"
645import { defineVars } from '@stylexjs/stylex';
646const base = { a: '1' };
647export const vars = defineVars({ ...base, ['x' + 'y']: '2', real: '#000' });
648",
649        );
650        // Only the statically-resolvable `real` leaf survives.
651        assert_eq!(paths(&d, "vars"), vec!["real"]);
652    }
653
654    #[test]
655    fn identifier_valued_key_is_not_a_leaf_but_call_and_member_values_are() {
656        // `palette: tailwindPalette` (bare identifier, an imported group) must NOT
657        // become a phantom `palette` leaf; `radius: px(2)` (call) and
658        // `red: colors.red['500']` (member access) are real scalar leaves.
659        let d = defs(
660            r"
661import { createGlobalTheme } from '@vanilla-extract/css';
662export const vars = createGlobalTheme(':root', {
663  palette: tailwindPalette,
664  radius: px(2),
665  red: colors.red['500'],
666});
667",
668        );
669        let p = paths(&d, "vars");
670        assert!(
671            !p.contains(&"palette".to_string()),
672            "identifier-valued key must not be a leaf: {p:?}"
673        );
674        assert!(
675            p.contains(&"radius".to_string()),
676            "call-valued key is a leaf: {p:?}"
677        );
678        assert!(
679            p.contains(&"red".to_string()),
680            "member-valued key is a leaf: {p:?}"
681        );
682    }
683
684    #[test]
685    fn no_css_in_js_import_returns_empty() {
686        let d = defs("export const vars = { color: { primary: '#000' } };");
687        assert!(d.is_empty());
688    }
689
690    fn leaves(paths: &[&str]) -> FxHashSet<String> {
691        paths.iter().map(|s| (*s).to_string()).collect()
692    }
693
694    fn consumers(source: &str, alias: &str, paths: &[&str]) -> Vec<TokenConsumerHit> {
695        css_in_js_token_consumers(source, Path::new("card.ts"), alias, &leaves(paths))
696    }
697
698    #[test]
699    fn consumer_matches_deepest_leaf_not_intermediate_group() {
700        // `vars.color.primary` is the leaf; `vars.color` (an intermediate group)
701        // must NOT be counted, so exactly one hit per access site.
702        let hits = consumers(
703            "const a = vars.color.primary;",
704            "vars",
705            &["color.primary", "color.secondary"],
706        );
707        assert_eq!(hits.len(), 1);
708        assert_eq!(hits[0].token_path, "color.primary");
709        assert_eq!(hits[0].line, 1);
710    }
711
712    #[test]
713    fn consumer_aliased_receiver() {
714        // The caller passes the local alias; member access on it is matched.
715        let hits = consumers("const a = v.color.primary;", "v", &["color.primary"]);
716        assert_eq!(hits.len(), 1);
717        assert_eq!(hits[0].token_path, "color.primary");
718    }
719
720    #[test]
721    fn consumer_multiple_sites_distinct_lines() {
722        let src = "const a = vars.color.primary;\nconst b = vars.space.sm;\nconst c = vars.color.primary;";
723        let hits = consumers(src, "vars", &["color.primary", "space.sm"]);
724        assert_eq!(hits.len(), 3);
725        let lines: Vec<u32> = hits.iter().map(|h| h.line).collect();
726        assert_eq!(lines, vec![1, 2, 3]);
727    }
728
729    #[test]
730    fn consumer_in_style_object_value_position() {
731        // The dominant real shape: a token read inside a style-call object value.
732        let hits = consumers(
733            "export const s = stylex.create({ root: { color: vars.color.primary } });",
734            "vars",
735            &["color.primary"],
736        );
737        assert_eq!(hits.len(), 1);
738        assert_eq!(hits[0].token_path, "color.primary");
739    }
740
741    #[test]
742    fn consumer_flat_stylex_depth_one() {
743        let hits = consumers("const a = vars.primaryColor;", "vars", &["primaryColor"]);
744        assert_eq!(hits.len(), 1);
745        assert_eq!(hits[0].token_path, "primaryColor");
746    }
747
748    #[test]
749    fn consumer_other_binding_not_matched() {
750        // A same-named member access on a DIFFERENT binding must not be a hit.
751        let hits = consumers("const a = other.color.primary;", "vars", &["color.primary"]);
752        assert!(hits.is_empty());
753    }
754
755    #[test]
756    fn consumer_deeper_access_past_leaf_matches_leaf_subexpression_once() {
757        // `vars.color.primary.toString()` reads the leaf `color.primary`; the outer
758        // `.toString` chain is not a leaf, the inner `vars.color.primary` is.
759        let hits = consumers(
760            "const a = vars.color.primary.toString();",
761            "vars",
762            &["color.primary"],
763        );
764        assert_eq!(hits.len(), 1);
765        assert_eq!(hits[0].token_path, "color.primary");
766    }
767
768    #[test]
769    fn consumer_undefined_path_not_matched() {
770        let hits = consumers("const a = vars.color.tertiary;", "vars", &["color.primary"]);
771        assert!(hits.is_empty());
772    }
773
774    #[test]
775    fn consumer_bracket_notation_hyphenated_key() {
776        // Hyphenated / digit-leading token keys are not valid JS identifiers, so
777        // they are consumed via bracket notation; the leaf path keeps the raw key.
778        let hits = consumers(
779            "const a = vars.color['gray-100'];\nconst b = vars.borderRadius['0x'];",
780            "vars",
781            &["color.gray-100", "borderRadius.0x"],
782        );
783        let paths: Vec<&str> = hits.iter().map(|h| h.token_path.as_str()).collect();
784        assert!(paths.contains(&"color.gray-100"));
785        assert!(paths.contains(&"borderRadius.0x"));
786        assert_eq!(hits.len(), 2);
787    }
788
789    #[test]
790    fn consumer_mixed_dot_and_bracket_chain() {
791        // `vars['color'].primary` and `vars.color['primary']` both reconstruct the
792        // same `color.primary` leaf.
793        let hits = consumers(
794            "const a = vars['color'].primary;\nconst b = vars.color['primary'];",
795            "vars",
796            &["color.primary"],
797        );
798        assert_eq!(hits.len(), 2);
799        assert!(hits.iter().all(|h| h.token_path == "color.primary"));
800    }
801
802    #[test]
803    fn consumer_non_literal_computed_key_not_matched() {
804        // A dynamic computed key cannot be resolved statically (lower-bound miss).
805        let hits = consumers(
806            "const k = 'primary'; const a = vars.color[k];",
807            "vars",
808            &["color.primary"],
809        );
810        assert!(hits.is_empty());
811    }
812
813    #[test]
814    fn consumer_empty_inputs_short_circuit() {
815        assert!(consumers("const a = vars.color.primary;", "", &["color.primary"]).is_empty());
816        assert!(consumers("const a = vars.color.primary;", "vars", &[]).is_empty());
817    }
818}