Skip to main content

fallow_core/plugins/
config_parser.rs

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