Skip to main content

fallow_core/plugins/
config_parser.rs

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