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