Skip to main content

fallow_core/plugins/
config_parser.rs

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