Skip to main content

fallow_core/plugins/
config_parser.rs

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