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), plus parenthesised / `as` wrappers.
2713fn find_default_export_array<'a>(program: &'a Program<'a>) -> Option<&'a ArrayExpression<'a>> {
2714    for stmt in &program.body {
2715        if let Statement::ExportDefaultDeclaration(decl) = stmt
2716            && let Some(expr) = decl.declaration.as_expression()
2717        {
2718            return array_from_expression(expr);
2719        }
2720    }
2721    None
2722}
2723
2724fn array_from_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
2725    match expr {
2726        Expression::ArrayExpression(arr) => Some(arr),
2727        Expression::ParenthesizedExpression(paren) => array_from_expression(&paren.expression),
2728        Expression::TSAsExpression(ts_as) => array_from_expression(&ts_as.expression),
2729        Expression::TSSatisfiesExpression(ts_sat) => array_from_expression(&ts_sat.expression),
2730        Expression::CallExpression(call) => call
2731            .arguments
2732            .first()
2733            .and_then(Argument::as_expression)
2734            .and_then(array_from_expression),
2735        _ => None,
2736    }
2737}
2738
2739pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
2740    let mut normalized = PathBuf::new();
2741
2742    for component in path.components() {
2743        match component {
2744            std::path::Component::CurDir => {}
2745            std::path::Component::ParentDir => {
2746                normalized.pop();
2747            }
2748            _ => normalized.push(component.as_os_str()),
2749        }
2750    }
2751
2752    normalized
2753}
2754
2755/// Whether `specifier` is a bare package specifier: not empty, not relative or
2756/// absolute, not protocol-prefixed, and free of characters that are invalid in
2757/// an npm package name (backslashes, whitespace).
2758pub(crate) fn is_package_specifier(specifier: &str) -> bool {
2759    !specifier.is_empty()
2760        && specifier != "."
2761        && specifier != ".."
2762        && !specifier.starts_with("./")
2763        && !specifier.starts_with("../")
2764        && !specifier.starts_with('/')
2765        && !specifier.contains(':')
2766        && !specifier.contains('\\')
2767        && !specifier.chars().any(char::is_whitespace)
2768}
2769
2770/// Convert an expression to a string array if it's an array of string literals.
2771fn expression_to_string_array(expr: &Expression) -> Vec<String> {
2772    match expr {
2773        Expression::ArrayExpression(arr) => arr
2774            .elements
2775            .iter()
2776            .filter_map(|el| match el {
2777                ArrayExpressionElement::SpreadElement(_) => None,
2778                _ => el.as_expression().and_then(expression_to_string),
2779            })
2780            .collect(),
2781        _ => vec![],
2782    }
2783}
2784
2785/// Collect only top-level string values from an expression.
2786///
2787/// For arrays, extracts direct string elements and the first string element of sub-arrays
2788/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
2789fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
2790    let mut values = Vec::new();
2791    match expr {
2792        Expression::StringLiteral(s) => {
2793            values.push(s.value.to_string());
2794        }
2795        Expression::ArrayExpression(arr) => {
2796            for el in &arr.elements {
2797                if let Some(inner) = el.as_expression() {
2798                    match inner {
2799                        Expression::StringLiteral(s) => {
2800                            values.push(s.value.to_string());
2801                        }
2802                        Expression::ArrayExpression(sub_arr) => {
2803                            if let Some(first) = sub_arr.elements.first()
2804                                && let Some(first_expr) = first.as_expression()
2805                                && let Some(s) = expression_to_string(first_expr)
2806                            {
2807                                values.push(s);
2808                            }
2809                        }
2810                        _ => {}
2811                    }
2812                }
2813            }
2814        }
2815        Expression::ObjectExpression(obj) => {
2816            for prop in &obj.properties {
2817                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2818                    match &p.value {
2819                        Expression::StringLiteral(s) => {
2820                            values.push(s.value.to_string());
2821                        }
2822                        Expression::ArrayExpression(sub_arr) => {
2823                            if let Some(first) = sub_arr.elements.first()
2824                                && let Some(first_expr) = first.as_expression()
2825                                && let Some(s) = expression_to_string(first_expr)
2826                            {
2827                                values.push(s);
2828                            }
2829                        }
2830                        _ => {}
2831                    }
2832                }
2833            }
2834        }
2835        _ => {}
2836    }
2837    values
2838}
2839
2840/// Collect top-level string values, plus a named string property from object entries.
2841fn collect_shallow_string_or_object_property_values(
2842    expr: &Expression,
2843    object_property: &str,
2844) -> Vec<String> {
2845    match expr {
2846        Expression::ArrayExpression(arr) => arr
2847            .elements
2848            .iter()
2849            .filter_map(|element| {
2850                element
2851                    .as_expression()
2852                    .and_then(|expr| shallow_string_or_object_property(expr, object_property))
2853            })
2854            .collect(),
2855        _ => shallow_string_or_object_property(expr, object_property)
2856            .into_iter()
2857            .collect(),
2858    }
2859}
2860
2861fn shallow_string_or_object_property(expr: &Expression, object_property: &str) -> Option<String> {
2862    match expr {
2863        Expression::ParenthesizedExpression(paren) => {
2864            shallow_string_or_object_property(&paren.expression, object_property)
2865        }
2866        Expression::TSSatisfiesExpression(ts_sat) => {
2867            shallow_string_or_object_property(&ts_sat.expression, object_property)
2868        }
2869        Expression::TSAsExpression(ts_as) => {
2870            shallow_string_or_object_property(&ts_as.expression, object_property)
2871        }
2872        Expression::ArrayExpression(sub_arr) => sub_arr
2873            .elements
2874            .first()
2875            .and_then(ArrayExpressionElement::as_expression)
2876            .and_then(expression_to_string),
2877        Expression::ObjectExpression(obj) => {
2878            find_property(obj, object_property).and_then(|prop| expression_to_string(&prop.value))
2879        }
2880        _ => expression_to_string(expr),
2881    }
2882}
2883
2884/// Recursively collect all string literal values from an expression tree.
2885fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
2886    match expr {
2887        Expression::StringLiteral(s) => {
2888            values.push(s.value.to_string());
2889        }
2890        Expression::ArrayExpression(arr) => {
2891            for el in &arr.elements {
2892                if let Some(expr) = el.as_expression() {
2893                    collect_all_string_values(expr, values);
2894                }
2895            }
2896        }
2897        Expression::ObjectExpression(obj) => {
2898            for prop in &obj.properties {
2899                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2900                    collect_all_string_values(&p.value, values);
2901                }
2902            }
2903        }
2904        _ => {}
2905    }
2906}
2907
2908/// Convert a `PropertyKey` to a `String`.
2909fn property_key_to_string(key: &PropertyKey) -> Option<String> {
2910    match key {
2911        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
2912        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
2913        _ => None,
2914    }
2915}
2916
2917/// Extract keys of an object at a nested property path.
2918fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2919    if path.is_empty() {
2920        return None;
2921    }
2922    let prop = find_property(obj, path[0])?;
2923    if path.len() == 1 {
2924        if let Expression::ObjectExpression(nested) = &prop.value {
2925            let keys = nested
2926                .properties
2927                .iter()
2928                .filter_map(|p| {
2929                    if let ObjectPropertyKind::ObjectProperty(p) = p {
2930                        property_key_to_string(&p.key)
2931                    } else {
2932                        None
2933                    }
2934                })
2935                .collect();
2936            return Some(keys);
2937        }
2938        return None;
2939    }
2940    if let Expression::ObjectExpression(nested) = &prop.value {
2941        get_nested_object_keys(nested, &path[1..])
2942    } else {
2943        None
2944    }
2945}
2946
2947/// Navigate a nested property path and return the raw expression at the end.
2948fn get_nested_expression<'a>(
2949    obj: &'a ObjectExpression<'a>,
2950    path: &[&str],
2951) -> Option<&'a Expression<'a>> {
2952    if path.is_empty() {
2953        return None;
2954    }
2955    let prop = find_property(obj, path[0])?;
2956    if path.len() == 1 {
2957        return Some(&prop.value);
2958    }
2959    if let Expression::ObjectExpression(nested) = &prop.value {
2960        get_nested_expression(nested, &path[1..])
2961    } else {
2962        None
2963    }
2964}
2965
2966/// Navigate a nested path and extract a string, string array, or object string/array values.
2967fn get_nested_string_or_array(
2968    obj: &ObjectExpression,
2969    path: &[&str],
2970    scope: PathScope<'_>,
2971) -> Option<Vec<String>> {
2972    if path.is_empty() {
2973        return None;
2974    }
2975    if path.len() == 1 {
2976        let prop = find_property(obj, path[0])?;
2977        return Some(string_or_array_in(&prop.value, scope));
2978    }
2979    let prop = find_property(obj, path[0])?;
2980    if let Expression::ObjectExpression(nested) = &prop.value {
2981        get_nested_string_or_array(nested, &path[1..], scope)
2982    } else {
2983        None
2984    }
2985}
2986
2987/// Convert an expression to a `Vec<String>`, handling string, array, object-with-string/array values,
2988/// and Webpack 5 entry descriptors (`{ import: "..." }`).
2989///
2990/// Array elements that are object literals are inspected for an `input` property
2991/// (Angular CLI schema for `styles`/`scripts`/`polyfills`:
2992/// `{ "input": "src/x.scss", "bundleName": "x", "inject": false }`). Extracting
2993/// `input` prevents object-form entries from being silently dropped. See #126.
2994pub(crate) fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
2995    string_or_array_in(expr, PathScope::Bare)
2996}
2997
2998fn string_or_array_in(expr: &Expression, scope: PathScope<'_>) -> Vec<String> {
2999    match expr {
3000        Expression::StringLiteral(s) => vec![s.value.to_string()],
3001        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
3002            .quasis
3003            .first()
3004            .map(|q| vec![q.value.raw.to_string()])
3005            .unwrap_or_default(),
3006        Expression::ArrayExpression(arr) => arr
3007            .elements
3008            .iter()
3009            .filter_map(|el| el.as_expression())
3010            .flat_map(|e| match e {
3011                Expression::ObjectExpression(obj) => find_property(obj, "input")
3012                    .map(|p| string_or_array_in(&p.value, scope))
3013                    .unwrap_or_default(),
3014                _ => path_string_in(e, scope).into_iter().collect(),
3015            })
3016            .collect(),
3017        Expression::ObjectExpression(obj) => obj
3018            .properties
3019            .iter()
3020            .flat_map(|p| {
3021                if let ObjectPropertyKind::ObjectProperty(p) = p {
3022                    match &p.value {
3023                        Expression::ArrayExpression(_) => string_or_array_in(&p.value, scope),
3024                        Expression::ObjectExpression(value_obj) => {
3025                            find_property(value_obj, "import")
3026                                .map(|import_prop| string_or_array_in(&import_prop.value, scope))
3027                                .unwrap_or_default()
3028                        }
3029                        _ => path_string_in(&p.value, scope).into_iter().collect(),
3030                    }
3031                } else {
3032                    Vec::new()
3033                }
3034            })
3035            .collect(),
3036        _ => path_string_in(expr, scope).into_iter().collect(),
3037    }
3038}
3039
3040/// Collect `require('...')` argument strings from an expression.
3041fn collect_require_sources(expr: &Expression) -> Vec<String> {
3042    let mut sources = Vec::new();
3043    match expr {
3044        Expression::CallExpression(call) if is_require_call(call) => {
3045            if let Some(s) = get_require_source(call) {
3046                sources.push(s);
3047            }
3048        }
3049        Expression::ArrayExpression(arr) => {
3050            for el in &arr.elements {
3051                if let Some(inner) = el.as_expression() {
3052                    match inner {
3053                        Expression::CallExpression(call) if is_require_call(call) => {
3054                            if let Some(s) = get_require_source(call) {
3055                                sources.push(s);
3056                            }
3057                        }
3058                        Expression::ArrayExpression(sub_arr) => {
3059                            if let Some(first) = sub_arr.elements.first()
3060                                && let Some(Expression::CallExpression(call)) =
3061                                    first.as_expression()
3062                                && is_require_call(call)
3063                                && let Some(s) = get_require_source(call)
3064                            {
3065                                sources.push(s);
3066                            }
3067                        }
3068                        _ => {}
3069                    }
3070                }
3071            }
3072        }
3073        _ => {}
3074    }
3075    sources
3076}
3077
3078/// Check if a call expression is `require(...)`.
3079pub(crate) fn is_require_call(call: &CallExpression) -> bool {
3080    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
3081}
3082
3083/// Get the first string argument of a `require()` call.
3084pub(crate) fn get_require_source(call: &CallExpression) -> Option<String> {
3085    call.arguments.first().and_then(|arg| {
3086        if let Argument::StringLiteral(s) = arg {
3087            Some(s.value.to_string())
3088        } else {
3089            None
3090        }
3091    })
3092}
3093
3094#[cfg(test)]
3095mod tests {
3096    use super::*;
3097    use std::path::PathBuf;
3098
3099    fn js_path() -> PathBuf {
3100        PathBuf::from("config.js")
3101    }
3102
3103    fn ts_path() -> PathBuf {
3104        PathBuf::from("config.ts")
3105    }
3106
3107    #[test]
3108    fn extract_config_array_tuple_heads_reads_first_string_of_each_tuple() {
3109        let source = r#"
3110            export default defineNuxtConfig({
3111                modules: ["@nuxt/image", ["@nuxt/content", { watch: false }], [someModule, {}], []]
3112            });
3113        "#;
3114        assert_eq!(
3115            extract_config_array_tuple_heads(source, &ts_path(), &["modules"]),
3116            vec!["@nuxt/content".to_string()]
3117        );
3118    }
3119
3120    #[test]
3121    fn extract_lazy_imports_bare_arrows() {
3122        let source = r"
3123            import { defineConfig } from '@adonisjs/core/app'
3124            export default defineConfig({
3125                preloads: [
3126                    () => import('#start/routes'),
3127                    () => import('#start/kernel'),
3128                ],
3129            })
3130        ";
3131        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["preloads"]);
3132        assert_eq!(specs, vec!["#start/routes", "#start/kernel"]);
3133    }
3134
3135    #[test]
3136    fn extract_lazy_imports_object_form_with_file_key() {
3137        let source = r"
3138            export default defineConfig({
3139                providers: [
3140                    () => import('@adonisjs/core/providers/app_provider'),
3141                    {
3142                        file: () => import('@adonisjs/core/providers/repl_provider'),
3143                        environment: ['repl', 'test'],
3144                    },
3145                ],
3146            })
3147        ";
3148        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
3149        assert_eq!(
3150            specs,
3151            vec![
3152                "@adonisjs/core/providers/app_provider",
3153                "@adonisjs/core/providers/repl_provider",
3154            ]
3155        );
3156    }
3157
3158    #[test]
3159    fn extract_lazy_imports_block_body_with_return() {
3160        let source = r"
3161            export default defineConfig({
3162                commands: [
3163                    () => { return import('@adonisjs/core/commands') },
3164                ],
3165            })
3166        ";
3167        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
3168        assert_eq!(specs, vec!["@adonisjs/core/commands"]);
3169    }
3170
3171    #[test]
3172    fn extract_lazy_imports_skips_unknown_element_shapes() {
3173        let source = r"
3174            export default defineConfig({
3175                commands: [
3176                    'string-entry',
3177                    42,
3178                    { other: 'value' },
3179                    () => import('@adonisjs/lucid/commands'),
3180                ],
3181            })
3182        ";
3183        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
3184        assert_eq!(specs, vec!["@adonisjs/lucid/commands"]);
3185    }
3186
3187    #[test]
3188    fn extract_lazy_imports_missing_property_returns_empty() {
3189        let source = r"
3190            export default defineConfig({
3191                preloads: [() => import('#start/routes')],
3192            })
3193        ";
3194        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
3195        assert!(specs.is_empty());
3196    }
3197
3198    #[test]
3199    fn extract_imports_basic() {
3200        let source = r"
3201            import foo from 'foo-pkg';
3202            import { bar } from '@scope/bar';
3203            export default {};
3204        ";
3205        let imports = extract_imports(source, &js_path());
3206        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
3207    }
3208
3209    #[test]
3210    fn extract_default_export_object_property() {
3211        let source = r#"export default { testDir: "./tests" };"#;
3212        let val = extract_config_string(source, &js_path(), &["testDir"]);
3213        assert_eq!(val, Some("./tests".to_string()));
3214    }
3215
3216    #[test]
3217    fn extract_define_config_property() {
3218        let source = r#"
3219            import { defineConfig } from 'vitest/config';
3220            export default defineConfig({
3221                test: {
3222                    include: ["**/*.test.ts", "**/*.spec.ts"],
3223                    setupFiles: ["./test/setup.ts"]
3224                }
3225            });
3226        "#;
3227        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3228        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
3229
3230        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
3231        assert_eq!(setup, vec!["./test/setup.ts"]);
3232    }
3233
3234    #[test]
3235    fn extract_module_exports_property() {
3236        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
3237        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
3238        assert_eq!(val, Some("jsdom".to_string()));
3239    }
3240
3241    #[test]
3242    fn extract_nested_string_array() {
3243        let source = r#"
3244            export default {
3245                resolve: {
3246                    alias: {
3247                        "@": "./src"
3248                    }
3249                },
3250                test: {
3251                    include: ["src/**/*.test.ts"]
3252                }
3253            };
3254        "#;
3255        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
3256        assert_eq!(include, vec!["src/**/*.test.ts"]);
3257    }
3258
3259    #[test]
3260    fn extract_addons_array() {
3261        let source = r#"
3262            export default {
3263                addons: [
3264                    "@storybook/addon-a11y",
3265                    "@storybook/addon-docs",
3266                    "@storybook/addon-links"
3267                ]
3268            };
3269        "#;
3270        let addons = extract_config_property_strings(source, &ts_path(), "addons");
3271        assert_eq!(
3272            addons,
3273            vec![
3274                "@storybook/addon-a11y",
3275                "@storybook/addon-docs",
3276                "@storybook/addon-links"
3277            ]
3278        );
3279    }
3280
3281    #[test]
3282    fn handle_empty_config() {
3283        let source = "";
3284        let result = extract_config_string(source, &js_path(), &["key"]);
3285        assert_eq!(result, None);
3286    }
3287
3288    #[test]
3289    fn object_keys_postcss_plugins() {
3290        let source = r"
3291            module.exports = {
3292                plugins: {
3293                    autoprefixer: {},
3294                    tailwindcss: {},
3295                    'postcss-import': {}
3296                }
3297            };
3298        ";
3299        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3300        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
3301    }
3302
3303    #[test]
3304    fn object_keys_nested_path() {
3305        let source = r"
3306            export default {
3307                build: {
3308                    plugins: {
3309                        minify: {},
3310                        compress: {}
3311                    }
3312                }
3313            };
3314        ";
3315        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
3316        assert_eq!(keys, vec!["minify", "compress"]);
3317    }
3318
3319    #[test]
3320    fn object_keys_empty_object() {
3321        let source = r"export default { plugins: {} };";
3322        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3323        assert!(keys.is_empty());
3324    }
3325
3326    #[test]
3327    fn object_keys_non_object_returns_empty() {
3328        let source = r#"export default { plugins: ["a", "b"] };"#;
3329        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3330        assert!(keys.is_empty());
3331    }
3332
3333    #[test]
3334    fn string_or_array_single_string() {
3335        let source = r#"export default { entry: "./src/index.js" };"#;
3336        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3337        assert_eq!(result, vec!["./src/index.js"]);
3338    }
3339
3340    #[test]
3341    fn string_or_array_array() {
3342        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
3343        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3344        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
3345    }
3346
3347    #[test]
3348    fn string_or_array_object_values() {
3349        let source =
3350            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
3351        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3352        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
3353    }
3354
3355    #[test]
3356    fn array_or_object_string_or_array_reads_each_array_element() {
3357        let source = r#"
3358            const shared = { format: ["esm"] };
3359            export default defineConfig([
3360                { entry: "./src/a.ts" },
3361                ({ ...shared, entry: { b: "./src/b.ts" } }),
3362                { entry: ["./src/c.ts"] } satisfies Options,
3363                { format: ["cjs"] },
3364                shared,
3365            ] as Options[]);
3366        "#;
3367        let result = extract_config_array_or_object_string_or_array(source, &ts_path(), &["entry"]);
3368        assert_eq!(result, vec!["./src/a.ts", "./src/b.ts", "./src/c.ts"]);
3369    }
3370
3371    #[test]
3372    fn array_or_object_string_or_array_falls_back_to_config_object() {
3373        let source = r#"export default defineConfig({ entry: { main: "./src/main.ts" } });"#;
3374        let result = extract_config_array_or_object_string_or_array(source, &ts_path(), &["entry"]);
3375        assert_eq!(result, vec!["./src/main.ts"]);
3376    }
3377
3378    #[test]
3379    fn string_or_array_object_array_values() {
3380        let source = r#"export default { entry: { app: ["./src/polyfill.js", "./src/app.js"] } };"#;
3381        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3382        assert_eq!(result, vec!["./src/polyfill.js", "./src/app.js"]);
3383    }
3384
3385    #[test]
3386    fn string_or_array_webpack_entry_descriptors() {
3387        let source = r#"
3388            export default {
3389                entry: {
3390                    app: {
3391                        import: "./src/app.js",
3392                        filename: "pages/app.js",
3393                        dependOn: "shared",
3394                    },
3395                    admin: {
3396                        import: ["./src/admin-polyfill.js", "./src/admin.js"],
3397                        runtime: "runtime",
3398                    },
3399                    shared: ["react", "react-dom"],
3400                },
3401            };
3402        "#;
3403        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3404        assert_eq!(
3405            result,
3406            vec![
3407                "./src/app.js",
3408                "./src/admin-polyfill.js",
3409                "./src/admin.js",
3410                "react",
3411                "react-dom"
3412            ]
3413        );
3414    }
3415
3416    #[test]
3417    fn string_or_array_nested_path() {
3418        let source = r#"
3419            export default {
3420                build: {
3421                    rollupOptions: {
3422                        input: ["./index.html", "./about.html"]
3423                    }
3424                }
3425            };
3426        "#;
3427        let result = extract_config_string_or_array(
3428            source,
3429            &js_path(),
3430            &["build", "rollupOptions", "input"],
3431        );
3432        assert_eq!(result, vec!["./index.html", "./about.html"]);
3433    }
3434
3435    #[test]
3436    fn string_or_array_template_literal() {
3437        let source = r"export default { entry: `./src/index.js` };";
3438        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3439        assert_eq!(result, vec!["./src/index.js"]);
3440    }
3441
3442    #[test]
3443    fn string_or_array_object_path_helper_values() {
3444        let source = r#"
3445            import { resolve, join } from "node:path";
3446            import path from "node:path";
3447            export default {
3448                build: {
3449                    rollupOptions: {
3450                        input: {
3451                            app: resolve(__dirname, "src/app.ts"),
3452                            modal: path.resolve(__dirname, "src/modal.ts"),
3453                            tabs: join(__dirname, "src/tabs.ts"),
3454                            styles: resolve(__dirname, "src/index.css"),
3455                        },
3456                    },
3457                },
3458            };
3459        "#;
3460        let result = extract_config_string_or_array(
3461            source,
3462            &js_path(),
3463            &["build", "rollupOptions", "input"],
3464        );
3465        assert_eq!(
3466            result,
3467            vec!["src/app.ts", "src/modal.ts", "src/tabs.ts", "src/index.css"]
3468        );
3469    }
3470
3471    #[test]
3472    fn string_or_array_array_path_helper_values() {
3473        let source = r#"
3474            import { resolve } from "node:path";
3475            export default {
3476                build: {
3477                    rollupOptions: {
3478                        input: [resolve(__dirname, "src/a.ts"), "./src/b.ts"],
3479                    },
3480                },
3481            };
3482        "#;
3483        let result = extract_config_string_or_array(
3484            source,
3485            &js_path(),
3486            &["build", "rollupOptions", "input"],
3487        );
3488        assert_eq!(result, vec!["src/a.ts", "./src/b.ts"]);
3489    }
3490
3491    #[test]
3492    fn string_or_array_top_level_path_helper_call() {
3493        let source = r#"
3494            import { resolve } from "node:path";
3495            export default { build: { lib: { entry: resolve(__dirname, "src/index.ts") } } };
3496        "#;
3497        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
3498        assert_eq!(result, vec!["src/index.ts"]);
3499    }
3500
3501    #[test]
3502    fn string_or_array_import_meta_dirname_anchor() {
3503        let source = r#"
3504            import { resolve } from "node:path";
3505            export default {
3506                build: { lib: { entry: resolve(import.meta.dirname, "src/index.ts") } },
3507            };
3508        "#;
3509        let result = extract_config_string_or_array(source, &ts_path(), &["build", "lib", "entry"]);
3510        assert_eq!(result, vec!["src/index.ts"]);
3511    }
3512
3513    #[test]
3514    fn string_or_array_non_literal_path_helper_args_dropped() {
3515        let source = r#"
3516            import { resolve } from "node:path";
3517            export default { build: { lib: { entry: resolve(baseDir, "src/index.ts") } } };
3518        "#;
3519        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
3520        assert!(
3521            result.is_empty(),
3522            "non-literal path-helper args must be dropped: {result:?}"
3523        );
3524    }
3525
3526    #[test]
3527    fn require_strings_array() {
3528        let source = r"
3529            module.exports = {
3530                plugins: [
3531                    require('autoprefixer'),
3532                    require('postcss-import')
3533                ]
3534            };
3535        ";
3536        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3537        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
3538    }
3539
3540    #[test]
3541    fn require_strings_with_tuples() {
3542        let source = r"
3543            module.exports = {
3544                plugins: [
3545                    require('autoprefixer'),
3546                    [require('postcss-preset-env'), { stage: 3 }]
3547                ]
3548            };
3549        ";
3550        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3551        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
3552    }
3553
3554    #[test]
3555    fn require_strings_empty_array() {
3556        let source = r"module.exports = { plugins: [] };";
3557        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3558        assert!(deps.is_empty());
3559    }
3560
3561    #[test]
3562    fn require_strings_no_require_calls() {
3563        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
3564        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3565        assert!(deps.is_empty());
3566    }
3567
3568    #[test]
3569    fn extract_aliases_from_object_with_file_url_to_path() {
3570        let source = r#"
3571            import { defineConfig } from 'vite';
3572            import { fileURLToPath, URL } from 'node:url';
3573
3574            export default defineConfig({
3575                resolve: {
3576                    alias: {
3577                        "@": fileURLToPath(new URL("./src", import.meta.url))
3578                    }
3579                }
3580            });
3581        "#;
3582
3583        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
3584        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
3585    }
3586
3587    #[test]
3588    fn extract_aliases_from_array_form() {
3589        let source = r#"
3590            export default {
3591                resolve: {
3592                    alias: [
3593                        { find: "@", replacement: "./src" },
3594                        { find: "$utils", replacement: "src/lib/utils" }
3595                    ]
3596                }
3597            };
3598        "#;
3599
3600        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
3601        assert_eq!(
3602            aliases,
3603            vec![
3604                ("@".to_string(), "./src".to_string()),
3605                ("$utils".to_string(), "src/lib/utils".to_string())
3606            ]
3607        );
3608    }
3609
3610    #[test]
3611    fn extract_aliases_from_object_with_array_values() {
3612        let source = r#"
3613            ({
3614                compilerOptions: {
3615                    paths: {
3616                        "@/*": ["./src/*"],
3617                        "@shared/*": ["./shared/*", "./fallback/*"]
3618                    }
3619                }
3620            })
3621        "#;
3622
3623        let aliases = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
3624        assert_eq!(
3625            aliases,
3626            vec![
3627                ("@/*".to_string(), "./src/*".to_string()),
3628                ("@shared/*".to_string(), "./shared/*".to_string())
3629            ]
3630        );
3631    }
3632
3633    #[test]
3634    fn extract_array_object_strings_mixed_forms() {
3635        let source = r#"
3636            export default {
3637                components: [
3638                    "~/components",
3639                    { path: "@/feature-components" }
3640                ]
3641            };
3642        "#;
3643
3644        let values =
3645            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
3646        assert_eq!(
3647            values,
3648            vec![
3649                "~/components".to_string(),
3650                "@/feature-components".to_string()
3651            ]
3652        );
3653    }
3654
3655    #[test]
3656    fn extract_config_plugin_option_string_from_json() {
3657        let source = r#"{
3658            "expo": {
3659                "plugins": [
3660                    ["expo-router", { "root": "src/app" }]
3661                ]
3662            }
3663        }"#;
3664
3665        let value = extract_config_plugin_option_string(
3666            source,
3667            &json_path(),
3668            &["expo", "plugins"],
3669            "expo-router",
3670            "root",
3671        );
3672
3673        assert_eq!(value, Some("src/app".to_string()));
3674    }
3675
3676    #[test]
3677    fn extract_config_plugin_option_string_from_top_level_plugins() {
3678        let source = r#"{
3679            "plugins": [
3680                ["expo-router", { "root": "./src/routes" }]
3681            ]
3682        }"#;
3683
3684        let value = extract_config_plugin_option_string_from_paths(
3685            source,
3686            &json_path(),
3687            &[&["plugins"], &["expo", "plugins"]],
3688            "expo-router",
3689            "root",
3690        );
3691
3692        assert_eq!(value, Some("./src/routes".to_string()));
3693    }
3694
3695    #[test]
3696    fn extract_config_plugin_option_string_from_ts_config() {
3697        let source = r"
3698            export default {
3699                expo: {
3700                    plugins: [
3701                        ['expo-router', { root: './src/app' }]
3702                    ]
3703                }
3704            };
3705        ";
3706
3707        let value = extract_config_plugin_option_string(
3708            source,
3709            &ts_path(),
3710            &["expo", "plugins"],
3711            "expo-router",
3712            "root",
3713        );
3714
3715        assert_eq!(value, Some("./src/app".to_string()));
3716    }
3717
3718    #[test]
3719    fn extract_config_plugin_option_string_returns_none_when_plugin_missing() {
3720        let source = r#"{
3721            "expo": {
3722                "plugins": [
3723                    ["expo-font", {}]
3724                ]
3725            }
3726        }"#;
3727
3728        let value = extract_config_plugin_option_string(
3729            source,
3730            &json_path(),
3731            &["expo", "plugins"],
3732            "expo-router",
3733            "root",
3734        );
3735
3736        assert_eq!(value, None);
3737    }
3738
3739    #[test]
3740    fn vite_react_babel_dependencies_extract_plain_tuple_and_prefixed_entries() {
3741        let source = r#"
3742            import react from "@vitejs/plugin-react";
3743
3744            export default defineConfig({
3745                plugins: [
3746                    react({
3747                        babel: {
3748                            plugins: [
3749                                "babel-plugin-plain",
3750                                ["module:@preact/signals-react-transform", { mode: "auto" }],
3751                            ],
3752                            presets: [["@babel/preset-react", { runtime: "automatic" }]],
3753                        },
3754                    }),
3755                ],
3756            });
3757        "#;
3758
3759        let deps = extract_vite_plugin_option_dependencies(source, &ts_path());
3760
3761        assert_eq!(
3762            deps,
3763            vec![
3764                "babel-plugin-plain".to_string(),
3765                "@preact/signals-react-transform".to_string(),
3766                "@babel/preset-react".to_string(),
3767            ]
3768        );
3769    }
3770
3771    #[test]
3772    fn vite_react_babel_dependencies_support_default_alias_import() {
3773        let source = r#"
3774            import { default as viteReact } from "@vitejs/plugin-react";
3775
3776            export default {
3777                plugins: [
3778                    viteReact({
3779                        babel: {
3780                            plugins: [["module:@scope/pkg/plugin", {}]],
3781                        },
3782                    }),
3783                ],
3784            };
3785        "#;
3786
3787        let deps = extract_vite_plugin_option_dependencies(source, &ts_path());
3788
3789        assert_eq!(deps, vec!["@scope/pkg".to_string()]);
3790    }
3791
3792    #[test]
3793    fn vite_react_babel_dependencies_ignore_unrelated_plugin_calls() {
3794        let source = r#"
3795            import vue from "@vitejs/plugin-vue";
3796
3797            export default {
3798                plugins: [
3799                    vue({
3800                        babel: {
3801                            plugins: ["@preact/signals-react-transform"],
3802                        },
3803                    }),
3804                ],
3805            };
3806        "#;
3807
3808        let deps = extract_vite_plugin_option_dependencies(source, &ts_path());
3809
3810        assert!(deps.is_empty());
3811    }
3812
3813    #[test]
3814    fn vite_react_babel_dependencies_skip_relative_and_protocol_entries() {
3815        let source = r#"
3816            import react from "@vitejs/plugin-react";
3817
3818            export default {
3819                plugins: [
3820                    react({
3821                        babel: {
3822                            plugins: ["./local-plugin", "module:./local-prefixed", "http://example.com/plugin"],
3823                        },
3824                    }),
3825                ],
3826            };
3827        "#;
3828
3829        let deps = extract_vite_plugin_option_dependencies(source, &ts_path());
3830
3831        assert!(deps.is_empty());
3832    }
3833
3834    #[test]
3835    fn normalize_config_path_relative_to_root() {
3836        let config_path = PathBuf::from("/project/vite.config.ts");
3837        let root = PathBuf::from("/project");
3838
3839        assert_eq!(
3840            normalize_config_path("./src/lib", &config_path, &root),
3841            Some("src/lib".to_string())
3842        );
3843        assert_eq!(
3844            normalize_config_path("/src/lib", &config_path, &root),
3845            Some("src/lib".to_string())
3846        );
3847    }
3848
3849    #[test]
3850    fn normalize_config_path_mixed_separators_and_parent_dirs() {
3851        let config_path = PathBuf::from("/project/config/vite.config.ts");
3852        let root = PathBuf::from("/project");
3853
3854        assert_eq!(
3855            normalize_config_path(".\\src\\..\\app\\lib", &config_path, &root),
3856            Some("config/app/lib".to_string())
3857        );
3858    }
3859
3860    /// Issue #2806: a literal absolute path under the project root is read as
3861    /// absolute, not joined onto the root a second time.
3862    #[test]
3863    fn normalize_config_path_reads_an_absolute_path_under_root_as_absolute() {
3864        let root = std::env::temp_dir().join("fallow-2806-project");
3865        let config_path = root.join("vite.config.ts");
3866        let absolute = path_to_config_string(&root.join("src").join("lib"));
3867
3868        assert_eq!(
3869            normalize_config_path(&absolute, &config_path, &root),
3870            Some("src/lib".to_string())
3871        );
3872        assert_eq!(
3873            normalize_filesystem_config_path(&absolute, &config_path, &root),
3874            Some("src/lib".to_string())
3875        );
3876    }
3877
3878    /// Vite reads a leading `/` as relative to the root when the value is the
3879    /// root itself, or when `<root>/<root>` is a directory (its `rootInRoot`
3880    /// rule). A project checked out at `/src` with a `src/` folder and the
3881    /// alias `'@': '/src'` resolves to `/src/src`.
3882    #[cfg(unix)]
3883    #[test]
3884    fn normalize_config_path_keeps_the_vite_root_in_root_reading() {
3885        let temp = tempfile::tempdir().expect("temp dir");
3886        let root = temp.path().join("src");
3887        std::fs::create_dir_all(&root).expect("root dir");
3888        let config_path = root.join("vite.config.ts");
3889        let root_string = path_to_config_string(&root);
3890        let nested = root_string.trim_start_matches('/').to_string();
3891        let under = format!("{root_string}/lib");
3892
3893        assert_eq!(
3894            normalize_config_path(&root_string, &config_path, &root),
3895            Some(nested.clone())
3896        );
3897        assert_eq!(
3898            normalize_config_path(&under, &config_path, &root),
3899            Some("lib".to_string())
3900        );
3901
3902        std::fs::create_dir_all(root.join(&nested)).expect("root-in-root dir");
3903        assert_eq!(
3904            normalize_config_path(&under, &config_path, &root),
3905            Some(format!("{nested}/lib"))
3906        );
3907        assert_eq!(
3908            normalize_filesystem_config_path(&under, &config_path, &root),
3909            Some("lib".to_string())
3910        );
3911    }
3912
3913    /// Issue #2806: a reader with filesystem semantics reads a leading `/` as
3914    /// an absolute path, so a path outside the project root resolves to nothing.
3915    #[test]
3916    fn normalize_filesystem_config_path_does_not_read_a_leading_slash_as_root_relative() {
3917        let config_path = PathBuf::from("/project/config/webpack.config.js");
3918        let root = PathBuf::from("/project");
3919
3920        assert_eq!(
3921            normalize_filesystem_config_path("/src/lib", &config_path, &root),
3922            None
3923        );
3924        assert_eq!(
3925            normalize_filesystem_config_path("./src/lib", &config_path, &root),
3926            Some("config/src/lib".to_string())
3927        );
3928        assert_eq!(
3929            normalize_config_path("/src/lib", &config_path, &root),
3930            Some("src/lib".to_string())
3931        );
3932    }
3933
3934    #[test]
3935    fn normalize_config_path_leading_slash_stays_project_relative() {
3936        let config_path = PathBuf::from("/project/vite.config.ts");
3937        let root = PathBuf::from("/project");
3938
3939        assert_eq!(
3940            normalize_config_path("/src\\lib", &config_path, &root),
3941            Some("src/lib".to_string())
3942        );
3943    }
3944
3945    #[test]
3946    fn json_wrapped_in_parens_string() {
3947        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
3948        let val = extract_config_string(source, &js_path(), &["extends"]);
3949        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
3950    }
3951
3952    #[test]
3953    fn json_wrapped_in_parens_nested_array() {
3954        let source =
3955            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
3956        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
3957        assert_eq!(types, vec!["node", "jest"]);
3958
3959        let include = extract_config_string_array(source, &js_path(), &["include"]);
3960        assert_eq!(include, vec!["src/**/*"]);
3961    }
3962
3963    #[test]
3964    fn json_wrapped_in_parens_object_keys() {
3965        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
3966        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3967        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
3968    }
3969
3970    fn json_path() -> PathBuf {
3971        PathBuf::from("config.json")
3972    }
3973
3974    #[test]
3975    fn json_file_parsed_correctly() {
3976        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
3977        let val = extract_config_string(source, &json_path(), &["key"]);
3978        assert_eq!(val, Some("value".to_string()));
3979
3980        let list = extract_config_string_array(source, &json_path(), &["list"]);
3981        assert_eq!(list, vec!["a", "b"]);
3982    }
3983
3984    #[test]
3985    fn jsonc_file_parsed_correctly() {
3986        let source = r#"{"key": "value"}"#;
3987        let path = PathBuf::from("tsconfig.jsonc");
3988        let val = extract_config_string(source, &path, &["key"]);
3989        assert_eq!(val, Some("value".to_string()));
3990    }
3991
3992    #[test]
3993    fn extract_define_config_arrow_function() {
3994        let source = r#"
3995            import { defineConfig } from 'vite';
3996            export default defineConfig(() => ({
3997                test: {
3998                    include: ["**/*.test.ts"]
3999                }
4000            }));
4001        "#;
4002        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
4003        assert_eq!(include, vec!["**/*.test.ts"]);
4004    }
4005
4006    /// A block-bodied callback is the common shape for configs that branch on
4007    /// the build mode. Only the concise arrow used to be traversed, so every
4008    /// such config extracted nothing at all (issue #2005).
4009    #[test]
4010    fn extract_define_config_block_body_arrow_function() {
4011        let source = r#"
4012            import { defineConfig } from 'vite';
4013            export default defineConfig(({ mode }) => {
4014                const isProduction = mode === 'production';
4015                return {
4016                    test: {
4017                        environment: "jsdom",
4018                        setupFiles: "./tests/setup.ts"
4019                    },
4020                    base: isProduction ? '/app/' : '/'
4021                };
4022            });
4023        "#;
4024        assert_eq!(
4025            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4026            Some("jsdom")
4027        );
4028        assert_eq!(
4029            extract_config_string(source, &ts_path(), &["test", "setupFiles"]).as_deref(),
4030            Some("./tests/setup.ts")
4031        );
4032    }
4033
4034    #[test]
4035    fn extract_define_config_function_expression() {
4036        let source = r#"
4037            import { defineConfig } from 'vite';
4038            export default defineConfig(function () {
4039                return { test: { environment: "happy-dom" } };
4040            });
4041        "#;
4042        assert_eq!(
4043            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4044            Some("happy-dom")
4045        );
4046    }
4047
4048    /// A wrapper takes the config first and its own options after it. Scanning
4049    /// the argument list for the first object literal read the options object
4050    /// instead, which is the exact shape the @sentry/nextjs wizard emits.
4051    #[test]
4052    fn wrapper_options_object_does_not_shadow_named_config_arg() {
4053        let source = r#"
4054            const nextConfig = { pageExtensions: ["page.tsx"] };
4055            module.exports = withSentryConfig(nextConfig, { org: "o", project: "p", silent: true });
4056        "#;
4057        assert_eq!(
4058            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
4059            vec!["page.tsx"]
4060        );
4061    }
4062
4063    #[test]
4064    fn wrapper_options_object_does_not_shadow_named_config_arg_esm() {
4065        let source = r#"
4066            const nextConfig = { pageExtensions: ["page.tsx"] };
4067            export default withSentryConfig(nextConfig, { org: "o", project: "p" });
4068        "#;
4069        assert_eq!(
4070            extract_config_string_array(source, &ts_path(), &["pageExtensions"]),
4071            vec!["page.tsx"]
4072        );
4073    }
4074
4075    /// Vitest's documented way to share a Vite config with the test runner.
4076    #[test]
4077    fn merge_config_extracts_nested_define_config_object() {
4078        let source = r#"
4079            import { defineConfig, mergeConfig } from 'vitest/config';
4080            import viteConfig from './vite.config';
4081            export default mergeConfig(viteConfig, defineConfig({
4082                test: { environment: "jsdom" }
4083            }));
4084        "#;
4085        assert_eq!(
4086            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4087            Some("jsdom")
4088        );
4089    }
4090
4091    #[test]
4092    fn define_config_wrapping_merge_config_extracts_object() {
4093        let source = r#"
4094            import { defineConfig, mergeConfig } from 'vitest/config';
4095            import base from './base';
4096            export default defineConfig(mergeConfig(base, { test: { environment: "jsdom" } }));
4097        "#;
4098        assert_eq!(
4099            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4100            Some("jsdom")
4101        );
4102    }
4103
4104    /// Guards against "prefer an identifier-resolved const over any object
4105    /// literal", which would change which object every plain config reads.
4106    #[test]
4107    fn inline_object_at_argument_zero_still_wins() {
4108        let source = r#"
4109            const unrelated = { pageExtensions: ["wrong.tsx"] };
4110            module.exports = withSentryConfig({ pageExtensions: ["right.tsx"] }, { org: "o" });
4111        "#;
4112        assert_eq!(
4113            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
4114            vec!["right.tsx"]
4115        );
4116    }
4117
4118    /// Vite's documented shape for switching config by command: every branch
4119    /// returns and the callback has no return of its own, so extraction has to
4120    /// descend to find anything at all.
4121    #[test]
4122    fn conditional_config_callback_extracts_branch_return() {
4123        let source = r#"
4124            import { defineConfig } from 'vite';
4125            export default defineConfig(({ command }) => {
4126                if (command === "serve") {
4127                    return { test: { environment: "jsdom" } };
4128                } else {
4129                    return { test: { environment: "node" } };
4130                }
4131            });
4132        "#;
4133        assert_eq!(
4134            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4135            Some("jsdom"),
4136            "with no top-level return, the first branch in source order wins"
4137        );
4138    }
4139
4140    #[test]
4141    fn try_block_return_is_extracted() {
4142        let source = r#"
4143            export default (() => {
4144                try {
4145                    return { test: { environment: "jsdom" } };
4146                } catch (e) {
4147                    return { test: { environment: "node" } };
4148                }
4149            });
4150        "#;
4151        assert_eq!(
4152            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4153            Some("jsdom")
4154        );
4155    }
4156
4157    #[test]
4158    fn switch_case_return_is_extracted() {
4159        let source = r#"
4160            import { defineConfig } from 'vite';
4161            export default defineConfig(({ mode }) => {
4162                switch (mode) {
4163                    case "test":
4164                        return { test: { environment: "jsdom" } };
4165                    default:
4166                        return { test: { environment: "node" } };
4167                }
4168            });
4169        "#;
4170        assert_eq!(
4171            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4172            Some("jsdom")
4173        );
4174    }
4175
4176    /// A guard clause followed by the real return must resolve to the trailing
4177    /// return. Descending into branches before checking this level regressed it:
4178    /// the guard's early return shadowed the actual config.
4179    #[test]
4180    fn guard_clause_does_not_shadow_the_top_level_return() {
4181        let source = r#"
4182            import { defineConfig } from 'vite';
4183            export default defineConfig(({ mode }) => {
4184                if (!mode) {
4185                    return {};
4186                }
4187                return { test: { environment: "jsdom" } };
4188            });
4189        "#;
4190        assert_eq!(
4191            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4192            Some("jsdom"),
4193            "a return at the callback's own level is the main config"
4194        );
4195    }
4196
4197    /// The same precedence with an else-if chain, where every branch returns and
4198    /// the trailing return is the default config.
4199    #[test]
4200    fn else_if_chain_does_not_shadow_the_top_level_return() {
4201        let source = r#"
4202            import { defineConfig } from 'vite';
4203            export default defineConfig(({ mode }) => {
4204                if (mode === "a") {
4205                    return { base: "/a/" };
4206                } else if (mode === "b") {
4207                    return { base: "/b/" };
4208                }
4209                return { test: { environment: "jsdom" } };
4210            });
4211        "#;
4212        assert_eq!(
4213            extract_config_string(source, &ts_path(), &["test", "environment"]).as_deref(),
4214            Some("jsdom")
4215        );
4216    }
4217
4218    #[test]
4219    fn extract_config_from_default_export_function_declaration() {
4220        let source = r#"
4221            export default function createConfig() {
4222                return {
4223                    clientModules: ["./src/client/global.js"]
4224                };
4225            }
4226        "#;
4227
4228        let client_modules = extract_config_string_array(source, &ts_path(), &["clientModules"]);
4229        assert_eq!(client_modules, vec!["./src/client/global.js"]);
4230    }
4231
4232    #[test]
4233    fn extract_config_from_default_export_async_function_declaration() {
4234        let source = r#"
4235            export default async function createConfigAsync() {
4236                return {
4237                    docs: {
4238                        path: "knowledge"
4239                    }
4240                };
4241            }
4242        "#;
4243
4244        let docs_path = extract_config_string(source, &ts_path(), &["docs", "path"]);
4245        assert_eq!(docs_path, Some("knowledge".to_string()));
4246    }
4247
4248    #[test]
4249    fn extract_config_from_exported_arrow_function_identifier() {
4250        let source = r#"
4251            const config = async () => {
4252                return {
4253                    themes: ["classic"]
4254                };
4255            };
4256
4257            export default config;
4258        "#;
4259
4260        let themes = extract_config_shallow_strings(source, &ts_path(), "themes");
4261        assert_eq!(themes, vec!["classic"]);
4262    }
4263
4264    #[test]
4265    fn module_exports_nested_string() {
4266        let source = r#"
4267            module.exports = {
4268                resolve: {
4269                    alias: {
4270                        "@": "./src"
4271                    }
4272                }
4273            };
4274        "#;
4275        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
4276        assert_eq!(val, Some("./src".to_string()));
4277    }
4278
4279    #[test]
4280    fn property_strings_nested_objects() {
4281        let source = r#"
4282            export default {
4283                plugins: {
4284                    group1: { a: "val-a" },
4285                    group2: { b: "val-b" }
4286                }
4287            };
4288        "#;
4289        let values = extract_config_property_strings(source, &js_path(), "plugins");
4290        assert!(values.contains(&"val-a".to_string()));
4291        assert!(values.contains(&"val-b".to_string()));
4292    }
4293
4294    #[test]
4295    fn property_strings_missing_key_returns_empty() {
4296        let source = r#"export default { other: "value" };"#;
4297        let values = extract_config_property_strings(source, &js_path(), "missing");
4298        assert!(values.is_empty());
4299    }
4300
4301    #[test]
4302    fn shallow_strings_tuple_array() {
4303        let source = r#"
4304            module.exports = {
4305                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
4306            };
4307        "#;
4308        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
4309        assert_eq!(values, vec!["default", "jest-junit"]);
4310        assert!(!values.contains(&"reports".to_string()));
4311    }
4312
4313    #[test]
4314    fn shallow_strings_single_string() {
4315        let source = r#"export default { preset: "ts-jest" };"#;
4316        let values = extract_config_shallow_strings(source, &js_path(), "preset");
4317        assert_eq!(values, vec!["ts-jest"]);
4318    }
4319
4320    #[test]
4321    fn shallow_strings_missing_key() {
4322        let source = r#"export default { other: "val" };"#;
4323        let values = extract_config_shallow_strings(source, &js_path(), "missing");
4324        assert!(values.is_empty());
4325    }
4326
4327    #[test]
4328    fn shallow_strings_or_object_property_alias_objects() {
4329        let source = r#"
4330            export default {
4331                jsPlugins: [
4332                    "eslint-plugin-playwright",
4333                    ["eslint-plugin-regexp", { rules: {} }],
4334                    { name: "short", specifier: "eslint-plugin-with-long-name" }
4335                ]
4336            };
4337        "#;
4338        let values = extract_config_shallow_strings_or_object_property(
4339            source,
4340            &ts_path(),
4341            "jsPlugins",
4342            "specifier",
4343        );
4344        assert_eq!(
4345            values,
4346            vec![
4347                "eslint-plugin-playwright",
4348                "eslint-plugin-regexp",
4349                "eslint-plugin-with-long-name"
4350            ]
4351        );
4352    }
4353
4354    #[test]
4355    fn nested_shallow_strings_vitest_reporters() {
4356        let source = r#"
4357            export default {
4358                test: {
4359                    reporters: ["default", "vitest-sonar-reporter"]
4360                }
4361            };
4362        "#;
4363        let values =
4364            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
4365        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
4366    }
4367
4368    #[test]
4369    fn nested_shallow_strings_tuple_format() {
4370        let source = r#"
4371            export default {
4372                test: {
4373                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
4374                }
4375            };
4376        "#;
4377        let values =
4378            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
4379        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
4380    }
4381
4382    #[test]
4383    fn nested_shallow_strings_missing_outer() {
4384        let source = r"export default { other: {} };";
4385        let values =
4386            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
4387        assert!(values.is_empty());
4388    }
4389
4390    #[test]
4391    fn nested_shallow_strings_missing_inner() {
4392        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
4393        let values =
4394            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
4395        assert!(values.is_empty());
4396    }
4397
4398    #[test]
4399    fn string_or_array_missing_path() {
4400        let source = r"export default {};";
4401        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
4402        assert!(result.is_empty());
4403    }
4404
4405    #[test]
4406    fn string_or_array_non_string_values() {
4407        let source = r"export default { entry: [42, true] };";
4408        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
4409        assert!(result.is_empty());
4410    }
4411
4412    #[test]
4413    fn array_nested_extraction() {
4414        let source = r#"
4415            export default defineConfig({
4416                test: {
4417                    projects: [
4418                        {
4419                            test: {
4420                                setupFiles: ["./test/setup-a.ts"]
4421                            }
4422                        },
4423                        {
4424                            test: {
4425                                setupFiles: "./test/setup-b.ts"
4426                            }
4427                        }
4428                    ]
4429                }
4430            });
4431        "#;
4432        let results = extract_config_array_nested_string_or_array(
4433            source,
4434            &ts_path(),
4435            &["test", "projects"],
4436            &["test", "setupFiles"],
4437        );
4438        assert!(results.contains(&"./test/setup-a.ts".to_string()));
4439        assert!(results.contains(&"./test/setup-b.ts".to_string()));
4440    }
4441
4442    #[test]
4443    fn array_nested_empty_when_no_array() {
4444        let source = r#"export default { test: { projects: "not-an-array" } };"#;
4445        let results = extract_config_array_nested_string_or_array(
4446            source,
4447            &js_path(),
4448            &["test", "projects"],
4449            &["test", "setupFiles"],
4450        );
4451        assert!(results.is_empty());
4452    }
4453
4454    #[test]
4455    fn object_nested_extraction() {
4456        let source = r#"{
4457            "projects": {
4458                "app-one": {
4459                    "architect": {
4460                        "build": {
4461                            "options": {
4462                                "styles": ["src/styles.css"]
4463                            }
4464                        }
4465                    }
4466                }
4467            }
4468        }"#;
4469        let results = extract_config_object_nested_string_or_array(
4470            source,
4471            &json_path(),
4472            &["projects"],
4473            &["architect", "build", "options", "styles"],
4474        );
4475        assert_eq!(results, vec!["src/styles.css"]);
4476    }
4477
4478    #[test]
4479    fn array_with_object_input_form_extracted() {
4480        let source = r#"{
4481            "projects": {
4482                "app": {
4483                    "architect": {
4484                        "build": {
4485                            "options": {
4486                                "styles": [
4487                                    "src/styles.scss",
4488                                    { "input": "src/theme.scss", "bundleName": "theme", "inject": false },
4489                                    { "bundleName": "lazy-only" }
4490                                ]
4491                            }
4492                        }
4493                    }
4494                }
4495            }
4496        }"#;
4497        let results = extract_config_object_nested_string_or_array(
4498            source,
4499            &json_path(),
4500            &["projects"],
4501            &["architect", "build", "options", "styles"],
4502        );
4503        assert!(
4504            results.contains(&"src/styles.scss".to_string()),
4505            "string form must still work: {results:?}"
4506        );
4507        assert!(
4508            results.contains(&"src/theme.scss".to_string()),
4509            "object form with `input` must be extracted: {results:?}"
4510        );
4511        assert!(
4512            !results.contains(&"lazy-only".to_string()),
4513            "bundleName must not be misinterpreted as a path: {results:?}"
4514        );
4515        assert!(
4516            !results.contains(&"theme".to_string()),
4517            "bundleName from full object must not leak: {results:?}"
4518        );
4519    }
4520
4521    #[test]
4522    fn object_nested_strings_extraction() {
4523        let source = r#"{
4524            "targets": {
4525                "build": {
4526                    "executor": "@angular/build:application"
4527                },
4528                "test": {
4529                    "executor": "@nx/vite:test"
4530                }
4531            }
4532        }"#;
4533        let results =
4534            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
4535        assert!(results.contains(&"@angular/build:application".to_string()));
4536        assert!(results.contains(&"@nx/vite:test".to_string()));
4537    }
4538
4539    #[test]
4540    fn require_strings_direct_call() {
4541        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
4542        let deps = extract_config_require_strings(source, &js_path(), "adapter");
4543        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
4544    }
4545
4546    #[test]
4547    fn require_strings_no_matching_key() {
4548        let source = r"module.exports = { other: require('something') };";
4549        let deps = extract_config_require_strings(source, &js_path(), "plugins");
4550        assert!(deps.is_empty());
4551    }
4552
4553    #[test]
4554    fn extract_imports_no_imports() {
4555        let source = r"export default {};";
4556        let imports = extract_imports(source, &js_path());
4557        assert!(imports.is_empty());
4558    }
4559
4560    #[test]
4561    fn extract_imports_side_effect_import() {
4562        let source = r"
4563            import 'polyfill';
4564            import './local-setup';
4565            export default {};
4566        ";
4567        let imports = extract_imports(source, &js_path());
4568        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
4569    }
4570
4571    #[test]
4572    fn extract_imports_mixed_specifiers() {
4573        let source = r"
4574            import defaultExport from 'module-a';
4575            import { named } from 'module-b';
4576            import * as ns from 'module-c';
4577            export default {};
4578        ";
4579        let imports = extract_imports(source, &js_path());
4580        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
4581    }
4582
4583    #[test]
4584    fn template_literal_in_string_or_array() {
4585        let source = r"export default { entry: `./src/index.ts` };";
4586        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
4587        assert_eq!(result, vec!["./src/index.ts"]);
4588    }
4589
4590    #[test]
4591    fn template_literal_in_config_string() {
4592        let source = r"export default { testDir: `./tests` };";
4593        let val = extract_config_string(source, &js_path(), &["testDir"]);
4594        assert_eq!(val, Some("./tests".to_string()));
4595    }
4596
4597    #[test]
4598    fn template_literal_command_recovers_static_command_tokens() {
4599        let source = r"
4600            const PORT = 3000;
4601            export default {
4602                webServer: {
4603                    command: `pnpm exec srvx --port ${PORT} --hostname 127.0.0.1`
4604                }
4605            };
4606        ";
4607        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
4608        assert_eq!(
4609            val,
4610            Some("pnpm exec srvx --port   --hostname 127.0.0.1".to_string())
4611        );
4612    }
4613
4614    #[test]
4615    fn template_literal_command_skips_dynamic_prefix() {
4616        let source = r"
4617            export default {
4618                webServer: { command: `${serverCommand} && pnpm exec srvx` }
4619            };
4620        ";
4621        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
4622        assert!(val.is_none());
4623    }
4624
4625    #[test]
4626    fn template_literal_command_skips_split_static_token() {
4627        let source = r"
4628            export default {
4629                webServer: { command: `pnpm exec sr${part}vx --port 3000` }
4630            };
4631        ";
4632        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
4633        assert!(val.is_none());
4634    }
4635
4636    #[test]
4637    fn array_object_command_pairs_recover_template_command() {
4638        let source = r"
4639            const PORT = 3000;
4640            export default {
4641                webServer: [
4642                    {
4643                        command: `pnpm exec srvx --port ${PORT}`,
4644                        cwd: 'apps/web'
4645                    }
4646                ]
4647            };
4648        ";
4649        let pairs = extract_config_array_object_command_pairs(
4650            source,
4651            &ts_path(),
4652            &["webServer"],
4653            "command",
4654            "cwd",
4655        );
4656        assert_eq!(
4657            pairs,
4658            vec![(
4659                "pnpm exec srvx --port  ".to_string(),
4660                Some("apps/web".to_string())
4661            )]
4662        );
4663    }
4664
4665    #[test]
4666    fn nested_string_array_empty_path() {
4667        let source = r#"export default { items: ["a", "b"] };"#;
4668        let result = extract_config_string_array(source, &js_path(), &[]);
4669        assert!(result.is_empty());
4670    }
4671
4672    #[test]
4673    fn nested_string_empty_path() {
4674        let source = r#"export default { key: "val" };"#;
4675        let result = extract_config_string(source, &js_path(), &[]);
4676        assert!(result.is_none());
4677    }
4678
4679    #[test]
4680    fn object_keys_empty_path() {
4681        let source = r"export default { plugins: {} };";
4682        let result = extract_config_object_keys(source, &js_path(), &[]);
4683        assert!(result.is_empty());
4684    }
4685
4686    #[test]
4687    fn no_config_object_returns_empty() {
4688        let source = r"const x = 42;";
4689        let result = extract_config_string(source, &js_path(), &["key"]);
4690        assert!(result.is_none());
4691
4692        let arr = extract_config_string_array(source, &js_path(), &["items"]);
4693        assert!(arr.is_empty());
4694
4695        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
4696        assert!(keys.is_empty());
4697    }
4698
4699    #[test]
4700    fn property_with_string_key() {
4701        let source = r#"export default { "string-key": "value" };"#;
4702        let val = extract_config_string(source, &js_path(), &["string-key"]);
4703        assert_eq!(val, Some("value".to_string()));
4704    }
4705
4706    #[test]
4707    fn nested_navigation_through_non_object() {
4708        let source = r#"export default { level1: "not-an-object" };"#;
4709        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
4710        assert!(val.is_none());
4711    }
4712
4713    #[test]
4714    fn variable_reference_untyped() {
4715        let source = r#"
4716            const config = {
4717                testDir: "./tests"
4718            };
4719            export default config;
4720        "#;
4721        let val = extract_config_string(source, &js_path(), &["testDir"]);
4722        assert_eq!(val, Some("./tests".to_string()));
4723    }
4724
4725    #[test]
4726    fn variable_reference_to_an_exported_const() {
4727        for source in [
4728            r#"
4729            export const config = { testDir: "./tests" };
4730            export default config;
4731            "#,
4732            r#"
4733            export const config = { testDir: "./tests" };
4734            module.exports = config;
4735            "#,
4736        ] {
4737            let val = extract_config_string(source, &js_path(), &["testDir"]);
4738            assert_eq!(val, Some("./tests".to_string()), "source: {source}");
4739        }
4740    }
4741
4742    #[test]
4743    fn variable_reference_with_type_annotation() {
4744        let source = r#"
4745            import type { StorybookConfig } from '@storybook/react-vite';
4746            const config: StorybookConfig = {
4747                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
4748                framework: "@storybook/react-vite"
4749            };
4750            export default config;
4751        "#;
4752        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
4753        assert_eq!(
4754            addons,
4755            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
4756        );
4757
4758        let framework = extract_config_string(source, &ts_path(), &["framework"]);
4759        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
4760    }
4761
4762    #[test]
4763    fn variable_reference_with_define_config() {
4764        let source = r#"
4765            import { defineConfig } from 'vitest/config';
4766            const config = defineConfig({
4767                test: {
4768                    include: ["**/*.test.ts"]
4769                }
4770            });
4771            export default config;
4772        "#;
4773        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
4774        assert_eq!(include, vec!["**/*.test.ts"]);
4775    }
4776
4777    #[test]
4778    fn ts_satisfies_direct_export() {
4779        let source = r#"
4780            export default {
4781                testDir: "./tests"
4782            } satisfies PlaywrightTestConfig;
4783        "#;
4784        let val = extract_config_string(source, &ts_path(), &["testDir"]);
4785        assert_eq!(val, Some("./tests".to_string()));
4786    }
4787
4788    #[test]
4789    fn ts_as_direct_export() {
4790        let source = r#"
4791            export default {
4792                testDir: "./tests"
4793            } as const;
4794        "#;
4795        let val = extract_config_string(source, &ts_path(), &["testDir"]);
4796        assert_eq!(val, Some("./tests".to_string()));
4797    }
4798
4799    // --- issue #811: resolve.alias as imported identifier / spread ---
4800
4801    fn aliases(source: &str) -> Vec<(String, String)> {
4802        extract_config_aliases(source, &js_path(), &["resolve", "alias"])
4803    }
4804
4805    #[test]
4806    fn aliases_inline_object_still_extracted() {
4807        // Regression: the resolver must not change inline-object behavior.
4808        let source = r#"
4809            export default defineConfig({
4810                resolve: { alias: { "@": "./src", utils: "../../utils" } }
4811            });
4812        "#;
4813        let mut got = aliases(source);
4814        got.sort();
4815        assert_eq!(
4816            got,
4817            vec![
4818                ("@".to_string(), "./src".to_string()),
4819                ("utils".to_string(), "../../utils".to_string()),
4820            ]
4821        );
4822    }
4823
4824    #[test]
4825    fn aliases_inline_array_still_extracted() {
4826        let source = r#"
4827            export default defineConfig({
4828                resolve: { alias: [{ find: "@", replacement: "./src" }] }
4829            });
4830        "#;
4831        assert_eq!(
4832            aliases(source),
4833            vec![("@".to_string(), "./src".to_string())]
4834        );
4835    }
4836
4837    #[test]
4838    fn aliases_local_const_array_identifier() {
4839        let source = r#"
4840            const sharedAliases = [{ find: "@", replacement: "./src" }];
4841            export default defineConfig({ resolve: { alias: sharedAliases } });
4842        "#;
4843        assert_eq!(
4844            aliases(source),
4845            vec![("@".to_string(), "./src".to_string())]
4846        );
4847    }
4848
4849    #[test]
4850    fn aliases_local_const_object_identifier() {
4851        let source = r#"
4852            const sharedAliases = { "@": "./src" };
4853            export default defineConfig({ resolve: { alias: sharedAliases } });
4854        "#;
4855        assert_eq!(
4856            aliases(source),
4857            vec![("@".to_string(), "./src".to_string())]
4858        );
4859    }
4860
4861    #[test]
4862    fn aliases_array_spread_of_identifiers_and_inline() {
4863        let source = r##"
4864            const a = [{ find: "@", replacement: "./src" }];
4865            const b = [{ find: "~", replacement: "./lib" }];
4866            export default defineConfig({
4867                resolve: { alias: [...a, ...b, { find: "#", replacement: "./test" }] }
4868            });
4869        "##;
4870        let mut got = aliases(source);
4871        got.sort();
4872        assert_eq!(
4873            got,
4874            vec![
4875                ("#".to_string(), "./test".to_string()),
4876                ("@".to_string(), "./src".to_string()),
4877                ("~".to_string(), "./lib".to_string()),
4878            ]
4879        );
4880    }
4881
4882    #[test]
4883    fn aliases_object_spread_of_identifier_and_inline() {
4884        let source = r#"
4885            const base = { "@": "./src" };
4886            export default defineConfig({
4887                resolve: { alias: { ...base, "~": "./lib" } }
4888            });
4889        "#;
4890        let mut got = aliases(source);
4891        got.sort();
4892        assert_eq!(
4893            got,
4894            vec![
4895                ("@".to_string(), "./src".to_string()),
4896                ("~".to_string(), "./lib".to_string()),
4897            ]
4898        );
4899    }
4900
4901    #[test]
4902    fn aliases_local_const_chained_identifier() {
4903        // `const a = b` indirection resolves through the chain.
4904        let source = r#"
4905            const real = [{ find: "@", replacement: "./src" }];
4906            const alias2 = real;
4907            export default defineConfig({ resolve: { alias: alias2 } });
4908        "#;
4909        assert_eq!(
4910            aliases(source),
4911            vec![("@".to_string(), "./src".to_string())]
4912        );
4913    }
4914
4915    #[test]
4916    fn aliases_imported_named_identifier_from_sibling() {
4917        let dir = tempfile::tempdir().unwrap();
4918        std::fs::write(
4919            dir.path().join("vite.shared.js"),
4920            r#"export const sharedAliases = [
4921                { find: "@", replacement: new URL("./src", import.meta.url).pathname },
4922            ];"#,
4923        )
4924        .unwrap();
4925        let config = dir.path().join("vite.config.js");
4926        let source = r#"
4927            import { defineConfig } from "vite";
4928            import { sharedAliases } from "./vite.shared.js";
4929            export default defineConfig({ resolve: { alias: sharedAliases } });
4930        "#;
4931        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4932        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4933    }
4934
4935    #[test]
4936    fn aliases_imported_extensionless_specifier_probed() {
4937        let dir = tempfile::tempdir().unwrap();
4938        std::fs::write(
4939            dir.path().join("aliases.mjs"),
4940            r#"export const sharedAliases = { "@": "./src" };"#,
4941        )
4942        .unwrap();
4943        let config = dir.path().join("vite.config.ts");
4944        let source = r#"
4945            import { sharedAliases } from "./aliases";
4946            export default defineConfig({ resolve: { alias: sharedAliases } });
4947        "#;
4948        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4949        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4950    }
4951
4952    #[test]
4953    fn aliases_imported_default_export_from_sibling() {
4954        let dir = tempfile::tempdir().unwrap();
4955        std::fs::write(
4956            dir.path().join("aliases.js"),
4957            r#"export default [{ find: "@", replacement: "./src" }];"#,
4958        )
4959        .unwrap();
4960        let config = dir.path().join("vite.config.js");
4961        let source = r#"
4962            import sharedAliases from "./aliases.js";
4963            export default defineConfig({ resolve: { alias: sharedAliases } });
4964        "#;
4965        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4966        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4967    }
4968
4969    #[test]
4970    fn aliases_imported_spread_from_two_siblings() {
4971        let dir = tempfile::tempdir().unwrap();
4972        std::fs::write(
4973            dir.path().join("a.js"),
4974            r#"export const a = [{ find: "@", replacement: "./src" }];"#,
4975        )
4976        .unwrap();
4977        std::fs::write(
4978            dir.path().join("b.js"),
4979            r#"export const b = [{ find: "~", replacement: "./lib" }];"#,
4980        )
4981        .unwrap();
4982        let config = dir.path().join("vite.config.js");
4983        let source = r#"
4984            import { a } from "./a.js";
4985            import { b } from "./b.js";
4986            export default defineConfig({ resolve: { alias: [...a, ...b] } });
4987        "#;
4988        let mut got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4989        got.sort();
4990        assert_eq!(
4991            got,
4992            vec![
4993                ("@".to_string(), "./src".to_string()),
4994                ("~".to_string(), "./lib".to_string()),
4995            ]
4996        );
4997    }
4998
4999    #[test]
5000    fn aliases_import_cycle_terminates() {
5001        // a.js imports from b.js and vice versa; resolution must not hang and
5002        // should still recover the literal pairs present.
5003        let dir = tempfile::tempdir().unwrap();
5004        std::fs::write(
5005            dir.path().join("a.js"),
5006            r#"import { b } from "./b.js";
5007               export const a = [{ find: "@", replacement: "./src" }, ...b];"#,
5008        )
5009        .unwrap();
5010        std::fs::write(
5011            dir.path().join("b.js"),
5012            r#"import { a } from "./a.js";
5013               export const b = [...a];"#,
5014        )
5015        .unwrap();
5016        let config = dir.path().join("vite.config.js");
5017        let source = r#"
5018            import { a } from "./a.js";
5019            export default defineConfig({ resolve: { alias: a } });
5020        "#;
5021        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
5022        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
5023    }
5024
5025    #[test]
5026    fn aliases_non_relative_import_not_followed() {
5027        // A bare-package import is intentionally out of scope: no node_modules
5028        // read for an alias literal.
5029        let source = r#"
5030            import { sharedAliases } from "some-pkg";
5031            export default defineConfig({ resolve: { alias: sharedAliases } });
5032        "#;
5033        let dir = tempfile::tempdir().unwrap();
5034        let config = dir.path().join("vite.config.js");
5035        assert!(extract_config_aliases(source, &config, &["resolve", "alias"]).is_empty());
5036    }
5037
5038    #[test]
5039    fn aliases_object_array_value_takes_first_entry() {
5040        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets;
5041        // the resolver must take the first, matching the long-standing non-kinded
5042        // behavior the TypeScript plugin depends on. Regression guard for the
5043        // array-value case that the kinded unification briefly dropped.
5044        let source = r#"
5045            export default {
5046                compilerOptions: { paths: { "@/*": ["./src/*"], "~/*": ["./lib/*", "./vendor/*"] } }
5047            };
5048        "#;
5049        let mut got = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
5050        got.sort();
5051        assert_eq!(
5052            got,
5053            vec![
5054                ("@/*".to_string(), "./src/*".to_string()),
5055                ("~/*".to_string(), "./lib/*".to_string()),
5056            ]
5057        );
5058    }
5059
5060    #[test]
5061    fn aliases_kinded_preserves_is_bare_through_resolution() {
5062        // The bare-string vs path discriminator must survive identifier + spread
5063        // resolution (the test.alias package-to-package gate depends on it).
5064        let source = r#"
5065            const a = [{ find: "lodash-es", replacement: "lodash" }];
5066            export default defineConfig({
5067                resolve: { alias: [...a, { find: "@", replacement: "./src" }] }
5068            });
5069        "#;
5070        let mut got = extract_config_aliases_kinded(source, &js_path(), &["resolve", "alias"]);
5071        got.sort();
5072        assert_eq!(
5073            got,
5074            vec![
5075                ("@".to_string(), "./src".to_string(), false),
5076                ("lodash-es".to_string(), "lodash".to_string(), true),
5077            ]
5078        );
5079    }
5080
5081    #[test]
5082    fn aliases_kinded_preserves_is_bare_through_imported_spread() {
5083        let dir = tempfile::tempdir().unwrap();
5084        std::fs::write(
5085            dir.path().join("aliases.js"),
5086            r#"export const packageAliases = [{ find: "lodash-es", replacement: "lodash" }];"#,
5087        )
5088        .unwrap();
5089        let config = dir.path().join("vite.config.js");
5090        let source = r#"
5091            import { packageAliases } from "./aliases.js";
5092            export default defineConfig({
5093                resolve: { alias: [...packageAliases, { find: "@", replacement: "./src" }] }
5094            });
5095        "#;
5096        let mut got = extract_config_aliases_kinded(source, &config, &["resolve", "alias"]);
5097        got.sort();
5098        assert_eq!(
5099            got,
5100            vec![
5101                ("@".to_string(), "./src".to_string(), false),
5102                ("lodash-es".to_string(), "lodash".to_string(), true),
5103            ]
5104        );
5105    }
5106
5107    // --- extract_config_command ---
5108
5109    #[test]
5110    fn extract_command_string_literal() {
5111        let source = r#"export default { start: "node server.js" };"#;
5112        let val = extract_config_command(source, &js_path(), &["start"]);
5113        assert_eq!(val, Some("node server.js".to_string()));
5114    }
5115
5116    #[test]
5117    fn extract_command_nested_path() {
5118        let source = r#"
5119            export default {
5120                scripts: {
5121                    dev: "vite dev"
5122                }
5123            };
5124        "#;
5125        let val = extract_config_command(source, &js_path(), &["scripts", "dev"]);
5126        assert_eq!(val, Some("vite dev".to_string()));
5127    }
5128
5129    #[test]
5130    fn extract_command_missing_key_returns_none() {
5131        let source = r#"export default { other: "val" };"#;
5132        let val = extract_config_command(source, &js_path(), &["start"]);
5133        assert!(val.is_none());
5134    }
5135
5136    #[test]
5137    fn extract_command_ts_as_expression() {
5138        let source = r#"export default { start: "node server.js" as string };"#;
5139        let val = extract_config_command(source, &ts_path(), &["start"]);
5140        assert_eq!(val, Some("node server.js".to_string()));
5141    }
5142
5143    #[test]
5144    fn extract_command_ts_satisfies_expression() {
5145        let source = r#"export default { start: "node server.js" satisfies string };"#;
5146        let val = extract_config_command(source, &ts_path(), &["start"]);
5147        assert_eq!(val, Some("node server.js".to_string()));
5148    }
5149
5150    #[test]
5151    fn extract_command_parenthesized_expression() {
5152        let source = r#"export default { start: ("node server.js") };"#;
5153        let val = extract_config_command(source, &js_path(), &["start"]);
5154        assert_eq!(val, Some("node server.js".to_string()));
5155    }
5156
5157    #[test]
5158    fn extract_command_empty_path_returns_none() {
5159        let source = r#"export default { start: "node server.js" };"#;
5160        let val = extract_config_command(source, &js_path(), &[]);
5161        assert!(val.is_none());
5162    }
5163
5164    // --- is_disabled_expression and extract_config_truthy_bool_or_object ---
5165
5166    #[test]
5167    fn truthy_bool_or_object_with_true_value() {
5168        let source = r"export default { typescript: true };";
5169        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5170        assert!(result);
5171    }
5172
5173    #[test]
5174    fn truthy_bool_or_object_with_false_value() {
5175        let source = r"export default { typescript: false };";
5176        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5177        assert!(!result);
5178    }
5179
5180    #[test]
5181    fn truthy_bool_or_object_with_object_value() {
5182        let source = r#"export default { typescript: { reactDocgen: "react-docgen" } };"#;
5183        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5184        assert!(result);
5185    }
5186
5187    #[test]
5188    fn truthy_bool_or_object_missing_key_returns_false() {
5189        let source = r"export default { other: true };";
5190        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5191        assert!(!result);
5192    }
5193
5194    #[test]
5195    fn truthy_bool_or_object_with_string_value_returns_false() {
5196        // A string is neither bool true nor object, so the else arm returns false.
5197        let source = r#"export default { typescript: "yes" };"#;
5198        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5199        assert!(!result);
5200    }
5201
5202    #[test]
5203    fn truthy_bool_or_object_ts_satisfies_wrapper() {
5204        let source = r"export default { typescript: (true satisfies boolean) };";
5205        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5206        assert!(result);
5207    }
5208
5209    #[test]
5210    fn truthy_bool_or_object_ts_as_wrapper() {
5211        let source = r"export default { typescript: (true as boolean) };";
5212        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5213        assert!(result);
5214    }
5215
5216    #[test]
5217    fn truthy_bool_or_object_parenthesized_wrapper() {
5218        let source = r"export default { typescript: (true) };";
5219        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
5220        assert!(result);
5221    }
5222
5223    // --- object_expression helper: exercises via static dir entries property_string ---
5224    // property_object calls object_expression; it is also exercised through
5225    // extract_object_from_expression, which handles TS wrappers at the top-export level.
5226    // The ts_satisfies_direct_export / ts_as_direct_export tests already cover those arms.
5227
5228    #[test]
5229    fn static_dir_entries_object_form_exercises_property_string() {
5230        // property_string (which calls property_expr then expression_to_string) is used
5231        // for the `from` and `to` keys in extract_config_static_dir_entries.
5232        let source = r#"
5233            export default {
5234                staticDirs: [
5235                    { from: "./media", to: "/assets" }
5236                ]
5237            };
5238        "#;
5239        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
5240        assert_eq!(
5241            entries,
5242            vec![("./media".to_string(), Some("/assets".to_string()))]
5243        );
5244    }
5245
5246    // --- expression_to_path_values (array form) ---
5247
5248    #[test]
5249    fn expression_to_path_values_array_form_via_config_path() {
5250        // The extract_config_path helper uses expression_to_path; path_values
5251        // is exercised when the value is an array via extract_config_string_or_array.
5252        let source = r#"export default { entries: ["./src/a.ts", "./src/b.ts"] };"#;
5253        let result = extract_config_string_or_array(source, &js_path(), &["entries"]);
5254        assert_eq!(result, vec!["./src/a.ts", "./src/b.ts"]);
5255    }
5256
5257    // --- extract_config_array_nested_aliases_kinded ---
5258
5259    #[test]
5260    fn array_nested_aliases_object_form() {
5261        let source = r#"
5262            export default {
5263                test: {
5264                    projects: [
5265                        {
5266                            resolve: {
5267                                alias: { "@": "./src" }
5268                            }
5269                        }
5270                    ]
5271                }
5272            };
5273        "#;
5274        let aliases = extract_config_array_nested_aliases_kinded(
5275            source,
5276            &ts_path(),
5277            &["test", "projects"],
5278            &["resolve", "alias"],
5279        );
5280        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
5281    }
5282
5283    #[test]
5284    fn array_nested_aliases_empty_when_path_is_not_array() {
5285        let source = r#"export default { test: { projects: "not-an-array" } };"#;
5286        let aliases = extract_config_array_nested_aliases_kinded(
5287            source,
5288            &ts_path(),
5289            &["test", "projects"],
5290            &["resolve", "alias"],
5291        );
5292        assert!(aliases.is_empty());
5293    }
5294
5295    #[test]
5296    fn array_nested_aliases_kinded_tracks_is_bare() {
5297        let source = r#"
5298            export default {
5299                projects: [
5300                    {
5301                        resolve: {
5302                            alias: [
5303                                { find: "lodash-es", replacement: "lodash" },
5304                                { find: "@", replacement: "./src" }
5305                            ]
5306                        }
5307                    }
5308                ]
5309            };
5310        "#;
5311        let mut aliases = extract_config_array_nested_aliases_kinded(
5312            source,
5313            &ts_path(),
5314            &["projects"],
5315            &["resolve", "alias"],
5316        );
5317        aliases.sort();
5318        assert_eq!(
5319            aliases,
5320            vec![
5321                ("@".to_string(), "./src".to_string(), false),
5322                ("lodash-es".to_string(), "lodash".to_string(), true),
5323            ]
5324        );
5325    }
5326
5327    // --- extract_default_export_array_aliases_kinded ---
5328
5329    #[test]
5330    fn default_export_array_aliases_kinded_extracts_from_workspace_config() {
5331        let source = r#"
5332            export default [
5333                {
5334                    resolve: {
5335                        alias: { "@": "./src" }
5336                    }
5337                },
5338                {
5339                    resolve: {
5340                        alias: [{ find: "~", replacement: "./lib" }]
5341                    }
5342                }
5343            ];
5344        "#;
5345        let mut aliases =
5346            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
5347        aliases.sort();
5348        assert_eq!(
5349            aliases,
5350            vec![
5351                ("@".to_string(), "./src".to_string(), false),
5352                ("~".to_string(), "./lib".to_string(), false),
5353            ]
5354        );
5355    }
5356
5357    #[test]
5358    fn default_export_array_aliases_kinded_define_workspace_wrapper() {
5359        let source = r#"
5360            export default defineWorkspace([
5361                {
5362                    resolve: { alias: { "@": "./src" } }
5363                }
5364            ]);
5365        "#;
5366        let aliases =
5367            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
5368        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
5369    }
5370
5371    #[test]
5372    fn default_export_array_aliases_kinded_empty_when_no_alias_path() {
5373        let source = r#"
5374            export default [
5375                { test: { include: ["**/*.test.ts"] } }
5376            ];
5377        "#;
5378        let aliases =
5379            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
5380        assert!(aliases.is_empty());
5381    }
5382
5383    // --- config_default_export_unreachable ---
5384
5385    #[test]
5386    fn config_default_export_unreachable_when_no_export() {
5387        let source = r"const x = 42;";
5388        assert!(config_default_export_unreachable(source, &js_path()));
5389    }
5390
5391    #[test]
5392    fn config_default_export_unreachable_false_for_object_export() {
5393        let source = r#"export default { key: "value" };"#;
5394        assert!(!config_default_export_unreachable(source, &js_path()));
5395    }
5396
5397    #[test]
5398    fn config_default_export_unreachable_false_for_array_export() {
5399        let source = r#"export default ["a", "b"];"#;
5400        assert!(!config_default_export_unreachable(source, &js_path()));
5401    }
5402
5403    #[test]
5404    fn config_default_export_unreachable_true_for_function_without_return_object() {
5405        // A function that returns a number is unreachable.
5406        let source = r"export default function config() { return 42; }";
5407        assert!(config_default_export_unreachable(source, &js_path()));
5408    }
5409
5410    // --- extract_config_static_dir_entries ---
5411
5412    #[test]
5413    fn static_dir_entries_string_and_object_form() {
5414        let source = r#"
5415            export default {
5416                staticDirs: [
5417                    "./public",
5418                    { from: "../assets", to: "/static" }
5419                ]
5420            };
5421        "#;
5422        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
5423        assert_eq!(
5424            entries,
5425            vec![
5426                ("./public".to_string(), None),
5427                ("../assets".to_string(), Some("/static".to_string())),
5428            ]
5429        );
5430    }
5431
5432    #[test]
5433    fn static_dir_entries_object_without_to() {
5434        let source = r#"
5435            export default {
5436                staticDirs: [
5437                    { from: "./media" }
5438                ]
5439            };
5440        "#;
5441        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
5442        assert_eq!(entries, vec![("./media".to_string(), None)]);
5443    }
5444
5445    #[test]
5446    fn static_dir_entries_object_missing_from_skipped() {
5447        // Objects without a `from` key are silently skipped.
5448        let source = r#"
5449            export default {
5450                staticDirs: [
5451                    { to: "/target" },
5452                    "./public"
5453                ]
5454            };
5455        "#;
5456        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
5457        assert_eq!(entries, vec![("./public".to_string(), None)]);
5458    }
5459
5460    #[test]
5461    fn static_dir_entries_empty_when_not_array() {
5462        let source = r#"export default { staticDirs: "./public" };"#;
5463        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
5464        assert!(entries.is_empty());
5465    }
5466
5467    // --- alias object and array forms ---
5468
5469    #[test]
5470    fn aliases_array_form_missing_find_or_replacement_skipped() {
5471        // An element missing "find" or "replacement" is silently skipped.
5472        let source = r#"
5473            export default {
5474                resolve: {
5475                    alias: [
5476                        { replacement: "./src" },
5477                        { find: "@" },
5478                        { find: "~", replacement: "./lib" }
5479                    ]
5480                }
5481            };
5482        "#;
5483        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5484        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
5485    }
5486
5487    #[test]
5488    fn aliases_object_form_computed_key_skipped() {
5489        // Computed keys (expression keys) are not statically recoverable.
5490        let source = r#"
5491            const k = "@";
5492            export default {
5493                resolve: {
5494                    alias: {
5495                        [k]: "./src",
5496                        "~": "./lib"
5497                    }
5498                }
5499            };
5500        "#;
5501        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5502        // Only the literal key "~" survives; computed [k] is dropped.
5503        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
5504    }
5505
5506    #[test]
5507    fn aliases_kinded_array_form_path_replacement_is_not_bare() {
5508        let source = r#"
5509            export default {
5510                resolve: {
5511                    alias: [{ find: "@", replacement: "./src" }]
5512                }
5513            };
5514        "#;
5515        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
5516        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
5517    }
5518
5519    #[test]
5520    fn aliases_kinded_object_form_bare_and_path_discrimination() {
5521        let source = r#"
5522            export default {
5523                resolve: {
5524                    alias: {
5525                        "lodash-es": "lodash",
5526                        "@": "./src"
5527                    }
5528                }
5529            };
5530        "#;
5531        let mut aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
5532        aliases.sort();
5533        assert_eq!(
5534            aliases,
5535            vec![
5536                ("@".to_string(), "./src".to_string(), false),
5537                ("lodash-es".to_string(), "lodash".to_string(), true),
5538            ]
5539        );
5540    }
5541
5542    #[test]
5543    fn aliases_kinded_parent_relative_replacement_is_not_bare() {
5544        let source = r#"
5545            export default {
5546                resolve: { alias: { "@": "../shared/src" } }
5547            };
5548        "#;
5549        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
5550        assert_eq!(
5551            aliases,
5552            vec![("@".to_string(), "../shared/src".to_string(), false)]
5553        );
5554    }
5555
5556    #[test]
5557    fn aliases_kinded_absolute_replacement_is_not_bare() {
5558        let source = r#"
5559            export default {
5560                resolve: { alias: { "@": "/absolute/path" } }
5561            };
5562        "#;
5563        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
5564        assert_eq!(
5565            aliases,
5566            vec![("@".to_string(), "/absolute/path".to_string(), false)]
5567        );
5568    }
5569
5570    // --- find_default_export_array / array_from_expression wrappers ---
5571
5572    #[test]
5573    fn default_export_array_ts_as_wrapper() {
5574        // array_from_expression must unwrap TSAsExpression.
5575        let source = r"export default [] as string[];";
5576        assert!(!config_default_export_unreachable(source, &js_path()));
5577    }
5578
5579    #[test]
5580    fn default_export_array_ts_satisfies_wrapper() {
5581        let source = r"export default [] satisfies string[];";
5582        assert!(!config_default_export_unreachable(source, &ts_path()));
5583    }
5584
5585    #[test]
5586    fn default_export_array_define_config_call_wrapper() {
5587        let source = r#"export default defineConfig(["**/*.test.ts"]);"#;
5588        assert!(!config_default_export_unreachable(source, &ts_path()));
5589    }
5590
5591    // --- collect_shallow_string_values: object-property branches ---
5592
5593    #[test]
5594    fn shallow_strings_object_with_string_values() {
5595        // The ObjectExpression arm of collect_shallow_string_values emits string values.
5596        let source = r#"
5597            export default {
5598                plugins: {
5599                    autoprefixer: "autoprefixer",
5600                    tailwindcss: "tailwindcss"
5601                }
5602            };
5603        "#;
5604        let vals = extract_config_shallow_strings(source, &js_path(), "plugins");
5605        assert!(vals.contains(&"autoprefixer".to_string()));
5606        assert!(vals.contains(&"tailwindcss".to_string()));
5607    }
5608
5609    #[test]
5610    fn shallow_strings_object_with_sub_array_first_element() {
5611        // An object property whose value is an array emits the first string element.
5612        let source = r#"
5613            export default {
5614                reporters: {
5615                    main: ["jest-junit", { outputFile: "report.xml" }],
5616                    alt: ["html-reporter"]
5617                }
5618            };
5619        "#;
5620        let vals = extract_config_shallow_strings(source, &js_path(), "reporters");
5621        assert!(vals.contains(&"jest-junit".to_string()));
5622        assert!(vals.contains(&"html-reporter".to_string()));
5623    }
5624
5625    // --- collect_shallow_string_or_object_property_values ---
5626
5627    #[test]
5628    fn shallow_strings_or_object_property_non_array_single_string() {
5629        // When the top-level value is a plain string (not an array), it is returned directly.
5630        let source = r#"export default { jsPlugins: "eslint-plugin-foo" };"#;
5631        let vals = extract_config_shallow_strings_or_object_property(
5632            source,
5633            &ts_path(),
5634            "jsPlugins",
5635            "specifier",
5636        );
5637        assert_eq!(vals, vec!["eslint-plugin-foo"]);
5638    }
5639
5640    #[test]
5641    fn shallow_strings_or_object_property_ts_satisfies_array_element() {
5642        // shallow_string_or_object_property unwraps TSSatisfiesExpression.
5643        let source = r#"
5644            export default {
5645                jsPlugins: [
5646                    ("eslint-plugin-a" satisfies string)
5647                ]
5648            };
5649        "#;
5650        let vals = extract_config_shallow_strings_or_object_property(
5651            source,
5652            &ts_path(),
5653            "jsPlugins",
5654            "specifier",
5655        );
5656        assert_eq!(vals, vec!["eslint-plugin-a"]);
5657    }
5658
5659    #[test]
5660    fn shallow_strings_or_object_property_ts_as_array_element() {
5661        let source = r#"
5662            export default {
5663                jsPlugins: [
5664                    ("eslint-plugin-b" as string)
5665                ]
5666            };
5667        "#;
5668        let vals = extract_config_shallow_strings_or_object_property(
5669            source,
5670            &ts_path(),
5671            "jsPlugins",
5672            "specifier",
5673        );
5674        assert_eq!(vals, vec!["eslint-plugin-b"]);
5675    }
5676
5677    #[test]
5678    fn shallow_strings_or_object_property_sub_array_first_element_string() {
5679        // A sub-array in jsPlugins returns the first string element.
5680        let source = r#"
5681            export default {
5682                jsPlugins: [
5683                    ["eslint-plugin-tuple-pkg", { options: true }]
5684                ]
5685            };
5686        "#;
5687        let vals = extract_config_shallow_strings_or_object_property(
5688            source,
5689            &ts_path(),
5690            "jsPlugins",
5691            "specifier",
5692        );
5693        assert_eq!(vals, vec!["eslint-plugin-tuple-pkg"]);
5694    }
5695
5696    // --- extract_config_array_object_command_pairs ---
5697
5698    #[test]
5699    fn array_object_command_pairs_basic() {
5700        let source = r#"
5701            export default {
5702                webServer: [
5703                    { command: "node server.js", cwd: "packages/api" },
5704                    { command: "vite dev" }
5705                ]
5706            };
5707        "#;
5708        let pairs = extract_config_array_object_command_pairs(
5709            source,
5710            &ts_path(),
5711            &["webServer"],
5712            "command",
5713            "cwd",
5714        );
5715        assert_eq!(
5716            pairs,
5717            vec![
5718                (
5719                    "node server.js".to_string(),
5720                    Some("packages/api".to_string())
5721                ),
5722                ("vite dev".to_string(), None),
5723            ]
5724        );
5725    }
5726
5727    #[test]
5728    fn array_object_command_pairs_skips_missing_command() {
5729        let source = r#"
5730            export default {
5731                webServer: [
5732                    { cwd: "packages/api" },
5733                    { command: "vite dev", cwd: "apps/web" }
5734                ]
5735            };
5736        "#;
5737        let pairs = extract_config_array_object_command_pairs(
5738            source,
5739            &ts_path(),
5740            &["webServer"],
5741            "command",
5742            "cwd",
5743        );
5744        assert_eq!(
5745            pairs,
5746            vec![("vite dev".to_string(), Some("apps/web".to_string()))]
5747        );
5748    }
5749
5750    #[test]
5751    fn array_object_command_pairs_empty_when_not_array() {
5752        let source = r#"export default { webServer: { command: "vite dev" } };"#;
5753        let pairs = extract_config_array_object_command_pairs(
5754            source,
5755            &ts_path(),
5756            &["webServer"],
5757            "command",
5758            "cwd",
5759        );
5760        assert!(pairs.is_empty());
5761    }
5762
5763    // --- normalize_config_path edge cases ---
5764
5765    #[test]
5766    fn normalize_config_path_empty_string_returns_none() {
5767        let config_path = PathBuf::from("/project/vite.config.ts");
5768        let root = PathBuf::from("/project");
5769        assert_eq!(normalize_config_path("", &config_path, &root), None);
5770    }
5771
5772    #[test]
5773    fn normalize_config_path_escapes_to_above_root_returns_none() {
5774        let config_path = PathBuf::from("/project/vite.config.ts");
5775        let root = PathBuf::from("/project");
5776        // "../../etc" normalizes to the parent of root, which fails the strip_prefix.
5777        assert_eq!(
5778            normalize_config_path("../../etc", &config_path, &root),
5779            None
5780        );
5781    }
5782
5783    #[test]
5784    fn normalize_config_path_dot_slash_resolves_relative_to_config_dir() {
5785        let config_path = PathBuf::from("/project/packages/app/vite.config.ts");
5786        let root = PathBuf::from("/project");
5787        assert_eq!(
5788            normalize_config_path("./src", &config_path, &root),
5789            Some("packages/app/src".to_string())
5790        );
5791    }
5792
5793    // --- JSON config parsing edge cases ---
5794
5795    #[test]
5796    fn json_config_array_of_arrays_via_shallow_strings() {
5797        // JSON with nested plugin tuples is parsed via the parenthesis-wrap path.
5798        let source = r#"{"reporters": ["default", ["jest-junit", {}]]}"#;
5799        let vals = extract_config_shallow_strings(source, &json_path(), "reporters");
5800        assert_eq!(vals, vec!["default", "jest-junit"]);
5801    }
5802
5803    // --- extract_config_path ---
5804
5805    #[test]
5806    fn extract_config_path_string_literal() {
5807        let source = r#"export default { outDir: "./dist" };"#;
5808        let path = extract_config_path(source, &js_path(), &["outDir"]);
5809        assert_eq!(
5810            path.map(|p| p.to_string_lossy().replace('\\', "/")),
5811            Some("./dist".to_string())
5812        );
5813    }
5814
5815    #[test]
5816    fn extract_config_path_with_resolve_call() {
5817        let source = r#"
5818            import { resolve } from "node:path";
5819            export default { outDir: resolve(__dirname, "dist") };
5820        "#;
5821        let path = extract_config_path(source, &js_path(), &["outDir"]);
5822        assert_eq!(
5823            path.map(|p| p.to_string_lossy().replace('\\', "/")),
5824            Some("dist".to_string())
5825        );
5826    }
5827
5828    #[test]
5829    fn extract_config_path_missing_key_returns_none() {
5830        let source = r#"export default { other: "val" };"#;
5831        let path = extract_config_path(source, &js_path(), &["outDir"]);
5832        assert!(path.is_none());
5833    }
5834
5835    // --- extract_imports_and_requires ---
5836
5837    #[test]
5838    fn extract_imports_and_requires_both_forms() {
5839        let source = r"
5840            import foo from 'foo-pkg';
5841            require('bar-pkg');
5842            export default {};
5843        ";
5844        let sources = extract_imports_and_requires(source, &js_path());
5845        assert!(sources.contains(&"foo-pkg".to_string()));
5846        assert!(sources.contains(&"bar-pkg".to_string()));
5847    }
5848
5849    #[test]
5850    fn extract_imports_and_requires_skips_non_require_calls() {
5851        let source = r"
5852            import foo from 'foo-pkg';
5853            someOtherCall('bar-pkg');
5854            export default {};
5855        ";
5856        let sources = extract_imports_and_requires(source, &js_path());
5857        assert_eq!(sources, vec!["foo-pkg"]);
5858    }
5859
5860    // --- extract_config_nested_shallow_strings: non-object nested value ---
5861
5862    #[test]
5863    fn nested_shallow_strings_non_object_nested_returns_empty() {
5864        // When the outer path points to a non-object, it returns empty.
5865        let source = r#"export default { test: "not-an-object" };"#;
5866        let vals =
5867            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
5868        assert!(vals.is_empty());
5869    }
5870
5871    // --- vite_react_babel_dependencies with namespace import ---
5872
5873    #[test]
5874    fn vite_react_babel_dependencies_namespace_import() {
5875        let source = r#"
5876            import * as react from "@vitejs/plugin-react";
5877
5878            export default defineConfig({
5879                plugins: [
5880                    react.default({
5881                        babel: {
5882                            plugins: ["babel-plugin-ns"],
5883                        },
5884                    }),
5885                ],
5886            });
5887        "#;
5888        let deps = extract_vite_plugin_option_dependencies(source, &ts_path());
5889        assert_eq!(deps, vec!["babel-plugin-ns".to_string()]);
5890    }
5891
5892    // --- collect_all_string_values nested object and array recursion ---
5893
5894    #[test]
5895    fn property_strings_deeply_nested_object_values() {
5896        // collect_all_string_values recurses into nested objects and arrays.
5897        let source = r#"
5898            export default {
5899                settings: {
5900                    a: "val-a",
5901                    b: {
5902                        c: "val-c",
5903                        d: ["val-d1", "val-d2"]
5904                    }
5905                }
5906            };
5907        "#;
5908        let values = extract_config_property_strings(source, &js_path(), "settings");
5909        assert!(values.contains(&"val-a".to_string()));
5910        assert!(values.contains(&"val-c".to_string()));
5911        assert!(values.contains(&"val-d1".to_string()));
5912        assert!(values.contains(&"val-d2".to_string()));
5913    }
5914
5915    // --- find_variable_init_expression: export const form ---
5916
5917    #[test]
5918    fn aliases_exported_const_form_resolves() {
5919        // find_variable_init_expression must handle `export const NAME = ...`.
5920        let source = r#"
5921            export const sharedAliases = { "@": "./src" };
5922            export default defineConfig({ resolve: { alias: sharedAliases } });
5923        "#;
5924        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5925        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5926    }
5927
5928    // --- resolve_sibling_module: index file probe ---
5929
5930    #[test]
5931    fn aliases_imported_from_sibling_directory_index_file() {
5932        // resolve_sibling_module probes <specifier>/index.<ext> when direct
5933        // path and extension-suffixed paths do not exist.
5934        let dir = tempfile::tempdir().unwrap();
5935        let aliases_dir = dir.path().join("aliases");
5936        std::fs::create_dir_all(&aliases_dir).unwrap();
5937        std::fs::write(
5938            aliases_dir.join("index.js"),
5939            r#"export const aliases = [{ find: "@", replacement: "./src" }];"#,
5940        )
5941        .unwrap();
5942        let config = dir.path().join("vite.config.js");
5943        let source = r#"
5944            import { aliases } from "./aliases";
5945            export default defineConfig({ resolve: { alias: aliases } });
5946        "#;
5947        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
5948        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
5949    }
5950
5951    // --- aliases max depth guard ---
5952
5953    #[test]
5954    fn aliases_depth_limit_terminates_deep_chain() {
5955        // A chain of more than MAX_ALIAS_RESOLVE_DEPTH identifiers terminates
5956        // without panic or infinite loop. We verify it does not crash.
5957        let source = r#"
5958            const a9 = [{ find: "@", replacement: "./src" }];
5959            const a8 = a9;
5960            const a7 = a8;
5961            const a6 = a7;
5962            const a5 = a6;
5963            const a4 = a5;
5964            const a3 = a4;
5965            const a2 = a3;
5966            const a1 = a2;
5967            export default defineConfig({ resolve: { alias: a1 } });
5968        "#;
5969        // At MAX_ALIAS_RESOLVE_DEPTH (8), resolution stops before reaching the literal.
5970        let got = extract_config_aliases(source, &js_path(), &["resolve", "alias"]);
5971        let _ = got; // empty or non-empty; both are valid, no panic is the assertion.
5972    }
5973
5974    // --- expression_to_path_string: new URL / fileURLToPath ---
5975
5976    #[test]
5977    fn extract_aliases_file_url_to_path_new_url() {
5978        // expression_to_path_string resolves new URL("./src", import.meta.url).
5979        let source = r#"
5980            import { fileURLToPath, URL } from 'node:url';
5981            export default {
5982                resolve: {
5983                    alias: {
5984                        "@": fileURLToPath(new URL("./src", import.meta.url))
5985                    }
5986                }
5987            };
5988        "#;
5989        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
5990        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
5991    }
5992
5993    #[test]
5994    fn extract_path_via_new_url_pathname_member() {
5995        // The .pathname member of new URL(...) is a path-string form.
5996        let source = r#"
5997            export default {
5998                resolve: {
5999                    alias: {
6000                        "@": new URL("./src", import.meta.url).pathname
6001                    }
6002                }
6003            };
6004        "#;
6005        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
6006        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
6007    }
6008
6009    // --- is_disabled_expression: null literal ---
6010
6011    #[test]
6012    fn truthy_bool_or_object_null_literal_returns_false() {
6013        // null is a disabled expression and therefore not truthy.
6014        let source = r"export default { typescript: null };";
6015        let result = extract_config_truthy_bool_or_object(source, &js_path(), &["typescript"]);
6016        assert!(!result);
6017    }
6018
6019    // --- expression_to_string_array: non-array form returns empty ---
6020
6021    #[test]
6022    fn string_array_non_array_value_returns_empty() {
6023        let source = r#"export default { items: "not-an-array" };"#;
6024        let result = extract_config_string_array(source, &js_path(), &["items"]);
6025        assert!(result.is_empty());
6026    }
6027
6028    // --- extract_config_object_nested edge cases ---
6029
6030    #[test]
6031    fn object_nested_empty_when_inner_value_is_not_object() {
6032        // extract_config_object_nested only processes properties whose value is an object.
6033        let source = r#"export default { targets: { build: "not-an-object" } };"#;
6034        let results =
6035            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
6036        assert!(results.is_empty());
6037    }
6038
6039    // --- extract_config_array_nested_string_or_array: missing inner path ---
6040
6041    #[test]
6042    fn array_nested_string_or_array_missing_inner_path_returns_empty() {
6043        let source = r#"
6044            export default {
6045                test: {
6046                    projects: [
6047                        { test: { include: ["**/*.test.ts"] } }
6048                    ]
6049                }
6050            };
6051        "#;
6052        let results = extract_config_array_nested_string_or_array(
6053            source,
6054            &ts_path(),
6055            &["test", "projects"],
6056            &["test", "setupFiles"],
6057        );
6058        assert!(results.is_empty());
6059    }
6060
6061    #[test]
6062    fn wrapped_named_const_default_export_resolves() {
6063        // `export default withMDX(nextConfig)` (official @next/mdx idiom): the
6064        // config is passed as a named const to a wrapper call. Regression #1642.
6065        let source = r#"
6066            import createMDX from "@next/mdx";
6067            const nextConfig = { pageExtensions: ["ts", "tsx", "md", "mdx"] };
6068            const withMDX = createMDX({});
6069            export default withMDX(nextConfig);
6070        "#;
6071        let exts = extract_config_string_array(source, &ts_path(), &["pageExtensions"]);
6072        assert_eq!(exts, vec!["ts", "tsx", "md", "mdx"]);
6073    }
6074
6075    #[test]
6076    fn wrapped_named_const_module_exports_resolves() {
6077        // `module.exports = createJestConfig(customConfig)` (next/jest idiom).
6078        let source = r#"
6079            const nextJest = require("next/jest");
6080            const createJestConfig = nextJest();
6081            const customConfig = { testMatch: ["**/*.test.ts"] };
6082            module.exports = createJestConfig(customConfig);
6083        "#;
6084        let matches = extract_config_string_array(source, &js_path(), &["testMatch"]);
6085        assert_eq!(matches, vec!["**/*.test.ts"]);
6086    }
6087
6088    #[test]
6089    fn wrapped_named_const_nested_and_curried_resolve() {
6090        let nested = r#"
6091            const nextConfig = { pageExtensions: ["mdx"] };
6092            const withMDX = (c) => c;
6093            const withFoo = (c) => c;
6094            export default withMDX(withFoo(nextConfig));
6095        "#;
6096        assert_eq!(
6097            extract_config_string_array(nested, &js_path(), &["pageExtensions"]),
6098            vec!["mdx"]
6099        );
6100
6101        let curried = r#"
6102            const nextConfig = { pageExtensions: ["md"] };
6103            const compose = (..._p) => (c) => c;
6104            export default compose(a, b)(nextConfig);
6105        "#;
6106        assert_eq!(
6107            extract_config_string_array(curried, &js_path(), &["pageExtensions"]),
6108            vec!["md"]
6109        );
6110    }
6111
6112    #[test]
6113    fn wrapped_inline_object_still_resolves() {
6114        // The pre-existing inline-object form must keep working unchanged.
6115        let source = r#"
6116            const withMDX = createMDX({});
6117            export default withMDX({ pageExtensions: ["mdx"] });
6118        "#;
6119        assert_eq!(
6120            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
6121            vec!["mdx"]
6122        );
6123    }
6124}