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