Skip to main content

fallow_core/plugins/
config_parser.rs

1//! AST-based config file parser utilities.
2//!
3//! Helpers for statically extracting config values from JS/TS files.
4
5use std::path::{Path, PathBuf};
6
7use fallow_extract::visitor::extract_import_from_callable;
8use oxc_allocator::Allocator;
9#[allow(clippy::wildcard_imports, reason = "many AST types used")]
10use oxc_ast::ast::*;
11use oxc_parser::Parser;
12use oxc_span::SourceType;
13use rustc_hash::FxHashSet;
14
15/// Extract all import source specifiers from JS/TS source code.
16#[must_use]
17pub fn extract_imports(source: &str, path: &Path) -> Vec<String> {
18    extract_from_source(source, path, |program| {
19        let mut sources = Vec::new();
20        for stmt in &program.body {
21            if let Statement::ImportDeclaration(decl) = stmt {
22                sources.push(decl.source.value.to_string());
23            }
24        }
25        Some(sources)
26    })
27    .unwrap_or_default()
28}
29
30/// Extract import sources and top-level `require('...')` statements.
31#[must_use]
32pub fn extract_imports_and_requires(source: &str, path: &Path) -> Vec<String> {
33    extract_from_source(source, path, |program| {
34        let mut sources = Vec::new();
35        for stmt in &program.body {
36            match stmt {
37                Statement::ImportDeclaration(decl) => {
38                    sources.push(decl.source.value.to_string());
39                }
40                Statement::ExpressionStatement(expr) => {
41                    if let Expression::CallExpression(call) = &expr.expression
42                        && is_require_call(call)
43                        && let Some(s) = get_require_source(call)
44                    {
45                        sources.push(s);
46                    }
47                }
48                _ => {}
49            }
50        }
51        Some(sources)
52    })
53    .unwrap_or_default()
54}
55
56/// Extract string array from a property at a nested path in a config's default export.
57#[must_use]
58pub fn extract_config_string_array(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
59    extract_from_source(source, path, |program| {
60        let obj = find_config_object(program)?;
61        get_nested_string_array_from_object(obj, prop_path)
62    })
63    .unwrap_or_default()
64}
65
66/// Extract a single string from a property at a nested path.
67#[must_use]
68pub fn extract_config_string(source: &str, path: &Path, prop_path: &[&str]) -> Option<String> {
69    extract_from_source(source, path, |program| {
70        let obj = find_config_object(program)?;
71        get_nested_string_from_object(obj, prop_path)
72    })
73}
74
75/// Extract a shell command string from a property at a nested path.
76#[must_use]
77pub fn extract_config_command(source: &str, path: &Path, prop_path: &[&str]) -> Option<String> {
78    extract_from_source(source, path, |program| {
79        let obj = find_config_object(program)?;
80        get_nested_command_from_object(obj, prop_path)
81    })
82}
83
84/// Extract string values from top-level properties of the default export or
85/// `module.exports` object.
86#[must_use]
87pub fn extract_config_property_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
88    extract_from_source(source, path, |program| {
89        let obj = find_config_object(program)?;
90        let mut values = Vec::new();
91        if let Some(prop) = find_property(obj, key) {
92            collect_all_string_values(&prop.value, &mut values);
93        }
94        Some(values)
95    })
96    .unwrap_or_default()
97}
98
99/// Extract only top-level string values from a property's array.
100#[must_use]
101pub fn extract_config_shallow_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
102    extract_from_source(source, path, |program| {
103        let obj = find_config_object(program)?;
104        let prop = find_property(obj, key)?;
105        Some(collect_shallow_string_values(&prop.value))
106    })
107    .unwrap_or_default()
108}
109
110/// Extract top-level string values from a config array, including object entries.
111#[must_use]
112pub fn extract_config_shallow_strings_or_object_property(
113    source: &str,
114    path: &Path,
115    key: &str,
116    object_property: &str,
117) -> Vec<String> {
118    extract_from_source(source, path, |program| {
119        let obj = find_config_object(program)?;
120        let prop = find_property(obj, key)?;
121        Some(collect_shallow_string_or_object_property_values(
122            &prop.value,
123            object_property,
124        ))
125    })
126    .unwrap_or_default()
127}
128
129/// Extract shallow strings from an array property inside a nested object path.
130#[must_use]
131pub fn extract_config_nested_shallow_strings(
132    source: &str,
133    path: &Path,
134    outer_path: &[&str],
135    key: &str,
136) -> Vec<String> {
137    extract_from_source(source, path, |program| {
138        let obj = find_config_object(program)?;
139        let nested = get_nested_expression(obj, outer_path)?;
140        if let Expression::ObjectExpression(nested_obj) = nested {
141            let prop = find_property(nested_obj, key)?;
142            Some(collect_shallow_string_values(&prop.value))
143        } else {
144            None
145        }
146    })
147    .unwrap_or_default()
148}
149
150/// Public wrapper for `find_config_object`.
151pub fn find_config_object_pub<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
152    find_config_object(program)
153}
154
155/// Get a top-level property expression from an object.
156pub(crate) fn property_expr<'a>(
157    obj: &'a ObjectExpression<'a>,
158    key: &str,
159) -> Option<&'a Expression<'a>> {
160    find_property(obj, key).map(|prop| &prop.value)
161}
162
163/// Get a top-level property object from an object.
164pub(crate) fn property_object<'a>(
165    obj: &'a ObjectExpression<'a>,
166    key: &str,
167) -> Option<&'a ObjectExpression<'a>> {
168    property_expr(obj, key).and_then(object_expression)
169}
170
171/// Get a string-like top-level property value from an object.
172pub(crate) fn property_string(obj: &ObjectExpression<'_>, key: &str) -> Option<String> {
173    property_expr(obj, key).and_then(expression_to_string)
174}
175
176/// Convert an expression to an object expression when it is statically recoverable.
177pub(crate) fn object_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ObjectExpression<'a>> {
178    match expr {
179        Expression::ObjectExpression(obj) => Some(obj),
180        Expression::ParenthesizedExpression(paren) => object_expression(&paren.expression),
181        Expression::TSSatisfiesExpression(ts_sat) => object_expression(&ts_sat.expression),
182        Expression::TSAsExpression(ts_as) => object_expression(&ts_as.expression),
183        _ => None,
184    }
185}
186
187/// Convert an expression to an array expression when it is statically recoverable.
188pub(crate) fn array_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
189    match expr {
190        Expression::ArrayExpression(arr) => Some(arr),
191        Expression::ParenthesizedExpression(paren) => array_expression(&paren.expression),
192        Expression::TSSatisfiesExpression(ts_sat) => array_expression(&ts_sat.expression),
193        Expression::TSAsExpression(ts_as) => array_expression(&ts_as.expression),
194        _ => None,
195    }
196}
197
198/// Convert a config path string to a `PathBuf` with platform-independent
199/// separator handling.
200pub(crate) fn path_from_config_string(raw: &str) -> PathBuf {
201    PathBuf::from(raw.replace('\\', "/"))
202}
203
204/// Convert a config path to the forward-slash string form used in plugin output.
205pub(crate) fn path_to_config_string(path: &Path) -> String {
206    path.to_string_lossy().replace('\\', "/")
207}
208
209/// Convert a path-like expression to a statically recoverable path.
210pub(crate) fn expression_to_path(expr: &Expression<'_>) -> Option<PathBuf> {
211    expression_to_path_string(expr).map(|path| path_from_config_string(&path))
212}
213
214/// Convert a path-like expression to zero or more statically recoverable paths.
215pub(crate) fn expression_to_path_values(expr: &Expression<'_>) -> Vec<PathBuf> {
216    match expr {
217        Expression::ArrayExpression(arr) => arr
218            .elements
219            .iter()
220            .filter_map(|element| element.as_expression().and_then(expression_to_path))
221            .collect(),
222        _ => expression_to_path(expr).into_iter().collect(),
223    }
224}
225
226/// True when an expression explicitly disables a config section.
227pub(crate) fn is_disabled_expression(expr: &Expression<'_>) -> bool {
228    matches!(expr, Expression::BooleanLiteral(boolean) if !boolean.value)
229        || matches!(expr, Expression::NullLiteral(_))
230}
231
232/// True when a nested config property is a static `true` boolean or object value.
233#[must_use]
234pub fn extract_config_truthy_bool_or_object(source: &str, path: &Path, prop_path: &[&str]) -> bool {
235    extract_from_source(source, path, |program| {
236        let obj = find_config_object(program)?;
237        let expr = get_nested_expression(obj, prop_path)?;
238        Some(is_truthy_bool_or_object(expr))
239    })
240    .unwrap_or(false)
241}
242
243fn is_truthy_bool_or_object(expr: &Expression<'_>) -> bool {
244    match expr {
245        Expression::BooleanLiteral(boolean) => boolean.value,
246        Expression::ObjectExpression(_) => true,
247        Expression::ParenthesizedExpression(paren) => is_truthy_bool_or_object(&paren.expression),
248        Expression::TSSatisfiesExpression(ts_sat) => is_truthy_bool_or_object(&ts_sat.expression),
249        Expression::TSAsExpression(ts_as) => is_truthy_bool_or_object(&ts_as.expression),
250        _ => false,
251    }
252}
253
254/// Extract keys of an object property at a nested path.
255#[must_use]
256pub fn extract_config_object_keys(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
257    extract_from_source(source, path, |program| {
258        let obj = find_config_object(program)?;
259        get_nested_object_keys(obj, prop_path)
260    })
261    .unwrap_or_default()
262}
263
264/// Extract a value that may be a single string, string array, or object with
265/// string/array values.
266#[must_use]
267pub fn extract_config_string_or_array(
268    source: &str,
269    path: &Path,
270    prop_path: &[&str],
271) -> Vec<String> {
272    extract_from_source(source, path, |program| {
273        let obj = find_config_object(program)?;
274        get_nested_string_or_array(obj, prop_path)
275    })
276    .unwrap_or_default()
277}
278
279/// Extract a statically recoverable path-like value from a property path.
280#[must_use]
281pub fn extract_config_path(source: &str, path: &Path, prop_path: &[&str]) -> Option<PathBuf> {
282    extract_from_source(source, path, |program| {
283        let obj = find_config_object(program)?;
284        let expr = get_nested_expression(obj, prop_path)?;
285        expression_to_path(expr)
286    })
287}
288
289/// Extract string values from a property path, also searching inside array elements.
290#[must_use]
291pub fn extract_config_array_nested_string_or_array(
292    source: &str,
293    path: &Path,
294    array_path: &[&str],
295    inner_path: &[&str],
296) -> Vec<String> {
297    extract_from_source(source, path, |program| {
298        let obj = find_config_object(program)?;
299        let array_expr = get_nested_expression(obj, array_path)?;
300        let Expression::ArrayExpression(arr) = array_expr else {
301            return None;
302        };
303        let mut results = Vec::new();
304        for element in &arr.elements {
305            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
306                && let Some(values) = get_nested_string_or_array(element_obj, inner_path)
307            {
308                results.extend(values);
309            }
310        }
311        if results.is_empty() {
312            None
313        } else {
314            Some(results)
315        }
316    })
317    .unwrap_or_default()
318}
319
320/// Extract string values from a property path, searching inside all values of an object.
321#[must_use]
322pub fn extract_config_object_nested_string_or_array(
323    source: &str,
324    path: &Path,
325    object_path: &[&str],
326    inner_path: &[&str],
327) -> Vec<String> {
328    extract_config_object_nested(source, path, object_path, |value_obj| {
329        get_nested_string_or_array(value_obj, inner_path)
330    })
331}
332
333/// Extract a single string value from each object under a property path.
334#[must_use]
335pub fn extract_config_object_nested_strings(
336    source: &str,
337    path: &Path,
338    object_path: &[&str],
339    inner_path: &[&str],
340) -> Vec<String> {
341    extract_config_object_nested(source, path, object_path, |value_obj| {
342        get_nested_string_from_object(value_obj, inner_path).map(|s| vec![s])
343    })
344}
345
346/// Shared helper for object-nested extraction.
347fn extract_config_object_nested(
348    source: &str,
349    path: &Path,
350    object_path: &[&str],
351    extract_fn: impl Fn(&ObjectExpression<'_>) -> Option<Vec<String>>,
352) -> Vec<String> {
353    extract_from_source(source, path, |program| {
354        let obj = find_config_object(program)?;
355        let obj_expr = get_nested_expression(obj, object_path)?;
356        let Expression::ObjectExpression(target_obj) = obj_expr else {
357            return None;
358        };
359        let mut results = Vec::new();
360        for prop in &target_obj.properties {
361            if let ObjectPropertyKind::ObjectProperty(p) = prop
362                && let Expression::ObjectExpression(value_obj) = &p.value
363                && let Some(values) = extract_fn(value_obj)
364            {
365                results.extend(values);
366            }
367        }
368        if results.is_empty() {
369            None
370        } else {
371            Some(results)
372        }
373    })
374    .unwrap_or_default()
375}
376
377/// Extract `require('...')` call argument strings from a property's value.
378#[must_use]
379pub fn extract_config_require_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
380    extract_from_source(source, path, |program| {
381        let obj = find_config_object(program)?;
382        let prop = find_property(obj, key)?;
383        Some(collect_require_sources(&prop.value))
384    })
385    .unwrap_or_default()
386}
387
388/// Extract alias mappings from an object or array-based alias config.
389#[must_use]
390pub fn extract_config_aliases(
391    source: &str,
392    path: &Path,
393    prop_path: &[&str],
394) -> Vec<(String, String)> {
395    extract_config_aliases_kinded(source, path, prop_path)
396        .into_iter()
397        .map(|(find, replacement, _is_bare)| (find, replacement))
398        .collect()
399}
400
401/// Extract alias mappings where the replacement is a filesystem path value.
402#[must_use]
403pub fn extract_config_path_aliases(
404    source: &str,
405    path: &Path,
406    prop_path: &[&str],
407) -> Vec<(String, PathBuf)> {
408    extract_config_aliases_kinded(source, path, prop_path)
409        .into_iter()
410        .map(|(find, replacement, _is_bare)| (find, path_from_config_string(&replacement)))
411        .collect()
412}
413
414/// Extract alias mappings nested inside an array of config objects.
415#[must_use]
416pub fn extract_config_array_nested_aliases(
417    source: &str,
418    path: &Path,
419    array_path: &[&str],
420    alias_path: &[&str],
421) -> Vec<(String, String)> {
422    extract_from_source(source, path, |program| {
423        let obj = find_config_object(program)?;
424        let array_expr = get_nested_expression(obj, array_path)?;
425        let Expression::ArrayExpression(arr) = array_expr else {
426            return None;
427        };
428        let mut results = Vec::new();
429        for element in &arr.elements {
430            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
431                && let Some(alias_expr) = get_nested_expression(element_obj, alias_path)
432            {
433                results.extend(expression_to_alias_pairs(alias_expr));
434            }
435        }
436        (!results.is_empty()).then_some(results)
437    })
438    .unwrap_or_default()
439}
440
441/// Like [`extract_config_aliases`] but each tuple carries a bare-string flag.
442#[must_use]
443pub fn extract_config_aliases_kinded(
444    source: &str,
445    path: &Path,
446    prop_path: &[&str],
447) -> Vec<(String, String, bool)> {
448    extract_from_source(source, path, |program| {
449        let obj = find_config_object(program)?;
450        let expr = get_nested_expression(obj, prop_path)?;
451        let mut visited = FxHashSet::default();
452        let aliases = resolve_alias_pairs_kinded(program, path, expr, &mut visited, 0);
453        (!aliases.is_empty()).then_some(aliases)
454    })
455    .unwrap_or_default()
456}
457
458/// Kinded variant of [`extract_config_array_nested_aliases`].
459#[must_use]
460pub fn extract_config_array_nested_aliases_kinded(
461    source: &str,
462    path: &Path,
463    array_path: &[&str],
464    alias_path: &[&str],
465) -> Vec<(String, String, bool)> {
466    extract_from_source(source, path, |program| {
467        let obj = find_config_object(program)?;
468        let array_expr = get_nested_expression(obj, array_path)?;
469        let Expression::ArrayExpression(arr) = array_expr else {
470            return None;
471        };
472        let mut results = Vec::new();
473        for element in &arr.elements {
474            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
475                && let Some(alias_expr) = get_nested_expression(element_obj, alias_path)
476            {
477                results.extend(expression_to_alias_pairs_kinded(alias_expr));
478            }
479        }
480        (!results.is_empty()).then_some(results)
481    })
482    .unwrap_or_default()
483}
484
485/// Extract kinded aliases from a default-exported ARRAY config.
486#[must_use]
487pub fn extract_default_export_array_aliases_kinded(
488    source: &str,
489    path: &Path,
490    alias_path: &[&str],
491) -> Vec<(String, String, bool)> {
492    extract_from_source(source, path, |program| {
493        let arr = find_default_export_array(program)?;
494        let mut results = Vec::new();
495        for element in &arr.elements {
496            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
497                && let Some(alias_expr) = get_nested_expression(element_obj, alias_path)
498            {
499                results.extend(expression_to_alias_pairs_kinded(alias_expr));
500            }
501        }
502        (!results.is_empty()).then_some(results)
503    })
504    .unwrap_or_default()
505}
506
507/// True when a parsed config has neither an object nor array default export.
508#[must_use]
509pub fn config_default_export_unreachable(source: &str, path: &Path) -> bool {
510    extract_from_source(source, path, |program| {
511        let reachable =
512            find_config_object(program).is_some() || find_default_export_array(program).is_some();
513        Some(reachable)
514    })
515    .is_some_and(|reachable| !reachable)
516}
517
518/// Extract string values from a nested array, supporting both string elements and
519/// object elements with a named string/path field.
520///
521/// Useful for configs like:
522/// - `components: ["~/components", { path: "~/feature-components" }]`
523#[must_use]
524pub fn extract_config_array_object_strings(
525    source: &str,
526    path: &Path,
527    array_path: &[&str],
528    key: &str,
529) -> Vec<String> {
530    extract_from_source(source, path, |program| {
531        let obj = find_config_object(program)?;
532        let array_expr = get_nested_expression(obj, array_path)?;
533        let Expression::ArrayExpression(arr) = array_expr else {
534            return None;
535        };
536
537        let mut results = Vec::new();
538        for element in &arr.elements {
539            let Some(expr) = element.as_expression() else {
540                continue;
541            };
542            match expr {
543                Expression::ObjectExpression(item) => {
544                    if let Some(prop) = find_property(item, key)
545                        && let Some(value) = expression_to_path_string(&prop.value)
546                    {
547                        results.push(value);
548                    }
549                }
550                _ => {
551                    if let Some(value) = expression_to_path_string(expr) {
552                        results.push(value);
553                    }
554                }
555            }
556        }
557
558        (!results.is_empty()).then_some(results)
559    })
560    .unwrap_or_default()
561}
562
563/// Extract Storybook-style static directory entries from an array.
564///
565/// Supports string entries and object entries with a string-like `from` plus
566/// optional string-like `to`.
567#[must_use]
568pub fn extract_config_static_dir_entries(
569    source: &str,
570    path: &Path,
571    array_path: &[&str],
572) -> Vec<(String, Option<String>)> {
573    extract_from_source(source, path, |program| {
574        let obj = find_config_object(program)?;
575        let array_expr = get_nested_expression(obj, array_path)?;
576        let Expression::ArrayExpression(arr) = array_expr else {
577            return None;
578        };
579
580        let mut results = Vec::new();
581        for element in &arr.elements {
582            let Some(expr) = element.as_expression() else {
583                continue;
584            };
585            match expr {
586                Expression::ObjectExpression(item) => {
587                    if let Some(from) = property_string(item, "from") {
588                        let to = property_string(item, "to");
589                        results.push((from, to));
590                    }
591                }
592                _ => {
593                    if let Some(from) = expression_to_path_string(expr) {
594                        results.push((from, None));
595                    }
596                }
597            }
598        }
599
600        (!results.is_empty()).then_some(results)
601    })
602    .unwrap_or_default()
603}
604
605/// Extract paired `(primary, optional secondary)` string values from each object
606/// element of an array at `array_path`.
607///
608/// Mirrors [`extract_config_array_object_strings`] but keeps a per-element
609/// secondary value alongside the primary one, so correlated fields stay paired.
610/// An element is included only when its `primary_key` resolves to a recoverable
611/// path string; the `secondary_key` is `None` when absent or non-recoverable.
612///
613/// Used for Playwright's `webServer: [{ command, cwd }]` form where each
614/// `command` must be resolved relative to its own `cwd`.
615#[must_use]
616pub fn extract_config_array_object_string_pairs(
617    source: &str,
618    path: &Path,
619    array_path: &[&str],
620    primary_key: &str,
621    secondary_key: &str,
622) -> Vec<(String, Option<String>)> {
623    extract_from_source(source, path, |program| {
624        let obj = find_config_object(program)?;
625        let array_expr = get_nested_expression(obj, array_path)?;
626        let Expression::ArrayExpression(arr) = array_expr else {
627            return None;
628        };
629
630        let mut results = Vec::new();
631        for element in &arr.elements {
632            let Some(Expression::ObjectExpression(item)) = element.as_expression() else {
633                continue;
634            };
635            let Some(primary) = find_property(item, primary_key)
636                .and_then(|prop| expression_to_path_string(&prop.value))
637            else {
638                continue;
639            };
640            let secondary = find_property(item, secondary_key)
641                .and_then(|prop| expression_to_path_string(&prop.value));
642            results.push((primary, secondary));
643        }
644
645        (!results.is_empty()).then_some(results)
646    })
647    .unwrap_or_default()
648}
649
650/// Extract paired shell command and string values from each object element of an array.
651#[must_use]
652pub fn extract_config_array_object_command_pairs(
653    source: &str,
654    path: &Path,
655    array_path: &[&str],
656    primary_key: &str,
657    secondary_key: &str,
658) -> Vec<(String, Option<String>)> {
659    extract_from_source(source, path, |program| {
660        let obj = find_config_object(program)?;
661        let array_expr = get_nested_expression(obj, array_path)?;
662        let Expression::ArrayExpression(arr) = array_expr else {
663            return None;
664        };
665
666        let mut results = Vec::new();
667        for element in &arr.elements {
668            let Some(Expression::ObjectExpression(item)) = element.as_expression() else {
669                continue;
670            };
671            let Some(primary) = find_property(item, primary_key)
672                .and_then(|prop| expression_to_command(&prop.value))
673            else {
674                continue;
675            };
676            let secondary = find_property(item, secondary_key)
677                .and_then(|prop| expression_to_path_string(&prop.value));
678            results.push((primary, secondary));
679        }
680
681        (!results.is_empty()).then_some(results)
682    })
683    .unwrap_or_default()
684}
685
686/// Extract static specifiers from thunk-wrapped dynamic imports inside an
687/// array property.
688///
689/// Captures the `SPEC` argument from each `() => import('SPEC')` element of
690/// an array nested under `prop_path` in the config's default-exported object.
691///
692/// # The pattern
693///
694/// Configs and registries that need to defer module evaluation commonly hold
695/// arrays of *thunks* — zero-argument arrow functions whose body is a single
696/// dynamic import:
697///
698/// ```ts
699/// export default defineConfig({
700///     modules: [
701///         () => import('./feature-a'),
702///         { file: () => import('./feature-b'), enabled: true },
703///     ],
704/// })
705/// ```
706///
707/// `import('SPEC')` is the ECMAScript dynamic-import expression (TC39
708/// dynamic-import proposal, shipped in ES2020): a runtime module loader call
709/// that returns a `Promise<Module>`. Wrapping it in `() => import('SPEC')`
710/// turns "load module X now" into "value that, when invoked, loads module X"
711/// — a thunk the host can call lazily.
712///
713/// The technique predates any single framework. It's the same shape used by
714/// route-level code-splitting (`Vue Router`, `React Router`, `Next.js`),
715/// `React.lazy`, Webpack's documented dynamic-import code-splitting recipes,
716/// and any registry that wants to keep boot cheap, break import cycles, or
717/// let bundlers tree-shake unused branches. Configs that adopt the pattern
718/// can therefore declare large module graphs without forcing eager
719/// evaluation of every entry at config parse time.
720///
721/// # Recognised array element shapes
722///
723/// - Concise arrow: `() => import('SPEC')`
724/// - Block-body arrow with explicit return: `() => { return import('SPEC') }`
725/// - Object form with a `file` property holding the arrow:
726///   `{ file: () => import('SPEC'), /* peer fields */ }`
727///
728/// Non-matching elements (string literals, variables, template-string
729/// specifiers, computed expressions) are silently skipped: callers receive
730/// only the statically-resolvable specifiers, in source order.
731#[must_use]
732pub fn extract_lazy_imports_in_array(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
733    extract_from_source(source, path, |program| {
734        let obj = find_config_object(program)?;
735        let array_expr = get_nested_expression(obj, prop_path)?;
736        let Expression::ArrayExpression(arr) = array_expr else {
737            return None;
738        };
739        let mut specs = Vec::new();
740        for element in &arr.elements {
741            let Some(expr) = element.as_expression() else {
742                continue;
743            };
744            if let Some(spec) = lazy_import_specifier(expr) {
745                specs.push(spec);
746            }
747        }
748        (!specs.is_empty()).then_some(specs)
749    })
750    .unwrap_or_default()
751}
752
753/// Read a lazy-import specifier from a single array element expression.
754///
755/// Two outer shapes are accepted at this level (array-element navigation):
756/// - A bare callable: `() => import('SPEC')` or the function-expression
757///   equivalent.
758/// - An object with a `file` property holding the callable:
759///   `{ file: () => import('SPEC'), /* peer fields */ }`.
760///
761/// The actual callable → import peeling is delegated to
762/// [`extract_import_from_callable`], which is shared with the visitor-side
763/// dynamic-import helpers so all three navigation pipelines stay in lockstep
764/// when ECMAScript adds new wrapper shapes.
765fn lazy_import_specifier(expr: &Expression<'_>) -> Option<String> {
766    let callable = match expr {
767        Expression::ObjectExpression(obj) => &find_property(obj, "file")?.value,
768        _ => expr,
769    };
770    let import_expr = extract_import_from_callable(callable)?;
771    expression_to_string(&import_expr.source)
772}
773
774/// Extract a string-like option from a plugin tuple inside a config plugin array.
775///
776/// Supports config shapes like:
777/// - `{ expo: { plugins: [["expo-router", { root: "src/app" }]] } }`
778/// - `export default { expo: { plugins: [["expo-router", { root: "./src/app" }]] } }`
779/// - `{ plugins: [["expo-router", { root: "./src/routes" }]] }`
780#[must_use]
781pub fn extract_config_plugin_option_string(
782    source: &str,
783    path: &Path,
784    plugins_path: &[&str],
785    plugin_name: &str,
786    option_key: &str,
787) -> Option<String> {
788    extract_from_source(source, path, |program| {
789        let obj = find_config_object(program)?;
790        let plugins_expr = get_nested_expression(obj, plugins_path)?;
791        let Expression::ArrayExpression(plugins) = plugins_expr else {
792            return None;
793        };
794
795        for entry in &plugins.elements {
796            let Some(Expression::ArrayExpression(tuple)) = entry.as_expression() else {
797                continue;
798            };
799            let Some(plugin_expr) = tuple
800                .elements
801                .first()
802                .and_then(ArrayExpressionElement::as_expression)
803            else {
804                continue;
805            };
806            if expression_to_string(plugin_expr).as_deref() != Some(plugin_name) {
807                continue;
808            }
809
810            let Some(options_expr) = tuple
811                .elements
812                .get(1)
813                .and_then(ArrayExpressionElement::as_expression)
814            else {
815                continue;
816            };
817            let Expression::ObjectExpression(options_obj) = options_expr else {
818                continue;
819            };
820            let option = find_property(options_obj, option_key)?;
821            return expression_to_path_string(&option.value);
822        }
823
824        None
825    })
826}
827
828/// Extract a string-like option from the first plugin array path that contains it.
829#[must_use]
830pub fn extract_config_plugin_option_string_from_paths(
831    source: &str,
832    path: &Path,
833    plugin_paths: &[&[&str]],
834    plugin_name: &str,
835    option_key: &str,
836) -> Option<String> {
837    plugin_paths.iter().find_map(|plugins_path| {
838        extract_config_plugin_option_string(source, path, plugins_path, plugin_name, option_key)
839    })
840}
841
842/// Extract Babel plugin and preset package names configured through
843/// `@vitejs/plugin-react` options in a Vite-style `plugins` array.
844#[must_use]
845pub fn extract_vite_react_babel_dependencies(source: &str, path: &Path) -> Vec<String> {
846    extract_from_source(source, path, |program| {
847        let react_plugin_imports = collect_vite_react_plugin_imports(program);
848        if react_plugin_imports.is_empty() {
849            return None;
850        }
851
852        let obj = find_config_object(program)?;
853        let plugins = get_nested_expression(obj, &["plugins"])?;
854        let Expression::ArrayExpression(plugin_array) = plugins else {
855            return None;
856        };
857
858        let mut deps = Vec::new();
859        for element in &plugin_array.elements {
860            let Some(Expression::CallExpression(call)) = element.as_expression() else {
861                continue;
862            };
863            if !is_vite_react_plugin_call(call, &react_plugin_imports) {
864                continue;
865            }
866            let Some(Expression::ObjectExpression(options)) =
867                call.arguments.first().and_then(Argument::as_expression)
868            else {
869                continue;
870            };
871            collect_vite_react_babel_dependencies(options, &mut deps);
872        }
873
874        (!deps.is_empty()).then_some(deps)
875    })
876    .unwrap_or_default()
877}
878
879/// Normalize a config-relative path to a project-root-relative path.
880///
881/// Handles values extracted from config files such as `"./src"`, `"src/lib"`,
882/// `"/src"`, or absolute filesystem paths under `root`.
883#[must_use]
884pub fn normalize_config_path_buf(
885    raw: impl AsRef<Path>,
886    config_path: &Path,
887    root: &Path,
888) -> Option<PathBuf> {
889    let raw = raw.as_ref();
890    if raw.as_os_str().is_empty() {
891        return None;
892    }
893
894    let raw_string = path_to_config_string(raw);
895    let raw_path = Path::new(&raw_string);
896    let candidate = if let Some(stripped) = raw_string.strip_prefix('/') {
897        lexical_normalize(&root.join(stripped))
898    } else if raw_path.is_absolute() {
899        lexical_normalize(raw_path)
900    } else {
901        let base = config_path.parent().unwrap_or(root);
902        lexical_normalize(&base.join(raw_path))
903    };
904
905    let relative = candidate.strip_prefix(root).ok()?;
906    (!relative.as_os_str().is_empty()).then(|| relative.to_path_buf())
907}
908
909/// Normalize a config-relative path to a project-root-relative forward-slash string.
910#[must_use]
911pub fn normalize_config_path(
912    raw: impl AsRef<Path>,
913    config_path: &Path,
914    root: &Path,
915) -> Option<String> {
916    normalize_config_path_buf(raw, config_path, root).map(|path| path_to_config_string(&path))
917}
918
919/// Parse source and run an extraction function on the AST.
920///
921/// JSON files (`.json`, `.jsonc`) are parsed as JavaScript expressions wrapped in
922/// parentheses to produce an AST compatible with `find_config_object`. The native
923/// JSON source type in Oxc produces a different AST structure that our helpers
924/// don't handle.
925pub(crate) fn extract_from_source<T>(
926    source: &str,
927    path: &Path,
928    extractor: impl FnOnce(&Program) -> Option<T>,
929) -> Option<T> {
930    let source_type = SourceType::from_path(path).unwrap_or_default();
931    let alloc = Allocator::default();
932
933    let is_json = path
934        .extension()
935        .is_some_and(|ext| ext == "json" || ext == "jsonc");
936    if is_json {
937        let wrapped = format!("({source})");
938        let parsed = Parser::new(&alloc, &wrapped, SourceType::mjs()).parse();
939        return extractor(&parsed.program);
940    }
941
942    let parsed = Parser::new(&alloc, source, source_type).parse();
943    extractor(&parsed.program)
944}
945
946#[derive(Default)]
947struct ViteReactPluginImports {
948    callables: Vec<String>,
949    namespaces: Vec<String>,
950}
951
952impl ViteReactPluginImports {
953    fn is_empty(&self) -> bool {
954        self.callables.is_empty() && self.namespaces.is_empty()
955    }
956}
957
958fn collect_vite_react_plugin_imports(program: &Program<'_>) -> ViteReactPluginImports {
959    let mut imports = ViteReactPluginImports::default();
960
961    for stmt in &program.body {
962        let Statement::ImportDeclaration(decl) = stmt else {
963            continue;
964        };
965        if decl.source.value != "@vitejs/plugin-react" {
966            continue;
967        }
968        let Some(specifiers) = &decl.specifiers else {
969            continue;
970        };
971        for specifier in specifiers {
972            match specifier {
973                ImportDeclarationSpecifier::ImportDefaultSpecifier(specifier) => {
974                    push_unique_string(&mut imports.callables, specifier.local.name.to_string());
975                }
976                ImportDeclarationSpecifier::ImportSpecifier(specifier)
977                    if specifier.imported.name().as_ref() == "default" =>
978                {
979                    push_unique_string(&mut imports.callables, specifier.local.name.to_string());
980                }
981                ImportDeclarationSpecifier::ImportNamespaceSpecifier(specifier) => {
982                    push_unique_string(&mut imports.namespaces, specifier.local.name.to_string());
983                }
984                ImportDeclarationSpecifier::ImportSpecifier(_) => {}
985            }
986        }
987    }
988
989    imports
990}
991
992fn is_vite_react_plugin_call(call: &CallExpression<'_>, imports: &ViteReactPluginImports) -> bool {
993    match &call.callee {
994        Expression::Identifier(identifier) => imports
995            .callables
996            .iter()
997            .any(|name| name == identifier.name.as_str()),
998        Expression::StaticMemberExpression(member) if matches!(&member.object, Expression::Identifier(object) if imports.namespaces.iter().any(|name| name == object.name.as_str())) => {
999            member.property.name == "default"
1000        }
1001        _ => false,
1002    }
1003}
1004
1005fn collect_vite_react_babel_dependencies(options: &ObjectExpression<'_>, deps: &mut Vec<String>) {
1006    let Some(babel) = property_object(options, "babel") else {
1007        return;
1008    };
1009    for key in ["plugins", "presets"] {
1010        let Some(prop) = find_property(babel, key) else {
1011            continue;
1012        };
1013        for raw in collect_shallow_string_values(&prop.value) {
1014            if let Some(dep) = vite_react_babel_dependency_name(&raw) {
1015                push_unique_string(deps, dep);
1016            }
1017        }
1018    }
1019}
1020
1021fn vite_react_babel_dependency_name(raw: &str) -> Option<String> {
1022    let raw = raw.trim();
1023    let specifier = raw.strip_prefix("module:").unwrap_or(raw).trim();
1024    if specifier.is_empty()
1025        || specifier.starts_with('.')
1026        || specifier.starts_with('/')
1027        || specifier.contains(':')
1028        || specifier.contains('\\')
1029    {
1030        return None;
1031    }
1032    Some(crate::resolve::extract_package_name(specifier))
1033}
1034
1035fn push_unique_string(items: &mut Vec<String>, value: String) {
1036    if !items.contains(&value) {
1037        items.push(value);
1038    }
1039}
1040
1041/// Find the "config object": the object expression in the default export or module.exports.
1042///
1043/// Handles these patterns:
1044/// - `export default { ... }`
1045/// - `export default defineConfig({ ... })`
1046/// - `export default defineConfig(async () => ({ ... }))`
1047/// - `export default { ... } satisfies Config` / `export default { ... } as Config`
1048/// - `const config = { ... }; export default config;`
1049/// - `const config: Config = { ... }; export default config;`
1050/// - `module.exports = { ... }`
1051/// - Top-level JSON object (for .json files)
1052fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
1053    for stmt in &program.body {
1054        match stmt {
1055            Statement::ExportDefaultDeclaration(decl) => {
1056                let expr: Option<&Expression> = match &decl.declaration {
1057                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
1058                        return Some(obj);
1059                    }
1060                    ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
1061                        return extract_object_from_function(func);
1062                    }
1063                    _ => decl.declaration.as_expression(),
1064                };
1065                if let Some(expr) = expr {
1066                    if let Some(obj) = extract_object_from_expression(expr) {
1067                        return Some(obj);
1068                    }
1069                    if let Some(name) = unwrap_to_identifier_name(expr) {
1070                        return find_variable_init_object(program, name);
1071                    }
1072                }
1073            }
1074            Statement::ExpressionStatement(expr_stmt) => {
1075                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
1076                    && is_module_exports_target(&assign.left)
1077                {
1078                    return extract_object_from_expression(&assign.right);
1079                }
1080            }
1081            _ => {}
1082        }
1083    }
1084
1085    if program.body.len() == 1
1086        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
1087    {
1088        match &expr_stmt.expression {
1089            Expression::ObjectExpression(obj) => return Some(obj),
1090            Expression::ParenthesizedExpression(paren) => {
1091                if let Expression::ObjectExpression(obj) = &paren.expression {
1092                    return Some(obj);
1093                }
1094            }
1095            _ => {}
1096        }
1097    }
1098
1099    None
1100}
1101
1102/// Extract an `ObjectExpression` from an expression, handling wrapper patterns.
1103fn extract_object_from_expression<'a>(
1104    expr: &'a Expression<'a>,
1105) -> Option<&'a ObjectExpression<'a>> {
1106    match expr {
1107        Expression::ObjectExpression(obj) => Some(obj),
1108        Expression::CallExpression(call) => {
1109            for arg in &call.arguments {
1110                match arg {
1111                    Argument::ObjectExpression(obj) => return Some(obj),
1112                    Argument::ArrowFunctionExpression(arrow) => {
1113                        if arrow.expression
1114                            && !arrow.body.statements.is_empty()
1115                            && let Statement::ExpressionStatement(expr_stmt) =
1116                                &arrow.body.statements[0]
1117                        {
1118                            return extract_object_from_expression(&expr_stmt.expression);
1119                        }
1120                    }
1121                    _ => {}
1122                }
1123            }
1124            None
1125        }
1126        Expression::ParenthesizedExpression(paren) => {
1127            extract_object_from_expression(&paren.expression)
1128        }
1129        Expression::TSSatisfiesExpression(ts_sat) => {
1130            extract_object_from_expression(&ts_sat.expression)
1131        }
1132        Expression::TSAsExpression(ts_as) => extract_object_from_expression(&ts_as.expression),
1133        Expression::ArrowFunctionExpression(arrow) => extract_object_from_arrow_function(arrow),
1134        Expression::FunctionExpression(func) => extract_object_from_function(func),
1135        _ => None,
1136    }
1137}
1138
1139fn extract_object_from_arrow_function<'a>(
1140    arrow: &'a ArrowFunctionExpression<'a>,
1141) -> Option<&'a ObjectExpression<'a>> {
1142    if arrow.expression {
1143        arrow.body.statements.first().and_then(|stmt| {
1144            if let Statement::ExpressionStatement(expr_stmt) = stmt {
1145                extract_object_from_expression(&expr_stmt.expression)
1146            } else {
1147                None
1148            }
1149        })
1150    } else {
1151        extract_object_from_function_body(&arrow.body)
1152    }
1153}
1154
1155fn extract_object_from_function<'a>(func: &'a Function<'a>) -> Option<&'a ObjectExpression<'a>> {
1156    func.body
1157        .as_ref()
1158        .and_then(|body| extract_object_from_function_body(body))
1159}
1160
1161fn extract_object_from_function_body<'a>(
1162    body: &'a FunctionBody<'a>,
1163) -> Option<&'a ObjectExpression<'a>> {
1164    for stmt in &body.statements {
1165        if let Statement::ReturnStatement(ret) = stmt
1166            && let Some(argument) = &ret.argument
1167            && let Some(obj) = extract_object_from_expression(argument)
1168        {
1169            return Some(obj);
1170        }
1171    }
1172    None
1173}
1174
1175/// Check if an assignment target is `module.exports`.
1176fn is_module_exports_target(target: &AssignmentTarget) -> bool {
1177    if let AssignmentTarget::StaticMemberExpression(member) = target
1178        && let Expression::Identifier(obj) = &member.object
1179    {
1180        return obj.name == "module" && member.property.name == "exports";
1181    }
1182    false
1183}
1184
1185/// Unwrap TS annotations and return the identifier name if the expression resolves to one.
1186///
1187/// Handles `config`, `config satisfies Type`, `config as Type`.
1188fn unwrap_to_identifier_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
1189    match expr {
1190        Expression::Identifier(id) => Some(&id.name),
1191        Expression::TSSatisfiesExpression(ts_sat) => unwrap_to_identifier_name(&ts_sat.expression),
1192        Expression::TSAsExpression(ts_as) => unwrap_to_identifier_name(&ts_as.expression),
1193        _ => None,
1194    }
1195}
1196
1197/// Find a top-level variable declaration by name and extract its init as an object expression.
1198///
1199/// Handles `const config = { ... }`, `const config: Type = { ... }`,
1200/// and `const config = defineConfig({ ... })`.
1201fn find_variable_init_object<'a>(
1202    program: &'a Program,
1203    name: &str,
1204) -> Option<&'a ObjectExpression<'a>> {
1205    for stmt in &program.body {
1206        if let Statement::VariableDeclaration(decl) = stmt {
1207            for declarator in &decl.declarations {
1208                if let BindingPattern::BindingIdentifier(id) = &declarator.id
1209                    && id.name == name
1210                    && let Some(init) = &declarator.init
1211                {
1212                    return extract_object_from_expression(init);
1213                }
1214            }
1215        }
1216    }
1217    None
1218}
1219
1220/// Find a named property in an object expression.
1221pub(crate) fn find_property<'a>(
1222    obj: &'a ObjectExpression<'a>,
1223    key: &str,
1224) -> Option<&'a ObjectProperty<'a>> {
1225    for prop in &obj.properties {
1226        if let ObjectPropertyKind::ObjectProperty(p) = prop
1227            && property_key_matches(&p.key, key)
1228        {
1229            return Some(p);
1230        }
1231    }
1232    None
1233}
1234
1235/// Check if a property key matches a string.
1236pub(crate) fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
1237    match key {
1238        PropertyKey::StaticIdentifier(id) => id.name == name,
1239        PropertyKey::StringLiteral(s) => s.value == name,
1240        _ => false,
1241    }
1242}
1243
1244/// Get a string value from an object property.
1245fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
1246    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
1247}
1248
1249/// Get an array of strings from an object property.
1250fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
1251    find_property(obj, key)
1252        .map(|p| expression_to_string_array(&p.value))
1253        .unwrap_or_default()
1254}
1255
1256/// Navigate a nested property path and get a string array.
1257fn get_nested_string_array_from_object(
1258    obj: &ObjectExpression,
1259    path: &[&str],
1260) -> Option<Vec<String>> {
1261    if path.is_empty() {
1262        return None;
1263    }
1264    if path.len() == 1 {
1265        return Some(get_object_string_array_property(obj, path[0]));
1266    }
1267    let prop = find_property(obj, path[0])?;
1268    if let Expression::ObjectExpression(nested) = &prop.value {
1269        get_nested_string_array_from_object(nested, &path[1..])
1270    } else {
1271        None
1272    }
1273}
1274
1275/// Navigate a nested property path and get a string value.
1276fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
1277    if path.is_empty() {
1278        return None;
1279    }
1280    if path.len() == 1 {
1281        return get_object_string_property(obj, path[0]);
1282    }
1283    let prop = find_property(obj, path[0])?;
1284    if let Expression::ObjectExpression(nested) = &prop.value {
1285        get_nested_string_from_object(nested, &path[1..])
1286    } else {
1287        None
1288    }
1289}
1290
1291/// Navigate a nested property path and get a shell command value.
1292fn get_nested_command_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
1293    if path.is_empty() {
1294        return None;
1295    }
1296    if path.len() == 1 {
1297        return find_property(obj, path[0]).and_then(|prop| expression_to_command(&prop.value));
1298    }
1299    let prop = find_property(obj, path[0])?;
1300    if let Expression::ObjectExpression(nested) = &prop.value {
1301        get_nested_command_from_object(nested, &path[1..])
1302    } else {
1303        None
1304    }
1305}
1306
1307/// Convert an expression to a string if it's a string literal.
1308pub(crate) fn expression_to_string(expr: &Expression) -> Option<String> {
1309    match expr {
1310        Expression::StringLiteral(s) => Some(s.value.to_string()),
1311        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
1312            t.quasis.first().map(|q| q.value.raw.to_string())
1313        }
1314        _ => None,
1315    }
1316}
1317
1318/// Convert an expression to a shell command when static command tokens are recoverable.
1319fn expression_to_command(expr: &Expression) -> Option<String> {
1320    match expr {
1321        Expression::StringLiteral(s) => Some(s.value.to_string()),
1322        Expression::TemplateLiteral(template) => template_literal_to_command(template),
1323        Expression::ParenthesizedExpression(paren) => expression_to_command(&paren.expression),
1324        Expression::TSAsExpression(ts_as) => expression_to_command(&ts_as.expression),
1325        Expression::TSSatisfiesExpression(ts_sat) => expression_to_command(&ts_sat.expression),
1326        _ => None,
1327    }
1328}
1329
1330fn template_literal_to_command(template: &TemplateLiteral<'_>) -> Option<String> {
1331    let first = template.quasis.first()?.value.raw.as_str();
1332    if first.trim_start().is_empty() {
1333        return None;
1334    }
1335
1336    let mut command = String::new();
1337    for (idx, quasi) in template.quasis.iter().enumerate() {
1338        command.push_str(quasi.value.raw.as_str());
1339        if idx < template.expressions.len() {
1340            let next = template
1341                .quasis
1342                .get(idx + 1)
1343                .map_or("", |next| next.value.raw.as_str());
1344            if dynamic_template_boundary_splits_static_token(quasi.value.raw.as_str(), next) {
1345                return None;
1346            }
1347            command.push(' ');
1348        }
1349    }
1350
1351    Some(command)
1352}
1353
1354fn dynamic_template_boundary_splits_static_token(before: &str, after: &str) -> bool {
1355    before
1356        .chars()
1357        .next_back()
1358        .is_some_and(is_command_token_char)
1359        && after.chars().next().is_some_and(is_command_token_char)
1360}
1361
1362fn is_command_token_char(ch: char) -> bool {
1363    !ch.is_whitespace() && !matches!(ch, '&' | '|' | ';' | '"' | '\'')
1364}
1365
1366/// Convert an expression to a path-like string if it's statically recoverable.
1367pub(crate) fn expression_to_path_string(expr: &Expression) -> Option<String> {
1368    match expr {
1369        Expression::ParenthesizedExpression(paren) => expression_to_path_string(&paren.expression),
1370        Expression::TSAsExpression(ts_as) => expression_to_path_string(&ts_as.expression),
1371        Expression::TSSatisfiesExpression(ts_sat) => expression_to_path_string(&ts_sat.expression),
1372        Expression::StaticMemberExpression(member) if member.property.name == "pathname" => {
1373            expression_to_path_string(&member.object)
1374        }
1375        Expression::CallExpression(call) => call_expression_to_path_string(call),
1376        Expression::NewExpression(new_expr) => new_expression_to_path_string(new_expr),
1377        _ => expression_to_string(expr),
1378    }
1379}
1380
1381fn call_expression_to_path_string(call: &CallExpression) -> Option<String> {
1382    if matches!(&call.callee, Expression::Identifier(id) if id.name == "fileURLToPath") {
1383        return call
1384            .arguments
1385            .first()
1386            .and_then(Argument::as_expression)
1387            .and_then(expression_to_path_string);
1388    }
1389
1390    let callee_name = match &call.callee {
1391        Expression::Identifier(id) => Some(id.name.as_str()),
1392        Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
1393        _ => None,
1394    }?;
1395
1396    if !matches!(callee_name, "resolve" | "join") {
1397        return None;
1398    }
1399
1400    let mut segments = Vec::new();
1401    for (index, arg) in call.arguments.iter().enumerate() {
1402        let expr = arg.as_expression()?;
1403
1404        if is_dirname_anchor(expr) {
1405            if index == 0 {
1406                continue;
1407            }
1408            return None;
1409        }
1410
1411        segments.push(expression_to_string(expr)?);
1412    }
1413
1414    (!segments.is_empty()).then(|| join_path_segments(&segments))
1415}
1416
1417/// True when an expression is a "current directory" anchor: the `__dirname`
1418/// CommonJS global or its ESM equivalent `import.meta.dirname` (Node 20.11+).
1419/// As the leading argument of `resolve(...)` / `join(...)` it is dropped so the
1420/// remaining literal segments yield a config-directory-relative path.
1421fn is_dirname_anchor(expr: &Expression) -> bool {
1422    match expr {
1423        Expression::Identifier(id) => id.name == "__dirname",
1424        Expression::StaticMemberExpression(member) => {
1425            member.property.name == "dirname" && is_import_meta_expression(&member.object)
1426        }
1427        _ => false,
1428    }
1429}
1430
1431/// True for the `import.meta` meta-property, distinct from `new.target`.
1432fn is_import_meta_expression(expr: &Expression) -> bool {
1433    matches!(
1434        expr,
1435        Expression::MetaProperty(meta) if meta.meta.name == "import" && meta.property.name == "meta"
1436    )
1437}
1438
1439fn new_expression_to_path_string(new_expr: &NewExpression) -> Option<String> {
1440    if !matches!(&new_expr.callee, Expression::Identifier(id) if id.name == "URL") {
1441        return None;
1442    }
1443
1444    let source = new_expr
1445        .arguments
1446        .first()
1447        .and_then(Argument::as_expression)
1448        .and_then(expression_to_string)?;
1449
1450    let base = new_expr
1451        .arguments
1452        .get(1)
1453        .and_then(Argument::as_expression)?;
1454    is_import_meta_url_expression(base).then_some(source)
1455}
1456
1457fn is_import_meta_url_expression(expr: &Expression) -> bool {
1458    if let Expression::StaticMemberExpression(member) = expr {
1459        member.property.name == "url" && matches!(member.object, Expression::MetaProperty(_))
1460    } else {
1461        false
1462    }
1463}
1464
1465fn join_path_segments(segments: &[String]) -> String {
1466    let mut joined = PathBuf::new();
1467    for segment in segments {
1468        joined.push(segment);
1469    }
1470    joined.to_string_lossy().replace('\\', "/")
1471}
1472
1473fn expression_to_alias_pairs(expr: &Expression) -> Vec<(String, String)> {
1474    match expr {
1475        Expression::ObjectExpression(obj) => obj
1476            .properties
1477            .iter()
1478            .filter_map(|prop| {
1479                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1480                    return None;
1481                };
1482                let find = property_key_to_string(&prop.key)?;
1483                let replacement = expression_to_path_values(&prop.value)
1484                    .into_iter()
1485                    .next()
1486                    .map(|path| path_to_config_string(&path))?;
1487                Some((find, replacement))
1488            })
1489            .collect(),
1490        Expression::ArrayExpression(arr) => arr
1491            .elements
1492            .iter()
1493            .filter_map(|element| {
1494                let Expression::ObjectExpression(obj) = element.as_expression()? else {
1495                    return None;
1496                };
1497                let find = find_property(obj, "find")
1498                    .and_then(|prop| expression_to_string(&prop.value))?;
1499                let replacement = find_property(obj, "replacement")
1500                    .and_then(|prop| expression_to_path_string(&prop.value))?;
1501                Some((find, replacement))
1502            })
1503            .collect(),
1504        _ => Vec::new(),
1505    }
1506}
1507
1508/// Kinded variant of [`expression_to_alias_pairs`]: each tuple gains a
1509/// `replacement_is_bare_string_literal` flag. See
1510/// [`extract_config_aliases_kinded`].
1511fn expression_to_alias_pairs_kinded(expr: &Expression) -> Vec<(String, String, bool)> {
1512    match expr {
1513        Expression::ObjectExpression(obj) => obj
1514            .properties
1515            .iter()
1516            .filter_map(|prop| {
1517                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1518                    return None;
1519                };
1520                let find = property_key_to_string(&prop.key)?;
1521                let (replacement, is_bare) = alias_replacement_kinded(&prop.value)?;
1522                Some((find, replacement, is_bare))
1523            })
1524            .collect(),
1525        Expression::ArrayExpression(arr) => arr
1526            .elements
1527            .iter()
1528            .filter_map(|element| {
1529                let Expression::ObjectExpression(obj) = element.as_expression()? else {
1530                    return None;
1531                };
1532                let find = find_property(obj, "find")
1533                    .and_then(|prop| expression_to_string(&prop.value))?;
1534                let (replacement, is_bare) = find_property(obj, "replacement")
1535                    .and_then(|prop| alias_replacement_kinded(&prop.value))?;
1536                Some((find, replacement, is_bare))
1537            })
1538            .collect(),
1539        _ => Vec::new(),
1540    }
1541}
1542
1543/// Extract an alias replacement string plus whether it was written as a plain
1544/// bare string literal. A bare string literal (not starting with `./`/`../`/`/`)
1545/// signals a potential package-to-package alias; a path expression
1546/// (`path.resolve(...)`, `path.join(...)`, `fileURLToPath(...)`, `new URL(...)`)
1547/// or a `./`-prefixed string is always a filesystem path. This is the
1548/// filesystem-free discriminator the package-to-package gate relies on.
1549fn alias_replacement_kinded(expr: &Expression) -> Option<(String, bool)> {
1550    match expr {
1551        Expression::ParenthesizedExpression(paren) => alias_replacement_kinded(&paren.expression),
1552        Expression::TSAsExpression(ts_as) => alias_replacement_kinded(&ts_as.expression),
1553        Expression::TSSatisfiesExpression(ts_sat) => alias_replacement_kinded(&ts_sat.expression),
1554        Expression::StringLiteral(s) => {
1555            let value = s.value.to_string();
1556            let is_bare =
1557                !value.starts_with("./") && !value.starts_with("../") && !value.starts_with('/');
1558            Some((value, is_bare))
1559        }
1560        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets
1561        // (`{ "@/*": ["./src/*"] }`); take the first entry, matching the prior
1562        // non-kinded `expression_to_path_values().next()` behavior.
1563        Expression::ArrayExpression(arr) => arr
1564            .elements
1565            .iter()
1566            .find_map(ArrayExpressionElement::as_expression)
1567            .and_then(alias_replacement_kinded),
1568        _ => expression_to_path_string(expr).map(|value| (value, false)),
1569    }
1570}
1571
1572/// Maximum identifier-indirection hops the alias resolver follows before giving
1573/// up. Each local-variable or imported-binding resolution counts one hop. The
1574/// per-file `visited` set is the real cycle guard; this bound additionally
1575/// terminates pathological local self-references (`const a = a`). Real configs
1576/// rarely exceed one or two hops (`alias: importedAliases`).
1577const MAX_ALIAS_RESOLVE_DEPTH: usize = 8;
1578
1579/// Sibling-file extensions probed when an alias identifier is imported from a
1580/// relative specifier. Mirrors the JS/TS config extensions Vite/Vitest configs
1581/// and their shared alias modules use. `.js` first matches the common
1582/// JS-project case; the direct-as-written read happens before any probing. JSON
1583/// is intentionally excluded: it parses as a bare expression with no `export`,
1584/// so `find_exported_init` could never recover an alias literal from it.
1585const ALIAS_SIBLING_EXTS: [&str; 6] = ["js", "mjs", "cjs", "ts", "mts", "cts"];
1586
1587/// Resolve an alias expression into `(find, replacement, is_bare)` tuples,
1588/// following identifiers and expanding spreads.
1589///
1590/// Beyond the inline object (`{ '@': './src' }`) and array
1591/// (`[{ find, replacement }]`) forms, this handles the indirection shapes from
1592/// issue #811:
1593/// - an identifier bound to a local `const NAME = [...] | {...}`,
1594/// - an identifier imported from a relative sibling file
1595///   (`import { sharedAliases } from "./vite.shared.js"`), read one hop and
1596///   parsed for `export const NAME` / `export default` / `export { NAME }`,
1597/// - array spread elements (`[...a, ...b]`) and object spread properties
1598///   (`{ ...a, '@': './src' }`), each resolved recursively.
1599///
1600/// `config_path` is the file `expr` lives in (used to resolve relative sibling
1601/// imports). `visited` holds already-read sibling paths to break import cycles;
1602/// `depth` bounds identifier indirection via [`MAX_ALIAS_RESOLVE_DEPTH`].
1603fn resolve_alias_pairs_kinded(
1604    program: &Program,
1605    config_path: &Path,
1606    expr: &Expression,
1607    visited: &mut FxHashSet<PathBuf>,
1608    depth: usize,
1609) -> Vec<(String, String, bool)> {
1610    match expr {
1611        Expression::ParenthesizedExpression(paren) => {
1612            resolve_alias_pairs_kinded(program, config_path, &paren.expression, visited, depth)
1613        }
1614        Expression::TSAsExpression(ts_as) => {
1615            resolve_alias_pairs_kinded(program, config_path, &ts_as.expression, visited, depth)
1616        }
1617        Expression::TSSatisfiesExpression(ts_sat) => {
1618            resolve_alias_pairs_kinded(program, config_path, &ts_sat.expression, visited, depth)
1619        }
1620        Expression::ObjectExpression(obj) => {
1621            let mut pairs = Vec::new();
1622            for prop in &obj.properties {
1623                match prop {
1624                    ObjectPropertyKind::ObjectProperty(prop) => {
1625                        if let Some(find) = property_key_to_string(&prop.key)
1626                            && let Some((replacement, is_bare)) =
1627                                alias_replacement_kinded(&prop.value)
1628                        {
1629                            pairs.push((find, replacement, is_bare));
1630                        }
1631                    }
1632                    // `{ ...sharedAliases, '@': './src' }`
1633                    ObjectPropertyKind::SpreadProperty(spread) => {
1634                        pairs.extend(resolve_alias_pairs_kinded(
1635                            program,
1636                            config_path,
1637                            &spread.argument,
1638                            visited,
1639                            depth,
1640                        ));
1641                    }
1642                }
1643            }
1644            pairs
1645        }
1646        Expression::ArrayExpression(arr) => {
1647            let mut pairs = Vec::new();
1648            for element in &arr.elements {
1649                match element {
1650                    // `[...sharedAliases, { find, replacement }]`
1651                    ArrayExpressionElement::SpreadElement(spread) => {
1652                        pairs.extend(resolve_alias_pairs_kinded(
1653                            program,
1654                            config_path,
1655                            &spread.argument,
1656                            visited,
1657                            depth,
1658                        ));
1659                    }
1660                    _ => {
1661                        if let Some(Expression::ObjectExpression(obj)) = element.as_expression()
1662                            && let Some(find) = find_property(obj, "find")
1663                                .and_then(|prop| expression_to_string(&prop.value))
1664                            && let Some((replacement, is_bare)) = find_property(obj, "replacement")
1665                                .and_then(|prop| alias_replacement_kinded(&prop.value))
1666                        {
1667                            pairs.push((find, replacement, is_bare));
1668                        }
1669                    }
1670                }
1671            }
1672            pairs
1673        }
1674        Expression::Identifier(id) => {
1675            resolve_identifier_alias_pairs(program, config_path, id.name.as_str(), visited, depth)
1676        }
1677        _ => Vec::new(),
1678    }
1679}
1680
1681/// Resolve an identifier used as an alias value to its literal pairs, first by
1682/// local `const`/`let`/`var` binding, then by a one-hop relative import.
1683fn resolve_identifier_alias_pairs(
1684    program: &Program,
1685    config_path: &Path,
1686    name: &str,
1687    visited: &mut FxHashSet<PathBuf>,
1688    depth: usize,
1689) -> Vec<(String, String, bool)> {
1690    if depth >= MAX_ALIAS_RESOLVE_DEPTH {
1691        return Vec::new();
1692    }
1693    // Local `const NAME = [...] | {...}` (or `const NAME = otherIdentifier`).
1694    if let Some(init) = find_variable_init_expression(program, name) {
1695        return resolve_alias_pairs_kinded(program, config_path, init, visited, depth + 1);
1696    }
1697    // `import { NAME } from "./sibling"` / `import NAME from "./sibling"`.
1698    let Some((specifier, imported_name)) = find_relative_import_binding(program, name) else {
1699        return Vec::new();
1700    };
1701    resolve_imported_alias_pairs(
1702        config_path,
1703        &specifier,
1704        imported_name.as_deref(),
1705        visited,
1706        depth + 1,
1707    )
1708}
1709
1710/// Read a relative sibling file and resolve the alias literal it exports under
1711/// `imported_name` (`None` = default export).
1712fn resolve_imported_alias_pairs(
1713    config_path: &Path,
1714    specifier: &str,
1715    imported_name: Option<&str>,
1716    visited: &mut FxHashSet<PathBuf>,
1717    depth: usize,
1718) -> Vec<(String, String, bool)> {
1719    let Some((sibling_path, sibling_source)) = resolve_sibling_module(config_path, specifier)
1720    else {
1721        return Vec::new();
1722    };
1723    if !visited.insert(sibling_path.clone()) {
1724        return Vec::new();
1725    }
1726    extract_from_source(&sibling_source, &sibling_path, |program| {
1727        let init = find_exported_init(program, imported_name)?;
1728        let pairs = resolve_alias_pairs_kinded(program, &sibling_path, init, visited, depth);
1729        (!pairs.is_empty()).then_some(pairs)
1730    })
1731    .unwrap_or_default()
1732}
1733
1734/// Find a top-level variable declaration by name and return its init expression
1735/// (array, object, or another identifier). Covers bare `const NAME = ...` and
1736/// `export const NAME = ...`. Generalizes [`find_variable_init_object`] to any
1737/// init shape so the alias resolver can recurse on array/identifier inits.
1738fn find_variable_init_expression<'a>(
1739    program: &'a Program<'a>,
1740    name: &str,
1741) -> Option<&'a Expression<'a>> {
1742    for stmt in &program.body {
1743        let decl = match stmt {
1744            Statement::VariableDeclaration(decl) => decl,
1745            Statement::ExportNamedDeclaration(export) => match &export.declaration {
1746                Some(Declaration::VariableDeclaration(decl)) => decl,
1747                _ => continue,
1748            },
1749            _ => continue,
1750        };
1751        for declarator in &decl.declarations {
1752            if let BindingPattern::BindingIdentifier(id) = &declarator.id
1753                && id.name == name
1754                && let Some(init) = &declarator.init
1755            {
1756                return Some(init);
1757            }
1758        }
1759    }
1760    None
1761}
1762
1763/// Find the init expression a sibling module exports under `name`
1764/// (`None` = default export). For named exports this covers both
1765/// `export const NAME = ...` and a local `const NAME = ...` later re-exported
1766/// via `export { NAME }` (both surface through [`find_variable_init_expression`]).
1767fn find_exported_init<'a>(
1768    program: &'a Program<'a>,
1769    name: Option<&str>,
1770) -> Option<&'a Expression<'a>> {
1771    match name {
1772        Some(name) => find_variable_init_expression(program, name),
1773        None => program.body.iter().find_map(|stmt| {
1774            if let Statement::ExportDefaultDeclaration(decl) = stmt {
1775                decl.declaration.as_expression()
1776            } else {
1777                None
1778            }
1779        }),
1780    }
1781}
1782
1783/// Find the import that binds local `name` to a RELATIVE module, returning the
1784/// specifier and the imported name (`None` for a default import). Bare-package
1785/// imports are intentionally skipped: reading a literal alias table out of
1786/// `node_modules` is not a real-world config shape.
1787fn find_relative_import_binding(program: &Program, name: &str) -> Option<(String, Option<String>)> {
1788    for stmt in &program.body {
1789        let Statement::ImportDeclaration(decl) = stmt else {
1790            continue;
1791        };
1792        let specifier = decl.source.value.as_str();
1793        if !is_relative_specifier(specifier) {
1794            continue;
1795        }
1796        let Some(specifiers) = &decl.specifiers else {
1797            continue;
1798        };
1799        for spec in specifiers {
1800            match spec {
1801                ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.local.name == name => {
1802                    return Some((
1803                        specifier.to_string(),
1804                        Some(spec.imported.name().to_string()),
1805                    ));
1806                }
1807                ImportDeclarationSpecifier::ImportDefaultSpecifier(spec)
1808                    if spec.local.name == name =>
1809                {
1810                    return Some((specifier.to_string(), None));
1811                }
1812                _ => {}
1813            }
1814        }
1815    }
1816    None
1817}
1818
1819/// True for a relative/absolute module specifier (`./x`, `../x`, `/x`), the
1820/// shapes that point at a sibling file rather than an npm package.
1821fn is_relative_specifier(specifier: &str) -> bool {
1822    specifier.starts_with("./") || specifier.starts_with("../") || specifier.starts_with('/')
1823}
1824
1825/// Resolve a relative specifier against `config_path`'s directory to a readable
1826/// sibling file, returning the resolved path and its source. Tries the path as
1827/// written first (covers `./vite.shared.js`), then appends each known config
1828/// extension (covers extensionless `./vite.shared` and dotted basenames where
1829/// `Path::extension` would misread `.shared`), then an `index.*` directory file.
1830fn resolve_sibling_module(config_path: &Path, specifier: &str) -> Option<(PathBuf, String)> {
1831    let parent = config_path.parent().unwrap_or(config_path);
1832    let direct = parent.join(specifier);
1833    if let Ok(source) = std::fs::read_to_string(&direct) {
1834        return Some((direct, source));
1835    }
1836    for ext in ALIAS_SIBLING_EXTS {
1837        let candidate = parent.join(format!("{specifier}.{ext}"));
1838        if let Ok(source) = std::fs::read_to_string(&candidate) {
1839            return Some((candidate, source));
1840        }
1841    }
1842    for ext in ALIAS_SIBLING_EXTS {
1843        let candidate = direct.join(format!("index.{ext}"));
1844        if let Ok(source) = std::fs::read_to_string(&candidate) {
1845            return Some((candidate, source));
1846        }
1847    }
1848    None
1849}
1850
1851/// Find a default-exported array config, the `defineWorkspace([...])` /
1852/// `vitest.workspace.{ts,js}` shape. Handles `export default [...]` and
1853/// `export default defineWorkspace([...])` / `defineConfig([...])` (the array as
1854/// the call's first argument), plus parenthesised / `as` wrappers.
1855fn find_default_export_array<'a>(program: &'a Program<'a>) -> Option<&'a ArrayExpression<'a>> {
1856    for stmt in &program.body {
1857        if let Statement::ExportDefaultDeclaration(decl) = stmt
1858            && let Some(expr) = decl.declaration.as_expression()
1859        {
1860            return array_from_expression(expr);
1861        }
1862    }
1863    None
1864}
1865
1866fn array_from_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
1867    match expr {
1868        Expression::ArrayExpression(arr) => Some(arr),
1869        Expression::ParenthesizedExpression(paren) => array_from_expression(&paren.expression),
1870        Expression::TSAsExpression(ts_as) => array_from_expression(&ts_as.expression),
1871        Expression::TSSatisfiesExpression(ts_sat) => array_from_expression(&ts_sat.expression),
1872        Expression::CallExpression(call) => call
1873            .arguments
1874            .first()
1875            .and_then(Argument::as_expression)
1876            .and_then(array_from_expression),
1877        _ => None,
1878    }
1879}
1880
1881pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
1882    let mut normalized = PathBuf::new();
1883
1884    for component in path.components() {
1885        match component {
1886            std::path::Component::CurDir => {}
1887            std::path::Component::ParentDir => {
1888                normalized.pop();
1889            }
1890            _ => normalized.push(component.as_os_str()),
1891        }
1892    }
1893
1894    normalized
1895}
1896
1897/// Convert an expression to a string array if it's an array of string literals.
1898fn expression_to_string_array(expr: &Expression) -> Vec<String> {
1899    match expr {
1900        Expression::ArrayExpression(arr) => arr
1901            .elements
1902            .iter()
1903            .filter_map(|el| match el {
1904                ArrayExpressionElement::SpreadElement(_) => None,
1905                _ => el.as_expression().and_then(expression_to_string),
1906            })
1907            .collect(),
1908        _ => vec![],
1909    }
1910}
1911
1912/// Collect only top-level string values from an expression.
1913///
1914/// For arrays, extracts direct string elements and the first string element of sub-arrays
1915/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
1916fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
1917    let mut values = Vec::new();
1918    match expr {
1919        Expression::StringLiteral(s) => {
1920            values.push(s.value.to_string());
1921        }
1922        Expression::ArrayExpression(arr) => {
1923            for el in &arr.elements {
1924                if let Some(inner) = el.as_expression() {
1925                    match inner {
1926                        Expression::StringLiteral(s) => {
1927                            values.push(s.value.to_string());
1928                        }
1929                        Expression::ArrayExpression(sub_arr) => {
1930                            if let Some(first) = sub_arr.elements.first()
1931                                && let Some(first_expr) = first.as_expression()
1932                                && let Some(s) = expression_to_string(first_expr)
1933                            {
1934                                values.push(s);
1935                            }
1936                        }
1937                        _ => {}
1938                    }
1939                }
1940            }
1941        }
1942        Expression::ObjectExpression(obj) => {
1943            for prop in &obj.properties {
1944                if let ObjectPropertyKind::ObjectProperty(p) = prop {
1945                    match &p.value {
1946                        Expression::StringLiteral(s) => {
1947                            values.push(s.value.to_string());
1948                        }
1949                        Expression::ArrayExpression(sub_arr) => {
1950                            if let Some(first) = sub_arr.elements.first()
1951                                && let Some(first_expr) = first.as_expression()
1952                                && let Some(s) = expression_to_string(first_expr)
1953                            {
1954                                values.push(s);
1955                            }
1956                        }
1957                        _ => {}
1958                    }
1959                }
1960            }
1961        }
1962        _ => {}
1963    }
1964    values
1965}
1966
1967/// Collect top-level string values, plus a named string property from object entries.
1968fn collect_shallow_string_or_object_property_values(
1969    expr: &Expression,
1970    object_property: &str,
1971) -> Vec<String> {
1972    match expr {
1973        Expression::ArrayExpression(arr) => arr
1974            .elements
1975            .iter()
1976            .filter_map(|element| {
1977                element
1978                    .as_expression()
1979                    .and_then(|expr| shallow_string_or_object_property(expr, object_property))
1980            })
1981            .collect(),
1982        _ => shallow_string_or_object_property(expr, object_property)
1983            .into_iter()
1984            .collect(),
1985    }
1986}
1987
1988fn shallow_string_or_object_property(expr: &Expression, object_property: &str) -> Option<String> {
1989    match expr {
1990        Expression::ParenthesizedExpression(paren) => {
1991            shallow_string_or_object_property(&paren.expression, object_property)
1992        }
1993        Expression::TSSatisfiesExpression(ts_sat) => {
1994            shallow_string_or_object_property(&ts_sat.expression, object_property)
1995        }
1996        Expression::TSAsExpression(ts_as) => {
1997            shallow_string_or_object_property(&ts_as.expression, object_property)
1998        }
1999        Expression::ArrayExpression(sub_arr) => sub_arr
2000            .elements
2001            .first()
2002            .and_then(ArrayExpressionElement::as_expression)
2003            .and_then(expression_to_string),
2004        Expression::ObjectExpression(obj) => {
2005            find_property(obj, object_property).and_then(|prop| expression_to_string(&prop.value))
2006        }
2007        _ => expression_to_string(expr),
2008    }
2009}
2010
2011/// Recursively collect all string literal values from an expression tree.
2012fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
2013    match expr {
2014        Expression::StringLiteral(s) => {
2015            values.push(s.value.to_string());
2016        }
2017        Expression::ArrayExpression(arr) => {
2018            for el in &arr.elements {
2019                if let Some(expr) = el.as_expression() {
2020                    collect_all_string_values(expr, values);
2021                }
2022            }
2023        }
2024        Expression::ObjectExpression(obj) => {
2025            for prop in &obj.properties {
2026                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2027                    collect_all_string_values(&p.value, values);
2028                }
2029            }
2030        }
2031        _ => {}
2032    }
2033}
2034
2035/// Convert a `PropertyKey` to a `String`.
2036fn property_key_to_string(key: &PropertyKey) -> Option<String> {
2037    match key {
2038        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
2039        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
2040        _ => None,
2041    }
2042}
2043
2044/// Extract keys of an object at a nested property path.
2045fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2046    if path.is_empty() {
2047        return None;
2048    }
2049    let prop = find_property(obj, path[0])?;
2050    if path.len() == 1 {
2051        if let Expression::ObjectExpression(nested) = &prop.value {
2052            let keys = nested
2053                .properties
2054                .iter()
2055                .filter_map(|p| {
2056                    if let ObjectPropertyKind::ObjectProperty(p) = p {
2057                        property_key_to_string(&p.key)
2058                    } else {
2059                        None
2060                    }
2061                })
2062                .collect();
2063            return Some(keys);
2064        }
2065        return None;
2066    }
2067    if let Expression::ObjectExpression(nested) = &prop.value {
2068        get_nested_object_keys(nested, &path[1..])
2069    } else {
2070        None
2071    }
2072}
2073
2074/// Navigate a nested property path and return the raw expression at the end.
2075fn get_nested_expression<'a>(
2076    obj: &'a ObjectExpression<'a>,
2077    path: &[&str],
2078) -> Option<&'a Expression<'a>> {
2079    if path.is_empty() {
2080        return None;
2081    }
2082    let prop = find_property(obj, path[0])?;
2083    if path.len() == 1 {
2084        return Some(&prop.value);
2085    }
2086    if let Expression::ObjectExpression(nested) = &prop.value {
2087        get_nested_expression(nested, &path[1..])
2088    } else {
2089        None
2090    }
2091}
2092
2093/// Navigate a nested path and extract a string, string array, or object string/array values.
2094fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2095    if path.is_empty() {
2096        return None;
2097    }
2098    if path.len() == 1 {
2099        let prop = find_property(obj, path[0])?;
2100        return Some(expression_to_string_or_array(&prop.value));
2101    }
2102    let prop = find_property(obj, path[0])?;
2103    if let Expression::ObjectExpression(nested) = &prop.value {
2104        get_nested_string_or_array(nested, &path[1..])
2105    } else {
2106        None
2107    }
2108}
2109
2110/// Convert an expression to a `Vec<String>`, handling string, array, object-with-string/array values,
2111/// and Webpack 5 entry descriptors (`{ import: "..." }`).
2112///
2113/// Array elements that are object literals are inspected for an `input` property
2114/// (Angular CLI schema for `styles`/`scripts`/`polyfills`:
2115/// `{ "input": "src/x.scss", "bundleName": "x", "inject": false }`). Extracting
2116/// `input` prevents object-form entries from being silently dropped. See #126.
2117fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
2118    match expr {
2119        Expression::StringLiteral(s) => vec![s.value.to_string()],
2120        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
2121            .quasis
2122            .first()
2123            .map(|q| vec![q.value.raw.to_string()])
2124            .unwrap_or_default(),
2125        Expression::ArrayExpression(arr) => arr
2126            .elements
2127            .iter()
2128            .filter_map(|el| el.as_expression())
2129            .flat_map(|e| match e {
2130                Expression::ObjectExpression(obj) => find_property(obj, "input")
2131                    .map(|p| expression_to_string_or_array(&p.value))
2132                    .unwrap_or_default(),
2133                _ => expression_to_path_string(e).into_iter().collect(),
2134            })
2135            .collect(),
2136        Expression::ObjectExpression(obj) => obj
2137            .properties
2138            .iter()
2139            .flat_map(|p| {
2140                if let ObjectPropertyKind::ObjectProperty(p) = p {
2141                    match &p.value {
2142                        Expression::ArrayExpression(_) => expression_to_string_or_array(&p.value),
2143                        Expression::ObjectExpression(value_obj) => {
2144                            find_property(value_obj, "import")
2145                                .map(|import_prop| {
2146                                    expression_to_string_or_array(&import_prop.value)
2147                                })
2148                                .unwrap_or_default()
2149                        }
2150                        _ => expression_to_path_string(&p.value).into_iter().collect(),
2151                    }
2152                } else {
2153                    Vec::new()
2154                }
2155            })
2156            .collect(),
2157        _ => expression_to_path_string(expr).into_iter().collect(),
2158    }
2159}
2160
2161/// Collect `require('...')` argument strings from an expression.
2162fn collect_require_sources(expr: &Expression) -> Vec<String> {
2163    let mut sources = Vec::new();
2164    match expr {
2165        Expression::CallExpression(call) if is_require_call(call) => {
2166            if let Some(s) = get_require_source(call) {
2167                sources.push(s);
2168            }
2169        }
2170        Expression::ArrayExpression(arr) => {
2171            for el in &arr.elements {
2172                if let Some(inner) = el.as_expression() {
2173                    match inner {
2174                        Expression::CallExpression(call) if is_require_call(call) => {
2175                            if let Some(s) = get_require_source(call) {
2176                                sources.push(s);
2177                            }
2178                        }
2179                        Expression::ArrayExpression(sub_arr) => {
2180                            if let Some(first) = sub_arr.elements.first()
2181                                && let Some(Expression::CallExpression(call)) =
2182                                    first.as_expression()
2183                                && is_require_call(call)
2184                                && let Some(s) = get_require_source(call)
2185                            {
2186                                sources.push(s);
2187                            }
2188                        }
2189                        _ => {}
2190                    }
2191                }
2192            }
2193        }
2194        _ => {}
2195    }
2196    sources
2197}
2198
2199/// Check if a call expression is `require(...)`.
2200fn is_require_call(call: &CallExpression) -> bool {
2201    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
2202}
2203
2204/// Get the first string argument of a `require()` call.
2205fn get_require_source(call: &CallExpression) -> Option<String> {
2206    call.arguments.first().and_then(|arg| {
2207        if let Argument::StringLiteral(s) = arg {
2208            Some(s.value.to_string())
2209        } else {
2210            None
2211        }
2212    })
2213}
2214
2215#[cfg(test)]
2216mod tests {
2217    use super::*;
2218    use std::path::PathBuf;
2219
2220    fn js_path() -> PathBuf {
2221        PathBuf::from("config.js")
2222    }
2223
2224    fn ts_path() -> PathBuf {
2225        PathBuf::from("config.ts")
2226    }
2227
2228    #[test]
2229    fn extract_lazy_imports_bare_arrows() {
2230        let source = r"
2231            import { defineConfig } from '@adonisjs/core/app'
2232            export default defineConfig({
2233                preloads: [
2234                    () => import('#start/routes'),
2235                    () => import('#start/kernel'),
2236                ],
2237            })
2238        ";
2239        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["preloads"]);
2240        assert_eq!(specs, vec!["#start/routes", "#start/kernel"]);
2241    }
2242
2243    #[test]
2244    fn extract_lazy_imports_object_form_with_file_key() {
2245        let source = r"
2246            export default defineConfig({
2247                providers: [
2248                    () => import('@adonisjs/core/providers/app_provider'),
2249                    {
2250                        file: () => import('@adonisjs/core/providers/repl_provider'),
2251                        environment: ['repl', 'test'],
2252                    },
2253                ],
2254            })
2255        ";
2256        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2257        assert_eq!(
2258            specs,
2259            vec![
2260                "@adonisjs/core/providers/app_provider",
2261                "@adonisjs/core/providers/repl_provider",
2262            ]
2263        );
2264    }
2265
2266    #[test]
2267    fn extract_lazy_imports_block_body_with_return() {
2268        let source = r"
2269            export default defineConfig({
2270                commands: [
2271                    () => { return import('@adonisjs/core/commands') },
2272                ],
2273            })
2274        ";
2275        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2276        assert_eq!(specs, vec!["@adonisjs/core/commands"]);
2277    }
2278
2279    #[test]
2280    fn extract_lazy_imports_skips_unknown_element_shapes() {
2281        let source = r"
2282            export default defineConfig({
2283                commands: [
2284                    'string-entry',
2285                    42,
2286                    { other: 'value' },
2287                    () => import('@adonisjs/lucid/commands'),
2288                ],
2289            })
2290        ";
2291        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2292        assert_eq!(specs, vec!["@adonisjs/lucid/commands"]);
2293    }
2294
2295    #[test]
2296    fn extract_lazy_imports_missing_property_returns_empty() {
2297        let source = r"
2298            export default defineConfig({
2299                preloads: [() => import('#start/routes')],
2300            })
2301        ";
2302        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2303        assert!(specs.is_empty());
2304    }
2305
2306    #[test]
2307    fn extract_imports_basic() {
2308        let source = r"
2309            import foo from 'foo-pkg';
2310            import { bar } from '@scope/bar';
2311            export default {};
2312        ";
2313        let imports = extract_imports(source, &js_path());
2314        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
2315    }
2316
2317    #[test]
2318    fn extract_default_export_object_property() {
2319        let source = r#"export default { testDir: "./tests" };"#;
2320        let val = extract_config_string(source, &js_path(), &["testDir"]);
2321        assert_eq!(val, Some("./tests".to_string()));
2322    }
2323
2324    #[test]
2325    fn extract_define_config_property() {
2326        let source = r#"
2327            import { defineConfig } from 'vitest/config';
2328            export default defineConfig({
2329                test: {
2330                    include: ["**/*.test.ts", "**/*.spec.ts"],
2331                    setupFiles: ["./test/setup.ts"]
2332                }
2333            });
2334        "#;
2335        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
2336        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
2337
2338        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
2339        assert_eq!(setup, vec!["./test/setup.ts"]);
2340    }
2341
2342    #[test]
2343    fn extract_module_exports_property() {
2344        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
2345        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
2346        assert_eq!(val, Some("jsdom".to_string()));
2347    }
2348
2349    #[test]
2350    fn extract_nested_string_array() {
2351        let source = r#"
2352            export default {
2353                resolve: {
2354                    alias: {
2355                        "@": "./src"
2356                    }
2357                },
2358                test: {
2359                    include: ["src/**/*.test.ts"]
2360                }
2361            };
2362        "#;
2363        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
2364        assert_eq!(include, vec!["src/**/*.test.ts"]);
2365    }
2366
2367    #[test]
2368    fn extract_addons_array() {
2369        let source = r#"
2370            export default {
2371                addons: [
2372                    "@storybook/addon-a11y",
2373                    "@storybook/addon-docs",
2374                    "@storybook/addon-links"
2375                ]
2376            };
2377        "#;
2378        let addons = extract_config_property_strings(source, &ts_path(), "addons");
2379        assert_eq!(
2380            addons,
2381            vec![
2382                "@storybook/addon-a11y",
2383                "@storybook/addon-docs",
2384                "@storybook/addon-links"
2385            ]
2386        );
2387    }
2388
2389    #[test]
2390    fn handle_empty_config() {
2391        let source = "";
2392        let result = extract_config_string(source, &js_path(), &["key"]);
2393        assert_eq!(result, None);
2394    }
2395
2396    #[test]
2397    fn object_keys_postcss_plugins() {
2398        let source = r"
2399            module.exports = {
2400                plugins: {
2401                    autoprefixer: {},
2402                    tailwindcss: {},
2403                    'postcss-import': {}
2404                }
2405            };
2406        ";
2407        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2408        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
2409    }
2410
2411    #[test]
2412    fn object_keys_nested_path() {
2413        let source = r"
2414            export default {
2415                build: {
2416                    plugins: {
2417                        minify: {},
2418                        compress: {}
2419                    }
2420                }
2421            };
2422        ";
2423        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
2424        assert_eq!(keys, vec!["minify", "compress"]);
2425    }
2426
2427    #[test]
2428    fn object_keys_empty_object() {
2429        let source = r"export default { plugins: {} };";
2430        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2431        assert!(keys.is_empty());
2432    }
2433
2434    #[test]
2435    fn object_keys_non_object_returns_empty() {
2436        let source = r#"export default { plugins: ["a", "b"] };"#;
2437        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2438        assert!(keys.is_empty());
2439    }
2440
2441    #[test]
2442    fn string_or_array_single_string() {
2443        let source = r#"export default { entry: "./src/index.js" };"#;
2444        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2445        assert_eq!(result, vec!["./src/index.js"]);
2446    }
2447
2448    #[test]
2449    fn string_or_array_array() {
2450        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
2451        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2452        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
2453    }
2454
2455    #[test]
2456    fn string_or_array_object_values() {
2457        let source =
2458            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
2459        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2460        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
2461    }
2462
2463    #[test]
2464    fn string_or_array_object_array_values() {
2465        let source = r#"export default { entry: { app: ["./src/polyfill.js", "./src/app.js"] } };"#;
2466        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2467        assert_eq!(result, vec!["./src/polyfill.js", "./src/app.js"]);
2468    }
2469
2470    #[test]
2471    fn string_or_array_webpack_entry_descriptors() {
2472        let source = r#"
2473            export default {
2474                entry: {
2475                    app: {
2476                        import: "./src/app.js",
2477                        filename: "pages/app.js",
2478                        dependOn: "shared",
2479                    },
2480                    admin: {
2481                        import: ["./src/admin-polyfill.js", "./src/admin.js"],
2482                        runtime: "runtime",
2483                    },
2484                    shared: ["react", "react-dom"],
2485                },
2486            };
2487        "#;
2488        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2489        assert_eq!(
2490            result,
2491            vec![
2492                "./src/app.js",
2493                "./src/admin-polyfill.js",
2494                "./src/admin.js",
2495                "react",
2496                "react-dom"
2497            ]
2498        );
2499    }
2500
2501    #[test]
2502    fn string_or_array_nested_path() {
2503        let source = r#"
2504            export default {
2505                build: {
2506                    rollupOptions: {
2507                        input: ["./index.html", "./about.html"]
2508                    }
2509                }
2510            };
2511        "#;
2512        let result = extract_config_string_or_array(
2513            source,
2514            &js_path(),
2515            &["build", "rollupOptions", "input"],
2516        );
2517        assert_eq!(result, vec!["./index.html", "./about.html"]);
2518    }
2519
2520    #[test]
2521    fn string_or_array_template_literal() {
2522        let source = r"export default { entry: `./src/index.js` };";
2523        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2524        assert_eq!(result, vec!["./src/index.js"]);
2525    }
2526
2527    #[test]
2528    fn string_or_array_object_path_helper_values() {
2529        let source = r#"
2530            import { resolve, join } from "node:path";
2531            import path from "node:path";
2532            export default {
2533                build: {
2534                    rollupOptions: {
2535                        input: {
2536                            app: resolve(__dirname, "src/app.ts"),
2537                            modal: path.resolve(__dirname, "src/modal.ts"),
2538                            tabs: join(__dirname, "src/tabs.ts"),
2539                            styles: resolve(__dirname, "src/index.css"),
2540                        },
2541                    },
2542                },
2543            };
2544        "#;
2545        let result = extract_config_string_or_array(
2546            source,
2547            &js_path(),
2548            &["build", "rollupOptions", "input"],
2549        );
2550        assert_eq!(
2551            result,
2552            vec!["src/app.ts", "src/modal.ts", "src/tabs.ts", "src/index.css"]
2553        );
2554    }
2555
2556    #[test]
2557    fn string_or_array_array_path_helper_values() {
2558        let source = r#"
2559            import { resolve } from "node:path";
2560            export default {
2561                build: {
2562                    rollupOptions: {
2563                        input: [resolve(__dirname, "src/a.ts"), "./src/b.ts"],
2564                    },
2565                },
2566            };
2567        "#;
2568        let result = extract_config_string_or_array(
2569            source,
2570            &js_path(),
2571            &["build", "rollupOptions", "input"],
2572        );
2573        assert_eq!(result, vec!["src/a.ts", "./src/b.ts"]);
2574    }
2575
2576    #[test]
2577    fn string_or_array_top_level_path_helper_call() {
2578        let source = r#"
2579            import { resolve } from "node:path";
2580            export default { build: { lib: { entry: resolve(__dirname, "src/index.ts") } } };
2581        "#;
2582        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2583        assert_eq!(result, vec!["src/index.ts"]);
2584    }
2585
2586    #[test]
2587    fn string_or_array_import_meta_dirname_anchor() {
2588        let source = r#"
2589            import { resolve } from "node:path";
2590            export default {
2591                build: { lib: { entry: resolve(import.meta.dirname, "src/index.ts") } },
2592            };
2593        "#;
2594        let result = extract_config_string_or_array(source, &ts_path(), &["build", "lib", "entry"]);
2595        assert_eq!(result, vec!["src/index.ts"]);
2596    }
2597
2598    #[test]
2599    fn string_or_array_non_literal_path_helper_args_dropped() {
2600        let source = r#"
2601            import { resolve } from "node:path";
2602            export default { build: { lib: { entry: resolve(baseDir, "src/index.ts") } } };
2603        "#;
2604        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2605        assert!(
2606            result.is_empty(),
2607            "non-literal path-helper args must be dropped: {result:?}"
2608        );
2609    }
2610
2611    #[test]
2612    fn require_strings_array() {
2613        let source = r"
2614            module.exports = {
2615                plugins: [
2616                    require('autoprefixer'),
2617                    require('postcss-import')
2618                ]
2619            };
2620        ";
2621        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2622        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
2623    }
2624
2625    #[test]
2626    fn require_strings_with_tuples() {
2627        let source = r"
2628            module.exports = {
2629                plugins: [
2630                    require('autoprefixer'),
2631                    [require('postcss-preset-env'), { stage: 3 }]
2632                ]
2633            };
2634        ";
2635        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2636        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
2637    }
2638
2639    #[test]
2640    fn require_strings_empty_array() {
2641        let source = r"module.exports = { plugins: [] };";
2642        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2643        assert!(deps.is_empty());
2644    }
2645
2646    #[test]
2647    fn require_strings_no_require_calls() {
2648        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
2649        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2650        assert!(deps.is_empty());
2651    }
2652
2653    #[test]
2654    fn extract_aliases_from_object_with_file_url_to_path() {
2655        let source = r#"
2656            import { defineConfig } from 'vite';
2657            import { fileURLToPath, URL } from 'node:url';
2658
2659            export default defineConfig({
2660                resolve: {
2661                    alias: {
2662                        "@": fileURLToPath(new URL("./src", import.meta.url))
2663                    }
2664                }
2665            });
2666        "#;
2667
2668        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2669        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
2670    }
2671
2672    #[test]
2673    fn extract_aliases_from_array_form() {
2674        let source = r#"
2675            export default {
2676                resolve: {
2677                    alias: [
2678                        { find: "@", replacement: "./src" },
2679                        { find: "$utils", replacement: "src/lib/utils" }
2680                    ]
2681                }
2682            };
2683        "#;
2684
2685        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2686        assert_eq!(
2687            aliases,
2688            vec![
2689                ("@".to_string(), "./src".to_string()),
2690                ("$utils".to_string(), "src/lib/utils".to_string())
2691            ]
2692        );
2693    }
2694
2695    #[test]
2696    fn extract_aliases_from_object_with_array_values() {
2697        let source = r#"
2698            ({
2699                compilerOptions: {
2700                    paths: {
2701                        "@/*": ["./src/*"],
2702                        "@shared/*": ["./shared/*", "./fallback/*"]
2703                    }
2704                }
2705            })
2706        "#;
2707
2708        let aliases = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
2709        assert_eq!(
2710            aliases,
2711            vec![
2712                ("@/*".to_string(), "./src/*".to_string()),
2713                ("@shared/*".to_string(), "./shared/*".to_string())
2714            ]
2715        );
2716    }
2717
2718    #[test]
2719    fn extract_array_object_strings_mixed_forms() {
2720        let source = r#"
2721            export default {
2722                components: [
2723                    "~/components",
2724                    { path: "@/feature-components" }
2725                ]
2726            };
2727        "#;
2728
2729        let values =
2730            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
2731        assert_eq!(
2732            values,
2733            vec![
2734                "~/components".to_string(),
2735                "@/feature-components".to_string()
2736            ]
2737        );
2738    }
2739
2740    #[test]
2741    fn extract_array_object_string_pairs_with_and_without_secondary() {
2742        let source = r#"
2743            export default {
2744                webServer: [
2745                    { command: "tsx scripts/api.ts", cwd: "packages/api" },
2746                    { command: "tsx scripts/web.ts" }
2747                ]
2748            };
2749        "#;
2750
2751        let pairs = extract_config_array_object_string_pairs(
2752            source,
2753            &ts_path(),
2754            &["webServer"],
2755            "command",
2756            "cwd",
2757        );
2758        assert_eq!(
2759            pairs,
2760            vec![
2761                (
2762                    "tsx scripts/api.ts".to_string(),
2763                    Some("packages/api".to_string())
2764                ),
2765                ("tsx scripts/web.ts".to_string(), None),
2766            ]
2767        );
2768    }
2769
2770    #[test]
2771    fn extract_array_object_string_pairs_skips_elements_missing_primary() {
2772        let source = r#"
2773            export default {
2774                webServer: [
2775                    { cwd: "packages/api" },
2776                    { command: "srvx --port 3000" }
2777                ]
2778            };
2779        "#;
2780
2781        let pairs = extract_config_array_object_string_pairs(
2782            source,
2783            &ts_path(),
2784            &["webServer"],
2785            "command",
2786            "cwd",
2787        );
2788        assert_eq!(pairs, vec![("srvx --port 3000".to_string(), None)]);
2789    }
2790
2791    #[test]
2792    fn extract_array_object_string_pairs_empty_for_object_form() {
2793        let source = r#"
2794            export default {
2795                webServer: { command: "srvx --port 3000" }
2796            };
2797        "#;
2798
2799        let pairs = extract_config_array_object_string_pairs(
2800            source,
2801            &ts_path(),
2802            &["webServer"],
2803            "command",
2804            "cwd",
2805        );
2806        assert!(pairs.is_empty());
2807    }
2808
2809    #[test]
2810    fn extract_config_plugin_option_string_from_json() {
2811        let source = r#"{
2812            "expo": {
2813                "plugins": [
2814                    ["expo-router", { "root": "src/app" }]
2815                ]
2816            }
2817        }"#;
2818
2819        let value = extract_config_plugin_option_string(
2820            source,
2821            &json_path(),
2822            &["expo", "plugins"],
2823            "expo-router",
2824            "root",
2825        );
2826
2827        assert_eq!(value, Some("src/app".to_string()));
2828    }
2829
2830    #[test]
2831    fn extract_config_plugin_option_string_from_top_level_plugins() {
2832        let source = r#"{
2833            "plugins": [
2834                ["expo-router", { "root": "./src/routes" }]
2835            ]
2836        }"#;
2837
2838        let value = extract_config_plugin_option_string_from_paths(
2839            source,
2840            &json_path(),
2841            &[&["plugins"], &["expo", "plugins"]],
2842            "expo-router",
2843            "root",
2844        );
2845
2846        assert_eq!(value, Some("./src/routes".to_string()));
2847    }
2848
2849    #[test]
2850    fn extract_config_plugin_option_string_from_ts_config() {
2851        let source = r"
2852            export default {
2853                expo: {
2854                    plugins: [
2855                        ['expo-router', { root: './src/app' }]
2856                    ]
2857                }
2858            };
2859        ";
2860
2861        let value = extract_config_plugin_option_string(
2862            source,
2863            &ts_path(),
2864            &["expo", "plugins"],
2865            "expo-router",
2866            "root",
2867        );
2868
2869        assert_eq!(value, Some("./src/app".to_string()));
2870    }
2871
2872    #[test]
2873    fn extract_config_plugin_option_string_returns_none_when_plugin_missing() {
2874        let source = r#"{
2875            "expo": {
2876                "plugins": [
2877                    ["expo-font", {}]
2878                ]
2879            }
2880        }"#;
2881
2882        let value = extract_config_plugin_option_string(
2883            source,
2884            &json_path(),
2885            &["expo", "plugins"],
2886            "expo-router",
2887            "root",
2888        );
2889
2890        assert_eq!(value, None);
2891    }
2892
2893    #[test]
2894    fn vite_react_babel_dependencies_extract_plain_tuple_and_prefixed_entries() {
2895        let source = r#"
2896            import react from "@vitejs/plugin-react";
2897
2898            export default defineConfig({
2899                plugins: [
2900                    react({
2901                        babel: {
2902                            plugins: [
2903                                "babel-plugin-plain",
2904                                ["module:@preact/signals-react-transform", { mode: "auto" }],
2905                            ],
2906                            presets: [["@babel/preset-react", { runtime: "automatic" }]],
2907                        },
2908                    }),
2909                ],
2910            });
2911        "#;
2912
2913        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
2914
2915        assert_eq!(
2916            deps,
2917            vec![
2918                "babel-plugin-plain".to_string(),
2919                "@preact/signals-react-transform".to_string(),
2920                "@babel/preset-react".to_string(),
2921            ]
2922        );
2923    }
2924
2925    #[test]
2926    fn vite_react_babel_dependencies_support_default_alias_import() {
2927        let source = r#"
2928            import { default as viteReact } from "@vitejs/plugin-react";
2929
2930            export default {
2931                plugins: [
2932                    viteReact({
2933                        babel: {
2934                            plugins: [["module:@scope/pkg/plugin", {}]],
2935                        },
2936                    }),
2937                ],
2938            };
2939        "#;
2940
2941        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
2942
2943        assert_eq!(deps, vec!["@scope/pkg".to_string()]);
2944    }
2945
2946    #[test]
2947    fn vite_react_babel_dependencies_ignore_unrelated_plugin_calls() {
2948        let source = r#"
2949            import vue from "@vitejs/plugin-vue";
2950
2951            export default {
2952                plugins: [
2953                    vue({
2954                        babel: {
2955                            plugins: ["@preact/signals-react-transform"],
2956                        },
2957                    }),
2958                ],
2959            };
2960        "#;
2961
2962        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
2963
2964        assert!(deps.is_empty());
2965    }
2966
2967    #[test]
2968    fn vite_react_babel_dependencies_skip_relative_and_protocol_entries() {
2969        let source = r#"
2970            import react from "@vitejs/plugin-react";
2971
2972            export default {
2973                plugins: [
2974                    react({
2975                        babel: {
2976                            plugins: ["./local-plugin", "module:./local-prefixed", "http://example.com/plugin"],
2977                        },
2978                    }),
2979                ],
2980            };
2981        "#;
2982
2983        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
2984
2985        assert!(deps.is_empty());
2986    }
2987
2988    #[test]
2989    fn normalize_config_path_relative_to_root() {
2990        let config_path = PathBuf::from("/project/vite.config.ts");
2991        let root = PathBuf::from("/project");
2992
2993        assert_eq!(
2994            normalize_config_path("./src/lib", &config_path, &root),
2995            Some("src/lib".to_string())
2996        );
2997        assert_eq!(
2998            normalize_config_path("/src/lib", &config_path, &root),
2999            Some("src/lib".to_string())
3000        );
3001    }
3002
3003    #[test]
3004    fn normalize_config_path_mixed_separators_and_parent_dirs() {
3005        let config_path = PathBuf::from("/project/config/vite.config.ts");
3006        let root = PathBuf::from("/project");
3007
3008        assert_eq!(
3009            normalize_config_path(".\\src\\..\\app\\lib", &config_path, &root),
3010            Some("config/app/lib".to_string())
3011        );
3012    }
3013
3014    #[test]
3015    fn normalize_config_path_leading_slash_stays_project_relative() {
3016        let config_path = PathBuf::from("/project/vite.config.ts");
3017        let root = PathBuf::from("/project");
3018
3019        assert_eq!(
3020            normalize_config_path("/src\\lib", &config_path, &root),
3021            Some("src/lib".to_string())
3022        );
3023    }
3024
3025    #[test]
3026    fn json_wrapped_in_parens_string() {
3027        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
3028        let val = extract_config_string(source, &js_path(), &["extends"]);
3029        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
3030    }
3031
3032    #[test]
3033    fn json_wrapped_in_parens_nested_array() {
3034        let source =
3035            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
3036        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
3037        assert_eq!(types, vec!["node", "jest"]);
3038
3039        let include = extract_config_string_array(source, &js_path(), &["include"]);
3040        assert_eq!(include, vec!["src/**/*"]);
3041    }
3042
3043    #[test]
3044    fn json_wrapped_in_parens_object_keys() {
3045        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
3046        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3047        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
3048    }
3049
3050    fn json_path() -> PathBuf {
3051        PathBuf::from("config.json")
3052    }
3053
3054    #[test]
3055    fn json_file_parsed_correctly() {
3056        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
3057        let val = extract_config_string(source, &json_path(), &["key"]);
3058        assert_eq!(val, Some("value".to_string()));
3059
3060        let list = extract_config_string_array(source, &json_path(), &["list"]);
3061        assert_eq!(list, vec!["a", "b"]);
3062    }
3063
3064    #[test]
3065    fn jsonc_file_parsed_correctly() {
3066        let source = r#"{"key": "value"}"#;
3067        let path = PathBuf::from("tsconfig.jsonc");
3068        let val = extract_config_string(source, &path, &["key"]);
3069        assert_eq!(val, Some("value".to_string()));
3070    }
3071
3072    #[test]
3073    fn extract_define_config_arrow_function() {
3074        let source = r#"
3075            import { defineConfig } from 'vite';
3076            export default defineConfig(() => ({
3077                test: {
3078                    include: ["**/*.test.ts"]
3079                }
3080            }));
3081        "#;
3082        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3083        assert_eq!(include, vec!["**/*.test.ts"]);
3084    }
3085
3086    #[test]
3087    fn extract_config_from_default_export_function_declaration() {
3088        let source = r#"
3089            export default function createConfig() {
3090                return {
3091                    clientModules: ["./src/client/global.js"]
3092                };
3093            }
3094        "#;
3095
3096        let client_modules = extract_config_string_array(source, &ts_path(), &["clientModules"]);
3097        assert_eq!(client_modules, vec!["./src/client/global.js"]);
3098    }
3099
3100    #[test]
3101    fn extract_config_from_default_export_async_function_declaration() {
3102        let source = r#"
3103            export default async function createConfigAsync() {
3104                return {
3105                    docs: {
3106                        path: "knowledge"
3107                    }
3108                };
3109            }
3110        "#;
3111
3112        let docs_path = extract_config_string(source, &ts_path(), &["docs", "path"]);
3113        assert_eq!(docs_path, Some("knowledge".to_string()));
3114    }
3115
3116    #[test]
3117    fn extract_config_from_exported_arrow_function_identifier() {
3118        let source = r#"
3119            const config = async () => {
3120                return {
3121                    themes: ["classic"]
3122                };
3123            };
3124
3125            export default config;
3126        "#;
3127
3128        let themes = extract_config_shallow_strings(source, &ts_path(), "themes");
3129        assert_eq!(themes, vec!["classic"]);
3130    }
3131
3132    #[test]
3133    fn module_exports_nested_string() {
3134        let source = r#"
3135            module.exports = {
3136                resolve: {
3137                    alias: {
3138                        "@": "./src"
3139                    }
3140                }
3141            };
3142        "#;
3143        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
3144        assert_eq!(val, Some("./src".to_string()));
3145    }
3146
3147    #[test]
3148    fn property_strings_nested_objects() {
3149        let source = r#"
3150            export default {
3151                plugins: {
3152                    group1: { a: "val-a" },
3153                    group2: { b: "val-b" }
3154                }
3155            };
3156        "#;
3157        let values = extract_config_property_strings(source, &js_path(), "plugins");
3158        assert!(values.contains(&"val-a".to_string()));
3159        assert!(values.contains(&"val-b".to_string()));
3160    }
3161
3162    #[test]
3163    fn property_strings_missing_key_returns_empty() {
3164        let source = r#"export default { other: "value" };"#;
3165        let values = extract_config_property_strings(source, &js_path(), "missing");
3166        assert!(values.is_empty());
3167    }
3168
3169    #[test]
3170    fn shallow_strings_tuple_array() {
3171        let source = r#"
3172            module.exports = {
3173                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
3174            };
3175        "#;
3176        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
3177        assert_eq!(values, vec!["default", "jest-junit"]);
3178        assert!(!values.contains(&"reports".to_string()));
3179    }
3180
3181    #[test]
3182    fn shallow_strings_single_string() {
3183        let source = r#"export default { preset: "ts-jest" };"#;
3184        let values = extract_config_shallow_strings(source, &js_path(), "preset");
3185        assert_eq!(values, vec!["ts-jest"]);
3186    }
3187
3188    #[test]
3189    fn shallow_strings_missing_key() {
3190        let source = r#"export default { other: "val" };"#;
3191        let values = extract_config_shallow_strings(source, &js_path(), "missing");
3192        assert!(values.is_empty());
3193    }
3194
3195    #[test]
3196    fn shallow_strings_or_object_property_alias_objects() {
3197        let source = r#"
3198            export default {
3199                jsPlugins: [
3200                    "eslint-plugin-playwright",
3201                    ["eslint-plugin-regexp", { rules: {} }],
3202                    { name: "short", specifier: "eslint-plugin-with-long-name" }
3203                ]
3204            };
3205        "#;
3206        let values = extract_config_shallow_strings_or_object_property(
3207            source,
3208            &ts_path(),
3209            "jsPlugins",
3210            "specifier",
3211        );
3212        assert_eq!(
3213            values,
3214            vec![
3215                "eslint-plugin-playwright",
3216                "eslint-plugin-regexp",
3217                "eslint-plugin-with-long-name"
3218            ]
3219        );
3220    }
3221
3222    #[test]
3223    fn nested_shallow_strings_vitest_reporters() {
3224        let source = r#"
3225            export default {
3226                test: {
3227                    reporters: ["default", "vitest-sonar-reporter"]
3228                }
3229            };
3230        "#;
3231        let values =
3232            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3233        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3234    }
3235
3236    #[test]
3237    fn nested_shallow_strings_tuple_format() {
3238        let source = r#"
3239            export default {
3240                test: {
3241                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
3242                }
3243            };
3244        "#;
3245        let values =
3246            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3247        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3248    }
3249
3250    #[test]
3251    fn nested_shallow_strings_missing_outer() {
3252        let source = r"export default { other: {} };";
3253        let values =
3254            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3255        assert!(values.is_empty());
3256    }
3257
3258    #[test]
3259    fn nested_shallow_strings_missing_inner() {
3260        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
3261        let values =
3262            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3263        assert!(values.is_empty());
3264    }
3265
3266    #[test]
3267    fn string_or_array_missing_path() {
3268        let source = r"export default {};";
3269        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3270        assert!(result.is_empty());
3271    }
3272
3273    #[test]
3274    fn string_or_array_non_string_values() {
3275        let source = r"export default { entry: [42, true] };";
3276        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3277        assert!(result.is_empty());
3278    }
3279
3280    #[test]
3281    fn array_nested_extraction() {
3282        let source = r#"
3283            export default defineConfig({
3284                test: {
3285                    projects: [
3286                        {
3287                            test: {
3288                                setupFiles: ["./test/setup-a.ts"]
3289                            }
3290                        },
3291                        {
3292                            test: {
3293                                setupFiles: "./test/setup-b.ts"
3294                            }
3295                        }
3296                    ]
3297                }
3298            });
3299        "#;
3300        let results = extract_config_array_nested_string_or_array(
3301            source,
3302            &ts_path(),
3303            &["test", "projects"],
3304            &["test", "setupFiles"],
3305        );
3306        assert!(results.contains(&"./test/setup-a.ts".to_string()));
3307        assert!(results.contains(&"./test/setup-b.ts".to_string()));
3308    }
3309
3310    #[test]
3311    fn array_nested_empty_when_no_array() {
3312        let source = r#"export default { test: { projects: "not-an-array" } };"#;
3313        let results = extract_config_array_nested_string_or_array(
3314            source,
3315            &js_path(),
3316            &["test", "projects"],
3317            &["test", "setupFiles"],
3318        );
3319        assert!(results.is_empty());
3320    }
3321
3322    #[test]
3323    fn object_nested_extraction() {
3324        let source = r#"{
3325            "projects": {
3326                "app-one": {
3327                    "architect": {
3328                        "build": {
3329                            "options": {
3330                                "styles": ["src/styles.css"]
3331                            }
3332                        }
3333                    }
3334                }
3335            }
3336        }"#;
3337        let results = extract_config_object_nested_string_or_array(
3338            source,
3339            &json_path(),
3340            &["projects"],
3341            &["architect", "build", "options", "styles"],
3342        );
3343        assert_eq!(results, vec!["src/styles.css"]);
3344    }
3345
3346    #[test]
3347    fn array_with_object_input_form_extracted() {
3348        let source = r#"{
3349            "projects": {
3350                "app": {
3351                    "architect": {
3352                        "build": {
3353                            "options": {
3354                                "styles": [
3355                                    "src/styles.scss",
3356                                    { "input": "src/theme.scss", "bundleName": "theme", "inject": false },
3357                                    { "bundleName": "lazy-only" }
3358                                ]
3359                            }
3360                        }
3361                    }
3362                }
3363            }
3364        }"#;
3365        let results = extract_config_object_nested_string_or_array(
3366            source,
3367            &json_path(),
3368            &["projects"],
3369            &["architect", "build", "options", "styles"],
3370        );
3371        assert!(
3372            results.contains(&"src/styles.scss".to_string()),
3373            "string form must still work: {results:?}"
3374        );
3375        assert!(
3376            results.contains(&"src/theme.scss".to_string()),
3377            "object form with `input` must be extracted: {results:?}"
3378        );
3379        assert!(
3380            !results.contains(&"lazy-only".to_string()),
3381            "bundleName must not be misinterpreted as a path: {results:?}"
3382        );
3383        assert!(
3384            !results.contains(&"theme".to_string()),
3385            "bundleName from full object must not leak: {results:?}"
3386        );
3387    }
3388
3389    #[test]
3390    fn object_nested_strings_extraction() {
3391        let source = r#"{
3392            "targets": {
3393                "build": {
3394                    "executor": "@angular/build:application"
3395                },
3396                "test": {
3397                    "executor": "@nx/vite:test"
3398                }
3399            }
3400        }"#;
3401        let results =
3402            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
3403        assert!(results.contains(&"@angular/build:application".to_string()));
3404        assert!(results.contains(&"@nx/vite:test".to_string()));
3405    }
3406
3407    #[test]
3408    fn require_strings_direct_call() {
3409        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
3410        let deps = extract_config_require_strings(source, &js_path(), "adapter");
3411        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
3412    }
3413
3414    #[test]
3415    fn require_strings_no_matching_key() {
3416        let source = r"module.exports = { other: require('something') };";
3417        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3418        assert!(deps.is_empty());
3419    }
3420
3421    #[test]
3422    fn extract_imports_no_imports() {
3423        let source = r"export default {};";
3424        let imports = extract_imports(source, &js_path());
3425        assert!(imports.is_empty());
3426    }
3427
3428    #[test]
3429    fn extract_imports_side_effect_import() {
3430        let source = r"
3431            import 'polyfill';
3432            import './local-setup';
3433            export default {};
3434        ";
3435        let imports = extract_imports(source, &js_path());
3436        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
3437    }
3438
3439    #[test]
3440    fn extract_imports_mixed_specifiers() {
3441        let source = r"
3442            import defaultExport from 'module-a';
3443            import { named } from 'module-b';
3444            import * as ns from 'module-c';
3445            export default {};
3446        ";
3447        let imports = extract_imports(source, &js_path());
3448        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
3449    }
3450
3451    #[test]
3452    fn template_literal_in_string_or_array() {
3453        let source = r"export default { entry: `./src/index.ts` };";
3454        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
3455        assert_eq!(result, vec!["./src/index.ts"]);
3456    }
3457
3458    #[test]
3459    fn template_literal_in_config_string() {
3460        let source = r"export default { testDir: `./tests` };";
3461        let val = extract_config_string(source, &js_path(), &["testDir"]);
3462        assert_eq!(val, Some("./tests".to_string()));
3463    }
3464
3465    #[test]
3466    fn template_literal_command_recovers_static_command_tokens() {
3467        let source = r"
3468            const PORT = 3000;
3469            export default {
3470                webServer: {
3471                    command: `pnpm exec srvx --port ${PORT} --hostname 127.0.0.1`
3472                }
3473            };
3474        ";
3475        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3476        assert_eq!(
3477            val,
3478            Some("pnpm exec srvx --port   --hostname 127.0.0.1".to_string())
3479        );
3480    }
3481
3482    #[test]
3483    fn template_literal_command_skips_dynamic_prefix() {
3484        let source = r"
3485            export default {
3486                webServer: { command: `${serverCommand} && pnpm exec srvx` }
3487            };
3488        ";
3489        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3490        assert!(val.is_none());
3491    }
3492
3493    #[test]
3494    fn template_literal_command_skips_split_static_token() {
3495        let source = r"
3496            export default {
3497                webServer: { command: `pnpm exec sr${part}vx --port 3000` }
3498            };
3499        ";
3500        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3501        assert!(val.is_none());
3502    }
3503
3504    #[test]
3505    fn array_object_command_pairs_recover_template_command() {
3506        let source = r"
3507            const PORT = 3000;
3508            export default {
3509                webServer: [
3510                    {
3511                        command: `pnpm exec srvx --port ${PORT}`,
3512                        cwd: 'apps/web'
3513                    }
3514                ]
3515            };
3516        ";
3517        let pairs = extract_config_array_object_command_pairs(
3518            source,
3519            &ts_path(),
3520            &["webServer"],
3521            "command",
3522            "cwd",
3523        );
3524        assert_eq!(
3525            pairs,
3526            vec![(
3527                "pnpm exec srvx --port  ".to_string(),
3528                Some("apps/web".to_string())
3529            )]
3530        );
3531    }
3532
3533    #[test]
3534    fn nested_string_array_empty_path() {
3535        let source = r#"export default { items: ["a", "b"] };"#;
3536        let result = extract_config_string_array(source, &js_path(), &[]);
3537        assert!(result.is_empty());
3538    }
3539
3540    #[test]
3541    fn nested_string_empty_path() {
3542        let source = r#"export default { key: "val" };"#;
3543        let result = extract_config_string(source, &js_path(), &[]);
3544        assert!(result.is_none());
3545    }
3546
3547    #[test]
3548    fn object_keys_empty_path() {
3549        let source = r"export default { plugins: {} };";
3550        let result = extract_config_object_keys(source, &js_path(), &[]);
3551        assert!(result.is_empty());
3552    }
3553
3554    #[test]
3555    fn no_config_object_returns_empty() {
3556        let source = r"const x = 42;";
3557        let result = extract_config_string(source, &js_path(), &["key"]);
3558        assert!(result.is_none());
3559
3560        let arr = extract_config_string_array(source, &js_path(), &["items"]);
3561        assert!(arr.is_empty());
3562
3563        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3564        assert!(keys.is_empty());
3565    }
3566
3567    #[test]
3568    fn property_with_string_key() {
3569        let source = r#"export default { "string-key": "value" };"#;
3570        let val = extract_config_string(source, &js_path(), &["string-key"]);
3571        assert_eq!(val, Some("value".to_string()));
3572    }
3573
3574    #[test]
3575    fn nested_navigation_through_non_object() {
3576        let source = r#"export default { level1: "not-an-object" };"#;
3577        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
3578        assert!(val.is_none());
3579    }
3580
3581    #[test]
3582    fn variable_reference_untyped() {
3583        let source = r#"
3584            const config = {
3585                testDir: "./tests"
3586            };
3587            export default config;
3588        "#;
3589        let val = extract_config_string(source, &js_path(), &["testDir"]);
3590        assert_eq!(val, Some("./tests".to_string()));
3591    }
3592
3593    #[test]
3594    fn variable_reference_with_type_annotation() {
3595        let source = r#"
3596            import type { StorybookConfig } from '@storybook/react-vite';
3597            const config: StorybookConfig = {
3598                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
3599                framework: "@storybook/react-vite"
3600            };
3601            export default config;
3602        "#;
3603        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
3604        assert_eq!(
3605            addons,
3606            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
3607        );
3608
3609        let framework = extract_config_string(source, &ts_path(), &["framework"]);
3610        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
3611    }
3612
3613    #[test]
3614    fn variable_reference_with_define_config() {
3615        let source = r#"
3616            import { defineConfig } from 'vitest/config';
3617            const config = defineConfig({
3618                test: {
3619                    include: ["**/*.test.ts"]
3620                }
3621            });
3622            export default config;
3623        "#;
3624        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3625        assert_eq!(include, vec!["**/*.test.ts"]);
3626    }
3627
3628    #[test]
3629    fn ts_satisfies_direct_export() {
3630        let source = r#"
3631            export default {
3632                testDir: "./tests"
3633            } satisfies PlaywrightTestConfig;
3634        "#;
3635        let val = extract_config_string(source, &ts_path(), &["testDir"]);
3636        assert_eq!(val, Some("./tests".to_string()));
3637    }
3638
3639    #[test]
3640    fn ts_as_direct_export() {
3641        let source = r#"
3642            export default {
3643                testDir: "./tests"
3644            } as const;
3645        "#;
3646        let val = extract_config_string(source, &ts_path(), &["testDir"]);
3647        assert_eq!(val, Some("./tests".to_string()));
3648    }
3649
3650    // --- issue #811: resolve.alias as imported identifier / spread ---
3651
3652    fn aliases(source: &str) -> Vec<(String, String)> {
3653        extract_config_aliases(source, &js_path(), &["resolve", "alias"])
3654    }
3655
3656    #[test]
3657    fn aliases_inline_object_still_extracted() {
3658        // Regression: the resolver must not change inline-object behavior.
3659        let source = r#"
3660            export default defineConfig({
3661                resolve: { alias: { "@": "./src", utils: "../../utils" } }
3662            });
3663        "#;
3664        let mut got = aliases(source);
3665        got.sort();
3666        assert_eq!(
3667            got,
3668            vec![
3669                ("@".to_string(), "./src".to_string()),
3670                ("utils".to_string(), "../../utils".to_string()),
3671            ]
3672        );
3673    }
3674
3675    #[test]
3676    fn aliases_inline_array_still_extracted() {
3677        let source = r#"
3678            export default defineConfig({
3679                resolve: { alias: [{ find: "@", replacement: "./src" }] }
3680            });
3681        "#;
3682        assert_eq!(
3683            aliases(source),
3684            vec![("@".to_string(), "./src".to_string())]
3685        );
3686    }
3687
3688    #[test]
3689    fn aliases_local_const_array_identifier() {
3690        let source = r#"
3691            const sharedAliases = [{ find: "@", replacement: "./src" }];
3692            export default defineConfig({ resolve: { alias: sharedAliases } });
3693        "#;
3694        assert_eq!(
3695            aliases(source),
3696            vec![("@".to_string(), "./src".to_string())]
3697        );
3698    }
3699
3700    #[test]
3701    fn aliases_local_const_object_identifier() {
3702        let source = r#"
3703            const sharedAliases = { "@": "./src" };
3704            export default defineConfig({ resolve: { alias: sharedAliases } });
3705        "#;
3706        assert_eq!(
3707            aliases(source),
3708            vec![("@".to_string(), "./src".to_string())]
3709        );
3710    }
3711
3712    #[test]
3713    fn aliases_array_spread_of_identifiers_and_inline() {
3714        let source = r##"
3715            const a = [{ find: "@", replacement: "./src" }];
3716            const b = [{ find: "~", replacement: "./lib" }];
3717            export default defineConfig({
3718                resolve: { alias: [...a, ...b, { find: "#", replacement: "./test" }] }
3719            });
3720        "##;
3721        let mut got = aliases(source);
3722        got.sort();
3723        assert_eq!(
3724            got,
3725            vec![
3726                ("#".to_string(), "./test".to_string()),
3727                ("@".to_string(), "./src".to_string()),
3728                ("~".to_string(), "./lib".to_string()),
3729            ]
3730        );
3731    }
3732
3733    #[test]
3734    fn aliases_object_spread_of_identifier_and_inline() {
3735        let source = r#"
3736            const base = { "@": "./src" };
3737            export default defineConfig({
3738                resolve: { alias: { ...base, "~": "./lib" } }
3739            });
3740        "#;
3741        let mut got = aliases(source);
3742        got.sort();
3743        assert_eq!(
3744            got,
3745            vec![
3746                ("@".to_string(), "./src".to_string()),
3747                ("~".to_string(), "./lib".to_string()),
3748            ]
3749        );
3750    }
3751
3752    #[test]
3753    fn aliases_local_const_chained_identifier() {
3754        // `const a = b` indirection resolves through the chain.
3755        let source = r#"
3756            const real = [{ find: "@", replacement: "./src" }];
3757            const alias2 = real;
3758            export default defineConfig({ resolve: { alias: alias2 } });
3759        "#;
3760        assert_eq!(
3761            aliases(source),
3762            vec![("@".to_string(), "./src".to_string())]
3763        );
3764    }
3765
3766    #[test]
3767    fn aliases_imported_named_identifier_from_sibling() {
3768        let dir = tempfile::tempdir().unwrap();
3769        std::fs::write(
3770            dir.path().join("vite.shared.js"),
3771            r#"export const sharedAliases = [
3772                { find: "@", replacement: new URL("./src", import.meta.url).pathname },
3773            ];"#,
3774        )
3775        .unwrap();
3776        let config = dir.path().join("vite.config.js");
3777        let source = r#"
3778            import { defineConfig } from "vite";
3779            import { sharedAliases } from "./vite.shared.js";
3780            export default defineConfig({ resolve: { alias: sharedAliases } });
3781        "#;
3782        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3783        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3784    }
3785
3786    #[test]
3787    fn aliases_imported_extensionless_specifier_probed() {
3788        let dir = tempfile::tempdir().unwrap();
3789        std::fs::write(
3790            dir.path().join("aliases.mjs"),
3791            r#"export const sharedAliases = { "@": "./src" };"#,
3792        )
3793        .unwrap();
3794        let config = dir.path().join("vite.config.ts");
3795        let source = r#"
3796            import { sharedAliases } from "./aliases";
3797            export default defineConfig({ resolve: { alias: sharedAliases } });
3798        "#;
3799        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3800        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3801    }
3802
3803    #[test]
3804    fn aliases_imported_default_export_from_sibling() {
3805        let dir = tempfile::tempdir().unwrap();
3806        std::fs::write(
3807            dir.path().join("aliases.js"),
3808            r#"export default [{ find: "@", replacement: "./src" }];"#,
3809        )
3810        .unwrap();
3811        let config = dir.path().join("vite.config.js");
3812        let source = r#"
3813            import sharedAliases from "./aliases.js";
3814            export default defineConfig({ resolve: { alias: sharedAliases } });
3815        "#;
3816        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3817        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3818    }
3819
3820    #[test]
3821    fn aliases_imported_spread_from_two_siblings() {
3822        let dir = tempfile::tempdir().unwrap();
3823        std::fs::write(
3824            dir.path().join("a.js"),
3825            r#"export const a = [{ find: "@", replacement: "./src" }];"#,
3826        )
3827        .unwrap();
3828        std::fs::write(
3829            dir.path().join("b.js"),
3830            r#"export const b = [{ find: "~", replacement: "./lib" }];"#,
3831        )
3832        .unwrap();
3833        let config = dir.path().join("vite.config.js");
3834        let source = r#"
3835            import { a } from "./a.js";
3836            import { b } from "./b.js";
3837            export default defineConfig({ resolve: { alias: [...a, ...b] } });
3838        "#;
3839        let mut got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3840        got.sort();
3841        assert_eq!(
3842            got,
3843            vec![
3844                ("@".to_string(), "./src".to_string()),
3845                ("~".to_string(), "./lib".to_string()),
3846            ]
3847        );
3848    }
3849
3850    #[test]
3851    fn aliases_import_cycle_terminates() {
3852        // a.js imports from b.js and vice versa; resolution must not hang and
3853        // should still recover the literal pairs present.
3854        let dir = tempfile::tempdir().unwrap();
3855        std::fs::write(
3856            dir.path().join("a.js"),
3857            r#"import { b } from "./b.js";
3858               export const a = [{ find: "@", replacement: "./src" }, ...b];"#,
3859        )
3860        .unwrap();
3861        std::fs::write(
3862            dir.path().join("b.js"),
3863            r#"import { a } from "./a.js";
3864               export const b = [...a];"#,
3865        )
3866        .unwrap();
3867        let config = dir.path().join("vite.config.js");
3868        let source = r#"
3869            import { a } from "./a.js";
3870            export default defineConfig({ resolve: { alias: a } });
3871        "#;
3872        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3873        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3874    }
3875
3876    #[test]
3877    fn aliases_non_relative_import_not_followed() {
3878        // A bare-package import is intentionally out of scope: no node_modules
3879        // read for an alias literal.
3880        let source = r#"
3881            import { sharedAliases } from "some-pkg";
3882            export default defineConfig({ resolve: { alias: sharedAliases } });
3883        "#;
3884        let dir = tempfile::tempdir().unwrap();
3885        let config = dir.path().join("vite.config.js");
3886        assert!(extract_config_aliases(source, &config, &["resolve", "alias"]).is_empty());
3887    }
3888
3889    #[test]
3890    fn aliases_object_array_value_takes_first_entry() {
3891        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets;
3892        // the resolver must take the first, matching the long-standing non-kinded
3893        // behavior the TypeScript plugin depends on. Regression guard for the
3894        // array-value case that the kinded unification briefly dropped.
3895        let source = r#"
3896            export default {
3897                compilerOptions: { paths: { "@/*": ["./src/*"], "~/*": ["./lib/*", "./vendor/*"] } }
3898            };
3899        "#;
3900        let mut got = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
3901        got.sort();
3902        assert_eq!(
3903            got,
3904            vec![
3905                ("@/*".to_string(), "./src/*".to_string()),
3906                ("~/*".to_string(), "./lib/*".to_string()),
3907            ]
3908        );
3909    }
3910
3911    #[test]
3912    fn aliases_kinded_preserves_is_bare_through_resolution() {
3913        // The bare-string vs path discriminator must survive identifier + spread
3914        // resolution (the test.alias package-to-package gate depends on it).
3915        let source = r#"
3916            const a = [{ find: "lodash-es", replacement: "lodash" }];
3917            export default defineConfig({
3918                resolve: { alias: [...a, { find: "@", replacement: "./src" }] }
3919            });
3920        "#;
3921        let mut got = extract_config_aliases_kinded(source, &js_path(), &["resolve", "alias"]);
3922        got.sort();
3923        assert_eq!(
3924            got,
3925            vec![
3926                ("@".to_string(), "./src".to_string(), false),
3927                ("lodash-es".to_string(), "lodash".to_string(), true),
3928            ]
3929        );
3930    }
3931
3932    #[test]
3933    fn aliases_kinded_preserves_is_bare_through_imported_spread() {
3934        let dir = tempfile::tempdir().unwrap();
3935        std::fs::write(
3936            dir.path().join("aliases.js"),
3937            r#"export const packageAliases = [{ find: "lodash-es", replacement: "lodash" }];"#,
3938        )
3939        .unwrap();
3940        let config = dir.path().join("vite.config.js");
3941        let source = r#"
3942            import { packageAliases } from "./aliases.js";
3943            export default defineConfig({
3944                resolve: { alias: [...packageAliases, { find: "@", replacement: "./src" }] }
3945            });
3946        "#;
3947        let mut got = extract_config_aliases_kinded(source, &config, &["resolve", "alias"]);
3948        got.sort();
3949        assert_eq!(
3950            got,
3951            vec![
3952                ("@".to_string(), "./src".to_string(), false),
3953                ("lodash-es".to_string(), "lodash".to_string(), true),
3954            ]
3955        );
3956    }
3957}