1use 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#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct CssInJsToken {
65 pub path: String,
67 pub def_line: u32,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct CssInJsTokenDef {
75 pub binding: String,
78 pub tokens: Vec<CssInJsToken>,
80}
81
82#[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#[derive(Debug, Clone, PartialEq, Eq)]
104pub struct TokenConsumerHit {
105 pub token_path: String,
108 pub line: u32,
110}
111
112#[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
146struct 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 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 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
204fn 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
226fn 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#[derive(Clone, Copy)]
237enum BindingSource {
238 LhsIdent,
240 TupleElement(usize),
242}
243
244#[derive(Clone, Copy)]
247struct Recognized {
248 binding_source: BindingSource,
249 tokens_arg: usize,
250}
251
252struct TokenDefCollector<'a> {
254 source: &'a str,
255 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 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 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 Some((lib, member.property.name.as_str()))
318 }
319 _ => None,
320 }
321 }
322
323 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 (Lib::StyleX, "defineVars") | (Lib::VanillaExtract, "createThemeContract")
337 if arg_count >= 1 =>
338 {
339 single(0)
340 }
341 (Lib::VanillaExtract, "createTheme") if arg_count == 1 => Some(Recognized {
345 binding_source: BindingSource::TupleElement(1),
346 tokens_arg: 0,
347 }),
348 (Lib::VanillaExtract, "createGlobalTheme") if arg_count == 2 => single(1),
352 _ => None,
353 }
354 }
355
356 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 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 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
454fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}