Skip to main content

fallow_core/plugins/
config_parser.rs

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