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/// Convert an expression to a string array if it's an array of string literals.
2174fn expression_to_string_array(expr: &Expression) -> Vec<String> {
2175    match expr {
2176        Expression::ArrayExpression(arr) => arr
2177            .elements
2178            .iter()
2179            .filter_map(|el| match el {
2180                ArrayExpressionElement::SpreadElement(_) => None,
2181                _ => el.as_expression().and_then(expression_to_string),
2182            })
2183            .collect(),
2184        _ => vec![],
2185    }
2186}
2187
2188/// Collect only top-level string values from an expression.
2189///
2190/// For arrays, extracts direct string elements and the first string element of sub-arrays
2191/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
2192fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
2193    let mut values = Vec::new();
2194    match expr {
2195        Expression::StringLiteral(s) => {
2196            values.push(s.value.to_string());
2197        }
2198        Expression::ArrayExpression(arr) => {
2199            for el in &arr.elements {
2200                if let Some(inner) = el.as_expression() {
2201                    match inner {
2202                        Expression::StringLiteral(s) => {
2203                            values.push(s.value.to_string());
2204                        }
2205                        Expression::ArrayExpression(sub_arr) => {
2206                            if let Some(first) = sub_arr.elements.first()
2207                                && let Some(first_expr) = first.as_expression()
2208                                && let Some(s) = expression_to_string(first_expr)
2209                            {
2210                                values.push(s);
2211                            }
2212                        }
2213                        _ => {}
2214                    }
2215                }
2216            }
2217        }
2218        Expression::ObjectExpression(obj) => {
2219            for prop in &obj.properties {
2220                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2221                    match &p.value {
2222                        Expression::StringLiteral(s) => {
2223                            values.push(s.value.to_string());
2224                        }
2225                        Expression::ArrayExpression(sub_arr) => {
2226                            if let Some(first) = sub_arr.elements.first()
2227                                && let Some(first_expr) = first.as_expression()
2228                                && let Some(s) = expression_to_string(first_expr)
2229                            {
2230                                values.push(s);
2231                            }
2232                        }
2233                        _ => {}
2234                    }
2235                }
2236            }
2237        }
2238        _ => {}
2239    }
2240    values
2241}
2242
2243/// Collect top-level string values, plus a named string property from object entries.
2244fn collect_shallow_string_or_object_property_values(
2245    expr: &Expression,
2246    object_property: &str,
2247) -> Vec<String> {
2248    match expr {
2249        Expression::ArrayExpression(arr) => arr
2250            .elements
2251            .iter()
2252            .filter_map(|element| {
2253                element
2254                    .as_expression()
2255                    .and_then(|expr| shallow_string_or_object_property(expr, object_property))
2256            })
2257            .collect(),
2258        _ => shallow_string_or_object_property(expr, object_property)
2259            .into_iter()
2260            .collect(),
2261    }
2262}
2263
2264fn shallow_string_or_object_property(expr: &Expression, object_property: &str) -> Option<String> {
2265    match expr {
2266        Expression::ParenthesizedExpression(paren) => {
2267            shallow_string_or_object_property(&paren.expression, object_property)
2268        }
2269        Expression::TSSatisfiesExpression(ts_sat) => {
2270            shallow_string_or_object_property(&ts_sat.expression, object_property)
2271        }
2272        Expression::TSAsExpression(ts_as) => {
2273            shallow_string_or_object_property(&ts_as.expression, object_property)
2274        }
2275        Expression::ArrayExpression(sub_arr) => sub_arr
2276            .elements
2277            .first()
2278            .and_then(ArrayExpressionElement::as_expression)
2279            .and_then(expression_to_string),
2280        Expression::ObjectExpression(obj) => {
2281            find_property(obj, object_property).and_then(|prop| expression_to_string(&prop.value))
2282        }
2283        _ => expression_to_string(expr),
2284    }
2285}
2286
2287/// Recursively collect all string literal values from an expression tree.
2288fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
2289    match expr {
2290        Expression::StringLiteral(s) => {
2291            values.push(s.value.to_string());
2292        }
2293        Expression::ArrayExpression(arr) => {
2294            for el in &arr.elements {
2295                if let Some(expr) = el.as_expression() {
2296                    collect_all_string_values(expr, values);
2297                }
2298            }
2299        }
2300        Expression::ObjectExpression(obj) => {
2301            for prop in &obj.properties {
2302                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2303                    collect_all_string_values(&p.value, values);
2304                }
2305            }
2306        }
2307        _ => {}
2308    }
2309}
2310
2311/// Convert a `PropertyKey` to a `String`.
2312fn property_key_to_string(key: &PropertyKey) -> Option<String> {
2313    match key {
2314        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
2315        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
2316        _ => None,
2317    }
2318}
2319
2320/// Extract keys of an object at a nested property path.
2321fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2322    if path.is_empty() {
2323        return None;
2324    }
2325    let prop = find_property(obj, path[0])?;
2326    if path.len() == 1 {
2327        if let Expression::ObjectExpression(nested) = &prop.value {
2328            let keys = nested
2329                .properties
2330                .iter()
2331                .filter_map(|p| {
2332                    if let ObjectPropertyKind::ObjectProperty(p) = p {
2333                        property_key_to_string(&p.key)
2334                    } else {
2335                        None
2336                    }
2337                })
2338                .collect();
2339            return Some(keys);
2340        }
2341        return None;
2342    }
2343    if let Expression::ObjectExpression(nested) = &prop.value {
2344        get_nested_object_keys(nested, &path[1..])
2345    } else {
2346        None
2347    }
2348}
2349
2350/// Navigate a nested property path and return the raw expression at the end.
2351fn get_nested_expression<'a>(
2352    obj: &'a ObjectExpression<'a>,
2353    path: &[&str],
2354) -> Option<&'a Expression<'a>> {
2355    if path.is_empty() {
2356        return None;
2357    }
2358    let prop = find_property(obj, path[0])?;
2359    if path.len() == 1 {
2360        return Some(&prop.value);
2361    }
2362    if let Expression::ObjectExpression(nested) = &prop.value {
2363        get_nested_expression(nested, &path[1..])
2364    } else {
2365        None
2366    }
2367}
2368
2369/// Navigate a nested path and extract a string, string array, or object string/array values.
2370fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2371    if path.is_empty() {
2372        return None;
2373    }
2374    if path.len() == 1 {
2375        let prop = find_property(obj, path[0])?;
2376        return Some(expression_to_string_or_array(&prop.value));
2377    }
2378    let prop = find_property(obj, path[0])?;
2379    if let Expression::ObjectExpression(nested) = &prop.value {
2380        get_nested_string_or_array(nested, &path[1..])
2381    } else {
2382        None
2383    }
2384}
2385
2386/// Convert an expression to a `Vec<String>`, handling string, array, object-with-string/array values,
2387/// and Webpack 5 entry descriptors (`{ import: "..." }`).
2388///
2389/// Array elements that are object literals are inspected for an `input` property
2390/// (Angular CLI schema for `styles`/`scripts`/`polyfills`:
2391/// `{ "input": "src/x.scss", "bundleName": "x", "inject": false }`). Extracting
2392/// `input` prevents object-form entries from being silently dropped. See #126.
2393fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
2394    match expr {
2395        Expression::StringLiteral(s) => vec![s.value.to_string()],
2396        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
2397            .quasis
2398            .first()
2399            .map(|q| vec![q.value.raw.to_string()])
2400            .unwrap_or_default(),
2401        Expression::ArrayExpression(arr) => arr
2402            .elements
2403            .iter()
2404            .filter_map(|el| el.as_expression())
2405            .flat_map(|e| match e {
2406                Expression::ObjectExpression(obj) => find_property(obj, "input")
2407                    .map(|p| expression_to_string_or_array(&p.value))
2408                    .unwrap_or_default(),
2409                _ => expression_to_path_string(e).into_iter().collect(),
2410            })
2411            .collect(),
2412        Expression::ObjectExpression(obj) => obj
2413            .properties
2414            .iter()
2415            .flat_map(|p| {
2416                if let ObjectPropertyKind::ObjectProperty(p) = p {
2417                    match &p.value {
2418                        Expression::ArrayExpression(_) => expression_to_string_or_array(&p.value),
2419                        Expression::ObjectExpression(value_obj) => {
2420                            find_property(value_obj, "import")
2421                                .map(|import_prop| {
2422                                    expression_to_string_or_array(&import_prop.value)
2423                                })
2424                                .unwrap_or_default()
2425                        }
2426                        _ => expression_to_path_string(&p.value).into_iter().collect(),
2427                    }
2428                } else {
2429                    Vec::new()
2430                }
2431            })
2432            .collect(),
2433        _ => expression_to_path_string(expr).into_iter().collect(),
2434    }
2435}
2436
2437/// Collect `require('...')` argument strings from an expression.
2438fn collect_require_sources(expr: &Expression) -> Vec<String> {
2439    let mut sources = Vec::new();
2440    match expr {
2441        Expression::CallExpression(call) if is_require_call(call) => {
2442            if let Some(s) = get_require_source(call) {
2443                sources.push(s);
2444            }
2445        }
2446        Expression::ArrayExpression(arr) => {
2447            for el in &arr.elements {
2448                if let Some(inner) = el.as_expression() {
2449                    match inner {
2450                        Expression::CallExpression(call) if is_require_call(call) => {
2451                            if let Some(s) = get_require_source(call) {
2452                                sources.push(s);
2453                            }
2454                        }
2455                        Expression::ArrayExpression(sub_arr) => {
2456                            if let Some(first) = sub_arr.elements.first()
2457                                && let Some(Expression::CallExpression(call)) =
2458                                    first.as_expression()
2459                                && is_require_call(call)
2460                                && let Some(s) = get_require_source(call)
2461                            {
2462                                sources.push(s);
2463                            }
2464                        }
2465                        _ => {}
2466                    }
2467                }
2468            }
2469        }
2470        _ => {}
2471    }
2472    sources
2473}
2474
2475/// Check if a call expression is `require(...)`.
2476fn is_require_call(call: &CallExpression) -> bool {
2477    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
2478}
2479
2480/// Get the first string argument of a `require()` call.
2481fn get_require_source(call: &CallExpression) -> Option<String> {
2482    call.arguments.first().and_then(|arg| {
2483        if let Argument::StringLiteral(s) = arg {
2484            Some(s.value.to_string())
2485        } else {
2486            None
2487        }
2488    })
2489}
2490
2491#[cfg(test)]
2492mod tests {
2493    use super::*;
2494    use std::path::PathBuf;
2495
2496    fn js_path() -> PathBuf {
2497        PathBuf::from("config.js")
2498    }
2499
2500    fn ts_path() -> PathBuf {
2501        PathBuf::from("config.ts")
2502    }
2503
2504    #[test]
2505    fn extract_lazy_imports_bare_arrows() {
2506        let source = r"
2507            import { defineConfig } from '@adonisjs/core/app'
2508            export default defineConfig({
2509                preloads: [
2510                    () => import('#start/routes'),
2511                    () => import('#start/kernel'),
2512                ],
2513            })
2514        ";
2515        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["preloads"]);
2516        assert_eq!(specs, vec!["#start/routes", "#start/kernel"]);
2517    }
2518
2519    #[test]
2520    fn extract_lazy_imports_object_form_with_file_key() {
2521        let source = r"
2522            export default defineConfig({
2523                providers: [
2524                    () => import('@adonisjs/core/providers/app_provider'),
2525                    {
2526                        file: () => import('@adonisjs/core/providers/repl_provider'),
2527                        environment: ['repl', 'test'],
2528                    },
2529                ],
2530            })
2531        ";
2532        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2533        assert_eq!(
2534            specs,
2535            vec![
2536                "@adonisjs/core/providers/app_provider",
2537                "@adonisjs/core/providers/repl_provider",
2538            ]
2539        );
2540    }
2541
2542    #[test]
2543    fn extract_lazy_imports_block_body_with_return() {
2544        let source = r"
2545            export default defineConfig({
2546                commands: [
2547                    () => { return import('@adonisjs/core/commands') },
2548                ],
2549            })
2550        ";
2551        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2552        assert_eq!(specs, vec!["@adonisjs/core/commands"]);
2553    }
2554
2555    #[test]
2556    fn extract_lazy_imports_skips_unknown_element_shapes() {
2557        let source = r"
2558            export default defineConfig({
2559                commands: [
2560                    'string-entry',
2561                    42,
2562                    { other: 'value' },
2563                    () => import('@adonisjs/lucid/commands'),
2564                ],
2565            })
2566        ";
2567        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2568        assert_eq!(specs, vec!["@adonisjs/lucid/commands"]);
2569    }
2570
2571    #[test]
2572    fn extract_lazy_imports_missing_property_returns_empty() {
2573        let source = r"
2574            export default defineConfig({
2575                preloads: [() => import('#start/routes')],
2576            })
2577        ";
2578        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2579        assert!(specs.is_empty());
2580    }
2581
2582    #[test]
2583    fn extract_imports_basic() {
2584        let source = r"
2585            import foo from 'foo-pkg';
2586            import { bar } from '@scope/bar';
2587            export default {};
2588        ";
2589        let imports = extract_imports(source, &js_path());
2590        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
2591    }
2592
2593    #[test]
2594    fn extract_default_export_object_property() {
2595        let source = r#"export default { testDir: "./tests" };"#;
2596        let val = extract_config_string(source, &js_path(), &["testDir"]);
2597        assert_eq!(val, Some("./tests".to_string()));
2598    }
2599
2600    #[test]
2601    fn extract_define_config_property() {
2602        let source = r#"
2603            import { defineConfig } from 'vitest/config';
2604            export default defineConfig({
2605                test: {
2606                    include: ["**/*.test.ts", "**/*.spec.ts"],
2607                    setupFiles: ["./test/setup.ts"]
2608                }
2609            });
2610        "#;
2611        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
2612        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
2613
2614        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
2615        assert_eq!(setup, vec!["./test/setup.ts"]);
2616    }
2617
2618    #[test]
2619    fn extract_module_exports_property() {
2620        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
2621        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
2622        assert_eq!(val, Some("jsdom".to_string()));
2623    }
2624
2625    #[test]
2626    fn extract_nested_string_array() {
2627        let source = r#"
2628            export default {
2629                resolve: {
2630                    alias: {
2631                        "@": "./src"
2632                    }
2633                },
2634                test: {
2635                    include: ["src/**/*.test.ts"]
2636                }
2637            };
2638        "#;
2639        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
2640        assert_eq!(include, vec!["src/**/*.test.ts"]);
2641    }
2642
2643    #[test]
2644    fn extract_addons_array() {
2645        let source = r#"
2646            export default {
2647                addons: [
2648                    "@storybook/addon-a11y",
2649                    "@storybook/addon-docs",
2650                    "@storybook/addon-links"
2651                ]
2652            };
2653        "#;
2654        let addons = extract_config_property_strings(source, &ts_path(), "addons");
2655        assert_eq!(
2656            addons,
2657            vec![
2658                "@storybook/addon-a11y",
2659                "@storybook/addon-docs",
2660                "@storybook/addon-links"
2661            ]
2662        );
2663    }
2664
2665    #[test]
2666    fn handle_empty_config() {
2667        let source = "";
2668        let result = extract_config_string(source, &js_path(), &["key"]);
2669        assert_eq!(result, None);
2670    }
2671
2672    #[test]
2673    fn object_keys_postcss_plugins() {
2674        let source = r"
2675            module.exports = {
2676                plugins: {
2677                    autoprefixer: {},
2678                    tailwindcss: {},
2679                    'postcss-import': {}
2680                }
2681            };
2682        ";
2683        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2684        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
2685    }
2686
2687    #[test]
2688    fn object_keys_nested_path() {
2689        let source = r"
2690            export default {
2691                build: {
2692                    plugins: {
2693                        minify: {},
2694                        compress: {}
2695                    }
2696                }
2697            };
2698        ";
2699        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
2700        assert_eq!(keys, vec!["minify", "compress"]);
2701    }
2702
2703    #[test]
2704    fn object_keys_empty_object() {
2705        let source = r"export default { plugins: {} };";
2706        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2707        assert!(keys.is_empty());
2708    }
2709
2710    #[test]
2711    fn object_keys_non_object_returns_empty() {
2712        let source = r#"export default { plugins: ["a", "b"] };"#;
2713        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2714        assert!(keys.is_empty());
2715    }
2716
2717    #[test]
2718    fn string_or_array_single_string() {
2719        let source = r#"export default { entry: "./src/index.js" };"#;
2720        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2721        assert_eq!(result, vec!["./src/index.js"]);
2722    }
2723
2724    #[test]
2725    fn string_or_array_array() {
2726        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
2727        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2728        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
2729    }
2730
2731    #[test]
2732    fn string_or_array_object_values() {
2733        let source =
2734            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
2735        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2736        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
2737    }
2738
2739    #[test]
2740    fn string_or_array_object_array_values() {
2741        let source = r#"export default { entry: { app: ["./src/polyfill.js", "./src/app.js"] } };"#;
2742        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2743        assert_eq!(result, vec!["./src/polyfill.js", "./src/app.js"]);
2744    }
2745
2746    #[test]
2747    fn string_or_array_webpack_entry_descriptors() {
2748        let source = r#"
2749            export default {
2750                entry: {
2751                    app: {
2752                        import: "./src/app.js",
2753                        filename: "pages/app.js",
2754                        dependOn: "shared",
2755                    },
2756                    admin: {
2757                        import: ["./src/admin-polyfill.js", "./src/admin.js"],
2758                        runtime: "runtime",
2759                    },
2760                    shared: ["react", "react-dom"],
2761                },
2762            };
2763        "#;
2764        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2765        assert_eq!(
2766            result,
2767            vec![
2768                "./src/app.js",
2769                "./src/admin-polyfill.js",
2770                "./src/admin.js",
2771                "react",
2772                "react-dom"
2773            ]
2774        );
2775    }
2776
2777    #[test]
2778    fn string_or_array_nested_path() {
2779        let source = r#"
2780            export default {
2781                build: {
2782                    rollupOptions: {
2783                        input: ["./index.html", "./about.html"]
2784                    }
2785                }
2786            };
2787        "#;
2788        let result = extract_config_string_or_array(
2789            source,
2790            &js_path(),
2791            &["build", "rollupOptions", "input"],
2792        );
2793        assert_eq!(result, vec!["./index.html", "./about.html"]);
2794    }
2795
2796    #[test]
2797    fn string_or_array_template_literal() {
2798        let source = r"export default { entry: `./src/index.js` };";
2799        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2800        assert_eq!(result, vec!["./src/index.js"]);
2801    }
2802
2803    #[test]
2804    fn string_or_array_object_path_helper_values() {
2805        let source = r#"
2806            import { resolve, join } from "node:path";
2807            import path from "node:path";
2808            export default {
2809                build: {
2810                    rollupOptions: {
2811                        input: {
2812                            app: resolve(__dirname, "src/app.ts"),
2813                            modal: path.resolve(__dirname, "src/modal.ts"),
2814                            tabs: join(__dirname, "src/tabs.ts"),
2815                            styles: resolve(__dirname, "src/index.css"),
2816                        },
2817                    },
2818                },
2819            };
2820        "#;
2821        let result = extract_config_string_or_array(
2822            source,
2823            &js_path(),
2824            &["build", "rollupOptions", "input"],
2825        );
2826        assert_eq!(
2827            result,
2828            vec!["src/app.ts", "src/modal.ts", "src/tabs.ts", "src/index.css"]
2829        );
2830    }
2831
2832    #[test]
2833    fn string_or_array_array_path_helper_values() {
2834        let source = r#"
2835            import { resolve } from "node:path";
2836            export default {
2837                build: {
2838                    rollupOptions: {
2839                        input: [resolve(__dirname, "src/a.ts"), "./src/b.ts"],
2840                    },
2841                },
2842            };
2843        "#;
2844        let result = extract_config_string_or_array(
2845            source,
2846            &js_path(),
2847            &["build", "rollupOptions", "input"],
2848        );
2849        assert_eq!(result, vec!["src/a.ts", "./src/b.ts"]);
2850    }
2851
2852    #[test]
2853    fn string_or_array_top_level_path_helper_call() {
2854        let source = r#"
2855            import { resolve } from "node:path";
2856            export default { build: { lib: { entry: resolve(__dirname, "src/index.ts") } } };
2857        "#;
2858        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2859        assert_eq!(result, vec!["src/index.ts"]);
2860    }
2861
2862    #[test]
2863    fn string_or_array_import_meta_dirname_anchor() {
2864        let source = r#"
2865            import { resolve } from "node:path";
2866            export default {
2867                build: { lib: { entry: resolve(import.meta.dirname, "src/index.ts") } },
2868            };
2869        "#;
2870        let result = extract_config_string_or_array(source, &ts_path(), &["build", "lib", "entry"]);
2871        assert_eq!(result, vec!["src/index.ts"]);
2872    }
2873
2874    #[test]
2875    fn string_or_array_non_literal_path_helper_args_dropped() {
2876        let source = r#"
2877            import { resolve } from "node:path";
2878            export default { build: { lib: { entry: resolve(baseDir, "src/index.ts") } } };
2879        "#;
2880        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2881        assert!(
2882            result.is_empty(),
2883            "non-literal path-helper args must be dropped: {result:?}"
2884        );
2885    }
2886
2887    #[test]
2888    fn require_strings_array() {
2889        let source = r"
2890            module.exports = {
2891                plugins: [
2892                    require('autoprefixer'),
2893                    require('postcss-import')
2894                ]
2895            };
2896        ";
2897        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2898        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
2899    }
2900
2901    #[test]
2902    fn require_strings_with_tuples() {
2903        let source = r"
2904            module.exports = {
2905                plugins: [
2906                    require('autoprefixer'),
2907                    [require('postcss-preset-env'), { stage: 3 }]
2908                ]
2909            };
2910        ";
2911        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2912        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
2913    }
2914
2915    #[test]
2916    fn require_strings_empty_array() {
2917        let source = r"module.exports = { plugins: [] };";
2918        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2919        assert!(deps.is_empty());
2920    }
2921
2922    #[test]
2923    fn require_strings_no_require_calls() {
2924        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
2925        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2926        assert!(deps.is_empty());
2927    }
2928
2929    #[test]
2930    fn extract_aliases_from_object_with_file_url_to_path() {
2931        let source = r#"
2932            import { defineConfig } from 'vite';
2933            import { fileURLToPath, URL } from 'node:url';
2934
2935            export default defineConfig({
2936                resolve: {
2937                    alias: {
2938                        "@": fileURLToPath(new URL("./src", import.meta.url))
2939                    }
2940                }
2941            });
2942        "#;
2943
2944        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2945        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
2946    }
2947
2948    #[test]
2949    fn extract_aliases_from_array_form() {
2950        let source = r#"
2951            export default {
2952                resolve: {
2953                    alias: [
2954                        { find: "@", replacement: "./src" },
2955                        { find: "$utils", replacement: "src/lib/utils" }
2956                    ]
2957                }
2958            };
2959        "#;
2960
2961        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2962        assert_eq!(
2963            aliases,
2964            vec![
2965                ("@".to_string(), "./src".to_string()),
2966                ("$utils".to_string(), "src/lib/utils".to_string())
2967            ]
2968        );
2969    }
2970
2971    #[test]
2972    fn extract_aliases_from_object_with_array_values() {
2973        let source = r#"
2974            ({
2975                compilerOptions: {
2976                    paths: {
2977                        "@/*": ["./src/*"],
2978                        "@shared/*": ["./shared/*", "./fallback/*"]
2979                    }
2980                }
2981            })
2982        "#;
2983
2984        let aliases = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
2985        assert_eq!(
2986            aliases,
2987            vec![
2988                ("@/*".to_string(), "./src/*".to_string()),
2989                ("@shared/*".to_string(), "./shared/*".to_string())
2990            ]
2991        );
2992    }
2993
2994    #[test]
2995    fn extract_array_object_strings_mixed_forms() {
2996        let source = r#"
2997            export default {
2998                components: [
2999                    "~/components",
3000                    { path: "@/feature-components" }
3001                ]
3002            };
3003        "#;
3004
3005        let values =
3006            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
3007        assert_eq!(
3008            values,
3009            vec![
3010                "~/components".to_string(),
3011                "@/feature-components".to_string()
3012            ]
3013        );
3014    }
3015
3016    #[test]
3017    fn extract_array_object_string_pairs_with_and_without_secondary() {
3018        let source = r#"
3019            export default {
3020                webServer: [
3021                    { command: "tsx scripts/api.ts", cwd: "packages/api" },
3022                    { command: "tsx scripts/web.ts" }
3023                ]
3024            };
3025        "#;
3026
3027        let pairs = extract_config_array_object_string_pairs(
3028            source,
3029            &ts_path(),
3030            &["webServer"],
3031            "command",
3032            "cwd",
3033        );
3034        assert_eq!(
3035            pairs,
3036            vec![
3037                (
3038                    "tsx scripts/api.ts".to_string(),
3039                    Some("packages/api".to_string())
3040                ),
3041                ("tsx scripts/web.ts".to_string(), None),
3042            ]
3043        );
3044    }
3045
3046    #[test]
3047    fn extract_array_object_string_pairs_skips_elements_missing_primary() {
3048        let source = r#"
3049            export default {
3050                webServer: [
3051                    { cwd: "packages/api" },
3052                    { command: "srvx --port 3000" }
3053                ]
3054            };
3055        "#;
3056
3057        let pairs = extract_config_array_object_string_pairs(
3058            source,
3059            &ts_path(),
3060            &["webServer"],
3061            "command",
3062            "cwd",
3063        );
3064        assert_eq!(pairs, vec![("srvx --port 3000".to_string(), None)]);
3065    }
3066
3067    #[test]
3068    fn extract_array_object_string_pairs_empty_for_object_form() {
3069        let source = r#"
3070            export default {
3071                webServer: { command: "srvx --port 3000" }
3072            };
3073        "#;
3074
3075        let pairs = extract_config_array_object_string_pairs(
3076            source,
3077            &ts_path(),
3078            &["webServer"],
3079            "command",
3080            "cwd",
3081        );
3082        assert!(pairs.is_empty());
3083    }
3084
3085    #[test]
3086    fn extract_config_plugin_option_string_from_json() {
3087        let source = r#"{
3088            "expo": {
3089                "plugins": [
3090                    ["expo-router", { "root": "src/app" }]
3091                ]
3092            }
3093        }"#;
3094
3095        let value = extract_config_plugin_option_string(
3096            source,
3097            &json_path(),
3098            &["expo", "plugins"],
3099            "expo-router",
3100            "root",
3101        );
3102
3103        assert_eq!(value, Some("src/app".to_string()));
3104    }
3105
3106    #[test]
3107    fn extract_config_plugin_option_string_from_top_level_plugins() {
3108        let source = r#"{
3109            "plugins": [
3110                ["expo-router", { "root": "./src/routes" }]
3111            ]
3112        }"#;
3113
3114        let value = extract_config_plugin_option_string_from_paths(
3115            source,
3116            &json_path(),
3117            &[&["plugins"], &["expo", "plugins"]],
3118            "expo-router",
3119            "root",
3120        );
3121
3122        assert_eq!(value, Some("./src/routes".to_string()));
3123    }
3124
3125    #[test]
3126    fn extract_config_plugin_option_string_from_ts_config() {
3127        let source = r"
3128            export default {
3129                expo: {
3130                    plugins: [
3131                        ['expo-router', { root: './src/app' }]
3132                    ]
3133                }
3134            };
3135        ";
3136
3137        let value = extract_config_plugin_option_string(
3138            source,
3139            &ts_path(),
3140            &["expo", "plugins"],
3141            "expo-router",
3142            "root",
3143        );
3144
3145        assert_eq!(value, Some("./src/app".to_string()));
3146    }
3147
3148    #[test]
3149    fn extract_config_plugin_option_string_returns_none_when_plugin_missing() {
3150        let source = r#"{
3151            "expo": {
3152                "plugins": [
3153                    ["expo-font", {}]
3154                ]
3155            }
3156        }"#;
3157
3158        let value = extract_config_plugin_option_string(
3159            source,
3160            &json_path(),
3161            &["expo", "plugins"],
3162            "expo-router",
3163            "root",
3164        );
3165
3166        assert_eq!(value, None);
3167    }
3168
3169    #[test]
3170    fn vite_react_babel_dependencies_extract_plain_tuple_and_prefixed_entries() {
3171        let source = r#"
3172            import react from "@vitejs/plugin-react";
3173
3174            export default defineConfig({
3175                plugins: [
3176                    react({
3177                        babel: {
3178                            plugins: [
3179                                "babel-plugin-plain",
3180                                ["module:@preact/signals-react-transform", { mode: "auto" }],
3181                            ],
3182                            presets: [["@babel/preset-react", { runtime: "automatic" }]],
3183                        },
3184                    }),
3185                ],
3186            });
3187        "#;
3188
3189        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3190
3191        assert_eq!(
3192            deps,
3193            vec![
3194                "babel-plugin-plain".to_string(),
3195                "@preact/signals-react-transform".to_string(),
3196                "@babel/preset-react".to_string(),
3197            ]
3198        );
3199    }
3200
3201    #[test]
3202    fn vite_react_babel_dependencies_support_default_alias_import() {
3203        let source = r#"
3204            import { default as viteReact } from "@vitejs/plugin-react";
3205
3206            export default {
3207                plugins: [
3208                    viteReact({
3209                        babel: {
3210                            plugins: [["module:@scope/pkg/plugin", {}]],
3211                        },
3212                    }),
3213                ],
3214            };
3215        "#;
3216
3217        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3218
3219        assert_eq!(deps, vec!["@scope/pkg".to_string()]);
3220    }
3221
3222    #[test]
3223    fn vite_react_babel_dependencies_ignore_unrelated_plugin_calls() {
3224        let source = r#"
3225            import vue from "@vitejs/plugin-vue";
3226
3227            export default {
3228                plugins: [
3229                    vue({
3230                        babel: {
3231                            plugins: ["@preact/signals-react-transform"],
3232                        },
3233                    }),
3234                ],
3235            };
3236        "#;
3237
3238        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3239
3240        assert!(deps.is_empty());
3241    }
3242
3243    #[test]
3244    fn vite_react_babel_dependencies_skip_relative_and_protocol_entries() {
3245        let source = r#"
3246            import react from "@vitejs/plugin-react";
3247
3248            export default {
3249                plugins: [
3250                    react({
3251                        babel: {
3252                            plugins: ["./local-plugin", "module:./local-prefixed", "http://example.com/plugin"],
3253                        },
3254                    }),
3255                ],
3256            };
3257        "#;
3258
3259        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3260
3261        assert!(deps.is_empty());
3262    }
3263
3264    #[test]
3265    fn normalize_config_path_relative_to_root() {
3266        let config_path = PathBuf::from("/project/vite.config.ts");
3267        let root = PathBuf::from("/project");
3268
3269        assert_eq!(
3270            normalize_config_path("./src/lib", &config_path, &root),
3271            Some("src/lib".to_string())
3272        );
3273        assert_eq!(
3274            normalize_config_path("/src/lib", &config_path, &root),
3275            Some("src/lib".to_string())
3276        );
3277    }
3278
3279    #[test]
3280    fn normalize_config_path_mixed_separators_and_parent_dirs() {
3281        let config_path = PathBuf::from("/project/config/vite.config.ts");
3282        let root = PathBuf::from("/project");
3283
3284        assert_eq!(
3285            normalize_config_path(".\\src\\..\\app\\lib", &config_path, &root),
3286            Some("config/app/lib".to_string())
3287        );
3288    }
3289
3290    #[test]
3291    fn normalize_config_path_leading_slash_stays_project_relative() {
3292        let config_path = PathBuf::from("/project/vite.config.ts");
3293        let root = PathBuf::from("/project");
3294
3295        assert_eq!(
3296            normalize_config_path("/src\\lib", &config_path, &root),
3297            Some("src/lib".to_string())
3298        );
3299    }
3300
3301    #[test]
3302    fn json_wrapped_in_parens_string() {
3303        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
3304        let val = extract_config_string(source, &js_path(), &["extends"]);
3305        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
3306    }
3307
3308    #[test]
3309    fn json_wrapped_in_parens_nested_array() {
3310        let source =
3311            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
3312        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
3313        assert_eq!(types, vec!["node", "jest"]);
3314
3315        let include = extract_config_string_array(source, &js_path(), &["include"]);
3316        assert_eq!(include, vec!["src/**/*"]);
3317    }
3318
3319    #[test]
3320    fn json_wrapped_in_parens_object_keys() {
3321        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
3322        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3323        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
3324    }
3325
3326    fn json_path() -> PathBuf {
3327        PathBuf::from("config.json")
3328    }
3329
3330    #[test]
3331    fn json_file_parsed_correctly() {
3332        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
3333        let val = extract_config_string(source, &json_path(), &["key"]);
3334        assert_eq!(val, Some("value".to_string()));
3335
3336        let list = extract_config_string_array(source, &json_path(), &["list"]);
3337        assert_eq!(list, vec!["a", "b"]);
3338    }
3339
3340    #[test]
3341    fn jsonc_file_parsed_correctly() {
3342        let source = r#"{"key": "value"}"#;
3343        let path = PathBuf::from("tsconfig.jsonc");
3344        let val = extract_config_string(source, &path, &["key"]);
3345        assert_eq!(val, Some("value".to_string()));
3346    }
3347
3348    #[test]
3349    fn extract_define_config_arrow_function() {
3350        let source = r#"
3351            import { defineConfig } from 'vite';
3352            export default defineConfig(() => ({
3353                test: {
3354                    include: ["**/*.test.ts"]
3355                }
3356            }));
3357        "#;
3358        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3359        assert_eq!(include, vec!["**/*.test.ts"]);
3360    }
3361
3362    /// A block-bodied callback is the common shape for configs that branch on
3363    /// the build mode. Only the concise arrow used to be traversed, so every
3364    /// such config extracted nothing at all (issue #2005).
3365    #[test]
3366    fn extract_define_config_block_body_arrow_function() {
3367        let source = r#"
3368            import { defineConfig } from 'vite';
3369            export default defineConfig(({ mode }) => {
3370                const isProduction = mode === 'production';
3371                return {
3372                    test: {
3373                        environment: "jsdom",
3374                        setupFiles: "./tests/setup.ts"
3375                    },
3376                    base: isProduction ? '/app/' : '/'
3377                };
3378            });
3379        "#;
3380        assert_eq!(
3381            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3382            Some("jsdom")
3383        );
3384        assert_eq!(
3385            extract_config_string(source, &ts_path(), &["test", "setupFiles"]).as_deref(),
3386            Some("./tests/setup.ts")
3387        );
3388    }
3389
3390    #[test]
3391    fn extract_define_config_function_expression() {
3392        let source = r#"
3393            import { defineConfig } from 'vite';
3394            export default defineConfig(function () {
3395                return { test: { environment: "happy-dom" } };
3396            });
3397        "#;
3398        assert_eq!(
3399            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3400            Some("happy-dom")
3401        );
3402    }
3403
3404    /// A wrapper takes the config first and its own options after it. Scanning
3405    /// the argument list for the first object literal read the options object
3406    /// instead, which is the exact shape the @sentry/nextjs wizard emits.
3407    #[test]
3408    fn wrapper_options_object_does_not_shadow_named_config_arg() {
3409        let source = r#"
3410            const nextConfig = { pageExtensions: ["page.tsx"] };
3411            module.exports = withSentryConfig(nextConfig, { org: "o", project: "p", silent: true });
3412        "#;
3413        assert_eq!(
3414            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
3415            vec!["page.tsx"]
3416        );
3417    }
3418
3419    #[test]
3420    fn wrapper_options_object_does_not_shadow_named_config_arg_esm() {
3421        let source = r#"
3422            const nextConfig = { pageExtensions: ["page.tsx"] };
3423            export default withSentryConfig(nextConfig, { org: "o", project: "p" });
3424        "#;
3425        assert_eq!(
3426            extract_config_string_array(source, &ts_path(), &["pageExtensions"]),
3427            vec!["page.tsx"]
3428        );
3429    }
3430
3431    /// Vitest's documented way to share a Vite config with the test runner.
3432    #[test]
3433    fn merge_config_extracts_nested_define_config_object() {
3434        let source = r#"
3435            import { defineConfig, mergeConfig } from 'vitest/config';
3436            import viteConfig from './vite.config';
3437            export default mergeConfig(viteConfig, defineConfig({
3438                test: { environment: "jsdom" }
3439            }));
3440        "#;
3441        assert_eq!(
3442            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3443            Some("jsdom")
3444        );
3445    }
3446
3447    #[test]
3448    fn define_config_wrapping_merge_config_extracts_object() {
3449        let source = r#"
3450            import { defineConfig, mergeConfig } from 'vitest/config';
3451            import base from './base';
3452            export default defineConfig(mergeConfig(base, { test: { environment: "jsdom" } }));
3453        "#;
3454        assert_eq!(
3455            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3456            Some("jsdom")
3457        );
3458    }
3459
3460    /// Guards against "prefer an identifier-resolved const over any object
3461    /// literal", which would change which object every plain config reads.
3462    #[test]
3463    fn inline_object_at_argument_zero_still_wins() {
3464        let source = r#"
3465            const unrelated = { pageExtensions: ["wrong.tsx"] };
3466            module.exports = withSentryConfig({ pageExtensions: ["right.tsx"] }, { org: "o" });
3467        "#;
3468        assert_eq!(
3469            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
3470            vec!["right.tsx"]
3471        );
3472    }
3473
3474    /// Vite's documented shape for switching config by command: every branch
3475    /// returns and the callback has no return of its own, so extraction has to
3476    /// descend to find anything at all.
3477    #[test]
3478    fn conditional_config_callback_extracts_branch_return() {
3479        let source = r#"
3480            import { defineConfig } from 'vite';
3481            export default defineConfig(({ command }) => {
3482                if (command === "serve") {
3483                    return { test: { environment: "jsdom" } };
3484                } else {
3485                    return { test: { environment: "node" } };
3486                }
3487            });
3488        "#;
3489        assert_eq!(
3490            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3491            Some("jsdom"),
3492            "with no top-level return, the first branch in source order wins"
3493        );
3494    }
3495
3496    #[test]
3497    fn try_block_return_is_extracted() {
3498        let source = r#"
3499            export default (() => {
3500                try {
3501                    return { test: { environment: "jsdom" } };
3502                } catch (e) {
3503                    return { test: { environment: "node" } };
3504                }
3505            });
3506        "#;
3507        assert_eq!(
3508            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3509            Some("jsdom")
3510        );
3511    }
3512
3513    #[test]
3514    fn switch_case_return_is_extracted() {
3515        let source = r#"
3516            import { defineConfig } from 'vite';
3517            export default defineConfig(({ mode }) => {
3518                switch (mode) {
3519                    case "test":
3520                        return { test: { environment: "jsdom" } };
3521                    default:
3522                        return { test: { environment: "node" } };
3523                }
3524            });
3525        "#;
3526        assert_eq!(
3527            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3528            Some("jsdom")
3529        );
3530    }
3531
3532    /// A guard clause followed by the real return must resolve to the trailing
3533    /// return. Descending into branches before checking this level regressed it:
3534    /// the guard's early return shadowed the actual config.
3535    #[test]
3536    fn guard_clause_does_not_shadow_the_top_level_return() {
3537        let source = r#"
3538            import { defineConfig } from 'vite';
3539            export default defineConfig(({ mode }) => {
3540                if (!mode) {
3541                    return {};
3542                }
3543                return { test: { environment: "jsdom" } };
3544            });
3545        "#;
3546        assert_eq!(
3547            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3548            Some("jsdom"),
3549            "a return at the callback's own level is the main config"
3550        );
3551    }
3552
3553    /// The same precedence with an else-if chain, where every branch returns and
3554    /// the trailing return is the default config.
3555    #[test]
3556    fn else_if_chain_does_not_shadow_the_top_level_return() {
3557        let source = r#"
3558            import { defineConfig } from 'vite';
3559            export default defineConfig(({ mode }) => {
3560                if (mode === "a") {
3561                    return { base: "/a/" };
3562                } else if (mode === "b") {
3563                    return { base: "/b/" };
3564                }
3565                return { test: { environment: "jsdom" } };
3566            });
3567        "#;
3568        assert_eq!(
3569            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
3570            Some("jsdom")
3571        );
3572    }
3573
3574    #[test]
3575    fn extract_config_from_default_export_function_declaration() {
3576        let source = r#"
3577            export default function createConfig() {
3578                return {
3579                    clientModules: ["./src/client/global.js"]
3580                };
3581            }
3582        "#;
3583
3584        let client_modules = extract_config_string_array(source, &ts_path(), &["clientModules"]);
3585        assert_eq!(client_modules, vec!["./src/client/global.js"]);
3586    }
3587
3588    #[test]
3589    fn extract_config_from_default_export_async_function_declaration() {
3590        let source = r#"
3591            export default async function createConfigAsync() {
3592                return {
3593                    docs: {
3594                        path: "knowledge"
3595                    }
3596                };
3597            }
3598        "#;
3599
3600        let docs_path = extract_config_string(source, &ts_path(), &["docs", "path"]);
3601        assert_eq!(docs_path, Some("knowledge".to_string()));
3602    }
3603
3604    #[test]
3605    fn extract_config_from_exported_arrow_function_identifier() {
3606        let source = r#"
3607            const config = async () => {
3608                return {
3609                    themes: ["classic"]
3610                };
3611            };
3612
3613            export default config;
3614        "#;
3615
3616        let themes = extract_config_shallow_strings(source, &ts_path(), "themes");
3617        assert_eq!(themes, vec!["classic"]);
3618    }
3619
3620    #[test]
3621    fn module_exports_nested_string() {
3622        let source = r#"
3623            module.exports = {
3624                resolve: {
3625                    alias: {
3626                        "@": "./src"
3627                    }
3628                }
3629            };
3630        "#;
3631        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
3632        assert_eq!(val, Some("./src".to_string()));
3633    }
3634
3635    #[test]
3636    fn property_strings_nested_objects() {
3637        let source = r#"
3638            export default {
3639                plugins: {
3640                    group1: { a: "val-a" },
3641                    group2: { b: "val-b" }
3642                }
3643            };
3644        "#;
3645        let values = extract_config_property_strings(source, &js_path(), "plugins");
3646        assert!(values.contains(&"val-a".to_string()));
3647        assert!(values.contains(&"val-b".to_string()));
3648    }
3649
3650    #[test]
3651    fn property_strings_missing_key_returns_empty() {
3652        let source = r#"export default { other: "value" };"#;
3653        let values = extract_config_property_strings(source, &js_path(), "missing");
3654        assert!(values.is_empty());
3655    }
3656
3657    #[test]
3658    fn shallow_strings_tuple_array() {
3659        let source = r#"
3660            module.exports = {
3661                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
3662            };
3663        "#;
3664        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
3665        assert_eq!(values, vec!["default", "jest-junit"]);
3666        assert!(!values.contains(&"reports".to_string()));
3667    }
3668
3669    #[test]
3670    fn shallow_strings_single_string() {
3671        let source = r#"export default { preset: "ts-jest" };"#;
3672        let values = extract_config_shallow_strings(source, &js_path(), "preset");
3673        assert_eq!(values, vec!["ts-jest"]);
3674    }
3675
3676    #[test]
3677    fn shallow_strings_missing_key() {
3678        let source = r#"export default { other: "val" };"#;
3679        let values = extract_config_shallow_strings(source, &js_path(), "missing");
3680        assert!(values.is_empty());
3681    }
3682
3683    #[test]
3684    fn shallow_strings_or_object_property_alias_objects() {
3685        let source = r#"
3686            export default {
3687                jsPlugins: [
3688                    "eslint-plugin-playwright",
3689                    ["eslint-plugin-regexp", { rules: {} }],
3690                    { name: "short", specifier: "eslint-plugin-with-long-name" }
3691                ]
3692            };
3693        "#;
3694        let values = extract_config_shallow_strings_or_object_property(
3695            source,
3696            &ts_path(),
3697            "jsPlugins",
3698            "specifier",
3699        );
3700        assert_eq!(
3701            values,
3702            vec![
3703                "eslint-plugin-playwright",
3704                "eslint-plugin-regexp",
3705                "eslint-plugin-with-long-name"
3706            ]
3707        );
3708    }
3709
3710    #[test]
3711    fn nested_shallow_strings_vitest_reporters() {
3712        let source = r#"
3713            export default {
3714                test: {
3715                    reporters: ["default", "vitest-sonar-reporter"]
3716                }
3717            };
3718        "#;
3719        let values =
3720            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3721        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3722    }
3723
3724    #[test]
3725    fn nested_shallow_strings_tuple_format() {
3726        let source = r#"
3727            export default {
3728                test: {
3729                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
3730                }
3731            };
3732        "#;
3733        let values =
3734            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3735        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3736    }
3737
3738    #[test]
3739    fn nested_shallow_strings_missing_outer() {
3740        let source = r"export default { other: {} };";
3741        let values =
3742            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3743        assert!(values.is_empty());
3744    }
3745
3746    #[test]
3747    fn nested_shallow_strings_missing_inner() {
3748        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
3749        let values =
3750            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3751        assert!(values.is_empty());
3752    }
3753
3754    #[test]
3755    fn string_or_array_missing_path() {
3756        let source = r"export default {};";
3757        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3758        assert!(result.is_empty());
3759    }
3760
3761    #[test]
3762    fn string_or_array_non_string_values() {
3763        let source = r"export default { entry: [42, true] };";
3764        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3765        assert!(result.is_empty());
3766    }
3767
3768    #[test]
3769    fn array_nested_extraction() {
3770        let source = r#"
3771            export default defineConfig({
3772                test: {
3773                    projects: [
3774                        {
3775                            test: {
3776                                setupFiles: ["./test/setup-a.ts"]
3777                            }
3778                        },
3779                        {
3780                            test: {
3781                                setupFiles: "./test/setup-b.ts"
3782                            }
3783                        }
3784                    ]
3785                }
3786            });
3787        "#;
3788        let results = extract_config_array_nested_string_or_array(
3789            source,
3790            &ts_path(),
3791            &["test", "projects"],
3792            &["test", "setupFiles"],
3793        );
3794        assert!(results.contains(&"./test/setup-a.ts".to_string()));
3795        assert!(results.contains(&"./test/setup-b.ts".to_string()));
3796    }
3797
3798    #[test]
3799    fn array_nested_empty_when_no_array() {
3800        let source = r#"export default { test: { projects: "not-an-array" } };"#;
3801        let results = extract_config_array_nested_string_or_array(
3802            source,
3803            &js_path(),
3804            &["test", "projects"],
3805            &["test", "setupFiles"],
3806        );
3807        assert!(results.is_empty());
3808    }
3809
3810    #[test]
3811    fn object_nested_extraction() {
3812        let source = r#"{
3813            "projects": {
3814                "app-one": {
3815                    "architect": {
3816                        "build": {
3817                            "options": {
3818                                "styles": ["src/styles.css"]
3819                            }
3820                        }
3821                    }
3822                }
3823            }
3824        }"#;
3825        let results = extract_config_object_nested_string_or_array(
3826            source,
3827            &json_path(),
3828            &["projects"],
3829            &["architect", "build", "options", "styles"],
3830        );
3831        assert_eq!(results, vec!["src/styles.css"]);
3832    }
3833
3834    #[test]
3835    fn array_with_object_input_form_extracted() {
3836        let source = r#"{
3837            "projects": {
3838                "app": {
3839                    "architect": {
3840                        "build": {
3841                            "options": {
3842                                "styles": [
3843                                    "src/styles.scss",
3844                                    { "input": "src/theme.scss", "bundleName": "theme", "inject": false },
3845                                    { "bundleName": "lazy-only" }
3846                                ]
3847                            }
3848                        }
3849                    }
3850                }
3851            }
3852        }"#;
3853        let results = extract_config_object_nested_string_or_array(
3854            source,
3855            &json_path(),
3856            &["projects"],
3857            &["architect", "build", "options", "styles"],
3858        );
3859        assert!(
3860            results.contains(&"src/styles.scss".to_string()),
3861            "string form must still work: {results:?}"
3862        );
3863        assert!(
3864            results.contains(&"src/theme.scss".to_string()),
3865            "object form with `input` must be extracted: {results:?}"
3866        );
3867        assert!(
3868            !results.contains(&"lazy-only".to_string()),
3869            "bundleName must not be misinterpreted as a path: {results:?}"
3870        );
3871        assert!(
3872            !results.contains(&"theme".to_string()),
3873            "bundleName from full object must not leak: {results:?}"
3874        );
3875    }
3876
3877    #[test]
3878    fn object_nested_strings_extraction() {
3879        let source = r#"{
3880            "targets": {
3881                "build": {
3882                    "executor": "@angular/build:application"
3883                },
3884                "test": {
3885                    "executor": "@nx/vite:test"
3886                }
3887            }
3888        }"#;
3889        let results =
3890            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
3891        assert!(results.contains(&"@angular/build:application".to_string()));
3892        assert!(results.contains(&"@nx/vite:test".to_string()));
3893    }
3894
3895    #[test]
3896    fn require_strings_direct_call() {
3897        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
3898        let deps = extract_config_require_strings(source, &js_path(), "adapter");
3899        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
3900    }
3901
3902    #[test]
3903    fn require_strings_no_matching_key() {
3904        let source = r"module.exports = { other: require('something') };";
3905        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3906        assert!(deps.is_empty());
3907    }
3908
3909    #[test]
3910    fn extract_imports_no_imports() {
3911        let source = r"export default {};";
3912        let imports = extract_imports(source, &js_path());
3913        assert!(imports.is_empty());
3914    }
3915
3916    #[test]
3917    fn extract_imports_side_effect_import() {
3918        let source = r"
3919            import 'polyfill';
3920            import './local-setup';
3921            export default {};
3922        ";
3923        let imports = extract_imports(source, &js_path());
3924        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
3925    }
3926
3927    #[test]
3928    fn extract_imports_mixed_specifiers() {
3929        let source = r"
3930            import defaultExport from 'module-a';
3931            import { named } from 'module-b';
3932            import * as ns from 'module-c';
3933            export default {};
3934        ";
3935        let imports = extract_imports(source, &js_path());
3936        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
3937    }
3938
3939    #[test]
3940    fn template_literal_in_string_or_array() {
3941        let source = r"export default { entry: `./src/index.ts` };";
3942        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
3943        assert_eq!(result, vec!["./src/index.ts"]);
3944    }
3945
3946    #[test]
3947    fn template_literal_in_config_string() {
3948        let source = r"export default { testDir: `./tests` };";
3949        let val = extract_config_string(source, &js_path(), &["testDir"]);
3950        assert_eq!(val, Some("./tests".to_string()));
3951    }
3952
3953    #[test]
3954    fn template_literal_command_recovers_static_command_tokens() {
3955        let source = r"
3956            const PORT = 3000;
3957            export default {
3958                webServer: {
3959                    command: `pnpm exec srvx --port ${PORT} --hostname 127.0.0.1`
3960                }
3961            };
3962        ";
3963        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3964        assert_eq!(
3965            val,
3966            Some("pnpm exec srvx --port   --hostname 127.0.0.1".to_string())
3967        );
3968    }
3969
3970    #[test]
3971    fn template_literal_command_skips_dynamic_prefix() {
3972        let source = r"
3973            export default {
3974                webServer: { command: `${serverCommand} && pnpm exec srvx` }
3975            };
3976        ";
3977        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3978        assert!(val.is_none());
3979    }
3980
3981    #[test]
3982    fn template_literal_command_skips_split_static_token() {
3983        let source = r"
3984            export default {
3985                webServer: { command: `pnpm exec sr${part}vx --port 3000` }
3986            };
3987        ";
3988        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3989        assert!(val.is_none());
3990    }
3991
3992    #[test]
3993    fn array_object_command_pairs_recover_template_command() {
3994        let source = r"
3995            const PORT = 3000;
3996            export default {
3997                webServer: [
3998                    {
3999                        command: `pnpm exec srvx --port ${PORT}`,
4000                        cwd: 'apps/web'
4001                    }
4002                ]
4003            };
4004        ";
4005        let pairs = extract_config_array_object_command_pairs(
4006            source,
4007            &ts_path(),
4008            &["webServer"],
4009            "command",
4010            "cwd",
4011        );
4012        assert_eq!(
4013            pairs,
4014            vec![(
4015                "pnpm exec srvx --port  ".to_string(),
4016                Some("apps/web".to_string())
4017            )]
4018        );
4019    }
4020
4021    #[test]
4022    fn nested_string_array_empty_path() {
4023        let source = r#"export default { items: ["a", "b"] };"#;
4024        let result = extract_config_string_array(source, &js_path(), &[]);
4025        assert!(result.is_empty());
4026    }
4027
4028    #[test]
4029    fn nested_string_empty_path() {
4030        let source = r#"export default { key: "val" };"#;
4031        let result = extract_config_string(source, &js_path(), &[]);
4032        assert!(result.is_none());
4033    }
4034
4035    #[test]
4036    fn object_keys_empty_path() {
4037        let source = r"export default { plugins: {} };";
4038        let result = extract_config_object_keys(source, &js_path(), &[]);
4039        assert!(result.is_empty());
4040    }
4041
4042    #[test]
4043    fn no_config_object_returns_empty() {
4044        let source = r"const x = 42;";
4045        let result = extract_config_string(source, &js_path(), &["key"]);
4046        assert!(result.is_none());
4047
4048        let arr = extract_config_string_array(source, &js_path(), &["items"]);
4049        assert!(arr.is_empty());
4050
4051        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
4052        assert!(keys.is_empty());
4053    }
4054
4055    #[test]
4056    fn property_with_string_key() {
4057        let source = r#"export default { "string-key": "value" };"#;
4058        let val = extract_config_string(source, &js_path(), &["string-key"]);
4059        assert_eq!(val, Some("value".to_string()));
4060    }
4061
4062    #[test]
4063    fn nested_navigation_through_non_object() {
4064        let source = r#"export default { level1: "not-an-object" };"#;
4065        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
4066        assert!(val.is_none());
4067    }
4068
4069    #[test]
4070    fn variable_reference_untyped() {
4071        let source = r#"
4072            const config = {
4073                testDir: "./tests"
4074            };
4075            export default config;
4076        "#;
4077        let val = extract_config_string(source, &js_path(), &["testDir"]);
4078        assert_eq!(val, Some("./tests".to_string()));
4079    }
4080
4081    #[test]
4082    fn variable_reference_with_type_annotation() {
4083        let source = r#"
4084            import type { StorybookConfig } from '@storybook/react-vite';
4085            const config: StorybookConfig = {
4086                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
4087                framework: "@storybook/react-vite"
4088            };
4089            export default config;
4090        "#;
4091        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
4092        assert_eq!(
4093            addons,
4094            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
4095        );
4096
4097        let framework = extract_config_string(source, &ts_path(), &["framework"]);
4098        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
4099    }
4100
4101    #[test]
4102    fn variable_reference_with_define_config() {
4103        let source = r#"
4104            import { defineConfig } from 'vitest/config';
4105            const config = defineConfig({
4106                test: {
4107                    include: ["**/*.test.ts"]
4108                }
4109            });
4110            export default config;
4111        "#;
4112        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
4113        assert_eq!(include, vec!["**/*.test.ts"]);
4114    }
4115
4116    #[test]
4117    fn ts_satisfies_direct_export() {
4118        let source = r#"
4119            export default {
4120                testDir: "./tests"
4121            } satisfies PlaywrightTestConfig;
4122        "#;
4123        let val = extract_config_string(source, &ts_path(), &["testDir"]);
4124        assert_eq!(val, Some("./tests".to_string()));
4125    }
4126
4127    #[test]
4128    fn ts_as_direct_export() {
4129        let source = r#"
4130            export default {
4131                testDir: "./tests"
4132            } as const;
4133        "#;
4134        let val = extract_config_string(source, &ts_path(), &["testDir"]);
4135        assert_eq!(val, Some("./tests".to_string()));
4136    }
4137
4138    // --- issue #811: resolve.alias as imported identifier / spread ---
4139
4140    fn aliases(source: &str) -> Vec<(String, String)> {
4141        extract_config_aliases(source, &js_path(), &["resolve", "alias"])
4142    }
4143
4144    #[test]
4145    fn aliases_inline_object_still_extracted() {
4146        // Regression: the resolver must not change inline-object behavior.
4147        let source = r#"
4148            export default defineConfig({
4149                resolve: { alias: { "@": "./src", utils: "../../utils" } }
4150            });
4151        "#;
4152        let mut got = aliases(source);
4153        got.sort();
4154        assert_eq!(
4155            got,
4156            vec![
4157                ("@".to_string(), "./src".to_string()),
4158                ("utils".to_string(), "../../utils".to_string()),
4159            ]
4160        );
4161    }
4162
4163    #[test]
4164    fn aliases_inline_array_still_extracted() {
4165        let source = r#"
4166            export default defineConfig({
4167                resolve: { alias: [{ find: "@", replacement: "./src" }] }
4168            });
4169        "#;
4170        assert_eq!(
4171            aliases(source),
4172            vec![("@".to_string(), "./src".to_string())]
4173        );
4174    }
4175
4176    #[test]
4177    fn aliases_local_const_array_identifier() {
4178        let source = r#"
4179            const sharedAliases = [{ find: "@", replacement: "./src" }];
4180            export default defineConfig({ resolve: { alias: sharedAliases } });
4181        "#;
4182        assert_eq!(
4183            aliases(source),
4184            vec![("@".to_string(), "./src".to_string())]
4185        );
4186    }
4187
4188    #[test]
4189    fn aliases_local_const_object_identifier() {
4190        let source = r#"
4191            const sharedAliases = { "@": "./src" };
4192            export default defineConfig({ resolve: { alias: sharedAliases } });
4193        "#;
4194        assert_eq!(
4195            aliases(source),
4196            vec![("@".to_string(), "./src".to_string())]
4197        );
4198    }
4199
4200    #[test]
4201    fn aliases_array_spread_of_identifiers_and_inline() {
4202        let source = r##"
4203            const a = [{ find: "@", replacement: "./src" }];
4204            const b = [{ find: "~", replacement: "./lib" }];
4205            export default defineConfig({
4206                resolve: { alias: [...a, ...b, { find: "#", replacement: "./test" }] }
4207            });
4208        "##;
4209        let mut got = aliases(source);
4210        got.sort();
4211        assert_eq!(
4212            got,
4213            vec![
4214                ("#".to_string(), "./test".to_string()),
4215                ("@".to_string(), "./src".to_string()),
4216                ("~".to_string(), "./lib".to_string()),
4217            ]
4218        );
4219    }
4220
4221    #[test]
4222    fn aliases_object_spread_of_identifier_and_inline() {
4223        let source = r#"
4224            const base = { "@": "./src" };
4225            export default defineConfig({
4226                resolve: { alias: { ...base, "~": "./lib" } }
4227            });
4228        "#;
4229        let mut got = aliases(source);
4230        got.sort();
4231        assert_eq!(
4232            got,
4233            vec![
4234                ("@".to_string(), "./src".to_string()),
4235                ("~".to_string(), "./lib".to_string()),
4236            ]
4237        );
4238    }
4239
4240    #[test]
4241    fn aliases_local_const_chained_identifier() {
4242        // `const a = b` indirection resolves through the chain.
4243        let source = r#"
4244            const real = [{ find: "@", replacement: "./src" }];
4245            const alias2 = real;
4246            export default defineConfig({ resolve: { alias: alias2 } });
4247        "#;
4248        assert_eq!(
4249            aliases(source),
4250            vec![("@".to_string(), "./src".to_string())]
4251        );
4252    }
4253
4254    #[test]
4255    fn aliases_imported_named_identifier_from_sibling() {
4256        let dir = tempfile::tempdir().unwrap();
4257        std::fs::write(
4258            dir.path().join("vite.shared.js"),
4259            r#"export const sharedAliases = [
4260                { find: "@", replacement: new URL("./src", import.meta.url).pathname },
4261            ];"#,
4262        )
4263        .unwrap();
4264        let config = dir.path().join("vite.config.js");
4265        let source = r#"
4266            import { defineConfig } from "vite";
4267            import { sharedAliases } from "./vite.shared.js";
4268            export default defineConfig({ resolve: { alias: sharedAliases } });
4269        "#;
4270        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4271        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4272    }
4273
4274    #[test]
4275    fn aliases_imported_extensionless_specifier_probed() {
4276        let dir = tempfile::tempdir().unwrap();
4277        std::fs::write(
4278            dir.path().join("aliases.mjs"),
4279            r#"export const sharedAliases = { "@": "./src" };"#,
4280        )
4281        .unwrap();
4282        let config = dir.path().join("vite.config.ts");
4283        let source = r#"
4284            import { sharedAliases } from "./aliases";
4285            export default defineConfig({ resolve: { alias: sharedAliases } });
4286        "#;
4287        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4288        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4289    }
4290
4291    #[test]
4292    fn aliases_imported_default_export_from_sibling() {
4293        let dir = tempfile::tempdir().unwrap();
4294        std::fs::write(
4295            dir.path().join("aliases.js"),
4296            r#"export default [{ find: "@", replacement: "./src" }];"#,
4297        )
4298        .unwrap();
4299        let config = dir.path().join("vite.config.js");
4300        let source = r#"
4301            import sharedAliases from "./aliases.js";
4302            export default defineConfig({ resolve: { alias: sharedAliases } });
4303        "#;
4304        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4305        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4306    }
4307
4308    #[test]
4309    fn aliases_imported_spread_from_two_siblings() {
4310        let dir = tempfile::tempdir().unwrap();
4311        std::fs::write(
4312            dir.path().join("a.js"),
4313            r#"export const a = [{ find: "@", replacement: "./src" }];"#,
4314        )
4315        .unwrap();
4316        std::fs::write(
4317            dir.path().join("b.js"),
4318            r#"export const b = [{ find: "~", replacement: "./lib" }];"#,
4319        )
4320        .unwrap();
4321        let config = dir.path().join("vite.config.js");
4322        let source = r#"
4323            import { a } from "./a.js";
4324            import { b } from "./b.js";
4325            export default defineConfig({ resolve: { alias: [...a, ...b] } });
4326        "#;
4327        let mut got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4328        got.sort();
4329        assert_eq!(
4330            got,
4331            vec![
4332                ("@".to_string(), "./src".to_string()),
4333                ("~".to_string(), "./lib".to_string()),
4334            ]
4335        );
4336    }
4337
4338    #[test]
4339    fn aliases_import_cycle_terminates() {
4340        // a.js imports from b.js and vice versa; resolution must not hang and
4341        // should still recover the literal pairs present.
4342        let dir = tempfile::tempdir().unwrap();
4343        std::fs::write(
4344            dir.path().join("a.js"),
4345            r#"import { b } from "./b.js";
4346               export const a = [{ find: "@", replacement: "./src" }, ...b];"#,
4347        )
4348        .unwrap();
4349        std::fs::write(
4350            dir.path().join("b.js"),
4351            r#"import { a } from "./a.js";
4352               export const b = [...a];"#,
4353        )
4354        .unwrap();
4355        let config = dir.path().join("vite.config.js");
4356        let source = r#"
4357            import { a } from "./a.js";
4358            export default defineConfig({ resolve: { alias: a } });
4359        "#;
4360        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4361        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4362    }
4363
4364    #[test]
4365    fn aliases_non_relative_import_not_followed() {
4366        // A bare-package import is intentionally out of scope: no node_modules
4367        // read for an alias literal.
4368        let source = r#"
4369            import { sharedAliases } from "some-pkg";
4370            export default defineConfig({ resolve: { alias: sharedAliases } });
4371        "#;
4372        let dir = tempfile::tempdir().unwrap();
4373        let config = dir.path().join("vite.config.js");
4374        assert!(extract_config_aliases(source, &config, &["resolve", "alias"]).is_empty());
4375    }
4376
4377    #[test]
4378    fn aliases_object_array_value_takes_first_entry() {
4379        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets;
4380        // the resolver must take the first, matching the long-standing non-kinded
4381        // behavior the TypeScript plugin depends on. Regression guard for the
4382        // array-value case that the kinded unification briefly dropped.
4383        let source = r#"
4384            export default {
4385                compilerOptions: { paths: { "@/*": ["./src/*"], "~/*": ["./lib/*", "./vendor/*"] } }
4386            };
4387        "#;
4388        let mut got = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
4389        got.sort();
4390        assert_eq!(
4391            got,
4392            vec![
4393                ("@/*".to_string(), "./src/*".to_string()),
4394                ("~/*".to_string(), "./lib/*".to_string()),
4395            ]
4396        );
4397    }
4398
4399    #[test]
4400    fn aliases_kinded_preserves_is_bare_through_resolution() {
4401        // The bare-string vs path discriminator must survive identifier + spread
4402        // resolution (the test.alias package-to-package gate depends on it).
4403        let source = r#"
4404            const a = [{ find: "lodash-es", replacement: "lodash" }];
4405            export default defineConfig({
4406                resolve: { alias: [...a, { find: "@", replacement: "./src" }] }
4407            });
4408        "#;
4409        let mut got = extract_config_aliases_kinded(source, &js_path(), &["resolve", "alias"]);
4410        got.sort();
4411        assert_eq!(
4412            got,
4413            vec![
4414                ("@".to_string(), "./src".to_string(), false),
4415                ("lodash-es".to_string(), "lodash".to_string(), true),
4416            ]
4417        );
4418    }
4419
4420    #[test]
4421    fn aliases_kinded_preserves_is_bare_through_imported_spread() {
4422        let dir = tempfile::tempdir().unwrap();
4423        std::fs::write(
4424            dir.path().join("aliases.js"),
4425            r#"export const packageAliases = [{ find: "lodash-es", replacement: "lodash" }];"#,
4426        )
4427        .unwrap();
4428        let config = dir.path().join("vite.config.js");
4429        let source = r#"
4430            import { packageAliases } from "./aliases.js";
4431            export default defineConfig({
4432                resolve: { alias: [...packageAliases, { find: "@", replacement: "./src" }] }
4433            });
4434        "#;
4435        let mut got = extract_config_aliases_kinded(source, &config, &["resolve", "alias"]);
4436        got.sort();
4437        assert_eq!(
4438            got,
4439            vec![
4440                ("@".to_string(), "./src".to_string(), false),
4441                ("lodash-es".to_string(), "lodash".to_string(), true),
4442            ]
4443        );
4444    }
4445
4446    // --- extract_config_command ---
4447
4448    #[test]
4449    fn extract_command_string_literal() {
4450        let source = r#"export default { start: "node server.js" };"#;
4451        let val = extract_config_command(source, &js_path(), &["start"]);
4452        assert_eq!(val, Some("node server.js".to_string()));
4453    }
4454
4455    #[test]
4456    fn extract_command_nested_path() {
4457        let source = r#"
4458            export default {
4459                scripts: {
4460                    dev: "vite dev"
4461                }
4462            };
4463        "#;
4464        let val = extract_config_command(source, &js_path(), &["scripts", "dev"]);
4465        assert_eq!(val, Some("vite dev".to_string()));
4466    }
4467
4468    #[test]
4469    fn extract_command_missing_key_returns_none() {
4470        let source = r#"export default { other: "val" };"#;
4471        let val = extract_config_command(source, &js_path(), &["start"]);
4472        assert!(val.is_none());
4473    }
4474
4475    #[test]
4476    fn extract_command_ts_as_expression() {
4477        let source = r#"export default { start: "node server.js" as string };"#;
4478        let val = extract_config_command(source, &ts_path(), &["start"]);
4479        assert_eq!(val, Some("node server.js".to_string()));
4480    }
4481
4482    #[test]
4483    fn extract_command_ts_satisfies_expression() {
4484        let source = r#"export default { start: "node server.js" satisfies string };"#;
4485        let val = extract_config_command(source, &ts_path(), &["start"]);
4486        assert_eq!(val, Some("node server.js".to_string()));
4487    }
4488
4489    #[test]
4490    fn extract_command_parenthesized_expression() {
4491        let source = r#"export default { start: ("node server.js") };"#;
4492        let val = extract_config_command(source, &js_path(), &["start"]);
4493        assert_eq!(val, Some("node server.js".to_string()));
4494    }
4495
4496    #[test]
4497    fn extract_command_empty_path_returns_none() {
4498        let source = r#"export default { start: "node server.js" };"#;
4499        let val = extract_config_command(source, &js_path(), &[]);
4500        assert!(val.is_none());
4501    }
4502
4503    // --- is_disabled_expression and extract_config_truthy_bool_or_object ---
4504
4505    #[test]
4506    fn truthy_bool_or_object_with_true_value() {
4507        let source = r"export default { typescript: true };";
4508        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4509        assert!(result);
4510    }
4511
4512    #[test]
4513    fn truthy_bool_or_object_with_false_value() {
4514        let source = r"export default { typescript: false };";
4515        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4516        assert!(!result);
4517    }
4518
4519    #[test]
4520    fn truthy_bool_or_object_with_object_value() {
4521        let source = r#"export default { typescript: { reactDocgen: "react-docgen" } };"#;
4522        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4523        assert!(result);
4524    }
4525
4526    #[test]
4527    fn truthy_bool_or_object_missing_key_returns_false() {
4528        let source = r"export default { other: true };";
4529        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4530        assert!(!result);
4531    }
4532
4533    #[test]
4534    fn truthy_bool_or_object_with_string_value_returns_false() {
4535        // A string is neither bool true nor object, so the else arm returns false.
4536        let source = r#"export default { typescript: "yes" };"#;
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_ts_satisfies_wrapper() {
4543        let source = r"export default { typescript: (true satisfies boolean) };";
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_ts_as_wrapper() {
4550        let source = r"export default { typescript: (true as boolean) };";
4551        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4552        assert!(result);
4553    }
4554
4555    #[test]
4556    fn truthy_bool_or_object_parenthesized_wrapper() {
4557        let source = r"export default { typescript: (true) };";
4558        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4559        assert!(result);
4560    }
4561
4562    // --- object_expression helper: exercises via static dir entries property_string ---
4563    // property_object calls object_expression; it is also exercised through
4564    // extract_object_from_expression, which handles TS wrappers at the top-export level.
4565    // The ts_satisfies_direct_export / ts_as_direct_export tests already cover those arms.
4566
4567    #[test]
4568    fn static_dir_entries_object_form_exercises_property_string() {
4569        // property_string (which calls property_expr then expression_to_string) is used
4570        // for the `from` and `to` keys in extract_config_static_dir_entries.
4571        let source = r#"
4572            export default {
4573                staticDirs: [
4574                    { from: "./media", to: "/assets" }
4575                ]
4576            };
4577        "#;
4578        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4579        assert_eq!(
4580            entries,
4581            vec![("./media".to_string(), Some("/assets".to_string()))]
4582        );
4583    }
4584
4585    // --- expression_to_path_values (array form) ---
4586
4587    #[test]
4588    fn expression_to_path_values_array_form_via_config_path() {
4589        // The extract_config_path helper uses expression_to_path; path_values
4590        // is exercised when the value is an array via extract_config_string_or_array.
4591        let source = r#"export default { entries: ["./src/a.ts", "./src/b.ts"] };"#;
4592        let result = extract_config_string_or_array(source, &js_path(), &["entries"]);
4593        assert_eq!(result, vec!["./src/a.ts", "./src/b.ts"]);
4594    }
4595
4596    // --- extract_config_array_nested_aliases ---
4597
4598    #[test]
4599    fn array_nested_aliases_object_form() {
4600        let source = r#"
4601            export default {
4602                test: {
4603                    projects: [
4604                        {
4605                            resolve: {
4606                                alias: { "@": "./src" }
4607                            }
4608                        }
4609                    ]
4610                }
4611            };
4612        "#;
4613        let aliases = extract_config_array_nested_aliases(
4614            source,
4615            &ts_path(),
4616            &["test", "projects"],
4617            &["resolve", "alias"],
4618        );
4619        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
4620    }
4621
4622    #[test]
4623    fn array_nested_aliases_array_form_find_replacement() {
4624        let source = r#"
4625            export default {
4626                projects: [
4627                    {
4628                        resolve: {
4629                            alias: [
4630                                { find: "@", replacement: "./src" },
4631                                { find: "~", replacement: "./lib" }
4632                            ]
4633                        }
4634                    }
4635                ]
4636            };
4637        "#;
4638        let aliases = extract_config_array_nested_aliases(
4639            source,
4640            &ts_path(),
4641            &["projects"],
4642            &["resolve", "alias"],
4643        );
4644        assert_eq!(
4645            aliases,
4646            vec![
4647                ("@".to_string(), "./src".to_string()),
4648                ("~".to_string(), "./lib".to_string()),
4649            ]
4650        );
4651    }
4652
4653    #[test]
4654    fn array_nested_aliases_empty_when_path_is_not_array() {
4655        let source = r#"export default { test: { projects: "not-an-array" } };"#;
4656        let aliases = extract_config_array_nested_aliases(
4657            source,
4658            &ts_path(),
4659            &["test", "projects"],
4660            &["resolve", "alias"],
4661        );
4662        assert!(aliases.is_empty());
4663    }
4664
4665    #[test]
4666    fn array_nested_aliases_kinded_tracks_is_bare() {
4667        let source = r#"
4668            export default {
4669                projects: [
4670                    {
4671                        resolve: {
4672                            alias: [
4673                                { find: "lodash-es", replacement: "lodash" },
4674                                { find: "@", replacement: "./src" }
4675                            ]
4676                        }
4677                    }
4678                ]
4679            };
4680        "#;
4681        let mut aliases = extract_config_array_nested_aliases_kinded(
4682            source,
4683            &ts_path(),
4684            &["projects"],
4685            &["resolve", "alias"],
4686        );
4687        aliases.sort();
4688        assert_eq!(
4689            aliases,
4690            vec![
4691                ("@".to_string(), "./src".to_string(), false),
4692                ("lodash-es".to_string(), "lodash".to_string(), true),
4693            ]
4694        );
4695    }
4696
4697    // --- extract_default_export_array_aliases_kinded ---
4698
4699    #[test]
4700    fn default_export_array_aliases_kinded_extracts_from_workspace_config() {
4701        let source = r#"
4702            export default [
4703                {
4704                    resolve: {
4705                        alias: { "@": "./src" }
4706                    }
4707                },
4708                {
4709                    resolve: {
4710                        alias: [{ find: "~", replacement: "./lib" }]
4711                    }
4712                }
4713            ];
4714        "#;
4715        let mut aliases =
4716            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4717        aliases.sort();
4718        assert_eq!(
4719            aliases,
4720            vec![
4721                ("@".to_string(), "./src".to_string(), false),
4722                ("~".to_string(), "./lib".to_string(), false),
4723            ]
4724        );
4725    }
4726
4727    #[test]
4728    fn default_export_array_aliases_kinded_define_workspace_wrapper() {
4729        let source = r#"
4730            export default defineWorkspace([
4731                {
4732                    resolve: { alias: { "@": "./src" } }
4733                }
4734            ]);
4735        "#;
4736        let aliases =
4737            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4738        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
4739    }
4740
4741    #[test]
4742    fn default_export_array_aliases_kinded_empty_when_no_alias_path() {
4743        let source = r#"
4744            export default [
4745                { test: { include: ["**/*.test.ts"] } }
4746            ];
4747        "#;
4748        let aliases =
4749            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4750        assert!(aliases.is_empty());
4751    }
4752
4753    // --- config_default_export_unreachable ---
4754
4755    #[test]
4756    fn config_default_export_unreachable_when_no_export() {
4757        let source = r"const x = 42;";
4758        assert!(config_default_export_unreachable(source, &js_path()));
4759    }
4760
4761    #[test]
4762    fn config_default_export_unreachable_false_for_object_export() {
4763        let source = r#"export default { key: "value" };"#;
4764        assert!(!config_default_export_unreachable(source, &js_path()));
4765    }
4766
4767    #[test]
4768    fn config_default_export_unreachable_false_for_array_export() {
4769        let source = r#"export default ["a", "b"];"#;
4770        assert!(!config_default_export_unreachable(source, &js_path()));
4771    }
4772
4773    #[test]
4774    fn config_default_export_unreachable_true_for_function_without_return_object() {
4775        // A function that returns a number is unreachable.
4776        let source = r"export default function config() { return 42; }";
4777        assert!(config_default_export_unreachable(source, &js_path()));
4778    }
4779
4780    // --- extract_config_static_dir_entries ---
4781
4782    #[test]
4783    fn static_dir_entries_string_and_object_form() {
4784        let source = r#"
4785            export default {
4786                staticDirs: [
4787                    "./public",
4788                    { from: "../assets", to: "/static" }
4789                ]
4790            };
4791        "#;
4792        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4793        assert_eq!(
4794            entries,
4795            vec![
4796                ("./public".to_string(), None),
4797                ("../assets".to_string(), Some("/static".to_string())),
4798            ]
4799        );
4800    }
4801
4802    #[test]
4803    fn static_dir_entries_object_without_to() {
4804        let source = r#"
4805            export default {
4806                staticDirs: [
4807                    { from: "./media" }
4808                ]
4809            };
4810        "#;
4811        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4812        assert_eq!(entries, vec![("./media".to_string(), None)]);
4813    }
4814
4815    #[test]
4816    fn static_dir_entries_object_missing_from_skipped() {
4817        // Objects without a `from` key are silently skipped.
4818        let source = r#"
4819            export default {
4820                staticDirs: [
4821                    { to: "/target" },
4822                    "./public"
4823                ]
4824            };
4825        "#;
4826        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4827        assert_eq!(entries, vec![("./public".to_string(), None)]);
4828    }
4829
4830    #[test]
4831    fn static_dir_entries_empty_when_not_array() {
4832        let source = r#"export default { staticDirs: "./public" };"#;
4833        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4834        assert!(entries.is_empty());
4835    }
4836
4837    // --- expression_to_alias_pairs and expression_to_alias_pairs_kinded (lines 1473-1541) ---
4838
4839    #[test]
4840    fn aliases_array_form_missing_find_or_replacement_skipped() {
4841        // An element missing "find" or "replacement" is silently skipped.
4842        let source = r#"
4843            export default {
4844                resolve: {
4845                    alias: [
4846                        { replacement: "./src" },
4847                        { find: "@" },
4848                        { find: "~", replacement: "./lib" }
4849                    ]
4850                }
4851            };
4852        "#;
4853        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4854        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
4855    }
4856
4857    #[test]
4858    fn aliases_object_form_computed_key_skipped() {
4859        // Computed keys (expression keys) are not statically recoverable.
4860        let source = r#"
4861            const k = "@";
4862            export default {
4863                resolve: {
4864                    alias: {
4865                        [k]: "./src",
4866                        "~": "./lib"
4867                    }
4868                }
4869            };
4870        "#;
4871        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4872        // Only the literal key "~" survives; computed [k] is dropped.
4873        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
4874    }
4875
4876    #[test]
4877    fn aliases_kinded_array_form_path_replacement_is_not_bare() {
4878        let source = r#"
4879            export default {
4880                resolve: {
4881                    alias: [{ find: "@", replacement: "./src" }]
4882                }
4883            };
4884        "#;
4885        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4886        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
4887    }
4888
4889    #[test]
4890    fn aliases_kinded_object_form_bare_and_path_discrimination() {
4891        let source = r#"
4892            export default {
4893                resolve: {
4894                    alias: {
4895                        "lodash-es": "lodash",
4896                        "@": "./src"
4897                    }
4898                }
4899            };
4900        "#;
4901        let mut aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4902        aliases.sort();
4903        assert_eq!(
4904            aliases,
4905            vec![
4906                ("@".to_string(), "./src".to_string(), false),
4907                ("lodash-es".to_string(), "lodash".to_string(), true),
4908            ]
4909        );
4910    }
4911
4912    #[test]
4913    fn aliases_kinded_parent_relative_replacement_is_not_bare() {
4914        let source = r#"
4915            export default {
4916                resolve: { alias: { "@": "../shared/src" } }
4917            };
4918        "#;
4919        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4920        assert_eq!(
4921            aliases,
4922            vec![("@".to_string(), "../shared/src".to_string(), false)]
4923        );
4924    }
4925
4926    #[test]
4927    fn aliases_kinded_absolute_replacement_is_not_bare() {
4928        let source = r#"
4929            export default {
4930                resolve: { alias: { "@": "/absolute/path" } }
4931            };
4932        "#;
4933        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4934        assert_eq!(
4935            aliases,
4936            vec![("@".to_string(), "/absolute/path".to_string(), false)]
4937        );
4938    }
4939
4940    // --- find_default_export_array / array_from_expression wrappers ---
4941
4942    #[test]
4943    fn default_export_array_ts_as_wrapper() {
4944        // array_from_expression must unwrap TSAsExpression.
4945        let source = r"export default [] as string[];";
4946        assert!(!config_default_export_unreachable(source, &js_path()));
4947    }
4948
4949    #[test]
4950    fn default_export_array_ts_satisfies_wrapper() {
4951        let source = r"export default [] satisfies string[];";
4952        assert!(!config_default_export_unreachable(source, &ts_path()));
4953    }
4954
4955    #[test]
4956    fn default_export_array_define_config_call_wrapper() {
4957        let source = r#"export default defineConfig(["**/*.test.ts"]);"#;
4958        assert!(!config_default_export_unreachable(source, &ts_path()));
4959    }
4960
4961    // --- collect_shallow_string_values: object-property branches ---
4962
4963    #[test]
4964    fn shallow_strings_object_with_string_values() {
4965        // The ObjectExpression arm of collect_shallow_string_values emits string values.
4966        let source = r#"
4967            export default {
4968                plugins: {
4969                    autoprefixer: "autoprefixer",
4970                    tailwindcss: "tailwindcss"
4971                }
4972            };
4973        "#;
4974        let vals = extract_config_shallow_strings(source, &js_path(), "plugins");
4975        assert!(vals.contains(&"autoprefixer".to_string()));
4976        assert!(vals.contains(&"tailwindcss".to_string()));
4977    }
4978
4979    #[test]
4980    fn shallow_strings_object_with_sub_array_first_element() {
4981        // An object property whose value is an array emits the first string element.
4982        let source = r#"
4983            export default {
4984                reporters: {
4985                    main: ["jest-junit", { outputFile: "report.xml" }],
4986                    alt: ["html-reporter"]
4987                }
4988            };
4989        "#;
4990        let vals = extract_config_shallow_strings(source, &js_path(), "reporters");
4991        assert!(vals.contains(&"jest-junit".to_string()));
4992        assert!(vals.contains(&"html-reporter".to_string()));
4993    }
4994
4995    // --- collect_shallow_string_or_object_property_values ---
4996
4997    #[test]
4998    fn shallow_strings_or_object_property_non_array_single_string() {
4999        // When the top-level value is a plain string (not an array), it is returned directly.
5000        let source = r#"export default { jsPlugins: "eslint-plugin-foo" };"#;
5001        let vals = extract_config_shallow_strings_or_object_property(
5002            source,
5003            &ts_path(),
5004            "jsPlugins",
5005            "specifier",
5006        );
5007        assert_eq!(vals, vec!["eslint-plugin-foo"]);
5008    }
5009
5010    #[test]
5011    fn shallow_strings_or_object_property_ts_satisfies_array_element() {
5012        // shallow_string_or_object_property unwraps TSSatisfiesExpression.
5013        let source = r#"
5014            export default {
5015                jsPlugins: [
5016                    ("eslint-plugin-a" satisfies string)
5017                ]
5018            };
5019        "#;
5020        let vals = extract_config_shallow_strings_or_object_property(
5021            source,
5022            &ts_path(),
5023            "jsPlugins",
5024            "specifier",
5025        );
5026        assert_eq!(vals, vec!["eslint-plugin-a"]);
5027    }
5028
5029    #[test]
5030    fn shallow_strings_or_object_property_ts_as_array_element() {
5031        let source = r#"
5032            export default {
5033                jsPlugins: [
5034                    ("eslint-plugin-b" as string)
5035                ]
5036            };
5037        "#;
5038        let vals = extract_config_shallow_strings_or_object_property(
5039            source,
5040            &ts_path(),
5041            "jsPlugins",
5042            "specifier",
5043        );
5044        assert_eq!(vals, vec!["eslint-plugin-b"]);
5045    }
5046
5047    #[test]
5048    fn shallow_strings_or_object_property_sub_array_first_element_string() {
5049        // A sub-array in jsPlugins returns the first string element.
5050        let source = r#"
5051            export default {
5052                jsPlugins: [
5053                    ["eslint-plugin-tuple-pkg", { options: true }]
5054                ]
5055            };
5056        "#;
5057        let vals = extract_config_shallow_strings_or_object_property(
5058            source,
5059            &ts_path(),
5060            "jsPlugins",
5061            "specifier",
5062        );
5063        assert_eq!(vals, vec!["eslint-plugin-tuple-pkg"]);
5064    }
5065
5066    // --- extract_config_array_object_command_pairs ---
5067
5068    #[test]
5069    fn array_object_command_pairs_basic() {
5070        let source = r#"
5071            export default {
5072                webServer: [
5073                    { command: "node server.js", cwd: "packages/api" },
5074                    { command: "vite dev" }
5075                ]
5076            };
5077        "#;
5078        let pairs = extract_config_array_object_command_pairs(
5079            source,
5080            &ts_path(),
5081            &["webServer"],
5082            "command",
5083            "cwd",
5084        );
5085        assert_eq!(
5086            pairs,
5087            vec![
5088                (
5089                    "node server.js".to_string(),
5090                    Some("packages/api".to_string())
5091                ),
5092                ("vite dev".to_string(), None),
5093            ]
5094        );
5095    }
5096
5097    #[test]
5098    fn array_object_command_pairs_skips_missing_command() {
5099        let source = r#"
5100            export default {
5101                webServer: [
5102                    { cwd: "packages/api" },
5103                    { command: "vite dev", cwd: "apps/web" }
5104                ]
5105            };
5106        "#;
5107        let pairs = extract_config_array_object_command_pairs(
5108            source,
5109            &ts_path(),
5110            &["webServer"],
5111            "command",
5112            "cwd",
5113        );
5114        assert_eq!(
5115            pairs,
5116            vec![("vite dev".to_string(), Some("apps/web".to_string()))]
5117        );
5118    }
5119
5120    #[test]
5121    fn array_object_command_pairs_empty_when_not_array() {
5122        let source = r#"export default { webServer: { command: "vite dev" } };"#;
5123        let pairs = extract_config_array_object_command_pairs(
5124            source,
5125            &ts_path(),
5126            &["webServer"],
5127            "command",
5128            "cwd",
5129        );
5130        assert!(pairs.is_empty());
5131    }
5132
5133    // --- normalize_config_path edge cases ---
5134
5135    #[test]
5136    fn normalize_config_path_empty_string_returns_none() {
5137        let config_path = PathBuf::from("/project/vite.config.ts");
5138        let root = PathBuf::from("/project");
5139        assert_eq!(normalize_config_path("", &config_path, &root), None);
5140    }
5141
5142    #[test]
5143    fn normalize_config_path_escapes_to_above_root_returns_none() {
5144        let config_path = PathBuf::from("/project/vite.config.ts");
5145        let root = PathBuf::from("/project");
5146        // "../../etc" normalizes to the parent of root, which fails the strip_prefix.
5147        assert_eq!(
5148            normalize_config_path("../../etc", &config_path, &root),
5149            None
5150        );
5151    }
5152
5153    #[test]
5154    fn normalize_config_path_dot_slash_resolves_relative_to_config_dir() {
5155        let config_path = PathBuf::from("/project/packages/app/vite.config.ts");
5156        let root = PathBuf::from("/project");
5157        assert_eq!(
5158            normalize_config_path("./src", &config_path, &root),
5159            Some("packages/app/src".to_string())
5160        );
5161    }
5162
5163    // --- JSON config parsing edge cases ---
5164
5165    #[test]
5166    fn json_config_array_of_arrays_via_shallow_strings() {
5167        // JSON with nested plugin tuples is parsed via the parenthesis-wrap path.
5168        let source = r#"{"reporters": ["default", ["jest-junit", {}]]}"#;
5169        let vals = extract_config_shallow_strings(source, &json_path(), "reporters");
5170        assert_eq!(vals, vec!["default", "jest-junit"]);
5171    }
5172
5173    // --- extract_config_path ---
5174
5175    #[test]
5176    fn extract_config_path_string_literal() {
5177        let source = r#"export default { outDir: "./dist" };"#;
5178        let path = extract_config_path(source, &js_path(), &["outDir"]);
5179        assert_eq!(
5180            path.map(|p| p.to_string_lossy().replace('\\', "/")),
5181            Some("./dist".to_string())
5182        );
5183    }
5184
5185    #[test]
5186    fn extract_config_path_with_resolve_call() {
5187        let source = r#"
5188            import { resolve } from "node:path";
5189            export default { outDir: resolve(__dirname, "dist") };
5190        "#;
5191        let path = extract_config_path(source, &js_path(), &["outDir"]);
5192        assert_eq!(
5193            path.map(|p| p.to_string_lossy().replace('\\', "/")),
5194            Some("dist".to_string())
5195        );
5196    }
5197
5198    #[test]
5199    fn extract_config_path_missing_key_returns_none() {
5200        let source = r#"export default { other: "val" };"#;
5201        let path = extract_config_path(source, &js_path(), &["outDir"]);
5202        assert!(path.is_none());
5203    }
5204
5205    // --- extract_imports_and_requires ---
5206
5207    #[test]
5208    fn extract_imports_and_requires_both_forms() {
5209        let source = r"
5210            import foo from 'foo-pkg';
5211            require('bar-pkg');
5212            export default {};
5213        ";
5214        let sources = extract_imports_and_requires(source, &js_path());
5215        assert!(sources.contains(&"foo-pkg".to_string()));
5216        assert!(sources.contains(&"bar-pkg".to_string()));
5217    }
5218
5219    #[test]
5220    fn extract_imports_and_requires_skips_non_require_calls() {
5221        let source = r"
5222            import foo from 'foo-pkg';
5223            someOtherCall('bar-pkg');
5224            export default {};
5225        ";
5226        let sources = extract_imports_and_requires(source, &js_path());
5227        assert_eq!(sources, vec!["foo-pkg"]);
5228    }
5229
5230    // --- extract_config_nested_shallow_strings: non-object nested value ---
5231
5232    #[test]
5233    fn nested_shallow_strings_non_object_nested_returns_empty() {
5234        // When the outer path points to a non-object, it returns empty.
5235        let source = r#"export default { test: "not-an-object" };"#;
5236        let vals =
5237            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
5238        assert!(vals.is_empty());
5239    }
5240
5241    // --- vite_react_babel_dependencies with namespace import ---
5242
5243    #[test]
5244    fn vite_react_babel_dependencies_namespace_import() {
5245        let source = r#"
5246            import * as react from "@vitejs/plugin-react";
5247
5248            export default defineConfig({
5249                plugins: [
5250                    react.default({
5251                        babel: {
5252                            plugins: ["babel-plugin-ns"],
5253                        },
5254                    }),
5255                ],
5256            });
5257        "#;
5258        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
5259        assert_eq!(deps, vec!["babel-plugin-ns".to_string()]);
5260    }
5261
5262    // --- collect_all_string_values nested object and array recursion ---
5263
5264    #[test]
5265    fn property_strings_deeply_nested_object_values() {
5266        // collect_all_string_values recurses into nested objects and arrays.
5267        let source = r#"
5268            export default {
5269                settings: {
5270                    a: "val-a",
5271                    b: {
5272                        c: "val-c",
5273                        d: ["val-d1", "val-d2"]
5274                    }
5275                }
5276            };
5277        "#;
5278        let values = extract_config_property_strings(source, &js_path(), "settings");
5279        assert!(values.contains(&"val-a".to_string()));
5280        assert!(values.contains(&"val-c".to_string()));
5281        assert!(values.contains(&"val-d1".to_string()));
5282        assert!(values.contains(&"val-d2".to_string()));
5283    }
5284
5285    // --- find_variable_init_expression: export const form ---
5286
5287    #[test]
5288    fn aliases_exported_const_form_resolves() {
5289        // find_variable_init_expression must handle `export const NAME = ...`.
5290        let source = r#"
5291            export const sharedAliases = { "@": "./src" };
5292            export default defineConfig({ resolve: { alias: sharedAliases } });
5293        "#;
5294        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5295        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5296    }
5297
5298    // --- resolve_sibling_module: index file probe ---
5299
5300    #[test]
5301    fn aliases_imported_from_sibling_directory_index_file() {
5302        // resolve_sibling_module probes <specifier>/index.<ext> when direct
5303        // path and extension-suffixed paths do not exist.
5304        let dir = tempfile::tempdir().unwrap();
5305        let aliases_dir = dir.path().join("aliases");
5306        std::fs::create_dir_all(&aliases_dir).unwrap();
5307        std::fs::write(
5308            aliases_dir.join("index.js"),
5309            r#"export const aliases = [{ find: "@", replacement: "./src" }];"#,
5310        )
5311        .unwrap();
5312        let config = dir.path().join("vite.config.js");
5313        let source = r#"
5314            import { aliases } from "./aliases";
5315            export default defineConfig({ resolve: { alias: aliases } });
5316        "#;
5317        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
5318        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
5319    }
5320
5321    // --- aliases max depth guard ---
5322
5323    #[test]
5324    fn aliases_depth_limit_terminates_deep_chain() {
5325        // A chain of more than MAX_ALIAS_RESOLVE_DEPTH identifiers terminates
5326        // without panic or infinite loop. We verify it does not crash.
5327        let source = r#"
5328            const a9 = [{ find: "@", replacement: "./src" }];
5329            const a8 = a9;
5330            const a7 = a8;
5331            const a6 = a7;
5332            const a5 = a6;
5333            const a4 = a5;
5334            const a3 = a4;
5335            const a2 = a3;
5336            const a1 = a2;
5337            export default defineConfig({ resolve: { alias: a1 } });
5338        "#;
5339        // At MAX_ALIAS_RESOLVE_DEPTH (8), resolution stops before reaching the literal.
5340        let got = extract_config_aliases(source, &js_path(), &["resolve", "alias"]);
5341        let _ = got; // empty or non-empty; both are valid, no panic is the assertion.
5342    }
5343
5344    // --- expression_to_path_string: new URL / fileURLToPath ---
5345
5346    #[test]
5347    fn extract_aliases_file_url_to_path_new_url() {
5348        // expression_to_path_string resolves new URL("./src", import.meta.url).
5349        let source = r#"
5350            import { fileURLToPath, URL } from 'node:url';
5351            export default {
5352                resolve: {
5353                    alias: {
5354                        "@": fileURLToPath(new URL("./src", import.meta.url))
5355                    }
5356                }
5357            };
5358        "#;
5359        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5360        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5361    }
5362
5363    #[test]
5364    fn extract_path_via_new_url_pathname_member() {
5365        // The .pathname member of new URL(...) is a path-string form.
5366        let source = r#"
5367            export default {
5368                resolve: {
5369                    alias: {
5370                        "@": new URL("./src", import.meta.url).pathname
5371                    }
5372                }
5373            };
5374        "#;
5375        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5376        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5377    }
5378
5379    // --- is_disabled_expression: null literal ---
5380
5381    #[test]
5382    fn truthy_bool_or_object_null_literal_returns_false() {
5383        // null is a disabled expression and therefore not truthy.
5384        let source = r"export default { typescript: null };";
5385        let result = extract_config_truthy_bool_or_object(source, &js_path(), &["typescript"]);
5386        assert!(!result);
5387    }
5388
5389    // --- expression_to_string_array: non-array form returns empty ---
5390
5391    #[test]
5392    fn string_array_non_array_value_returns_empty() {
5393        let source = r#"export default { items: "not-an-array" };"#;
5394        let result = extract_config_string_array(source, &js_path(), &["items"]);
5395        assert!(result.is_empty());
5396    }
5397
5398    // --- extract_config_object_nested edge cases ---
5399
5400    #[test]
5401    fn object_nested_empty_when_inner_value_is_not_object() {
5402        // extract_config_object_nested only processes properties whose value is an object.
5403        let source = r#"export default { targets: { build: "not-an-object" } };"#;
5404        let results =
5405            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
5406        assert!(results.is_empty());
5407    }
5408
5409    // --- extract_config_array_nested_string_or_array: missing inner path ---
5410
5411    #[test]
5412    fn array_nested_string_or_array_missing_inner_path_returns_empty() {
5413        let source = r#"
5414            export default {
5415                test: {
5416                    projects: [
5417                        { test: { include: ["**/*.test.ts"] } }
5418                    ]
5419                }
5420            };
5421        "#;
5422        let results = extract_config_array_nested_string_or_array(
5423            source,
5424            &ts_path(),
5425            &["test", "projects"],
5426            &["test", "setupFiles"],
5427        );
5428        assert!(results.is_empty());
5429    }
5430
5431    #[test]
5432    fn wrapped_named_const_default_export_resolves() {
5433        // `export default withMDX(nextConfig)` (official @next/mdx idiom): the
5434        // config is passed as a named const to a wrapper call. Regression #1642.
5435        let source = r#"
5436            import createMDX from "@next/mdx";
5437            const nextConfig = { pageExtensions: ["ts", "tsx", "md", "mdx"] };
5438            const withMDX = createMDX({});
5439            export default withMDX(nextConfig);
5440        "#;
5441        let exts = extract_config_string_array(source, &ts_path(), &["pageExtensions"]);
5442        assert_eq!(exts, vec!["ts", "tsx", "md", "mdx"]);
5443    }
5444
5445    #[test]
5446    fn wrapped_named_const_module_exports_resolves() {
5447        // `module.exports = createJestConfig(customConfig)` (next/jest idiom).
5448        let source = r#"
5449            const nextJest = require("next/jest");
5450            const createJestConfig = nextJest();
5451            const customConfig = { testMatch: ["**/*.test.ts"] };
5452            module.exports = createJestConfig(customConfig);
5453        "#;
5454        let matches = extract_config_string_array(source, &js_path(), &["testMatch"]);
5455        assert_eq!(matches, vec!["**/*.test.ts"]);
5456    }
5457
5458    #[test]
5459    fn wrapped_named_const_nested_and_curried_resolve() {
5460        let nested = r#"
5461            const nextConfig = { pageExtensions: ["mdx"] };
5462            const withMDX = (c) => c;
5463            const withFoo = (c) => c;
5464            export default withMDX(withFoo(nextConfig));
5465        "#;
5466        assert_eq!(
5467            extract_config_string_array(nested, &js_path(), &["pageExtensions"]),
5468            vec!["mdx"]
5469        );
5470
5471        let curried = r#"
5472            const nextConfig = { pageExtensions: ["md"] };
5473            const compose = (..._p) => (c) => c;
5474            export default compose(a, b)(nextConfig);
5475        "#;
5476        assert_eq!(
5477            extract_config_string_array(curried, &js_path(), &["pageExtensions"]),
5478            vec!["md"]
5479        );
5480    }
5481
5482    #[test]
5483    fn wrapped_inline_object_still_resolves() {
5484        // The pre-existing inline-object form must keep working unchanged.
5485        let source = r#"
5486            const withMDX = createMDX({});
5487            export default withMDX({ pageExtensions: ["mdx"] });
5488        "#;
5489        assert_eq!(
5490            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
5491            vec!["mdx"]
5492        );
5493    }
5494}