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