1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::sync::atomic::{AtomicU64, Ordering};
4use std::time::Instant;
5
6use ls_types::{Range, Uri};
7use oxc_allocator::Allocator;
8use oxc_ast::ast::{
9 Argument, ArrayExpression, ArrayExpressionElement, ArrowFunctionBody, ArrowFunctionExpression,
10 AssignmentExpression, AssignmentTarget, BindingPattern, BlockStatement, CatchClause,
11 Expression, ForInStatement, ForOfStatement, ForStatement, ForStatementInit, ForStatementLeft,
12 FormalParameters, ImportDeclaration, ImportDeclarationSpecifier, MemberExpression,
13 ModuleExportName, ObjectExpression, ObjectPropertyKind, Program, PropertyKey,
14 SimpleAssignmentTarget, Statement, SwitchStatement, UpdateExpression, VariableDeclaration,
15 VariableDeclarator,
16};
17use oxc_ast::AstKind;
18use oxc_ast_visit::{
19 walk::{
20 walk_assignment_expression, walk_block_statement, walk_formal_parameters,
21 walk_update_expression,
22 },
23 Visit,
24};
25use oxc_parser::Parser;
26use oxc_span::{GetSpan, SourceType, Span};
27
28use crate::manager::CssVariableManager;
29use crate::parsers::css::{parse_css_snippet, CssParseContext};
30use crate::types::{offset_to_position, CssVariable};
31
32pub(crate) const MAX_CONFIG_BYTES: usize = 1024 * 1024;
33const ASTRO_CONFIG_NAMES: &[&str] = &[
34 "astro.config.js",
35 "astro.config.mjs",
36 "astro.config.cjs",
37 "astro.config.ts",
38 "astro.config.mts",
39 "astro.config.cts",
40];
41const VITE_CONFIG_NAMES: &[&str] = &[
42 "vite.config.js",
43 "vite.config.mjs",
44 "vite.config.cjs",
45 "vite.config.ts",
46 "vite.config.mts",
47 "vite.config.cts",
48];
49const VITE_PREPROCESSOR: &str = "scss";
50const MAX_STATIC_STRING_DEPTH: usize = 16;
51const MAX_STATIC_STRING_VISITS: usize = 64;
52const MAX_STATIC_STRUCTURE_VISITS: usize = 1024;
53static CONFIG_PARSE_COUNT: AtomicU64 = AtomicU64::new(0);
54static OVERSIZED_CONFIG_SKIP_COUNT: AtomicU64 = AtomicU64::new(0);
55
56#[derive(Clone, Copy)]
57enum ConfigKind {
58 Astro,
59 Vite,
60}
61
62#[derive(Clone, Copy)]
63pub(crate) enum ConfigVariableSource {
64 AstroFont,
65 ViteScssAdditionalData,
66}
67
68impl ConfigVariableSource {
69 pub(crate) fn label(self) -> &'static str {
70 match self {
71 Self::AstroFont => "Astro font configuration",
72 Self::ViteScssAdditionalData => "Vite SCSS additionalData",
73 }
74 }
75}
76
77fn config_kind(path: &Path) -> Option<ConfigKind> {
78 let name = path.file_name()?.to_str()?;
79 if ASTRO_CONFIG_NAMES.contains(&name) {
80 Some(ConfigKind::Astro)
81 } else if VITE_CONFIG_NAMES.contains(&name) {
82 Some(ConfigKind::Vite)
83 } else {
84 None
85 }
86}
87
88fn supports_commonjs(path: &Path) -> bool {
89 matches!(
90 path.extension().and_then(|extension| extension.to_str()),
91 Some("js" | "cjs" | "ts" | "cts")
92 )
93}
94
95pub fn is_supported_config_path(path: &Path) -> bool {
97 config_kind(path).is_some()
98}
99
100pub(crate) fn config_variable_source(uri: &Uri) -> Option<ConfigVariableSource> {
101 match config_kind(Path::new(uri.path().as_str()))? {
102 ConfigKind::Astro => Some(ConfigVariableSource::AstroFont),
103 ConfigKind::Vite => Some(ConfigVariableSource::ViteScssAdditionalData),
104 }
105}
106pub async fn parse_config_document(
108 text: &str,
109 uri: &Uri,
110 manager: &CssVariableManager,
111) -> Result<(), String> {
112 parse_config_document_inner(text, uri, manager, None).await
113}
114
115pub(crate) async fn parse_config_document_at_version(
118 text: &str,
119 uri: &Uri,
120 manager: &CssVariableManager,
121 version: i64,
122) -> Result<(), String> {
123 parse_config_document_inner(text, uri, manager, Some(version)).await
124}
125
126async fn parse_config_document_inner(
127 text: &str,
128 uri: &Uri,
129 manager: &CssVariableManager,
130 version: Option<i64>,
131) -> Result<(), String> {
132 let path = Path::new(uri.path().as_str());
133 if config_kind(path).is_none() {
134 return Ok(());
135 }
136
137 if text.len() > MAX_CONFIG_BYTES {
138 let skip_count = OVERSIZED_CONFIG_SKIP_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
139 tracing::debug!(
140 uri = ?uri,
141 bytes = text.len(),
142 limit = MAX_CONFIG_BYTES,
143 skip_count,
144 "configuration analysis retained the last valid state for an oversized source"
145 );
146 return Ok(());
147 }
148
149 let Some(extracted) = extract_config_variables(text, path, uri)? else {
150 return Ok(());
151 };
152 let mut variables: Vec<_> = extracted
153 .variables
154 .into_iter()
155 .map(|extracted| CssVariable {
156 name: extracted.name,
157 value: String::new(),
158 uri: uri.clone(),
159 range: span_to_range(text, extracted.declaration_span),
160 name_range: Some(span_to_range(text, extracted.name_span)),
161 value_range: None,
162 selector: ":root".to_string(),
163 important: false,
164 inline: false,
165 source_position: extracted.declaration_span.start as usize,
166 })
167 .collect();
168
169 let mut usages = Vec::new();
170 if !extracted.css_snippets.is_empty() {
171 let snippet_manager = CssVariableManager::new(manager.get_config().await);
172 for snippet in extracted.css_snippets {
173 parse_css_snippet(CssParseContext {
174 css_text: &snippet.text,
175 full_text: text,
176 uri,
177 manager: &snippet_manager,
178 base_offset: snippet.content_span.start as usize,
179 inline: false,
180 usage_context_override: None,
181 dom_node: None,
182 })
183 .await?;
184 }
185 variables.extend(snippet_manager.get_document_variables(uri).await);
186 usages.extend(snippet_manager.get_document_usages(uri).await);
187 }
188
189 if let Some(version) = version {
190 manager
191 .replace_document_analysis_at_version(uri, version, variables, usages)
192 .await?;
193 } else {
194 manager
195 .replace_document_analysis(uri, variables, usages)
196 .await?;
197 }
198 Ok(())
199}
200
201fn extract_config_variables(
202 text: &str,
203 path: &Path,
204 uri: &Uri,
205) -> Result<Option<ConfigExtraction>, String> {
206 let started = Instant::now();
207 let parse_count = CONFIG_PARSE_COUNT.fetch_add(1, Ordering::Relaxed) + 1;
208 let kind = config_kind(path)
209 .ok_or_else(|| format!("Unsupported configuration source: {}", path.display()))?;
210 let source_type = SourceType::from_path(path)
211 .map_err(|_| format!("Unsupported configuration source type: {}", path.display()))?;
212 let allocator = Allocator::default();
213 let parsed = Parser::new(&allocator, text, source_type).parse();
214
215 let diagnostic_count = parsed.diagnostics.len();
216 if diagnostic_count > 0 && (parsed.panicked || parsed.program.body.is_empty()) {
217 tracing::debug!(
218 uri = ?uri,
219 errors = diagnostic_count,
220 parse_count,
221 elapsed_micros = started.elapsed().as_micros(),
222 "configuration analysis retained the last valid state after a catastrophic parse"
223 );
224 return Ok(None);
225 }
226 if diagnostic_count > 0 {
227 tracing::debug!(
228 uri = ?uri,
229 errors = diagnostic_count,
230 parse_count,
231 "configuration analysis is using Oxc's recoverable AST"
232 );
233 }
234
235 let module_source = match kind {
236 ConfigKind::Astro => "astro/config",
237 ConfigKind::Vite => "vite",
238 };
239 let mut imports = DefineConfigImportCollector::new(module_source);
240 imports.visit_program(&parsed.program);
241 if supports_commonjs(path) {
242 imports.collect_commonjs_program(&parsed.program);
243 }
244 imports.reject_mutated_helpers(&parsed.program);
245 let static_resolver = collect_static_resolver(&parsed.program, text);
246 let expression_resolver = StaticExpressionResolver::new(&static_resolver);
247
248 let has_esm_default = parsed
249 .program
250 .body
251 .iter()
252 .any(|statement| matches!(statement, Statement::ExportDefaultDeclaration(_)));
253 let DefineConfigImportCollector {
254 define_config_bindings,
255 define_config_namespaces,
256 ..
257 } = imports;
258 let extracted = match kind {
259 ConfigKind::Astro => {
260 let mut extractor = AstroFontExtractor {
261 source: text,
262 define_config_bindings,
263 define_config_namespaces,
264 expression_resolver,
265 variables: Vec::new(),
266 };
267 extractor.extract_esm_program(&parsed.program);
268 if supports_commonjs(path) && !has_esm_default {
269 extractor.extract_commonjs_program(&parsed.program);
270 }
271 ConfigExtraction {
272 variables: extractor.variables,
273 css_snippets: Vec::new(),
274 }
275 }
276 ConfigKind::Vite => {
277 let mut extractor = ViteAdditionalDataExtractor {
278 source: text,
279 define_config_bindings,
280 define_config_namespaces,
281 expression_resolver,
282 css_snippets: Vec::new(),
283 };
284 extractor.extract_esm_program(&parsed.program);
285 if supports_commonjs(path) && !has_esm_default {
286 extractor.extract_commonjs_program(&parsed.program);
287 }
288 ConfigExtraction {
289 variables: Vec::new(),
290 css_snippets: extractor.css_snippets,
291 }
292 }
293 };
294 tracing::debug!(
295 uri = ?uri,
296 parse_count,
297 diagnostic_count,
298 variables = extracted.variables.len(),
299 css_snippets = extracted.css_snippets.len(),
300 elapsed_micros = started.elapsed().as_micros(),
301 "configuration analysis completed"
302 );
303 Ok(Some(extracted))
304}
305
306#[derive(Default)]
307struct ConfigExtraction {
308 variables: Vec<ExtractedVariable>,
309 css_snippets: Vec<ExtractedCssSnippet>,
310}
311
312struct ExtractedVariable {
313 name: String,
314 declaration_span: Span,
315 name_span: Span,
316}
317
318struct ExtractedCssSnippet {
319 text: String,
320 content_span: Span,
321}
322
323#[derive(Clone)]
324struct ResolvedStaticString {
325 content_span: Span,
326 available_after: u32,
327}
328
329#[derive(Clone)]
330struct StaticScope {
331 span: Span,
332 names: HashSet<String>,
333}
334
335struct StaticExpressionResolver<'a> {
336 strings: &'a HashMap<String, ResolvedStaticString>,
337 bindings: &'a HashMap<String, &'a Expression<'a>>,
338 assigned_names: &'a HashSet<String>,
339 scopes: &'a [StaticScope],
340}
341
342enum ResolvedProperty<'a> {
343 Known {
344 value: &'a Expression<'a>,
345 declaration_span: Span,
346 },
347 Unknown,
348}
349
350impl<'a> StaticExpressionResolver<'a> {
351 fn new(static_strings: &'a StaticStringResolver<'a, '_>) -> Self {
352 Self {
353 strings: &static_strings.resolved_strings,
354 bindings: &static_strings.bindings,
355 assigned_names: &static_strings.assigned_names,
356 scopes: &static_strings.scopes,
357 }
358 }
359
360 fn is_shadowed(&self, name: &str, span: Span) -> bool {
361 self.scopes.iter().any(|scope| {
362 scope.names.contains(name)
363 && scope.span.start <= span.start
364 && span.end <= scope.span.end
365 })
366 }
367
368 fn resolve_expression(
369 &self,
370 expression: &'a Expression<'a>,
371 depth: usize,
372 visits: &mut usize,
373 resolving: &mut HashSet<String>,
374 ) -> Option<&'a Expression<'a>> {
375 if depth >= MAX_STATIC_STRING_DEPTH || *visits >= MAX_STATIC_STRING_VISITS {
376 return None;
377 }
378 *visits += 1;
379 let expression = unwrap_expression(expression);
380 let Expression::Identifier(identifier) = expression else {
381 return Some(expression);
382 };
383 let name = identifier.name.as_str();
384 if self.is_shadowed(name, identifier.span)
385 || self.assigned_names.contains(name)
386 || !resolving.insert(name.to_string())
387 {
388 return None;
389 }
390 let init = self.bindings.get(name)?;
391 if init.span().end > identifier.span.end {
392 resolving.remove(name);
393 return None;
394 }
395 let result = self.resolve_expression(init, depth + 1, visits, resolving);
396 resolving.remove(name);
397 result
398 }
399
400 fn as_object(&self, expression: &'a Expression<'a>) -> Option<&'a ObjectExpression<'a>> {
401 let mut visits = 0;
402 let mut resolving = HashSet::new();
403 self.resolve_expression(expression, 0, &mut visits, &mut resolving)
404 .and_then(as_object_expression)
405 }
406
407 fn as_array(&self, expression: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
408 let mut visits = 0;
409 let mut resolving = HashSet::new();
410 match self.resolve_expression(expression, 0, &mut visits, &mut resolving)? {
411 Expression::ArrayExpression(array) => Some(array.as_ref()),
412 _ => None,
413 }
414 }
415
416 fn property(
417 &self,
418 object: &'a ObjectExpression<'a>,
419 name: &str,
420 ) -> Option<ResolvedProperty<'a>> {
421 let mut visits = 0;
422 self.property_inner(object, name, 0, &mut visits)
423 }
424
425 fn property_inner(
426 &self,
427 object: &'a ObjectExpression<'a>,
428 name: &str,
429 depth: usize,
430 visits: &mut usize,
431 ) -> Option<ResolvedProperty<'a>> {
432 if depth >= MAX_STATIC_STRING_DEPTH || *visits >= MAX_STATIC_STRUCTURE_VISITS {
433 return Some(ResolvedProperty::Unknown);
434 }
435 *visits += 1;
436
437 let mut unknown_after_match = false;
438 for property in object.properties.iter().rev() {
439 match property {
440 ObjectPropertyKind::ObjectProperty(property) => {
441 let matches = if property.computed {
442 property.key.is_specific_string_literal(name)
443 } else {
444 property_key_matches(&property.key, name)
445 };
446 if matches {
447 return (!unknown_after_match).then_some(ResolvedProperty::Known {
448 value: &property.value,
449 declaration_span: property.span,
450 });
451 }
452 if property.computed && !matches!(property.key, PropertyKey::StringLiteral(_)) {
453 unknown_after_match = true;
454 }
455 }
456 ObjectPropertyKind::SpreadProperty(spread) => {
457 let Some(spread_object) = self.as_object(&spread.argument) else {
458 unknown_after_match = true;
459 continue;
460 };
461 match self.property_inner(spread_object, name, depth + 1, visits) {
462 Some(known @ ResolvedProperty::Known { .. }) if !unknown_after_match => {
463 return Some(known);
464 }
465 Some(_) => return Some(ResolvedProperty::Unknown),
466 None => {}
467 }
468 }
469 }
470 }
471 if unknown_after_match {
472 Some(ResolvedProperty::Unknown)
473 } else {
474 None
475 }
476 }
477
478 fn property_value(
479 &self,
480 object: &'a ObjectExpression<'a>,
481 name: &str,
482 ) -> Option<&'a Expression<'a>> {
483 match self.property(object, name)? {
484 ResolvedProperty::Known { value, .. } => Some(value),
485 ResolvedProperty::Unknown => None,
486 }
487 }
488}
489
490#[derive(Default)]
491struct AssignmentTargetNameCollector {
492 names: HashSet<String>,
493}
494
495impl<'a> Visit<'a> for AssignmentTargetNameCollector {
496 fn visit_identifier_reference(&mut self, identifier: &oxc_ast::ast::IdentifierReference<'a>) {
497 self.names.insert(identifier.name.as_str().to_string());
498 }
499
500 fn visit_member_expression(&mut self, _expression: &MemberExpression<'a>) {}
501
502 fn visit_assignment_target_with_default(
503 &mut self,
504 target: &oxc_ast::ast::AssignmentTargetWithDefault<'a>,
505 ) {
506 self.visit_assignment_target(&target.binding);
507 }
508
509 fn visit_assignment_target_property_identifier(
510 &mut self,
511 property: &oxc_ast::ast::AssignmentTargetPropertyIdentifier<'a>,
512 ) {
513 self.visit_identifier_reference(&property.binding);
514 }
515
516 fn visit_assignment_target_property_property(
517 &mut self,
518 property: &oxc_ast::ast::AssignmentTargetPropertyProperty<'a>,
519 ) {
520 self.visit_assignment_target_maybe_default(&property.binding);
521 }
522}
523
524#[derive(Default)]
525struct BindingNameCollector {
526 names: HashSet<String>,
527}
528
529impl<'a> Visit<'a> for BindingNameCollector {
530 fn visit_binding_identifier(&mut self, identifier: &oxc_ast::ast::BindingIdentifier<'a>) {
531 self.names.insert(identifier.name.as_str().to_string());
532 }
533
534 fn visit_expression(&mut self, _expression: &Expression<'a>) {}
535}
536#[derive(Default)]
537struct FunctionScopedVarCollector {
538 nested_function_depth: usize,
539 names: HashSet<String>,
540}
541
542impl<'a> Visit<'a> for FunctionScopedVarCollector {
543 fn enter_node(&mut self, kind: AstKind<'a>) {
544 if matches!(
545 kind,
546 AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)
547 ) {
548 self.nested_function_depth += 1;
549 }
550 }
551
552 fn leave_node(&mut self, kind: AstKind<'a>) {
553 if matches!(
554 kind,
555 AstKind::Function(_) | AstKind::ArrowFunctionExpression(_)
556 ) {
557 self.nested_function_depth = self.nested_function_depth.saturating_sub(1);
558 }
559 }
560
561 fn visit_variable_declaration(&mut self, declaration: &VariableDeclaration<'a>) {
562 if self.nested_function_depth == 0 && declaration.kind.is_var() {
563 self.names
564 .extend(AssignedBindingCollector::declaration_names(declaration));
565 }
566 }
567}
568
569struct AssignedBindingCollector {
570 tracked_names: HashSet<String>,
571 names: HashSet<String>,
572 shadowed_scopes: Vec<HashSet<String>>,
573 pending_function_scopes: Vec<HashSet<String>>,
574 tracked_member_property: Option<&'static str>,
575}
576
577impl AssignedBindingCollector {
578 fn new(tracked_names: HashSet<String>) -> Self {
579 Self {
580 tracked_names,
581 names: HashSet::new(),
582 shadowed_scopes: Vec::new(),
583 pending_function_scopes: Vec::new(),
584 tracked_member_property: None,
585 }
586 }
587
588 fn with_member_property(mut self, property: &'static str) -> Self {
589 self.tracked_member_property = Some(property);
590 self
591 }
592
593 fn record_names(&mut self, names: HashSet<String>) {
594 for name in names {
595 let shadowed = self
596 .shadowed_scopes
597 .iter()
598 .rev()
599 .any(|scope| scope.contains(&name));
600 if self.tracked_names.contains(&name) && !shadowed {
601 self.names.insert(name);
602 }
603 }
604 }
605
606 fn record_assignment_target<'a>(&mut self, target: &AssignmentTarget<'a>) {
607 let mut collector = AssignmentTargetNameCollector::default();
608 collector.visit_assignment_target(target);
609 self.record_names(collector.names);
610
611 let Some(member) = target.as_member_expression() else {
612 return;
613 };
614 self.record_member_assignment(member);
615 }
616
617 fn record_simple_assignment_target<'a>(&mut self, target: &SimpleAssignmentTarget<'a>) {
618 let mut collector = AssignmentTargetNameCollector::default();
619 collector.visit_simple_assignment_target(target);
620 self.record_names(collector.names);
621 if let Some(member) = target.as_member_expression() {
622 self.record_member_assignment(member);
623 }
624 }
625
626 fn record_member_root(&mut self, expression: &Expression<'_>) {
627 let mut object = expression;
628 loop {
629 match unwrap_expression(object) {
630 Expression::Identifier(identifier) => {
631 self.record_names(HashSet::from([identifier.name.as_str().to_string()]));
632 return;
633 }
634 expression => {
635 let Some(parent) = expression.as_member_expression() else {
636 return;
637 };
638 object = parent.object();
639 }
640 }
641 }
642 }
643
644 fn record_member_assignment(&mut self, member: &MemberExpression<'_>) {
645 if self
646 .tracked_member_property
647 .is_some_and(|property| member.static_property_name() != Some(property))
648 {
649 return;
650 }
651 self.record_member_root(member.object());
652 }
653
654 fn record_mutating_call(&mut self, call: &oxc_ast::ast::CallExpression<'_>) {
655 const MUTATORS: &[&str] = &[
656 "pop",
657 "push",
658 "splice",
659 "shift",
660 "unshift",
661 "sort",
662 "reverse",
663 "fill",
664 "copyWithin",
665 ];
666 let callee = unwrap_expression(&call.callee);
667 if let Some(member) = callee.as_member_expression() {
668 if member
669 .static_property_name()
670 .is_some_and(|name| MUTATORS.contains(&name))
671 {
672 self.record_member_root(member.object());
673 return;
674 }
675
676 if member.static_property_name() == Some("assign")
677 && matches!(
678 unwrap_expression(member.object()),
679 Expression::Identifier(identifier) if identifier.name.as_str() == "Object"
680 )
681 {
682 if let Some(argument) = call.arguments.first().and_then(Argument::as_expression) {
683 self.record_member_root(argument);
684 }
685 }
686 }
687 }
688
689 fn record_for_statement_left<'a>(&mut self, left: &ForStatementLeft<'a>) {
690 let mut collector = AssignmentTargetNameCollector::default();
691 collector.visit_for_statement_left(left);
692 self.record_names(collector.names);
693 }
694
695 fn declaration_names(declaration: &VariableDeclaration<'_>) -> HashSet<String> {
696 let mut collector = BindingNameCollector::default();
697 for declarator in &declaration.declarations {
698 collector.visit_binding_pattern(&declarator.id);
699 }
700 collector.names
701 }
702
703 fn lexical_declaration_names(declaration: &VariableDeclaration<'_>) -> HashSet<String> {
704 if declaration.kind.is_var() {
705 HashSet::new()
706 } else {
707 Self::declaration_names(declaration)
708 }
709 }
710
711 fn block_shadow_names(block: &BlockStatement<'_>) -> HashSet<String> {
712 Self::statement_shadow_names(&block.body, true)
713 }
714
715 fn function_body_shadow_names(body: &oxc_ast::ast::FunctionBody<'_>) -> HashSet<String> {
716 let mut names = Self::statement_shadow_names(&body.statements, false);
717 let mut vars = FunctionScopedVarCollector::default();
718 vars.visit_function_body(body);
719 names.extend(vars.names);
720 names
721 }
722 fn statement_shadow_names(statements: &[Statement<'_>], lexical_only: bool) -> HashSet<String> {
723 let mut names = HashSet::new();
724 for statement in statements {
725 match statement {
726 Statement::VariableDeclaration(declaration) => {
727 if lexical_only {
728 names.extend(Self::lexical_declaration_names(declaration));
729 } else {
730 names.extend(Self::declaration_names(declaration));
731 }
732 }
733 Statement::FunctionDeclaration(function) => {
734 if let Some(identifier) = &function.id {
735 names.insert(identifier.name.as_str().to_string());
736 }
737 }
738 Statement::ClassDeclaration(class) => {
739 if let Some(identifier) = &class.id {
740 names.insert(identifier.name.as_str().to_string());
741 }
742 }
743 _ => {}
744 }
745 }
746 names
747 }
748
749 fn switch_shadow_names(statement: &SwitchStatement<'_>) -> HashSet<String> {
750 let mut names = HashSet::new();
751 for case in &statement.cases {
752 for statement in &case.consequent {
753 match statement {
754 Statement::VariableDeclaration(declaration) => {
755 names.extend(Self::lexical_declaration_names(declaration));
756 }
757 Statement::FunctionDeclaration(function) => {
758 if let Some(identifier) = &function.id {
759 names.insert(identifier.name.as_str().to_string());
760 }
761 }
762 Statement::ClassDeclaration(class) => {
763 if let Some(identifier) = &class.id {
764 names.insert(identifier.name.as_str().to_string());
765 }
766 }
767 _ => {}
768 }
769 }
770 }
771 names
772 }
773
774 fn with_shadowed_scope(&mut self, names: HashSet<String>, visit: impl FnOnce(&mut Self)) {
775 self.shadowed_scopes.push(names);
776 visit(self);
777 self.shadowed_scopes.pop();
778 }
779}
780
781impl<'a> Visit<'a> for AssignedBindingCollector {
782 fn enter_node(&mut self, kind: AstKind<'a>) {
783 if let AstKind::Function(function) = kind {
784 let mut names = HashSet::new();
785 if let Some(identifier) = &function.id {
786 names.insert(identifier.name.as_str().to_string());
787 }
788 self.pending_function_scopes.push(names);
789 }
790 }
791
792 fn visit_assignment_expression(&mut self, expression: &AssignmentExpression<'a>) {
793 self.record_assignment_target(&expression.left);
794 walk_assignment_expression(self, expression);
795 }
796
797 fn visit_call_expression(&mut self, expression: &oxc_ast::ast::CallExpression<'a>) {
798 self.record_mutating_call(expression);
799 oxc_ast_visit::walk::walk_call_expression(self, expression);
800 }
801
802 fn visit_update_expression(&mut self, expression: &UpdateExpression<'a>) {
803 self.record_simple_assignment_target(&expression.argument);
804 walk_update_expression(self, expression);
805 }
806
807 fn visit_formal_parameters(&mut self, parameters: &FormalParameters<'a>) {
808 let mut names = self.pending_function_scopes.pop().unwrap_or_default();
809 let mut parameter_names = BindingNameCollector::default();
810 parameter_names.visit_formal_parameters(parameters);
811 names.extend(parameter_names.names);
812 self.pending_function_scopes.push(names.clone());
813 self.with_shadowed_scope(names, |collector| {
814 walk_formal_parameters(collector, parameters);
815 });
816 }
817
818 fn visit_function_body(&mut self, body: &oxc_ast::ast::FunctionBody<'a>) {
819 let mut names = self.pending_function_scopes.pop().unwrap_or_default();
820 names.extend(Self::function_body_shadow_names(body));
821 self.with_shadowed_scope(names, |collector| {
822 oxc_ast_visit::walk::walk_function_body(collector, body);
823 });
824 }
825
826 fn visit_arrow_function_expression(&mut self, expression: &ArrowFunctionExpression<'a>) {
827 let mut names = BindingNameCollector::default();
828 names.visit_formal_parameters(&expression.params);
829 let has_block_body = matches!(&expression.body, ArrowFunctionBody::FunctionBody(_));
830 self.pending_function_scopes.push(names.names.clone());
831 self.with_shadowed_scope(names.names, |collector| {
832 walk_formal_parameters(collector, &expression.params);
833 collector.visit_arrow_function_body(&expression.body);
834 });
835 if !has_block_body {
836 self.pending_function_scopes.pop();
837 }
838 }
839
840 fn visit_block_statement(&mut self, block: &BlockStatement<'a>) {
841 let names = Self::block_shadow_names(block);
842 self.with_shadowed_scope(names, |collector| walk_block_statement(collector, block));
843 }
844
845 fn visit_catch_clause(&mut self, clause: &CatchClause<'a>) {
846 let mut names = BindingNameCollector::default();
847 if let Some(parameter) = &clause.param {
848 names.visit_binding_pattern(¶meter.pattern);
849 }
850 self.with_shadowed_scope(names.names, |collector| {
851 collector.visit_block_statement(&clause.body);
852 });
853 }
854
855 fn visit_switch_statement(&mut self, statement: &SwitchStatement<'a>) {
856 self.visit_expression(&statement.discriminant);
857 let names = Self::switch_shadow_names(statement);
858 self.with_shadowed_scope(names, |collector| {
859 collector.visit_switch_cases(&statement.cases);
860 });
861 }
862
863 fn visit_for_statement(&mut self, statement: &ForStatement<'a>) {
864 let shadowed = statement
865 .init
866 .as_ref()
867 .and_then(|init| match init {
868 ForStatementInit::VariableDeclaration(declaration) => {
869 Some(Self::lexical_declaration_names(declaration))
870 }
871 _ => None,
872 })
873 .unwrap_or_default();
874 self.with_shadowed_scope(shadowed, |collector| {
875 if let Some(init) = &statement.init {
876 collector.visit_for_statement_init(init);
877 }
878 if let Some(test) = &statement.test {
879 collector.visit_expression(test);
880 }
881 if let Some(update) = &statement.update {
882 collector.visit_expression(update);
883 }
884 collector.visit_statement(&statement.body);
885 });
886 }
887
888 fn visit_for_in_statement(&mut self, statement: &ForInStatement<'a>) {
889 let shadowed = match &statement.left {
890 ForStatementLeft::VariableDeclaration(declaration) => {
891 Self::lexical_declaration_names(declaration)
892 }
893 _ => HashSet::new(),
894 };
895 self.with_shadowed_scope(shadowed, |collector| {
896 if matches!(&statement.left, ForStatementLeft::VariableDeclaration(_)) {
897 collector.visit_for_statement_left(&statement.left);
898 } else {
899 collector.record_for_statement_left(&statement.left);
900 }
901 collector.visit_expression(&statement.right);
902 collector.visit_statement(&statement.body);
903 });
904 }
905
906 fn visit_for_of_statement(&mut self, statement: &ForOfStatement<'a>) {
907 let shadowed = match &statement.left {
908 ForStatementLeft::VariableDeclaration(declaration) => {
909 Self::lexical_declaration_names(declaration)
910 }
911 _ => HashSet::new(),
912 };
913 self.with_shadowed_scope(shadowed, |collector| {
914 if matches!(&statement.left, ForStatementLeft::VariableDeclaration(_)) {
915 collector.visit_for_statement_left(&statement.left);
916 } else {
917 collector.record_for_statement_left(&statement.left);
918 }
919 collector.visit_expression(&statement.right);
920 collector.visit_statement(&statement.body);
921 });
922 }
923}
924#[derive(Default)]
925struct StaticScopeCollector {
926 scopes: Vec<StaticScope>,
927}
928
929impl StaticScopeCollector {
930 fn function_scope_names(function: &oxc_ast::ast::Function<'_>) -> HashSet<String> {
931 let mut names = HashSet::new();
932 if let Some(identifier) = &function.id {
933 names.insert(identifier.name.as_str().to_string());
934 }
935 let mut parameters = BindingNameCollector::default();
936 parameters.visit_formal_parameters(&function.params);
937 names.extend(parameters.names);
938 if let Some(body) = function.body.as_deref() {
939 names.extend(AssignedBindingCollector::function_body_shadow_names(body));
940 }
941 names
942 }
943
944 fn arrow_scope_names(function: &ArrowFunctionExpression<'_>) -> HashSet<String> {
945 let mut names = BindingNameCollector::default();
946 names.visit_formal_parameters(&function.params);
947 let mut result = names.names;
948 if let ArrowFunctionBody::FunctionBody(body) = &function.body {
949 result.extend(AssignedBindingCollector::function_body_shadow_names(body));
950 }
951 result
952 }
953
954 fn push_scope(&mut self, span: Span, names: HashSet<String>) {
955 self.scopes.push(StaticScope { span, names });
956 }
957}
958
959impl<'a> Visit<'a> for StaticScopeCollector {
960 fn enter_node(&mut self, kind: AstKind<'a>) {
961 match kind {
962 AstKind::Function(function) => {
963 self.push_scope(function.span, Self::function_scope_names(function));
964 }
965 AstKind::ArrowFunctionExpression(function) => {
966 self.push_scope(function.span, Self::arrow_scope_names(function));
967 }
968 AstKind::BlockStatement(block) => {
969 self.push_scope(
970 block.span,
971 AssignedBindingCollector::block_shadow_names(block),
972 );
973 }
974 AstKind::CatchClause(clause) => {
975 let mut names = BindingNameCollector::default();
976 if let Some(parameter) = &clause.param {
977 names.visit_binding_pattern(¶meter.pattern);
978 }
979 self.push_scope(clause.span, names.names);
980 }
981 AstKind::SwitchStatement(statement) => {
982 self.push_scope(
983 statement.span,
984 AssignedBindingCollector::switch_shadow_names(statement),
985 );
986 }
987 AstKind::ForStatement(statement) => {
988 let names = statement
989 .init
990 .as_ref()
991 .and_then(|init| match init {
992 ForStatementInit::VariableDeclaration(declaration) => Some(
993 AssignedBindingCollector::lexical_declaration_names(declaration),
994 ),
995 _ => None,
996 })
997 .unwrap_or_default();
998 self.push_scope(statement.span, names);
999 }
1000 AstKind::ForInStatement(statement) => {
1001 let names = match &statement.left {
1002 ForStatementLeft::VariableDeclaration(declaration) => {
1003 AssignedBindingCollector::lexical_declaration_names(declaration)
1004 }
1005 _ => HashSet::new(),
1006 };
1007 self.push_scope(statement.span, names);
1008 }
1009 AstKind::ForOfStatement(statement) => {
1010 let names = match &statement.left {
1011 ForStatementLeft::VariableDeclaration(declaration) => {
1012 AssignedBindingCollector::lexical_declaration_names(declaration)
1013 }
1014 _ => HashSet::new(),
1015 };
1016 self.push_scope(statement.span, names);
1017 }
1018 _ => {}
1019 }
1020 }
1021
1022 fn leave_node(&mut self, kind: AstKind<'a>) {
1023 if matches!(
1024 kind,
1025 AstKind::Function(_)
1026 | AstKind::ArrowFunctionExpression(_)
1027 | AstKind::BlockStatement(_)
1028 | AstKind::CatchClause(_)
1029 | AstKind::SwitchStatement(_)
1030 | AstKind::ForStatement(_)
1031 | AstKind::ForInStatement(_)
1032 | AstKind::ForOfStatement(_)
1033 ) {
1034 self.scopes.pop();
1035 }
1036 }
1037}
1038
1039struct StaticStringResolver<'a, 's> {
1040 source: &'s str,
1041 bindings: HashMap<String, &'a Expression<'a>>,
1042 resolved_strings: HashMap<String, ResolvedStaticString>,
1043 assigned_names: HashSet<String>,
1044 scopes: Vec<StaticScope>,
1045}
1046
1047fn collect_exported_aliases(program: &Program<'_>, source: &str) -> HashSet<String> {
1048 let mut names = HashSet::new();
1049 for statement in &program.body {
1050 match statement {
1051 Statement::ExportNamedDeclaration(declaration)
1052 if !declaration.export_kind.is_type() =>
1053 {
1054 for specifier in &declaration.specifiers {
1055 match &specifier.local {
1056 ModuleExportName::IdentifierReference(identifier) => {
1057 names.insert(identifier.name.as_str().to_string());
1058 }
1059 ModuleExportName::IdentifierName(identifier) => {
1060 names.insert(identifier.name.as_str().to_string());
1061 }
1062 ModuleExportName::StringLiteral(_) => {}
1063 }
1064 }
1065 }
1066 Statement::ExpressionStatement(statement) => {
1067 let Expression::AssignmentExpression(assignment) =
1068 unwrap_expression(&statement.expression)
1069 else {
1070 continue;
1071 };
1072 if !assignment.operator.is_assign() {
1073 continue;
1074 }
1075 let Some(target) = source.get(
1076 assignment.left.span().start as usize..assignment.left.span().end as usize,
1077 ) else {
1078 continue;
1079 };
1080 let target = target.trim();
1081 if !(target.starts_with("exports.")
1082 || target.starts_with("exports[")
1083 || target.starts_with("module.exports.")
1084 || target.starts_with("module.exports["))
1085 {
1086 continue;
1087 }
1088 if let Expression::Identifier(identifier) = unwrap_expression(&assignment.right) {
1089 names.insert(identifier.name.as_str().to_string());
1090 }
1091 }
1092 _ => {}
1093 }
1094 }
1095 names
1096}
1097
1098impl<'a, 's> StaticStringResolver<'a, 's> {
1099 fn from_program(program: &'a Program<'a>, source: &'s str) -> Self {
1100 let mut bindings = HashMap::new();
1101 let mut duplicate_names = HashSet::new();
1102
1103 for statement in &program.body {
1104 let Statement::VariableDeclaration(declaration) = statement else {
1105 continue;
1106 };
1107 if !declaration.kind.is_const() || declaration.declare {
1108 continue;
1109 }
1110
1111 for declarator in &declaration.declarations {
1112 let BindingPattern::BindingIdentifier(identifier) = &declarator.id else {
1113 continue;
1114 };
1115 let Some(init) = declarator.init.as_ref() else {
1116 continue;
1117 };
1118 let name = identifier.name.as_str().to_string();
1119 if bindings.insert(name.clone(), init).is_some() {
1120 duplicate_names.insert(name);
1121 }
1122 }
1123 }
1124
1125 for name in duplicate_names {
1126 bindings.remove(&name);
1127 }
1128
1129 let mut assignments = AssignedBindingCollector::new(bindings.keys().cloned().collect());
1130 assignments.visit_program(program);
1131 assignments
1132 .names
1133 .extend(collect_exported_aliases(program, source));
1134
1135 let mut scopes = StaticScopeCollector::default();
1136 scopes.visit_program(program);
1137
1138 Self {
1139 source,
1140 bindings,
1141 resolved_strings: HashMap::new(),
1142 assigned_names: assignments.names,
1143 scopes: scopes.scopes,
1144 }
1145 }
1146
1147 fn resolve_all(&mut self) {
1148 self.resolved_strings = self
1149 .bindings
1150 .iter()
1151 .filter_map(|(name, init)| {
1152 if self.assigned_names.contains(name) {
1153 return None;
1154 }
1155
1156 let mut resolving = HashSet::from([name.clone()]);
1157 let mut visits = 0;
1158 let content_span = self.resolve_expression(init, 0, &mut visits, &mut resolving)?;
1159 Some((
1160 name.clone(),
1161 ResolvedStaticString {
1162 content_span,
1163 available_after: init.span().end,
1164 },
1165 ))
1166 })
1167 .collect();
1168 }
1169
1170 fn resolve_expression(
1171 &self,
1172 expression: &'a Expression<'a>,
1173 depth: usize,
1174 visits: &mut usize,
1175 resolving: &mut HashSet<String>,
1176 ) -> Option<Span> {
1177 if depth >= MAX_STATIC_STRING_DEPTH || *visits >= MAX_STATIC_STRING_VISITS {
1178 return None;
1179 }
1180 *visits += 1;
1181
1182 let expression = unwrap_expression(expression);
1183 if let Some(span) = literal_static_string_span(expression, self.source) {
1184 return Some(span);
1185 }
1186
1187 let Expression::Identifier(identifier) = expression else {
1188 return None;
1189 };
1190 let name = identifier.name.as_str();
1191 if self.scopes.iter().any(|scope| {
1192 scope.names.contains(name)
1193 && scope.span.start <= identifier.span.start
1194 && identifier.span.end <= scope.span.end
1195 }) || self.assigned_names.contains(name)
1196 || !resolving.insert(name.to_string())
1197 {
1198 return None;
1199 }
1200
1201 let init = self.bindings.get(name)?;
1202 if init.span().end > identifier.span.end {
1203 resolving.remove(name);
1204 return None;
1205 }
1206
1207 let result = self.resolve_expression(init, depth + 1, visits, resolving);
1208 resolving.remove(name);
1209 result
1210 }
1211}
1212
1213fn collect_static_resolver<'a, 's>(
1214 program: &'a Program<'a>,
1215 source: &'s str,
1216) -> StaticStringResolver<'a, 's> {
1217 let mut resolver = StaticStringResolver::from_program(program, source);
1218 resolver.resolve_all();
1219 resolver
1220}
1221struct DefineConfigImportCollector<'s> {
1222 module_source: &'s str,
1223 define_config_bindings: HashSet<String>,
1224 define_config_namespaces: HashSet<String>,
1225}
1226
1227impl<'s> DefineConfigImportCollector<'s> {
1228 fn new(module_source: &'s str) -> Self {
1229 Self {
1230 module_source,
1231 define_config_bindings: HashSet::new(),
1232 define_config_namespaces: HashSet::new(),
1233 }
1234 }
1235
1236 fn collect_commonjs_program(&mut self, program: &Program<'_>) {
1237 for statement in &program.body {
1238 let Statement::VariableDeclaration(declaration) = statement else {
1239 continue;
1240 };
1241 for declarator in &declaration.declarations {
1242 self.collect_commonjs_declarator(declarator);
1243 }
1244 }
1245 }
1246
1247 fn reject_mutated_helpers(&mut self, program: &Program<'_>) {
1248 let tracked_names = self
1249 .define_config_bindings
1250 .iter()
1251 .chain(&self.define_config_namespaces)
1252 .cloned()
1253 .collect();
1254 let mut assignments =
1255 AssignedBindingCollector::new(tracked_names).with_member_property("defineConfig");
1256 assignments.visit_program(program);
1257 self.define_config_bindings
1258 .retain(|name| !assignments.names.contains(name));
1259 self.define_config_namespaces
1260 .retain(|name| !assignments.names.contains(name));
1261 }
1262
1263 fn collect_commonjs_declarator(&mut self, declarator: &VariableDeclarator<'_>) {
1264 let Some(init) = declarator.init.as_ref().map(unwrap_expression) else {
1265 return;
1266 };
1267
1268 if let Some(member) = init.as_member_expression() {
1269 if member.static_property_name() == Some("defineConfig")
1270 && is_require_expression(member.object(), self.module_source)
1271 {
1272 if let BindingPattern::BindingIdentifier(local) = &declarator.id {
1273 self.define_config_bindings
1274 .insert(local.name.as_str().to_string());
1275 }
1276 }
1277 return;
1278 }
1279
1280 if !is_require_expression(init, self.module_source) {
1281 return;
1282 }
1283
1284 match &declarator.id {
1285 BindingPattern::BindingIdentifier(local) => {
1286 self.define_config_namespaces
1287 .insert(local.name.as_str().to_string());
1288 }
1289 BindingPattern::ObjectPattern(pattern) => {
1290 for property in &pattern.properties {
1291 if property.computed || !property_key_matches(&property.key, "defineConfig") {
1292 continue;
1293 }
1294 if let BindingPattern::BindingIdentifier(local) = &property.value {
1295 self.define_config_bindings
1296 .insert(local.name.as_str().to_string());
1297 }
1298 }
1299 }
1300 _ => {}
1301 }
1302 }
1303}
1304
1305impl<'a> Visit<'a> for DefineConfigImportCollector<'_> {
1306 fn visit_import_declaration(&mut self, declaration: &ImportDeclaration<'a>) {
1307 if declaration.import_kind.is_type()
1308 || declaration.source.value.as_str() != self.module_source
1309 {
1310 return;
1311 }
1312 let Some(specifiers) = declaration.specifiers.as_ref() else {
1313 return;
1314 };
1315
1316 for specifier in specifiers {
1317 match specifier {
1318 ImportDeclarationSpecifier::ImportSpecifier(specifier)
1319 if specifier.import_kind.is_value()
1320 && specifier.imported.name().as_str() == "defineConfig" =>
1321 {
1322 self.define_config_bindings
1323 .insert(specifier.local.name.as_str().to_string());
1324 }
1325 ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
1326 self.define_config_namespaces
1327 .insert(specifier.local.name.as_str().to_string());
1328 }
1329 _ => {}
1330 }
1331 }
1332 }
1333}
1334
1335struct AstroFontExtractor<'a, 's> {
1336 source: &'s str,
1337 define_config_bindings: HashSet<String>,
1338 define_config_namespaces: HashSet<String>,
1339 expression_resolver: StaticExpressionResolver<'a>,
1340 variables: Vec<ExtractedVariable>,
1341}
1342
1343impl<'a, 's> AstroFontExtractor<'a, 's> {
1344 fn extract_esm_program(&mut self, program: &'a Program<'a>) {
1345 for statement in &program.body {
1346 if let Statement::ExportDefaultDeclaration(declaration) = statement {
1347 if let Some(expression) = declaration.declaration.as_expression() {
1348 self.extract_default_expression(expression);
1349 }
1350 }
1351 }
1352 }
1353
1354 fn extract_default_expression(&mut self, expression: &'a Expression<'a>) {
1355 if let Some(config) = config_object_from_expression(
1356 expression,
1357 &self.define_config_bindings,
1358 &self.define_config_namespaces,
1359 &self.expression_resolver,
1360 ) {
1361 self.extract_astro_config(config);
1362 }
1363 }
1364
1365 fn extract_commonjs_program(&mut self, program: &'a Program<'a>) {
1366 if let Some(config) = commonjs_config_object(
1367 program,
1368 self.source,
1369 &self.define_config_bindings,
1370 &self.define_config_namespaces,
1371 &self.expression_resolver,
1372 ) {
1373 self.extract_astro_config(config);
1374 }
1375 }
1376
1377 fn extract_astro_config(&mut self, config: &'a ObjectExpression<'a>) {
1378 if let Some(fonts) = self
1379 .expression_resolver
1380 .property_value(config, "fonts")
1381 .and_then(|value| self.expression_resolver.as_array(value))
1382 {
1383 self.extract_fonts(fonts);
1384 }
1385
1386 if let Some(experimental) = self
1387 .expression_resolver
1388 .property_value(config, "experimental")
1389 .and_then(|value| self.expression_resolver.as_object(value))
1390 {
1391 if let Some(fonts) = self
1392 .expression_resolver
1393 .property_value(experimental, "fonts")
1394 .and_then(|value| self.expression_resolver.as_array(value))
1395 {
1396 self.extract_fonts(fonts);
1397 }
1398 }
1399 }
1400
1401 fn extract_fonts(&mut self, fonts: &'a ArrayExpression<'a>) {
1402 let mut active_arrays = HashSet::new();
1403 let mut visits = 0;
1404 if !self.array_is_fully_static(fonts, 0, &mut visits, &mut active_arrays) {
1405 return;
1406 }
1407 self.extract_known_fonts(fonts);
1408 }
1409
1410 fn array_is_fully_static(
1411 &self,
1412 array: &'a ArrayExpression<'a>,
1413 depth: usize,
1414 visits: &mut usize,
1415 active_arrays: &mut HashSet<(u32, u32)>,
1416 ) -> bool {
1417 if depth >= MAX_STATIC_STRING_DEPTH || *visits >= MAX_STATIC_STRUCTURE_VISITS {
1418 return false;
1419 }
1420 *visits += 1;
1421 let key = (array.span.start, array.span.end);
1422 if !active_arrays.insert(key) {
1423 return false;
1424 }
1425
1426 let mut is_static = true;
1427 for element in &array.elements {
1428 let ArrayExpressionElement::SpreadElement(spread) = element else {
1429 continue;
1430 };
1431 let Some(spread_array) = self.expression_resolver.as_array(&spread.argument) else {
1432 is_static = false;
1433 break;
1434 };
1435 if !self.array_is_fully_static(spread_array, depth + 1, visits, active_arrays) {
1436 is_static = false;
1437 break;
1438 }
1439 }
1440
1441 active_arrays.remove(&key);
1442 is_static
1443 }
1444
1445 fn extract_known_fonts(&mut self, fonts: &'a ArrayExpression<'a>) {
1446 for element in &fonts.elements {
1447 if let ArrayExpressionElement::SpreadElement(spread) = element {
1448 if let Some(array) = self.expression_resolver.as_array(&spread.argument) {
1449 self.extract_known_fonts(array);
1450 }
1451 continue;
1452 }
1453 let Some(font) = self.array_element_object(element) else {
1454 continue;
1455 };
1456 let Some(ResolvedProperty::Known {
1457 value,
1458 declaration_span,
1459 }) = self.expression_resolver.property(font, "cssVariable")
1460 else {
1461 continue;
1462 };
1463 let Some((name, name_span)) =
1464 static_string_value(value, self.source, &self.expression_resolver)
1465 else {
1466 continue;
1467 };
1468 if !is_supported_custom_property_name(&name) {
1469 continue;
1470 }
1471 if self
1472 .variables
1473 .iter()
1474 .any(|variable| variable.name == name && variable.name_span == name_span)
1475 {
1476 continue;
1477 }
1478
1479 self.variables.push(ExtractedVariable {
1480 name,
1481 declaration_span: if matches!(unwrap_expression(value), Expression::Identifier(_)) {
1482 name_span
1483 } else {
1484 declaration_span
1485 },
1486 name_span,
1487 });
1488 }
1489 }
1490
1491 fn array_element_object(
1492 &self,
1493 element: &'a ArrayExpressionElement<'a>,
1494 ) -> Option<&'a ObjectExpression<'a>> {
1495 let expression = element.as_expression()?;
1496 self.expression_resolver.as_object(expression)
1497 }
1498}
1499
1500struct ViteAdditionalDataExtractor<'a, 's> {
1501 source: &'s str,
1502 define_config_bindings: HashSet<String>,
1503 define_config_namespaces: HashSet<String>,
1504 expression_resolver: StaticExpressionResolver<'a>,
1505 css_snippets: Vec<ExtractedCssSnippet>,
1506}
1507
1508impl<'a, 's> ViteAdditionalDataExtractor<'a, 's> {
1509 fn extract_esm_program(&mut self, program: &'a Program<'a>) {
1510 for statement in &program.body {
1511 if let Statement::ExportDefaultDeclaration(declaration) = statement {
1512 if let Some(expression) = declaration.declaration.as_expression() {
1513 self.extract_default_expression(expression);
1514 }
1515 }
1516 }
1517 }
1518
1519 fn extract_default_expression(&mut self, expression: &'a Expression<'a>) {
1520 if let Some(config) = config_object_from_expression(
1521 expression,
1522 &self.define_config_bindings,
1523 &self.define_config_namespaces,
1524 &self.expression_resolver,
1525 ) {
1526 self.extract_vite_config(config);
1527 }
1528 }
1529
1530 fn extract_commonjs_program(&mut self, program: &'a Program<'a>) {
1531 if let Some(config) = commonjs_config_object(
1532 program,
1533 self.source,
1534 &self.define_config_bindings,
1535 &self.define_config_namespaces,
1536 &self.expression_resolver,
1537 ) {
1538 self.extract_vite_config(config);
1539 }
1540 }
1541
1542 fn extract_vite_config(&mut self, config: &'a ObjectExpression<'a>) {
1543 let Some(preprocessor_options) = self
1544 .expression_resolver
1545 .property_value(config, "css")
1546 .and_then(|value| self.expression_resolver.as_object(value))
1547 .and_then(|css| {
1548 self.expression_resolver
1549 .property_value(css, "preprocessorOptions")
1550 })
1551 .and_then(|value| self.expression_resolver.as_object(value))
1552 else {
1553 return;
1554 };
1555
1556 let Some(options) = self
1557 .expression_resolver
1558 .property_value(preprocessor_options, VITE_PREPROCESSOR)
1559 .and_then(|value| self.expression_resolver.as_object(value))
1560 else {
1561 return;
1562 };
1563 let Some(additional_data) = self
1564 .expression_resolver
1565 .property_value(options, "additionalData")
1566 else {
1567 return;
1568 };
1569 let Some((text, content_span)) =
1570 static_string_value(additional_data, self.source, &self.expression_resolver)
1571 else {
1572 return;
1573 };
1574 if contains_unsupported_scss_control_flow(&text) {
1575 return;
1576 }
1577 let text = mask_scss_comments_for_parser(&text);
1578 self.css_snippets
1579 .push(ExtractedCssSnippet { text, content_span });
1580 }
1581}
1582
1583fn config_object_from_expression<'a>(
1584 expression: &'a Expression<'a>,
1585 define_config_bindings: &HashSet<String>,
1586 define_config_namespaces: &HashSet<String>,
1587 resolver: &StaticExpressionResolver<'a>,
1588) -> Option<&'a ObjectExpression<'a>> {
1589 match unwrap_expression(expression) {
1590 Expression::ObjectExpression(object) => Some(object.as_ref()),
1591 Expression::Identifier(_) => resolver.as_object(expression),
1592 Expression::ArrowFunctionExpression(function) => {
1593 config_object_from_arrow_body(&function.body, resolver)
1594 }
1595 Expression::FunctionExpression(function) => function
1596 .body
1597 .as_deref()
1598 .and_then(|body| config_object_from_function_body(body, resolver)),
1599 Expression::CallExpression(call)
1600 if is_define_config_callee(
1601 &call.callee,
1602 define_config_bindings,
1603 define_config_namespaces,
1604 ) =>
1605 {
1606 call.arguments
1607 .first()
1608 .and_then(|argument| argument.as_expression())
1609 .and_then(|expression| {
1610 config_object_from_expression(
1611 expression,
1612 define_config_bindings,
1613 define_config_namespaces,
1614 resolver,
1615 )
1616 })
1617 }
1618 _ => None,
1619 }
1620}
1621
1622fn config_object_from_arrow_body<'a>(
1623 body: &'a ArrowFunctionBody<'a>,
1624 resolver: &StaticExpressionResolver<'a>,
1625) -> Option<&'a ObjectExpression<'a>> {
1626 if let Some(expression) = body.as_expression() {
1627 resolver.as_object(expression)
1628 } else if let ArrowFunctionBody::FunctionBody(body) = body {
1629 config_object_from_function_body(body, resolver)
1630 } else {
1631 None
1632 }
1633}
1634
1635fn config_object_from_function_body<'a>(
1636 body: &'a oxc_ast::ast::FunctionBody<'a>,
1637 resolver: &StaticExpressionResolver<'a>,
1638) -> Option<&'a ObjectExpression<'a>> {
1639 let [Statement::ReturnStatement(statement)] = body.statements.as_slice() else {
1640 return None;
1641 };
1642 statement
1643 .argument
1644 .as_ref()
1645 .and_then(|expression| resolver.as_object(expression))
1646}
1647
1648fn commonjs_config_object<'a>(
1649 program: &'a Program<'a>,
1650 source: &str,
1651 define_config_bindings: &HashSet<String>,
1652 define_config_namespaces: &HashSet<String>,
1653 resolver: &StaticExpressionResolver<'a>,
1654) -> Option<&'a ObjectExpression<'a>> {
1655 let mut assignments = ModuleExportsAssignmentCollector::new(source);
1656 assignments.visit_program(program);
1657
1658 for statement in program.body.iter().rev() {
1659 let Statement::ExpressionStatement(statement) = statement else {
1660 continue;
1661 };
1662 let Expression::AssignmentExpression(assignment) = unwrap_expression(&statement.expression)
1663 else {
1664 continue;
1665 };
1666 if is_module_exports_assignment(assignment, source) {
1667 if assignments
1668 .spans
1669 .iter()
1670 .any(|span| span.start > assignment.span.start)
1671 {
1672 return None;
1673 }
1674 return config_object_from_expression(
1675 &assignment.right,
1676 define_config_bindings,
1677 define_config_namespaces,
1678 resolver,
1679 );
1680 }
1681 }
1682 None
1683}
1684
1685struct ModuleExportsAssignmentCollector<'s> {
1686 source: &'s str,
1687 spans: Vec<Span>,
1688}
1689
1690impl<'s> ModuleExportsAssignmentCollector<'s> {
1691 fn new(source: &'s str) -> Self {
1692 Self {
1693 source,
1694 spans: Vec::new(),
1695 }
1696 }
1697}
1698
1699impl<'a> Visit<'a> for ModuleExportsAssignmentCollector<'_> {
1700 fn visit_assignment_expression(&mut self, assignment: &AssignmentExpression<'a>) {
1701 if is_module_exports_assignment(assignment, self.source) {
1702 self.spans.push(assignment.span);
1703 }
1704 walk_assignment_expression(self, assignment);
1705 }
1706
1707 fn visit_function_body(&mut self, _body: &oxc_ast::ast::FunctionBody<'a>) {}
1708}
1709
1710fn is_require_expression(expression: &Expression<'_>, module_source: &str) -> bool {
1711 let Expression::CallExpression(call) = unwrap_expression(expression) else {
1712 return false;
1713 };
1714 call.is_require_call()
1715 && matches!(
1716 call.arguments.first(),
1717 Some(Argument::StringLiteral(source)) if source.value.as_str() == module_source
1718 )
1719}
1720
1721fn is_define_config_callee(
1722 callee: &Expression<'_>,
1723 define_config_bindings: &HashSet<String>,
1724 define_config_namespaces: &HashSet<String>,
1725) -> bool {
1726 let callee = unwrap_expression(callee);
1727 if matches!(
1728 callee,
1729 Expression::Identifier(identifier)
1730 if define_config_bindings.contains(identifier.name.as_str())
1731 ) {
1732 return true;
1733 }
1734
1735 let Some(member) = callee.as_member_expression() else {
1736 return false;
1737 };
1738 if member.static_property_name() != Some("defineConfig") {
1739 return false;
1740 }
1741 matches!(
1742 unwrap_expression(member.object()),
1743 Expression::Identifier(identifier)
1744 if define_config_namespaces.contains(identifier.name.as_str())
1745 )
1746}
1747
1748fn is_module_exports_assignment(assignment: &AssignmentExpression<'_>, source: &str) -> bool {
1749 assignment.operator.is_assign()
1750 && source
1751 .get(assignment.left.span().start as usize..assignment.left.span().end as usize)
1752 .is_some_and(|target| target.trim() == "module.exports")
1753}
1754
1755fn unwrap_expression<'a>(mut expression: &'a Expression<'a>) -> &'a Expression<'a> {
1756 loop {
1757 expression = match expression {
1758 Expression::ParenthesizedExpression(wrapper) => &wrapper.expression,
1759 Expression::TSAsExpression(wrapper) => &wrapper.expression,
1760 Expression::TSSatisfiesExpression(wrapper) => &wrapper.expression,
1761 Expression::TSTypeAssertion(wrapper) => &wrapper.expression,
1762 Expression::TSNonNullExpression(wrapper) => &wrapper.expression,
1763 Expression::TSInstantiationExpression(wrapper) => &wrapper.expression,
1764 _ => return expression,
1765 };
1766 }
1767}
1768
1769fn property_key_matches(key: &PropertyKey<'_>, name: &str) -> bool {
1770 key.is_specific_id(name) || key.is_specific_string_literal(name)
1771}
1772
1773fn is_supported_custom_property_name(name: &str) -> bool {
1774 let Some(suffix) = name.strip_prefix("--") else {
1775 return false;
1776 };
1777 !suffix.is_empty()
1778 && suffix
1779 .chars()
1780 .all(|character| character.is_alphanumeric() || matches!(character, '-' | '_'))
1781}
1782
1783fn as_object_expression<'a>(expression: &'a Expression<'a>) -> Option<&'a ObjectExpression<'a>> {
1784 match expression {
1785 Expression::ObjectExpression(object) => Some(object.as_ref()),
1786 _ => None,
1787 }
1788}
1789
1790fn literal_content_span(span: Span) -> Span {
1791 if span.end > span.start + 1 {
1792 Span::new(span.start + 1, span.end - 1)
1793 } else {
1794 span
1795 }
1796}
1797
1798fn literal_static_string_span(expression: &Expression<'_>, source: &str) -> Option<Span> {
1799 match expression {
1800 Expression::StringLiteral(literal) => {
1801 let span = literal_content_span(literal.span);
1802 let raw = source.get(span.start as usize..span.end as usize)?;
1803 (raw == literal.value.as_str()).then_some(span)
1804 }
1805 Expression::TemplateLiteral(template)
1806 if template.expressions.is_empty() && template.quasis.len() == 1 =>
1807 {
1808 let span = literal_content_span(template.span);
1809 let raw = source.get(span.start as usize..span.end as usize)?;
1810 (!raw.contains('\\')).then_some(span)
1811 }
1812 _ => None,
1813 }
1814}
1815
1816fn static_string_value(
1817 expression: &Expression<'_>,
1818 source: &str,
1819 resolver: &StaticExpressionResolver<'_>,
1820) -> Option<(String, Span)> {
1821 let expression = unwrap_expression(expression);
1822 let content_span = if let Some(span) = literal_static_string_span(expression, source) {
1823 span
1824 } else {
1825 let Expression::Identifier(identifier) = expression else {
1826 return None;
1827 };
1828 if resolver.is_shadowed(identifier.name.as_str(), identifier.span) {
1829 return None;
1830 }
1831 let value = resolver.strings.get(identifier.name.as_str())?;
1832 if value.available_after > identifier.span.start {
1833 return None;
1834 }
1835 value.content_span
1836 };
1837 let value = source.get(content_span.start as usize..content_span.end as usize)?;
1838 Some((value.to_string(), content_span))
1839}
1840
1841fn mask_scss_lexical(source: &str, mask_strings: bool) -> String {
1842 let mut bytes = source.as_bytes().to_vec();
1843 let mut index = 0;
1844 let mut quote = None;
1845
1846 let blank = |bytes: &mut [u8], start: usize, end: usize| {
1847 for byte in &mut bytes[start..end] {
1848 if !matches!(*byte, b'\n' | b'\r') {
1849 *byte = b' ';
1850 }
1851 }
1852 };
1853
1854 while index < bytes.len() {
1855 if let Some(delimiter) = quote {
1856 if bytes[index] == b'\\' {
1857 if mask_strings {
1858 let end = (index + 2).min(bytes.len());
1859 blank(&mut bytes, index, end);
1860 }
1861 index = (index + 2).min(bytes.len());
1862 continue;
1863 }
1864 let closed = bytes[index] == delimiter;
1865 if mask_strings {
1866 blank(&mut bytes, index, index + 1);
1867 }
1868 index += 1;
1869 if closed {
1870 quote = None;
1871 }
1872 continue;
1873 }
1874
1875 if bytes[index] == b'\'' || bytes[index] == b'"' {
1876 quote = Some(bytes[index]);
1877 if mask_strings {
1878 blank(&mut bytes, index, index + 1);
1879 }
1880 index += 1;
1881 continue;
1882 }
1883
1884 if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'*') {
1885 let start = index;
1886 index += 2;
1887 while index + 1 < bytes.len() && !(bytes[index] == b'*' && bytes[index + 1] == b'/') {
1888 index += 1;
1889 }
1890 index = (index + 2).min(bytes.len());
1891 blank(&mut bytes, start, index);
1892 continue;
1893 }
1894
1895 if bytes[index] == b'/' && bytes.get(index + 1) == Some(&b'/') {
1896 let start = index;
1897 index += 2;
1898 while index < bytes.len() && !matches!(bytes[index], b'\n' | b'\r') {
1899 index += 1;
1900 }
1901 blank(&mut bytes, start, index);
1902 continue;
1903 }
1904
1905 index += 1;
1906 }
1907
1908 String::from_utf8(bytes).unwrap_or_else(|_| source.to_string())
1909}
1910
1911fn mask_scss_comments_for_parser(source: &str) -> String {
1912 mask_scss_lexical(source, false)
1913}
1914
1915fn contains_unsupported_scss_control_flow(source: &str) -> bool {
1916 const UNSUPPORTED_AT_RULES: &[&str] = &[
1917 "if", "else", "for", "each", "while", "mixin", "include", "content", "function", "return",
1918 "extend", "at-root",
1919 ];
1920
1921 let masked = mask_scss_lexical(source, true);
1922 let bytes = masked.as_bytes();
1923 let mut index = 0;
1924 while index < bytes.len() {
1925 if bytes[index] != b'@' {
1926 index += 1;
1927 continue;
1928 }
1929
1930 let start = index + 1;
1931 let mut end = start;
1932 while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'-') {
1933 end += 1;
1934 }
1935 if end > start {
1936 let name = &masked[start..end];
1937 if UNSUPPORTED_AT_RULES
1938 .iter()
1939 .any(|candidate| name.eq_ignore_ascii_case(candidate))
1940 {
1941 return true;
1942 }
1943 }
1944 index = end.max(index + 1);
1945 }
1946
1947 false
1948}
1949fn span_to_range(text: &str, span: Span) -> Range {
1950 Range::new(
1951 offset_to_position(text, span.start as usize),
1952 offset_to_position(text, span.end as usize),
1953 )
1954}
1955
1956#[cfg(test)]
1957mod tests {
1958 use super::*;
1959 use crate::types::Config;
1960
1961 fn test_uri(name: &str) -> Uri {
1962 Uri::from_file_path(std::env::temp_dir().join(name)).unwrap()
1963 }
1964
1965 #[test]
1966 fn recognizes_supported_astro_config_names() {
1967 assert!(is_supported_config_path(Path::new("astro.config.mjs")));
1968 assert!(is_supported_config_path(Path::new(
1969 "/workspace/astro.config.ts"
1970 )));
1971 assert!(!is_supported_config_path(Path::new("astro.config.json")));
1972 assert!(!is_supported_config_path(Path::new("astro.config.foo.ts")));
1973 assert!(!is_supported_config_path(Path::new("src/config.ts")));
1974 }
1975
1976 #[test]
1977 fn recognizes_supported_vite_config_names() {
1978 for name in [
1979 "vite.config.js",
1980 "vite.config.mjs",
1981 "vite.config.cjs",
1982 "vite.config.ts",
1983 "vite.config.mts",
1984 "vite.config.cts",
1985 ] {
1986 assert!(is_supported_config_path(Path::new(name)), "{name}");
1987 }
1988 assert!(!is_supported_config_path(Path::new("vite.config.json")));
1989 assert!(!is_supported_config_path(Path::new("src/vite.ts")));
1990 }
1991
1992 #[tokio::test]
1993 async fn extracts_vite_preprocessor_additional_data_variables() {
1994 let manager = CssVariableManager::new(Config::default());
1995 let uri = test_uri("vite.config.ts");
1996 let text = r#"
1997 import { defineConfig as configure } from "vite";
1998
1999 export default configure({
2000 css: {
2001 preprocessorOptions: {
2002 scss: {
2003 additionalData: `:root {
2004 --vite-brand: #123456;
2005 --vite-derived: var(--base-color);
2006 }`,
2007 },
2008 less: {
2009 additionalData: ".theme { --vite-less: rebeccapurple; }",
2010 },
2011 },
2012 },
2013 });
2014 "#;
2015
2016 parse_config_document(text, &uri, &manager).await.unwrap();
2017
2018 let brand = manager.get_variables("--vite-brand").await;
2019 assert_eq!(brand.len(), 1);
2020 assert_eq!(brand[0].value, "#123456");
2021 assert_eq!(brand[0].selector, ":root");
2022 let brand_range = brand[0].name_range.expect("name range");
2023 let brand_start = text.find("--vite-brand").unwrap();
2024 assert_eq!(
2025 brand_range,
2026 Range::new(
2027 offset_to_position(text, brand_start),
2028 offset_to_position(text, brand_start + "--vite-brand".len()),
2029 )
2030 );
2031
2032 assert_eq!(manager.get_variables("--vite-derived").await.len(), 1);
2033 assert_eq!(manager.get_usages("--base-color").await.len(), 1);
2034 assert!(manager.get_variables("--vite-less").await.is_empty());
2035 }
2036
2037 #[tokio::test]
2038 async fn resolves_reusable_top_level_const_strings_for_astro_and_vite() {
2039 let astro_manager = CssVariableManager::new(Config::default());
2040 let astro_uri = test_uri("astro.config.ts");
2041 let astro_text = r#"
2042 import { defineConfig } from "astro/config";
2043
2044 const FONT_NAME = ("--font-const" as const) satisfies string;
2045 const FONT_ALIAS = FONT_NAME;
2046
2047 export default defineConfig({
2048 fonts: [
2049 { cssVariable: FONT_ALIAS },
2050 { cssVariable: FONT_ALIAS },
2051 ],
2052 });
2053 "#;
2054
2055 parse_config_document(astro_text, &astro_uri, &astro_manager)
2056 .await
2057 .unwrap();
2058
2059 let font = astro_manager.get_variables("--font-const").await;
2060 assert_eq!(font.len(), 1);
2061 let font_start = astro_text.find("--font-const").unwrap();
2062 assert_eq!(
2063 font[0].name_range,
2064 Some(Range::new(
2065 offset_to_position(astro_text, font_start),
2066 offset_to_position(astro_text, font_start + "--font-const".len()),
2067 ))
2068 );
2069
2070 let vite_manager = CssVariableManager::new(Config::default());
2071 let vite_uri = test_uri("vite.config.ts");
2072 let vite_text = r#"
2073 import { defineConfig } from "vite";
2074
2075 const SHARED_SCSS = `:root {
2076 --vite-const: #123456;
2077 --vite-const-derived: var(--base-color);
2078 }`;
2079
2080 export default defineConfig({
2081 css: {
2082 preprocessorOptions: {
2083 scss: { additionalData: SHARED_SCSS },
2084 },
2085 },
2086 });
2087 "#;
2088
2089 parse_config_document(vite_text, &vite_uri, &vite_manager)
2090 .await
2091 .unwrap();
2092
2093 assert_eq!(vite_manager.get_variables("--vite-const").await.len(), 1);
2094 let vite_const = vite_manager.get_variables("--vite-const").await;
2095 let vite_start = vite_text.find("--vite-const").unwrap();
2096 assert_eq!(
2097 vite_const[0].name_range,
2098 Some(Range::new(
2099 offset_to_position(vite_text, vite_start),
2100 offset_to_position(vite_text, vite_start + "--vite-const".len()),
2101 ))
2102 );
2103 assert_eq!(
2104 vite_manager
2105 .get_variables("--vite-const-derived")
2106 .await
2107 .len(),
2108 1
2109 );
2110 assert_eq!(vite_manager.get_usages("--base-color").await.len(), 1);
2111
2112 let commonjs_manager = CssVariableManager::new(Config::default());
2113 let commonjs_uri = test_uri("astro.config.cjs");
2114 parse_config_document(
2115 r#"
2116 const FONT_NAME = "--font-commonjs-const";
2117 module.exports = {
2118 fonts: [{ cssVariable: FONT_NAME }],
2119 };
2120 "#,
2121 &commonjs_uri,
2122 &commonjs_manager,
2123 )
2124 .await
2125 .unwrap();
2126 assert_eq!(
2127 commonjs_manager
2128 .get_variables("--font-commonjs-const")
2129 .await
2130 .len(),
2131 1
2132 );
2133 }
2134
2135 #[tokio::test]
2136 async fn const_string_resolution_rejects_dynamic_or_unsafe_bindings() {
2137 let manager = CssVariableManager::new(Config::default());
2138 let uri = test_uri("astro.config.ts");
2139 parse_config_document(
2140 r#"
2141 import { defineConfig } from "astro/config";
2142
2143 let LET_NAME = "--font-let";
2144 var VAR_NAME = "--font-var";
2145 const DYNAMIC = `--font-${family}`;
2146
2147 export default defineConfig({
2148 fonts: [
2149 { cssVariable: LET_NAME },
2150 { cssVariable: VAR_NAME },
2151 { cssVariable: DYNAMIC },
2152 { cssVariable: DECLARED_AFTER_USE },
2153 { cssVariable: LOCAL_ONLY },
2154 ],
2155 });
2156
2157 const DECLARED_AFTER_USE = "--font-after-use";
2158 function unused() {
2159 const LOCAL_ONLY = "--font-local";
2160 return LOCAL_ONLY;
2161 }
2162 "#,
2163 &uri,
2164 &manager,
2165 )
2166 .await
2167 .unwrap();
2168
2169 for name in [
2170 "--font-let",
2171 "--font-var",
2172 "--font-after-use",
2173 "--font-local",
2174 ] {
2175 assert!(manager.get_variables(name).await.is_empty(), "{name}");
2176 }
2177 }
2178
2179 #[test]
2180 fn static_string_resolution_rejects_mutation_cycles_and_excessive_depth() {
2181 let mut text = String::from(
2182 r#"
2183 const REASSIGNED = "--font-before-reassignment";
2184 REASSIGNED = "--font-after-reassignment";
2185 const UPDATED = "--font-before-update";
2186 UPDATED++;
2187 const DESTRUCTURED = "--font-before-destructure";
2188 ({ value: DESTRUCTURED } = source);
2189 const FOR_OF_TARGET = "--font-before-for-of";
2190 for (FOR_OF_TARGET of values) {}
2191 const TS_WRAPPED = "--font-before-ts-assignment";
2192 (TS_WRAPPED as string) = source;
2193 const SHADOWED = "--font-shadowed";
2194 {
2195 let SHADOWED = 0;
2196 SHADOWED++;
2197 }
2198 const SWITCH_SHADOWED = "--font-switch-shadowed";
2199 switch (mode) {
2200 case 1:
2201 let SWITCH_SHADOWED;
2202 SWITCH_SHADOWED = source;
2203 break;
2204 }
2205 const CYCLE_A = CYCLE_B;
2206 const CYCLE_B = CYCLE_A;
2207 const VALUE_20 = "--font-depth";
2208 "#,
2209 );
2210 for index in (0..20).rev() {
2211 text.push_str(&format!("const VALUE_{index} = VALUE_{};\n", index + 1));
2212 }
2213
2214 let allocator = Allocator::default();
2215 let source_type = SourceType::from_path(Path::new("astro.config.ts")).unwrap();
2216 let parsed = Parser::new(&allocator, &text, source_type).parse();
2217 let values = collect_static_resolver(&parsed.program, &text).resolved_strings;
2218
2219 assert!(!values.contains_key("REASSIGNED"));
2220 assert!(!values.contains_key("UPDATED"));
2221 assert!(!values.contains_key("DESTRUCTURED"));
2222 assert!(!values.contains_key("FOR_OF_TARGET"));
2223 assert!(!values.contains_key("TS_WRAPPED"));
2224 assert!(values.contains_key("SHADOWED"));
2225 assert!(values.contains_key("SWITCH_SHADOWED"));
2226 assert!(!values.contains_key("CYCLE_A"));
2227 assert!(!values.contains_key("CYCLE_B"));
2228 assert!(!values.contains_key("VALUE_0"));
2229 let depth_span = values.get("VALUE_5").unwrap().content_span;
2230 assert_eq!(
2231 text.get(depth_span.start as usize..depth_span.end as usize),
2232 Some("--font-depth")
2233 );
2234 }
2235
2236 #[tokio::test]
2237 async fn exported_const_strings_remain_out_of_scope() {
2238 let manager = CssVariableManager::new(Config::default());
2239 let uri = test_uri("astro.config.ts");
2240 parse_config_document(
2241 r#"
2242 export const FONT_NAME = "--font-exported-const";
2243 export default {
2244 fonts: [{ cssVariable: FONT_NAME }],
2245 };
2246 "#,
2247 &uri,
2248 &manager,
2249 )
2250 .await
2251 .unwrap();
2252
2253 assert!(manager
2254 .get_variables("--font-exported-const")
2255 .await
2256 .is_empty());
2257 }
2258
2259 #[tokio::test]
2260 async fn resolves_static_config_structures_and_vite_function_returns() {
2261 let astro_manager = CssVariableManager::new(Config::default());
2262 let astro_uri = test_uri("astro.config.ts");
2263 parse_config_document(
2264 r#"
2265 import { defineConfig } from "astro/config";
2266
2267 const BODY_FONT = { cssVariable: "--font-structured-body" };
2268 const HEADING_FONT = { cssVariable: "--font-structured-heading" };
2269 const BASE_FONTS = [BODY_FONT];
2270 const FONTS = [...BASE_FONTS, HEADING_FONT];
2271 const FONT_CONFIG = { fonts: FONTS };
2272 const CONFIG = { ...FONT_CONFIG };
2273
2274 export default defineConfig(CONFIG);
2275 "#,
2276 &astro_uri,
2277 &astro_manager,
2278 )
2279 .await
2280 .unwrap();
2281
2282 assert_eq!(
2283 astro_manager
2284 .get_variables("--font-structured-body")
2285 .await
2286 .len(),
2287 1
2288 );
2289 assert_eq!(
2290 astro_manager
2291 .get_variables("--font-structured-heading")
2292 .await
2293 .len(),
2294 1
2295 );
2296
2297 let vite_manager = CssVariableManager::new(Config::default());
2298 let vite_uri = test_uri("vite.config.ts");
2299 parse_config_document(
2300 r#"
2301 import { defineConfig } from "vite";
2302
2303 const SHARED_SCSS = `:root { --vite-structured: #123456; }`;
2304 const SCSS = { additionalData: SHARED_SCSS };
2305 const PREPROCESSORS = { scss: SCSS };
2306 const CSS = { preprocessorOptions: PREPROCESSORS };
2307 const BASE = { css: CSS };
2308
2309 export default defineConfig(() => ({ ...BASE }));
2310 "#,
2311 &vite_uri,
2312 &vite_manager,
2313 )
2314 .await
2315 .unwrap();
2316
2317 assert_eq!(
2318 vite_manager.get_variables("--vite-structured").await.len(),
2319 1
2320 );
2321 }
2322
2323 #[tokio::test]
2324 async fn static_structure_resolution_rejects_unknown_overrides_and_dynamic_functions() {
2325 let manager = CssVariableManager::new(Config::default());
2326 let uri = test_uri("vite.config.ts");
2327 parse_config_document(
2328 r#"
2329 import { defineConfig } from "vite";
2330
2331 const VALID = {
2332 css: {
2333 preprocessorOptions: {
2334 scss: { additionalData: ":root { --vite-before-unknown: red; }" },
2335 },
2336 },
2337 ...runtimeConfig,
2338 };
2339
2340 export default defineConfig((env) => {
2341 if (env.mode === "production") {
2342 return VALID;
2343 }
2344 return {
2345 css: {
2346 preprocessorOptions: {
2347 scss: { additionalData: ":root { --vite-dynamic-return: blue; }" },
2348 },
2349 },
2350 };
2351 });
2352 "#,
2353 &uri,
2354 &manager,
2355 )
2356 .await
2357 .unwrap();
2358
2359 assert!(manager
2360 .get_variables("--vite-before-unknown")
2361 .await
2362 .is_empty());
2363 assert!(manager
2364 .get_variables("--vite-dynamic-return")
2365 .await
2366 .is_empty());
2367
2368 let computed_manager = CssVariableManager::new(Config::default());
2369 parse_config_document(
2370 r#"
2371 import { defineConfig } from "vite";
2372
2373 const CSS = {
2374 preprocessorOptions: {
2375 scss: {
2376 additionalData: ":root { --vite-before-computed: green; }",
2377 [runtimeKey]: runtimeValue,
2378 },
2379 },
2380 };
2381
2382 export default defineConfig({
2383 css: CSS,
2384 [runtimeKey]: runtimeValue,
2385 });
2386 "#,
2387 &uri,
2388 &computed_manager,
2389 )
2390 .await
2391 .unwrap();
2392 assert!(computed_manager
2393 .get_variables("--vite-before-computed")
2394 .await
2395 .is_empty());
2396 }
2397
2398 #[tokio::test]
2399 async fn static_structure_resolution_rejects_direct_member_mutation() {
2400 let manager = CssVariableManager::new(Config::default());
2401 let uri = test_uri("astro.config.ts");
2402 parse_config_document(
2403 r#"
2404 const CONFIG = {
2405 fonts: [{ cssVariable: "--font-before-member-write" }],
2406 };
2407 CONFIG.fonts = [{ cssVariable: "--font-after-member-write" }];
2408 export default CONFIG;
2409 "#,
2410 &uri,
2411 &manager,
2412 )
2413 .await
2414 .unwrap();
2415
2416 assert!(manager
2417 .get_variables("--font-before-member-write")
2418 .await
2419 .is_empty());
2420 assert!(manager
2421 .get_variables("--font-after-member-write")
2422 .await
2423 .is_empty());
2424 }
2425
2426 #[tokio::test]
2427 async fn cyclic_object_spreads_are_rejected_without_unbounded_recursion() {
2428 let manager = CssVariableManager::new(Config::default());
2429 let uri = test_uri("astro.config.ts");
2430 parse_config_document(
2431 r#"
2432 const CONFIG = {
2433 fonts: [{ cssVariable: "--font-object-cycle" }],
2434 ...CONFIG,
2435 };
2436 export default CONFIG;
2437 "#,
2438 &uri,
2439 &manager,
2440 )
2441 .await
2442 .unwrap();
2443
2444 assert!(manager
2445 .get_variables("--font-object-cycle")
2446 .await
2447 .is_empty());
2448 }
2449
2450 #[tokio::test]
2451 async fn cyclic_and_unknown_array_spreads_reject_the_entire_fonts_value() {
2452 for (name, spread) in [
2453 ("--font-array-cycle", "...FONTS"),
2454 ("--font-array-unknown", "...runtimeFonts"),
2455 ] {
2456 let manager = CssVariableManager::new(Config::default());
2457 let uri = test_uri("astro.config.ts");
2458 let text = format!(
2459 r#"
2460 const FONTS = [{{ cssVariable: "{name}" }}, {spread}];
2461 export default {{ fonts: FONTS }};
2462 "#
2463 );
2464 parse_config_document(&text, &uri, &manager).await.unwrap();
2465 assert!(manager.get_variables(name).await.is_empty(), "{spread}");
2466 }
2467 }
2468
2469 #[tokio::test]
2470 async fn vite_extraction_accepts_commonjs_and_rejects_dynamic_or_unrelated_values() {
2471 let manager = CssVariableManager::new(Config::default());
2472 let uri = test_uri("vite.config.cjs");
2473 parse_config_document(
2474 r#"
2475 const { defineConfig: configure } = require("vite");
2476 module.exports = configure({
2477 define: {
2478 __BRAND_VARIABLE__: JSON.stringify("--vite-define"),
2479 },
2480 additionalData: ":root { --wrong-level: red; }",
2481 css: {
2482 preprocessorOptions: {
2483 scss: {
2484 additionalData: () => ":root { --dynamic: red; }",
2485 },
2486 scss: {
2487 additionalData: ":root { --vite-cjs: blue; }",
2488 },
2489 },
2490 },
2491 });
2492 "#,
2493 &uri,
2494 &manager,
2495 )
2496 .await
2497 .unwrap();
2498
2499 assert_eq!(manager.get_variables("--vite-cjs").await.len(), 1);
2500 assert!(manager.get_variables("--vite-define").await.is_empty());
2501 assert!(manager.get_variables("--wrong-level").await.is_empty());
2502 assert!(manager.get_variables("--dynamic").await.is_empty());
2503 }
2504
2505 #[tokio::test]
2506 async fn vite_extraction_requires_a_proven_define_config_binding() {
2507 let manager = CssVariableManager::new(Config::default());
2508 let uri = test_uri("vite.config.ts");
2509 parse_config_document(
2510 r#"
2511 const defineConfig = (value) => value;
2512 export default defineConfig({
2513 css: {
2514 preprocessorOptions: {
2515 scss: {
2516 additionalData: ":root { --unproven-vite: red; }",
2517 },
2518 },
2519 },
2520 });
2521 "#,
2522 &uri,
2523 &manager,
2524 )
2525 .await
2526 .unwrap();
2527
2528 assert!(manager.get_variables("--unproven-vite").await.is_empty());
2529 }
2530
2531 #[tokio::test]
2532 async fn vite_extraction_rejects_type_only_define_config_imports() {
2533 let manager = CssVariableManager::new(Config::default());
2534 let uri = test_uri("vite.config.ts");
2535 parse_config_document(
2536 r#"
2537 import type { defineConfig } from "vite";
2538 export default defineConfig({
2539 css: {
2540 preprocessorOptions: {
2541 scss: {
2542 additionalData: ":root { --type-only-vite: red; }",
2543 },
2544 },
2545 },
2546 });
2547 "#,
2548 &uri,
2549 &manager,
2550 )
2551 .await
2552 .unwrap();
2553
2554 assert!(manager.get_variables("--type-only-vite").await.is_empty());
2555 }
2556
2557 #[tokio::test]
2558 async fn vite_extraction_prefers_esm_over_commonjs_in_ambiguous_sources() {
2559 let manager = CssVariableManager::new(Config::default());
2560 let uri = test_uri("vite.config.ts");
2561 parse_config_document(
2562 r#"
2563 import { defineConfig } from "vite";
2564 export default defineConfig({
2565 css: {
2566 preprocessorOptions: {
2567 scss: {
2568 additionalData: ":root { --vite-esm: red; }",
2569 },
2570 },
2571 },
2572 });
2573 module.exports = {
2574 css: {
2575 preprocessorOptions: {
2576 scss: {
2577 additionalData: ":root { --vite-cjs-shadow: blue; }",
2578 },
2579 },
2580 },
2581 };
2582 "#,
2583 &uri,
2584 &manager,
2585 )
2586 .await
2587 .unwrap();
2588
2589 assert_eq!(manager.get_variables("--vite-esm").await.len(), 1);
2590 assert!(manager.get_variables("--vite-cjs-shadow").await.is_empty());
2591 }
2592
2593 #[tokio::test]
2594 async fn vite_extraction_preserves_crlf_template_ranges() {
2595 let manager = CssVariableManager::new(Config::default());
2596 let uri = test_uri("vite.config.ts");
2597 let text = "import { defineConfig } from \"vite\";\r\nexport default defineConfig({ css: { preprocessorOptions: { scss: { additionalData: `:root {\r\n --vite-crlf: red;\r\n}` } } } });\r\n";
2598
2599 parse_config_document(text, &uri, &manager).await.unwrap();
2600
2601 let variable = manager.get_variables("--vite-crlf").await;
2602 assert_eq!(variable.len(), 1);
2603 let start = text.find("--vite-crlf").unwrap();
2604 assert_eq!(
2605 variable[0].name_range,
2606 Some(Range::new(
2607 offset_to_position(text, start),
2608 offset_to_position(text, start + "--vite-crlf".len()),
2609 ))
2610 );
2611 }
2612
2613 #[tokio::test]
2614 async fn vite_extraction_rejects_scss_control_flow_conservatively() {
2615 let manager = CssVariableManager::new(Config::default());
2616 let uri = test_uri("vite.config.ts");
2617 parse_config_document(
2618 r#"
2619 export default {
2620 css: {
2621 preprocessorOptions: {
2622 scss: {
2623 additionalData: `
2624 @if false {
2625 :root { --vite-phantom: red; }
2626 }
2627 :root { --vite-after-conditional: blue; }
2628 `,
2629 },
2630 },
2631 },
2632 };
2633 "#,
2634 &uri,
2635 &manager,
2636 )
2637 .await
2638 .unwrap();
2639
2640 assert!(manager.get_variables("--vite-phantom").await.is_empty());
2641 assert!(manager
2642 .get_variables("--vite-after-conditional")
2643 .await
2644 .is_empty());
2645 }
2646
2647 #[tokio::test]
2648 async fn extracts_static_font_variables_from_current_and_legacy_locations() {
2649 let manager = CssVariableManager::new(Config::default());
2650 let uri = test_uri("astro.config.ts");
2651 let text = r#"
2652 import { defineConfig as astroConfig } from "astro/config";
2653 let dynamicName = "--font-dynamic";
2654 const unrelated = { fonts: [{ cssVariable: "--font-unrelated" }] };
2655 export default astroConfig({
2656 fonts: [
2657 { cssVariable: "--font-body" },
2658 { "cssVariable": '--font-heading' },
2659 { cssVariable: `--font-code` },
2660 { cssVariable: `--font-${family}` },
2661 { cssVariable: dynamicName },
2662 ],
2663 experimental: {
2664 fonts: [{ cssVariable: "--font-legacy" }],
2665 },
2666 });
2667 "#;
2668
2669 parse_config_document(text, &uri, &manager).await.unwrap();
2670
2671 assert_eq!(manager.get_variables("--font-body").await.len(), 1);
2672 assert_eq!(manager.get_variables("--font-heading").await.len(), 1);
2673 assert_eq!(manager.get_variables("--font-code").await.len(), 1);
2674 assert_eq!(manager.get_variables("--font-legacy").await.len(), 1);
2675 assert!(manager.get_variables("--font-dynamic").await.is_empty());
2676 assert!(manager.get_variables("--font-unrelated").await.is_empty());
2677 }
2678
2679 #[tokio::test]
2680 async fn extracts_from_a_direct_default_object() {
2681 let manager = CssVariableManager::new(Config::default());
2682 let uri = test_uri("astro.config.mjs");
2683 let text = r#"export default { fonts: [{ cssVariable: "--font-direct" }] };"#;
2684
2685 parse_config_document(text, &uri, &manager).await.unwrap();
2686
2687 let variables = manager.get_variables("--font-direct").await;
2688 assert_eq!(variables.len(), 1);
2689 let declaration_start = text.find("cssVariable").unwrap();
2690 let declaration_end = text.find("\"--font-direct\"").unwrap() + "\"--font-direct\"".len();
2691 assert_eq!(
2692 variables[0].range,
2693 Range::new(
2694 offset_to_position(text, declaration_start),
2695 offset_to_position(text, declaration_end),
2696 ),
2697 "direct literals should retain the containing property declaration range"
2698 );
2699 }
2700
2701 #[tokio::test]
2702 async fn extracts_from_commonjs_configs() {
2703 let manager = CssVariableManager::new(Config::default());
2704 let uri = test_uri("astro.config.cjs");
2705
2706 parse_config_document(
2707 r#"module.exports = { fonts: [{ cssVariable: "--font-commonjs" }] };"#,
2708 &uri,
2709 &manager,
2710 )
2711 .await
2712 .unwrap();
2713
2714 assert_eq!(manager.get_variables("--font-commonjs").await.len(), 1);
2715 }
2716
2717 #[tokio::test]
2718 async fn extracts_from_commonjs_define_config_require() {
2719 let manager = CssVariableManager::new(Config::default());
2720 let uri = test_uri("astro.config.cts");
2721
2722 parse_config_document(
2723 r#"
2724 const { defineConfig: astroConfig } = require("astro/config");
2725 module.exports = astroConfig({
2726 fonts: [{ cssVariable: "--font-commonjs-helper" }],
2727 });
2728 "#,
2729 &uri,
2730 &manager,
2731 )
2732 .await
2733 .unwrap();
2734
2735 assert_eq!(
2736 manager.get_variables("--font-commonjs-helper").await.len(),
2737 1
2738 );
2739
2740 let direct_manager = CssVariableManager::new(Config::default());
2741 parse_config_document(
2742 r#"
2743 const defineConfig = require("astro/config").defineConfig;
2744 module.exports = defineConfig({
2745 fonts: [{ cssVariable: "--font-commonjs-direct-helper" }],
2746 });
2747 "#,
2748 &uri,
2749 &direct_manager,
2750 )
2751 .await
2752 .unwrap();
2753 assert_eq!(
2754 direct_manager
2755 .get_variables("--font-commonjs-direct-helper")
2756 .await
2757 .len(),
2758 1
2759 );
2760
2761 let namespace_manager = CssVariableManager::new(Config::default());
2762 parse_config_document(
2763 r#"
2764 const astro = require("astro/config");
2765 module.exports = astro.defineConfig({
2766 fonts: [{ cssVariable: "--font-commonjs-namespace-helper" }],
2767 });
2768 "#,
2769 &uri,
2770 &namespace_manager,
2771 )
2772 .await
2773 .unwrap();
2774 assert_eq!(
2775 namespace_manager
2776 .get_variables("--font-commonjs-namespace-helper")
2777 .await
2778 .len(),
2779 1
2780 );
2781 }
2782
2783 #[tokio::test]
2784 async fn extracts_from_proven_namespace_define_config_imports_only() {
2785 let uri = test_uri("astro.config.mjs");
2786 let manager = CssVariableManager::new(Config::default());
2787 parse_config_document(
2788 r#"
2789 import * as astro from "astro/config";
2790 export default astro.defineConfig({
2791 fonts: [{ cssVariable: "--font-esm-namespace-helper" }],
2792 });
2793 "#,
2794 &uri,
2795 &manager,
2796 )
2797 .await
2798 .unwrap();
2799 assert_eq!(
2800 manager
2801 .get_variables("--font-esm-namespace-helper")
2802 .await
2803 .len(),
2804 1
2805 );
2806
2807 let unproven_manager = CssVariableManager::new(Config::default());
2808 parse_config_document(
2809 r#"
2810 import * as astro from "unrelated-package";
2811 export default astro.defineConfig({
2812 fonts: [{ cssVariable: "--font-unproven-namespace-helper" }],
2813 });
2814 "#,
2815 &uri,
2816 &unproven_manager,
2817 )
2818 .await
2819 .unwrap();
2820 assert!(unproven_manager
2821 .get_variables("--font-unproven-namespace-helper")
2822 .await
2823 .is_empty());
2824 }
2825
2826 #[tokio::test]
2827 async fn mutated_define_config_helpers_are_not_trusted() {
2828 let cjs_uri = test_uri("astro.config.cjs");
2829 for (name, source) in [
2830 (
2831 "--font-mutated-direct-helper",
2832 r#"
2833 let defineConfig = require("astro/config").defineConfig;
2834 defineConfig = runtimeHelper;
2835 module.exports = defineConfig({
2836 fonts: [{ cssVariable: "--font-mutated-direct-helper" }],
2837 });
2838 "#,
2839 ),
2840 (
2841 "--font-mutated-namespace-helper",
2842 r#"
2843 const astro = require("astro/config");
2844 astro.defineConfig = runtimeHelper;
2845 module.exports = astro.defineConfig({
2846 fonts: [{ cssVariable: "--font-mutated-namespace-helper" }],
2847 });
2848 "#,
2849 ),
2850 ] {
2851 let manager = CssVariableManager::new(Config::default());
2852 parse_config_document(source, &cjs_uri, &manager)
2853 .await
2854 .unwrap();
2855 assert!(manager.get_variables(name).await.is_empty(), "{name}");
2856 }
2857
2858 let esm_manager = CssVariableManager::new(Config::default());
2859 let esm_uri = test_uri("astro.config.mjs");
2860 parse_config_document(
2861 r#"
2862 import * as astro from "astro/config";
2863 astro.defineConfig = runtimeHelper;
2864 export default astro.defineConfig({
2865 fonts: [{ cssVariable: "--font-mutated-esm-namespace-helper" }],
2866 });
2867 "#,
2868 &esm_uri,
2869 &esm_manager,
2870 )
2871 .await
2872 .unwrap();
2873 assert!(esm_manager
2874 .get_variables("--font-mutated-esm-namespace-helper")
2875 .await
2876 .is_empty());
2877 }
2878
2879 #[tokio::test]
2880 async fn module_exports_is_ignored_in_explicit_esm_configs() {
2881 for name in ["astro.config.mjs", "astro.config.mts"] {
2882 let manager = CssVariableManager::new(Config::default());
2883 let uri = test_uri(name);
2884 parse_config_document(
2885 r#"module.exports = { fonts: [{ cssVariable: "--font-wrong-module" }] };"#,
2886 &uri,
2887 &manager,
2888 )
2889 .await
2890 .unwrap();
2891 assert!(manager
2892 .get_variables("--font-wrong-module")
2893 .await
2894 .is_empty());
2895 }
2896 }
2897
2898 #[tokio::test]
2899 async fn malformed_and_escaped_values_are_not_indexed() {
2900 let manager = CssVariableManager::new(Config::default());
2901 let uri = test_uri("astro.config.ts");
2902
2903 parse_config_document(
2904 r#"export default { fonts: [{ cssVariable: "--font-escaped\x2dname" }] };"#,
2905 &uri,
2906 &manager,
2907 )
2908 .await
2909 .unwrap();
2910 assert!(manager
2911 .get_variables("--font-escaped-name")
2912 .await
2913 .is_empty());
2914
2915 parse_config_document(
2916 r#"export default { fonts: [{ cssVariable: "--font-phantom" }]"#,
2917 &uri,
2918 &manager,
2919 )
2920 .await
2921 .unwrap();
2922 assert!(manager.get_variables("--font-phantom").await.is_empty());
2923 }
2924
2925 #[tokio::test]
2926 async fn typescript_wrappers_and_unrelated_decorators_preserve_extraction() {
2927 let manager = CssVariableManager::new(Config::default());
2928 let uri = test_uri("astro.config.ts");
2929 parse_config_document(
2930 r#"
2931 @sealed
2932 class ConfigMarker {}
2933
2934 const CONFIG = {
2935 fonts: [{ cssVariable: "--font-satisfies" }],
2936 } satisfies Record<string, unknown>;
2937
2938 export default CONFIG;
2939 "#,
2940 &uri,
2941 &manager,
2942 )
2943 .await
2944 .unwrap();
2945
2946 assert_eq!(manager.get_variables("--font-satisfies").await.len(), 1);
2947 }
2948
2949 #[tokio::test]
2950 async fn malformed_source_replaces_stale_state_with_safe_recovered_prefix() {
2951 let manager = CssVariableManager::new(Config::default());
2952 let uri = test_uri("astro.config.ts");
2953 parse_config_document(
2954 r#"export default { fonts: [{ cssVariable: "--font-stale" }] };"#,
2955 &uri,
2956 &manager,
2957 )
2958 .await
2959 .unwrap();
2960
2961 parse_config_document(
2962 r#"
2963 export default {
2964 fonts: [{ cssVariable: "--font-safe-prefix" }],
2965 }; /*
2966 "#,
2967 &uri,
2968 &manager,
2969 )
2970 .await
2971 .unwrap();
2972
2973 assert!(manager.get_variables("--font-stale").await.is_empty());
2974 assert_eq!(manager.get_variables("--font-safe-prefix").await.len(), 1);
2975 }
2976
2977 #[tokio::test]
2978 async fn oversized_config_preserves_the_last_valid_analysis() {
2979 let manager = CssVariableManager::new(Config::default());
2980 let uri = test_uri("astro.config.ts");
2981 parse_config_document(
2982 r#"export default { fonts: [{ cssVariable: "--font-before-oversize" }] };"#,
2983 &uri,
2984 &manager,
2985 )
2986 .await
2987 .unwrap();
2988
2989 let oversized = " ".repeat(MAX_CONFIG_BYTES + 1);
2990 parse_config_document(&oversized, &uri, &manager)
2991 .await
2992 .expect("oversized recognized configs should be skipped without an LSP error");
2993
2994 assert_eq!(
2995 manager.get_variables("--font-before-oversize").await.len(),
2996 1
2997 );
2998 }
2999
3000 #[tokio::test]
3001 async fn commonjs_exports_must_be_unconditional_top_level_assignments() {
3002 let manager = CssVariableManager::new(Config::default());
3003 let uri = test_uri("astro.config.cjs");
3004
3005 parse_config_document(
3006 r#"
3007 function unused() {
3008 module.exports = { fonts: [{ cssVariable: "--font-nested" }] };
3009 }
3010 if (false) {
3011 module.exports = { fonts: [{ cssVariable: "--font-conditional" }] };
3012 }
3013 "#,
3014 &uri,
3015 &manager,
3016 )
3017 .await
3018 .unwrap();
3019
3020 assert!(manager.get_variables("--font-nested").await.is_empty());
3021 assert!(manager.get_variables("--font-conditional").await.is_empty());
3022 }
3023
3024 #[tokio::test]
3025 async fn commonjs_exports_reject_later_conditional_overrides() {
3026 let manager = CssVariableManager::new(Config::default());
3027 let uri = test_uri("astro.config.cjs");
3028
3029 parse_config_document(
3030 r#"
3031 module.exports = {
3032 fonts: [{ cssVariable: "--font-before-conditional-override" }],
3033 };
3034 if (process.env.USE_OTHER_CONFIG) {
3035 module.exports = { fonts: [] };
3036 }
3037 "#,
3038 &uri,
3039 &manager,
3040 )
3041 .await
3042 .unwrap();
3043
3044 assert!(manager
3045 .get_variables("--font-before-conditional-override")
3046 .await
3047 .is_empty());
3048 }
3049
3050 #[tokio::test]
3051 async fn define_config_helpers_mutated_inside_functions_are_rejected() {
3052 let manager = CssVariableManager::new(Config::default());
3053 let uri = test_uri("vite.config.cjs");
3054
3055 parse_config_document(
3056 r#"
3057 const vite = require("vite");
3058 (() => { vite.defineConfig = (value) => value; })();
3059 module.exports = vite.defineConfig({
3060 css: {
3061 preprocessorOptions: {
3062 scss: {
3063 additionalData: ":root { --vite-mutated-in-iife: red; }",
3064 },
3065 },
3066 },
3067 });
3068 "#,
3069 &uri,
3070 &manager,
3071 )
3072 .await
3073 .unwrap();
3074
3075 assert!(manager
3076 .get_variables("--vite-mutated-in-iife")
3077 .await
3078 .is_empty());
3079 }
3080
3081 #[tokio::test]
3082 async fn function_parameter_shadowing_does_not_mutate_define_config_helpers() {
3083 let manager = CssVariableManager::new(Config::default());
3084 let uri = test_uri("vite.config.cjs");
3085
3086 parse_config_document(
3087 r#"
3088 const vite = require("vite");
3089 ((vite) => { vite.defineConfig = (value) => value; })({});
3090 (function (vite) { vite.defineConfig = (value) => value; })({});
3091 module.exports = vite.defineConfig({
3092 css: {
3093 preprocessorOptions: {
3094 scss: {
3095 additionalData: ":root { --vite-outer-helper: red; }",
3096 },
3097 },
3098 },
3099 });
3100 "#,
3101 &uri,
3102 &manager,
3103 )
3104 .await
3105 .unwrap();
3106
3107 assert_eq!(manager.get_variables("--vite-outer-helper").await.len(), 1);
3108 }
3109
3110 #[tokio::test]
3111 async fn the_last_top_level_commonjs_export_wins() {
3112 let manager = CssVariableManager::new(Config::default());
3113 let uri = test_uri("astro.config.cts");
3114
3115 parse_config_document(
3116 r#"
3117 module.exports = { fonts: [{ cssVariable: "--font-old-export" }] };
3118 module.exports = { fonts: [{ cssVariable: "--font-current-export" }] };
3119 "#,
3120 &uri,
3121 &manager,
3122 )
3123 .await
3124 .unwrap();
3125
3126 assert!(manager.get_variables("--font-old-export").await.is_empty());
3127 assert_eq!(
3128 manager.get_variables("--font-current-export").await.len(),
3129 1
3130 );
3131 }
3132
3133 #[tokio::test]
3134 async fn extraction_follows_effective_object_properties_conservatively() {
3135 let manager = CssVariableManager::new(Config::default());
3136 let uri = test_uri("astro.config.mjs");
3137
3138 parse_config_document(
3139 r#"
3140 export default {
3141 fonts: [{ cssVariable: "--font-overridden-root" }],
3142 fonts: [
3143 {
3144 cssVariable: "--font-overridden-value",
3145 cssVariable: "--font-effective",
3146 },
3147 {
3148 cssVariable: "--font-possibly-overridden",
3149 ...fontOverrides,
3150 },
3151 { cssVariable: "--font invalid" },
3152 ],
3153 };
3154 "#,
3155 &uri,
3156 &manager,
3157 )
3158 .await
3159 .unwrap();
3160
3161 assert_eq!(manager.get_variables("--font-effective").await.len(), 1);
3162 assert!(manager
3163 .get_variables("--font-overridden-root")
3164 .await
3165 .is_empty());
3166 assert!(manager
3167 .get_variables("--font-overridden-value")
3168 .await
3169 .is_empty());
3170 assert!(manager
3171 .get_variables("--font-possibly-overridden")
3172 .await
3173 .is_empty());
3174 assert!(manager.get_variables("--font invalid").await.is_empty());
3175 }
3176}