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