Skip to main content

fallow_core/plugins/
config_parser.rs

1//! AST-based config file parser utilities.
2//!
3//! Helpers for statically extracting config values from JS/TS files.
4
5use std::path::{Path, PathBuf};
6
7use fallow_extract::visitor::extract_import_from_callable;
8use oxc_allocator::Allocator;
9#[allow(clippy::wildcard_imports, reason = "many AST types used")]
10use oxc_ast::ast::*;
11use oxc_parser::Parser;
12use oxc_span::SourceType;
13use rustc_hash::FxHashSet;
14
15/// Extract all import source specifiers from JS/TS source code.
16#[must_use]
17pub(crate) fn extract_imports(source: &str, path: &Path) -> Vec<String> {
18    extract_from_source(source, path, |program| {
19        let mut sources = Vec::new();
20        for stmt in &program.body {
21            if let Statement::ImportDeclaration(decl) = stmt {
22                sources.push(decl.source.value.to_string());
23            }
24        }
25        Some(sources)
26    })
27    .unwrap_or_default()
28}
29
30/// Extract import sources and top-level `require('...')` statements.
31#[must_use]
32pub(crate) fn extract_imports_and_requires(source: &str, path: &Path) -> Vec<String> {
33    extract_from_source(source, path, |program| {
34        let mut sources = Vec::new();
35        for stmt in &program.body {
36            match stmt {
37                Statement::ImportDeclaration(decl) => {
38                    sources.push(decl.source.value.to_string());
39                }
40                Statement::ExpressionStatement(expr) => {
41                    if let Expression::CallExpression(call) = &expr.expression
42                        && is_require_call(call)
43                        && let Some(s) = get_require_source(call)
44                    {
45                        sources.push(s);
46                    }
47                }
48                _ => {}
49            }
50        }
51        Some(sources)
52    })
53    .unwrap_or_default()
54}
55
56/// Extract string array from a property at a nested path in a config's default export.
57#[must_use]
58pub(crate) fn extract_config_string_array(
59    source: &str,
60    path: &Path,
61    prop_path: &[&str],
62) -> Vec<String> {
63    extract_from_source(source, path, |program| {
64        let obj = find_config_object(program)?;
65        get_nested_string_array_from_object(obj, prop_path)
66    })
67    .unwrap_or_default()
68}
69
70/// Extract a single string from a property at a nested path.
71#[must_use]
72pub(crate) fn extract_config_string(
73    source: &str,
74    path: &Path,
75    prop_path: &[&str],
76) -> Option<String> {
77    extract_from_source(source, path, |program| {
78        let obj = find_config_object(program)?;
79        get_nested_string_from_object(obj, prop_path)
80    })
81}
82
83/// Extract a shell command string from a property at a nested path.
84#[must_use]
85pub(crate) fn extract_config_command(
86    source: &str,
87    path: &Path,
88    prop_path: &[&str],
89) -> Option<String> {
90    extract_from_source(source, path, |program| {
91        let obj = find_config_object(program)?;
92        get_nested_command_from_object(obj, prop_path)
93    })
94}
95
96/// Extract string values from top-level properties of the default export or
97/// `module.exports` object.
98#[must_use]
99pub(crate) fn extract_config_property_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
100    extract_from_source(source, path, |program| {
101        let obj = find_config_object(program)?;
102        let mut values = Vec::new();
103        if let Some(prop) = find_property(obj, key) {
104            collect_all_string_values(&prop.value, &mut values);
105        }
106        Some(values)
107    })
108    .unwrap_or_default()
109}
110
111/// Extract only top-level string values from a property's array.
112#[must_use]
113pub(crate) fn extract_config_shallow_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
114    extract_from_source(source, path, |program| {
115        let obj = find_config_object(program)?;
116        let prop = find_property(obj, key)?;
117        Some(collect_shallow_string_values(&prop.value))
118    })
119    .unwrap_or_default()
120}
121
122/// Extract top-level string values from a config array, including object entries.
123#[must_use]
124pub(crate) fn extract_config_shallow_strings_or_object_property(
125    source: &str,
126    path: &Path,
127    key: &str,
128    object_property: &str,
129) -> Vec<String> {
130    extract_from_source(source, path, |program| {
131        let obj = find_config_object(program)?;
132        let prop = find_property(obj, key)?;
133        Some(collect_shallow_string_or_object_property_values(
134            &prop.value,
135            object_property,
136        ))
137    })
138    .unwrap_or_default()
139}
140
141/// Extract shallow strings from an array property inside a nested object path.
142#[must_use]
143pub(crate) fn extract_config_nested_shallow_strings(
144    source: &str,
145    path: &Path,
146    outer_path: &[&str],
147    key: &str,
148) -> Vec<String> {
149    extract_from_source(source, path, |program| {
150        let obj = find_config_object(program)?;
151        let nested = get_nested_expression(obj, outer_path)?;
152        if let Expression::ObjectExpression(nested_obj) = nested {
153            let prop = find_property(nested_obj, key)?;
154            Some(collect_shallow_string_values(&prop.value))
155        } else {
156            None
157        }
158    })
159    .unwrap_or_default()
160}
161
162/// Public wrapper for `find_config_object`.
163pub(crate) fn find_config_object_pub<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
164    find_config_object(program)
165}
166
167/// Get a top-level property expression from an object.
168pub(crate) fn property_expr<'a>(
169    obj: &'a ObjectExpression<'a>,
170    key: &str,
171) -> Option<&'a Expression<'a>> {
172    find_property(obj, key).map(|prop| &prop.value)
173}
174
175/// Get a top-level property object from an object.
176pub(crate) fn property_object<'a>(
177    obj: &'a ObjectExpression<'a>,
178    key: &str,
179) -> Option<&'a ObjectExpression<'a>> {
180    property_expr(obj, key).and_then(object_expression)
181}
182
183/// Get a string-like top-level property value from an object.
184pub(crate) fn property_string(obj: &ObjectExpression<'_>, key: &str) -> Option<String> {
185    property_expr(obj, key).and_then(expression_to_string)
186}
187
188/// Convert an expression to an object expression when it is statically recoverable.
189pub(crate) fn object_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ObjectExpression<'a>> {
190    match expr {
191        Expression::ObjectExpression(obj) => Some(obj),
192        Expression::ParenthesizedExpression(paren) => object_expression(&paren.expression),
193        Expression::TSSatisfiesExpression(ts_sat) => object_expression(&ts_sat.expression),
194        Expression::TSAsExpression(ts_as) => object_expression(&ts_as.expression),
195        _ => None,
196    }
197}
198
199/// Convert an expression to an array expression when it is statically recoverable.
200pub(crate) fn array_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
201    match expr {
202        Expression::ArrayExpression(arr) => Some(arr),
203        Expression::ParenthesizedExpression(paren) => array_expression(&paren.expression),
204        Expression::TSSatisfiesExpression(ts_sat) => array_expression(&ts_sat.expression),
205        Expression::TSAsExpression(ts_as) => array_expression(&ts_as.expression),
206        _ => None,
207    }
208}
209
210/// Convert a config path string to a `PathBuf` with platform-independent
211/// separator handling.
212pub(crate) fn path_from_config_string(raw: &str) -> PathBuf {
213    PathBuf::from(raw.replace('\\', "/"))
214}
215
216/// Convert a config path to the forward-slash string form used in plugin output.
217pub(crate) fn path_to_config_string(path: &Path) -> String {
218    path.to_string_lossy().replace('\\', "/")
219}
220
221/// Convert a path-like expression to a statically recoverable path.
222pub(crate) fn expression_to_path(expr: &Expression<'_>) -> Option<PathBuf> {
223    expression_to_path_string(expr).map(|path| path_from_config_string(&path))
224}
225
226/// Convert a path-like expression to zero or more statically recoverable paths.
227pub(crate) fn expression_to_path_values(expr: &Expression<'_>) -> Vec<PathBuf> {
228    match expr {
229        Expression::ArrayExpression(arr) => arr
230            .elements
231            .iter()
232            .filter_map(|element| element.as_expression().and_then(expression_to_path))
233            .collect(),
234        _ => expression_to_path(expr).into_iter().collect(),
235    }
236}
237
238/// True when an expression explicitly disables a config section.
239pub(crate) fn is_disabled_expression(expr: &Expression<'_>) -> bool {
240    matches!(expr, Expression::BooleanLiteral(boolean) if !boolean.value)
241        || matches!(expr, Expression::NullLiteral(_))
242}
243
244/// True when a nested config property is a static `true` boolean or object value.
245#[must_use]
246pub(crate) fn extract_config_truthy_bool_or_object(
247    source: &str,
248    path: &Path,
249    prop_path: &[&str],
250) -> bool {
251    extract_from_source(source, path, |program| {
252        let obj = find_config_object(program)?;
253        let expr = get_nested_expression(obj, prop_path)?;
254        Some(is_truthy_bool_or_object(expr))
255    })
256    .unwrap_or(false)
257}
258
259fn is_truthy_bool_or_object(expr: &Expression<'_>) -> bool {
260    match expr {
261        Expression::BooleanLiteral(boolean) => boolean.value,
262        Expression::ObjectExpression(_) => true,
263        Expression::ParenthesizedExpression(paren) => is_truthy_bool_or_object(&paren.expression),
264        Expression::TSSatisfiesExpression(ts_sat) => is_truthy_bool_or_object(&ts_sat.expression),
265        Expression::TSAsExpression(ts_as) => is_truthy_bool_or_object(&ts_as.expression),
266        _ => false,
267    }
268}
269
270/// Extract keys of an object property at a nested path.
271#[must_use]
272pub(crate) fn extract_config_object_keys(
273    source: &str,
274    path: &Path,
275    prop_path: &[&str],
276) -> Vec<String> {
277    extract_from_source(source, path, |program| {
278        let obj = find_config_object(program)?;
279        get_nested_object_keys(obj, prop_path)
280    })
281    .unwrap_or_default()
282}
283
284/// Extract a value that may be a single string, string array, or object with
285/// string/array values.
286#[must_use]
287pub(crate) fn extract_config_string_or_array(
288    source: &str,
289    path: &Path,
290    prop_path: &[&str],
291) -> Vec<String> {
292    extract_from_source(source, path, |program| {
293        let obj = find_config_object(program)?;
294        get_nested_string_or_array(obj, prop_path)
295    })
296    .unwrap_or_default()
297}
298
299/// Extract a statically recoverable path-like value from a property path.
300#[must_use]
301pub(crate) fn extract_config_path(
302    source: &str,
303    path: &Path,
304    prop_path: &[&str],
305) -> Option<PathBuf> {
306    extract_from_source(source, path, |program| {
307        let obj = find_config_object(program)?;
308        let expr = get_nested_expression(obj, prop_path)?;
309        expression_to_path(expr)
310    })
311}
312
313/// Extract string values from a property path, also searching inside array elements.
314#[must_use]
315pub(crate) fn extract_config_array_nested_string_or_array(
316    source: &str,
317    path: &Path,
318    array_path: &[&str],
319    inner_path: &[&str],
320) -> Vec<String> {
321    extract_from_source(source, path, |program| {
322        let obj = find_config_object(program)?;
323        let array_expr = get_nested_expression(obj, array_path)?;
324        let Expression::ArrayExpression(arr) = array_expr else {
325            return None;
326        };
327        let mut results = Vec::new();
328        for element in &arr.elements {
329            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
330                && let Some(values) = get_nested_string_or_array(element_obj, inner_path)
331            {
332                results.extend(values);
333            }
334        }
335        if results.is_empty() {
336            None
337        } else {
338            Some(results)
339        }
340    })
341    .unwrap_or_default()
342}
343
344/// Extract string values from a property path, searching inside all values of an object.
345#[must_use]
346pub(crate) fn extract_config_object_nested_string_or_array(
347    source: &str,
348    path: &Path,
349    object_path: &[&str],
350    inner_path: &[&str],
351) -> Vec<String> {
352    extract_config_object_nested(source, path, object_path, |value_obj| {
353        get_nested_string_or_array(value_obj, inner_path)
354    })
355}
356
357/// Extract a single string value from each object under a property path.
358#[must_use]
359pub(crate) fn extract_config_object_nested_strings(
360    source: &str,
361    path: &Path,
362    object_path: &[&str],
363    inner_path: &[&str],
364) -> Vec<String> {
365    extract_config_object_nested(source, path, object_path, |value_obj| {
366        get_nested_string_from_object(value_obj, inner_path).map(|s| vec![s])
367    })
368}
369
370/// Shared helper for object-nested extraction.
371fn extract_config_object_nested(
372    source: &str,
373    path: &Path,
374    object_path: &[&str],
375    extract_fn: impl Fn(&ObjectExpression<'_>) -> Option<Vec<String>>,
376) -> Vec<String> {
377    extract_from_source(source, path, |program| {
378        let obj = find_config_object(program)?;
379        let obj_expr = get_nested_expression(obj, object_path)?;
380        let Expression::ObjectExpression(target_obj) = obj_expr else {
381            return None;
382        };
383        let mut results = Vec::new();
384        for prop in &target_obj.properties {
385            if let ObjectPropertyKind::ObjectProperty(p) = prop
386                && let Expression::ObjectExpression(value_obj) = &p.value
387                && let Some(values) = extract_fn(value_obj)
388            {
389                results.extend(values);
390            }
391        }
392        if results.is_empty() {
393            None
394        } else {
395            Some(results)
396        }
397    })
398    .unwrap_or_default()
399}
400
401/// Extract `require('...')` call argument strings from a property's value.
402#[must_use]
403pub(crate) fn extract_config_require_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
404    extract_from_source(source, path, |program| {
405        let obj = find_config_object(program)?;
406        let prop = find_property(obj, key)?;
407        Some(collect_require_sources(&prop.value))
408    })
409    .unwrap_or_default()
410}
411
412/// Extract alias mappings from an object or array-based alias config.
413#[must_use]
414pub(crate) fn extract_config_aliases(
415    source: &str,
416    path: &Path,
417    prop_path: &[&str],
418) -> Vec<(String, String)> {
419    extract_config_aliases_kinded(source, path, prop_path)
420        .into_iter()
421        .map(|(find, replacement, _is_bare)| (find, replacement))
422        .collect()
423}
424
425/// Extract alias mappings where the replacement is a filesystem path value.
426#[must_use]
427pub(crate) fn extract_config_path_aliases(
428    source: &str,
429    path: &Path,
430    prop_path: &[&str],
431) -> Vec<(String, PathBuf)> {
432    extract_config_aliases_kinded(source, path, prop_path)
433        .into_iter()
434        .map(|(find, replacement, _is_bare)| (find, path_from_config_string(&replacement)))
435        .collect()
436}
437
438/// Extract alias mappings nested inside an array of config objects.
439#[must_use]
440pub fn extract_config_array_nested_aliases(
441    source: &str,
442    path: &Path,
443    array_path: &[&str],
444    alias_path: &[&str],
445) -> Vec<(String, String)> {
446    extract_from_source(source, path, |program| {
447        let obj = find_config_object(program)?;
448        let array_expr = get_nested_expression(obj, array_path)?;
449        let Expression::ArrayExpression(arr) = array_expr else {
450            return None;
451        };
452        let mut results = Vec::new();
453        for element in &arr.elements {
454            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
455                && let Some(alias_expr) = get_nested_expression(element_obj, alias_path)
456            {
457                results.extend(expression_to_alias_pairs(alias_expr));
458            }
459        }
460        (!results.is_empty()).then_some(results)
461    })
462    .unwrap_or_default()
463}
464
465/// Like [`extract_config_aliases`] but each tuple carries a bare-string flag.
466#[must_use]
467pub(crate) fn extract_config_aliases_kinded(
468    source: &str,
469    path: &Path,
470    prop_path: &[&str],
471) -> Vec<(String, String, bool)> {
472    extract_from_source(source, path, |program| {
473        let obj = find_config_object(program)?;
474        let expr = get_nested_expression(obj, prop_path)?;
475        let mut visited = FxHashSet::default();
476        let aliases = resolve_alias_pairs_kinded(program, path, expr, &mut visited, 0);
477        (!aliases.is_empty()).then_some(aliases)
478    })
479    .unwrap_or_default()
480}
481
482/// Kinded variant of [`extract_config_array_nested_aliases`].
483#[must_use]
484pub(crate) fn extract_config_array_nested_aliases_kinded(
485    source: &str,
486    path: &Path,
487    array_path: &[&str],
488    alias_path: &[&str],
489) -> Vec<(String, String, bool)> {
490    extract_from_source(source, path, |program| {
491        let obj = find_config_object(program)?;
492        let array_expr = get_nested_expression(obj, array_path)?;
493        let Expression::ArrayExpression(arr) = array_expr else {
494            return None;
495        };
496        let mut results = Vec::new();
497        for element in &arr.elements {
498            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
499                && let Some(alias_expr) = get_nested_expression(element_obj, alias_path)
500            {
501                results.extend(expression_to_alias_pairs_kinded(alias_expr));
502            }
503        }
504        (!results.is_empty()).then_some(results)
505    })
506    .unwrap_or_default()
507}
508
509/// Extract kinded aliases from a default-exported ARRAY config.
510#[must_use]
511pub(crate) fn extract_default_export_array_aliases_kinded(
512    source: &str,
513    path: &Path,
514    alias_path: &[&str],
515) -> Vec<(String, String, bool)> {
516    extract_from_source(source, path, |program| {
517        let arr = find_default_export_array(program)?;
518        let mut results = Vec::new();
519        for element in &arr.elements {
520            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
521                && let Some(alias_expr) = get_nested_expression(element_obj, alias_path)
522            {
523                results.extend(expression_to_alias_pairs_kinded(alias_expr));
524            }
525        }
526        (!results.is_empty()).then_some(results)
527    })
528    .unwrap_or_default()
529}
530
531/// True when a parsed config has neither an object nor array default export.
532#[must_use]
533pub(crate) fn config_default_export_unreachable(source: &str, path: &Path) -> bool {
534    extract_from_source(source, path, |program| {
535        let reachable =
536            find_config_object(program).is_some() || find_default_export_array(program).is_some();
537        Some(reachable)
538    })
539    .is_some_and(|reachable| !reachable)
540}
541
542/// Extract string values from a nested array, supporting both string elements and
543/// object elements with a named string/path field.
544///
545/// Useful for configs like:
546/// - `components: ["~/components", { path: "~/feature-components" }]`
547#[must_use]
548pub(crate) fn extract_config_array_object_strings(
549    source: &str,
550    path: &Path,
551    array_path: &[&str],
552    key: &str,
553) -> Vec<String> {
554    extract_from_source(source, path, |program| {
555        let obj = find_config_object(program)?;
556        let array_expr = get_nested_expression(obj, array_path)?;
557        let Expression::ArrayExpression(arr) = array_expr else {
558            return None;
559        };
560
561        let mut results = Vec::new();
562        for element in &arr.elements {
563            let Some(expr) = element.as_expression() else {
564                continue;
565            };
566            match expr {
567                Expression::ObjectExpression(item) => {
568                    if let Some(prop) = find_property(item, key)
569                        && let Some(value) = expression_to_path_string(&prop.value)
570                    {
571                        results.push(value);
572                    }
573                }
574                _ => {
575                    if let Some(value) = expression_to_path_string(expr) {
576                        results.push(value);
577                    }
578                }
579            }
580        }
581
582        (!results.is_empty()).then_some(results)
583    })
584    .unwrap_or_default()
585}
586
587/// Extract Storybook-style static directory entries from an array.
588///
589/// Supports string entries and object entries with a string-like `from` plus
590/// optional string-like `to`.
591#[must_use]
592pub(crate) fn extract_config_static_dir_entries(
593    source: &str,
594    path: &Path,
595    array_path: &[&str],
596) -> Vec<(String, Option<String>)> {
597    extract_from_source(source, path, |program| {
598        let obj = find_config_object(program)?;
599        let array_expr = get_nested_expression(obj, array_path)?;
600        let Expression::ArrayExpression(arr) = array_expr else {
601            return None;
602        };
603
604        let mut results = Vec::new();
605        for element in &arr.elements {
606            let Some(expr) = element.as_expression() else {
607                continue;
608            };
609            match expr {
610                Expression::ObjectExpression(item) => {
611                    if let Some(from) = property_string(item, "from") {
612                        let to = property_string(item, "to");
613                        results.push((from, to));
614                    }
615                }
616                _ => {
617                    if let Some(from) = expression_to_path_string(expr) {
618                        results.push((from, None));
619                    }
620                }
621            }
622        }
623
624        (!results.is_empty()).then_some(results)
625    })
626    .unwrap_or_default()
627}
628
629/// Extract paired `(primary, optional secondary)` string values from each object
630/// element of an array at `array_path`.
631///
632/// Mirrors `extract_config_array_object_strings` but keeps a per-element
633/// secondary value alongside the primary one, so correlated fields stay paired.
634/// An element is included only when its `primary_key` resolves to a recoverable
635/// path string; the `secondary_key` is `None` when absent or non-recoverable.
636///
637/// Used for Playwright's `webServer: [{ command, cwd }]` form where each
638/// `command` must be resolved relative to its own `cwd`.
639#[must_use]
640pub fn extract_config_array_object_string_pairs(
641    source: &str,
642    path: &Path,
643    array_path: &[&str],
644    primary_key: &str,
645    secondary_key: &str,
646) -> Vec<(String, Option<String>)> {
647    extract_from_source(source, path, |program| {
648        let obj = find_config_object(program)?;
649        let array_expr = get_nested_expression(obj, array_path)?;
650        let Expression::ArrayExpression(arr) = array_expr else {
651            return None;
652        };
653
654        let mut results = Vec::new();
655        for element in &arr.elements {
656            let Some(Expression::ObjectExpression(item)) = element.as_expression() else {
657                continue;
658            };
659            let Some(primary) = find_property(item, primary_key)
660                .and_then(|prop| expression_to_path_string(&prop.value))
661            else {
662                continue;
663            };
664            let secondary = find_property(item, secondary_key)
665                .and_then(|prop| expression_to_path_string(&prop.value));
666            results.push((primary, secondary));
667        }
668
669        (!results.is_empty()).then_some(results)
670    })
671    .unwrap_or_default()
672}
673
674/// Extract paired shell command and string values from each object element of an array.
675#[must_use]
676pub(crate) fn extract_config_array_object_command_pairs(
677    source: &str,
678    path: &Path,
679    array_path: &[&str],
680    primary_key: &str,
681    secondary_key: &str,
682) -> Vec<(String, Option<String>)> {
683    extract_from_source(source, path, |program| {
684        let obj = find_config_object(program)?;
685        let array_expr = get_nested_expression(obj, array_path)?;
686        let Expression::ArrayExpression(arr) = array_expr else {
687            return None;
688        };
689
690        let mut results = Vec::new();
691        for element in &arr.elements {
692            let Some(Expression::ObjectExpression(item)) = element.as_expression() else {
693                continue;
694            };
695            let Some(primary) = find_property(item, primary_key)
696                .and_then(|prop| expression_to_command(&prop.value))
697            else {
698                continue;
699            };
700            let secondary = find_property(item, secondary_key)
701                .and_then(|prop| expression_to_path_string(&prop.value));
702            results.push((primary, secondary));
703        }
704
705        (!results.is_empty()).then_some(results)
706    })
707    .unwrap_or_default()
708}
709
710/// Extract static specifiers from thunk-wrapped dynamic imports inside an
711/// array property.
712///
713/// Captures the `SPEC` argument from each `() => import('SPEC')` element of
714/// an array nested under `prop_path` in the config's default-exported object.
715///
716/// # The pattern
717///
718/// Configs and registries that need to defer module evaluation commonly hold
719/// arrays of *thunks* — zero-argument arrow functions whose body is a single
720/// dynamic import:
721///
722/// ```ts
723/// export default defineConfig({
724///     modules: [
725///         () => import('./feature-a'),
726///         { file: () => import('./feature-b'), enabled: true },
727///     ],
728/// })
729/// ```
730///
731/// `import('SPEC')` is the ECMAScript dynamic-import expression (TC39
732/// dynamic-import proposal, shipped in ES2020): a runtime module loader call
733/// that returns a `Promise<Module>`. Wrapping it in `() => import('SPEC')`
734/// turns "load module X now" into "value that, when invoked, loads module X"
735/// — a thunk the host can call lazily.
736///
737/// The technique predates any single framework. It's the same shape used by
738/// route-level code-splitting (`Vue Router`, `React Router`, `Next.js`),
739/// `React.lazy`, Webpack's documented dynamic-import code-splitting recipes,
740/// and any registry that wants to keep boot cheap, break import cycles, or
741/// let bundlers tree-shake unused branches. Configs that adopt the pattern
742/// can therefore declare large module graphs without forcing eager
743/// evaluation of every entry at config parse time.
744///
745/// # Recognised array element shapes
746///
747/// - Concise arrow: `() => import('SPEC')`
748/// - Block-body arrow with explicit return: `() => { return import('SPEC') }`
749/// - Object form with a `file` property holding the arrow:
750///   `{ file: () => import('SPEC'), /* peer fields */ }`
751///
752/// Non-matching elements (string literals, variables, template-string
753/// specifiers, computed expressions) are silently skipped: callers receive
754/// only the statically-resolvable specifiers, in source order.
755#[must_use]
756pub(crate) fn extract_lazy_imports_in_array(
757    source: &str,
758    path: &Path,
759    prop_path: &[&str],
760) -> Vec<String> {
761    extract_from_source(source, path, |program| {
762        let obj = find_config_object(program)?;
763        let array_expr = get_nested_expression(obj, prop_path)?;
764        let Expression::ArrayExpression(arr) = array_expr else {
765            return None;
766        };
767        let mut specs = Vec::new();
768        for element in &arr.elements {
769            let Some(expr) = element.as_expression() else {
770                continue;
771            };
772            if let Some(spec) = lazy_import_specifier(expr) {
773                specs.push(spec);
774            }
775        }
776        (!specs.is_empty()).then_some(specs)
777    })
778    .unwrap_or_default()
779}
780
781/// Read a lazy-import specifier from a single array element expression.
782///
783/// Two outer shapes are accepted at this level (array-element navigation):
784/// - A bare callable: `() => import('SPEC')` or the function-expression
785///   equivalent.
786/// - An object with a `file` property holding the callable:
787///   `{ file: () => import('SPEC'), /* peer fields */ }`.
788///
789/// The actual callable → import peeling is delegated to
790/// [`extract_import_from_callable`], which is shared with the visitor-side
791/// dynamic-import helpers so all three navigation pipelines stay in lockstep
792/// when ECMAScript adds new wrapper shapes.
793fn lazy_import_specifier(expr: &Expression<'_>) -> Option<String> {
794    let callable = match expr {
795        Expression::ObjectExpression(obj) => &find_property(obj, "file")?.value,
796        _ => expr,
797    };
798    let import_expr = extract_import_from_callable(callable)?;
799    expression_to_string(&import_expr.source)
800}
801
802/// Extract a string-like option from a plugin tuple inside a config plugin array.
803///
804/// Supports config shapes like:
805/// - `{ expo: { plugins: [["expo-router", { root: "src/app" }]] } }`
806/// - `export default { expo: { plugins: [["expo-router", { root: "./src/app" }]] } }`
807/// - `{ plugins: [["expo-router", { root: "./src/routes" }]] }`
808#[must_use]
809fn extract_config_plugin_option_string(
810    source: &str,
811    path: &Path,
812    plugins_path: &[&str],
813    plugin_name: &str,
814    option_key: &str,
815) -> Option<String> {
816    extract_from_source(source, path, |program| {
817        let obj = find_config_object(program)?;
818        let plugins_expr = get_nested_expression(obj, plugins_path)?;
819        let Expression::ArrayExpression(plugins) = plugins_expr else {
820            return None;
821        };
822
823        for entry in &plugins.elements {
824            let Some(Expression::ArrayExpression(tuple)) = entry.as_expression() else {
825                continue;
826            };
827            let Some(plugin_expr) = tuple
828                .elements
829                .first()
830                .and_then(ArrayExpressionElement::as_expression)
831            else {
832                continue;
833            };
834            if expression_to_string(plugin_expr).as_deref() != Some(plugin_name) {
835                continue;
836            }
837
838            let Some(options_expr) = tuple
839                .elements
840                .get(1)
841                .and_then(ArrayExpressionElement::as_expression)
842            else {
843                continue;
844            };
845            let Expression::ObjectExpression(options_obj) = options_expr else {
846                continue;
847            };
848            let option = find_property(options_obj, option_key)?;
849            return expression_to_path_string(&option.value);
850        }
851
852        None
853    })
854}
855
856/// Extract a string-like option from the first plugin array path that contains it.
857#[must_use]
858pub(crate) fn extract_config_plugin_option_string_from_paths(
859    source: &str,
860    path: &Path,
861    plugin_paths: &[&[&str]],
862    plugin_name: &str,
863    option_key: &str,
864) -> Option<String> {
865    plugin_paths.iter().find_map(|plugins_path| {
866        extract_config_plugin_option_string(source, path, plugins_path, plugin_name, option_key)
867    })
868}
869
870/// Extract Babel plugin and preset package names configured through
871/// `@vitejs/plugin-react` options in a Vite-style `plugins` array.
872#[must_use]
873pub(crate) fn extract_vite_react_babel_dependencies(source: &str, path: &Path) -> Vec<String> {
874    extract_from_source(source, path, |program| {
875        let react_plugin_imports = collect_vite_react_plugin_imports(program);
876        if react_plugin_imports.is_empty() {
877            return None;
878        }
879
880        let obj = find_config_object(program)?;
881        let plugins = get_nested_expression(obj, &["plugins"])?;
882        let Expression::ArrayExpression(plugin_array) = plugins else {
883            return None;
884        };
885
886        let mut deps = Vec::new();
887        for element in &plugin_array.elements {
888            let Some(Expression::CallExpression(call)) = element.as_expression() else {
889                continue;
890            };
891            if !is_vite_react_plugin_call(call, &react_plugin_imports) {
892                continue;
893            }
894            let Some(Expression::ObjectExpression(options)) =
895                call.arguments.first().and_then(Argument::as_expression)
896            else {
897                continue;
898            };
899            collect_vite_react_babel_dependencies(options, &mut deps);
900        }
901
902        (!deps.is_empty()).then_some(deps)
903    })
904    .unwrap_or_default()
905}
906
907/// Normalize a config-relative path to a project-root-relative path.
908///
909/// Handles values extracted from config files such as `"./src"`, `"src/lib"`,
910/// `"/src"`, or absolute filesystem paths under `root`.
911#[must_use]
912pub(crate) fn normalize_config_path_buf(
913    raw: impl AsRef<Path>,
914    config_path: &Path,
915    root: &Path,
916) -> Option<PathBuf> {
917    let raw = raw.as_ref();
918    if raw.as_os_str().is_empty() {
919        return None;
920    }
921
922    let raw_string = path_to_config_string(raw);
923    let raw_path = Path::new(&raw_string);
924    let candidate = if let Some(stripped) = raw_string.strip_prefix('/') {
925        lexical_normalize(&root.join(stripped))
926    } else if raw_path.is_absolute() {
927        lexical_normalize(raw_path)
928    } else {
929        let base = config_path.parent().unwrap_or(root);
930        lexical_normalize(&base.join(raw_path))
931    };
932
933    let relative = candidate.strip_prefix(root).ok()?;
934    (!relative.as_os_str().is_empty()).then(|| relative.to_path_buf())
935}
936
937/// Normalize a config-relative path to a project-root-relative forward-slash string.
938#[must_use]
939pub(crate) fn normalize_config_path(
940    raw: impl AsRef<Path>,
941    config_path: &Path,
942    root: &Path,
943) -> Option<String> {
944    normalize_config_path_buf(raw, config_path, root).map(|path| path_to_config_string(&path))
945}
946
947/// Parse source and run an extraction function on the AST.
948///
949/// JSON files (`.json`, `.jsonc`) are parsed as JavaScript expressions wrapped in
950/// parentheses to produce an AST compatible with `find_config_object`. The native
951/// JSON source type in Oxc produces a different AST structure that our helpers
952/// don't handle.
953pub(crate) fn extract_from_source<T>(
954    source: &str,
955    path: &Path,
956    extractor: impl FnOnce(&Program) -> Option<T>,
957) -> Option<T> {
958    let source_type = SourceType::from_path(path).unwrap_or_default();
959    let alloc = Allocator::default();
960
961    let is_json = path
962        .extension()
963        .is_some_and(|ext| ext == "json" || ext == "jsonc");
964    if is_json {
965        let wrapped = format!("({source})");
966        let parsed = Parser::new(&alloc, &wrapped, SourceType::mjs()).parse();
967        return extractor(&parsed.program);
968    }
969
970    let parsed = Parser::new(&alloc, source, source_type).parse();
971    extractor(&parsed.program)
972}
973
974#[derive(Default)]
975struct ViteReactPluginImports {
976    callables: Vec<String>,
977    namespaces: Vec<String>,
978}
979
980impl ViteReactPluginImports {
981    fn is_empty(&self) -> bool {
982        self.callables.is_empty() && self.namespaces.is_empty()
983    }
984}
985
986fn collect_vite_react_plugin_imports(program: &Program<'_>) -> ViteReactPluginImports {
987    let mut imports = ViteReactPluginImports::default();
988
989    for stmt in &program.body {
990        let Statement::ImportDeclaration(decl) = stmt else {
991            continue;
992        };
993        if decl.source.value != "@vitejs/plugin-react" {
994            continue;
995        }
996        let Some(specifiers) = &decl.specifiers else {
997            continue;
998        };
999        for specifier in specifiers {
1000            match specifier {
1001                ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => {
1002                    push_unique_string(&mut imports.callables, specifier.local.name.to_string());
1003                }
1004                ImportDeclarationSpecifier::ImportSpecifier(specifier)
1005                    if specifier.imported.name().as_ref() == "default" =>
1006                {
1007                    push_unique_string(&mut imports.callables, specifier.local.name.to_string());
1008                }
1009                ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
1010                    push_unique_string(&mut imports.namespaces, specifier.local.name.to_string());
1011                }
1012                ImportDeclarationSpecifier::ImportSpecifier(_) => {}
1013            }
1014        }
1015    }
1016
1017    imports
1018}
1019
1020fn is_vite_react_plugin_call(call: &CallExpression<'_>, imports: &ViteReactPluginImports) -> bool {
1021    match &call.callee {
1022        Expression::Identifier(identifier) => imports
1023            .callables
1024            .iter()
1025            .any(|name| name == identifier.name.as_str()),
1026        Expression::StaticMemberExpression(member) if matches!(&member.object, Expression::Identifier(object) if imports.namespaces.iter().any(|name| name == object.name.as_str())) => {
1027            member.property.name == "default"
1028        }
1029        _ => false,
1030    }
1031}
1032
1033fn collect_vite_react_babel_dependencies(options: &ObjectExpression<'_>, deps: &mut Vec<String>) {
1034    let Some(babel) = property_object(options, "babel") else {
1035        return;
1036    };
1037    for key in ["plugins", "presets"] {
1038        let Some(prop) = find_property(babel, key) else {
1039            continue;
1040        };
1041        for raw in collect_shallow_string_values(&prop.value) {
1042            if let Some(dep) = vite_react_babel_dependency_name(&raw) {
1043                push_unique_string(deps, dep);
1044            }
1045        }
1046    }
1047}
1048
1049fn vite_react_babel_dependency_name(raw: &str) -> Option<String> {
1050    let raw = raw.trim();
1051    let specifier = raw.strip_prefix("module:").unwrap_or(raw).trim();
1052    if specifier.is_empty()
1053        || specifier.starts_with('.')
1054        || specifier.starts_with('/')
1055        || specifier.contains(':')
1056        || specifier.contains('\\')
1057    {
1058        return None;
1059    }
1060    Some(crate::resolve::extract_package_name(specifier))
1061}
1062
1063fn push_unique_string(items: &mut Vec<String>, value: String) {
1064    if !items.contains(&value) {
1065        items.push(value);
1066    }
1067}
1068
1069/// Find the "config object": the object expression in the default export or module.exports.
1070///
1071/// Handles these patterns:
1072/// - `export default { ... }`
1073/// - `export default defineConfig({ ... })`
1074/// - `export default defineConfig(async () => ({ ... }))`
1075/// - `export default { ... } satisfies Config` / `export default { ... } as Config`
1076/// - `const config = { ... }; export default config;`
1077/// - `const config: Config = { ... }; export default config;`
1078/// - `module.exports = { ... }`
1079/// - Top-level JSON object (for .json files)
1080fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
1081    for stmt in &program.body {
1082        match stmt {
1083            Statement::ExportDefaultDeclaration(decl) => {
1084                let expr: Option<&Expression> = match &decl.declaration {
1085                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
1086                        return Some(obj);
1087                    }
1088                    ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
1089                        return extract_object_from_function(func);
1090                    }
1091                    _ => decl.declaration.as_expression(),
1092                };
1093                if let Some(expr) = expr {
1094                    if let Some(obj) =
1095                        resolve_call_config_object(program, expr, MAX_CONFIG_WRAPPER_DEPTH)
1096                    {
1097                        return Some(obj);
1098                    }
1099                    if let Some(obj) = extract_object_from_expression(expr) {
1100                        return Some(obj);
1101                    }
1102                    if let Some(name) = unwrap_to_identifier_name(expr) {
1103                        return find_variable_init_object(program, name);
1104                    }
1105                    if let Some(obj) = resolve_wrapped_config_object(program, expr) {
1106                        return Some(obj);
1107                    }
1108                }
1109            }
1110            Statement::ExpressionStatement(expr_stmt) => {
1111                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
1112                    && is_module_exports_target(&assign.left)
1113                {
1114                    if let Some(obj) =
1115                        resolve_call_config_object(program, &assign.right, MAX_CONFIG_WRAPPER_DEPTH)
1116                    {
1117                        return Some(obj);
1118                    }
1119                    if let Some(obj) = extract_object_from_expression(&assign.right) {
1120                        return Some(obj);
1121                    }
1122                    if let Some(name) = unwrap_to_identifier_name(&assign.right) {
1123                        return find_variable_init_object(program, name);
1124                    }
1125                    return resolve_wrapped_config_object(program, &assign.right);
1126                }
1127            }
1128            _ => {}
1129        }
1130    }
1131
1132    if program.body.len() == 1
1133        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
1134    {
1135        match &expr_stmt.expression {
1136            Expression::ObjectExpression(obj) => return Some(obj),
1137            Expression::ParenthesizedExpression(paren) => {
1138                if let Expression::ObjectExpression(obj) = &paren.expression {
1139                    return Some(obj);
1140                }
1141            }
1142            _ => {}
1143        }
1144    }
1145
1146    None
1147}
1148
1149/// Extract an `ObjectExpression` from an expression, handling wrapper patterns.
1150fn extract_object_from_expression<'a>(
1151    expr: &'a Expression<'a>,
1152) -> Option<&'a ObjectExpression<'a>> {
1153    match expr {
1154        Expression::ObjectExpression(obj) => Some(obj),
1155        Expression::CallExpression(call) => {
1156            for arg in &call.arguments {
1157                match arg {
1158                    Argument::ObjectExpression(obj) => return Some(obj),
1159                    // Both arrow forms reach here: the concise
1160                    // `defineConfig(() => ({ ... }))` and the block body
1161                    // `defineConfig(({ mode }) => { ...; return { ... }; })`.
1162                    // The block form is the common shape for configs that
1163                    // branch on the mode, and handling only the concise one
1164                    // made every such config invisible to extraction.
1165                    Argument::ArrowFunctionExpression(arrow) => {
1166                        if let Some(obj) = extract_object_from_arrow_function(arrow) {
1167                            return Some(obj);
1168                        }
1169                    }
1170                    Argument::FunctionExpression(func) => {
1171                        if let Some(obj) = extract_object_from_function(func) {
1172                            return Some(obj);
1173                        }
1174                    }
1175                    _ => {}
1176                }
1177            }
1178            None
1179        }
1180        Expression::ParenthesizedExpression(paren) => {
1181            extract_object_from_expression(&paren.expression)
1182        }
1183        Expression::TSSatisfiesExpression(ts_sat) => {
1184            extract_object_from_expression(&ts_sat.expression)
1185        }
1186        Expression::TSAsExpression(ts_as) => extract_object_from_expression(&ts_as.expression),
1187        Expression::ArrowFunctionExpression(arrow) => extract_object_from_arrow_function(arrow),
1188        Expression::FunctionExpression(func) => extract_object_from_function(func),
1189        _ => None,
1190    }
1191}
1192
1193fn extract_object_from_arrow_function<'a>(
1194    arrow: &'a ArrowFunctionExpression<'a>,
1195) -> Option<&'a ObjectExpression<'a>> {
1196    if arrow.expression {
1197        arrow.body.statements.first().and_then(|stmt| {
1198            if let Statement::ExpressionStatement(expr_stmt) = stmt {
1199                extract_object_from_expression(&expr_stmt.expression)
1200            } else {
1201                None
1202            }
1203        })
1204    } else {
1205        extract_object_from_function_body(&arrow.body)
1206    }
1207}
1208
1209fn extract_object_from_function<'a>(func: &'a Function<'a>) -> Option<&'a ObjectExpression<'a>> {
1210    func.body
1211        .as_ref()
1212        .and_then(|body| extract_object_from_function_body(body))
1213}
1214
1215/// Resolve the object a config callback returns.
1216///
1217/// A return at the body's own level is the callback's main config and always
1218/// wins, which keeps the overwhelmingly common `guard clause; return { ... }`
1219/// shape resolving to the real config rather than to the guard's early return.
1220/// Only a body with no top-level return falls back to searching branches, which
1221/// is the shape Vite documents for switching config by command:
1222/// `if (command === "serve") { return { ... } } else { return { ... } }`.
1223fn extract_object_from_function_body<'a>(
1224    body: &'a FunctionBody<'a>,
1225) -> Option<&'a ObjectExpression<'a>> {
1226    find_top_level_returned_object(&body.statements)
1227        .or_else(|| find_returned_object(&body.statements, MAX_CONFIG_BRANCH_DEPTH))
1228}
1229
1230/// Maximum control-flow nesting searched for a config `return`.
1231const MAX_CONFIG_BRANCH_DEPTH: u8 = 4;
1232
1233/// Find the first object returned by a statement at this exact level.
1234fn find_top_level_returned_object<'a>(
1235    statements: &'a [Statement<'a>],
1236) -> Option<&'a ObjectExpression<'a>> {
1237    statements.iter().find_map(|stmt| match stmt {
1238        Statement::ReturnStatement(ret) => ret
1239            .argument
1240            .as_ref()
1241            .and_then(|argument| extract_object_from_expression(argument)),
1242        _ => None,
1243    })
1244}
1245
1246/// Find the first returned object literal in `statements`, descending into
1247/// control flow.
1248///
1249/// Nested function and arrow bodies are deliberately not searched: their returns
1250/// belong to the inner function, not to the config callback.
1251///
1252/// The first return in source order wins. A config whose branches declare
1253/// different values therefore contributes only the first branch's.
1254fn find_returned_object<'a>(
1255    statements: &'a [Statement<'a>],
1256    depth: u8,
1257) -> Option<&'a ObjectExpression<'a>> {
1258    if depth == 0 {
1259        return None;
1260    }
1261    for stmt in statements {
1262        let found = match stmt {
1263            Statement::ReturnStatement(ret) => ret
1264                .argument
1265                .as_ref()
1266                .and_then(|argument| extract_object_from_expression(argument)),
1267            Statement::BlockStatement(block) => find_returned_object(&block.body, depth - 1),
1268            Statement::IfStatement(if_stmt) => {
1269                find_returned_object_in_statement(&if_stmt.consequent, depth - 1).or_else(|| {
1270                    if_stmt
1271                        .alternate
1272                        .as_ref()
1273                        .and_then(|alt| find_returned_object_in_statement(alt, depth - 1))
1274                })
1275            }
1276            Statement::TryStatement(try_stmt) => {
1277                find_returned_object(&try_stmt.block.body, depth - 1)
1278                    .or_else(|| {
1279                        try_stmt
1280                            .handler
1281                            .as_ref()
1282                            .and_then(|handler| find_returned_object(&handler.body.body, depth - 1))
1283                    })
1284                    .or_else(|| {
1285                        try_stmt
1286                            .finalizer
1287                            .as_ref()
1288                            .and_then(|finalizer| find_returned_object(&finalizer.body, depth - 1))
1289                    })
1290            }
1291            Statement::SwitchStatement(switch) => switch
1292                .cases
1293                .iter()
1294                .find_map(|case| find_returned_object(&case.consequent, depth - 1)),
1295            _ => None,
1296        };
1297        if found.is_some() {
1298            return found;
1299        }
1300    }
1301    None
1302}
1303
1304fn find_returned_object_in_statement<'a>(
1305    stmt: &'a Statement<'a>,
1306    depth: u8,
1307) -> Option<&'a ObjectExpression<'a>> {
1308    match stmt {
1309        Statement::BlockStatement(block) => find_returned_object(&block.body, depth),
1310        other => find_returned_object(std::slice::from_ref(other), depth),
1311    }
1312}
1313
1314/// Check if an assignment target is `module.exports`.
1315fn is_module_exports_target(target: &AssignmentTarget) -> bool {
1316    if let AssignmentTarget::StaticMemberExpression(member) = target
1317        && let Expression::Identifier(obj) = &member.object
1318    {
1319        return obj.name == "module" && member.property.name == "exports";
1320    }
1321    false
1322}
1323
1324/// Unwrap TS annotations and return the identifier name if the expression resolves to one.
1325///
1326/// Handles `config`, `config satisfies Type`, `config as Type`.
1327fn unwrap_to_identifier_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
1328    match expr {
1329        Expression::Identifier(id) => Some(&id.name),
1330        Expression::TSSatisfiesExpression(ts_sat) => unwrap_to_identifier_name(&ts_sat.expression),
1331        Expression::TSAsExpression(ts_as) => unwrap_to_identifier_name(&ts_as.expression),
1332        _ => None,
1333    }
1334}
1335
1336/// Find a top-level variable declaration by name and extract its init as an object expression.
1337///
1338/// Handles `const config = { ... }`, `const config: Type = { ... }`,
1339/// and `const config = defineConfig({ ... })`.
1340fn find_variable_init_object<'a>(
1341    program: &'a Program,
1342    name: &str,
1343) -> Option<&'a ObjectExpression<'a>> {
1344    for stmt in &program.body {
1345        if let Statement::VariableDeclaration(decl) = stmt {
1346            for declarator in &decl.declarations {
1347                if let BindingPattern::BindingIdentifier(id) = &declarator.id
1348                    && id.name == name
1349                    && let Some(init) = &declarator.init
1350                {
1351                    return extract_object_from_expression(init);
1352                }
1353            }
1354        }
1355    }
1356    None
1357}
1358
1359/// Resolve a config object that is passed as a NAMED CONST to a wrapper call:
1360/// `export default withMDX(nextConfig)`, `module.exports = createJestConfig(cfg)`,
1361/// nested `withMDX(withFoo(nextConfig))`, and curried `compose(...)(nextConfig)`.
1362/// This is the call-argument analog of the bare `export default config` identifier
1363/// resolution already done via [`unwrap_to_identifier_name`] +
1364/// [`find_variable_init_object`]; it lets the official `@next/mdx` /
1365/// `withSentry(nextConfig)` / `next-compose-plugins` idioms resolve so their
1366/// `pageExtensions` / plugin config is extracted instead of silently dropped.
1367///
1368/// Returns the first argument (scanning nested wrapper calls) that resolves to a
1369/// local `const NAME = { ... }`. Inline object and callback arguments are handled
1370/// by [`resolve_call_config_object`], which the caller tries first.
1371fn resolve_wrapped_config_object<'a>(
1372    program: &'a Program,
1373    expr: &'a Expression<'a>,
1374) -> Option<&'a ObjectExpression<'a>> {
1375    let call = match expr {
1376        Expression::CallExpression(call) => call,
1377        Expression::ParenthesizedExpression(paren) => {
1378            return resolve_wrapped_config_object(program, &paren.expression);
1379        }
1380        Expression::TSSatisfiesExpression(ts_sat) => {
1381            return resolve_wrapped_config_object(program, &ts_sat.expression);
1382        }
1383        Expression::TSAsExpression(ts_as) => {
1384            return resolve_wrapped_config_object(program, &ts_as.expression);
1385        }
1386        _ => return None,
1387    };
1388    for arg in &call.arguments {
1389        let Some(arg_expr) = arg.as_expression() else {
1390            continue;
1391        };
1392        if let Some(name) = unwrap_to_identifier_name(arg_expr)
1393            && let Some(obj) = find_variable_init_object(program, name)
1394        {
1395            return Some(obj);
1396        }
1397        if let Some(obj) = resolve_wrapped_config_object(program, arg_expr) {
1398            return Some(obj);
1399        }
1400    }
1401    None
1402}
1403
1404/// Maximum wrapper nesting resolved by [`resolve_call_config_object`].
1405///
1406/// Covers the shapes seen in the wild (`defineConfig(mergeConfig(base, defineConfig({..})))`)
1407/// while keeping a hand-written config from driving unbounded recursion.
1408const MAX_CONFIG_WRAPPER_DEPTH: u8 = 3;
1409
1410/// Resolve the config object carried by a wrapper call, giving each argument the
1411/// full resolution chain in source order.
1412///
1413/// Config wrappers take the config first and their own options after it, as in
1414/// `withSentryConfig(nextConfig, { org, project })` or
1415/// `mergeConfig(viteConfig, defineConfig({ test }))`. Scanning the whole argument
1416/// list for the first object literal therefore picks up the wrapper's options
1417/// object whenever the config itself arrives as an identifier or a nested call,
1418/// silently reading the wrong object. Resolving argument by argument, chain-first,
1419/// keeps the config's own position winning.
1420fn resolve_call_config_object<'a>(
1421    program: &'a Program,
1422    expr: &'a Expression<'a>,
1423    depth: u8,
1424) -> Option<&'a ObjectExpression<'a>> {
1425    if depth == 0 {
1426        return None;
1427    }
1428    let call = match expr {
1429        Expression::CallExpression(call) => call,
1430        Expression::ParenthesizedExpression(paren) => {
1431            return resolve_call_config_object(program, &paren.expression, depth);
1432        }
1433        Expression::TSSatisfiesExpression(ts_sat) => {
1434            return resolve_call_config_object(program, &ts_sat.expression, depth);
1435        }
1436        Expression::TSAsExpression(ts_as) => {
1437            return resolve_call_config_object(program, &ts_as.expression, depth);
1438        }
1439        _ => return None,
1440    };
1441
1442    call.arguments
1443        .iter()
1444        .filter_map(oxc_ast::ast::Argument::as_expression)
1445        .find_map(|arg| resolve_config_argument(program, arg, depth))
1446}
1447
1448/// Resolve one wrapper argument to the object it stands for.
1449fn resolve_config_argument<'a>(
1450    program: &'a Program,
1451    expr: &'a Expression<'a>,
1452    depth: u8,
1453) -> Option<&'a ObjectExpression<'a>> {
1454    match expr {
1455        Expression::ObjectExpression(obj) => Some(obj),
1456        Expression::ArrowFunctionExpression(arrow) => extract_object_from_arrow_function(arrow),
1457        Expression::FunctionExpression(func) => extract_object_from_function(func),
1458        Expression::ParenthesizedExpression(paren) => {
1459            resolve_config_argument(program, &paren.expression, depth)
1460        }
1461        Expression::TSSatisfiesExpression(ts_sat) => {
1462            resolve_config_argument(program, &ts_sat.expression, depth)
1463        }
1464        Expression::TSAsExpression(ts_as) => {
1465            resolve_config_argument(program, &ts_as.expression, depth)
1466        }
1467        Expression::CallExpression(_) => resolve_call_config_object(program, expr, depth - 1),
1468        _ => unwrap_to_identifier_name(expr)
1469            .and_then(|name| find_variable_init_object(program, name)),
1470    }
1471}
1472
1473/// Find a named property in an object expression.
1474pub(crate) fn find_property<'a>(
1475    obj: &'a ObjectExpression<'a>,
1476    key: &str,
1477) -> Option<&'a ObjectProperty<'a>> {
1478    for prop in &obj.properties {
1479        if let ObjectPropertyKind::ObjectProperty(p) = prop
1480            && property_key_matches(&p.key, key)
1481        {
1482            return Some(p);
1483        }
1484    }
1485    None
1486}
1487
1488/// Check if a property key matches a string.
1489fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
1490    match key {
1491        PropertyKey::StaticIdentifier(id) => id.name == name,
1492        PropertyKey::StringLiteral(s) => s.value == name,
1493        _ => false,
1494    }
1495}
1496
1497/// Get a string value from an object property.
1498fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
1499    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
1500}
1501
1502/// Get an array of strings from an object property.
1503fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
1504    find_property(obj, key)
1505        .map(|p| expression_to_string_array(&p.value))
1506        .unwrap_or_default()
1507}
1508
1509/// Navigate a nested property path and get a string array.
1510fn get_nested_string_array_from_object(
1511    obj: &ObjectExpression,
1512    path: &[&str],
1513) -> Option<Vec<String>> {
1514    if path.is_empty() {
1515        return None;
1516    }
1517    if path.len() == 1 {
1518        return Some(get_object_string_array_property(obj, path[0]));
1519    }
1520    let prop = find_property(obj, path[0])?;
1521    if let Expression::ObjectExpression(nested) = &prop.value {
1522        get_nested_string_array_from_object(nested, &path[1..])
1523    } else {
1524        None
1525    }
1526}
1527
1528/// Navigate a nested property path and get a string value.
1529fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
1530    if path.is_empty() {
1531        return None;
1532    }
1533    if path.len() == 1 {
1534        return get_object_string_property(obj, path[0]);
1535    }
1536    let prop = find_property(obj, path[0])?;
1537    if let Expression::ObjectExpression(nested) = &prop.value {
1538        get_nested_string_from_object(nested, &path[1..])
1539    } else {
1540        None
1541    }
1542}
1543
1544/// Navigate a nested property path and get a shell command value.
1545fn get_nested_command_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
1546    if path.is_empty() {
1547        return None;
1548    }
1549    if path.len() == 1 {
1550        return find_property(obj, path[0]).and_then(|prop| expression_to_command(&prop.value));
1551    }
1552    let prop = find_property(obj, path[0])?;
1553    if let Expression::ObjectExpression(nested) = &prop.value {
1554        get_nested_command_from_object(nested, &path[1..])
1555    } else {
1556        None
1557    }
1558}
1559
1560/// Convert an expression to a string if it's a string literal.
1561pub(crate) fn expression_to_string(expr: &Expression) -> Option<String> {
1562    match expr {
1563        Expression::StringLiteral(s) => Some(s.value.to_string()),
1564        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
1565            t.quasis.first().map(|q| q.value.raw.to_string())
1566        }
1567        _ => None,
1568    }
1569}
1570
1571/// Convert an expression to a shell command when static command tokens are recoverable.
1572fn expression_to_command(expr: &Expression) -> Option<String> {
1573    match expr {
1574        Expression::StringLiteral(s) => Some(s.value.to_string()),
1575        Expression::TemplateLiteral(template) => template_literal_to_command(template),
1576        Expression::ParenthesizedExpression(paren) => expression_to_command(&paren.expression),
1577        Expression::TSAsExpression(ts_as) => expression_to_command(&ts_as.expression),
1578        Expression::TSSatisfiesExpression(ts_sat) => expression_to_command(&ts_sat.expression),
1579        _ => None,
1580    }
1581}
1582
1583fn template_literal_to_command(template: &TemplateLiteral<'_>) -> Option<String> {
1584    let first = template.quasis.first()?.value.raw.as_str();
1585    if first.trim_start().is_empty() {
1586        return None;
1587    }
1588
1589    let mut command = String::new();
1590    for (idx, quasi) in template.quasis.iter().enumerate() {
1591        command.push_str(quasi.value.raw.as_str());
1592        if idx < template.expressions.len() {
1593            let next = template
1594                .quasis
1595                .get(idx + 1)
1596                .map_or("", |next| next.value.raw.as_str());
1597            if dynamic_template_boundary_splits_static_token(quasi.value.raw.as_str(), next) {
1598                return None;
1599            }
1600            command.push(' ');
1601        }
1602    }
1603
1604    Some(command)
1605}
1606
1607fn dynamic_template_boundary_splits_static_token(before: &str, after: &str) -> bool {
1608    before
1609        .chars()
1610        .next_back()
1611        .is_some_and(is_command_token_char)
1612        && after.chars().next().is_some_and(is_command_token_char)
1613}
1614
1615fn is_command_token_char(ch: char) -> bool {
1616    !ch.is_whitespace() && !matches!(ch, '&' | '|' | ';' | '"' | '\'')
1617}
1618
1619/// Convert an expression to a path-like string if it's statically recoverable.
1620pub(crate) fn expression_to_path_string(expr: &Expression) -> Option<String> {
1621    match expr {
1622        Expression::ParenthesizedExpression(paren) => expression_to_path_string(&paren.expression),
1623        Expression::TSAsExpression(ts_as) => expression_to_path_string(&ts_as.expression),
1624        Expression::TSSatisfiesExpression(ts_sat) => expression_to_path_string(&ts_sat.expression),
1625        Expression::StaticMemberExpression(member) if member.property.name == "pathname" => {
1626            expression_to_path_string(&member.object)
1627        }
1628        Expression::CallExpression(call) => call_expression_to_path_string(call),
1629        Expression::NewExpression(new_expr) => new_expression_to_path_string(new_expr),
1630        _ => expression_to_string(expr),
1631    }
1632}
1633
1634fn call_expression_to_path_string(call: &CallExpression) -> Option<String> {
1635    if matches!(&call.callee, Expression::Identifier(id) if id.name == "fileURLToPath") {
1636        return call
1637            .arguments
1638            .first()
1639            .and_then(Argument::as_expression)
1640            .and_then(expression_to_path_string);
1641    }
1642
1643    let callee_name = match &call.callee {
1644        Expression::Identifier(id) => Some(id.name.as_str()),
1645        Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
1646        _ => None,
1647    }?;
1648
1649    if !matches!(callee_name, "resolve" | "join") {
1650        return None;
1651    }
1652
1653    let mut segments = Vec::new();
1654    for (index, arg) in call.arguments.iter().enumerate() {
1655        let expr = arg.as_expression()?;
1656
1657        if is_dirname_anchor(expr) {
1658            if index == 0 {
1659                continue;
1660            }
1661            return None;
1662        }
1663
1664        segments.push(expression_to_string(expr)?);
1665    }
1666
1667    (!segments.is_empty()).then(|| join_path_segments(&segments))
1668}
1669
1670/// True when an expression is a "current directory" anchor: the `__dirname`
1671/// CommonJS global or its ESM equivalent `import.meta.dirname` (Node 20.11+).
1672/// As the leading argument of `resolve(...)` / `join(...)` it is dropped so the
1673/// remaining literal segments yield a config-directory-relative path.
1674fn is_dirname_anchor(expr: &Expression) -> bool {
1675    match expr {
1676        Expression::Identifier(id) => id.name == "__dirname",
1677        Expression::StaticMemberExpression(member) => {
1678            member.property.name == "dirname" && is_import_meta_expression(&member.object)
1679        }
1680        _ => false,
1681    }
1682}
1683
1684/// True for the `import.meta` meta-property, distinct from `new.target`.
1685fn is_import_meta_expression(expr: &Expression) -> bool {
1686    matches!(
1687        expr,
1688        Expression::MetaProperty(meta) if meta.meta.name == "import" && meta.property.name == "meta"
1689    )
1690}
1691
1692fn new_expression_to_path_string(new_expr: &NewExpression) -> Option<String> {
1693    if !matches!(&new_expr.callee, Expression::Identifier(id) if id.name == "URL") {
1694        return None;
1695    }
1696
1697    let source = new_expr
1698        .arguments
1699        .first()
1700        .and_then(Argument::as_expression)
1701        .and_then(expression_to_string)?;
1702
1703    let base = new_expr
1704        .arguments
1705        .get(1)
1706        .and_then(Argument::as_expression)?;
1707    is_import_meta_url_expression(base).then_some(source)
1708}
1709
1710fn is_import_meta_url_expression(expr: &Expression) -> bool {
1711    if let Expression::StaticMemberExpression(member) = expr {
1712        member.property.name == "url" && matches!(member.object, Expression::MetaProperty(_))
1713    } else {
1714        false
1715    }
1716}
1717
1718fn join_path_segments(segments: &[String]) -> String {
1719    let mut joined = PathBuf::new();
1720    for segment in segments {
1721        joined.push(segment);
1722    }
1723    joined.to_string_lossy().replace('\\', "/")
1724}
1725
1726fn expression_to_alias_pairs(expr: &Expression) -> Vec<(String, String)> {
1727    match expr {
1728        Expression::ObjectExpression(obj) => obj
1729            .properties
1730            .iter()
1731            .filter_map(|prop| {
1732                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1733                    return None;
1734                };
1735                let find = property_key_to_string(&prop.key)?;
1736                let replacement = expression_to_path_values(&prop.value)
1737                    .into_iter()
1738                    .next()
1739                    .map(|path| path_to_config_string(&path))?;
1740                Some((find, replacement))
1741            })
1742            .collect(),
1743        Expression::ArrayExpression(arr) => arr
1744            .elements
1745            .iter()
1746            .filter_map(|element| {
1747                let Expression::ObjectExpression(obj) = element.as_expression()? else {
1748                    return None;
1749                };
1750                let find = find_property(obj, "find")
1751                    .and_then(|prop| expression_to_string(&prop.value))?;
1752                let replacement = find_property(obj, "replacement")
1753                    .and_then(|prop| expression_to_path_string(&prop.value))?;
1754                Some((find, replacement))
1755            })
1756            .collect(),
1757        _ => Vec::new(),
1758    }
1759}
1760
1761/// Kinded variant of [`expression_to_alias_pairs`]: each tuple gains a
1762/// `replacement_is_bare_string_literal` flag. See
1763/// [`extract_config_aliases_kinded`].
1764fn expression_to_alias_pairs_kinded(expr: &Expression) -> Vec<(String, String, bool)> {
1765    match expr {
1766        Expression::ObjectExpression(obj) => obj
1767            .properties
1768            .iter()
1769            .filter_map(|prop| {
1770                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1771                    return None;
1772                };
1773                let find = property_key_to_string(&prop.key)?;
1774                let (replacement, is_bare) = alias_replacement_kinded(&prop.value)?;
1775                Some((find, replacement, is_bare))
1776            })
1777            .collect(),
1778        Expression::ArrayExpression(arr) => arr
1779            .elements
1780            .iter()
1781            .filter_map(|element| {
1782                let Expression::ObjectExpression(obj) = element.as_expression()? else {
1783                    return None;
1784                };
1785                let find = find_property(obj, "find")
1786                    .and_then(|prop| expression_to_string(&prop.value))?;
1787                let (replacement, is_bare) = find_property(obj, "replacement")
1788                    .and_then(|prop| alias_replacement_kinded(&prop.value))?;
1789                Some((find, replacement, is_bare))
1790            })
1791            .collect(),
1792        _ => Vec::new(),
1793    }
1794}
1795
1796/// Extract an alias replacement string plus whether it was written as a plain
1797/// bare string literal. A bare string literal (not starting with `./`/`../`/`/`)
1798/// signals a potential package-to-package alias; a path expression
1799/// (`path.resolve(...)`, `path.join(...)`, `fileURLToPath(...)`, `new URL(...)`)
1800/// or a `./`-prefixed string is always a filesystem path. This is the
1801/// filesystem-free discriminator the package-to-package gate relies on.
1802fn alias_replacement_kinded(expr: &Expression) -> Option<(String, bool)> {
1803    match expr {
1804        Expression::ParenthesizedExpression(paren) => alias_replacement_kinded(&paren.expression),
1805        Expression::TSAsExpression(ts_as) => alias_replacement_kinded(&ts_as.expression),
1806        Expression::TSSatisfiesExpression(ts_sat) => alias_replacement_kinded(&ts_sat.expression),
1807        Expression::StringLiteral(s) => {
1808            let value = s.value.to_string();
1809            let is_bare =
1810                !value.starts_with("./") && !value.starts_with("../") && !value.starts_with('/');
1811            Some((value, is_bare))
1812        }
1813        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets
1814        // (`{ "@/*": ["./src/*"] }`); take the first entry, matching the prior
1815        // non-kinded `expression_to_path_values().next()` behavior.
1816        Expression::ArrayExpression(arr) => arr
1817            .elements
1818            .iter()
1819            .find_map(ArrayExpressionElement::as_expression)
1820            .and_then(alias_replacement_kinded),
1821        _ => expression_to_path_string(expr).map(|value| (value, false)),
1822    }
1823}
1824
1825/// Maximum identifier-indirection hops the alias resolver follows before giving
1826/// up. Each local-variable or imported-binding resolution counts one hop. The
1827/// per-file `visited` set is the real cycle guard; this bound additionally
1828/// terminates pathological local self-references (`const a = a`). Real configs
1829/// rarely exceed one or two hops (`alias: importedAliases`).
1830const MAX_ALIAS_RESOLVE_DEPTH: usize = 8;
1831
1832/// Sibling-file extensions probed when an alias identifier is imported from a
1833/// relative specifier. Mirrors the JS/TS config extensions Vite/Vitest configs
1834/// and their shared alias modules use. `.js` first matches the common
1835/// JS-project case; the direct-as-written read happens before any probing. JSON
1836/// is intentionally excluded: it parses as a bare expression with no `export`,
1837/// so `find_exported_init` could never recover an alias literal from it.
1838const ALIAS_SIBLING_EXTS: [&str; 6] = ["js", "mjs", "cjs", "ts", "mts", "cts"];
1839
1840/// Resolve an alias expression into `(find, replacement, is_bare)` tuples,
1841/// following identifiers and expanding spreads.
1842///
1843/// Beyond the inline object (`{ '@': './src' }`) and array
1844/// (`[{ find, replacement }]`) forms, this handles the indirection shapes from
1845/// issue #811:
1846/// - an identifier bound to a local `const NAME = [...] | {...}`,
1847/// - an identifier imported from a relative sibling file
1848///   (`import { sharedAliases } from "./vite.shared.js"`), read one hop and
1849///   parsed for `export const NAME` / `export default` / `export { NAME }`,
1850/// - array spread elements (`[...a, ...b]`) and object spread properties
1851///   (`{ ...a, '@': './src' }`), each resolved recursively.
1852///
1853/// `config_path` is the file `expr` lives in (used to resolve relative sibling
1854/// imports). `visited` holds already-read sibling paths to break import cycles;
1855/// `depth` bounds identifier indirection via [`MAX_ALIAS_RESOLVE_DEPTH`].
1856fn resolve_alias_pairs_kinded(
1857    program: &Program,
1858    config_path: &Path,
1859    expr: &Expression,
1860    visited: &mut FxHashSet<PathBuf>,
1861    depth: usize,
1862) -> Vec<(String, String, bool)> {
1863    match expr {
1864        Expression::ParenthesizedExpression(paren) => {
1865            resolve_alias_pairs_kinded(program, config_path, &paren.expression, visited, depth)
1866        }
1867        Expression::TSAsExpression(ts_as) => {
1868            resolve_alias_pairs_kinded(program, config_path, &ts_as.expression, visited, depth)
1869        }
1870        Expression::TSSatisfiesExpression(ts_sat) => {
1871            resolve_alias_pairs_kinded(program, config_path, &ts_sat.expression, visited, depth)
1872        }
1873        Expression::ObjectExpression(obj) => {
1874            resolve_object_alias_pairs_kinded(program, config_path, obj, visited, depth)
1875        }
1876        Expression::ArrayExpression(arr) => {
1877            resolve_array_alias_pairs_kinded(program, config_path, arr, visited, depth)
1878        }
1879        Expression::Identifier(id) => {
1880            resolve_identifier_alias_pairs(program, config_path, id.name.as_str(), visited, depth)
1881        }
1882        _ => Vec::new(),
1883    }
1884}
1885
1886/// Resolve object-form alias pairs (`{ '@': './src', ...spread }`), expanding
1887/// spread properties recursively.
1888fn resolve_object_alias_pairs_kinded(
1889    program: &Program,
1890    config_path: &Path,
1891    obj: &ObjectExpression,
1892    visited: &mut FxHashSet<PathBuf>,
1893    depth: usize,
1894) -> Vec<(String, String, bool)> {
1895    let mut pairs = Vec::new();
1896    for prop in &obj.properties {
1897        match prop {
1898            ObjectPropertyKind::ObjectProperty(prop) => {
1899                if let Some(find) = property_key_to_string(&prop.key)
1900                    && let Some((replacement, is_bare)) = alias_replacement_kinded(&prop.value)
1901                {
1902                    pairs.push((find, replacement, is_bare));
1903                }
1904            }
1905            // `{ ...sharedAliases, '@': './src' }`
1906            ObjectPropertyKind::SpreadProperty(spread) => {
1907                pairs.extend(resolve_alias_pairs_kinded(
1908                    program,
1909                    config_path,
1910                    &spread.argument,
1911                    visited,
1912                    depth,
1913                ));
1914            }
1915        }
1916    }
1917    pairs
1918}
1919
1920/// Resolve array-form alias pairs (`[{ find, replacement }, ...spread]`),
1921/// expanding spread elements recursively.
1922fn resolve_array_alias_pairs_kinded(
1923    program: &Program,
1924    config_path: &Path,
1925    arr: &ArrayExpression,
1926    visited: &mut FxHashSet<PathBuf>,
1927    depth: usize,
1928) -> Vec<(String, String, bool)> {
1929    let mut pairs = Vec::new();
1930    for element in &arr.elements {
1931        match element {
1932            // `[...sharedAliases, { find, replacement }]`
1933            ArrayExpressionElement::SpreadElement(spread) => {
1934                pairs.extend(resolve_alias_pairs_kinded(
1935                    program,
1936                    config_path,
1937                    &spread.argument,
1938                    visited,
1939                    depth,
1940                ));
1941            }
1942            _ => {
1943                if let Some(Expression::ObjectExpression(obj)) = element.as_expression()
1944                    && let Some(find) = find_property(obj, "find")
1945                        .and_then(|prop| expression_to_string(&prop.value))
1946                    && let Some((replacement, is_bare)) = find_property(obj, "replacement")
1947                        .and_then(|prop| alias_replacement_kinded(&prop.value))
1948                {
1949                    pairs.push((find, replacement, is_bare));
1950                }
1951            }
1952        }
1953    }
1954    pairs
1955}
1956
1957/// Resolve an identifier used as an alias value to its literal pairs, first by
1958/// local `const`/`let`/`var` binding, then by a one-hop relative import.
1959fn resolve_identifier_alias_pairs(
1960    program: &Program,
1961    config_path: &Path,
1962    name: &str,
1963    visited: &mut FxHashSet<PathBuf>,
1964    depth: usize,
1965) -> Vec<(String, String, bool)> {
1966    if depth >= MAX_ALIAS_RESOLVE_DEPTH {
1967        return Vec::new();
1968    }
1969    // Local `const NAME = [...] | {...}` (or `const NAME = otherIdentifier`).
1970    if let Some(init) = find_variable_init_expression(program, name) {
1971        return resolve_alias_pairs_kinded(program, config_path, init, visited, depth + 1);
1972    }
1973    // `import { NAME } from "./sibling"` / `import NAME from "./sibling"`.
1974    let Some((specifier, imported_name)) = find_relative_import_binding(program, name) else {
1975        return Vec::new();
1976    };
1977    resolve_imported_alias_pairs(
1978        config_path,
1979        &specifier,
1980        imported_name.as_deref(),
1981        visited,
1982        depth + 1,
1983    )
1984}
1985
1986/// Read a relative sibling file and resolve the alias literal it exports under
1987/// `imported_name` (`None` = default export).
1988fn resolve_imported_alias_pairs(
1989    config_path: &Path,
1990    specifier: &str,
1991    imported_name: Option<&str>,
1992    visited: &mut FxHashSet<PathBuf>,
1993    depth: usize,
1994) -> Vec<(String, String, bool)> {
1995    let Some((sibling_path, sibling_source)) = resolve_sibling_module(config_path, specifier)
1996    else {
1997        return Vec::new();
1998    };
1999    if !visited.insert(sibling_path.clone()) {
2000        return Vec::new();
2001    }
2002    extract_from_source(&sibling_source, &sibling_path, |program| {
2003        let init = find_exported_init(program, imported_name)?;
2004        let pairs = resolve_alias_pairs_kinded(program, &sibling_path, init, visited, depth);
2005        (!pairs.is_empty()).then_some(pairs)
2006    })
2007    .unwrap_or_default()
2008}
2009
2010/// Find a top-level variable declaration by name and return its init expression
2011/// (array, object, or another identifier). Covers bare `const NAME = ...` and
2012/// `export const NAME = ...`. Generalizes [`find_variable_init_object`] to any
2013/// init shape so the alias resolver can recurse on array/identifier inits.
2014fn find_variable_init_expression<'a>(
2015    program: &'a Program<'a>,
2016    name: &str,
2017) -> Option<&'a Expression<'a>> {
2018    for stmt in &program.body {
2019        let decl = match stmt {
2020            Statement::VariableDeclaration(decl) => decl,
2021            Statement::ExportNamedDeclaration(export) => match &export.declaration {
2022                Some(Declaration::VariableDeclaration(decl)) => decl,
2023                _ => continue,
2024            },
2025            _ => continue,
2026        };
2027        for declarator in &decl.declarations {
2028            if let BindingPattern::BindingIdentifier(id) = &declarator.id
2029                && id.name == name
2030                && let Some(init) = &declarator.init
2031            {
2032                return Some(init);
2033            }
2034        }
2035    }
2036    None
2037}
2038
2039/// Find the init expression a sibling module exports under `name`
2040/// (`None` = default export). For named exports this covers both
2041/// `export const NAME = ...` and a local `const NAME = ...` later re-exported
2042/// via `export { NAME }` (both surface through [`find_variable_init_expression`]).
2043fn find_exported_init<'a>(
2044    program: &'a Program<'a>,
2045    name: Option<&str>,
2046) -> Option<&'a Expression<'a>> {
2047    match name {
2048        Some(name) => find_variable_init_expression(program, name),
2049        None => program.body.iter().find_map(|stmt| {
2050            if let Statement::ExportDefaultDeclaration(decl) = stmt {
2051                decl.declaration.as_expression()
2052            } else {
2053                None
2054            }
2055        }),
2056    }
2057}
2058
2059/// Find the import that binds local `name` to a RELATIVE module, returning the
2060/// specifier and the imported name (`None` for a default import). Bare-package
2061/// imports are intentionally skipped: reading a literal alias table out of
2062/// `node_modules` is not a real-world config shape.
2063fn find_relative_import_binding(program: &Program, name: &str) -> Option<(String, Option<String>)> {
2064    for stmt in &program.body {
2065        let Statement::ImportDeclaration(decl) = stmt else {
2066            continue;
2067        };
2068        let specifier = decl.source.value.as_str();
2069        if !is_relative_specifier(specifier) {
2070            continue;
2071        }
2072        let Some(specifiers) = &decl.specifiers else {
2073            continue;
2074        };
2075        for spec in specifiers {
2076            match spec {
2077                ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.local.name == name => {
2078                    return Some((
2079                        specifier.to_string(),
2080                        Some(spec.imported.name().to_string()),
2081                    ));
2082                }
2083                ImportDeclarationSpecifier::ImportDefaultSpecifier(spec)
2084                    if spec.local.name == name =>
2085                {
2086                    return Some((specifier.to_string(), None));
2087                }
2088                _ => {}
2089            }
2090        }
2091    }
2092    None
2093}
2094
2095/// True for a relative/absolute module specifier (`./x`, `../x`, `/x`), the
2096/// shapes that point at a sibling file rather than an npm package.
2097fn is_relative_specifier(specifier: &str) -> bool {
2098    specifier.starts_with("./") || specifier.starts_with("../") || specifier.starts_with('/')
2099}
2100
2101/// Resolve a relative specifier against `config_path`'s directory to a readable
2102/// sibling file, returning the resolved path and its source. Tries the path as
2103/// written first (covers `./vite.shared.js`), then appends each known config
2104/// extension (covers extensionless `./vite.shared` and dotted basenames where
2105/// `Path::extension` would misread `.shared`), then an `index.*` directory file.
2106fn resolve_sibling_module(config_path: &Path, specifier: &str) -> Option<(PathBuf, String)> {
2107    let parent = config_path.parent().unwrap_or(config_path);
2108    let direct = parent.join(specifier);
2109    if let Ok(source) = std::fs::read_to_string(&direct) {
2110        return Some((direct, source));
2111    }
2112    for ext in ALIAS_SIBLING_EXTS {
2113        let candidate = parent.join(format!("{specifier}.{ext}"));
2114        if let Ok(source) = std::fs::read_to_string(&candidate) {
2115            return Some((candidate, source));
2116        }
2117    }
2118    for ext in ALIAS_SIBLING_EXTS {
2119        let candidate = direct.join(format!("index.{ext}"));
2120        if let Ok(source) = std::fs::read_to_string(&candidate) {
2121            return Some((candidate, source));
2122        }
2123    }
2124    None
2125}
2126
2127/// Find a default-exported array config, the `defineWorkspace([...])` /
2128/// `vitest.workspace.{ts,js}` shape. Handles `export default [...]` and
2129/// `export default defineWorkspace([...])` / `defineConfig([...])` (the array as
2130/// the call's first argument), plus parenthesised / `as` wrappers.
2131fn find_default_export_array<'a>(program: &'a Program<'a>) -> Option<&'a ArrayExpression<'a>> {
2132    for stmt in &program.body {
2133        if let Statement::ExportDefaultDeclaration(decl) = stmt
2134            && let Some(expr) = decl.declaration.as_expression()
2135        {
2136            return array_from_expression(expr);
2137        }
2138    }
2139    None
2140}
2141
2142fn array_from_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
2143    match expr {
2144        Expression::ArrayExpression(arr) => Some(arr),
2145        Expression::ParenthesizedExpression(paren) => array_from_expression(&paren.expression),
2146        Expression::TSAsExpression(ts_as) => array_from_expression(&ts_as.expression),
2147        Expression::TSSatisfiesExpression(ts_sat) => array_from_expression(&ts_sat.expression),
2148        Expression::CallExpression(call) => call
2149            .arguments
2150            .first()
2151            .and_then(Argument::as_expression)
2152            .and_then(array_from_expression),
2153        _ => None,
2154    }
2155}
2156
2157pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
2158    let mut normalized = PathBuf::new();
2159
2160    for component in path.components() {
2161        match component {
2162            std::path::Component::CurDir => {}
2163            std::path::Component::ParentDir => {
2164                normalized.pop();
2165            }
2166            _ => normalized.push(component.as_os_str()),
2167        }
2168    }
2169
2170    normalized
2171}
2172
2173/// Whether `specifier` is a bare package specifier: not empty, not relative or
2174/// absolute, not protocol-prefixed, and free of characters that are invalid in
2175/// an npm package name (backslashes, whitespace).
2176pub(crate) fn is_package_specifier(specifier: &str) -> bool {
2177    !specifier.is_empty()
2178        && specifier != "."
2179        && specifier != ".."
2180        && !specifier.starts_with("./")
2181        && !specifier.starts_with("../")
2182        && !specifier.starts_with('/')
2183        && !specifier.contains(':')
2184        && !specifier.contains('\\')
2185        && !specifier.chars().any(char::is_whitespace)
2186}
2187
2188/// Convert an expression to a string array if it's an array of string literals.
2189fn expression_to_string_array(expr: &Expression) -> Vec<String> {
2190    match expr {
2191        Expression::ArrayExpression(arr) => arr
2192            .elements
2193            .iter()
2194            .filter_map(|el| match el {
2195                ArrayExpressionElement::SpreadElement(_) => None,
2196                _ => el.as_expression().and_then(expression_to_string),
2197            })
2198            .collect(),
2199        _ => vec![],
2200    }
2201}
2202
2203/// Collect only top-level string values from an expression.
2204///
2205/// For arrays, extracts direct string elements and the first string element of sub-arrays
2206/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
2207fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
2208    let mut values = Vec::new();
2209    match expr {
2210        Expression::StringLiteral(s) => {
2211            values.push(s.value.to_string());
2212        }
2213        Expression::ArrayExpression(arr) => {
2214            for el in &arr.elements {
2215                if let Some(inner) = el.as_expression() {
2216                    match inner {
2217                        Expression::StringLiteral(s) => {
2218                            values.push(s.value.to_string());
2219                        }
2220                        Expression::ArrayExpression(sub_arr) => {
2221                            if let Some(first) = sub_arr.elements.first()
2222                                && let Some(first_expr) = first.as_expression()
2223                                && let Some(s) = expression_to_string(first_expr)
2224                            {
2225                                values.push(s);
2226                            }
2227                        }
2228                        _ => {}
2229                    }
2230                }
2231            }
2232        }
2233        Expression::ObjectExpression(obj) => {
2234            for prop in &obj.properties {
2235                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2236                    match &p.value {
2237                        Expression::StringLiteral(s) => {
2238                            values.push(s.value.to_string());
2239                        }
2240                        Expression::ArrayExpression(sub_arr) => {
2241                            if let Some(first) = sub_arr.elements.first()
2242                                && let Some(first_expr) = first.as_expression()
2243                                && let Some(s) = expression_to_string(first_expr)
2244                            {
2245                                values.push(s);
2246                            }
2247                        }
2248                        _ => {}
2249                    }
2250                }
2251            }
2252        }
2253        _ => {}
2254    }
2255    values
2256}
2257
2258/// Collect top-level string values, plus a named string property from object entries.
2259fn collect_shallow_string_or_object_property_values(
2260    expr: &Expression,
2261    object_property: &str,
2262) -> Vec<String> {
2263    match expr {
2264        Expression::ArrayExpression(arr) => arr
2265            .elements
2266            .iter()
2267            .filter_map(|element| {
2268                element
2269                    .as_expression()
2270                    .and_then(|expr| shallow_string_or_object_property(expr, object_property))
2271            })
2272            .collect(),
2273        _ => shallow_string_or_object_property(expr, object_property)
2274            .into_iter()
2275            .collect(),
2276    }
2277}
2278
2279fn shallow_string_or_object_property(expr: &Expression, object_property: &str) -> Option<String> {
2280    match expr {
2281        Expression::ParenthesizedExpression(paren) => {
2282            shallow_string_or_object_property(&paren.expression, object_property)
2283        }
2284        Expression::TSSatisfiesExpression(ts_sat) => {
2285            shallow_string_or_object_property(&ts_sat.expression, object_property)
2286        }
2287        Expression::TSAsExpression(ts_as) => {
2288            shallow_string_or_object_property(&ts_as.expression, object_property)
2289        }
2290        Expression::ArrayExpression(sub_arr) => sub_arr
2291            .elements
2292            .first()
2293            .and_then(ArrayExpressionElement::as_expression)
2294            .and_then(expression_to_string),
2295        Expression::ObjectExpression(obj) => {
2296            find_property(obj, object_property).and_then(|prop| expression_to_string(&prop.value))
2297        }
2298        _ => expression_to_string(expr),
2299    }
2300}
2301
2302/// Recursively collect all string literal values from an expression tree.
2303fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
2304    match expr {
2305        Expression::StringLiteral(s) => {
2306            values.push(s.value.to_string());
2307        }
2308        Expression::ArrayExpression(arr) => {
2309            for el in &arr.elements {
2310                if let Some(expr) = el.as_expression() {
2311                    collect_all_string_values(expr, values);
2312                }
2313            }
2314        }
2315        Expression::ObjectExpression(obj) => {
2316            for prop in &obj.properties {
2317                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2318                    collect_all_string_values(&p.value, values);
2319                }
2320            }
2321        }
2322        _ => {}
2323    }
2324}
2325
2326/// Convert a `PropertyKey` to a `String`.
2327fn property_key_to_string(key: &PropertyKey) -> Option<String> {
2328    match key {
2329        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
2330        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
2331        _ => None,
2332    }
2333}
2334
2335/// Extract keys of an object at a nested property path.
2336fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2337    if path.is_empty() {
2338        return None;
2339    }
2340    let prop = find_property(obj, path[0])?;
2341    if path.len() == 1 {
2342        if let Expression::ObjectExpression(nested) = &prop.value {
2343            let keys = nested
2344                .properties
2345                .iter()
2346                .filter_map(|p| {
2347                    if let ObjectPropertyKind::ObjectProperty(p) = p {
2348                        property_key_to_string(&p.key)
2349                    } else {
2350                        None
2351                    }
2352                })
2353                .collect();
2354            return Some(keys);
2355        }
2356        return None;
2357    }
2358    if let Expression::ObjectExpression(nested) = &prop.value {
2359        get_nested_object_keys(nested, &path[1..])
2360    } else {
2361        None
2362    }
2363}
2364
2365/// Navigate a nested property path and return the raw expression at the end.
2366fn get_nested_expression<'a>(
2367    obj: &'a ObjectExpression<'a>,
2368    path: &[&str],
2369) -> Option<&'a Expression<'a>> {
2370    if path.is_empty() {
2371        return None;
2372    }
2373    let prop = find_property(obj, path[0])?;
2374    if path.len() == 1 {
2375        return Some(&prop.value);
2376    }
2377    if let Expression::ObjectExpression(nested) = &prop.value {
2378        get_nested_expression(nested, &path[1..])
2379    } else {
2380        None
2381    }
2382}
2383
2384/// Navigate a nested path and extract a string, string array, or object string/array values.
2385fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2386    if path.is_empty() {
2387        return None;
2388    }
2389    if path.len() == 1 {
2390        let prop = find_property(obj, path[0])?;
2391        return Some(expression_to_string_or_array(&prop.value));
2392    }
2393    let prop = find_property(obj, path[0])?;
2394    if let Expression::ObjectExpression(nested) = &prop.value {
2395        get_nested_string_or_array(nested, &path[1..])
2396    } else {
2397        None
2398    }
2399}
2400
2401/// Convert an expression to a `Vec<String>`, handling string, array, object-with-string/array values,
2402/// and Webpack 5 entry descriptors (`{ import: "..." }`).
2403///
2404/// Array elements that are object literals are inspected for an `input` property
2405/// (Angular CLI schema for `styles`/`scripts`/`polyfills`:
2406/// `{ "input": "src/x.scss", "bundleName": "x", "inject": false }`). Extracting
2407/// `input` prevents object-form entries from being silently dropped. See #126.
2408fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
2409    match expr {
2410        Expression::StringLiteral(s) => vec![s.value.to_string()],
2411        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
2412            .quasis
2413            .first()
2414            .map(|q| vec![q.value.raw.to_string()])
2415            .unwrap_or_default(),
2416        Expression::ArrayExpression(arr) => arr
2417            .elements
2418            .iter()
2419            .filter_map(|el| el.as_expression())
2420            .flat_map(|e| match e {
2421                Expression::ObjectExpression(obj) => find_property(obj, "input")
2422                    .map(|p| expression_to_string_or_array(&p.value))
2423                    .unwrap_or_default(),
2424                _ => expression_to_path_string(e).into_iter().collect(),
2425            })
2426            .collect(),
2427        Expression::ObjectExpression(obj) => obj
2428            .properties
2429            .iter()
2430            .flat_map(|p| {
2431                if let ObjectPropertyKind::ObjectProperty(p) = p {
2432                    match &p.value {
2433                        Expression::ArrayExpression(_) => expression_to_string_or_array(&p.value),
2434                        Expression::ObjectExpression(value_obj) => {
2435                            find_property(value_obj, "import")
2436                                .map(|import_prop| {
2437                                    expression_to_string_or_array(&import_prop.value)
2438                                })
2439                                .unwrap_or_default()
2440                        }
2441                        _ => expression_to_path_string(&p.value).into_iter().collect(),
2442                    }
2443                } else {
2444                    Vec::new()
2445                }
2446            })
2447            .collect(),
2448        _ => expression_to_path_string(expr).into_iter().collect(),
2449    }
2450}
2451
2452/// Collect `require('...')` argument strings from an expression.
2453fn collect_require_sources(expr: &Expression) -> Vec<String> {
2454    let mut sources = Vec::new();
2455    match expr {
2456        Expression::CallExpression(call) if is_require_call(call) => {
2457            if let Some(s) = get_require_source(call) {
2458                sources.push(s);
2459            }
2460        }
2461        Expression::ArrayExpression(arr) => {
2462            for el in &arr.elements {
2463                if let Some(inner) = el.as_expression() {
2464                    match inner {
2465                        Expression::CallExpression(call) if is_require_call(call) => {
2466                            if let Some(s) = get_require_source(call) {
2467                                sources.push(s);
2468                            }
2469                        }
2470                        Expression::ArrayExpression(sub_arr) => {
2471                            if let Some(first) = sub_arr.elements.first()
2472                                && let Some(Expression::CallExpression(call)) =
2473                                    first.as_expression()
2474                                && is_require_call(call)
2475                                && let Some(s) = get_require_source(call)
2476                            {
2477                                sources.push(s);
2478                            }
2479                        }
2480                        _ => {}
2481                    }
2482                }
2483            }
2484        }
2485        _ => {}
2486    }
2487    sources
2488}
2489
2490/// Check if a call expression is `require(...)`.
2491fn is_require_call(call: &CallExpression) -> bool {
2492    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
2493}
2494
2495/// Get the first string argument of a `require()` call.
2496fn get_require_source(call: &CallExpression) -> Option<String> {
2497    call.arguments.first().and_then(|arg| {
2498        if let Argument::StringLiteral(s) = arg {
2499            Some(s.value.to_string())
2500        } else {
2501            None
2502        }
2503    })
2504}
2505
2506#[cfg(test)]
2507mod tests {
2508    use super::*;
2509    use std::path::PathBuf;
2510
2511    fn js_path() -> PathBuf {
2512        PathBuf::from("config.js")
2513    }
2514
2515    fn ts_path() -> PathBuf {
2516        PathBuf::from("config.ts")
2517    }
2518
2519    #[test]
2520    fn extract_lazy_imports_bare_arrows() {
2521        let source = r"
2522            import { defineConfig } from '@adonisjs/core/app'
2523            export default defineConfig({
2524                preloads: [
2525                    () => import('#start/routes'),
2526                    () => import('#start/kernel'),
2527                ],
2528            })
2529        ";
2530        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["preloads"]);
2531        assert_eq!(specs, vec!["#start/routes", "#start/kernel"]);
2532    }
2533
2534    #[test]
2535    fn extract_lazy_imports_object_form_with_file_key() {
2536        let source = r"
2537            export default defineConfig({
2538                providers: [
2539                    () => import('@adonisjs/core/providers/app_provider'),
2540                    {
2541                        file: () => import('@adonisjs/core/providers/repl_provider'),
2542                        environment: ['repl', 'test'],
2543                    },
2544                ],
2545            })
2546        ";
2547        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2548        assert_eq!(
2549            specs,
2550            vec![
2551                "@adonisjs/core/providers/app_provider",
2552                "@adonisjs/core/providers/repl_provider",
2553            ]
2554        );
2555    }
2556
2557    #[test]
2558    fn extract_lazy_imports_block_body_with_return() {
2559        let source = r"
2560            export default defineConfig({
2561                commands: [
2562                    () => { return import('@adonisjs/core/commands') },
2563                ],
2564            })
2565        ";
2566        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2567        assert_eq!(specs, vec!["@adonisjs/core/commands"]);
2568    }
2569
2570    #[test]
2571    fn extract_lazy_imports_skips_unknown_element_shapes() {
2572        let source = r"
2573            export default defineConfig({
2574                commands: [
2575                    'string-entry',
2576                    42,
2577                    { other: 'value' },
2578                    () => import('@adonisjs/lucid/commands'),
2579                ],
2580            })
2581        ";
2582        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2583        assert_eq!(specs, vec!["@adonisjs/lucid/commands"]);
2584    }
2585
2586    #[test]
2587    fn extract_lazy_imports_missing_property_returns_empty() {
2588        let source = r"
2589            export default defineConfig({
2590                preloads: [() => import('#start/routes')],
2591            })
2592        ";
2593        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2594        assert!(specs.is_empty());
2595    }
2596
2597    #[test]
2598    fn extract_imports_basic() {
2599        let source = r"
2600            import foo from 'foo-pkg';
2601            import { bar } from '@scope/bar';
2602            export default {};
2603        ";
2604        let imports = extract_imports(source, &js_path());
2605        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
2606    }
2607
2608    #[test]
2609    fn extract_default_export_object_property() {
2610        let source = r#"export default { testDir: "./tests" };"#;
2611        let val = extract_config_string(source, &js_path(), &["testDir"]);
2612        assert_eq!(val, Some("./tests".to_string()));
2613    }
2614
2615    #[test]
2616    fn extract_define_config_property() {
2617        let source = r#"
2618            import { defineConfig } from 'vitest/config';
2619            export default defineConfig({
2620                test: {
2621                    include: ["**/*.test.ts", "**/*.spec.ts"],
2622                    setupFiles: ["./test/setup.ts"]
2623                }
2624            });
2625        "#;
2626        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
2627        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
2628
2629        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
2630        assert_eq!(setup, vec!["./test/setup.ts"]);
2631    }
2632
2633    #[test]
2634    fn extract_module_exports_property() {
2635        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
2636        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
2637        assert_eq!(val, Some("jsdom".to_string()));
2638    }
2639
2640    #[test]
2641    fn extract_nested_string_array() {
2642        let source = r#"
2643            export default {
2644                resolve: {
2645                    alias: {
2646                        "@": "./src"
2647                    }
2648                },
2649                test: {
2650                    include: ["src/**/*.test.ts"]
2651                }
2652            };
2653        "#;
2654        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
2655        assert_eq!(include, vec!["src/**/*.test.ts"]);
2656    }
2657
2658    #[test]
2659    fn extract_addons_array() {
2660        let source = r#"
2661            export default {
2662                addons: [
2663                    "@storybook/addon-a11y",
2664                    "@storybook/addon-docs",
2665                    "@storybook/addon-links"
2666                ]
2667            };
2668        "#;
2669        let addons = extract_config_property_strings(source, &ts_path(), "addons");
2670        assert_eq!(
2671            addons,
2672            vec![
2673                "@storybook/addon-a11y",
2674                "@storybook/addon-docs",
2675                "@storybook/addon-links"
2676            ]
2677        );
2678    }
2679
2680    #[test]
2681    fn handle_empty_config() {
2682        let source = "";
2683        let result = extract_config_string(source, &js_path(), &["key"]);
2684        assert_eq!(result, None);
2685    }
2686
2687    #[test]
2688    fn object_keys_postcss_plugins() {
2689        let source = r"
2690            module.exports = {
2691                plugins: {
2692                    autoprefixer: {},
2693                    tailwindcss: {},
2694                    'postcss-import': {}
2695                }
2696            };
2697        ";
2698        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2699        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
2700    }
2701
2702    #[test]
2703    fn object_keys_nested_path() {
2704        let source = r"
2705            export default {
2706                build: {
2707                    plugins: {
2708                        minify: {},
2709                        compress: {}
2710                    }
2711                }
2712            };
2713        ";
2714        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
2715        assert_eq!(keys, vec!["minify", "compress"]);
2716    }
2717
2718    #[test]
2719    fn object_keys_empty_object() {
2720        let source = r"export default { plugins: {} };";
2721        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2722        assert!(keys.is_empty());
2723    }
2724
2725    #[test]
2726    fn object_keys_non_object_returns_empty() {
2727        let source = r#"export default { plugins: ["a", "b"] };"#;
2728        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2729        assert!(keys.is_empty());
2730    }
2731
2732    #[test]
2733    fn string_or_array_single_string() {
2734        let source = r#"export default { entry: "./src/index.js" };"#;
2735        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2736        assert_eq!(result, vec!["./src/index.js"]);
2737    }
2738
2739    #[test]
2740    fn string_or_array_array() {
2741        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
2742        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2743        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
2744    }
2745
2746    #[test]
2747    fn string_or_array_object_values() {
2748        let source =
2749            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
2750        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2751        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
2752    }
2753
2754    #[test]
2755    fn string_or_array_object_array_values() {
2756        let source = r#"export default { entry: { app: ["./src/polyfill.js", "./src/app.js"] } };"#;
2757        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2758        assert_eq!(result, vec!["./src/polyfill.js", "./src/app.js"]);
2759    }
2760
2761    #[test]
2762    fn string_or_array_webpack_entry_descriptors() {
2763        let source = r#"
2764            export default {
2765                entry: {
2766                    app: {
2767                        import: "./src/app.js",
2768                        filename: "pages/app.js",
2769                        dependOn: "shared",
2770                    },
2771                    admin: {
2772                        import: ["./src/admin-polyfill.js", "./src/admin.js"],
2773                        runtime: "runtime",
2774                    },
2775                    shared: ["react", "react-dom"],
2776                },
2777            };
2778        "#;
2779        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2780        assert_eq!(
2781            result,
2782            vec![
2783                "./src/app.js",
2784                "./src/admin-polyfill.js",
2785                "./src/admin.js",
2786                "react",
2787                "react-dom"
2788            ]
2789        );
2790    }
2791
2792    #[test]
2793    fn string_or_array_nested_path() {
2794        let source = r#"
2795            export default {
2796                build: {
2797                    rollupOptions: {
2798                        input: ["./index.html", "./about.html"]
2799                    }
2800                }
2801            };
2802        "#;
2803        let result = extract_config_string_or_array(
2804            source,
2805            &js_path(),
2806            &["build", "rollupOptions", "input"],
2807        );
2808        assert_eq!(result, vec!["./index.html", "./about.html"]);
2809    }
2810
2811    #[test]
2812    fn string_or_array_template_literal() {
2813        let source = r"export default { entry: `./src/index.js` };";
2814        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2815        assert_eq!(result, vec!["./src/index.js"]);
2816    }
2817
2818    #[test]
2819    fn string_or_array_object_path_helper_values() {
2820        let source = r#"
2821            import { resolve, join } from "node:path";
2822            import path from "node:path";
2823            export default {
2824                build: {
2825                    rollupOptions: {
2826                        input: {
2827                            app: resolve(__dirname, "src/app.ts"),
2828                            modal: path.resolve(__dirname, "src/modal.ts"),
2829                            tabs: join(__dirname, "src/tabs.ts"),
2830                            styles: resolve(__dirname, "src/index.css"),
2831                        },
2832                    },
2833                },
2834            };
2835        "#;
2836        let result = extract_config_string_or_array(
2837            source,
2838            &js_path(),
2839            &["build", "rollupOptions", "input"],
2840        );
2841        assert_eq!(
2842            result,
2843            vec!["src/app.ts", "src/modal.ts", "src/tabs.ts", "src/index.css"]
2844        );
2845    }
2846
2847    #[test]
2848    fn string_or_array_array_path_helper_values() {
2849        let source = r#"
2850            import { resolve } from "node:path";
2851            export default {
2852                build: {
2853                    rollupOptions: {
2854                        input: [resolve(__dirname, "src/a.ts"), "./src/b.ts"],
2855                    },
2856                },
2857            };
2858        "#;
2859        let result = extract_config_string_or_array(
2860            source,
2861            &js_path(),
2862            &["build", "rollupOptions", "input"],
2863        );
2864        assert_eq!(result, vec!["src/a.ts", "./src/b.ts"]);
2865    }
2866
2867    #[test]
2868    fn string_or_array_top_level_path_helper_call() {
2869        let source = r#"
2870            import { resolve } from "node:path";
2871            export default { build: { lib: { entry: resolve(__dirname, "src/index.ts") } } };
2872        "#;
2873        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2874        assert_eq!(result, vec!["src/index.ts"]);
2875    }
2876
2877    #[test]
2878    fn string_or_array_import_meta_dirname_anchor() {
2879        let source = r#"
2880            import { resolve } from "node:path";
2881            export default {
2882                build: { lib: { entry: resolve(import.meta.dirname, "src/index.ts") } },
2883            };
2884        "#;
2885        let result = extract_config_string_or_array(source, &ts_path(), &["build", "lib", "entry"]);
2886        assert_eq!(result, vec!["src/index.ts"]);
2887    }
2888
2889    #[test]
2890    fn string_or_array_non_literal_path_helper_args_dropped() {
2891        let source = r#"
2892            import { resolve } from "node:path";
2893            export default { build: { lib: { entry: resolve(baseDir, "src/index.ts") } } };
2894        "#;
2895        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2896        assert!(
2897            result.is_empty(),
2898            "non-literal path-helper args must be dropped: {result:?}"
2899        );
2900    }
2901
2902    #[test]
2903    fn require_strings_array() {
2904        let source = r"
2905            module.exports = {
2906                plugins: [
2907                    require('autoprefixer'),
2908                    require('postcss-import')
2909                ]
2910            };
2911        ";
2912        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2913        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
2914    }
2915
2916    #[test]
2917    fn require_strings_with_tuples() {
2918        let source = r"
2919            module.exports = {
2920                plugins: [
2921                    require('autoprefixer'),
2922                    [require('postcss-preset-env'), { stage: 3 }]
2923                ]
2924            };
2925        ";
2926        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2927        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
2928    }
2929
2930    #[test]
2931    fn require_strings_empty_array() {
2932        let source = r"module.exports = { plugins: [] };";
2933        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2934        assert!(deps.is_empty());
2935    }
2936
2937    #[test]
2938    fn require_strings_no_require_calls() {
2939        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
2940        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2941        assert!(deps.is_empty());
2942    }
2943
2944    #[test]
2945    fn extract_aliases_from_object_with_file_url_to_path() {
2946        let source = r#"
2947            import { defineConfig } from 'vite';
2948            import { fileURLToPath, URL } from 'node:url';
2949
2950            export default defineConfig({
2951                resolve: {
2952                    alias: {
2953                        "@": fileURLToPath(new URL("./src", import.meta.url))
2954                    }
2955                }
2956            });
2957        "#;
2958
2959        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2960        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
2961    }
2962
2963    #[test]
2964    fn extract_aliases_from_array_form() {
2965        let source = r#"
2966            export default {
2967                resolve: {
2968                    alias: [
2969                        { find: "@", replacement: "./src" },
2970                        { find: "$utils", replacement: "src/lib/utils" }
2971                    ]
2972                }
2973            };
2974        "#;
2975
2976        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2977        assert_eq!(
2978            aliases,
2979            vec![
2980                ("@".to_string(), "./src".to_string()),
2981                ("$utils".to_string(), "src/lib/utils".to_string())
2982            ]
2983        );
2984    }
2985
2986    #[test]
2987    fn extract_aliases_from_object_with_array_values() {
2988        let source = r#"
2989            ({
2990                compilerOptions: {
2991                    paths: {
2992                        "@/*": ["./src/*"],
2993                        "@shared/*": ["./shared/*", "./fallback/*"]
2994                    }
2995                }
2996            })
2997        "#;
2998
2999        let aliases = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
3000        assert_eq!(
3001            aliases,
3002            vec![
3003                ("@/*".to_string(), "./src/*".to_string()),
3004                ("@shared/*".to_string(), "./shared/*".to_string())
3005            ]
3006        );
3007    }
3008
3009    #[test]
3010    fn extract_array_object_strings_mixed_forms() {
3011        let source = r#"
3012            export default {
3013                components: [
3014                    "~/components",
3015                    { path: "@/feature-components" }
3016                ]
3017            };
3018        "#;
3019
3020        let values =
3021            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
3022        assert_eq!(
3023            values,
3024            vec![
3025                "~/components".to_string(),
3026                "@/feature-components".to_string()
3027            ]
3028        );
3029    }
3030
3031    #[test]
3032    fn extract_array_object_string_pairs_with_and_without_secondary() {
3033        let source = r#"
3034            export default {
3035                webServer: [
3036                    { command: "tsx scripts/api.ts", cwd: "packages/api" },
3037                    { command: "tsx scripts/web.ts" }
3038                ]
3039            };
3040        "#;
3041
3042        let pairs = extract_config_array_object_string_pairs(
3043            source,
3044            &ts_path(),
3045            &["webServer"],
3046            "command",
3047            "cwd",
3048        );
3049        assert_eq!(
3050            pairs,
3051            vec![
3052                (
3053                    "tsx scripts/api.ts".to_string(),
3054                    Some("packages/api".to_string())
3055                ),
3056                ("tsx scripts/web.ts".to_string(), None),
3057            ]
3058        );
3059    }
3060
3061    #[test]
3062    fn extract_array_object_string_pairs_skips_elements_missing_primary() {
3063        let source = r#"
3064            export default {
3065                webServer: [
3066                    { cwd: "packages/api" },
3067                    { command: "srvx --port 3000" }
3068                ]
3069            };
3070        "#;
3071
3072        let pairs = extract_config_array_object_string_pairs(
3073            source,
3074            &ts_path(),
3075            &["webServer"],
3076            "command",
3077            "cwd",
3078        );
3079        assert_eq!(pairs, vec![("srvx --port 3000".to_string(), None)]);
3080    }
3081
3082    #[test]
3083    fn extract_array_object_string_pairs_empty_for_object_form() {
3084        let source = r#"
3085            export default {
3086                webServer: { command: "srvx --port 3000" }
3087            };
3088        "#;
3089
3090        let pairs = extract_config_array_object_string_pairs(
3091            source,
3092            &ts_path(),
3093            &["webServer"],
3094            "command",
3095            "cwd",
3096        );
3097        assert!(pairs.is_empty());
3098    }
3099
3100    #[test]
3101    fn extract_config_plugin_option_string_from_json() {
3102        let source = r#"{
3103            "expo": {
3104                "plugins": [
3105                    ["expo-router", { "root": "src/app" }]
3106                ]
3107            }
3108        }"#;
3109
3110        let value = extract_config_plugin_option_string(
3111            source,
3112            &json_path(),
3113            &["expo", "plugins"],
3114            "expo-router",
3115            "root",
3116        );
3117
3118        assert_eq!(value, Some("src/app".to_string()));
3119    }
3120
3121    #[test]
3122    fn extract_config_plugin_option_string_from_top_level_plugins() {
3123        let source = r#"{
3124            "plugins": [
3125                ["expo-router", { "root": "./src/routes" }]
3126            ]
3127        }"#;
3128
3129        let value = extract_config_plugin_option_string_from_paths(
3130            source,
3131            &json_path(),
3132            &[&["plugins"], &["expo", "plugins"]],
3133            "expo-router",
3134            "root",
3135        );
3136
3137        assert_eq!(value, Some("./src/routes".to_string()));
3138    }
3139
3140    #[test]
3141    fn extract_config_plugin_option_string_from_ts_config() {
3142        let source = r"
3143            export default {
3144                expo: {
3145                    plugins: [
3146                        ['expo-router', { root: './src/app' }]
3147                    ]
3148                }
3149            };
3150        ";
3151
3152        let value = extract_config_plugin_option_string(
3153            source,
3154            &ts_path(),
3155            &["expo", "plugins"],
3156            "expo-router",
3157            "root",
3158        );
3159
3160        assert_eq!(value, Some("./src/app".to_string()));
3161    }
3162
3163    #[test]
3164    fn extract_config_plugin_option_string_returns_none_when_plugin_missing() {
3165        let source = r#"{
3166            "expo": {
3167                "plugins": [
3168                    ["expo-font", {}]
3169                ]
3170            }
3171        }"#;
3172
3173        let value = extract_config_plugin_option_string(
3174            source,
3175            &json_path(),
3176            &["expo", "plugins"],
3177            "expo-router",
3178            "root",
3179        );
3180
3181        assert_eq!(value, None);
3182    }
3183
3184    #[test]
3185    fn vite_react_babel_dependencies_extract_plain_tuple_and_prefixed_entries() {
3186        let source = r#"
3187            import react from "@vitejs/plugin-react";
3188
3189            export default defineConfig({
3190                plugins: [
3191                    react({
3192                        babel: {
3193                            plugins: [
3194                                "babel-plugin-plain",
3195                                ["module:@preact/signals-react-transform", { mode: "auto" }],
3196                            ],
3197                            presets: [["@babel/preset-react", { runtime: "automatic" }]],
3198                        },
3199                    }),
3200                ],
3201            });
3202        "#;
3203
3204        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3205
3206        assert_eq!(
3207            deps,
3208            vec![
3209                "babel-plugin-plain".to_string(),
3210                "@preact/signals-react-transform".to_string(),
3211                "@babel/preset-react".to_string(),
3212            ]
3213        );
3214    }
3215
3216    #[test]
3217    fn vite_react_babel_dependencies_support_default_alias_import() {
3218        let source = r#"
3219            import { default as viteReact } from "@vitejs/plugin-react";
3220
3221            export default {
3222                plugins: [
3223                    viteReact({
3224                        babel: {
3225                            plugins: [["module:@scope/pkg/plugin", {}]],
3226                        },
3227                    }),
3228                ],
3229            };
3230        "#;
3231
3232        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3233
3234        assert_eq!(deps, vec!["@scope/pkg".to_string()]);
3235    }
3236
3237    #[test]
3238    fn vite_react_babel_dependencies_ignore_unrelated_plugin_calls() {
3239        let source = r#"
3240            import vue from "@vitejs/plugin-vue";
3241
3242            export default {
3243                plugins: [
3244                    vue({
3245                        babel: {
3246                            plugins: ["@preact/signals-react-transform"],
3247                        },
3248                    }),
3249                ],
3250            };
3251        "#;
3252
3253        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3254
3255        assert!(deps.is_empty());
3256    }
3257
3258    #[test]
3259    fn vite_react_babel_dependencies_skip_relative_and_protocol_entries() {
3260        let source = r#"
3261            import react from "@vitejs/plugin-react";
3262
3263            export default {
3264                plugins: [
3265                    react({
3266                        babel: {
3267                            plugins: ["./local-plugin", "module:./local-prefixed", "http://example.com/plugin"],
3268                        },
3269                    }),
3270                ],
3271            };
3272        "#;
3273
3274        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3275
3276        assert!(deps.is_empty());
3277    }
3278
3279    #[test]
3280    fn normalize_config_path_relative_to_root() {
3281        let config_path = PathBuf::from("/project/vite.config.ts");
3282        let root = PathBuf::from("/project");
3283
3284        assert_eq!(
3285            normalize_config_path("./src/lib", &config_path, &root),
3286            Some("src/lib".to_string())
3287        );
3288        assert_eq!(
3289            normalize_config_path("/src/lib", &config_path, &root),
3290            Some("src/lib".to_string())
3291        );
3292    }
3293
3294    #[test]
3295    fn normalize_config_path_mixed_separators_and_parent_dirs() {
3296        let config_path = PathBuf::from("/project/config/vite.config.ts");
3297        let root = PathBuf::from("/project");
3298
3299        assert_eq!(
3300            normalize_config_path(".\\src\\..\\app\\lib", &config_path, &root),
3301            Some("config/app/lib".to_string())
3302        );
3303    }
3304
3305    #[test]
3306    fn normalize_config_path_leading_slash_stays_project_relative() {
3307        let config_path = PathBuf::from("/project/vite.config.ts");
3308        let root = PathBuf::from("/project");
3309
3310        assert_eq!(
3311            normalize_config_path("/src\\lib", &config_path, &root),
3312            Some("src/lib".to_string())
3313        );
3314    }
3315
3316    #[test]
3317    fn json_wrapped_in_parens_string() {
3318        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
3319        let val = extract_config_string(source, &js_path(), &["extends"]);
3320        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
3321    }
3322
3323    #[test]
3324    fn json_wrapped_in_parens_nested_array() {
3325        let source =
3326            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
3327        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
3328        assert_eq!(types, vec!["node", "jest"]);
3329
3330        let include = extract_config_string_array(source, &js_path(), &["include"]);
3331        assert_eq!(include, vec!["src/**/*"]);
3332    }
3333
3334    #[test]
3335    fn json_wrapped_in_parens_object_keys() {
3336        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
3337        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3338        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
3339    }
3340
3341    fn json_path() -> PathBuf {
3342        PathBuf::from("config.json")
3343    }
3344
3345    #[test]
3346    fn json_file_parsed_correctly() {
3347        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
3348        let val = extract_config_string(source, &json_path(), &["key"]);
3349        assert_eq!(val, Some("value".to_string()));
3350
3351        let list = extract_config_string_array(source, &json_path(), &["list"]);
3352        assert_eq!(list, vec!["a", "b"]);
3353    }
3354
3355    #[test]
3356    fn jsonc_file_parsed_correctly() {
3357        let source = r#"{"key": "value"}"#;
3358        let path = PathBuf::from("tsconfig.jsonc");
3359        let val = extract_config_string(source, &path, &["key"]);
3360        assert_eq!(val, Some("value".to_string()));
3361    }
3362
3363    #[test]
3364    fn extract_define_config_arrow_function() {
3365        let source = r#"
3366            import { defineConfig } from 'vite';
3367            export default defineConfig(() => ({
3368                test: {
3369                    include: ["**/*.test.ts"]
3370                }
3371            }));
3372        "#;
3373        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3374        assert_eq!(include, vec!["**/*.test.ts"]);
3375    }
3376
3377    /// A block-bodied callback is the common shape for configs that branch on
3378    /// the build mode. Only the concise arrow used to be traversed, so every
3379    /// such config extracted nothing at all (issue #2005).
3380    #[test]
3381    fn extract_define_config_block_body_arrow_function() {
3382        let source = r#"
3383            import { defineConfig } from 'vite';
3384            export default defineConfig(({ mode }) => {
3385                const isProduction = mode === 'production';
3386                return {
3387                    test: {
3388                        environment: "jsdom",
3389                        setupFiles: "./tests/setup.ts"
3390                    },
3391                    base: isProduction ? '/app/' : '/'
3392                };
3393            });
3394        "#;
3395        assert_eq!(
3396            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3397            Some("jsdom")
3398        );
3399        assert_eq!(
3400            extract_config_string(source, &ts_path(), &["test", "setupFiles"]).as_deref(),
3401            Some("./tests/setup.ts")
3402        );
3403    }
3404
3405    #[test]
3406    fn extract_define_config_function_expression() {
3407        let source = r#"
3408            import { defineConfig } from 'vite';
3409            export default defineConfig(function () {
3410                return { test: { environment: "happy-dom" } };
3411            });
3412        "#;
3413        assert_eq!(
3414            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3415            Some("happy-dom")
3416        );
3417    }
3418
3419    /// A wrapper takes the config first and its own options after it. Scanning
3420    /// the argument list for the first object literal read the options object
3421    /// instead, which is the exact shape the @sentry/nextjs wizard emits.
3422    #[test]
3423    fn wrapper_options_object_does_not_shadow_named_config_arg() {
3424        let source = r#"
3425            const nextConfig = { pageExtensions: ["page.tsx"] };
3426            module.exports = withSentryConfig(nextConfig, { org: "o", project: "p", silent: true });
3427        "#;
3428        assert_eq!(
3429            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
3430            vec!["page.tsx"]
3431        );
3432    }
3433
3434    #[test]
3435    fn wrapper_options_object_does_not_shadow_named_config_arg_esm() {
3436        let source = r#"
3437            const nextConfig = { pageExtensions: ["page.tsx"] };
3438            export default withSentryConfig(nextConfig, { org: "o", project: "p" });
3439        "#;
3440        assert_eq!(
3441            extract_config_string_array(source, &ts_path(), &["pageExtensions"]),
3442            vec!["page.tsx"]
3443        );
3444    }
3445
3446    /// Vitest's documented way to share a Vite config with the test runner.
3447    #[test]
3448    fn merge_config_extracts_nested_define_config_object() {
3449        let source = r#"
3450            import { defineConfig, mergeConfig } from 'vitest/config';
3451            import viteConfig from './vite.config';
3452            export default mergeConfig(viteConfig, defineConfig({
3453                test: { environment: "jsdom" }
3454            }));
3455        "#;
3456        assert_eq!(
3457            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3458            Some("jsdom")
3459        );
3460    }
3461
3462    #[test]
3463    fn define_config_wrapping_merge_config_extracts_object() {
3464        let source = r#"
3465            import { defineConfig, mergeConfig } from 'vitest/config';
3466            import base from './base';
3467            export default defineConfig(mergeConfig(base, { test: { environment: "jsdom" } }));
3468        "#;
3469        assert_eq!(
3470            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3471            Some("jsdom")
3472        );
3473    }
3474
3475    /// Guards against "prefer an identifier-resolved const over any object
3476    /// literal", which would change which object every plain config reads.
3477    #[test]
3478    fn inline_object_at_argument_zero_still_wins() {
3479        let source = r#"
3480            const unrelated = { pageExtensions: ["wrong.tsx"] };
3481            module.exports = withSentryConfig({ pageExtensions: ["right.tsx"] }, { org: "o" });
3482        "#;
3483        assert_eq!(
3484            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
3485            vec!["right.tsx"]
3486        );
3487    }
3488
3489    /// Vite's documented shape for switching config by command: every branch
3490    /// returns and the callback has no return of its own, so extraction has to
3491    /// descend to find anything at all.
3492    #[test]
3493    fn conditional_config_callback_extracts_branch_return() {
3494        let source = r#"
3495            import { defineConfig } from 'vite';
3496            export default defineConfig(({ command }) => {
3497                if (command === "serve") {
3498                    return { test: { environment: "jsdom" } };
3499                } else {
3500                    return { test: { environment: "node" } };
3501                }
3502            });
3503        "#;
3504        assert_eq!(
3505            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3506            Some("jsdom"),
3507            "with no top-level return, the first branch in source order wins"
3508        );
3509    }
3510
3511    #[test]
3512    fn try_block_return_is_extracted() {
3513        let source = r#"
3514            export default (() => {
3515                try {
3516                    return { test: { environment: "jsdom" } };
3517                } catch (e) {
3518                    return { test: { environment: "node" } };
3519                }
3520            });
3521        "#;
3522        assert_eq!(
3523            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3524            Some("jsdom")
3525        );
3526    }
3527
3528    #[test]
3529    fn switch_case_return_is_extracted() {
3530        let source = r#"
3531            import { defineConfig } from 'vite';
3532            export default defineConfig(({ mode }) => {
3533                switch (mode) {
3534                    case "test":
3535                        return { test: { environment: "jsdom" } };
3536                    default:
3537                        return { test: { environment: "node" } };
3538                }
3539            });
3540        "#;
3541        assert_eq!(
3542            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3543            Some("jsdom")
3544        );
3545    }
3546
3547    /// A guard clause followed by the real return must resolve to the trailing
3548    /// return. Descending into branches before checking this level regressed it:
3549    /// the guard's early return shadowed the actual config.
3550    #[test]
3551    fn guard_clause_does_not_shadow_the_top_level_return() {
3552        let source = r#"
3553            import { defineConfig } from 'vite';
3554            export default defineConfig(({ mode }) => {
3555                if (!mode) {
3556                    return {};
3557                }
3558                return { test: { environment: "jsdom" } };
3559            });
3560        "#;
3561        assert_eq!(
3562            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3563            Some("jsdom"),
3564            "a return at the callback's own level is the main config"
3565        );
3566    }
3567
3568    /// The same precedence with an else-if chain, where every branch returns and
3569    /// the trailing return is the default config.
3570    #[test]
3571    fn else_if_chain_does_not_shadow_the_top_level_return() {
3572        let source = r#"
3573            import { defineConfig } from 'vite';
3574            export default defineConfig(({ mode }) => {
3575                if (mode === "a") {
3576                    return { base: "/a/" };
3577                } else if (mode === "b") {
3578                    return { base: "/b/" };
3579                }
3580                return { test: { environment: "jsdom" } };
3581            });
3582        "#;
3583        assert_eq!(
3584            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3585            Some("jsdom")
3586        );
3587    }
3588
3589    #[test]
3590    fn extract_config_from_default_export_function_declaration() {
3591        let source = r#"
3592            export default function createConfig() {
3593                return {
3594                    clientModules: ["./src/client/global.js"]
3595                };
3596            }
3597        "#;
3598
3599        let client_modules = extract_config_string_array(source, &ts_path(), &["clientModules"]);
3600        assert_eq!(client_modules, vec!["./src/client/global.js"]);
3601    }
3602
3603    #[test]
3604    fn extract_config_from_default_export_async_function_declaration() {
3605        let source = r#"
3606            export default async function createConfigAsync() {
3607                return {
3608                    docs: {
3609                        path: "knowledge"
3610                    }
3611                };
3612            }
3613        "#;
3614
3615        let docs_path = extract_config_string(source, &ts_path(), &["docs", "path"]);
3616        assert_eq!(docs_path, Some("knowledge".to_string()));
3617    }
3618
3619    #[test]
3620    fn extract_config_from_exported_arrow_function_identifier() {
3621        let source = r#"
3622            const config = async () => {
3623                return {
3624                    themes: ["classic"]
3625                };
3626            };
3627
3628            export default config;
3629        "#;
3630
3631        let themes = extract_config_shallow_strings(source, &ts_path(), "themes");
3632        assert_eq!(themes, vec!["classic"]);
3633    }
3634
3635    #[test]
3636    fn module_exports_nested_string() {
3637        let source = r#"
3638            module.exports = {
3639                resolve: {
3640                    alias: {
3641                        "@": "./src"
3642                    }
3643                }
3644            };
3645        "#;
3646        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
3647        assert_eq!(val, Some("./src".to_string()));
3648    }
3649
3650    #[test]
3651    fn property_strings_nested_objects() {
3652        let source = r#"
3653            export default {
3654                plugins: {
3655                    group1: { a: "val-a" },
3656                    group2: { b: "val-b" }
3657                }
3658            };
3659        "#;
3660        let values = extract_config_property_strings(source, &js_path(), "plugins");
3661        assert!(values.contains(&"val-a".to_string()));
3662        assert!(values.contains(&"val-b".to_string()));
3663    }
3664
3665    #[test]
3666    fn property_strings_missing_key_returns_empty() {
3667        let source = r#"export default { other: "value" };"#;
3668        let values = extract_config_property_strings(source, &js_path(), "missing");
3669        assert!(values.is_empty());
3670    }
3671
3672    #[test]
3673    fn shallow_strings_tuple_array() {
3674        let source = r#"
3675            module.exports = {
3676                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
3677            };
3678        "#;
3679        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
3680        assert_eq!(values, vec!["default", "jest-junit"]);
3681        assert!(!values.contains(&"reports".to_string()));
3682    }
3683
3684    #[test]
3685    fn shallow_strings_single_string() {
3686        let source = r#"export default { preset: "ts-jest" };"#;
3687        let values = extract_config_shallow_strings(source, &js_path(), "preset");
3688        assert_eq!(values, vec!["ts-jest"]);
3689    }
3690
3691    #[test]
3692    fn shallow_strings_missing_key() {
3693        let source = r#"export default { other: "val" };"#;
3694        let values = extract_config_shallow_strings(source, &js_path(), "missing");
3695        assert!(values.is_empty());
3696    }
3697
3698    #[test]
3699    fn shallow_strings_or_object_property_alias_objects() {
3700        let source = r#"
3701            export default {
3702                jsPlugins: [
3703                    "eslint-plugin-playwright",
3704                    ["eslint-plugin-regexp", { rules: {} }],
3705                    { name: "short", specifier: "eslint-plugin-with-long-name" }
3706                ]
3707            };
3708        "#;
3709        let values = extract_config_shallow_strings_or_object_property(
3710            source,
3711            &ts_path(),
3712            "jsPlugins",
3713            "specifier",
3714        );
3715        assert_eq!(
3716            values,
3717            vec![
3718                "eslint-plugin-playwright",
3719                "eslint-plugin-regexp",
3720                "eslint-plugin-with-long-name"
3721            ]
3722        );
3723    }
3724
3725    #[test]
3726    fn nested_shallow_strings_vitest_reporters() {
3727        let source = r#"
3728            export default {
3729                test: {
3730                    reporters: ["default", "vitest-sonar-reporter"]
3731                }
3732            };
3733        "#;
3734        let values =
3735            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3736        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3737    }
3738
3739    #[test]
3740    fn nested_shallow_strings_tuple_format() {
3741        let source = r#"
3742            export default {
3743                test: {
3744                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
3745                }
3746            };
3747        "#;
3748        let values =
3749            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3750        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3751    }
3752
3753    #[test]
3754    fn nested_shallow_strings_missing_outer() {
3755        let source = r"export default { other: {} };";
3756        let values =
3757            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3758        assert!(values.is_empty());
3759    }
3760
3761    #[test]
3762    fn nested_shallow_strings_missing_inner() {
3763        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
3764        let values =
3765            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3766        assert!(values.is_empty());
3767    }
3768
3769    #[test]
3770    fn string_or_array_missing_path() {
3771        let source = r"export default {};";
3772        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3773        assert!(result.is_empty());
3774    }
3775
3776    #[test]
3777    fn string_or_array_non_string_values() {
3778        let source = r"export default { entry: [42, true] };";
3779        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3780        assert!(result.is_empty());
3781    }
3782
3783    #[test]
3784    fn array_nested_extraction() {
3785        let source = r#"
3786            export default defineConfig({
3787                test: {
3788                    projects: [
3789                        {
3790                            test: {
3791                                setupFiles: ["./test/setup-a.ts"]
3792                            }
3793                        },
3794                        {
3795                            test: {
3796                                setupFiles: "./test/setup-b.ts"
3797                            }
3798                        }
3799                    ]
3800                }
3801            });
3802        "#;
3803        let results = extract_config_array_nested_string_or_array(
3804            source,
3805            &ts_path(),
3806            &["test", "projects"],
3807            &["test", "setupFiles"],
3808        );
3809        assert!(results.contains(&"./test/setup-a.ts".to_string()));
3810        assert!(results.contains(&"./test/setup-b.ts".to_string()));
3811    }
3812
3813    #[test]
3814    fn array_nested_empty_when_no_array() {
3815        let source = r#"export default { test: { projects: "not-an-array" } };"#;
3816        let results = extract_config_array_nested_string_or_array(
3817            source,
3818            &js_path(),
3819            &["test", "projects"],
3820            &["test", "setupFiles"],
3821        );
3822        assert!(results.is_empty());
3823    }
3824
3825    #[test]
3826    fn object_nested_extraction() {
3827        let source = r#"{
3828            "projects": {
3829                "app-one": {
3830                    "architect": {
3831                        "build": {
3832                            "options": {
3833                                "styles": ["src/styles.css"]
3834                            }
3835                        }
3836                    }
3837                }
3838            }
3839        }"#;
3840        let results = extract_config_object_nested_string_or_array(
3841            source,
3842            &json_path(),
3843            &["projects"],
3844            &["architect", "build", "options", "styles"],
3845        );
3846        assert_eq!(results, vec!["src/styles.css"]);
3847    }
3848
3849    #[test]
3850    fn array_with_object_input_form_extracted() {
3851        let source = r#"{
3852            "projects": {
3853                "app": {
3854                    "architect": {
3855                        "build": {
3856                            "options": {
3857                                "styles": [
3858                                    "src/styles.scss",
3859                                    { "input": "src/theme.scss", "bundleName": "theme", "inject": false },
3860                                    { "bundleName": "lazy-only" }
3861                                ]
3862                            }
3863                        }
3864                    }
3865                }
3866            }
3867        }"#;
3868        let results = extract_config_object_nested_string_or_array(
3869            source,
3870            &json_path(),
3871            &["projects"],
3872            &["architect", "build", "options", "styles"],
3873        );
3874        assert!(
3875            results.contains(&"src/styles.scss".to_string()),
3876            "string form must still work: {results:?}"
3877        );
3878        assert!(
3879            results.contains(&"src/theme.scss".to_string()),
3880            "object form with `input` must be extracted: {results:?}"
3881        );
3882        assert!(
3883            !results.contains(&"lazy-only".to_string()),
3884            "bundleName must not be misinterpreted as a path: {results:?}"
3885        );
3886        assert!(
3887            !results.contains(&"theme".to_string()),
3888            "bundleName from full object must not leak: {results:?}"
3889        );
3890    }
3891
3892    #[test]
3893    fn object_nested_strings_extraction() {
3894        let source = r#"{
3895            "targets": {
3896                "build": {
3897                    "executor": "@angular/build:application"
3898                },
3899                "test": {
3900                    "executor": "@nx/vite:test"
3901                }
3902            }
3903        }"#;
3904        let results =
3905            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
3906        assert!(results.contains(&"@angular/build:application".to_string()));
3907        assert!(results.contains(&"@nx/vite:test".to_string()));
3908    }
3909
3910    #[test]
3911    fn require_strings_direct_call() {
3912        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
3913        let deps = extract_config_require_strings(source, &js_path(), "adapter");
3914        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
3915    }
3916
3917    #[test]
3918    fn require_strings_no_matching_key() {
3919        let source = r"module.exports = { other: require('something') };";
3920        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3921        assert!(deps.is_empty());
3922    }
3923
3924    #[test]
3925    fn extract_imports_no_imports() {
3926        let source = r"export default {};";
3927        let imports = extract_imports(source, &js_path());
3928        assert!(imports.is_empty());
3929    }
3930
3931    #[test]
3932    fn extract_imports_side_effect_import() {
3933        let source = r"
3934            import 'polyfill';
3935            import './local-setup';
3936            export default {};
3937        ";
3938        let imports = extract_imports(source, &js_path());
3939        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
3940    }
3941
3942    #[test]
3943    fn extract_imports_mixed_specifiers() {
3944        let source = r"
3945            import defaultExport from 'module-a';
3946            import { named } from 'module-b';
3947            import * as ns from 'module-c';
3948            export default {};
3949        ";
3950        let imports = extract_imports(source, &js_path());
3951        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
3952    }
3953
3954    #[test]
3955    fn template_literal_in_string_or_array() {
3956        let source = r"export default { entry: `./src/index.ts` };";
3957        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
3958        assert_eq!(result, vec!["./src/index.ts"]);
3959    }
3960
3961    #[test]
3962    fn template_literal_in_config_string() {
3963        let source = r"export default { testDir: `./tests` };";
3964        let val = extract_config_string(source, &js_path(), &["testDir"]);
3965        assert_eq!(val, Some("./tests".to_string()));
3966    }
3967
3968    #[test]
3969    fn template_literal_command_recovers_static_command_tokens() {
3970        let source = r"
3971            const PORT = 3000;
3972            export default {
3973                webServer: {
3974                    command: `pnpm exec srvx --port ${PORT} --hostname 127.0.0.1`
3975                }
3976            };
3977        ";
3978        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3979        assert_eq!(
3980            val,
3981            Some("pnpm exec srvx --port   --hostname 127.0.0.1".to_string())
3982        );
3983    }
3984
3985    #[test]
3986    fn template_literal_command_skips_dynamic_prefix() {
3987        let source = r"
3988            export default {
3989                webServer: { command: `${serverCommand} && pnpm exec srvx` }
3990            };
3991        ";
3992        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3993        assert!(val.is_none());
3994    }
3995
3996    #[test]
3997    fn template_literal_command_skips_split_static_token() {
3998        let source = r"
3999            export default {
4000                webServer: { command: `pnpm exec sr${part}vx --port 3000` }
4001            };
4002        ";
4003        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
4004        assert!(val.is_none());
4005    }
4006
4007    #[test]
4008    fn array_object_command_pairs_recover_template_command() {
4009        let source = r"
4010            const PORT = 3000;
4011            export default {
4012                webServer: [
4013                    {
4014                        command: `pnpm exec srvx --port ${PORT}`,
4015                        cwd: 'apps/web'
4016                    }
4017                ]
4018            };
4019        ";
4020        let pairs = extract_config_array_object_command_pairs(
4021            source,
4022            &ts_path(),
4023            &["webServer"],
4024            "command",
4025            "cwd",
4026        );
4027        assert_eq!(
4028            pairs,
4029            vec![(
4030                "pnpm exec srvx --port  ".to_string(),
4031                Some("apps/web".to_string())
4032            )]
4033        );
4034    }
4035
4036    #[test]
4037    fn nested_string_array_empty_path() {
4038        let source = r#"export default { items: ["a", "b"] };"#;
4039        let result = extract_config_string_array(source, &js_path(), &[]);
4040        assert!(result.is_empty());
4041    }
4042
4043    #[test]
4044    fn nested_string_empty_path() {
4045        let source = r#"export default { key: "val" };"#;
4046        let result = extract_config_string(source, &js_path(), &[]);
4047        assert!(result.is_none());
4048    }
4049
4050    #[test]
4051    fn object_keys_empty_path() {
4052        let source = r"export default { plugins: {} };";
4053        let result = extract_config_object_keys(source, &js_path(), &[]);
4054        assert!(result.is_empty());
4055    }
4056
4057    #[test]
4058    fn no_config_object_returns_empty() {
4059        let source = r"const x = 42;";
4060        let result = extract_config_string(source, &js_path(), &["key"]);
4061        assert!(result.is_none());
4062
4063        let arr = extract_config_string_array(source, &js_path(), &["items"]);
4064        assert!(arr.is_empty());
4065
4066        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
4067        assert!(keys.is_empty());
4068    }
4069
4070    #[test]
4071    fn property_with_string_key() {
4072        let source = r#"export default { "string-key": "value" };"#;
4073        let val = extract_config_string(source, &js_path(), &["string-key"]);
4074        assert_eq!(val, Some("value".to_string()));
4075    }
4076
4077    #[test]
4078    fn nested_navigation_through_non_object() {
4079        let source = r#"export default { level1: "not-an-object" };"#;
4080        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
4081        assert!(val.is_none());
4082    }
4083
4084    #[test]
4085    fn variable_reference_untyped() {
4086        let source = r#"
4087            const config = {
4088                testDir: "./tests"
4089            };
4090            export default config;
4091        "#;
4092        let val = extract_config_string(source, &js_path(), &["testDir"]);
4093        assert_eq!(val, Some("./tests".to_string()));
4094    }
4095
4096    #[test]
4097    fn variable_reference_with_type_annotation() {
4098        let source = r#"
4099            import type { StorybookConfig } from '@storybook/react-vite';
4100            const config: StorybookConfig = {
4101                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
4102                framework: "@storybook/react-vite"
4103            };
4104            export default config;
4105        "#;
4106        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
4107        assert_eq!(
4108            addons,
4109            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
4110        );
4111
4112        let framework = extract_config_string(source, &ts_path(), &["framework"]);
4113        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
4114    }
4115
4116    #[test]
4117    fn variable_reference_with_define_config() {
4118        let source = r#"
4119            import { defineConfig } from 'vitest/config';
4120            const config = defineConfig({
4121                test: {
4122                    include: ["**/*.test.ts"]
4123                }
4124            });
4125            export default config;
4126        "#;
4127        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
4128        assert_eq!(include, vec!["**/*.test.ts"]);
4129    }
4130
4131    #[test]
4132    fn ts_satisfies_direct_export() {
4133        let source = r#"
4134            export default {
4135                testDir: "./tests"
4136            } satisfies PlaywrightTestConfig;
4137        "#;
4138        let val = extract_config_string(source, &ts_path(), &["testDir"]);
4139        assert_eq!(val, Some("./tests".to_string()));
4140    }
4141
4142    #[test]
4143    fn ts_as_direct_export() {
4144        let source = r#"
4145            export default {
4146                testDir: "./tests"
4147            } as const;
4148        "#;
4149        let val = extract_config_string(source, &ts_path(), &["testDir"]);
4150        assert_eq!(val, Some("./tests".to_string()));
4151    }
4152
4153    // --- issue #811: resolve.alias as imported identifier / spread ---
4154
4155    fn aliases(source: &str) -> Vec<(String, String)> {
4156        extract_config_aliases(source, &js_path(), &["resolve", "alias"])
4157    }
4158
4159    #[test]
4160    fn aliases_inline_object_still_extracted() {
4161        // Regression: the resolver must not change inline-object behavior.
4162        let source = r#"
4163            export default defineConfig({
4164                resolve: { alias: { "@": "./src", utils: "../../utils" } }
4165            });
4166        "#;
4167        let mut got = aliases(source);
4168        got.sort();
4169        assert_eq!(
4170            got,
4171            vec![
4172                ("@".to_string(), "./src".to_string()),
4173                ("utils".to_string(), "../../utils".to_string()),
4174            ]
4175        );
4176    }
4177
4178    #[test]
4179    fn aliases_inline_array_still_extracted() {
4180        let source = r#"
4181            export default defineConfig({
4182                resolve: { alias: [{ find: "@", replacement: "./src" }] }
4183            });
4184        "#;
4185        assert_eq!(
4186            aliases(source),
4187            vec![("@".to_string(), "./src".to_string())]
4188        );
4189    }
4190
4191    #[test]
4192    fn aliases_local_const_array_identifier() {
4193        let source = r#"
4194            const sharedAliases = [{ find: "@", replacement: "./src" }];
4195            export default defineConfig({ resolve: { alias: sharedAliases } });
4196        "#;
4197        assert_eq!(
4198            aliases(source),
4199            vec![("@".to_string(), "./src".to_string())]
4200        );
4201    }
4202
4203    #[test]
4204    fn aliases_local_const_object_identifier() {
4205        let source = r#"
4206            const sharedAliases = { "@": "./src" };
4207            export default defineConfig({ resolve: { alias: sharedAliases } });
4208        "#;
4209        assert_eq!(
4210            aliases(source),
4211            vec![("@".to_string(), "./src".to_string())]
4212        );
4213    }
4214
4215    #[test]
4216    fn aliases_array_spread_of_identifiers_and_inline() {
4217        let source = r##"
4218            const a = [{ find: "@", replacement: "./src" }];
4219            const b = [{ find: "~", replacement: "./lib" }];
4220            export default defineConfig({
4221                resolve: { alias: [...a, ...b, { find: "#", replacement: "./test" }] }
4222            });
4223        "##;
4224        let mut got = aliases(source);
4225        got.sort();
4226        assert_eq!(
4227            got,
4228            vec![
4229                ("#".to_string(), "./test".to_string()),
4230                ("@".to_string(), "./src".to_string()),
4231                ("~".to_string(), "./lib".to_string()),
4232            ]
4233        );
4234    }
4235
4236    #[test]
4237    fn aliases_object_spread_of_identifier_and_inline() {
4238        let source = r#"
4239            const base = { "@": "./src" };
4240            export default defineConfig({
4241                resolve: { alias: { ...base, "~": "./lib" } }
4242            });
4243        "#;
4244        let mut got = aliases(source);
4245        got.sort();
4246        assert_eq!(
4247            got,
4248            vec![
4249                ("@".to_string(), "./src".to_string()),
4250                ("~".to_string(), "./lib".to_string()),
4251            ]
4252        );
4253    }
4254
4255    #[test]
4256    fn aliases_local_const_chained_identifier() {
4257        // `const a = b` indirection resolves through the chain.
4258        let source = r#"
4259            const real = [{ find: "@", replacement: "./src" }];
4260            const alias2 = real;
4261            export default defineConfig({ resolve: { alias: alias2 } });
4262        "#;
4263        assert_eq!(
4264            aliases(source),
4265            vec![("@".to_string(), "./src".to_string())]
4266        );
4267    }
4268
4269    #[test]
4270    fn aliases_imported_named_identifier_from_sibling() {
4271        let dir = tempfile::tempdir().unwrap();
4272        std::fs::write(
4273            dir.path().join("vite.shared.js"),
4274            r#"export const sharedAliases = [
4275                { find: "@", replacement: new URL("./src", import.meta.url).pathname },
4276            ];"#,
4277        )
4278        .unwrap();
4279        let config = dir.path().join("vite.config.js");
4280        let source = r#"
4281            import { defineConfig } from "vite";
4282            import { sharedAliases } from "./vite.shared.js";
4283            export default defineConfig({ resolve: { alias: sharedAliases } });
4284        "#;
4285        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4286        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4287    }
4288
4289    #[test]
4290    fn aliases_imported_extensionless_specifier_probed() {
4291        let dir = tempfile::tempdir().unwrap();
4292        std::fs::write(
4293            dir.path().join("aliases.mjs"),
4294            r#"export const sharedAliases = { "@": "./src" };"#,
4295        )
4296        .unwrap();
4297        let config = dir.path().join("vite.config.ts");
4298        let source = r#"
4299            import { sharedAliases } from "./aliases";
4300            export default defineConfig({ resolve: { alias: sharedAliases } });
4301        "#;
4302        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4303        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4304    }
4305
4306    #[test]
4307    fn aliases_imported_default_export_from_sibling() {
4308        let dir = tempfile::tempdir().unwrap();
4309        std::fs::write(
4310            dir.path().join("aliases.js"),
4311            r#"export default [{ find: "@", replacement: "./src" }];"#,
4312        )
4313        .unwrap();
4314        let config = dir.path().join("vite.config.js");
4315        let source = r#"
4316            import sharedAliases from "./aliases.js";
4317            export default defineConfig({ resolve: { alias: sharedAliases } });
4318        "#;
4319        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4320        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4321    }
4322
4323    #[test]
4324    fn aliases_imported_spread_from_two_siblings() {
4325        let dir = tempfile::tempdir().unwrap();
4326        std::fs::write(
4327            dir.path().join("a.js"),
4328            r#"export const a = [{ find: "@", replacement: "./src" }];"#,
4329        )
4330        .unwrap();
4331        std::fs::write(
4332            dir.path().join("b.js"),
4333            r#"export const b = [{ find: "~", replacement: "./lib" }];"#,
4334        )
4335        .unwrap();
4336        let config = dir.path().join("vite.config.js");
4337        let source = r#"
4338            import { a } from "./a.js";
4339            import { b } from "./b.js";
4340            export default defineConfig({ resolve: { alias: [...a, ...b] } });
4341        "#;
4342        let mut got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4343        got.sort();
4344        assert_eq!(
4345            got,
4346            vec![
4347                ("@".to_string(), "./src".to_string()),
4348                ("~".to_string(), "./lib".to_string()),
4349            ]
4350        );
4351    }
4352
4353    #[test]
4354    fn aliases_import_cycle_terminates() {
4355        // a.js imports from b.js and vice versa; resolution must not hang and
4356        // should still recover the literal pairs present.
4357        let dir = tempfile::tempdir().unwrap();
4358        std::fs::write(
4359            dir.path().join("a.js"),
4360            r#"import { b } from "./b.js";
4361               export const a = [{ find: "@", replacement: "./src" }, ...b];"#,
4362        )
4363        .unwrap();
4364        std::fs::write(
4365            dir.path().join("b.js"),
4366            r#"import { a } from "./a.js";
4367               export const b = [...a];"#,
4368        )
4369        .unwrap();
4370        let config = dir.path().join("vite.config.js");
4371        let source = r#"
4372            import { a } from "./a.js";
4373            export default defineConfig({ resolve: { alias: a } });
4374        "#;
4375        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4376        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4377    }
4378
4379    #[test]
4380    fn aliases_non_relative_import_not_followed() {
4381        // A bare-package import is intentionally out of scope: no node_modules
4382        // read for an alias literal.
4383        let source = r#"
4384            import { sharedAliases } from "some-pkg";
4385            export default defineConfig({ resolve: { alias: sharedAliases } });
4386        "#;
4387        let dir = tempfile::tempdir().unwrap();
4388        let config = dir.path().join("vite.config.js");
4389        assert!(extract_config_aliases(source, &config, &["resolve", "alias"]).is_empty());
4390    }
4391
4392    #[test]
4393    fn aliases_object_array_value_takes_first_entry() {
4394        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets;
4395        // the resolver must take the first, matching the long-standing non-kinded
4396        // behavior the TypeScript plugin depends on. Regression guard for the
4397        // array-value case that the kinded unification briefly dropped.
4398        let source = r#"
4399            export default {
4400                compilerOptions: { paths: { "@/*": ["./src/*"], "~/*": ["./lib/*", "./vendor/*"] } }
4401            };
4402        "#;
4403        let mut got = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
4404        got.sort();
4405        assert_eq!(
4406            got,
4407            vec![
4408                ("@/*".to_string(), "./src/*".to_string()),
4409                ("~/*".to_string(), "./lib/*".to_string()),
4410            ]
4411        );
4412    }
4413
4414    #[test]
4415    fn aliases_kinded_preserves_is_bare_through_resolution() {
4416        // The bare-string vs path discriminator must survive identifier + spread
4417        // resolution (the test.alias package-to-package gate depends on it).
4418        let source = r#"
4419            const a = [{ find: "lodash-es", replacement: "lodash" }];
4420            export default defineConfig({
4421                resolve: { alias: [...a, { find: "@", replacement: "./src" }] }
4422            });
4423        "#;
4424        let mut got = extract_config_aliases_kinded(source, &js_path(), &["resolve", "alias"]);
4425        got.sort();
4426        assert_eq!(
4427            got,
4428            vec![
4429                ("@".to_string(), "./src".to_string(), false),
4430                ("lodash-es".to_string(), "lodash".to_string(), true),
4431            ]
4432        );
4433    }
4434
4435    #[test]
4436    fn aliases_kinded_preserves_is_bare_through_imported_spread() {
4437        let dir = tempfile::tempdir().unwrap();
4438        std::fs::write(
4439            dir.path().join("aliases.js"),
4440            r#"export const packageAliases = [{ find: "lodash-es", replacement: "lodash" }];"#,
4441        )
4442        .unwrap();
4443        let config = dir.path().join("vite.config.js");
4444        let source = r#"
4445            import { packageAliases } from "./aliases.js";
4446            export default defineConfig({
4447                resolve: { alias: [...packageAliases, { find: "@", replacement: "./src" }] }
4448            });
4449        "#;
4450        let mut got = extract_config_aliases_kinded(source, &config, &["resolve", "alias"]);
4451        got.sort();
4452        assert_eq!(
4453            got,
4454            vec![
4455                ("@".to_string(), "./src".to_string(), false),
4456                ("lodash-es".to_string(), "lodash".to_string(), true),
4457            ]
4458        );
4459    }
4460
4461    // --- extract_config_command ---
4462
4463    #[test]
4464    fn extract_command_string_literal() {
4465        let source = r#"export default { start: "node server.js" };"#;
4466        let val = extract_config_command(source, &js_path(), &["start"]);
4467        assert_eq!(val, Some("node server.js".to_string()));
4468    }
4469
4470    #[test]
4471    fn extract_command_nested_path() {
4472        let source = r#"
4473            export default {
4474                scripts: {
4475                    dev: "vite dev"
4476                }
4477            };
4478        "#;
4479        let val = extract_config_command(source, &js_path(), &["scripts", "dev"]);
4480        assert_eq!(val, Some("vite dev".to_string()));
4481    }
4482
4483    #[test]
4484    fn extract_command_missing_key_returns_none() {
4485        let source = r#"export default { other: "val" };"#;
4486        let val = extract_config_command(source, &js_path(), &["start"]);
4487        assert!(val.is_none());
4488    }
4489
4490    #[test]
4491    fn extract_command_ts_as_expression() {
4492        let source = r#"export default { start: "node server.js" as string };"#;
4493        let val = extract_config_command(source, &ts_path(), &["start"]);
4494        assert_eq!(val, Some("node server.js".to_string()));
4495    }
4496
4497    #[test]
4498    fn extract_command_ts_satisfies_expression() {
4499        let source = r#"export default { start: "node server.js" satisfies string };"#;
4500        let val = extract_config_command(source, &ts_path(), &["start"]);
4501        assert_eq!(val, Some("node server.js".to_string()));
4502    }
4503
4504    #[test]
4505    fn extract_command_parenthesized_expression() {
4506        let source = r#"export default { start: ("node server.js") };"#;
4507        let val = extract_config_command(source, &js_path(), &["start"]);
4508        assert_eq!(val, Some("node server.js".to_string()));
4509    }
4510
4511    #[test]
4512    fn extract_command_empty_path_returns_none() {
4513        let source = r#"export default { start: "node server.js" };"#;
4514        let val = extract_config_command(source, &js_path(), &[]);
4515        assert!(val.is_none());
4516    }
4517
4518    // --- is_disabled_expression and extract_config_truthy_bool_or_object ---
4519
4520    #[test]
4521    fn truthy_bool_or_object_with_true_value() {
4522        let source = r"export default { typescript: true };";
4523        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4524        assert!(result);
4525    }
4526
4527    #[test]
4528    fn truthy_bool_or_object_with_false_value() {
4529        let source = r"export default { typescript: false };";
4530        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4531        assert!(!result);
4532    }
4533
4534    #[test]
4535    fn truthy_bool_or_object_with_object_value() {
4536        let source = r#"export default { typescript: { reactDocgen: "react-docgen" } };"#;
4537        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4538        assert!(result);
4539    }
4540
4541    #[test]
4542    fn truthy_bool_or_object_missing_key_returns_false() {
4543        let source = r"export default { other: true };";
4544        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4545        assert!(!result);
4546    }
4547
4548    #[test]
4549    fn truthy_bool_or_object_with_string_value_returns_false() {
4550        // A string is neither bool true nor object, so the else arm returns false.
4551        let source = r#"export default { typescript: "yes" };"#;
4552        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4553        assert!(!result);
4554    }
4555
4556    #[test]
4557    fn truthy_bool_or_object_ts_satisfies_wrapper() {
4558        let source = r"export default { typescript: (true satisfies boolean) };";
4559        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4560        assert!(result);
4561    }
4562
4563    #[test]
4564    fn truthy_bool_or_object_ts_as_wrapper() {
4565        let source = r"export default { typescript: (true as boolean) };";
4566        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4567        assert!(result);
4568    }
4569
4570    #[test]
4571    fn truthy_bool_or_object_parenthesized_wrapper() {
4572        let source = r"export default { typescript: (true) };";
4573        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4574        assert!(result);
4575    }
4576
4577    // --- object_expression helper: exercises via static dir entries property_string ---
4578    // property_object calls object_expression; it is also exercised through
4579    // extract_object_from_expression, which handles TS wrappers at the top-export level.
4580    // The ts_satisfies_direct_export / ts_as_direct_export tests already cover those arms.
4581
4582    #[test]
4583    fn static_dir_entries_object_form_exercises_property_string() {
4584        // property_string (which calls property_expr then expression_to_string) is used
4585        // for the `from` and `to` keys in extract_config_static_dir_entries.
4586        let source = r#"
4587            export default {
4588                staticDirs: [
4589                    { from: "./media", to: "/assets" }
4590                ]
4591            };
4592        "#;
4593        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4594        assert_eq!(
4595            entries,
4596            vec![("./media".to_string(), Some("/assets".to_string()))]
4597        );
4598    }
4599
4600    // --- expression_to_path_values (array form) ---
4601
4602    #[test]
4603    fn expression_to_path_values_array_form_via_config_path() {
4604        // The extract_config_path helper uses expression_to_path; path_values
4605        // is exercised when the value is an array via extract_config_string_or_array.
4606        let source = r#"export default { entries: ["./src/a.ts", "./src/b.ts"] };"#;
4607        let result = extract_config_string_or_array(source, &js_path(), &["entries"]);
4608        assert_eq!(result, vec!["./src/a.ts", "./src/b.ts"]);
4609    }
4610
4611    // --- extract_config_array_nested_aliases ---
4612
4613    #[test]
4614    fn array_nested_aliases_object_form() {
4615        let source = r#"
4616            export default {
4617                test: {
4618                    projects: [
4619                        {
4620                            resolve: {
4621                                alias: { "@": "./src" }
4622                            }
4623                        }
4624                    ]
4625                }
4626            };
4627        "#;
4628        let aliases = extract_config_array_nested_aliases(
4629            source,
4630            &ts_path(),
4631            &["test", "projects"],
4632            &["resolve", "alias"],
4633        );
4634        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
4635    }
4636
4637    #[test]
4638    fn array_nested_aliases_array_form_find_replacement() {
4639        let source = r#"
4640            export default {
4641                projects: [
4642                    {
4643                        resolve: {
4644                            alias: [
4645                                { find: "@", replacement: "./src" },
4646                                { find: "~", replacement: "./lib" }
4647                            ]
4648                        }
4649                    }
4650                ]
4651            };
4652        "#;
4653        let aliases = extract_config_array_nested_aliases(
4654            source,
4655            &ts_path(),
4656            &["projects"],
4657            &["resolve", "alias"],
4658        );
4659        assert_eq!(
4660            aliases,
4661            vec![
4662                ("@".to_string(), "./src".to_string()),
4663                ("~".to_string(), "./lib".to_string()),
4664            ]
4665        );
4666    }
4667
4668    #[test]
4669    fn array_nested_aliases_empty_when_path_is_not_array() {
4670        let source = r#"export default { test: { projects: "not-an-array" } };"#;
4671        let aliases = extract_config_array_nested_aliases(
4672            source,
4673            &ts_path(),
4674            &["test", "projects"],
4675            &["resolve", "alias"],
4676        );
4677        assert!(aliases.is_empty());
4678    }
4679
4680    #[test]
4681    fn array_nested_aliases_kinded_tracks_is_bare() {
4682        let source = r#"
4683            export default {
4684                projects: [
4685                    {
4686                        resolve: {
4687                            alias: [
4688                                { find: "lodash-es", replacement: "lodash" },
4689                                { find: "@", replacement: "./src" }
4690                            ]
4691                        }
4692                    }
4693                ]
4694            };
4695        "#;
4696        let mut aliases = extract_config_array_nested_aliases_kinded(
4697            source,
4698            &ts_path(),
4699            &["projects"],
4700            &["resolve", "alias"],
4701        );
4702        aliases.sort();
4703        assert_eq!(
4704            aliases,
4705            vec![
4706                ("@".to_string(), "./src".to_string(), false),
4707                ("lodash-es".to_string(), "lodash".to_string(), true),
4708            ]
4709        );
4710    }
4711
4712    // --- extract_default_export_array_aliases_kinded ---
4713
4714    #[test]
4715    fn default_export_array_aliases_kinded_extracts_from_workspace_config() {
4716        let source = r#"
4717            export default [
4718                {
4719                    resolve: {
4720                        alias: { "@": "./src" }
4721                    }
4722                },
4723                {
4724                    resolve: {
4725                        alias: [{ find: "~", replacement: "./lib" }]
4726                    }
4727                }
4728            ];
4729        "#;
4730        let mut aliases =
4731            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4732        aliases.sort();
4733        assert_eq!(
4734            aliases,
4735            vec![
4736                ("@".to_string(), "./src".to_string(), false),
4737                ("~".to_string(), "./lib".to_string(), false),
4738            ]
4739        );
4740    }
4741
4742    #[test]
4743    fn default_export_array_aliases_kinded_define_workspace_wrapper() {
4744        let source = r#"
4745            export default defineWorkspace([
4746                {
4747                    resolve: { alias: { "@": "./src" } }
4748                }
4749            ]);
4750        "#;
4751        let aliases =
4752            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4753        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
4754    }
4755
4756    #[test]
4757    fn default_export_array_aliases_kinded_empty_when_no_alias_path() {
4758        let source = r#"
4759            export default [
4760                { test: { include: ["**/*.test.ts"] } }
4761            ];
4762        "#;
4763        let aliases =
4764            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4765        assert!(aliases.is_empty());
4766    }
4767
4768    // --- config_default_export_unreachable ---
4769
4770    #[test]
4771    fn config_default_export_unreachable_when_no_export() {
4772        let source = r"const x = 42;";
4773        assert!(config_default_export_unreachable(source, &js_path()));
4774    }
4775
4776    #[test]
4777    fn config_default_export_unreachable_false_for_object_export() {
4778        let source = r#"export default { key: "value" };"#;
4779        assert!(!config_default_export_unreachable(source, &js_path()));
4780    }
4781
4782    #[test]
4783    fn config_default_export_unreachable_false_for_array_export() {
4784        let source = r#"export default ["a", "b"];"#;
4785        assert!(!config_default_export_unreachable(source, &js_path()));
4786    }
4787
4788    #[test]
4789    fn config_default_export_unreachable_true_for_function_without_return_object() {
4790        // A function that returns a number is unreachable.
4791        let source = r"export default function config() { return 42; }";
4792        assert!(config_default_export_unreachable(source, &js_path()));
4793    }
4794
4795    // --- extract_config_static_dir_entries ---
4796
4797    #[test]
4798    fn static_dir_entries_string_and_object_form() {
4799        let source = r#"
4800            export default {
4801                staticDirs: [
4802                    "./public",
4803                    { from: "../assets", to: "/static" }
4804                ]
4805            };
4806        "#;
4807        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4808        assert_eq!(
4809            entries,
4810            vec![
4811                ("./public".to_string(), None),
4812                ("../assets".to_string(), Some("/static".to_string())),
4813            ]
4814        );
4815    }
4816
4817    #[test]
4818    fn static_dir_entries_object_without_to() {
4819        let source = r#"
4820            export default {
4821                staticDirs: [
4822                    { from: "./media" }
4823                ]
4824            };
4825        "#;
4826        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4827        assert_eq!(entries, vec![("./media".to_string(), None)]);
4828    }
4829
4830    #[test]
4831    fn static_dir_entries_object_missing_from_skipped() {
4832        // Objects without a `from` key are silently skipped.
4833        let source = r#"
4834            export default {
4835                staticDirs: [
4836                    { to: "/target" },
4837                    "./public"
4838                ]
4839            };
4840        "#;
4841        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4842        assert_eq!(entries, vec![("./public".to_string(), None)]);
4843    }
4844
4845    #[test]
4846    fn static_dir_entries_empty_when_not_array() {
4847        let source = r#"export default { staticDirs: "./public" };"#;
4848        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4849        assert!(entries.is_empty());
4850    }
4851
4852    // --- expression_to_alias_pairs and expression_to_alias_pairs_kinded (lines 1473-1541) ---
4853
4854    #[test]
4855    fn aliases_array_form_missing_find_or_replacement_skipped() {
4856        // An element missing "find" or "replacement" is silently skipped.
4857        let source = r#"
4858            export default {
4859                resolve: {
4860                    alias: [
4861                        { replacement: "./src" },
4862                        { find: "@" },
4863                        { find: "~", replacement: "./lib" }
4864                    ]
4865                }
4866            };
4867        "#;
4868        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4869        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
4870    }
4871
4872    #[test]
4873    fn aliases_object_form_computed_key_skipped() {
4874        // Computed keys (expression keys) are not statically recoverable.
4875        let source = r#"
4876            const k = "@";
4877            export default {
4878                resolve: {
4879                    alias: {
4880                        [k]: "./src",
4881                        "~": "./lib"
4882                    }
4883                }
4884            };
4885        "#;
4886        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4887        // Only the literal key "~" survives; computed [k] is dropped.
4888        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
4889    }
4890
4891    #[test]
4892    fn aliases_kinded_array_form_path_replacement_is_not_bare() {
4893        let source = r#"
4894            export default {
4895                resolve: {
4896                    alias: [{ find: "@", replacement: "./src" }]
4897                }
4898            };
4899        "#;
4900        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4901        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
4902    }
4903
4904    #[test]
4905    fn aliases_kinded_object_form_bare_and_path_discrimination() {
4906        let source = r#"
4907            export default {
4908                resolve: {
4909                    alias: {
4910                        "lodash-es": "lodash",
4911                        "@": "./src"
4912                    }
4913                }
4914            };
4915        "#;
4916        let mut aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4917        aliases.sort();
4918        assert_eq!(
4919            aliases,
4920            vec![
4921                ("@".to_string(), "./src".to_string(), false),
4922                ("lodash-es".to_string(), "lodash".to_string(), true),
4923            ]
4924        );
4925    }
4926
4927    #[test]
4928    fn aliases_kinded_parent_relative_replacement_is_not_bare() {
4929        let source = r#"
4930            export default {
4931                resolve: { alias: { "@": "../shared/src" } }
4932            };
4933        "#;
4934        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4935        assert_eq!(
4936            aliases,
4937            vec![("@".to_string(), "../shared/src".to_string(), false)]
4938        );
4939    }
4940
4941    #[test]
4942    fn aliases_kinded_absolute_replacement_is_not_bare() {
4943        let source = r#"
4944            export default {
4945                resolve: { alias: { "@": "/absolute/path" } }
4946            };
4947        "#;
4948        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4949        assert_eq!(
4950            aliases,
4951            vec![("@".to_string(), "/absolute/path".to_string(), false)]
4952        );
4953    }
4954
4955    // --- find_default_export_array / array_from_expression wrappers ---
4956
4957    #[test]
4958    fn default_export_array_ts_as_wrapper() {
4959        // array_from_expression must unwrap TSAsExpression.
4960        let source = r"export default [] as string[];";
4961        assert!(!config_default_export_unreachable(source, &js_path()));
4962    }
4963
4964    #[test]
4965    fn default_export_array_ts_satisfies_wrapper() {
4966        let source = r"export default [] satisfies string[];";
4967        assert!(!config_default_export_unreachable(source, &ts_path()));
4968    }
4969
4970    #[test]
4971    fn default_export_array_define_config_call_wrapper() {
4972        let source = r#"export default defineConfig(["**/*.test.ts"]);"#;
4973        assert!(!config_default_export_unreachable(source, &ts_path()));
4974    }
4975
4976    // --- collect_shallow_string_values: object-property branches ---
4977
4978    #[test]
4979    fn shallow_strings_object_with_string_values() {
4980        // The ObjectExpression arm of collect_shallow_string_values emits string values.
4981        let source = r#"
4982            export default {
4983                plugins: {
4984                    autoprefixer: "autoprefixer",
4985                    tailwindcss: "tailwindcss"
4986                }
4987            };
4988        "#;
4989        let vals = extract_config_shallow_strings(source, &js_path(), "plugins");
4990        assert!(vals.contains(&"autoprefixer".to_string()));
4991        assert!(vals.contains(&"tailwindcss".to_string()));
4992    }
4993
4994    #[test]
4995    fn shallow_strings_object_with_sub_array_first_element() {
4996        // An object property whose value is an array emits the first string element.
4997        let source = r#"
4998            export default {
4999                reporters: {
5000                    main: ["jest-junit", { outputFile: "report.xml" }],
5001                    alt: ["html-reporter"]
5002                }
5003            };
5004        "#;
5005        let vals = extract_config_shallow_strings(source, &js_path(), "reporters");
5006        assert!(vals.contains(&"jest-junit".to_string()));
5007        assert!(vals.contains(&"html-reporter".to_string()));
5008    }
5009
5010    // --- collect_shallow_string_or_object_property_values ---
5011
5012    #[test]
5013    fn shallow_strings_or_object_property_non_array_single_string() {
5014        // When the top-level value is a plain string (not an array), it is returned directly.
5015        let source = r#"export default { jsPlugins: "eslint-plugin-foo" };"#;
5016        let vals = extract_config_shallow_strings_or_object_property(
5017            source,
5018            &ts_path(),
5019            "jsPlugins",
5020            "specifier",
5021        );
5022        assert_eq!(vals, vec!["eslint-plugin-foo"]);
5023    }
5024
5025    #[test]
5026    fn shallow_strings_or_object_property_ts_satisfies_array_element() {
5027        // shallow_string_or_object_property unwraps TSSatisfiesExpression.
5028        let source = r#"
5029            export default {
5030                jsPlugins: [
5031                    ("eslint-plugin-a" satisfies string)
5032                ]
5033            };
5034        "#;
5035        let vals = extract_config_shallow_strings_or_object_property(
5036            source,
5037            &ts_path(),
5038            "jsPlugins",
5039            "specifier",
5040        );
5041        assert_eq!(vals, vec!["eslint-plugin-a"]);
5042    }
5043
5044    #[test]
5045    fn shallow_strings_or_object_property_ts_as_array_element() {
5046        let source = r#"
5047            export default {
5048                jsPlugins: [
5049                    ("eslint-plugin-b" as string)
5050                ]
5051            };
5052        "#;
5053        let vals = extract_config_shallow_strings_or_object_property(
5054            source,
5055            &ts_path(),
5056            "jsPlugins",
5057            "specifier",
5058        );
5059        assert_eq!(vals, vec!["eslint-plugin-b"]);
5060    }
5061
5062    #[test]
5063    fn shallow_strings_or_object_property_sub_array_first_element_string() {
5064        // A sub-array in jsPlugins returns the first string element.
5065        let source = r#"
5066            export default {
5067                jsPlugins: [
5068                    ["eslint-plugin-tuple-pkg", { options: true }]
5069                ]
5070            };
5071        "#;
5072        let vals = extract_config_shallow_strings_or_object_property(
5073            source,
5074            &ts_path(),
5075            "jsPlugins",
5076            "specifier",
5077        );
5078        assert_eq!(vals, vec!["eslint-plugin-tuple-pkg"]);
5079    }
5080
5081    // --- extract_config_array_object_command_pairs ---
5082
5083    #[test]
5084    fn array_object_command_pairs_basic() {
5085        let source = r#"
5086            export default {
5087                webServer: [
5088                    { command: "node server.js", cwd: "packages/api" },
5089                    { command: "vite dev" }
5090                ]
5091            };
5092        "#;
5093        let pairs = extract_config_array_object_command_pairs(
5094            source,
5095            &ts_path(),
5096            &["webServer"],
5097            "command",
5098            "cwd",
5099        );
5100        assert_eq!(
5101            pairs,
5102            vec![
5103                (
5104                    "node server.js".to_string(),
5105                    Some("packages/api".to_string())
5106                ),
5107                ("vite dev".to_string(), None),
5108            ]
5109        );
5110    }
5111
5112    #[test]
5113    fn array_object_command_pairs_skips_missing_command() {
5114        let source = r#"
5115            export default {
5116                webServer: [
5117                    { cwd: "packages/api" },
5118                    { command: "vite dev", cwd: "apps/web" }
5119                ]
5120            };
5121        "#;
5122        let pairs = extract_config_array_object_command_pairs(
5123            source,
5124            &ts_path(),
5125            &["webServer"],
5126            "command",
5127            "cwd",
5128        );
5129        assert_eq!(
5130            pairs,
5131            vec![("vite dev".to_string(), Some("apps/web".to_string()))]
5132        );
5133    }
5134
5135    #[test]
5136    fn array_object_command_pairs_empty_when_not_array() {
5137        let source = r#"export default { webServer: { command: "vite dev" } };"#;
5138        let pairs = extract_config_array_object_command_pairs(
5139            source,
5140            &ts_path(),
5141            &["webServer"],
5142            "command",
5143            "cwd",
5144        );
5145        assert!(pairs.is_empty());
5146    }
5147
5148    // --- normalize_config_path edge cases ---
5149
5150    #[test]
5151    fn normalize_config_path_empty_string_returns_none() {
5152        let config_path = PathBuf::from("/project/vite.config.ts");
5153        let root = PathBuf::from("/project");
5154        assert_eq!(normalize_config_path("", &config_path, &root), None);
5155    }
5156
5157    #[test]
5158    fn normalize_config_path_escapes_to_above_root_returns_none() {
5159        let config_path = PathBuf::from("/project/vite.config.ts");
5160        let root = PathBuf::from("/project");
5161        // "../../etc" normalizes to the parent of root, which fails the strip_prefix.
5162        assert_eq!(
5163            normalize_config_path("../../etc", &config_path, &root),
5164            None
5165        );
5166    }
5167
5168    #[test]
5169    fn normalize_config_path_dot_slash_resolves_relative_to_config_dir() {
5170        let config_path = PathBuf::from("/project/packages/app/vite.config.ts");
5171        let root = PathBuf::from("/project");
5172        assert_eq!(
5173            normalize_config_path("./src", &config_path, &root),
5174            Some("packages/app/src".to_string())
5175        );
5176    }
5177
5178    // --- JSON config parsing edge cases ---
5179
5180    #[test]
5181    fn json_config_array_of_arrays_via_shallow_strings() {
5182        // JSON with nested plugin tuples is parsed via the parenthesis-wrap path.
5183        let source = r#"{"reporters": ["default", ["jest-junit", {}]]}"#;
5184        let vals = extract_config_shallow_strings(source, &json_path(), "reporters");
5185        assert_eq!(vals, vec!["default", "jest-junit"]);
5186    }
5187
5188    // --- extract_config_path ---
5189
5190    #[test]
5191    fn extract_config_path_string_literal() {
5192        let source = r#"export default { outDir: "./dist" };"#;
5193        let path = extract_config_path(source, &js_path(), &["outDir"]);
5194        assert_eq!(
5195            path.map(|p| p.to_string_lossy().replace('\\', "/")),
5196            Some("./dist".to_string())
5197        );
5198    }
5199
5200    #[test]
5201    fn extract_config_path_with_resolve_call() {
5202        let source = r#"
5203            import { resolve } from "node:path";
5204            export default { outDir: resolve(__dirname, "dist") };
5205        "#;
5206        let path = extract_config_path(source, &js_path(), &["outDir"]);
5207        assert_eq!(
5208            path.map(|p| p.to_string_lossy().replace('\\', "/")),
5209            Some("dist".to_string())
5210        );
5211    }
5212
5213    #[test]
5214    fn extract_config_path_missing_key_returns_none() {
5215        let source = r#"export default { other: "val" };"#;
5216        let path = extract_config_path(source, &js_path(), &["outDir"]);
5217        assert!(path.is_none());
5218    }
5219
5220    // --- extract_imports_and_requires ---
5221
5222    #[test]
5223    fn extract_imports_and_requires_both_forms() {
5224        let source = r"
5225            import foo from 'foo-pkg';
5226            require('bar-pkg');
5227            export default {};
5228        ";
5229        let sources = extract_imports_and_requires(source, &js_path());
5230        assert!(sources.contains(&"foo-pkg".to_string()));
5231        assert!(sources.contains(&"bar-pkg".to_string()));
5232    }
5233
5234    #[test]
5235    fn extract_imports_and_requires_skips_non_require_calls() {
5236        let source = r"
5237            import foo from 'foo-pkg';
5238            someOtherCall('bar-pkg');
5239            export default {};
5240        ";
5241        let sources = extract_imports_and_requires(source, &js_path());
5242        assert_eq!(sources, vec!["foo-pkg"]);
5243    }
5244
5245    // --- extract_config_nested_shallow_strings: non-object nested value ---
5246
5247    #[test]
5248    fn nested_shallow_strings_non_object_nested_returns_empty() {
5249        // When the outer path points to a non-object, it returns empty.
5250        let source = r#"export default { test: "not-an-object" };"#;
5251        let vals =
5252            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
5253        assert!(vals.is_empty());
5254    }
5255
5256    // --- vite_react_babel_dependencies with namespace import ---
5257
5258    #[test]
5259    fn vite_react_babel_dependencies_namespace_import() {
5260        let source = r#"
5261            import * as react from "@vitejs/plugin-react";
5262
5263            export default defineConfig({
5264                plugins: [
5265                    react.default({
5266                        babel: {
5267                            plugins: ["babel-plugin-ns"],
5268                        },
5269                    }),
5270                ],
5271            });
5272        "#;
5273        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
5274        assert_eq!(deps, vec!["babel-plugin-ns".to_string()]);
5275    }
5276
5277    // --- collect_all_string_values nested object and array recursion ---
5278
5279    #[test]
5280    fn property_strings_deeply_nested_object_values() {
5281        // collect_all_string_values recurses into nested objects and arrays.
5282        let source = r#"
5283            export default {
5284                settings: {
5285                    a: "val-a",
5286                    b: {
5287                        c: "val-c",
5288                        d: ["val-d1", "val-d2"]
5289                    }
5290                }
5291            };
5292        "#;
5293        let values = extract_config_property_strings(source, &js_path(), "settings");
5294        assert!(values.contains(&"val-a".to_string()));
5295        assert!(values.contains(&"val-c".to_string()));
5296        assert!(values.contains(&"val-d1".to_string()));
5297        assert!(values.contains(&"val-d2".to_string()));
5298    }
5299
5300    // --- find_variable_init_expression: export const form ---
5301
5302    #[test]
5303    fn aliases_exported_const_form_resolves() {
5304        // find_variable_init_expression must handle `export const NAME = ...`.
5305        let source = r#"
5306            export const sharedAliases = { "@": "./src" };
5307            export default defineConfig({ resolve: { alias: sharedAliases } });
5308        "#;
5309        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5310        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5311    }
5312
5313    // --- resolve_sibling_module: index file probe ---
5314
5315    #[test]
5316    fn aliases_imported_from_sibling_directory_index_file() {
5317        // resolve_sibling_module probes <specifier>/index.<ext> when direct
5318        // path and extension-suffixed paths do not exist.
5319        let dir = tempfile::tempdir().unwrap();
5320        let aliases_dir = dir.path().join("aliases");
5321        std::fs::create_dir_all(&aliases_dir).unwrap();
5322        std::fs::write(
5323            aliases_dir.join("index.js"),
5324            r#"export const aliases = [{ find: "@", replacement: "./src" }];"#,
5325        )
5326        .unwrap();
5327        let config = dir.path().join("vite.config.js");
5328        let source = r#"
5329            import { aliases } from "./aliases";
5330            export default defineConfig({ resolve: { alias: aliases } });
5331        "#;
5332        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
5333        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
5334    }
5335
5336    // --- aliases max depth guard ---
5337
5338    #[test]
5339    fn aliases_depth_limit_terminates_deep_chain() {
5340        // A chain of more than MAX_ALIAS_RESOLVE_DEPTH identifiers terminates
5341        // without panic or infinite loop. We verify it does not crash.
5342        let source = r#"
5343            const a9 = [{ find: "@", replacement: "./src" }];
5344            const a8 = a9;
5345            const a7 = a8;
5346            const a6 = a7;
5347            const a5 = a6;
5348            const a4 = a5;
5349            const a3 = a4;
5350            const a2 = a3;
5351            const a1 = a2;
5352            export default defineConfig({ resolve: { alias: a1 } });
5353        "#;
5354        // At MAX_ALIAS_RESOLVE_DEPTH (8), resolution stops before reaching the literal.
5355        let got = extract_config_aliases(source, &js_path(), &["resolve", "alias"]);
5356        let _ = got; // empty or non-empty; both are valid, no panic is the assertion.
5357    }
5358
5359    // --- expression_to_path_string: new URL / fileURLToPath ---
5360
5361    #[test]
5362    fn extract_aliases_file_url_to_path_new_url() {
5363        // expression_to_path_string resolves new URL("./src", import.meta.url).
5364        let source = r#"
5365            import { fileURLToPath, URL } from 'node:url';
5366            export default {
5367                resolve: {
5368                    alias: {
5369                        "@": fileURLToPath(new URL("./src", import.meta.url))
5370                    }
5371                }
5372            };
5373        "#;
5374        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5375        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5376    }
5377
5378    #[test]
5379    fn extract_path_via_new_url_pathname_member() {
5380        // The .pathname member of new URL(...) is a path-string form.
5381        let source = r#"
5382            export default {
5383                resolve: {
5384                    alias: {
5385                        "@": new URL("./src", import.meta.url).pathname
5386                    }
5387                }
5388            };
5389        "#;
5390        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5391        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5392    }
5393
5394    // --- is_disabled_expression: null literal ---
5395
5396    #[test]
5397    fn truthy_bool_or_object_null_literal_returns_false() {
5398        // null is a disabled expression and therefore not truthy.
5399        let source = r"export default { typescript: null };";
5400        let result = extract_config_truthy_bool_or_object(source, &js_path(), &["typescript"]);
5401        assert!(!result);
5402    }
5403
5404    // --- expression_to_string_array: non-array form returns empty ---
5405
5406    #[test]
5407    fn string_array_non_array_value_returns_empty() {
5408        let source = r#"export default { items: "not-an-array" };"#;
5409        let result = extract_config_string_array(source, &js_path(), &["items"]);
5410        assert!(result.is_empty());
5411    }
5412
5413    // --- extract_config_object_nested edge cases ---
5414
5415    #[test]
5416    fn object_nested_empty_when_inner_value_is_not_object() {
5417        // extract_config_object_nested only processes properties whose value is an object.
5418        let source = r#"export default { targets: { build: "not-an-object" } };"#;
5419        let results =
5420            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
5421        assert!(results.is_empty());
5422    }
5423
5424    // --- extract_config_array_nested_string_or_array: missing inner path ---
5425
5426    #[test]
5427    fn array_nested_string_or_array_missing_inner_path_returns_empty() {
5428        let source = r#"
5429            export default {
5430                test: {
5431                    projects: [
5432                        { test: { include: ["**/*.test.ts"] } }
5433                    ]
5434                }
5435            };
5436        "#;
5437        let results = extract_config_array_nested_string_or_array(
5438            source,
5439            &ts_path(),
5440            &["test", "projects"],
5441            &["test", "setupFiles"],
5442        );
5443        assert!(results.is_empty());
5444    }
5445
5446    #[test]
5447    fn wrapped_named_const_default_export_resolves() {
5448        // `export default withMDX(nextConfig)` (official @next/mdx idiom): the
5449        // config is passed as a named const to a wrapper call. Regression #1642.
5450        let source = r#"
5451            import createMDX from "@next/mdx";
5452            const nextConfig = { pageExtensions: ["ts", "tsx", "md", "mdx"] };
5453            const withMDX = createMDX({});
5454            export default withMDX(nextConfig);
5455        "#;
5456        let exts = extract_config_string_array(source, &ts_path(), &["pageExtensions"]);
5457        assert_eq!(exts, vec!["ts", "tsx", "md", "mdx"]);
5458    }
5459
5460    #[test]
5461    fn wrapped_named_const_module_exports_resolves() {
5462        // `module.exports = createJestConfig(customConfig)` (next/jest idiom).
5463        let source = r#"
5464            const nextJest = require("next/jest");
5465            const createJestConfig = nextJest();
5466            const customConfig = { testMatch: ["**/*.test.ts"] };
5467            module.exports = createJestConfig(customConfig);
5468        "#;
5469        let matches = extract_config_string_array(source, &js_path(), &["testMatch"]);
5470        assert_eq!(matches, vec!["**/*.test.ts"]);
5471    }
5472
5473    #[test]
5474    fn wrapped_named_const_nested_and_curried_resolve() {
5475        let nested = r#"
5476            const nextConfig = { pageExtensions: ["mdx"] };
5477            const withMDX = (c) => c;
5478            const withFoo = (c) => c;
5479            export default withMDX(withFoo(nextConfig));
5480        "#;
5481        assert_eq!(
5482            extract_config_string_array(nested, &js_path(), &["pageExtensions"]),
5483            vec!["mdx"]
5484        );
5485
5486        let curried = r#"
5487            const nextConfig = { pageExtensions: ["md"] };
5488            const compose = (..._p) => (c) => c;
5489            export default compose(a, b)(nextConfig);
5490        "#;
5491        assert_eq!(
5492            extract_config_string_array(curried, &js_path(), &["pageExtensions"]),
5493            vec!["md"]
5494        );
5495    }
5496
5497    #[test]
5498    fn wrapped_inline_object_still_resolves() {
5499        // The pre-existing inline-object form must keep working unchanged.
5500        let source = r#"
5501            const withMDX = createMDX({});
5502            export default withMDX({ pageExtensions: ["mdx"] });
5503        "#;
5504        assert_eq!(
5505            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
5506            vec!["mdx"]
5507        );
5508    }
5509}