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