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                    Argument::ArrowFunctionExpression(arrow) => {
1150                        if arrow.expression
1151                            && !arrow.body.statements.is_empty()
1152                            && let Statement::ExpressionStatement(expr_stmt) =
1153                                &arrow.body.statements[0]
1154                        {
1155                            return extract_object_from_expression(&expr_stmt.expression);
1156                        }
1157                    }
1158                    _ => {}
1159                }
1160            }
1161            None
1162        }
1163        Expression::ParenthesizedExpression(paren) => {
1164            extract_object_from_expression(&paren.expression)
1165        }
1166        Expression::TSSatisfiesExpression(ts_sat) => {
1167            extract_object_from_expression(&ts_sat.expression)
1168        }
1169        Expression::TSAsExpression(ts_as) => extract_object_from_expression(&ts_as.expression),
1170        Expression::ArrowFunctionExpression(arrow) => extract_object_from_arrow_function(arrow),
1171        Expression::FunctionExpression(func) => extract_object_from_function(func),
1172        _ => None,
1173    }
1174}
1175
1176fn extract_object_from_arrow_function<'a>(
1177    arrow: &'a ArrowFunctionExpression<'a>,
1178) -> Option<&'a ObjectExpression<'a>> {
1179    if arrow.expression {
1180        arrow.body.statements.first().and_then(|stmt| {
1181            if let Statement::ExpressionStatement(expr_stmt) = stmt {
1182                extract_object_from_expression(&expr_stmt.expression)
1183            } else {
1184                None
1185            }
1186        })
1187    } else {
1188        extract_object_from_function_body(&arrow.body)
1189    }
1190}
1191
1192fn extract_object_from_function<'a>(func: &'a Function<'a>) -> Option<&'a ObjectExpression<'a>> {
1193    func.body
1194        .as_ref()
1195        .and_then(|body| extract_object_from_function_body(body))
1196}
1197
1198fn extract_object_from_function_body<'a>(
1199    body: &'a FunctionBody<'a>,
1200) -> Option<&'a ObjectExpression<'a>> {
1201    for stmt in &body.statements {
1202        if let Statement::ReturnStatement(ret) = stmt
1203            && let Some(argument) = &ret.argument
1204            && let Some(obj) = extract_object_from_expression(argument)
1205        {
1206            return Some(obj);
1207        }
1208    }
1209    None
1210}
1211
1212/// Check if an assignment target is `module.exports`.
1213fn is_module_exports_target(target: &AssignmentTarget) -> bool {
1214    if let AssignmentTarget::StaticMemberExpression(member) = target
1215        && let Expression::Identifier(obj) = &member.object
1216    {
1217        return obj.name == "module" && member.property.name == "exports";
1218    }
1219    false
1220}
1221
1222/// Unwrap TS annotations and return the identifier name if the expression resolves to one.
1223///
1224/// Handles `config`, `config satisfies Type`, `config as Type`.
1225fn unwrap_to_identifier_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
1226    match expr {
1227        Expression::Identifier(id) => Some(&id.name),
1228        Expression::TSSatisfiesExpression(ts_sat) => unwrap_to_identifier_name(&ts_sat.expression),
1229        Expression::TSAsExpression(ts_as) => unwrap_to_identifier_name(&ts_as.expression),
1230        _ => None,
1231    }
1232}
1233
1234/// Find a top-level variable declaration by name and extract its init as an object expression.
1235///
1236/// Handles `const config = { ... }`, `const config: Type = { ... }`,
1237/// and `const config = defineConfig({ ... })`.
1238fn find_variable_init_object<'a>(
1239    program: &'a Program,
1240    name: &str,
1241) -> Option<&'a ObjectExpression<'a>> {
1242    for stmt in &program.body {
1243        if let Statement::VariableDeclaration(decl) = stmt {
1244            for declarator in &decl.declarations {
1245                if let BindingPattern::BindingIdentifier(id) = &declarator.id
1246                    && id.name == name
1247                    && let Some(init) = &declarator.init
1248                {
1249                    return extract_object_from_expression(init);
1250                }
1251            }
1252        }
1253    }
1254    None
1255}
1256
1257/// Resolve a config object that is passed as a NAMED CONST to a wrapper call:
1258/// `export default withMDX(nextConfig)`, `module.exports = createJestConfig(cfg)`,
1259/// nested `withMDX(withFoo(nextConfig))`, and curried `compose(...)(nextConfig)`.
1260/// This is the call-argument analog of the bare `export default config` identifier
1261/// resolution already done via [`unwrap_to_identifier_name`] +
1262/// [`find_variable_init_object`]; it lets the official `@next/mdx` /
1263/// `withSentry(nextConfig)` / `next-compose-plugins` idioms resolve so their
1264/// `pageExtensions` / plugin config is extracted instead of silently dropped.
1265///
1266/// Returns the first argument (scanning nested wrapper calls) that resolves to a
1267/// local `const NAME = { ... }`. An inline object argument is already handled by
1268/// [`extract_object_from_expression`], which the caller tries first.
1269fn resolve_wrapped_config_object<'a>(
1270    program: &'a Program,
1271    expr: &'a Expression<'a>,
1272) -> Option<&'a ObjectExpression<'a>> {
1273    let call = match expr {
1274        Expression::CallExpression(call) => call,
1275        Expression::ParenthesizedExpression(paren) => {
1276            return resolve_wrapped_config_object(program, &paren.expression);
1277        }
1278        Expression::TSSatisfiesExpression(ts_sat) => {
1279            return resolve_wrapped_config_object(program, &ts_sat.expression);
1280        }
1281        Expression::TSAsExpression(ts_as) => {
1282            return resolve_wrapped_config_object(program, &ts_as.expression);
1283        }
1284        _ => return None,
1285    };
1286    for arg in &call.arguments {
1287        let Some(arg_expr) = arg.as_expression() else {
1288            continue;
1289        };
1290        if let Some(name) = unwrap_to_identifier_name(arg_expr)
1291            && let Some(obj) = find_variable_init_object(program, name)
1292        {
1293            return Some(obj);
1294        }
1295        if let Some(obj) = resolve_wrapped_config_object(program, arg_expr) {
1296            return Some(obj);
1297        }
1298    }
1299    None
1300}
1301
1302/// Find a named property in an object expression.
1303pub(crate) fn find_property<'a>(
1304    obj: &'a ObjectExpression<'a>,
1305    key: &str,
1306) -> Option<&'a ObjectProperty<'a>> {
1307    for prop in &obj.properties {
1308        if let ObjectPropertyKind::ObjectProperty(p) = prop
1309            && property_key_matches(&p.key, key)
1310        {
1311            return Some(p);
1312        }
1313    }
1314    None
1315}
1316
1317/// Check if a property key matches a string.
1318fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
1319    match key {
1320        PropertyKey::StaticIdentifier(id) => id.name == name,
1321        PropertyKey::StringLiteral(s) => s.value == name,
1322        _ => false,
1323    }
1324}
1325
1326/// Get a string value from an object property.
1327fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
1328    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
1329}
1330
1331/// Get an array of strings from an object property.
1332fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
1333    find_property(obj, key)
1334        .map(|p| expression_to_string_array(&p.value))
1335        .unwrap_or_default()
1336}
1337
1338/// Navigate a nested property path and get a string array.
1339fn get_nested_string_array_from_object(
1340    obj: &ObjectExpression,
1341    path: &[&str],
1342) -> Option<Vec<String>> {
1343    if path.is_empty() {
1344        return None;
1345    }
1346    if path.len() == 1 {
1347        return Some(get_object_string_array_property(obj, path[0]));
1348    }
1349    let prop = find_property(obj, path[0])?;
1350    if let Expression::ObjectExpression(nested) = &prop.value {
1351        get_nested_string_array_from_object(nested, &path[1..])
1352    } else {
1353        None
1354    }
1355}
1356
1357/// Navigate a nested property path and get a string value.
1358fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
1359    if path.is_empty() {
1360        return None;
1361    }
1362    if path.len() == 1 {
1363        return get_object_string_property(obj, path[0]);
1364    }
1365    let prop = find_property(obj, path[0])?;
1366    if let Expression::ObjectExpression(nested) = &prop.value {
1367        get_nested_string_from_object(nested, &path[1..])
1368    } else {
1369        None
1370    }
1371}
1372
1373/// Navigate a nested property path and get a shell command value.
1374fn get_nested_command_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
1375    if path.is_empty() {
1376        return None;
1377    }
1378    if path.len() == 1 {
1379        return find_property(obj, path[0]).and_then(|prop| expression_to_command(&prop.value));
1380    }
1381    let prop = find_property(obj, path[0])?;
1382    if let Expression::ObjectExpression(nested) = &prop.value {
1383        get_nested_command_from_object(nested, &path[1..])
1384    } else {
1385        None
1386    }
1387}
1388
1389/// Convert an expression to a string if it's a string literal.
1390pub(crate) fn expression_to_string(expr: &Expression) -> Option<String> {
1391    match expr {
1392        Expression::StringLiteral(s) => Some(s.value.to_string()),
1393        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
1394            t.quasis.first().map(|q| q.value.raw.to_string())
1395        }
1396        _ => None,
1397    }
1398}
1399
1400/// Convert an expression to a shell command when static command tokens are recoverable.
1401fn expression_to_command(expr: &Expression) -> Option<String> {
1402    match expr {
1403        Expression::StringLiteral(s) => Some(s.value.to_string()),
1404        Expression::TemplateLiteral(template) => template_literal_to_command(template),
1405        Expression::ParenthesizedExpression(paren) => expression_to_command(&paren.expression),
1406        Expression::TSAsExpression(ts_as) => expression_to_command(&ts_as.expression),
1407        Expression::TSSatisfiesExpression(ts_sat) => expression_to_command(&ts_sat.expression),
1408        _ => None,
1409    }
1410}
1411
1412fn template_literal_to_command(template: &TemplateLiteral<'_>) -> Option<String> {
1413    let first = template.quasis.first()?.value.raw.as_str();
1414    if first.trim_start().is_empty() {
1415        return None;
1416    }
1417
1418    let mut command = String::new();
1419    for (idx, quasi) in template.quasis.iter().enumerate() {
1420        command.push_str(quasi.value.raw.as_str());
1421        if idx < template.expressions.len() {
1422            let next = template
1423                .quasis
1424                .get(idx + 1)
1425                .map_or("", |next| next.value.raw.as_str());
1426            if dynamic_template_boundary_splits_static_token(quasi.value.raw.as_str(), next) {
1427                return None;
1428            }
1429            command.push(' ');
1430        }
1431    }
1432
1433    Some(command)
1434}
1435
1436fn dynamic_template_boundary_splits_static_token(before: &str, after: &str) -> bool {
1437    before
1438        .chars()
1439        .next_back()
1440        .is_some_and(is_command_token_char)
1441        && after.chars().next().is_some_and(is_command_token_char)
1442}
1443
1444fn is_command_token_char(ch: char) -> bool {
1445    !ch.is_whitespace() && !matches!(ch, '&' | '|' | ';' | '"' | '\'')
1446}
1447
1448/// Convert an expression to a path-like string if it's statically recoverable.
1449pub(crate) fn expression_to_path_string(expr: &Expression) -> Option<String> {
1450    match expr {
1451        Expression::ParenthesizedExpression(paren) => expression_to_path_string(&paren.expression),
1452        Expression::TSAsExpression(ts_as) => expression_to_path_string(&ts_as.expression),
1453        Expression::TSSatisfiesExpression(ts_sat) => expression_to_path_string(&ts_sat.expression),
1454        Expression::StaticMemberExpression(member) if member.property.name == "pathname" => {
1455            expression_to_path_string(&member.object)
1456        }
1457        Expression::CallExpression(call) => call_expression_to_path_string(call),
1458        Expression::NewExpression(new_expr) => new_expression_to_path_string(new_expr),
1459        _ => expression_to_string(expr),
1460    }
1461}
1462
1463fn call_expression_to_path_string(call: &CallExpression) -> Option<String> {
1464    if matches!(&call.callee, Expression::Identifier(id) if id.name == "fileURLToPath") {
1465        return call
1466            .arguments
1467            .first()
1468            .and_then(Argument::as_expression)
1469            .and_then(expression_to_path_string);
1470    }
1471
1472    let callee_name = match &call.callee {
1473        Expression::Identifier(id) => Some(id.name.as_str()),
1474        Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
1475        _ => None,
1476    }?;
1477
1478    if !matches!(callee_name, "resolve" | "join") {
1479        return None;
1480    }
1481
1482    let mut segments = Vec::new();
1483    for (index, arg) in call.arguments.iter().enumerate() {
1484        let expr = arg.as_expression()?;
1485
1486        if is_dirname_anchor(expr) {
1487            if index == 0 {
1488                continue;
1489            }
1490            return None;
1491        }
1492
1493        segments.push(expression_to_string(expr)?);
1494    }
1495
1496    (!segments.is_empty()).then(|| join_path_segments(&segments))
1497}
1498
1499/// True when an expression is a "current directory" anchor: the `__dirname`
1500/// CommonJS global or its ESM equivalent `import.meta.dirname` (Node 20.11+).
1501/// As the leading argument of `resolve(...)` / `join(...)` it is dropped so the
1502/// remaining literal segments yield a config-directory-relative path.
1503fn is_dirname_anchor(expr: &Expression) -> bool {
1504    match expr {
1505        Expression::Identifier(id) => id.name == "__dirname",
1506        Expression::StaticMemberExpression(member) => {
1507            member.property.name == "dirname" && is_import_meta_expression(&member.object)
1508        }
1509        _ => false,
1510    }
1511}
1512
1513/// True for the `import.meta` meta-property, distinct from `new.target`.
1514fn is_import_meta_expression(expr: &Expression) -> bool {
1515    matches!(
1516        expr,
1517        Expression::MetaProperty(meta) if meta.meta.name == "import" && meta.property.name == "meta"
1518    )
1519}
1520
1521fn new_expression_to_path_string(new_expr: &NewExpression) -> Option<String> {
1522    if !matches!(&new_expr.callee, Expression::Identifier(id) if id.name == "URL") {
1523        return None;
1524    }
1525
1526    let source = new_expr
1527        .arguments
1528        .first()
1529        .and_then(Argument::as_expression)
1530        .and_then(expression_to_string)?;
1531
1532    let base = new_expr
1533        .arguments
1534        .get(1)
1535        .and_then(Argument::as_expression)?;
1536    is_import_meta_url_expression(base).then_some(source)
1537}
1538
1539fn is_import_meta_url_expression(expr: &Expression) -> bool {
1540    if let Expression::StaticMemberExpression(member) = expr {
1541        member.property.name == "url" && matches!(member.object, Expression::MetaProperty(_))
1542    } else {
1543        false
1544    }
1545}
1546
1547fn join_path_segments(segments: &[String]) -> String {
1548    let mut joined = PathBuf::new();
1549    for segment in segments {
1550        joined.push(segment);
1551    }
1552    joined.to_string_lossy().replace('\\', "/")
1553}
1554
1555fn expression_to_alias_pairs(expr: &Expression) -> Vec<(String, String)> {
1556    match expr {
1557        Expression::ObjectExpression(obj) => obj
1558            .properties
1559            .iter()
1560            .filter_map(|prop| {
1561                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1562                    return None;
1563                };
1564                let find = property_key_to_string(&prop.key)?;
1565                let replacement = expression_to_path_values(&prop.value)
1566                    .into_iter()
1567                    .next()
1568                    .map(|path| path_to_config_string(&path))?;
1569                Some((find, replacement))
1570            })
1571            .collect(),
1572        Expression::ArrayExpression(arr) => arr
1573            .elements
1574            .iter()
1575            .filter_map(|element| {
1576                let Expression::ObjectExpression(obj) = element.as_expression()? else {
1577                    return None;
1578                };
1579                let find = find_property(obj, "find")
1580                    .and_then(|prop| expression_to_string(&prop.value))?;
1581                let replacement = find_property(obj, "replacement")
1582                    .and_then(|prop| expression_to_path_string(&prop.value))?;
1583                Some((find, replacement))
1584            })
1585            .collect(),
1586        _ => Vec::new(),
1587    }
1588}
1589
1590/// Kinded variant of [`expression_to_alias_pairs`]: each tuple gains a
1591/// `replacement_is_bare_string_literal` flag. See
1592/// [`extract_config_aliases_kinded`].
1593fn expression_to_alias_pairs_kinded(expr: &Expression) -> Vec<(String, String, bool)> {
1594    match expr {
1595        Expression::ObjectExpression(obj) => obj
1596            .properties
1597            .iter()
1598            .filter_map(|prop| {
1599                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1600                    return None;
1601                };
1602                let find = property_key_to_string(&prop.key)?;
1603                let (replacement, is_bare) = alias_replacement_kinded(&prop.value)?;
1604                Some((find, replacement, is_bare))
1605            })
1606            .collect(),
1607        Expression::ArrayExpression(arr) => arr
1608            .elements
1609            .iter()
1610            .filter_map(|element| {
1611                let Expression::ObjectExpression(obj) = element.as_expression()? else {
1612                    return None;
1613                };
1614                let find = find_property(obj, "find")
1615                    .and_then(|prop| expression_to_string(&prop.value))?;
1616                let (replacement, is_bare) = find_property(obj, "replacement")
1617                    .and_then(|prop| alias_replacement_kinded(&prop.value))?;
1618                Some((find, replacement, is_bare))
1619            })
1620            .collect(),
1621        _ => Vec::new(),
1622    }
1623}
1624
1625/// Extract an alias replacement string plus whether it was written as a plain
1626/// bare string literal. A bare string literal (not starting with `./`/`../`/`/`)
1627/// signals a potential package-to-package alias; a path expression
1628/// (`path.resolve(...)`, `path.join(...)`, `fileURLToPath(...)`, `new URL(...)`)
1629/// or a `./`-prefixed string is always a filesystem path. This is the
1630/// filesystem-free discriminator the package-to-package gate relies on.
1631fn alias_replacement_kinded(expr: &Expression) -> Option<(String, bool)> {
1632    match expr {
1633        Expression::ParenthesizedExpression(paren) => alias_replacement_kinded(&paren.expression),
1634        Expression::TSAsExpression(ts_as) => alias_replacement_kinded(&ts_as.expression),
1635        Expression::TSSatisfiesExpression(ts_sat) => alias_replacement_kinded(&ts_sat.expression),
1636        Expression::StringLiteral(s) => {
1637            let value = s.value.to_string();
1638            let is_bare =
1639                !value.starts_with("./") && !value.starts_with("../") && !value.starts_with('/');
1640            Some((value, is_bare))
1641        }
1642        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets
1643        // (`{ "@/*": ["./src/*"] }`); take the first entry, matching the prior
1644        // non-kinded `expression_to_path_values().next()` behavior.
1645        Expression::ArrayExpression(arr) => arr
1646            .elements
1647            .iter()
1648            .find_map(ArrayExpressionElement::as_expression)
1649            .and_then(alias_replacement_kinded),
1650        _ => expression_to_path_string(expr).map(|value| (value, false)),
1651    }
1652}
1653
1654/// Maximum identifier-indirection hops the alias resolver follows before giving
1655/// up. Each local-variable or imported-binding resolution counts one hop. The
1656/// per-file `visited` set is the real cycle guard; this bound additionally
1657/// terminates pathological local self-references (`const a = a`). Real configs
1658/// rarely exceed one or two hops (`alias: importedAliases`).
1659const MAX_ALIAS_RESOLVE_DEPTH: usize = 8;
1660
1661/// Sibling-file extensions probed when an alias identifier is imported from a
1662/// relative specifier. Mirrors the JS/TS config extensions Vite/Vitest configs
1663/// and their shared alias modules use. `.js` first matches the common
1664/// JS-project case; the direct-as-written read happens before any probing. JSON
1665/// is intentionally excluded: it parses as a bare expression with no `export`,
1666/// so `find_exported_init` could never recover an alias literal from it.
1667const ALIAS_SIBLING_EXTS: [&str; 6] = ["js", "mjs", "cjs", "ts", "mts", "cts"];
1668
1669/// Resolve an alias expression into `(find, replacement, is_bare)` tuples,
1670/// following identifiers and expanding spreads.
1671///
1672/// Beyond the inline object (`{ '@': './src' }`) and array
1673/// (`[{ find, replacement }]`) forms, this handles the indirection shapes from
1674/// issue #811:
1675/// - an identifier bound to a local `const NAME = [...] | {...}`,
1676/// - an identifier imported from a relative sibling file
1677///   (`import { sharedAliases } from "./vite.shared.js"`), read one hop and
1678///   parsed for `export const NAME` / `export default` / `export { NAME }`,
1679/// - array spread elements (`[...a, ...b]`) and object spread properties
1680///   (`{ ...a, '@': './src' }`), each resolved recursively.
1681///
1682/// `config_path` is the file `expr` lives in (used to resolve relative sibling
1683/// imports). `visited` holds already-read sibling paths to break import cycles;
1684/// `depth` bounds identifier indirection via [`MAX_ALIAS_RESOLVE_DEPTH`].
1685fn resolve_alias_pairs_kinded(
1686    program: &Program,
1687    config_path: &Path,
1688    expr: &Expression,
1689    visited: &mut FxHashSet<PathBuf>,
1690    depth: usize,
1691) -> Vec<(String, String, bool)> {
1692    match expr {
1693        Expression::ParenthesizedExpression(paren) => {
1694            resolve_alias_pairs_kinded(program, config_path, &paren.expression, visited, depth)
1695        }
1696        Expression::TSAsExpression(ts_as) => {
1697            resolve_alias_pairs_kinded(program, config_path, &ts_as.expression, visited, depth)
1698        }
1699        Expression::TSSatisfiesExpression(ts_sat) => {
1700            resolve_alias_pairs_kinded(program, config_path, &ts_sat.expression, visited, depth)
1701        }
1702        Expression::ObjectExpression(obj) => {
1703            resolve_object_alias_pairs_kinded(program, config_path, obj, visited, depth)
1704        }
1705        Expression::ArrayExpression(arr) => {
1706            resolve_array_alias_pairs_kinded(program, config_path, arr, visited, depth)
1707        }
1708        Expression::Identifier(id) => {
1709            resolve_identifier_alias_pairs(program, config_path, id.name.as_str(), visited, depth)
1710        }
1711        _ => Vec::new(),
1712    }
1713}
1714
1715/// Resolve object-form alias pairs (`{ '@': './src', ...spread }`), expanding
1716/// spread properties recursively.
1717fn resolve_object_alias_pairs_kinded(
1718    program: &Program,
1719    config_path: &Path,
1720    obj: &ObjectExpression,
1721    visited: &mut FxHashSet<PathBuf>,
1722    depth: usize,
1723) -> Vec<(String, String, bool)> {
1724    let mut pairs = Vec::new();
1725    for prop in &obj.properties {
1726        match prop {
1727            ObjectPropertyKind::ObjectProperty(prop) => {
1728                if let Some(find) = property_key_to_string(&prop.key)
1729                    && let Some((replacement, is_bare)) = alias_replacement_kinded(&prop.value)
1730                {
1731                    pairs.push((find, replacement, is_bare));
1732                }
1733            }
1734            // `{ ...sharedAliases, '@': './src' }`
1735            ObjectPropertyKind::SpreadProperty(spread) => {
1736                pairs.extend(resolve_alias_pairs_kinded(
1737                    program,
1738                    config_path,
1739                    &spread.argument,
1740                    visited,
1741                    depth,
1742                ));
1743            }
1744        }
1745    }
1746    pairs
1747}
1748
1749/// Resolve array-form alias pairs (`[{ find, replacement }, ...spread]`),
1750/// expanding spread elements recursively.
1751fn resolve_array_alias_pairs_kinded(
1752    program: &Program,
1753    config_path: &Path,
1754    arr: &ArrayExpression,
1755    visited: &mut FxHashSet<PathBuf>,
1756    depth: usize,
1757) -> Vec<(String, String, bool)> {
1758    let mut pairs = Vec::new();
1759    for element in &arr.elements {
1760        match element {
1761            // `[...sharedAliases, { find, replacement }]`
1762            ArrayExpressionElement::SpreadElement(spread) => {
1763                pairs.extend(resolve_alias_pairs_kinded(
1764                    program,
1765                    config_path,
1766                    &spread.argument,
1767                    visited,
1768                    depth,
1769                ));
1770            }
1771            _ => {
1772                if let Some(Expression::ObjectExpression(obj)) = element.as_expression()
1773                    && let Some(find) = find_property(obj, "find")
1774                        .and_then(|prop| expression_to_string(&prop.value))
1775                    && let Some((replacement, is_bare)) = find_property(obj, "replacement")
1776                        .and_then(|prop| alias_replacement_kinded(&prop.value))
1777                {
1778                    pairs.push((find, replacement, is_bare));
1779                }
1780            }
1781        }
1782    }
1783    pairs
1784}
1785
1786/// Resolve an identifier used as an alias value to its literal pairs, first by
1787/// local `const`/`let`/`var` binding, then by a one-hop relative import.
1788fn resolve_identifier_alias_pairs(
1789    program: &Program,
1790    config_path: &Path,
1791    name: &str,
1792    visited: &mut FxHashSet<PathBuf>,
1793    depth: usize,
1794) -> Vec<(String, String, bool)> {
1795    if depth >= MAX_ALIAS_RESOLVE_DEPTH {
1796        return Vec::new();
1797    }
1798    // Local `const NAME = [...] | {...}` (or `const NAME = otherIdentifier`).
1799    if let Some(init) = find_variable_init_expression(program, name) {
1800        return resolve_alias_pairs_kinded(program, config_path, init, visited, depth + 1);
1801    }
1802    // `import { NAME } from "./sibling"` / `import NAME from "./sibling"`.
1803    let Some((specifier, imported_name)) = find_relative_import_binding(program, name) else {
1804        return Vec::new();
1805    };
1806    resolve_imported_alias_pairs(
1807        config_path,
1808        &specifier,
1809        imported_name.as_deref(),
1810        visited,
1811        depth + 1,
1812    )
1813}
1814
1815/// Read a relative sibling file and resolve the alias literal it exports under
1816/// `imported_name` (`None` = default export).
1817fn resolve_imported_alias_pairs(
1818    config_path: &Path,
1819    specifier: &str,
1820    imported_name: Option<&str>,
1821    visited: &mut FxHashSet<PathBuf>,
1822    depth: usize,
1823) -> Vec<(String, String, bool)> {
1824    let Some((sibling_path, sibling_source)) = resolve_sibling_module(config_path, specifier)
1825    else {
1826        return Vec::new();
1827    };
1828    if !visited.insert(sibling_path.clone()) {
1829        return Vec::new();
1830    }
1831    extract_from_source(&sibling_source, &sibling_path, |program| {
1832        let init = find_exported_init(program, imported_name)?;
1833        let pairs = resolve_alias_pairs_kinded(program, &sibling_path, init, visited, depth);
1834        (!pairs.is_empty()).then_some(pairs)
1835    })
1836    .unwrap_or_default()
1837}
1838
1839/// Find a top-level variable declaration by name and return its init expression
1840/// (array, object, or another identifier). Covers bare `const NAME = ...` and
1841/// `export const NAME = ...`. Generalizes [`find_variable_init_object`] to any
1842/// init shape so the alias resolver can recurse on array/identifier inits.
1843fn find_variable_init_expression<'a>(
1844    program: &'a Program<'a>,
1845    name: &str,
1846) -> Option<&'a Expression<'a>> {
1847    for stmt in &program.body {
1848        let decl = match stmt {
1849            Statement::VariableDeclaration(decl) => decl,
1850            Statement::ExportNamedDeclaration(export) => match &export.declaration {
1851                Some(Declaration::VariableDeclaration(decl)) => decl,
1852                _ => continue,
1853            },
1854            _ => continue,
1855        };
1856        for declarator in &decl.declarations {
1857            if let BindingPattern::BindingIdentifier(id) = &declarator.id
1858                && id.name == name
1859                && let Some(init) = &declarator.init
1860            {
1861                return Some(init);
1862            }
1863        }
1864    }
1865    None
1866}
1867
1868/// Find the init expression a sibling module exports under `name`
1869/// (`None` = default export). For named exports this covers both
1870/// `export const NAME = ...` and a local `const NAME = ...` later re-exported
1871/// via `export { NAME }` (both surface through [`find_variable_init_expression`]).
1872fn find_exported_init<'a>(
1873    program: &'a Program<'a>,
1874    name: Option<&str>,
1875) -> Option<&'a Expression<'a>> {
1876    match name {
1877        Some(name) => find_variable_init_expression(program, name),
1878        None => program.body.iter().find_map(|stmt| {
1879            if let Statement::ExportDefaultDeclaration(decl) = stmt {
1880                decl.declaration.as_expression()
1881            } else {
1882                None
1883            }
1884        }),
1885    }
1886}
1887
1888/// Find the import that binds local `name` to a RELATIVE module, returning the
1889/// specifier and the imported name (`None` for a default import). Bare-package
1890/// imports are intentionally skipped: reading a literal alias table out of
1891/// `node_modules` is not a real-world config shape.
1892fn find_relative_import_binding(program: &Program, name: &str) -> Option<(String, Option<String>)> {
1893    for stmt in &program.body {
1894        let Statement::ImportDeclaration(decl) = stmt else {
1895            continue;
1896        };
1897        let specifier = decl.source.value.as_str();
1898        if !is_relative_specifier(specifier) {
1899            continue;
1900        }
1901        let Some(specifiers) = &decl.specifiers else {
1902            continue;
1903        };
1904        for spec in specifiers {
1905            match spec {
1906                ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.local.name == name => {
1907                    return Some((
1908                        specifier.to_string(),
1909                        Some(spec.imported.name().to_string()),
1910                    ));
1911                }
1912                ImportDeclarationSpecifier::ImportDefaultSpecifier(spec)
1913                    if spec.local.name == name =>
1914                {
1915                    return Some((specifier.to_string(), None));
1916                }
1917                _ => {}
1918            }
1919        }
1920    }
1921    None
1922}
1923
1924/// True for a relative/absolute module specifier (`./x`, `../x`, `/x`), the
1925/// shapes that point at a sibling file rather than an npm package.
1926fn is_relative_specifier(specifier: &str) -> bool {
1927    specifier.starts_with("./") || specifier.starts_with("../") || specifier.starts_with('/')
1928}
1929
1930/// Resolve a relative specifier against `config_path`'s directory to a readable
1931/// sibling file, returning the resolved path and its source. Tries the path as
1932/// written first (covers `./vite.shared.js`), then appends each known config
1933/// extension (covers extensionless `./vite.shared` and dotted basenames where
1934/// `Path::extension` would misread `.shared`), then an `index.*` directory file.
1935fn resolve_sibling_module(config_path: &Path, specifier: &str) -> Option<(PathBuf, String)> {
1936    let parent = config_path.parent().unwrap_or(config_path);
1937    let direct = parent.join(specifier);
1938    if let Ok(source) = std::fs::read_to_string(&direct) {
1939        return Some((direct, source));
1940    }
1941    for ext in ALIAS_SIBLING_EXTS {
1942        let candidate = parent.join(format!("{specifier}.{ext}"));
1943        if let Ok(source) = std::fs::read_to_string(&candidate) {
1944            return Some((candidate, source));
1945        }
1946    }
1947    for ext in ALIAS_SIBLING_EXTS {
1948        let candidate = direct.join(format!("index.{ext}"));
1949        if let Ok(source) = std::fs::read_to_string(&candidate) {
1950            return Some((candidate, source));
1951        }
1952    }
1953    None
1954}
1955
1956/// Find a default-exported array config, the `defineWorkspace([...])` /
1957/// `vitest.workspace.{ts,js}` shape. Handles `export default [...]` and
1958/// `export default defineWorkspace([...])` / `defineConfig([...])` (the array as
1959/// the call's first argument), plus parenthesised / `as` wrappers.
1960fn find_default_export_array<'a>(program: &'a Program<'a>) -> Option<&'a ArrayExpression<'a>> {
1961    for stmt in &program.body {
1962        if let Statement::ExportDefaultDeclaration(decl) = stmt
1963            && let Some(expr) = decl.declaration.as_expression()
1964        {
1965            return array_from_expression(expr);
1966        }
1967    }
1968    None
1969}
1970
1971fn array_from_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
1972    match expr {
1973        Expression::ArrayExpression(arr) => Some(arr),
1974        Expression::ParenthesizedExpression(paren) => array_from_expression(&paren.expression),
1975        Expression::TSAsExpression(ts_as) => array_from_expression(&ts_as.expression),
1976        Expression::TSSatisfiesExpression(ts_sat) => array_from_expression(&ts_sat.expression),
1977        Expression::CallExpression(call) => call
1978            .arguments
1979            .first()
1980            .and_then(Argument::as_expression)
1981            .and_then(array_from_expression),
1982        _ => None,
1983    }
1984}
1985
1986pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
1987    let mut normalized = PathBuf::new();
1988
1989    for component in path.components() {
1990        match component {
1991            std::path::Component::CurDir => {}
1992            std::path::Component::ParentDir => {
1993                normalized.pop();
1994            }
1995            _ => normalized.push(component.as_os_str()),
1996        }
1997    }
1998
1999    normalized
2000}
2001
2002/// Convert an expression to a string array if it's an array of string literals.
2003fn expression_to_string_array(expr: &Expression) -> Vec<String> {
2004    match expr {
2005        Expression::ArrayExpression(arr) => arr
2006            .elements
2007            .iter()
2008            .filter_map(|el| match el {
2009                ArrayExpressionElement::SpreadElement(_) => None,
2010                _ => el.as_expression().and_then(expression_to_string),
2011            })
2012            .collect(),
2013        _ => vec![],
2014    }
2015}
2016
2017/// Collect only top-level string values from an expression.
2018///
2019/// For arrays, extracts direct string elements and the first string element of sub-arrays
2020/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
2021fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
2022    let mut values = Vec::new();
2023    match expr {
2024        Expression::StringLiteral(s) => {
2025            values.push(s.value.to_string());
2026        }
2027        Expression::ArrayExpression(arr) => {
2028            for el in &arr.elements {
2029                if let Some(inner) = el.as_expression() {
2030                    match inner {
2031                        Expression::StringLiteral(s) => {
2032                            values.push(s.value.to_string());
2033                        }
2034                        Expression::ArrayExpression(sub_arr) => {
2035                            if let Some(first) = sub_arr.elements.first()
2036                                && let Some(first_expr) = first.as_expression()
2037                                && let Some(s) = expression_to_string(first_expr)
2038                            {
2039                                values.push(s);
2040                            }
2041                        }
2042                        _ => {}
2043                    }
2044                }
2045            }
2046        }
2047        Expression::ObjectExpression(obj) => {
2048            for prop in &obj.properties {
2049                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2050                    match &p.value {
2051                        Expression::StringLiteral(s) => {
2052                            values.push(s.value.to_string());
2053                        }
2054                        Expression::ArrayExpression(sub_arr) => {
2055                            if let Some(first) = sub_arr.elements.first()
2056                                && let Some(first_expr) = first.as_expression()
2057                                && let Some(s) = expression_to_string(first_expr)
2058                            {
2059                                values.push(s);
2060                            }
2061                        }
2062                        _ => {}
2063                    }
2064                }
2065            }
2066        }
2067        _ => {}
2068    }
2069    values
2070}
2071
2072/// Collect top-level string values, plus a named string property from object entries.
2073fn collect_shallow_string_or_object_property_values(
2074    expr: &Expression,
2075    object_property: &str,
2076) -> Vec<String> {
2077    match expr {
2078        Expression::ArrayExpression(arr) => arr
2079            .elements
2080            .iter()
2081            .filter_map(|element| {
2082                element
2083                    .as_expression()
2084                    .and_then(|expr| shallow_string_or_object_property(expr, object_property))
2085            })
2086            .collect(),
2087        _ => shallow_string_or_object_property(expr, object_property)
2088            .into_iter()
2089            .collect(),
2090    }
2091}
2092
2093fn shallow_string_or_object_property(expr: &Expression, object_property: &str) -> Option<String> {
2094    match expr {
2095        Expression::ParenthesizedExpression(paren) => {
2096            shallow_string_or_object_property(&paren.expression, object_property)
2097        }
2098        Expression::TSSatisfiesExpression(ts_sat) => {
2099            shallow_string_or_object_property(&ts_sat.expression, object_property)
2100        }
2101        Expression::TSAsExpression(ts_as) => {
2102            shallow_string_or_object_property(&ts_as.expression, object_property)
2103        }
2104        Expression::ArrayExpression(sub_arr) => sub_arr
2105            .elements
2106            .first()
2107            .and_then(ArrayExpressionElement::as_expression)
2108            .and_then(expression_to_string),
2109        Expression::ObjectExpression(obj) => {
2110            find_property(obj, object_property).and_then(|prop| expression_to_string(&prop.value))
2111        }
2112        _ => expression_to_string(expr),
2113    }
2114}
2115
2116/// Recursively collect all string literal values from an expression tree.
2117fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
2118    match expr {
2119        Expression::StringLiteral(s) => {
2120            values.push(s.value.to_string());
2121        }
2122        Expression::ArrayExpression(arr) => {
2123            for el in &arr.elements {
2124                if let Some(expr) = el.as_expression() {
2125                    collect_all_string_values(expr, values);
2126                }
2127            }
2128        }
2129        Expression::ObjectExpression(obj) => {
2130            for prop in &obj.properties {
2131                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2132                    collect_all_string_values(&p.value, values);
2133                }
2134            }
2135        }
2136        _ => {}
2137    }
2138}
2139
2140/// Convert a `PropertyKey` to a `String`.
2141fn property_key_to_string(key: &PropertyKey) -> Option<String> {
2142    match key {
2143        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
2144        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
2145        _ => None,
2146    }
2147}
2148
2149/// Extract keys of an object at a nested property path.
2150fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2151    if path.is_empty() {
2152        return None;
2153    }
2154    let prop = find_property(obj, path[0])?;
2155    if path.len() == 1 {
2156        if let Expression::ObjectExpression(nested) = &prop.value {
2157            let keys = nested
2158                .properties
2159                .iter()
2160                .filter_map(|p| {
2161                    if let ObjectPropertyKind::ObjectProperty(p) = p {
2162                        property_key_to_string(&p.key)
2163                    } else {
2164                        None
2165                    }
2166                })
2167                .collect();
2168            return Some(keys);
2169        }
2170        return None;
2171    }
2172    if let Expression::ObjectExpression(nested) = &prop.value {
2173        get_nested_object_keys(nested, &path[1..])
2174    } else {
2175        None
2176    }
2177}
2178
2179/// Navigate a nested property path and return the raw expression at the end.
2180fn get_nested_expression<'a>(
2181    obj: &'a ObjectExpression<'a>,
2182    path: &[&str],
2183) -> Option<&'a Expression<'a>> {
2184    if path.is_empty() {
2185        return None;
2186    }
2187    let prop = find_property(obj, path[0])?;
2188    if path.len() == 1 {
2189        return Some(&prop.value);
2190    }
2191    if let Expression::ObjectExpression(nested) = &prop.value {
2192        get_nested_expression(nested, &path[1..])
2193    } else {
2194        None
2195    }
2196}
2197
2198/// Navigate a nested path and extract a string, string array, or object string/array values.
2199fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2200    if path.is_empty() {
2201        return None;
2202    }
2203    if path.len() == 1 {
2204        let prop = find_property(obj, path[0])?;
2205        return Some(expression_to_string_or_array(&prop.value));
2206    }
2207    let prop = find_property(obj, path[0])?;
2208    if let Expression::ObjectExpression(nested) = &prop.value {
2209        get_nested_string_or_array(nested, &path[1..])
2210    } else {
2211        None
2212    }
2213}
2214
2215/// Convert an expression to a `Vec<String>`, handling string, array, object-with-string/array values,
2216/// and Webpack 5 entry descriptors (`{ import: "..." }`).
2217///
2218/// Array elements that are object literals are inspected for an `input` property
2219/// (Angular CLI schema for `styles`/`scripts`/`polyfills`:
2220/// `{ "input": "src/x.scss", "bundleName": "x", "inject": false }`). Extracting
2221/// `input` prevents object-form entries from being silently dropped. See #126.
2222fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
2223    match expr {
2224        Expression::StringLiteral(s) => vec![s.value.to_string()],
2225        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
2226            .quasis
2227            .first()
2228            .map(|q| vec![q.value.raw.to_string()])
2229            .unwrap_or_default(),
2230        Expression::ArrayExpression(arr) => arr
2231            .elements
2232            .iter()
2233            .filter_map(|el| el.as_expression())
2234            .flat_map(|e| match e {
2235                Expression::ObjectExpression(obj) => find_property(obj, "input")
2236                    .map(|p| expression_to_string_or_array(&p.value))
2237                    .unwrap_or_default(),
2238                _ => expression_to_path_string(e).into_iter().collect(),
2239            })
2240            .collect(),
2241        Expression::ObjectExpression(obj) => obj
2242            .properties
2243            .iter()
2244            .flat_map(|p| {
2245                if let ObjectPropertyKind::ObjectProperty(p) = p {
2246                    match &p.value {
2247                        Expression::ArrayExpression(_) => expression_to_string_or_array(&p.value),
2248                        Expression::ObjectExpression(value_obj) => {
2249                            find_property(value_obj, "import")
2250                                .map(|import_prop| {
2251                                    expression_to_string_or_array(&import_prop.value)
2252                                })
2253                                .unwrap_or_default()
2254                        }
2255                        _ => expression_to_path_string(&p.value).into_iter().collect(),
2256                    }
2257                } else {
2258                    Vec::new()
2259                }
2260            })
2261            .collect(),
2262        _ => expression_to_path_string(expr).into_iter().collect(),
2263    }
2264}
2265
2266/// Collect `require('...')` argument strings from an expression.
2267fn collect_require_sources(expr: &Expression) -> Vec<String> {
2268    let mut sources = Vec::new();
2269    match expr {
2270        Expression::CallExpression(call) if is_require_call(call) => {
2271            if let Some(s) = get_require_source(call) {
2272                sources.push(s);
2273            }
2274        }
2275        Expression::ArrayExpression(arr) => {
2276            for el in &arr.elements {
2277                if let Some(inner) = el.as_expression() {
2278                    match inner {
2279                        Expression::CallExpression(call) if is_require_call(call) => {
2280                            if let Some(s) = get_require_source(call) {
2281                                sources.push(s);
2282                            }
2283                        }
2284                        Expression::ArrayExpression(sub_arr) => {
2285                            if let Some(first) = sub_arr.elements.first()
2286                                && let Some(Expression::CallExpression(call)) =
2287                                    first.as_expression()
2288                                && is_require_call(call)
2289                                && let Some(s) = get_require_source(call)
2290                            {
2291                                sources.push(s);
2292                            }
2293                        }
2294                        _ => {}
2295                    }
2296                }
2297            }
2298        }
2299        _ => {}
2300    }
2301    sources
2302}
2303
2304/// Check if a call expression is `require(...)`.
2305fn is_require_call(call: &CallExpression) -> bool {
2306    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
2307}
2308
2309/// Get the first string argument of a `require()` call.
2310fn get_require_source(call: &CallExpression) -> Option<String> {
2311    call.arguments.first().and_then(|arg| {
2312        if let Argument::StringLiteral(s) = arg {
2313            Some(s.value.to_string())
2314        } else {
2315            None
2316        }
2317    })
2318}
2319
2320#[cfg(test)]
2321mod tests {
2322    use super::*;
2323    use std::path::PathBuf;
2324
2325    fn js_path() -> PathBuf {
2326        PathBuf::from("config.js")
2327    }
2328
2329    fn ts_path() -> PathBuf {
2330        PathBuf::from("config.ts")
2331    }
2332
2333    #[test]
2334    fn extract_lazy_imports_bare_arrows() {
2335        let source = r"
2336            import { defineConfig } from '@adonisjs/core/app'
2337            export default defineConfig({
2338                preloads: [
2339                    () => import('#start/routes'),
2340                    () => import('#start/kernel'),
2341                ],
2342            })
2343        ";
2344        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["preloads"]);
2345        assert_eq!(specs, vec!["#start/routes", "#start/kernel"]);
2346    }
2347
2348    #[test]
2349    fn extract_lazy_imports_object_form_with_file_key() {
2350        let source = r"
2351            export default defineConfig({
2352                providers: [
2353                    () => import('@adonisjs/core/providers/app_provider'),
2354                    {
2355                        file: () => import('@adonisjs/core/providers/repl_provider'),
2356                        environment: ['repl', 'test'],
2357                    },
2358                ],
2359            })
2360        ";
2361        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2362        assert_eq!(
2363            specs,
2364            vec![
2365                "@adonisjs/core/providers/app_provider",
2366                "@adonisjs/core/providers/repl_provider",
2367            ]
2368        );
2369    }
2370
2371    #[test]
2372    fn extract_lazy_imports_block_body_with_return() {
2373        let source = r"
2374            export default defineConfig({
2375                commands: [
2376                    () => { return import('@adonisjs/core/commands') },
2377                ],
2378            })
2379        ";
2380        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2381        assert_eq!(specs, vec!["@adonisjs/core/commands"]);
2382    }
2383
2384    #[test]
2385    fn extract_lazy_imports_skips_unknown_element_shapes() {
2386        let source = r"
2387            export default defineConfig({
2388                commands: [
2389                    'string-entry',
2390                    42,
2391                    { other: 'value' },
2392                    () => import('@adonisjs/lucid/commands'),
2393                ],
2394            })
2395        ";
2396        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2397        assert_eq!(specs, vec!["@adonisjs/lucid/commands"]);
2398    }
2399
2400    #[test]
2401    fn extract_lazy_imports_missing_property_returns_empty() {
2402        let source = r"
2403            export default defineConfig({
2404                preloads: [() => import('#start/routes')],
2405            })
2406        ";
2407        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2408        assert!(specs.is_empty());
2409    }
2410
2411    #[test]
2412    fn extract_imports_basic() {
2413        let source = r"
2414            import foo from 'foo-pkg';
2415            import { bar } from '@scope/bar';
2416            export default {};
2417        ";
2418        let imports = extract_imports(source, &js_path());
2419        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
2420    }
2421
2422    #[test]
2423    fn extract_default_export_object_property() {
2424        let source = r#"export default { testDir: "./tests" };"#;
2425        let val = extract_config_string(source, &js_path(), &["testDir"]);
2426        assert_eq!(val, Some("./tests".to_string()));
2427    }
2428
2429    #[test]
2430    fn extract_define_config_property() {
2431        let source = r#"
2432            import { defineConfig } from 'vitest/config';
2433            export default defineConfig({
2434                test: {
2435                    include: ["**/*.test.ts", "**/*.spec.ts"],
2436                    setupFiles: ["./test/setup.ts"]
2437                }
2438            });
2439        "#;
2440        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
2441        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
2442
2443        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
2444        assert_eq!(setup, vec!["./test/setup.ts"]);
2445    }
2446
2447    #[test]
2448    fn extract_module_exports_property() {
2449        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
2450        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
2451        assert_eq!(val, Some("jsdom".to_string()));
2452    }
2453
2454    #[test]
2455    fn extract_nested_string_array() {
2456        let source = r#"
2457            export default {
2458                resolve: {
2459                    alias: {
2460                        "@": "./src"
2461                    }
2462                },
2463                test: {
2464                    include: ["src/**/*.test.ts"]
2465                }
2466            };
2467        "#;
2468        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
2469        assert_eq!(include, vec!["src/**/*.test.ts"]);
2470    }
2471
2472    #[test]
2473    fn extract_addons_array() {
2474        let source = r#"
2475            export default {
2476                addons: [
2477                    "@storybook/addon-a11y",
2478                    "@storybook/addon-docs",
2479                    "@storybook/addon-links"
2480                ]
2481            };
2482        "#;
2483        let addons = extract_config_property_strings(source, &ts_path(), "addons");
2484        assert_eq!(
2485            addons,
2486            vec![
2487                "@storybook/addon-a11y",
2488                "@storybook/addon-docs",
2489                "@storybook/addon-links"
2490            ]
2491        );
2492    }
2493
2494    #[test]
2495    fn handle_empty_config() {
2496        let source = "";
2497        let result = extract_config_string(source, &js_path(), &["key"]);
2498        assert_eq!(result, None);
2499    }
2500
2501    #[test]
2502    fn object_keys_postcss_plugins() {
2503        let source = r"
2504            module.exports = {
2505                plugins: {
2506                    autoprefixer: {},
2507                    tailwindcss: {},
2508                    'postcss-import': {}
2509                }
2510            };
2511        ";
2512        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2513        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
2514    }
2515
2516    #[test]
2517    fn object_keys_nested_path() {
2518        let source = r"
2519            export default {
2520                build: {
2521                    plugins: {
2522                        minify: {},
2523                        compress: {}
2524                    }
2525                }
2526            };
2527        ";
2528        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
2529        assert_eq!(keys, vec!["minify", "compress"]);
2530    }
2531
2532    #[test]
2533    fn object_keys_empty_object() {
2534        let source = r"export default { plugins: {} };";
2535        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2536        assert!(keys.is_empty());
2537    }
2538
2539    #[test]
2540    fn object_keys_non_object_returns_empty() {
2541        let source = r#"export default { plugins: ["a", "b"] };"#;
2542        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2543        assert!(keys.is_empty());
2544    }
2545
2546    #[test]
2547    fn string_or_array_single_string() {
2548        let source = r#"export default { entry: "./src/index.js" };"#;
2549        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2550        assert_eq!(result, vec!["./src/index.js"]);
2551    }
2552
2553    #[test]
2554    fn string_or_array_array() {
2555        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
2556        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2557        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
2558    }
2559
2560    #[test]
2561    fn string_or_array_object_values() {
2562        let source =
2563            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
2564        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2565        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
2566    }
2567
2568    #[test]
2569    fn string_or_array_object_array_values() {
2570        let source = r#"export default { entry: { app: ["./src/polyfill.js", "./src/app.js"] } };"#;
2571        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2572        assert_eq!(result, vec!["./src/polyfill.js", "./src/app.js"]);
2573    }
2574
2575    #[test]
2576    fn string_or_array_webpack_entry_descriptors() {
2577        let source = r#"
2578            export default {
2579                entry: {
2580                    app: {
2581                        import: "./src/app.js",
2582                        filename: "pages/app.js",
2583                        dependOn: "shared",
2584                    },
2585                    admin: {
2586                        import: ["./src/admin-polyfill.js", "./src/admin.js"],
2587                        runtime: "runtime",
2588                    },
2589                    shared: ["react", "react-dom"],
2590                },
2591            };
2592        "#;
2593        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2594        assert_eq!(
2595            result,
2596            vec![
2597                "./src/app.js",
2598                "./src/admin-polyfill.js",
2599                "./src/admin.js",
2600                "react",
2601                "react-dom"
2602            ]
2603        );
2604    }
2605
2606    #[test]
2607    fn string_or_array_nested_path() {
2608        let source = r#"
2609            export default {
2610                build: {
2611                    rollupOptions: {
2612                        input: ["./index.html", "./about.html"]
2613                    }
2614                }
2615            };
2616        "#;
2617        let result = extract_config_string_or_array(
2618            source,
2619            &js_path(),
2620            &["build", "rollupOptions", "input"],
2621        );
2622        assert_eq!(result, vec!["./index.html", "./about.html"]);
2623    }
2624
2625    #[test]
2626    fn string_or_array_template_literal() {
2627        let source = r"export default { entry: `./src/index.js` };";
2628        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2629        assert_eq!(result, vec!["./src/index.js"]);
2630    }
2631
2632    #[test]
2633    fn string_or_array_object_path_helper_values() {
2634        let source = r#"
2635            import { resolve, join } from "node:path";
2636            import path from "node:path";
2637            export default {
2638                build: {
2639                    rollupOptions: {
2640                        input: {
2641                            app: resolve(__dirname, "src/app.ts"),
2642                            modal: path.resolve(__dirname, "src/modal.ts"),
2643                            tabs: join(__dirname, "src/tabs.ts"),
2644                            styles: resolve(__dirname, "src/index.css"),
2645                        },
2646                    },
2647                },
2648            };
2649        "#;
2650        let result = extract_config_string_or_array(
2651            source,
2652            &js_path(),
2653            &["build", "rollupOptions", "input"],
2654        );
2655        assert_eq!(
2656            result,
2657            vec!["src/app.ts", "src/modal.ts", "src/tabs.ts", "src/index.css"]
2658        );
2659    }
2660
2661    #[test]
2662    fn string_or_array_array_path_helper_values() {
2663        let source = r#"
2664            import { resolve } from "node:path";
2665            export default {
2666                build: {
2667                    rollupOptions: {
2668                        input: [resolve(__dirname, "src/a.ts"), "./src/b.ts"],
2669                    },
2670                },
2671            };
2672        "#;
2673        let result = extract_config_string_or_array(
2674            source,
2675            &js_path(),
2676            &["build", "rollupOptions", "input"],
2677        );
2678        assert_eq!(result, vec!["src/a.ts", "./src/b.ts"]);
2679    }
2680
2681    #[test]
2682    fn string_or_array_top_level_path_helper_call() {
2683        let source = r#"
2684            import { resolve } from "node:path";
2685            export default { build: { lib: { entry: resolve(__dirname, "src/index.ts") } } };
2686        "#;
2687        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2688        assert_eq!(result, vec!["src/index.ts"]);
2689    }
2690
2691    #[test]
2692    fn string_or_array_import_meta_dirname_anchor() {
2693        let source = r#"
2694            import { resolve } from "node:path";
2695            export default {
2696                build: { lib: { entry: resolve(import.meta.dirname, "src/index.ts") } },
2697            };
2698        "#;
2699        let result = extract_config_string_or_array(source, &ts_path(), &["build", "lib", "entry"]);
2700        assert_eq!(result, vec!["src/index.ts"]);
2701    }
2702
2703    #[test]
2704    fn string_or_array_non_literal_path_helper_args_dropped() {
2705        let source = r#"
2706            import { resolve } from "node:path";
2707            export default { build: { lib: { entry: resolve(baseDir, "src/index.ts") } } };
2708        "#;
2709        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2710        assert!(
2711            result.is_empty(),
2712            "non-literal path-helper args must be dropped: {result:?}"
2713        );
2714    }
2715
2716    #[test]
2717    fn require_strings_array() {
2718        let source = r"
2719            module.exports = {
2720                plugins: [
2721                    require('autoprefixer'),
2722                    require('postcss-import')
2723                ]
2724            };
2725        ";
2726        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2727        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
2728    }
2729
2730    #[test]
2731    fn require_strings_with_tuples() {
2732        let source = r"
2733            module.exports = {
2734                plugins: [
2735                    require('autoprefixer'),
2736                    [require('postcss-preset-env'), { stage: 3 }]
2737                ]
2738            };
2739        ";
2740        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2741        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
2742    }
2743
2744    #[test]
2745    fn require_strings_empty_array() {
2746        let source = r"module.exports = { plugins: [] };";
2747        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2748        assert!(deps.is_empty());
2749    }
2750
2751    #[test]
2752    fn require_strings_no_require_calls() {
2753        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
2754        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2755        assert!(deps.is_empty());
2756    }
2757
2758    #[test]
2759    fn extract_aliases_from_object_with_file_url_to_path() {
2760        let source = r#"
2761            import { defineConfig } from 'vite';
2762            import { fileURLToPath, URL } from 'node:url';
2763
2764            export default defineConfig({
2765                resolve: {
2766                    alias: {
2767                        "@": fileURLToPath(new URL("./src", import.meta.url))
2768                    }
2769                }
2770            });
2771        "#;
2772
2773        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2774        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
2775    }
2776
2777    #[test]
2778    fn extract_aliases_from_array_form() {
2779        let source = r#"
2780            export default {
2781                resolve: {
2782                    alias: [
2783                        { find: "@", replacement: "./src" },
2784                        { find: "$utils", replacement: "src/lib/utils" }
2785                    ]
2786                }
2787            };
2788        "#;
2789
2790        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2791        assert_eq!(
2792            aliases,
2793            vec![
2794                ("@".to_string(), "./src".to_string()),
2795                ("$utils".to_string(), "src/lib/utils".to_string())
2796            ]
2797        );
2798    }
2799
2800    #[test]
2801    fn extract_aliases_from_object_with_array_values() {
2802        let source = r#"
2803            ({
2804                compilerOptions: {
2805                    paths: {
2806                        "@/*": ["./src/*"],
2807                        "@shared/*": ["./shared/*", "./fallback/*"]
2808                    }
2809                }
2810            })
2811        "#;
2812
2813        let aliases = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
2814        assert_eq!(
2815            aliases,
2816            vec![
2817                ("@/*".to_string(), "./src/*".to_string()),
2818                ("@shared/*".to_string(), "./shared/*".to_string())
2819            ]
2820        );
2821    }
2822
2823    #[test]
2824    fn extract_array_object_strings_mixed_forms() {
2825        let source = r#"
2826            export default {
2827                components: [
2828                    "~/components",
2829                    { path: "@/feature-components" }
2830                ]
2831            };
2832        "#;
2833
2834        let values =
2835            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
2836        assert_eq!(
2837            values,
2838            vec![
2839                "~/components".to_string(),
2840                "@/feature-components".to_string()
2841            ]
2842        );
2843    }
2844
2845    #[test]
2846    fn extract_array_object_string_pairs_with_and_without_secondary() {
2847        let source = r#"
2848            export default {
2849                webServer: [
2850                    { command: "tsx scripts/api.ts", cwd: "packages/api" },
2851                    { command: "tsx scripts/web.ts" }
2852                ]
2853            };
2854        "#;
2855
2856        let pairs = extract_config_array_object_string_pairs(
2857            source,
2858            &ts_path(),
2859            &["webServer"],
2860            "command",
2861            "cwd",
2862        );
2863        assert_eq!(
2864            pairs,
2865            vec![
2866                (
2867                    "tsx scripts/api.ts".to_string(),
2868                    Some("packages/api".to_string())
2869                ),
2870                ("tsx scripts/web.ts".to_string(), None),
2871            ]
2872        );
2873    }
2874
2875    #[test]
2876    fn extract_array_object_string_pairs_skips_elements_missing_primary() {
2877        let source = r#"
2878            export default {
2879                webServer: [
2880                    { cwd: "packages/api" },
2881                    { command: "srvx --port 3000" }
2882                ]
2883            };
2884        "#;
2885
2886        let pairs = extract_config_array_object_string_pairs(
2887            source,
2888            &ts_path(),
2889            &["webServer"],
2890            "command",
2891            "cwd",
2892        );
2893        assert_eq!(pairs, vec![("srvx --port 3000".to_string(), None)]);
2894    }
2895
2896    #[test]
2897    fn extract_array_object_string_pairs_empty_for_object_form() {
2898        let source = r#"
2899            export default {
2900                webServer: { command: "srvx --port 3000" }
2901            };
2902        "#;
2903
2904        let pairs = extract_config_array_object_string_pairs(
2905            source,
2906            &ts_path(),
2907            &["webServer"],
2908            "command",
2909            "cwd",
2910        );
2911        assert!(pairs.is_empty());
2912    }
2913
2914    #[test]
2915    fn extract_config_plugin_option_string_from_json() {
2916        let source = r#"{
2917            "expo": {
2918                "plugins": [
2919                    ["expo-router", { "root": "src/app" }]
2920                ]
2921            }
2922        }"#;
2923
2924        let value = extract_config_plugin_option_string(
2925            source,
2926            &json_path(),
2927            &["expo", "plugins"],
2928            "expo-router",
2929            "root",
2930        );
2931
2932        assert_eq!(value, Some("src/app".to_string()));
2933    }
2934
2935    #[test]
2936    fn extract_config_plugin_option_string_from_top_level_plugins() {
2937        let source = r#"{
2938            "plugins": [
2939                ["expo-router", { "root": "./src/routes" }]
2940            ]
2941        }"#;
2942
2943        let value = extract_config_plugin_option_string_from_paths(
2944            source,
2945            &json_path(),
2946            &[&["plugins"], &["expo", "plugins"]],
2947            "expo-router",
2948            "root",
2949        );
2950
2951        assert_eq!(value, Some("./src/routes".to_string()));
2952    }
2953
2954    #[test]
2955    fn extract_config_plugin_option_string_from_ts_config() {
2956        let source = r"
2957            export default {
2958                expo: {
2959                    plugins: [
2960                        ['expo-router', { root: './src/app' }]
2961                    ]
2962                }
2963            };
2964        ";
2965
2966        let value = extract_config_plugin_option_string(
2967            source,
2968            &ts_path(),
2969            &["expo", "plugins"],
2970            "expo-router",
2971            "root",
2972        );
2973
2974        assert_eq!(value, Some("./src/app".to_string()));
2975    }
2976
2977    #[test]
2978    fn extract_config_plugin_option_string_returns_none_when_plugin_missing() {
2979        let source = r#"{
2980            "expo": {
2981                "plugins": [
2982                    ["expo-font", {}]
2983                ]
2984            }
2985        }"#;
2986
2987        let value = extract_config_plugin_option_string(
2988            source,
2989            &json_path(),
2990            &["expo", "plugins"],
2991            "expo-router",
2992            "root",
2993        );
2994
2995        assert_eq!(value, None);
2996    }
2997
2998    #[test]
2999    fn vite_react_babel_dependencies_extract_plain_tuple_and_prefixed_entries() {
3000        let source = r#"
3001            import react from "@vitejs/plugin-react";
3002
3003            export default defineConfig({
3004                plugins: [
3005                    react({
3006                        babel: {
3007                            plugins: [
3008                                "babel-plugin-plain",
3009                                ["module:@preact/signals-react-transform", { mode: "auto" }],
3010                            ],
3011                            presets: [["@babel/preset-react", { runtime: "automatic" }]],
3012                        },
3013                    }),
3014                ],
3015            });
3016        "#;
3017
3018        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3019
3020        assert_eq!(
3021            deps,
3022            vec![
3023                "babel-plugin-plain".to_string(),
3024                "@preact/signals-react-transform".to_string(),
3025                "@babel/preset-react".to_string(),
3026            ]
3027        );
3028    }
3029
3030    #[test]
3031    fn vite_react_babel_dependencies_support_default_alias_import() {
3032        let source = r#"
3033            import { default as viteReact } from "@vitejs/plugin-react";
3034
3035            export default {
3036                plugins: [
3037                    viteReact({
3038                        babel: {
3039                            plugins: [["module:@scope/pkg/plugin", {}]],
3040                        },
3041                    }),
3042                ],
3043            };
3044        "#;
3045
3046        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3047
3048        assert_eq!(deps, vec!["@scope/pkg".to_string()]);
3049    }
3050
3051    #[test]
3052    fn vite_react_babel_dependencies_ignore_unrelated_plugin_calls() {
3053        let source = r#"
3054            import vue from "@vitejs/plugin-vue";
3055
3056            export default {
3057                plugins: [
3058                    vue({
3059                        babel: {
3060                            plugins: ["@preact/signals-react-transform"],
3061                        },
3062                    }),
3063                ],
3064            };
3065        "#;
3066
3067        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3068
3069        assert!(deps.is_empty());
3070    }
3071
3072    #[test]
3073    fn vite_react_babel_dependencies_skip_relative_and_protocol_entries() {
3074        let source = r#"
3075            import react from "@vitejs/plugin-react";
3076
3077            export default {
3078                plugins: [
3079                    react({
3080                        babel: {
3081                            plugins: ["./local-plugin", "module:./local-prefixed", "http://example.com/plugin"],
3082                        },
3083                    }),
3084                ],
3085            };
3086        "#;
3087
3088        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3089
3090        assert!(deps.is_empty());
3091    }
3092
3093    #[test]
3094    fn normalize_config_path_relative_to_root() {
3095        let config_path = PathBuf::from("/project/vite.config.ts");
3096        let root = PathBuf::from("/project");
3097
3098        assert_eq!(
3099            normalize_config_path("./src/lib", &config_path, &root),
3100            Some("src/lib".to_string())
3101        );
3102        assert_eq!(
3103            normalize_config_path("/src/lib", &config_path, &root),
3104            Some("src/lib".to_string())
3105        );
3106    }
3107
3108    #[test]
3109    fn normalize_config_path_mixed_separators_and_parent_dirs() {
3110        let config_path = PathBuf::from("/project/config/vite.config.ts");
3111        let root = PathBuf::from("/project");
3112
3113        assert_eq!(
3114            normalize_config_path(".\\src\\..\\app\\lib", &config_path, &root),
3115            Some("config/app/lib".to_string())
3116        );
3117    }
3118
3119    #[test]
3120    fn normalize_config_path_leading_slash_stays_project_relative() {
3121        let config_path = PathBuf::from("/project/vite.config.ts");
3122        let root = PathBuf::from("/project");
3123
3124        assert_eq!(
3125            normalize_config_path("/src\\lib", &config_path, &root),
3126            Some("src/lib".to_string())
3127        );
3128    }
3129
3130    #[test]
3131    fn json_wrapped_in_parens_string() {
3132        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
3133        let val = extract_config_string(source, &js_path(), &["extends"]);
3134        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
3135    }
3136
3137    #[test]
3138    fn json_wrapped_in_parens_nested_array() {
3139        let source =
3140            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
3141        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
3142        assert_eq!(types, vec!["node", "jest"]);
3143
3144        let include = extract_config_string_array(source, &js_path(), &["include"]);
3145        assert_eq!(include, vec!["src/**/*"]);
3146    }
3147
3148    #[test]
3149    fn json_wrapped_in_parens_object_keys() {
3150        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
3151        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3152        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
3153    }
3154
3155    fn json_path() -> PathBuf {
3156        PathBuf::from("config.json")
3157    }
3158
3159    #[test]
3160    fn json_file_parsed_correctly() {
3161        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
3162        let val = extract_config_string(source, &json_path(), &["key"]);
3163        assert_eq!(val, Some("value".to_string()));
3164
3165        let list = extract_config_string_array(source, &json_path(), &["list"]);
3166        assert_eq!(list, vec!["a", "b"]);
3167    }
3168
3169    #[test]
3170    fn jsonc_file_parsed_correctly() {
3171        let source = r#"{"key": "value"}"#;
3172        let path = PathBuf::from("tsconfig.jsonc");
3173        let val = extract_config_string(source, &path, &["key"]);
3174        assert_eq!(val, Some("value".to_string()));
3175    }
3176
3177    #[test]
3178    fn extract_define_config_arrow_function() {
3179        let source = r#"
3180            import { defineConfig } from 'vite';
3181            export default defineConfig(() => ({
3182                test: {
3183                    include: ["**/*.test.ts"]
3184                }
3185            }));
3186        "#;
3187        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3188        assert_eq!(include, vec!["**/*.test.ts"]);
3189    }
3190
3191    #[test]
3192    fn extract_config_from_default_export_function_declaration() {
3193        let source = r#"
3194            export default function createConfig() {
3195                return {
3196                    clientModules: ["./src/client/global.js"]
3197                };
3198            }
3199        "#;
3200
3201        let client_modules = extract_config_string_array(source, &ts_path(), &["clientModules"]);
3202        assert_eq!(client_modules, vec!["./src/client/global.js"]);
3203    }
3204
3205    #[test]
3206    fn extract_config_from_default_export_async_function_declaration() {
3207        let source = r#"
3208            export default async function createConfigAsync() {
3209                return {
3210                    docs: {
3211                        path: "knowledge"
3212                    }
3213                };
3214            }
3215        "#;
3216
3217        let docs_path = extract_config_string(source, &ts_path(), &["docs", "path"]);
3218        assert_eq!(docs_path, Some("knowledge".to_string()));
3219    }
3220
3221    #[test]
3222    fn extract_config_from_exported_arrow_function_identifier() {
3223        let source = r#"
3224            const config = async () => {
3225                return {
3226                    themes: ["classic"]
3227                };
3228            };
3229
3230            export default config;
3231        "#;
3232
3233        let themes = extract_config_shallow_strings(source, &ts_path(), "themes");
3234        assert_eq!(themes, vec!["classic"]);
3235    }
3236
3237    #[test]
3238    fn module_exports_nested_string() {
3239        let source = r#"
3240            module.exports = {
3241                resolve: {
3242                    alias: {
3243                        "@": "./src"
3244                    }
3245                }
3246            };
3247        "#;
3248        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
3249        assert_eq!(val, Some("./src".to_string()));
3250    }
3251
3252    #[test]
3253    fn property_strings_nested_objects() {
3254        let source = r#"
3255            export default {
3256                plugins: {
3257                    group1: { a: "val-a" },
3258                    group2: { b: "val-b" }
3259                }
3260            };
3261        "#;
3262        let values = extract_config_property_strings(source, &js_path(), "plugins");
3263        assert!(values.contains(&"val-a".to_string()));
3264        assert!(values.contains(&"val-b".to_string()));
3265    }
3266
3267    #[test]
3268    fn property_strings_missing_key_returns_empty() {
3269        let source = r#"export default { other: "value" };"#;
3270        let values = extract_config_property_strings(source, &js_path(), "missing");
3271        assert!(values.is_empty());
3272    }
3273
3274    #[test]
3275    fn shallow_strings_tuple_array() {
3276        let source = r#"
3277            module.exports = {
3278                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
3279            };
3280        "#;
3281        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
3282        assert_eq!(values, vec!["default", "jest-junit"]);
3283        assert!(!values.contains(&"reports".to_string()));
3284    }
3285
3286    #[test]
3287    fn shallow_strings_single_string() {
3288        let source = r#"export default { preset: "ts-jest" };"#;
3289        let values = extract_config_shallow_strings(source, &js_path(), "preset");
3290        assert_eq!(values, vec!["ts-jest"]);
3291    }
3292
3293    #[test]
3294    fn shallow_strings_missing_key() {
3295        let source = r#"export default { other: "val" };"#;
3296        let values = extract_config_shallow_strings(source, &js_path(), "missing");
3297        assert!(values.is_empty());
3298    }
3299
3300    #[test]
3301    fn shallow_strings_or_object_property_alias_objects() {
3302        let source = r#"
3303            export default {
3304                jsPlugins: [
3305                    "eslint-plugin-playwright",
3306                    ["eslint-plugin-regexp", { rules: {} }],
3307                    { name: "short", specifier: "eslint-plugin-with-long-name" }
3308                ]
3309            };
3310        "#;
3311        let values = extract_config_shallow_strings_or_object_property(
3312            source,
3313            &ts_path(),
3314            "jsPlugins",
3315            "specifier",
3316        );
3317        assert_eq!(
3318            values,
3319            vec![
3320                "eslint-plugin-playwright",
3321                "eslint-plugin-regexp",
3322                "eslint-plugin-with-long-name"
3323            ]
3324        );
3325    }
3326
3327    #[test]
3328    fn nested_shallow_strings_vitest_reporters() {
3329        let source = r#"
3330            export default {
3331                test: {
3332                    reporters: ["default", "vitest-sonar-reporter"]
3333                }
3334            };
3335        "#;
3336        let values =
3337            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3338        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3339    }
3340
3341    #[test]
3342    fn nested_shallow_strings_tuple_format() {
3343        let source = r#"
3344            export default {
3345                test: {
3346                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
3347                }
3348            };
3349        "#;
3350        let values =
3351            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3352        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3353    }
3354
3355    #[test]
3356    fn nested_shallow_strings_missing_outer() {
3357        let source = r"export default { other: {} };";
3358        let values =
3359            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3360        assert!(values.is_empty());
3361    }
3362
3363    #[test]
3364    fn nested_shallow_strings_missing_inner() {
3365        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
3366        let values =
3367            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3368        assert!(values.is_empty());
3369    }
3370
3371    #[test]
3372    fn string_or_array_missing_path() {
3373        let source = r"export default {};";
3374        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3375        assert!(result.is_empty());
3376    }
3377
3378    #[test]
3379    fn string_or_array_non_string_values() {
3380        let source = r"export default { entry: [42, true] };";
3381        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3382        assert!(result.is_empty());
3383    }
3384
3385    #[test]
3386    fn array_nested_extraction() {
3387        let source = r#"
3388            export default defineConfig({
3389                test: {
3390                    projects: [
3391                        {
3392                            test: {
3393                                setupFiles: ["./test/setup-a.ts"]
3394                            }
3395                        },
3396                        {
3397                            test: {
3398                                setupFiles: "./test/setup-b.ts"
3399                            }
3400                        }
3401                    ]
3402                }
3403            });
3404        "#;
3405        let results = extract_config_array_nested_string_or_array(
3406            source,
3407            &ts_path(),
3408            &["test", "projects"],
3409            &["test", "setupFiles"],
3410        );
3411        assert!(results.contains(&"./test/setup-a.ts".to_string()));
3412        assert!(results.contains(&"./test/setup-b.ts".to_string()));
3413    }
3414
3415    #[test]
3416    fn array_nested_empty_when_no_array() {
3417        let source = r#"export default { test: { projects: "not-an-array" } };"#;
3418        let results = extract_config_array_nested_string_or_array(
3419            source,
3420            &js_path(),
3421            &["test", "projects"],
3422            &["test", "setupFiles"],
3423        );
3424        assert!(results.is_empty());
3425    }
3426
3427    #[test]
3428    fn object_nested_extraction() {
3429        let source = r#"{
3430            "projects": {
3431                "app-one": {
3432                    "architect": {
3433                        "build": {
3434                            "options": {
3435                                "styles": ["src/styles.css"]
3436                            }
3437                        }
3438                    }
3439                }
3440            }
3441        }"#;
3442        let results = extract_config_object_nested_string_or_array(
3443            source,
3444            &json_path(),
3445            &["projects"],
3446            &["architect", "build", "options", "styles"],
3447        );
3448        assert_eq!(results, vec!["src/styles.css"]);
3449    }
3450
3451    #[test]
3452    fn array_with_object_input_form_extracted() {
3453        let source = r#"{
3454            "projects": {
3455                "app": {
3456                    "architect": {
3457                        "build": {
3458                            "options": {
3459                                "styles": [
3460                                    "src/styles.scss",
3461                                    { "input": "src/theme.scss", "bundleName": "theme", "inject": false },
3462                                    { "bundleName": "lazy-only" }
3463                                ]
3464                            }
3465                        }
3466                    }
3467                }
3468            }
3469        }"#;
3470        let results = extract_config_object_nested_string_or_array(
3471            source,
3472            &json_path(),
3473            &["projects"],
3474            &["architect", "build", "options", "styles"],
3475        );
3476        assert!(
3477            results.contains(&"src/styles.scss".to_string()),
3478            "string form must still work: {results:?}"
3479        );
3480        assert!(
3481            results.contains(&"src/theme.scss".to_string()),
3482            "object form with `input` must be extracted: {results:?}"
3483        );
3484        assert!(
3485            !results.contains(&"lazy-only".to_string()),
3486            "bundleName must not be misinterpreted as a path: {results:?}"
3487        );
3488        assert!(
3489            !results.contains(&"theme".to_string()),
3490            "bundleName from full object must not leak: {results:?}"
3491        );
3492    }
3493
3494    #[test]
3495    fn object_nested_strings_extraction() {
3496        let source = r#"{
3497            "targets": {
3498                "build": {
3499                    "executor": "@angular/build:application"
3500                },
3501                "test": {
3502                    "executor": "@nx/vite:test"
3503                }
3504            }
3505        }"#;
3506        let results =
3507            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
3508        assert!(results.contains(&"@angular/build:application".to_string()));
3509        assert!(results.contains(&"@nx/vite:test".to_string()));
3510    }
3511
3512    #[test]
3513    fn require_strings_direct_call() {
3514        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
3515        let deps = extract_config_require_strings(source, &js_path(), "adapter");
3516        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
3517    }
3518
3519    #[test]
3520    fn require_strings_no_matching_key() {
3521        let source = r"module.exports = { other: require('something') };";
3522        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3523        assert!(deps.is_empty());
3524    }
3525
3526    #[test]
3527    fn extract_imports_no_imports() {
3528        let source = r"export default {};";
3529        let imports = extract_imports(source, &js_path());
3530        assert!(imports.is_empty());
3531    }
3532
3533    #[test]
3534    fn extract_imports_side_effect_import() {
3535        let source = r"
3536            import 'polyfill';
3537            import './local-setup';
3538            export default {};
3539        ";
3540        let imports = extract_imports(source, &js_path());
3541        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
3542    }
3543
3544    #[test]
3545    fn extract_imports_mixed_specifiers() {
3546        let source = r"
3547            import defaultExport from 'module-a';
3548            import { named } from 'module-b';
3549            import * as ns from 'module-c';
3550            export default {};
3551        ";
3552        let imports = extract_imports(source, &js_path());
3553        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
3554    }
3555
3556    #[test]
3557    fn template_literal_in_string_or_array() {
3558        let source = r"export default { entry: `./src/index.ts` };";
3559        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
3560        assert_eq!(result, vec!["./src/index.ts"]);
3561    }
3562
3563    #[test]
3564    fn template_literal_in_config_string() {
3565        let source = r"export default { testDir: `./tests` };";
3566        let val = extract_config_string(source, &js_path(), &["testDir"]);
3567        assert_eq!(val, Some("./tests".to_string()));
3568    }
3569
3570    #[test]
3571    fn template_literal_command_recovers_static_command_tokens() {
3572        let source = r"
3573            const PORT = 3000;
3574            export default {
3575                webServer: {
3576                    command: `pnpm exec srvx --port ${PORT} --hostname 127.0.0.1`
3577                }
3578            };
3579        ";
3580        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3581        assert_eq!(
3582            val,
3583            Some("pnpm exec srvx --port   --hostname 127.0.0.1".to_string())
3584        );
3585    }
3586
3587    #[test]
3588    fn template_literal_command_skips_dynamic_prefix() {
3589        let source = r"
3590            export default {
3591                webServer: { command: `${serverCommand} && pnpm exec srvx` }
3592            };
3593        ";
3594        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3595        assert!(val.is_none());
3596    }
3597
3598    #[test]
3599    fn template_literal_command_skips_split_static_token() {
3600        let source = r"
3601            export default {
3602                webServer: { command: `pnpm exec sr${part}vx --port 3000` }
3603            };
3604        ";
3605        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3606        assert!(val.is_none());
3607    }
3608
3609    #[test]
3610    fn array_object_command_pairs_recover_template_command() {
3611        let source = r"
3612            const PORT = 3000;
3613            export default {
3614                webServer: [
3615                    {
3616                        command: `pnpm exec srvx --port ${PORT}`,
3617                        cwd: 'apps/web'
3618                    }
3619                ]
3620            };
3621        ";
3622        let pairs = extract_config_array_object_command_pairs(
3623            source,
3624            &ts_path(),
3625            &["webServer"],
3626            "command",
3627            "cwd",
3628        );
3629        assert_eq!(
3630            pairs,
3631            vec![(
3632                "pnpm exec srvx --port  ".to_string(),
3633                Some("apps/web".to_string())
3634            )]
3635        );
3636    }
3637
3638    #[test]
3639    fn nested_string_array_empty_path() {
3640        let source = r#"export default { items: ["a", "b"] };"#;
3641        let result = extract_config_string_array(source, &js_path(), &[]);
3642        assert!(result.is_empty());
3643    }
3644
3645    #[test]
3646    fn nested_string_empty_path() {
3647        let source = r#"export default { key: "val" };"#;
3648        let result = extract_config_string(source, &js_path(), &[]);
3649        assert!(result.is_none());
3650    }
3651
3652    #[test]
3653    fn object_keys_empty_path() {
3654        let source = r"export default { plugins: {} };";
3655        let result = extract_config_object_keys(source, &js_path(), &[]);
3656        assert!(result.is_empty());
3657    }
3658
3659    #[test]
3660    fn no_config_object_returns_empty() {
3661        let source = r"const x = 42;";
3662        let result = extract_config_string(source, &js_path(), &["key"]);
3663        assert!(result.is_none());
3664
3665        let arr = extract_config_string_array(source, &js_path(), &["items"]);
3666        assert!(arr.is_empty());
3667
3668        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3669        assert!(keys.is_empty());
3670    }
3671
3672    #[test]
3673    fn property_with_string_key() {
3674        let source = r#"export default { "string-key": "value" };"#;
3675        let val = extract_config_string(source, &js_path(), &["string-key"]);
3676        assert_eq!(val, Some("value".to_string()));
3677    }
3678
3679    #[test]
3680    fn nested_navigation_through_non_object() {
3681        let source = r#"export default { level1: "not-an-object" };"#;
3682        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
3683        assert!(val.is_none());
3684    }
3685
3686    #[test]
3687    fn variable_reference_untyped() {
3688        let source = r#"
3689            const config = {
3690                testDir: "./tests"
3691            };
3692            export default config;
3693        "#;
3694        let val = extract_config_string(source, &js_path(), &["testDir"]);
3695        assert_eq!(val, Some("./tests".to_string()));
3696    }
3697
3698    #[test]
3699    fn variable_reference_with_type_annotation() {
3700        let source = r#"
3701            import type { StorybookConfig } from '@storybook/react-vite';
3702            const config: StorybookConfig = {
3703                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
3704                framework: "@storybook/react-vite"
3705            };
3706            export default config;
3707        "#;
3708        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
3709        assert_eq!(
3710            addons,
3711            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
3712        );
3713
3714        let framework = extract_config_string(source, &ts_path(), &["framework"]);
3715        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
3716    }
3717
3718    #[test]
3719    fn variable_reference_with_define_config() {
3720        let source = r#"
3721            import { defineConfig } from 'vitest/config';
3722            const config = defineConfig({
3723                test: {
3724                    include: ["**/*.test.ts"]
3725                }
3726            });
3727            export default config;
3728        "#;
3729        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3730        assert_eq!(include, vec!["**/*.test.ts"]);
3731    }
3732
3733    #[test]
3734    fn ts_satisfies_direct_export() {
3735        let source = r#"
3736            export default {
3737                testDir: "./tests"
3738            } satisfies PlaywrightTestConfig;
3739        "#;
3740        let val = extract_config_string(source, &ts_path(), &["testDir"]);
3741        assert_eq!(val, Some("./tests".to_string()));
3742    }
3743
3744    #[test]
3745    fn ts_as_direct_export() {
3746        let source = r#"
3747            export default {
3748                testDir: "./tests"
3749            } as const;
3750        "#;
3751        let val = extract_config_string(source, &ts_path(), &["testDir"]);
3752        assert_eq!(val, Some("./tests".to_string()));
3753    }
3754
3755    // --- issue #811: resolve.alias as imported identifier / spread ---
3756
3757    fn aliases(source: &str) -> Vec<(String, String)> {
3758        extract_config_aliases(source, &js_path(), &["resolve", "alias"])
3759    }
3760
3761    #[test]
3762    fn aliases_inline_object_still_extracted() {
3763        // Regression: the resolver must not change inline-object behavior.
3764        let source = r#"
3765            export default defineConfig({
3766                resolve: { alias: { "@": "./src", utils: "../../utils" } }
3767            });
3768        "#;
3769        let mut got = aliases(source);
3770        got.sort();
3771        assert_eq!(
3772            got,
3773            vec![
3774                ("@".to_string(), "./src".to_string()),
3775                ("utils".to_string(), "../../utils".to_string()),
3776            ]
3777        );
3778    }
3779
3780    #[test]
3781    fn aliases_inline_array_still_extracted() {
3782        let source = r#"
3783            export default defineConfig({
3784                resolve: { alias: [{ find: "@", replacement: "./src" }] }
3785            });
3786        "#;
3787        assert_eq!(
3788            aliases(source),
3789            vec![("@".to_string(), "./src".to_string())]
3790        );
3791    }
3792
3793    #[test]
3794    fn aliases_local_const_array_identifier() {
3795        let source = r#"
3796            const sharedAliases = [{ find: "@", replacement: "./src" }];
3797            export default defineConfig({ resolve: { alias: sharedAliases } });
3798        "#;
3799        assert_eq!(
3800            aliases(source),
3801            vec![("@".to_string(), "./src".to_string())]
3802        );
3803    }
3804
3805    #[test]
3806    fn aliases_local_const_object_identifier() {
3807        let source = r#"
3808            const sharedAliases = { "@": "./src" };
3809            export default defineConfig({ resolve: { alias: sharedAliases } });
3810        "#;
3811        assert_eq!(
3812            aliases(source),
3813            vec![("@".to_string(), "./src".to_string())]
3814        );
3815    }
3816
3817    #[test]
3818    fn aliases_array_spread_of_identifiers_and_inline() {
3819        let source = r##"
3820            const a = [{ find: "@", replacement: "./src" }];
3821            const b = [{ find: "~", replacement: "./lib" }];
3822            export default defineConfig({
3823                resolve: { alias: [...a, ...b, { find: "#", replacement: "./test" }] }
3824            });
3825        "##;
3826        let mut got = aliases(source);
3827        got.sort();
3828        assert_eq!(
3829            got,
3830            vec![
3831                ("#".to_string(), "./test".to_string()),
3832                ("@".to_string(), "./src".to_string()),
3833                ("~".to_string(), "./lib".to_string()),
3834            ]
3835        );
3836    }
3837
3838    #[test]
3839    fn aliases_object_spread_of_identifier_and_inline() {
3840        let source = r#"
3841            const base = { "@": "./src" };
3842            export default defineConfig({
3843                resolve: { alias: { ...base, "~": "./lib" } }
3844            });
3845        "#;
3846        let mut got = aliases(source);
3847        got.sort();
3848        assert_eq!(
3849            got,
3850            vec![
3851                ("@".to_string(), "./src".to_string()),
3852                ("~".to_string(), "./lib".to_string()),
3853            ]
3854        );
3855    }
3856
3857    #[test]
3858    fn aliases_local_const_chained_identifier() {
3859        // `const a = b` indirection resolves through the chain.
3860        let source = r#"
3861            const real = [{ find: "@", replacement: "./src" }];
3862            const alias2 = real;
3863            export default defineConfig({ resolve: { alias: alias2 } });
3864        "#;
3865        assert_eq!(
3866            aliases(source),
3867            vec![("@".to_string(), "./src".to_string())]
3868        );
3869    }
3870
3871    #[test]
3872    fn aliases_imported_named_identifier_from_sibling() {
3873        let dir = tempfile::tempdir().unwrap();
3874        std::fs::write(
3875            dir.path().join("vite.shared.js"),
3876            r#"export const sharedAliases = [
3877                { find: "@", replacement: new URL("./src", import.meta.url).pathname },
3878            ];"#,
3879        )
3880        .unwrap();
3881        let config = dir.path().join("vite.config.js");
3882        let source = r#"
3883            import { defineConfig } from "vite";
3884            import { sharedAliases } from "./vite.shared.js";
3885            export default defineConfig({ resolve: { alias: sharedAliases } });
3886        "#;
3887        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3888        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3889    }
3890
3891    #[test]
3892    fn aliases_imported_extensionless_specifier_probed() {
3893        let dir = tempfile::tempdir().unwrap();
3894        std::fs::write(
3895            dir.path().join("aliases.mjs"),
3896            r#"export const sharedAliases = { "@": "./src" };"#,
3897        )
3898        .unwrap();
3899        let config = dir.path().join("vite.config.ts");
3900        let source = r#"
3901            import { sharedAliases } from "./aliases";
3902            export default defineConfig({ resolve: { alias: sharedAliases } });
3903        "#;
3904        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3905        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3906    }
3907
3908    #[test]
3909    fn aliases_imported_default_export_from_sibling() {
3910        let dir = tempfile::tempdir().unwrap();
3911        std::fs::write(
3912            dir.path().join("aliases.js"),
3913            r#"export default [{ find: "@", replacement: "./src" }];"#,
3914        )
3915        .unwrap();
3916        let config = dir.path().join("vite.config.js");
3917        let source = r#"
3918            import sharedAliases from "./aliases.js";
3919            export default defineConfig({ resolve: { alias: sharedAliases } });
3920        "#;
3921        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3922        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3923    }
3924
3925    #[test]
3926    fn aliases_imported_spread_from_two_siblings() {
3927        let dir = tempfile::tempdir().unwrap();
3928        std::fs::write(
3929            dir.path().join("a.js"),
3930            r#"export const a = [{ find: "@", replacement: "./src" }];"#,
3931        )
3932        .unwrap();
3933        std::fs::write(
3934            dir.path().join("b.js"),
3935            r#"export const b = [{ find: "~", replacement: "./lib" }];"#,
3936        )
3937        .unwrap();
3938        let config = dir.path().join("vite.config.js");
3939        let source = r#"
3940            import { a } from "./a.js";
3941            import { b } from "./b.js";
3942            export default defineConfig({ resolve: { alias: [...a, ...b] } });
3943        "#;
3944        let mut got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3945        got.sort();
3946        assert_eq!(
3947            got,
3948            vec![
3949                ("@".to_string(), "./src".to_string()),
3950                ("~".to_string(), "./lib".to_string()),
3951            ]
3952        );
3953    }
3954
3955    #[test]
3956    fn aliases_import_cycle_terminates() {
3957        // a.js imports from b.js and vice versa; resolution must not hang and
3958        // should still recover the literal pairs present.
3959        let dir = tempfile::tempdir().unwrap();
3960        std::fs::write(
3961            dir.path().join("a.js"),
3962            r#"import { b } from "./b.js";
3963               export const a = [{ find: "@", replacement: "./src" }, ...b];"#,
3964        )
3965        .unwrap();
3966        std::fs::write(
3967            dir.path().join("b.js"),
3968            r#"import { a } from "./a.js";
3969               export const b = [...a];"#,
3970        )
3971        .unwrap();
3972        let config = dir.path().join("vite.config.js");
3973        let source = r#"
3974            import { a } from "./a.js";
3975            export default defineConfig({ resolve: { alias: a } });
3976        "#;
3977        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3978        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3979    }
3980
3981    #[test]
3982    fn aliases_non_relative_import_not_followed() {
3983        // A bare-package import is intentionally out of scope: no node_modules
3984        // read for an alias literal.
3985        let source = r#"
3986            import { sharedAliases } from "some-pkg";
3987            export default defineConfig({ resolve: { alias: sharedAliases } });
3988        "#;
3989        let dir = tempfile::tempdir().unwrap();
3990        let config = dir.path().join("vite.config.js");
3991        assert!(extract_config_aliases(source, &config, &["resolve", "alias"]).is_empty());
3992    }
3993
3994    #[test]
3995    fn aliases_object_array_value_takes_first_entry() {
3996        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets;
3997        // the resolver must take the first, matching the long-standing non-kinded
3998        // behavior the TypeScript plugin depends on. Regression guard for the
3999        // array-value case that the kinded unification briefly dropped.
4000        let source = r#"
4001            export default {
4002                compilerOptions: { paths: { "@/*": ["./src/*"], "~/*": ["./lib/*", "./vendor/*"] } }
4003            };
4004        "#;
4005        let mut got = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
4006        got.sort();
4007        assert_eq!(
4008            got,
4009            vec![
4010                ("@/*".to_string(), "./src/*".to_string()),
4011                ("~/*".to_string(), "./lib/*".to_string()),
4012            ]
4013        );
4014    }
4015
4016    #[test]
4017    fn aliases_kinded_preserves_is_bare_through_resolution() {
4018        // The bare-string vs path discriminator must survive identifier + spread
4019        // resolution (the test.alias package-to-package gate depends on it).
4020        let source = r#"
4021            const a = [{ find: "lodash-es", replacement: "lodash" }];
4022            export default defineConfig({
4023                resolve: { alias: [...a, { find: "@", replacement: "./src" }] }
4024            });
4025        "#;
4026        let mut got = extract_config_aliases_kinded(source, &js_path(), &["resolve", "alias"]);
4027        got.sort();
4028        assert_eq!(
4029            got,
4030            vec![
4031                ("@".to_string(), "./src".to_string(), false),
4032                ("lodash-es".to_string(), "lodash".to_string(), true),
4033            ]
4034        );
4035    }
4036
4037    #[test]
4038    fn aliases_kinded_preserves_is_bare_through_imported_spread() {
4039        let dir = tempfile::tempdir().unwrap();
4040        std::fs::write(
4041            dir.path().join("aliases.js"),
4042            r#"export const packageAliases = [{ find: "lodash-es", replacement: "lodash" }];"#,
4043        )
4044        .unwrap();
4045        let config = dir.path().join("vite.config.js");
4046        let source = r#"
4047            import { packageAliases } from "./aliases.js";
4048            export default defineConfig({
4049                resolve: { alias: [...packageAliases, { find: "@", replacement: "./src" }] }
4050            });
4051        "#;
4052        let mut got = extract_config_aliases_kinded(source, &config, &["resolve", "alias"]);
4053        got.sort();
4054        assert_eq!(
4055            got,
4056            vec![
4057                ("@".to_string(), "./src".to_string(), false),
4058                ("lodash-es".to_string(), "lodash".to_string(), true),
4059            ]
4060        );
4061    }
4062
4063    // --- extract_config_command ---
4064
4065    #[test]
4066    fn extract_command_string_literal() {
4067        let source = r#"export default { start: "node server.js" };"#;
4068        let val = extract_config_command(source, &js_path(), &["start"]);
4069        assert_eq!(val, Some("node server.js".to_string()));
4070    }
4071
4072    #[test]
4073    fn extract_command_nested_path() {
4074        let source = r#"
4075            export default {
4076                scripts: {
4077                    dev: "vite dev"
4078                }
4079            };
4080        "#;
4081        let val = extract_config_command(source, &js_path(), &["scripts", "dev"]);
4082        assert_eq!(val, Some("vite dev".to_string()));
4083    }
4084
4085    #[test]
4086    fn extract_command_missing_key_returns_none() {
4087        let source = r#"export default { other: "val" };"#;
4088        let val = extract_config_command(source, &js_path(), &["start"]);
4089        assert!(val.is_none());
4090    }
4091
4092    #[test]
4093    fn extract_command_ts_as_expression() {
4094        let source = r#"export default { start: "node server.js" as string };"#;
4095        let val = extract_config_command(source, &ts_path(), &["start"]);
4096        assert_eq!(val, Some("node server.js".to_string()));
4097    }
4098
4099    #[test]
4100    fn extract_command_ts_satisfies_expression() {
4101        let source = r#"export default { start: "node server.js" satisfies string };"#;
4102        let val = extract_config_command(source, &ts_path(), &["start"]);
4103        assert_eq!(val, Some("node server.js".to_string()));
4104    }
4105
4106    #[test]
4107    fn extract_command_parenthesized_expression() {
4108        let source = r#"export default { start: ("node server.js") };"#;
4109        let val = extract_config_command(source, &js_path(), &["start"]);
4110        assert_eq!(val, Some("node server.js".to_string()));
4111    }
4112
4113    #[test]
4114    fn extract_command_empty_path_returns_none() {
4115        let source = r#"export default { start: "node server.js" };"#;
4116        let val = extract_config_command(source, &js_path(), &[]);
4117        assert!(val.is_none());
4118    }
4119
4120    // --- is_disabled_expression and extract_config_truthy_bool_or_object ---
4121
4122    #[test]
4123    fn truthy_bool_or_object_with_true_value() {
4124        let source = r"export default { typescript: true };";
4125        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4126        assert!(result);
4127    }
4128
4129    #[test]
4130    fn truthy_bool_or_object_with_false_value() {
4131        let source = r"export default { typescript: false };";
4132        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4133        assert!(!result);
4134    }
4135
4136    #[test]
4137    fn truthy_bool_or_object_with_object_value() {
4138        let source = r#"export default { typescript: { reactDocgen: "react-docgen" } };"#;
4139        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4140        assert!(result);
4141    }
4142
4143    #[test]
4144    fn truthy_bool_or_object_missing_key_returns_false() {
4145        let source = r"export default { other: true };";
4146        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4147        assert!(!result);
4148    }
4149
4150    #[test]
4151    fn truthy_bool_or_object_with_string_value_returns_false() {
4152        // A string is neither bool true nor object, so the else arm returns false.
4153        let source = r#"export default { typescript: "yes" };"#;
4154        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4155        assert!(!result);
4156    }
4157
4158    #[test]
4159    fn truthy_bool_or_object_ts_satisfies_wrapper() {
4160        let source = r"export default { typescript: (true satisfies boolean) };";
4161        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4162        assert!(result);
4163    }
4164
4165    #[test]
4166    fn truthy_bool_or_object_ts_as_wrapper() {
4167        let source = r"export default { typescript: (true as boolean) };";
4168        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4169        assert!(result);
4170    }
4171
4172    #[test]
4173    fn truthy_bool_or_object_parenthesized_wrapper() {
4174        let source = r"export default { typescript: (true) };";
4175        let result = extract_config_truthy_bool_or_object(source, &ts_path(), &["typescript"]);
4176        assert!(result);
4177    }
4178
4179    // --- object_expression helper: exercises via static dir entries property_string ---
4180    // property_object calls object_expression; it is also exercised through
4181    // extract_object_from_expression, which handles TS wrappers at the top-export level.
4182    // The ts_satisfies_direct_export / ts_as_direct_export tests already cover those arms.
4183
4184    #[test]
4185    fn static_dir_entries_object_form_exercises_property_string() {
4186        // property_string (which calls property_expr then expression_to_string) is used
4187        // for the `from` and `to` keys in extract_config_static_dir_entries.
4188        let source = r#"
4189            export default {
4190                staticDirs: [
4191                    { from: "./media", to: "/assets" }
4192                ]
4193            };
4194        "#;
4195        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4196        assert_eq!(
4197            entries,
4198            vec![("./media".to_string(), Some("/assets".to_string()))]
4199        );
4200    }
4201
4202    // --- expression_to_path_values (array form) ---
4203
4204    #[test]
4205    fn expression_to_path_values_array_form_via_config_path() {
4206        // The extract_config_path helper uses expression_to_path; path_values
4207        // is exercised when the value is an array via extract_config_string_or_array.
4208        let source = r#"export default { entries: ["./src/a.ts", "./src/b.ts"] };"#;
4209        let result = extract_config_string_or_array(source, &js_path(), &["entries"]);
4210        assert_eq!(result, vec!["./src/a.ts", "./src/b.ts"]);
4211    }
4212
4213    // --- extract_config_array_nested_aliases ---
4214
4215    #[test]
4216    fn array_nested_aliases_object_form() {
4217        let source = r#"
4218            export default {
4219                test: {
4220                    projects: [
4221                        {
4222                            resolve: {
4223                                alias: { "@": "./src" }
4224                            }
4225                        }
4226                    ]
4227                }
4228            };
4229        "#;
4230        let aliases = extract_config_array_nested_aliases(
4231            source,
4232            &ts_path(),
4233            &["test", "projects"],
4234            &["resolve", "alias"],
4235        );
4236        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
4237    }
4238
4239    #[test]
4240    fn array_nested_aliases_array_form_find_replacement() {
4241        let source = r#"
4242            export default {
4243                projects: [
4244                    {
4245                        resolve: {
4246                            alias: [
4247                                { find: "@", replacement: "./src" },
4248                                { find: "~", replacement: "./lib" }
4249                            ]
4250                        }
4251                    }
4252                ]
4253            };
4254        "#;
4255        let aliases = extract_config_array_nested_aliases(
4256            source,
4257            &ts_path(),
4258            &["projects"],
4259            &["resolve", "alias"],
4260        );
4261        assert_eq!(
4262            aliases,
4263            vec![
4264                ("@".to_string(), "./src".to_string()),
4265                ("~".to_string(), "./lib".to_string()),
4266            ]
4267        );
4268    }
4269
4270    #[test]
4271    fn array_nested_aliases_empty_when_path_is_not_array() {
4272        let source = r#"export default { test: { projects: "not-an-array" } };"#;
4273        let aliases = extract_config_array_nested_aliases(
4274            source,
4275            &ts_path(),
4276            &["test", "projects"],
4277            &["resolve", "alias"],
4278        );
4279        assert!(aliases.is_empty());
4280    }
4281
4282    #[test]
4283    fn array_nested_aliases_kinded_tracks_is_bare() {
4284        let source = r#"
4285            export default {
4286                projects: [
4287                    {
4288                        resolve: {
4289                            alias: [
4290                                { find: "lodash-es", replacement: "lodash" },
4291                                { find: "@", replacement: "./src" }
4292                            ]
4293                        }
4294                    }
4295                ]
4296            };
4297        "#;
4298        let mut aliases = extract_config_array_nested_aliases_kinded(
4299            source,
4300            &ts_path(),
4301            &["projects"],
4302            &["resolve", "alias"],
4303        );
4304        aliases.sort();
4305        assert_eq!(
4306            aliases,
4307            vec![
4308                ("@".to_string(), "./src".to_string(), false),
4309                ("lodash-es".to_string(), "lodash".to_string(), true),
4310            ]
4311        );
4312    }
4313
4314    // --- extract_default_export_array_aliases_kinded ---
4315
4316    #[test]
4317    fn default_export_array_aliases_kinded_extracts_from_workspace_config() {
4318        let source = r#"
4319            export default [
4320                {
4321                    resolve: {
4322                        alias: { "@": "./src" }
4323                    }
4324                },
4325                {
4326                    resolve: {
4327                        alias: [{ find: "~", replacement: "./lib" }]
4328                    }
4329                }
4330            ];
4331        "#;
4332        let mut aliases =
4333            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4334        aliases.sort();
4335        assert_eq!(
4336            aliases,
4337            vec![
4338                ("@".to_string(), "./src".to_string(), false),
4339                ("~".to_string(), "./lib".to_string(), false),
4340            ]
4341        );
4342    }
4343
4344    #[test]
4345    fn default_export_array_aliases_kinded_define_workspace_wrapper() {
4346        let source = r#"
4347            export default defineWorkspace([
4348                {
4349                    resolve: { alias: { "@": "./src" } }
4350                }
4351            ]);
4352        "#;
4353        let aliases =
4354            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4355        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
4356    }
4357
4358    #[test]
4359    fn default_export_array_aliases_kinded_empty_when_no_alias_path() {
4360        let source = r#"
4361            export default [
4362                { test: { include: ["**/*.test.ts"] } }
4363            ];
4364        "#;
4365        let aliases =
4366            extract_default_export_array_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4367        assert!(aliases.is_empty());
4368    }
4369
4370    // --- config_default_export_unreachable ---
4371
4372    #[test]
4373    fn config_default_export_unreachable_when_no_export() {
4374        let source = r"const x = 42;";
4375        assert!(config_default_export_unreachable(source, &js_path()));
4376    }
4377
4378    #[test]
4379    fn config_default_export_unreachable_false_for_object_export() {
4380        let source = r#"export default { key: "value" };"#;
4381        assert!(!config_default_export_unreachable(source, &js_path()));
4382    }
4383
4384    #[test]
4385    fn config_default_export_unreachable_false_for_array_export() {
4386        let source = r#"export default ["a", "b"];"#;
4387        assert!(!config_default_export_unreachable(source, &js_path()));
4388    }
4389
4390    #[test]
4391    fn config_default_export_unreachable_true_for_function_without_return_object() {
4392        // A function that returns a number is unreachable.
4393        let source = r"export default function config() { return 42; }";
4394        assert!(config_default_export_unreachable(source, &js_path()));
4395    }
4396
4397    // --- extract_config_static_dir_entries ---
4398
4399    #[test]
4400    fn static_dir_entries_string_and_object_form() {
4401        let source = r#"
4402            export default {
4403                staticDirs: [
4404                    "./public",
4405                    { from: "../assets", to: "/static" }
4406                ]
4407            };
4408        "#;
4409        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4410        assert_eq!(
4411            entries,
4412            vec![
4413                ("./public".to_string(), None),
4414                ("../assets".to_string(), Some("/static".to_string())),
4415            ]
4416        );
4417    }
4418
4419    #[test]
4420    fn static_dir_entries_object_without_to() {
4421        let source = r#"
4422            export default {
4423                staticDirs: [
4424                    { from: "./media" }
4425                ]
4426            };
4427        "#;
4428        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4429        assert_eq!(entries, vec![("./media".to_string(), None)]);
4430    }
4431
4432    #[test]
4433    fn static_dir_entries_object_missing_from_skipped() {
4434        // Objects without a `from` key are silently skipped.
4435        let source = r#"
4436            export default {
4437                staticDirs: [
4438                    { to: "/target" },
4439                    "./public"
4440                ]
4441            };
4442        "#;
4443        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4444        assert_eq!(entries, vec![("./public".to_string(), None)]);
4445    }
4446
4447    #[test]
4448    fn static_dir_entries_empty_when_not_array() {
4449        let source = r#"export default { staticDirs: "./public" };"#;
4450        let entries = extract_config_static_dir_entries(source, &ts_path(), &["staticDirs"]);
4451        assert!(entries.is_empty());
4452    }
4453
4454    // --- expression_to_alias_pairs and expression_to_alias_pairs_kinded (lines 1473-1541) ---
4455
4456    #[test]
4457    fn aliases_array_form_missing_find_or_replacement_skipped() {
4458        // An element missing "find" or "replacement" is silently skipped.
4459        let source = r#"
4460            export default {
4461                resolve: {
4462                    alias: [
4463                        { replacement: "./src" },
4464                        { find: "@" },
4465                        { find: "~", replacement: "./lib" }
4466                    ]
4467                }
4468            };
4469        "#;
4470        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4471        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
4472    }
4473
4474    #[test]
4475    fn aliases_object_form_computed_key_skipped() {
4476        // Computed keys (expression keys) are not statically recoverable.
4477        let source = r#"
4478            const k = "@";
4479            export default {
4480                resolve: {
4481                    alias: {
4482                        [k]: "./src",
4483                        "~": "./lib"
4484                    }
4485                }
4486            };
4487        "#;
4488        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4489        // Only the literal key "~" survives; computed [k] is dropped.
4490        assert_eq!(aliases, vec![("~".to_string(), "./lib".to_string())]);
4491    }
4492
4493    #[test]
4494    fn aliases_kinded_array_form_path_replacement_is_not_bare() {
4495        let source = r#"
4496            export default {
4497                resolve: {
4498                    alias: [{ find: "@", replacement: "./src" }]
4499                }
4500            };
4501        "#;
4502        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4503        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string(), false)]);
4504    }
4505
4506    #[test]
4507    fn aliases_kinded_object_form_bare_and_path_discrimination() {
4508        let source = r#"
4509            export default {
4510                resolve: {
4511                    alias: {
4512                        "lodash-es": "lodash",
4513                        "@": "./src"
4514                    }
4515                }
4516            };
4517        "#;
4518        let mut aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4519        aliases.sort();
4520        assert_eq!(
4521            aliases,
4522            vec![
4523                ("@".to_string(), "./src".to_string(), false),
4524                ("lodash-es".to_string(), "lodash".to_string(), true),
4525            ]
4526        );
4527    }
4528
4529    #[test]
4530    fn aliases_kinded_parent_relative_replacement_is_not_bare() {
4531        let source = r#"
4532            export default {
4533                resolve: { alias: { "@": "../shared/src" } }
4534            };
4535        "#;
4536        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4537        assert_eq!(
4538            aliases,
4539            vec![("@".to_string(), "../shared/src".to_string(), false)]
4540        );
4541    }
4542
4543    #[test]
4544    fn aliases_kinded_absolute_replacement_is_not_bare() {
4545        let source = r#"
4546            export default {
4547                resolve: { alias: { "@": "/absolute/path" } }
4548            };
4549        "#;
4550        let aliases = extract_config_aliases_kinded(source, &ts_path(), &["resolve", "alias"]);
4551        assert_eq!(
4552            aliases,
4553            vec![("@".to_string(), "/absolute/path".to_string(), false)]
4554        );
4555    }
4556
4557    // --- find_default_export_array / array_from_expression wrappers ---
4558
4559    #[test]
4560    fn default_export_array_ts_as_wrapper() {
4561        // array_from_expression must unwrap TSAsExpression.
4562        let source = r"export default [] as string[];";
4563        assert!(!config_default_export_unreachable(source, &js_path()));
4564    }
4565
4566    #[test]
4567    fn default_export_array_ts_satisfies_wrapper() {
4568        let source = r"export default [] satisfies string[];";
4569        assert!(!config_default_export_unreachable(source, &ts_path()));
4570    }
4571
4572    #[test]
4573    fn default_export_array_define_config_call_wrapper() {
4574        let source = r#"export default defineConfig(["**/*.test.ts"]);"#;
4575        assert!(!config_default_export_unreachable(source, &ts_path()));
4576    }
4577
4578    // --- collect_shallow_string_values: object-property branches ---
4579
4580    #[test]
4581    fn shallow_strings_object_with_string_values() {
4582        // The ObjectExpression arm of collect_shallow_string_values emits string values.
4583        let source = r#"
4584            export default {
4585                plugins: {
4586                    autoprefixer: "autoprefixer",
4587                    tailwindcss: "tailwindcss"
4588                }
4589            };
4590        "#;
4591        let vals = extract_config_shallow_strings(source, &js_path(), "plugins");
4592        assert!(vals.contains(&"autoprefixer".to_string()));
4593        assert!(vals.contains(&"tailwindcss".to_string()));
4594    }
4595
4596    #[test]
4597    fn shallow_strings_object_with_sub_array_first_element() {
4598        // An object property whose value is an array emits the first string element.
4599        let source = r#"
4600            export default {
4601                reporters: {
4602                    main: ["jest-junit", { outputFile: "report.xml" }],
4603                    alt: ["html-reporter"]
4604                }
4605            };
4606        "#;
4607        let vals = extract_config_shallow_strings(source, &js_path(), "reporters");
4608        assert!(vals.contains(&"jest-junit".to_string()));
4609        assert!(vals.contains(&"html-reporter".to_string()));
4610    }
4611
4612    // --- collect_shallow_string_or_object_property_values ---
4613
4614    #[test]
4615    fn shallow_strings_or_object_property_non_array_single_string() {
4616        // When the top-level value is a plain string (not an array), it is returned directly.
4617        let source = r#"export default { jsPlugins: "eslint-plugin-foo" };"#;
4618        let vals = extract_config_shallow_strings_or_object_property(
4619            source,
4620            &ts_path(),
4621            "jsPlugins",
4622            "specifier",
4623        );
4624        assert_eq!(vals, vec!["eslint-plugin-foo"]);
4625    }
4626
4627    #[test]
4628    fn shallow_strings_or_object_property_ts_satisfies_array_element() {
4629        // shallow_string_or_object_property unwraps TSSatisfiesExpression.
4630        let source = r#"
4631            export default {
4632                jsPlugins: [
4633                    ("eslint-plugin-a" satisfies string)
4634                ]
4635            };
4636        "#;
4637        let vals = extract_config_shallow_strings_or_object_property(
4638            source,
4639            &ts_path(),
4640            "jsPlugins",
4641            "specifier",
4642        );
4643        assert_eq!(vals, vec!["eslint-plugin-a"]);
4644    }
4645
4646    #[test]
4647    fn shallow_strings_or_object_property_ts_as_array_element() {
4648        let source = r#"
4649            export default {
4650                jsPlugins: [
4651                    ("eslint-plugin-b" as string)
4652                ]
4653            };
4654        "#;
4655        let vals = extract_config_shallow_strings_or_object_property(
4656            source,
4657            &ts_path(),
4658            "jsPlugins",
4659            "specifier",
4660        );
4661        assert_eq!(vals, vec!["eslint-plugin-b"]);
4662    }
4663
4664    #[test]
4665    fn shallow_strings_or_object_property_sub_array_first_element_string() {
4666        // A sub-array in jsPlugins returns the first string element.
4667        let source = r#"
4668            export default {
4669                jsPlugins: [
4670                    ["eslint-plugin-tuple-pkg", { options: true }]
4671                ]
4672            };
4673        "#;
4674        let vals = extract_config_shallow_strings_or_object_property(
4675            source,
4676            &ts_path(),
4677            "jsPlugins",
4678            "specifier",
4679        );
4680        assert_eq!(vals, vec!["eslint-plugin-tuple-pkg"]);
4681    }
4682
4683    // --- extract_config_array_object_command_pairs ---
4684
4685    #[test]
4686    fn array_object_command_pairs_basic() {
4687        let source = r#"
4688            export default {
4689                webServer: [
4690                    { command: "node server.js", cwd: "packages/api" },
4691                    { command: "vite dev" }
4692                ]
4693            };
4694        "#;
4695        let pairs = extract_config_array_object_command_pairs(
4696            source,
4697            &ts_path(),
4698            &["webServer"],
4699            "command",
4700            "cwd",
4701        );
4702        assert_eq!(
4703            pairs,
4704            vec![
4705                (
4706                    "node server.js".to_string(),
4707                    Some("packages/api".to_string())
4708                ),
4709                ("vite dev".to_string(), None),
4710            ]
4711        );
4712    }
4713
4714    #[test]
4715    fn array_object_command_pairs_skips_missing_command() {
4716        let source = r#"
4717            export default {
4718                webServer: [
4719                    { cwd: "packages/api" },
4720                    { command: "vite dev", cwd: "apps/web" }
4721                ]
4722            };
4723        "#;
4724        let pairs = extract_config_array_object_command_pairs(
4725            source,
4726            &ts_path(),
4727            &["webServer"],
4728            "command",
4729            "cwd",
4730        );
4731        assert_eq!(
4732            pairs,
4733            vec![("vite dev".to_string(), Some("apps/web".to_string()))]
4734        );
4735    }
4736
4737    #[test]
4738    fn array_object_command_pairs_empty_when_not_array() {
4739        let source = r#"export default { webServer: { command: "vite dev" } };"#;
4740        let pairs = extract_config_array_object_command_pairs(
4741            source,
4742            &ts_path(),
4743            &["webServer"],
4744            "command",
4745            "cwd",
4746        );
4747        assert!(pairs.is_empty());
4748    }
4749
4750    // --- normalize_config_path edge cases ---
4751
4752    #[test]
4753    fn normalize_config_path_empty_string_returns_none() {
4754        let config_path = PathBuf::from("/project/vite.config.ts");
4755        let root = PathBuf::from("/project");
4756        assert_eq!(normalize_config_path("", &config_path, &root), None);
4757    }
4758
4759    #[test]
4760    fn normalize_config_path_escapes_to_above_root_returns_none() {
4761        let config_path = PathBuf::from("/project/vite.config.ts");
4762        let root = PathBuf::from("/project");
4763        // "../../etc" normalizes to the parent of root, which fails the strip_prefix.
4764        assert_eq!(
4765            normalize_config_path("../../etc", &config_path, &root),
4766            None
4767        );
4768    }
4769
4770    #[test]
4771    fn normalize_config_path_dot_slash_resolves_relative_to_config_dir() {
4772        let config_path = PathBuf::from("/project/packages/app/vite.config.ts");
4773        let root = PathBuf::from("/project");
4774        assert_eq!(
4775            normalize_config_path("./src", &config_path, &root),
4776            Some("packages/app/src".to_string())
4777        );
4778    }
4779
4780    // --- JSON config parsing edge cases ---
4781
4782    #[test]
4783    fn json_config_array_of_arrays_via_shallow_strings() {
4784        // JSON with nested plugin tuples is parsed via the parenthesis-wrap path.
4785        let source = r#"{"reporters": ["default", ["jest-junit", {}]]}"#;
4786        let vals = extract_config_shallow_strings(source, &json_path(), "reporters");
4787        assert_eq!(vals, vec!["default", "jest-junit"]);
4788    }
4789
4790    // --- extract_config_path ---
4791
4792    #[test]
4793    fn extract_config_path_string_literal() {
4794        let source = r#"export default { outDir: "./dist" };"#;
4795        let path = extract_config_path(source, &js_path(), &["outDir"]);
4796        assert_eq!(
4797            path.map(|p| p.to_string_lossy().replace('\\', "/")),
4798            Some("./dist".to_string())
4799        );
4800    }
4801
4802    #[test]
4803    fn extract_config_path_with_resolve_call() {
4804        let source = r#"
4805            import { resolve } from "node:path";
4806            export default { outDir: resolve(__dirname, "dist") };
4807        "#;
4808        let path = extract_config_path(source, &js_path(), &["outDir"]);
4809        assert_eq!(
4810            path.map(|p| p.to_string_lossy().replace('\\', "/")),
4811            Some("dist".to_string())
4812        );
4813    }
4814
4815    #[test]
4816    fn extract_config_path_missing_key_returns_none() {
4817        let source = r#"export default { other: "val" };"#;
4818        let path = extract_config_path(source, &js_path(), &["outDir"]);
4819        assert!(path.is_none());
4820    }
4821
4822    // --- extract_imports_and_requires ---
4823
4824    #[test]
4825    fn extract_imports_and_requires_both_forms() {
4826        let source = r"
4827            import foo from 'foo-pkg';
4828            require('bar-pkg');
4829            export default {};
4830        ";
4831        let sources = extract_imports_and_requires(source, &js_path());
4832        assert!(sources.contains(&"foo-pkg".to_string()));
4833        assert!(sources.contains(&"bar-pkg".to_string()));
4834    }
4835
4836    #[test]
4837    fn extract_imports_and_requires_skips_non_require_calls() {
4838        let source = r"
4839            import foo from 'foo-pkg';
4840            someOtherCall('bar-pkg');
4841            export default {};
4842        ";
4843        let sources = extract_imports_and_requires(source, &js_path());
4844        assert_eq!(sources, vec!["foo-pkg"]);
4845    }
4846
4847    // --- extract_config_nested_shallow_strings: non-object nested value ---
4848
4849    #[test]
4850    fn nested_shallow_strings_non_object_nested_returns_empty() {
4851        // When the outer path points to a non-object, it returns empty.
4852        let source = r#"export default { test: "not-an-object" };"#;
4853        let vals =
4854            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
4855        assert!(vals.is_empty());
4856    }
4857
4858    // --- vite_react_babel_dependencies with namespace import ---
4859
4860    #[test]
4861    fn vite_react_babel_dependencies_namespace_import() {
4862        let source = r#"
4863            import * as react from "@vitejs/plugin-react";
4864
4865            export default defineConfig({
4866                plugins: [
4867                    react.default({
4868                        babel: {
4869                            plugins: ["babel-plugin-ns"],
4870                        },
4871                    }),
4872                ],
4873            });
4874        "#;
4875        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
4876        assert_eq!(deps, vec!["babel-plugin-ns".to_string()]);
4877    }
4878
4879    // --- collect_all_string_values nested object and array recursion ---
4880
4881    #[test]
4882    fn property_strings_deeply_nested_object_values() {
4883        // collect_all_string_values recurses into nested objects and arrays.
4884        let source = r#"
4885            export default {
4886                settings: {
4887                    a: "val-a",
4888                    b: {
4889                        c: "val-c",
4890                        d: ["val-d1", "val-d2"]
4891                    }
4892                }
4893            };
4894        "#;
4895        let values = extract_config_property_strings(source, &js_path(), "settings");
4896        assert!(values.contains(&"val-a".to_string()));
4897        assert!(values.contains(&"val-c".to_string()));
4898        assert!(values.contains(&"val-d1".to_string()));
4899        assert!(values.contains(&"val-d2".to_string()));
4900    }
4901
4902    // --- find_variable_init_expression: export const form ---
4903
4904    #[test]
4905    fn aliases_exported_const_form_resolves() {
4906        // find_variable_init_expression must handle `export const NAME = ...`.
4907        let source = r#"
4908            export const sharedAliases = { "@": "./src" };
4909            export default defineConfig({ resolve: { alias: sharedAliases } });
4910        "#;
4911        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4912        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
4913    }
4914
4915    // --- resolve_sibling_module: index file probe ---
4916
4917    #[test]
4918    fn aliases_imported_from_sibling_directory_index_file() {
4919        // resolve_sibling_module probes <specifier>/index.<ext> when direct
4920        // path and extension-suffixed paths do not exist.
4921        let dir = tempfile::tempdir().unwrap();
4922        let aliases_dir = dir.path().join("aliases");
4923        std::fs::create_dir_all(&aliases_dir).unwrap();
4924        std::fs::write(
4925            aliases_dir.join("index.js"),
4926            r#"export const aliases = [{ find: "@", replacement: "./src" }];"#,
4927        )
4928        .unwrap();
4929        let config = dir.path().join("vite.config.js");
4930        let source = r#"
4931            import { aliases } from "./aliases";
4932            export default defineConfig({ resolve: { alias: aliases } });
4933        "#;
4934        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
4935        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
4936    }
4937
4938    // --- aliases max depth guard ---
4939
4940    #[test]
4941    fn aliases_depth_limit_terminates_deep_chain() {
4942        // A chain of more than MAX_ALIAS_RESOLVE_DEPTH identifiers terminates
4943        // without panic or infinite loop. We verify it does not crash.
4944        let source = r#"
4945            const a9 = [{ find: "@", replacement: "./src" }];
4946            const a8 = a9;
4947            const a7 = a8;
4948            const a6 = a7;
4949            const a5 = a6;
4950            const a4 = a5;
4951            const a3 = a4;
4952            const a2 = a3;
4953            const a1 = a2;
4954            export default defineConfig({ resolve: { alias: a1 } });
4955        "#;
4956        // At MAX_ALIAS_RESOLVE_DEPTH (8), resolution stops before reaching the literal.
4957        let got = extract_config_aliases(source, &js_path(), &["resolve", "alias"]);
4958        let _ = got; // empty or non-empty; both are valid, no panic is the assertion.
4959    }
4960
4961    // --- expression_to_path_string: new URL / fileURLToPath ---
4962
4963    #[test]
4964    fn extract_aliases_file_url_to_path_new_url() {
4965        // expression_to_path_string resolves new URL("./src", import.meta.url).
4966        let source = r#"
4967            import { fileURLToPath, URL } from 'node:url';
4968            export default {
4969                resolve: {
4970                    alias: {
4971                        "@": fileURLToPath(new URL("./src", import.meta.url))
4972                    }
4973                }
4974            };
4975        "#;
4976        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4977        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
4978    }
4979
4980    #[test]
4981    fn extract_path_via_new_url_pathname_member() {
4982        // The .pathname member of new URL(...) is a path-string form.
4983        let source = r#"
4984            export default {
4985                resolve: {
4986                    alias: {
4987                        "@": new URL("./src", import.meta.url).pathname
4988                    }
4989                }
4990            };
4991        "#;
4992        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
4993        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
4994    }
4995
4996    // --- is_disabled_expression: null literal ---
4997
4998    #[test]
4999    fn truthy_bool_or_object_null_literal_returns_false() {
5000        // null is a disabled expression and therefore not truthy.
5001        let source = r"export default { typescript: null };";
5002        let result = extract_config_truthy_bool_or_object(source, &js_path(), &["typescript"]);
5003        assert!(!result);
5004    }
5005
5006    // --- expression_to_string_array: non-array form returns empty ---
5007
5008    #[test]
5009    fn string_array_non_array_value_returns_empty() {
5010        let source = r#"export default { items: "not-an-array" };"#;
5011        let result = extract_config_string_array(source, &js_path(), &["items"]);
5012        assert!(result.is_empty());
5013    }
5014
5015    // --- extract_config_object_nested edge cases ---
5016
5017    #[test]
5018    fn object_nested_empty_when_inner_value_is_not_object() {
5019        // extract_config_object_nested only processes properties whose value is an object.
5020        let source = r#"export default { targets: { build: "not-an-object" } };"#;
5021        let results =
5022            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
5023        assert!(results.is_empty());
5024    }
5025
5026    // --- extract_config_array_nested_string_or_array: missing inner path ---
5027
5028    #[test]
5029    fn array_nested_string_or_array_missing_inner_path_returns_empty() {
5030        let source = r#"
5031            export default {
5032                test: {
5033                    projects: [
5034                        { test: { include: ["**/*.test.ts"] } }
5035                    ]
5036                }
5037            };
5038        "#;
5039        let results = extract_config_array_nested_string_or_array(
5040            source,
5041            &ts_path(),
5042            &["test", "projects"],
5043            &["test", "setupFiles"],
5044        );
5045        assert!(results.is_empty());
5046    }
5047
5048    #[test]
5049    fn wrapped_named_const_default_export_resolves() {
5050        // `export default withMDX(nextConfig)` (official @next/mdx idiom): the
5051        // config is passed as a named const to a wrapper call. Regression #1642.
5052        let source = r#"
5053            import createMDX from "@next/mdx";
5054            const nextConfig = { pageExtensions: ["ts", "tsx", "md", "mdx"] };
5055            const withMDX = createMDX({});
5056            export default withMDX(nextConfig);
5057        "#;
5058        let exts = extract_config_string_array(source, &ts_path(), &["pageExtensions"]);
5059        assert_eq!(exts, vec!["ts", "tsx", "md", "mdx"]);
5060    }
5061
5062    #[test]
5063    fn wrapped_named_const_module_exports_resolves() {
5064        // `module.exports = createJestConfig(customConfig)` (next/jest idiom).
5065        let source = r#"
5066            const nextJest = require("next/jest");
5067            const createJestConfig = nextJest();
5068            const customConfig = { testMatch: ["**/*.test.ts"] };
5069            module.exports = createJestConfig(customConfig);
5070        "#;
5071        let matches = extract_config_string_array(source, &js_path(), &["testMatch"]);
5072        assert_eq!(matches, vec!["**/*.test.ts"]);
5073    }
5074
5075    #[test]
5076    fn wrapped_named_const_nested_and_curried_resolve() {
5077        let nested = r#"
5078            const nextConfig = { pageExtensions: ["mdx"] };
5079            const withMDX = (c) => c;
5080            const withFoo = (c) => c;
5081            export default withMDX(withFoo(nextConfig));
5082        "#;
5083        assert_eq!(
5084            extract_config_string_array(nested, &js_path(), &["pageExtensions"]),
5085            vec!["mdx"]
5086        );
5087
5088        let curried = r#"
5089            const nextConfig = { pageExtensions: ["md"] };
5090            const compose = (..._p) => (c) => c;
5091            export default compose(a, b)(nextConfig);
5092        "#;
5093        assert_eq!(
5094            extract_config_string_array(curried, &js_path(), &["pageExtensions"]),
5095            vec!["md"]
5096        );
5097    }
5098
5099    #[test]
5100    fn wrapped_inline_object_still_resolves() {
5101        // The pre-existing inline-object form must keep working unchanged.
5102        let source = r#"
5103            const withMDX = createMDX({});
5104            export default withMDX({ pageExtensions: ["mdx"] });
5105        "#;
5106        assert_eq!(
5107            extract_config_string_array(source, &js_path(), &["pageExtensions"]),
5108            vec!["mdx"]
5109        );
5110    }
5111}