Skip to main content

css_variable_lsp/
config_analysis.rs

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
95/// Return whether a path is a supported framework configuration source.
96pub 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}
106/// Parse a recognized framework configuration file without executing it.
107pub 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
115/// Parse a recognized framework configuration file and commit only if its
116/// version is still current.
117pub(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(&parameter.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(&parameter.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}