Skip to main content

fallow_core/plugins/
config_parser.rs

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