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