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            resolve_object_alias_pairs_kinded(program, config_path, obj, visited, depth)
1622        }
1623        Expression::ArrayExpression(arr) => {
1624            resolve_array_alias_pairs_kinded(program, config_path, arr, visited, depth)
1625        }
1626        Expression::Identifier(id) => {
1627            resolve_identifier_alias_pairs(program, config_path, id.name.as_str(), visited, depth)
1628        }
1629        _ => Vec::new(),
1630    }
1631}
1632
1633/// Resolve object-form alias pairs (`{ '@': './src', ...spread }`), expanding
1634/// spread properties recursively.
1635fn resolve_object_alias_pairs_kinded(
1636    program: &Program,
1637    config_path: &Path,
1638    obj: &ObjectExpression,
1639    visited: &mut FxHashSet<PathBuf>,
1640    depth: usize,
1641) -> Vec<(String, String, bool)> {
1642    let mut pairs = Vec::new();
1643    for prop in &obj.properties {
1644        match prop {
1645            ObjectPropertyKind::ObjectProperty(prop) => {
1646                if let Some(find) = property_key_to_string(&prop.key)
1647                    && let Some((replacement, is_bare)) = alias_replacement_kinded(&prop.value)
1648                {
1649                    pairs.push((find, replacement, is_bare));
1650                }
1651            }
1652            // `{ ...sharedAliases, '@': './src' }`
1653            ObjectPropertyKind::SpreadProperty(spread) => {
1654                pairs.extend(resolve_alias_pairs_kinded(
1655                    program,
1656                    config_path,
1657                    &spread.argument,
1658                    visited,
1659                    depth,
1660                ));
1661            }
1662        }
1663    }
1664    pairs
1665}
1666
1667/// Resolve array-form alias pairs (`[{ find, replacement }, ...spread]`),
1668/// expanding spread elements recursively.
1669fn resolve_array_alias_pairs_kinded(
1670    program: &Program,
1671    config_path: &Path,
1672    arr: &ArrayExpression,
1673    visited: &mut FxHashSet<PathBuf>,
1674    depth: usize,
1675) -> Vec<(String, String, bool)> {
1676    let mut pairs = Vec::new();
1677    for element in &arr.elements {
1678        match element {
1679            // `[...sharedAliases, { find, replacement }]`
1680            ArrayExpressionElement::SpreadElement(spread) => {
1681                pairs.extend(resolve_alias_pairs_kinded(
1682                    program,
1683                    config_path,
1684                    &spread.argument,
1685                    visited,
1686                    depth,
1687                ));
1688            }
1689            _ => {
1690                if let Some(Expression::ObjectExpression(obj)) = element.as_expression()
1691                    && let Some(find) = find_property(obj, "find")
1692                        .and_then(|prop| expression_to_string(&prop.value))
1693                    && let Some((replacement, is_bare)) = find_property(obj, "replacement")
1694                        .and_then(|prop| alias_replacement_kinded(&prop.value))
1695                {
1696                    pairs.push((find, replacement, is_bare));
1697                }
1698            }
1699        }
1700    }
1701    pairs
1702}
1703
1704/// Resolve an identifier used as an alias value to its literal pairs, first by
1705/// local `const`/`let`/`var` binding, then by a one-hop relative import.
1706fn resolve_identifier_alias_pairs(
1707    program: &Program,
1708    config_path: &Path,
1709    name: &str,
1710    visited: &mut FxHashSet<PathBuf>,
1711    depth: usize,
1712) -> Vec<(String, String, bool)> {
1713    if depth >= MAX_ALIAS_RESOLVE_DEPTH {
1714        return Vec::new();
1715    }
1716    // Local `const NAME = [...] | {...}` (or `const NAME = otherIdentifier`).
1717    if let Some(init) = find_variable_init_expression(program, name) {
1718        return resolve_alias_pairs_kinded(program, config_path, init, visited, depth + 1);
1719    }
1720    // `import { NAME } from "./sibling"` / `import NAME from "./sibling"`.
1721    let Some((specifier, imported_name)) = find_relative_import_binding(program, name) else {
1722        return Vec::new();
1723    };
1724    resolve_imported_alias_pairs(
1725        config_path,
1726        &specifier,
1727        imported_name.as_deref(),
1728        visited,
1729        depth + 1,
1730    )
1731}
1732
1733/// Read a relative sibling file and resolve the alias literal it exports under
1734/// `imported_name` (`None` = default export).
1735fn resolve_imported_alias_pairs(
1736    config_path: &Path,
1737    specifier: &str,
1738    imported_name: Option<&str>,
1739    visited: &mut FxHashSet<PathBuf>,
1740    depth: usize,
1741) -> Vec<(String, String, bool)> {
1742    let Some((sibling_path, sibling_source)) = resolve_sibling_module(config_path, specifier)
1743    else {
1744        return Vec::new();
1745    };
1746    if !visited.insert(sibling_path.clone()) {
1747        return Vec::new();
1748    }
1749    extract_from_source(&sibling_source, &sibling_path, |program| {
1750        let init = find_exported_init(program, imported_name)?;
1751        let pairs = resolve_alias_pairs_kinded(program, &sibling_path, init, visited, depth);
1752        (!pairs.is_empty()).then_some(pairs)
1753    })
1754    .unwrap_or_default()
1755}
1756
1757/// Find a top-level variable declaration by name and return its init expression
1758/// (array, object, or another identifier). Covers bare `const NAME = ...` and
1759/// `export const NAME = ...`. Generalizes [`find_variable_init_object`] to any
1760/// init shape so the alias resolver can recurse on array/identifier inits.
1761fn find_variable_init_expression<'a>(
1762    program: &'a Program<'a>,
1763    name: &str,
1764) -> Option<&'a Expression<'a>> {
1765    for stmt in &program.body {
1766        let decl = match stmt {
1767            Statement::VariableDeclaration(decl) => decl,
1768            Statement::ExportNamedDeclaration(export) => match &export.declaration {
1769                Some(Declaration::VariableDeclaration(decl)) => decl,
1770                _ => continue,
1771            },
1772            _ => continue,
1773        };
1774        for declarator in &decl.declarations {
1775            if let BindingPattern::BindingIdentifier(id) = &declarator.id
1776                && id.name == name
1777                && let Some(init) = &declarator.init
1778            {
1779                return Some(init);
1780            }
1781        }
1782    }
1783    None
1784}
1785
1786/// Find the init expression a sibling module exports under `name`
1787/// (`None` = default export). For named exports this covers both
1788/// `export const NAME = ...` and a local `const NAME = ...` later re-exported
1789/// via `export { NAME }` (both surface through [`find_variable_init_expression`]).
1790fn find_exported_init<'a>(
1791    program: &'a Program<'a>,
1792    name: Option<&str>,
1793) -> Option<&'a Expression<'a>> {
1794    match name {
1795        Some(name) => find_variable_init_expression(program, name),
1796        None => program.body.iter().find_map(|stmt| {
1797            if let Statement::ExportDefaultDeclaration(decl) = stmt {
1798                decl.declaration.as_expression()
1799            } else {
1800                None
1801            }
1802        }),
1803    }
1804}
1805
1806/// Find the import that binds local `name` to a RELATIVE module, returning the
1807/// specifier and the imported name (`None` for a default import). Bare-package
1808/// imports are intentionally skipped: reading a literal alias table out of
1809/// `node_modules` is not a real-world config shape.
1810fn find_relative_import_binding(program: &Program, name: &str) -> Option<(String, Option<String>)> {
1811    for stmt in &program.body {
1812        let Statement::ImportDeclaration(decl) = stmt else {
1813            continue;
1814        };
1815        let specifier = decl.source.value.as_str();
1816        if !is_relative_specifier(specifier) {
1817            continue;
1818        }
1819        let Some(specifiers) = &decl.specifiers else {
1820            continue;
1821        };
1822        for spec in specifiers {
1823            match spec {
1824                ImportDeclarationSpecifier::ImportSpecifier(spec) if spec.local.name == name => {
1825                    return Some((
1826                        specifier.to_string(),
1827                        Some(spec.imported.name().to_string()),
1828                    ));
1829                }
1830                ImportDeclarationSpecifier::ImportDefaultSpecifier(spec)
1831                    if spec.local.name == name =>
1832                {
1833                    return Some((specifier.to_string(), None));
1834                }
1835                _ => {}
1836            }
1837        }
1838    }
1839    None
1840}
1841
1842/// True for a relative/absolute module specifier (`./x`, `../x`, `/x`), the
1843/// shapes that point at a sibling file rather than an npm package.
1844fn is_relative_specifier(specifier: &str) -> bool {
1845    specifier.starts_with("./") || specifier.starts_with("../") || specifier.starts_with('/')
1846}
1847
1848/// Resolve a relative specifier against `config_path`'s directory to a readable
1849/// sibling file, returning the resolved path and its source. Tries the path as
1850/// written first (covers `./vite.shared.js`), then appends each known config
1851/// extension (covers extensionless `./vite.shared` and dotted basenames where
1852/// `Path::extension` would misread `.shared`), then an `index.*` directory file.
1853fn resolve_sibling_module(config_path: &Path, specifier: &str) -> Option<(PathBuf, String)> {
1854    let parent = config_path.parent().unwrap_or(config_path);
1855    let direct = parent.join(specifier);
1856    if let Ok(source) = std::fs::read_to_string(&direct) {
1857        return Some((direct, source));
1858    }
1859    for ext in ALIAS_SIBLING_EXTS {
1860        let candidate = parent.join(format!("{specifier}.{ext}"));
1861        if let Ok(source) = std::fs::read_to_string(&candidate) {
1862            return Some((candidate, source));
1863        }
1864    }
1865    for ext in ALIAS_SIBLING_EXTS {
1866        let candidate = direct.join(format!("index.{ext}"));
1867        if let Ok(source) = std::fs::read_to_string(&candidate) {
1868            return Some((candidate, source));
1869        }
1870    }
1871    None
1872}
1873
1874/// Find a default-exported array config, the `defineWorkspace([...])` /
1875/// `vitest.workspace.{ts,js}` shape. Handles `export default [...]` and
1876/// `export default defineWorkspace([...])` / `defineConfig([...])` (the array as
1877/// the call's first argument), plus parenthesised / `as` wrappers.
1878fn find_default_export_array<'a>(program: &'a Program<'a>) -> Option<&'a ArrayExpression<'a>> {
1879    for stmt in &program.body {
1880        if let Statement::ExportDefaultDeclaration(decl) = stmt
1881            && let Some(expr) = decl.declaration.as_expression()
1882        {
1883            return array_from_expression(expr);
1884        }
1885    }
1886    None
1887}
1888
1889fn array_from_expression<'a>(expr: &'a Expression<'a>) -> Option<&'a ArrayExpression<'a>> {
1890    match expr {
1891        Expression::ArrayExpression(arr) => Some(arr),
1892        Expression::ParenthesizedExpression(paren) => array_from_expression(&paren.expression),
1893        Expression::TSAsExpression(ts_as) => array_from_expression(&ts_as.expression),
1894        Expression::TSSatisfiesExpression(ts_sat) => array_from_expression(&ts_sat.expression),
1895        Expression::CallExpression(call) => call
1896            .arguments
1897            .first()
1898            .and_then(Argument::as_expression)
1899            .and_then(array_from_expression),
1900        _ => None,
1901    }
1902}
1903
1904pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
1905    let mut normalized = PathBuf::new();
1906
1907    for component in path.components() {
1908        match component {
1909            std::path::Component::CurDir => {}
1910            std::path::Component::ParentDir => {
1911                normalized.pop();
1912            }
1913            _ => normalized.push(component.as_os_str()),
1914        }
1915    }
1916
1917    normalized
1918}
1919
1920/// Convert an expression to a string array if it's an array of string literals.
1921fn expression_to_string_array(expr: &Expression) -> Vec<String> {
1922    match expr {
1923        Expression::ArrayExpression(arr) => arr
1924            .elements
1925            .iter()
1926            .filter_map(|el| match el {
1927                ArrayExpressionElement::SpreadElement(_) => None,
1928                _ => el.as_expression().and_then(expression_to_string),
1929            })
1930            .collect(),
1931        _ => vec![],
1932    }
1933}
1934
1935/// Collect only top-level string values from an expression.
1936///
1937/// For arrays, extracts direct string elements and the first string element of sub-arrays
1938/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
1939fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
1940    let mut values = Vec::new();
1941    match expr {
1942        Expression::StringLiteral(s) => {
1943            values.push(s.value.to_string());
1944        }
1945        Expression::ArrayExpression(arr) => {
1946            for el in &arr.elements {
1947                if let Some(inner) = el.as_expression() {
1948                    match inner {
1949                        Expression::StringLiteral(s) => {
1950                            values.push(s.value.to_string());
1951                        }
1952                        Expression::ArrayExpression(sub_arr) => {
1953                            if let Some(first) = sub_arr.elements.first()
1954                                && let Some(first_expr) = first.as_expression()
1955                                && let Some(s) = expression_to_string(first_expr)
1956                            {
1957                                values.push(s);
1958                            }
1959                        }
1960                        _ => {}
1961                    }
1962                }
1963            }
1964        }
1965        Expression::ObjectExpression(obj) => {
1966            for prop in &obj.properties {
1967                if let ObjectPropertyKind::ObjectProperty(p) = prop {
1968                    match &p.value {
1969                        Expression::StringLiteral(s) => {
1970                            values.push(s.value.to_string());
1971                        }
1972                        Expression::ArrayExpression(sub_arr) => {
1973                            if let Some(first) = sub_arr.elements.first()
1974                                && let Some(first_expr) = first.as_expression()
1975                                && let Some(s) = expression_to_string(first_expr)
1976                            {
1977                                values.push(s);
1978                            }
1979                        }
1980                        _ => {}
1981                    }
1982                }
1983            }
1984        }
1985        _ => {}
1986    }
1987    values
1988}
1989
1990/// Collect top-level string values, plus a named string property from object entries.
1991fn collect_shallow_string_or_object_property_values(
1992    expr: &Expression,
1993    object_property: &str,
1994) -> Vec<String> {
1995    match expr {
1996        Expression::ArrayExpression(arr) => arr
1997            .elements
1998            .iter()
1999            .filter_map(|element| {
2000                element
2001                    .as_expression()
2002                    .and_then(|expr| shallow_string_or_object_property(expr, object_property))
2003            })
2004            .collect(),
2005        _ => shallow_string_or_object_property(expr, object_property)
2006            .into_iter()
2007            .collect(),
2008    }
2009}
2010
2011fn shallow_string_or_object_property(expr: &Expression, object_property: &str) -> Option<String> {
2012    match expr {
2013        Expression::ParenthesizedExpression(paren) => {
2014            shallow_string_or_object_property(&paren.expression, object_property)
2015        }
2016        Expression::TSSatisfiesExpression(ts_sat) => {
2017            shallow_string_or_object_property(&ts_sat.expression, object_property)
2018        }
2019        Expression::TSAsExpression(ts_as) => {
2020            shallow_string_or_object_property(&ts_as.expression, object_property)
2021        }
2022        Expression::ArrayExpression(sub_arr) => sub_arr
2023            .elements
2024            .first()
2025            .and_then(ArrayExpressionElement::as_expression)
2026            .and_then(expression_to_string),
2027        Expression::ObjectExpression(obj) => {
2028            find_property(obj, object_property).and_then(|prop| expression_to_string(&prop.value))
2029        }
2030        _ => expression_to_string(expr),
2031    }
2032}
2033
2034/// Recursively collect all string literal values from an expression tree.
2035fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
2036    match expr {
2037        Expression::StringLiteral(s) => {
2038            values.push(s.value.to_string());
2039        }
2040        Expression::ArrayExpression(arr) => {
2041            for el in &arr.elements {
2042                if let Some(expr) = el.as_expression() {
2043                    collect_all_string_values(expr, values);
2044                }
2045            }
2046        }
2047        Expression::ObjectExpression(obj) => {
2048            for prop in &obj.properties {
2049                if let ObjectPropertyKind::ObjectProperty(p) = prop {
2050                    collect_all_string_values(&p.value, values);
2051                }
2052            }
2053        }
2054        _ => {}
2055    }
2056}
2057
2058/// Convert a `PropertyKey` to a `String`.
2059fn property_key_to_string(key: &PropertyKey) -> Option<String> {
2060    match key {
2061        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
2062        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
2063        _ => None,
2064    }
2065}
2066
2067/// Extract keys of an object at a nested property path.
2068fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2069    if path.is_empty() {
2070        return None;
2071    }
2072    let prop = find_property(obj, path[0])?;
2073    if path.len() == 1 {
2074        if let Expression::ObjectExpression(nested) = &prop.value {
2075            let keys = nested
2076                .properties
2077                .iter()
2078                .filter_map(|p| {
2079                    if let ObjectPropertyKind::ObjectProperty(p) = p {
2080                        property_key_to_string(&p.key)
2081                    } else {
2082                        None
2083                    }
2084                })
2085                .collect();
2086            return Some(keys);
2087        }
2088        return None;
2089    }
2090    if let Expression::ObjectExpression(nested) = &prop.value {
2091        get_nested_object_keys(nested, &path[1..])
2092    } else {
2093        None
2094    }
2095}
2096
2097/// Navigate a nested property path and return the raw expression at the end.
2098fn get_nested_expression<'a>(
2099    obj: &'a ObjectExpression<'a>,
2100    path: &[&str],
2101) -> Option<&'a Expression<'a>> {
2102    if path.is_empty() {
2103        return None;
2104    }
2105    let prop = find_property(obj, path[0])?;
2106    if path.len() == 1 {
2107        return Some(&prop.value);
2108    }
2109    if let Expression::ObjectExpression(nested) = &prop.value {
2110        get_nested_expression(nested, &path[1..])
2111    } else {
2112        None
2113    }
2114}
2115
2116/// Navigate a nested path and extract a string, string array, or object string/array values.
2117fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
2118    if path.is_empty() {
2119        return None;
2120    }
2121    if path.len() == 1 {
2122        let prop = find_property(obj, path[0])?;
2123        return Some(expression_to_string_or_array(&prop.value));
2124    }
2125    let prop = find_property(obj, path[0])?;
2126    if let Expression::ObjectExpression(nested) = &prop.value {
2127        get_nested_string_or_array(nested, &path[1..])
2128    } else {
2129        None
2130    }
2131}
2132
2133/// Convert an expression to a `Vec<String>`, handling string, array, object-with-string/array values,
2134/// and Webpack 5 entry descriptors (`{ import: "..." }`).
2135///
2136/// Array elements that are object literals are inspected for an `input` property
2137/// (Angular CLI schema for `styles`/`scripts`/`polyfills`:
2138/// `{ "input": "src/x.scss", "bundleName": "x", "inject": false }`). Extracting
2139/// `input` prevents object-form entries from being silently dropped. See #126.
2140fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
2141    match expr {
2142        Expression::StringLiteral(s) => vec![s.value.to_string()],
2143        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
2144            .quasis
2145            .first()
2146            .map(|q| vec![q.value.raw.to_string()])
2147            .unwrap_or_default(),
2148        Expression::ArrayExpression(arr) => arr
2149            .elements
2150            .iter()
2151            .filter_map(|el| el.as_expression())
2152            .flat_map(|e| match e {
2153                Expression::ObjectExpression(obj) => find_property(obj, "input")
2154                    .map(|p| expression_to_string_or_array(&p.value))
2155                    .unwrap_or_default(),
2156                _ => expression_to_path_string(e).into_iter().collect(),
2157            })
2158            .collect(),
2159        Expression::ObjectExpression(obj) => obj
2160            .properties
2161            .iter()
2162            .flat_map(|p| {
2163                if let ObjectPropertyKind::ObjectProperty(p) = p {
2164                    match &p.value {
2165                        Expression::ArrayExpression(_) => expression_to_string_or_array(&p.value),
2166                        Expression::ObjectExpression(value_obj) => {
2167                            find_property(value_obj, "import")
2168                                .map(|import_prop| {
2169                                    expression_to_string_or_array(&import_prop.value)
2170                                })
2171                                .unwrap_or_default()
2172                        }
2173                        _ => expression_to_path_string(&p.value).into_iter().collect(),
2174                    }
2175                } else {
2176                    Vec::new()
2177                }
2178            })
2179            .collect(),
2180        _ => expression_to_path_string(expr).into_iter().collect(),
2181    }
2182}
2183
2184/// Collect `require('...')` argument strings from an expression.
2185fn collect_require_sources(expr: &Expression) -> Vec<String> {
2186    let mut sources = Vec::new();
2187    match expr {
2188        Expression::CallExpression(call) if is_require_call(call) => {
2189            if let Some(s) = get_require_source(call) {
2190                sources.push(s);
2191            }
2192        }
2193        Expression::ArrayExpression(arr) => {
2194            for el in &arr.elements {
2195                if let Some(inner) = el.as_expression() {
2196                    match inner {
2197                        Expression::CallExpression(call) if is_require_call(call) => {
2198                            if let Some(s) = get_require_source(call) {
2199                                sources.push(s);
2200                            }
2201                        }
2202                        Expression::ArrayExpression(sub_arr) => {
2203                            if let Some(first) = sub_arr.elements.first()
2204                                && let Some(Expression::CallExpression(call)) =
2205                                    first.as_expression()
2206                                && is_require_call(call)
2207                                && let Some(s) = get_require_source(call)
2208                            {
2209                                sources.push(s);
2210                            }
2211                        }
2212                        _ => {}
2213                    }
2214                }
2215            }
2216        }
2217        _ => {}
2218    }
2219    sources
2220}
2221
2222/// Check if a call expression is `require(...)`.
2223fn is_require_call(call: &CallExpression) -> bool {
2224    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
2225}
2226
2227/// Get the first string argument of a `require()` call.
2228fn get_require_source(call: &CallExpression) -> Option<String> {
2229    call.arguments.first().and_then(|arg| {
2230        if let Argument::StringLiteral(s) = arg {
2231            Some(s.value.to_string())
2232        } else {
2233            None
2234        }
2235    })
2236}
2237
2238#[cfg(test)]
2239mod tests {
2240    use super::*;
2241    use std::path::PathBuf;
2242
2243    fn js_path() -> PathBuf {
2244        PathBuf::from("config.js")
2245    }
2246
2247    fn ts_path() -> PathBuf {
2248        PathBuf::from("config.ts")
2249    }
2250
2251    #[test]
2252    fn extract_lazy_imports_bare_arrows() {
2253        let source = r"
2254            import { defineConfig } from '@adonisjs/core/app'
2255            export default defineConfig({
2256                preloads: [
2257                    () => import('#start/routes'),
2258                    () => import('#start/kernel'),
2259                ],
2260            })
2261        ";
2262        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["preloads"]);
2263        assert_eq!(specs, vec!["#start/routes", "#start/kernel"]);
2264    }
2265
2266    #[test]
2267    fn extract_lazy_imports_object_form_with_file_key() {
2268        let source = r"
2269            export default defineConfig({
2270                providers: [
2271                    () => import('@adonisjs/core/providers/app_provider'),
2272                    {
2273                        file: () => import('@adonisjs/core/providers/repl_provider'),
2274                        environment: ['repl', 'test'],
2275                    },
2276                ],
2277            })
2278        ";
2279        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2280        assert_eq!(
2281            specs,
2282            vec![
2283                "@adonisjs/core/providers/app_provider",
2284                "@adonisjs/core/providers/repl_provider",
2285            ]
2286        );
2287    }
2288
2289    #[test]
2290    fn extract_lazy_imports_block_body_with_return() {
2291        let source = r"
2292            export default defineConfig({
2293                commands: [
2294                    () => { return import('@adonisjs/core/commands') },
2295                ],
2296            })
2297        ";
2298        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2299        assert_eq!(specs, vec!["@adonisjs/core/commands"]);
2300    }
2301
2302    #[test]
2303    fn extract_lazy_imports_skips_unknown_element_shapes() {
2304        let source = r"
2305            export default defineConfig({
2306                commands: [
2307                    'string-entry',
2308                    42,
2309                    { other: 'value' },
2310                    () => import('@adonisjs/lucid/commands'),
2311                ],
2312            })
2313        ";
2314        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
2315        assert_eq!(specs, vec!["@adonisjs/lucid/commands"]);
2316    }
2317
2318    #[test]
2319    fn extract_lazy_imports_missing_property_returns_empty() {
2320        let source = r"
2321            export default defineConfig({
2322                preloads: [() => import('#start/routes')],
2323            })
2324        ";
2325        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
2326        assert!(specs.is_empty());
2327    }
2328
2329    #[test]
2330    fn extract_imports_basic() {
2331        let source = r"
2332            import foo from 'foo-pkg';
2333            import { bar } from '@scope/bar';
2334            export default {};
2335        ";
2336        let imports = extract_imports(source, &js_path());
2337        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
2338    }
2339
2340    #[test]
2341    fn extract_default_export_object_property() {
2342        let source = r#"export default { testDir: "./tests" };"#;
2343        let val = extract_config_string(source, &js_path(), &["testDir"]);
2344        assert_eq!(val, Some("./tests".to_string()));
2345    }
2346
2347    #[test]
2348    fn extract_define_config_property() {
2349        let source = r#"
2350            import { defineConfig } from 'vitest/config';
2351            export default defineConfig({
2352                test: {
2353                    include: ["**/*.test.ts", "**/*.spec.ts"],
2354                    setupFiles: ["./test/setup.ts"]
2355                }
2356            });
2357        "#;
2358        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
2359        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
2360
2361        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
2362        assert_eq!(setup, vec!["./test/setup.ts"]);
2363    }
2364
2365    #[test]
2366    fn extract_module_exports_property() {
2367        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
2368        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
2369        assert_eq!(val, Some("jsdom".to_string()));
2370    }
2371
2372    #[test]
2373    fn extract_nested_string_array() {
2374        let source = r#"
2375            export default {
2376                resolve: {
2377                    alias: {
2378                        "@": "./src"
2379                    }
2380                },
2381                test: {
2382                    include: ["src/**/*.test.ts"]
2383                }
2384            };
2385        "#;
2386        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
2387        assert_eq!(include, vec!["src/**/*.test.ts"]);
2388    }
2389
2390    #[test]
2391    fn extract_addons_array() {
2392        let source = r#"
2393            export default {
2394                addons: [
2395                    "@storybook/addon-a11y",
2396                    "@storybook/addon-docs",
2397                    "@storybook/addon-links"
2398                ]
2399            };
2400        "#;
2401        let addons = extract_config_property_strings(source, &ts_path(), "addons");
2402        assert_eq!(
2403            addons,
2404            vec![
2405                "@storybook/addon-a11y",
2406                "@storybook/addon-docs",
2407                "@storybook/addon-links"
2408            ]
2409        );
2410    }
2411
2412    #[test]
2413    fn handle_empty_config() {
2414        let source = "";
2415        let result = extract_config_string(source, &js_path(), &["key"]);
2416        assert_eq!(result, None);
2417    }
2418
2419    #[test]
2420    fn object_keys_postcss_plugins() {
2421        let source = r"
2422            module.exports = {
2423                plugins: {
2424                    autoprefixer: {},
2425                    tailwindcss: {},
2426                    'postcss-import': {}
2427                }
2428            };
2429        ";
2430        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2431        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
2432    }
2433
2434    #[test]
2435    fn object_keys_nested_path() {
2436        let source = r"
2437            export default {
2438                build: {
2439                    plugins: {
2440                        minify: {},
2441                        compress: {}
2442                    }
2443                }
2444            };
2445        ";
2446        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
2447        assert_eq!(keys, vec!["minify", "compress"]);
2448    }
2449
2450    #[test]
2451    fn object_keys_empty_object() {
2452        let source = r"export default { plugins: {} };";
2453        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2454        assert!(keys.is_empty());
2455    }
2456
2457    #[test]
2458    fn object_keys_non_object_returns_empty() {
2459        let source = r#"export default { plugins: ["a", "b"] };"#;
2460        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2461        assert!(keys.is_empty());
2462    }
2463
2464    #[test]
2465    fn string_or_array_single_string() {
2466        let source = r#"export default { entry: "./src/index.js" };"#;
2467        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2468        assert_eq!(result, vec!["./src/index.js"]);
2469    }
2470
2471    #[test]
2472    fn string_or_array_array() {
2473        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
2474        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2475        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
2476    }
2477
2478    #[test]
2479    fn string_or_array_object_values() {
2480        let source =
2481            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
2482        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2483        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
2484    }
2485
2486    #[test]
2487    fn string_or_array_object_array_values() {
2488        let source = r#"export default { entry: { app: ["./src/polyfill.js", "./src/app.js"] } };"#;
2489        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2490        assert_eq!(result, vec!["./src/polyfill.js", "./src/app.js"]);
2491    }
2492
2493    #[test]
2494    fn string_or_array_webpack_entry_descriptors() {
2495        let source = r#"
2496            export default {
2497                entry: {
2498                    app: {
2499                        import: "./src/app.js",
2500                        filename: "pages/app.js",
2501                        dependOn: "shared",
2502                    },
2503                    admin: {
2504                        import: ["./src/admin-polyfill.js", "./src/admin.js"],
2505                        runtime: "runtime",
2506                    },
2507                    shared: ["react", "react-dom"],
2508                },
2509            };
2510        "#;
2511        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2512        assert_eq!(
2513            result,
2514            vec![
2515                "./src/app.js",
2516                "./src/admin-polyfill.js",
2517                "./src/admin.js",
2518                "react",
2519                "react-dom"
2520            ]
2521        );
2522    }
2523
2524    #[test]
2525    fn string_or_array_nested_path() {
2526        let source = r#"
2527            export default {
2528                build: {
2529                    rollupOptions: {
2530                        input: ["./index.html", "./about.html"]
2531                    }
2532                }
2533            };
2534        "#;
2535        let result = extract_config_string_or_array(
2536            source,
2537            &js_path(),
2538            &["build", "rollupOptions", "input"],
2539        );
2540        assert_eq!(result, vec!["./index.html", "./about.html"]);
2541    }
2542
2543    #[test]
2544    fn string_or_array_template_literal() {
2545        let source = r"export default { entry: `./src/index.js` };";
2546        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2547        assert_eq!(result, vec!["./src/index.js"]);
2548    }
2549
2550    #[test]
2551    fn string_or_array_object_path_helper_values() {
2552        let source = r#"
2553            import { resolve, join } from "node:path";
2554            import path from "node:path";
2555            export default {
2556                build: {
2557                    rollupOptions: {
2558                        input: {
2559                            app: resolve(__dirname, "src/app.ts"),
2560                            modal: path.resolve(__dirname, "src/modal.ts"),
2561                            tabs: join(__dirname, "src/tabs.ts"),
2562                            styles: resolve(__dirname, "src/index.css"),
2563                        },
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!(
2574            result,
2575            vec!["src/app.ts", "src/modal.ts", "src/tabs.ts", "src/index.css"]
2576        );
2577    }
2578
2579    #[test]
2580    fn string_or_array_array_path_helper_values() {
2581        let source = r#"
2582            import { resolve } from "node:path";
2583            export default {
2584                build: {
2585                    rollupOptions: {
2586                        input: [resolve(__dirname, "src/a.ts"), "./src/b.ts"],
2587                    },
2588                },
2589            };
2590        "#;
2591        let result = extract_config_string_or_array(
2592            source,
2593            &js_path(),
2594            &["build", "rollupOptions", "input"],
2595        );
2596        assert_eq!(result, vec!["src/a.ts", "./src/b.ts"]);
2597    }
2598
2599    #[test]
2600    fn string_or_array_top_level_path_helper_call() {
2601        let source = r#"
2602            import { resolve } from "node:path";
2603            export default { build: { lib: { entry: resolve(__dirname, "src/index.ts") } } };
2604        "#;
2605        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2606        assert_eq!(result, vec!["src/index.ts"]);
2607    }
2608
2609    #[test]
2610    fn string_or_array_import_meta_dirname_anchor() {
2611        let source = r#"
2612            import { resolve } from "node:path";
2613            export default {
2614                build: { lib: { entry: resolve(import.meta.dirname, "src/index.ts") } },
2615            };
2616        "#;
2617        let result = extract_config_string_or_array(source, &ts_path(), &["build", "lib", "entry"]);
2618        assert_eq!(result, vec!["src/index.ts"]);
2619    }
2620
2621    #[test]
2622    fn string_or_array_non_literal_path_helper_args_dropped() {
2623        let source = r#"
2624            import { resolve } from "node:path";
2625            export default { build: { lib: { entry: resolve(baseDir, "src/index.ts") } } };
2626        "#;
2627        let result = extract_config_string_or_array(source, &js_path(), &["build", "lib", "entry"]);
2628        assert!(
2629            result.is_empty(),
2630            "non-literal path-helper args must be dropped: {result:?}"
2631        );
2632    }
2633
2634    #[test]
2635    fn require_strings_array() {
2636        let source = r"
2637            module.exports = {
2638                plugins: [
2639                    require('autoprefixer'),
2640                    require('postcss-import')
2641                ]
2642            };
2643        ";
2644        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2645        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
2646    }
2647
2648    #[test]
2649    fn require_strings_with_tuples() {
2650        let source = r"
2651            module.exports = {
2652                plugins: [
2653                    require('autoprefixer'),
2654                    [require('postcss-preset-env'), { stage: 3 }]
2655                ]
2656            };
2657        ";
2658        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2659        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
2660    }
2661
2662    #[test]
2663    fn require_strings_empty_array() {
2664        let source = r"module.exports = { plugins: [] };";
2665        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2666        assert!(deps.is_empty());
2667    }
2668
2669    #[test]
2670    fn require_strings_no_require_calls() {
2671        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
2672        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2673        assert!(deps.is_empty());
2674    }
2675
2676    #[test]
2677    fn extract_aliases_from_object_with_file_url_to_path() {
2678        let source = r#"
2679            import { defineConfig } from 'vite';
2680            import { fileURLToPath, URL } from 'node:url';
2681
2682            export default defineConfig({
2683                resolve: {
2684                    alias: {
2685                        "@": fileURLToPath(new URL("./src", import.meta.url))
2686                    }
2687                }
2688            });
2689        "#;
2690
2691        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2692        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
2693    }
2694
2695    #[test]
2696    fn extract_aliases_from_array_form() {
2697        let source = r#"
2698            export default {
2699                resolve: {
2700                    alias: [
2701                        { find: "@", replacement: "./src" },
2702                        { find: "$utils", replacement: "src/lib/utils" }
2703                    ]
2704                }
2705            };
2706        "#;
2707
2708        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
2709        assert_eq!(
2710            aliases,
2711            vec![
2712                ("@".to_string(), "./src".to_string()),
2713                ("$utils".to_string(), "src/lib/utils".to_string())
2714            ]
2715        );
2716    }
2717
2718    #[test]
2719    fn extract_aliases_from_object_with_array_values() {
2720        let source = r#"
2721            ({
2722                compilerOptions: {
2723                    paths: {
2724                        "@/*": ["./src/*"],
2725                        "@shared/*": ["./shared/*", "./fallback/*"]
2726                    }
2727                }
2728            })
2729        "#;
2730
2731        let aliases = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
2732        assert_eq!(
2733            aliases,
2734            vec![
2735                ("@/*".to_string(), "./src/*".to_string()),
2736                ("@shared/*".to_string(), "./shared/*".to_string())
2737            ]
2738        );
2739    }
2740
2741    #[test]
2742    fn extract_array_object_strings_mixed_forms() {
2743        let source = r#"
2744            export default {
2745                components: [
2746                    "~/components",
2747                    { path: "@/feature-components" }
2748                ]
2749            };
2750        "#;
2751
2752        let values =
2753            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
2754        assert_eq!(
2755            values,
2756            vec![
2757                "~/components".to_string(),
2758                "@/feature-components".to_string()
2759            ]
2760        );
2761    }
2762
2763    #[test]
2764    fn extract_array_object_string_pairs_with_and_without_secondary() {
2765        let source = r#"
2766            export default {
2767                webServer: [
2768                    { command: "tsx scripts/api.ts", cwd: "packages/api" },
2769                    { command: "tsx scripts/web.ts" }
2770                ]
2771            };
2772        "#;
2773
2774        let pairs = extract_config_array_object_string_pairs(
2775            source,
2776            &ts_path(),
2777            &["webServer"],
2778            "command",
2779            "cwd",
2780        );
2781        assert_eq!(
2782            pairs,
2783            vec![
2784                (
2785                    "tsx scripts/api.ts".to_string(),
2786                    Some("packages/api".to_string())
2787                ),
2788                ("tsx scripts/web.ts".to_string(), None),
2789            ]
2790        );
2791    }
2792
2793    #[test]
2794    fn extract_array_object_string_pairs_skips_elements_missing_primary() {
2795        let source = r#"
2796            export default {
2797                webServer: [
2798                    { cwd: "packages/api" },
2799                    { command: "srvx --port 3000" }
2800                ]
2801            };
2802        "#;
2803
2804        let pairs = extract_config_array_object_string_pairs(
2805            source,
2806            &ts_path(),
2807            &["webServer"],
2808            "command",
2809            "cwd",
2810        );
2811        assert_eq!(pairs, vec![("srvx --port 3000".to_string(), None)]);
2812    }
2813
2814    #[test]
2815    fn extract_array_object_string_pairs_empty_for_object_form() {
2816        let source = r#"
2817            export default {
2818                webServer: { command: "srvx --port 3000" }
2819            };
2820        "#;
2821
2822        let pairs = extract_config_array_object_string_pairs(
2823            source,
2824            &ts_path(),
2825            &["webServer"],
2826            "command",
2827            "cwd",
2828        );
2829        assert!(pairs.is_empty());
2830    }
2831
2832    #[test]
2833    fn extract_config_plugin_option_string_from_json() {
2834        let source = r#"{
2835            "expo": {
2836                "plugins": [
2837                    ["expo-router", { "root": "src/app" }]
2838                ]
2839            }
2840        }"#;
2841
2842        let value = extract_config_plugin_option_string(
2843            source,
2844            &json_path(),
2845            &["expo", "plugins"],
2846            "expo-router",
2847            "root",
2848        );
2849
2850        assert_eq!(value, Some("src/app".to_string()));
2851    }
2852
2853    #[test]
2854    fn extract_config_plugin_option_string_from_top_level_plugins() {
2855        let source = r#"{
2856            "plugins": [
2857                ["expo-router", { "root": "./src/routes" }]
2858            ]
2859        }"#;
2860
2861        let value = extract_config_plugin_option_string_from_paths(
2862            source,
2863            &json_path(),
2864            &[&["plugins"], &["expo", "plugins"]],
2865            "expo-router",
2866            "root",
2867        );
2868
2869        assert_eq!(value, Some("./src/routes".to_string()));
2870    }
2871
2872    #[test]
2873    fn extract_config_plugin_option_string_from_ts_config() {
2874        let source = r"
2875            export default {
2876                expo: {
2877                    plugins: [
2878                        ['expo-router', { root: './src/app' }]
2879                    ]
2880                }
2881            };
2882        ";
2883
2884        let value = extract_config_plugin_option_string(
2885            source,
2886            &ts_path(),
2887            &["expo", "plugins"],
2888            "expo-router",
2889            "root",
2890        );
2891
2892        assert_eq!(value, Some("./src/app".to_string()));
2893    }
2894
2895    #[test]
2896    fn extract_config_plugin_option_string_returns_none_when_plugin_missing() {
2897        let source = r#"{
2898            "expo": {
2899                "plugins": [
2900                    ["expo-font", {}]
2901                ]
2902            }
2903        }"#;
2904
2905        let value = extract_config_plugin_option_string(
2906            source,
2907            &json_path(),
2908            &["expo", "plugins"],
2909            "expo-router",
2910            "root",
2911        );
2912
2913        assert_eq!(value, None);
2914    }
2915
2916    #[test]
2917    fn vite_react_babel_dependencies_extract_plain_tuple_and_prefixed_entries() {
2918        let source = r#"
2919            import react from "@vitejs/plugin-react";
2920
2921            export default defineConfig({
2922                plugins: [
2923                    react({
2924                        babel: {
2925                            plugins: [
2926                                "babel-plugin-plain",
2927                                ["module:@preact/signals-react-transform", { mode: "auto" }],
2928                            ],
2929                            presets: [["@babel/preset-react", { runtime: "automatic" }]],
2930                        },
2931                    }),
2932                ],
2933            });
2934        "#;
2935
2936        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
2937
2938        assert_eq!(
2939            deps,
2940            vec![
2941                "babel-plugin-plain".to_string(),
2942                "@preact/signals-react-transform".to_string(),
2943                "@babel/preset-react".to_string(),
2944            ]
2945        );
2946    }
2947
2948    #[test]
2949    fn vite_react_babel_dependencies_support_default_alias_import() {
2950        let source = r#"
2951            import { default as viteReact } from "@vitejs/plugin-react";
2952
2953            export default {
2954                plugins: [
2955                    viteReact({
2956                        babel: {
2957                            plugins: [["module:@scope/pkg/plugin", {}]],
2958                        },
2959                    }),
2960                ],
2961            };
2962        "#;
2963
2964        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
2965
2966        assert_eq!(deps, vec!["@scope/pkg".to_string()]);
2967    }
2968
2969    #[test]
2970    fn vite_react_babel_dependencies_ignore_unrelated_plugin_calls() {
2971        let source = r#"
2972            import vue from "@vitejs/plugin-vue";
2973
2974            export default {
2975                plugins: [
2976                    vue({
2977                        babel: {
2978                            plugins: ["@preact/signals-react-transform"],
2979                        },
2980                    }),
2981                ],
2982            };
2983        "#;
2984
2985        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
2986
2987        assert!(deps.is_empty());
2988    }
2989
2990    #[test]
2991    fn vite_react_babel_dependencies_skip_relative_and_protocol_entries() {
2992        let source = r#"
2993            import react from "@vitejs/plugin-react";
2994
2995            export default {
2996                plugins: [
2997                    react({
2998                        babel: {
2999                            plugins: ["./local-plugin", "module:./local-prefixed", "http://example.com/plugin"],
3000                        },
3001                    }),
3002                ],
3003            };
3004        "#;
3005
3006        let deps = extract_vite_react_babel_dependencies(source, &ts_path());
3007
3008        assert!(deps.is_empty());
3009    }
3010
3011    #[test]
3012    fn normalize_config_path_relative_to_root() {
3013        let config_path = PathBuf::from("/project/vite.config.ts");
3014        let root = PathBuf::from("/project");
3015
3016        assert_eq!(
3017            normalize_config_path("./src/lib", &config_path, &root),
3018            Some("src/lib".to_string())
3019        );
3020        assert_eq!(
3021            normalize_config_path("/src/lib", &config_path, &root),
3022            Some("src/lib".to_string())
3023        );
3024    }
3025
3026    #[test]
3027    fn normalize_config_path_mixed_separators_and_parent_dirs() {
3028        let config_path = PathBuf::from("/project/config/vite.config.ts");
3029        let root = PathBuf::from("/project");
3030
3031        assert_eq!(
3032            normalize_config_path(".\\src\\..\\app\\lib", &config_path, &root),
3033            Some("config/app/lib".to_string())
3034        );
3035    }
3036
3037    #[test]
3038    fn normalize_config_path_leading_slash_stays_project_relative() {
3039        let config_path = PathBuf::from("/project/vite.config.ts");
3040        let root = PathBuf::from("/project");
3041
3042        assert_eq!(
3043            normalize_config_path("/src\\lib", &config_path, &root),
3044            Some("src/lib".to_string())
3045        );
3046    }
3047
3048    #[test]
3049    fn json_wrapped_in_parens_string() {
3050        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
3051        let val = extract_config_string(source, &js_path(), &["extends"]);
3052        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
3053    }
3054
3055    #[test]
3056    fn json_wrapped_in_parens_nested_array() {
3057        let source =
3058            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
3059        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
3060        assert_eq!(types, vec!["node", "jest"]);
3061
3062        let include = extract_config_string_array(source, &js_path(), &["include"]);
3063        assert_eq!(include, vec!["src/**/*"]);
3064    }
3065
3066    #[test]
3067    fn json_wrapped_in_parens_object_keys() {
3068        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
3069        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3070        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
3071    }
3072
3073    fn json_path() -> PathBuf {
3074        PathBuf::from("config.json")
3075    }
3076
3077    #[test]
3078    fn json_file_parsed_correctly() {
3079        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
3080        let val = extract_config_string(source, &json_path(), &["key"]);
3081        assert_eq!(val, Some("value".to_string()));
3082
3083        let list = extract_config_string_array(source, &json_path(), &["list"]);
3084        assert_eq!(list, vec!["a", "b"]);
3085    }
3086
3087    #[test]
3088    fn jsonc_file_parsed_correctly() {
3089        let source = r#"{"key": "value"}"#;
3090        let path = PathBuf::from("tsconfig.jsonc");
3091        let val = extract_config_string(source, &path, &["key"]);
3092        assert_eq!(val, Some("value".to_string()));
3093    }
3094
3095    #[test]
3096    fn extract_define_config_arrow_function() {
3097        let source = r#"
3098            import { defineConfig } from 'vite';
3099            export default defineConfig(() => ({
3100                test: {
3101                    include: ["**/*.test.ts"]
3102                }
3103            }));
3104        "#;
3105        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3106        assert_eq!(include, vec!["**/*.test.ts"]);
3107    }
3108
3109    #[test]
3110    fn extract_config_from_default_export_function_declaration() {
3111        let source = r#"
3112            export default function createConfig() {
3113                return {
3114                    clientModules: ["./src/client/global.js"]
3115                };
3116            }
3117        "#;
3118
3119        let client_modules = extract_config_string_array(source, &ts_path(), &["clientModules"]);
3120        assert_eq!(client_modules, vec!["./src/client/global.js"]);
3121    }
3122
3123    #[test]
3124    fn extract_config_from_default_export_async_function_declaration() {
3125        let source = r#"
3126            export default async function createConfigAsync() {
3127                return {
3128                    docs: {
3129                        path: "knowledge"
3130                    }
3131                };
3132            }
3133        "#;
3134
3135        let docs_path = extract_config_string(source, &ts_path(), &["docs", "path"]);
3136        assert_eq!(docs_path, Some("knowledge".to_string()));
3137    }
3138
3139    #[test]
3140    fn extract_config_from_exported_arrow_function_identifier() {
3141        let source = r#"
3142            const config = async () => {
3143                return {
3144                    themes: ["classic"]
3145                };
3146            };
3147
3148            export default config;
3149        "#;
3150
3151        let themes = extract_config_shallow_strings(source, &ts_path(), "themes");
3152        assert_eq!(themes, vec!["classic"]);
3153    }
3154
3155    #[test]
3156    fn module_exports_nested_string() {
3157        let source = r#"
3158            module.exports = {
3159                resolve: {
3160                    alias: {
3161                        "@": "./src"
3162                    }
3163                }
3164            };
3165        "#;
3166        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
3167        assert_eq!(val, Some("./src".to_string()));
3168    }
3169
3170    #[test]
3171    fn property_strings_nested_objects() {
3172        let source = r#"
3173            export default {
3174                plugins: {
3175                    group1: { a: "val-a" },
3176                    group2: { b: "val-b" }
3177                }
3178            };
3179        "#;
3180        let values = extract_config_property_strings(source, &js_path(), "plugins");
3181        assert!(values.contains(&"val-a".to_string()));
3182        assert!(values.contains(&"val-b".to_string()));
3183    }
3184
3185    #[test]
3186    fn property_strings_missing_key_returns_empty() {
3187        let source = r#"export default { other: "value" };"#;
3188        let values = extract_config_property_strings(source, &js_path(), "missing");
3189        assert!(values.is_empty());
3190    }
3191
3192    #[test]
3193    fn shallow_strings_tuple_array() {
3194        let source = r#"
3195            module.exports = {
3196                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
3197            };
3198        "#;
3199        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
3200        assert_eq!(values, vec!["default", "jest-junit"]);
3201        assert!(!values.contains(&"reports".to_string()));
3202    }
3203
3204    #[test]
3205    fn shallow_strings_single_string() {
3206        let source = r#"export default { preset: "ts-jest" };"#;
3207        let values = extract_config_shallow_strings(source, &js_path(), "preset");
3208        assert_eq!(values, vec!["ts-jest"]);
3209    }
3210
3211    #[test]
3212    fn shallow_strings_missing_key() {
3213        let source = r#"export default { other: "val" };"#;
3214        let values = extract_config_shallow_strings(source, &js_path(), "missing");
3215        assert!(values.is_empty());
3216    }
3217
3218    #[test]
3219    fn shallow_strings_or_object_property_alias_objects() {
3220        let source = r#"
3221            export default {
3222                jsPlugins: [
3223                    "eslint-plugin-playwright",
3224                    ["eslint-plugin-regexp", { rules: {} }],
3225                    { name: "short", specifier: "eslint-plugin-with-long-name" }
3226                ]
3227            };
3228        "#;
3229        let values = extract_config_shallow_strings_or_object_property(
3230            source,
3231            &ts_path(),
3232            "jsPlugins",
3233            "specifier",
3234        );
3235        assert_eq!(
3236            values,
3237            vec![
3238                "eslint-plugin-playwright",
3239                "eslint-plugin-regexp",
3240                "eslint-plugin-with-long-name"
3241            ]
3242        );
3243    }
3244
3245    #[test]
3246    fn nested_shallow_strings_vitest_reporters() {
3247        let source = r#"
3248            export default {
3249                test: {
3250                    reporters: ["default", "vitest-sonar-reporter"]
3251                }
3252            };
3253        "#;
3254        let values =
3255            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3256        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3257    }
3258
3259    #[test]
3260    fn nested_shallow_strings_tuple_format() {
3261        let source = r#"
3262            export default {
3263                test: {
3264                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
3265                }
3266            };
3267        "#;
3268        let values =
3269            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3270        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
3271    }
3272
3273    #[test]
3274    fn nested_shallow_strings_missing_outer() {
3275        let source = r"export default { other: {} };";
3276        let values =
3277            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3278        assert!(values.is_empty());
3279    }
3280
3281    #[test]
3282    fn nested_shallow_strings_missing_inner() {
3283        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
3284        let values =
3285            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
3286        assert!(values.is_empty());
3287    }
3288
3289    #[test]
3290    fn string_or_array_missing_path() {
3291        let source = r"export default {};";
3292        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3293        assert!(result.is_empty());
3294    }
3295
3296    #[test]
3297    fn string_or_array_non_string_values() {
3298        let source = r"export default { entry: [42, true] };";
3299        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
3300        assert!(result.is_empty());
3301    }
3302
3303    #[test]
3304    fn array_nested_extraction() {
3305        let source = r#"
3306            export default defineConfig({
3307                test: {
3308                    projects: [
3309                        {
3310                            test: {
3311                                setupFiles: ["./test/setup-a.ts"]
3312                            }
3313                        },
3314                        {
3315                            test: {
3316                                setupFiles: "./test/setup-b.ts"
3317                            }
3318                        }
3319                    ]
3320                }
3321            });
3322        "#;
3323        let results = extract_config_array_nested_string_or_array(
3324            source,
3325            &ts_path(),
3326            &["test", "projects"],
3327            &["test", "setupFiles"],
3328        );
3329        assert!(results.contains(&"./test/setup-a.ts".to_string()));
3330        assert!(results.contains(&"./test/setup-b.ts".to_string()));
3331    }
3332
3333    #[test]
3334    fn array_nested_empty_when_no_array() {
3335        let source = r#"export default { test: { projects: "not-an-array" } };"#;
3336        let results = extract_config_array_nested_string_or_array(
3337            source,
3338            &js_path(),
3339            &["test", "projects"],
3340            &["test", "setupFiles"],
3341        );
3342        assert!(results.is_empty());
3343    }
3344
3345    #[test]
3346    fn object_nested_extraction() {
3347        let source = r#"{
3348            "projects": {
3349                "app-one": {
3350                    "architect": {
3351                        "build": {
3352                            "options": {
3353                                "styles": ["src/styles.css"]
3354                            }
3355                        }
3356                    }
3357                }
3358            }
3359        }"#;
3360        let results = extract_config_object_nested_string_or_array(
3361            source,
3362            &json_path(),
3363            &["projects"],
3364            &["architect", "build", "options", "styles"],
3365        );
3366        assert_eq!(results, vec!["src/styles.css"]);
3367    }
3368
3369    #[test]
3370    fn array_with_object_input_form_extracted() {
3371        let source = r#"{
3372            "projects": {
3373                "app": {
3374                    "architect": {
3375                        "build": {
3376                            "options": {
3377                                "styles": [
3378                                    "src/styles.scss",
3379                                    { "input": "src/theme.scss", "bundleName": "theme", "inject": false },
3380                                    { "bundleName": "lazy-only" }
3381                                ]
3382                            }
3383                        }
3384                    }
3385                }
3386            }
3387        }"#;
3388        let results = extract_config_object_nested_string_or_array(
3389            source,
3390            &json_path(),
3391            &["projects"],
3392            &["architect", "build", "options", "styles"],
3393        );
3394        assert!(
3395            results.contains(&"src/styles.scss".to_string()),
3396            "string form must still work: {results:?}"
3397        );
3398        assert!(
3399            results.contains(&"src/theme.scss".to_string()),
3400            "object form with `input` must be extracted: {results:?}"
3401        );
3402        assert!(
3403            !results.contains(&"lazy-only".to_string()),
3404            "bundleName must not be misinterpreted as a path: {results:?}"
3405        );
3406        assert!(
3407            !results.contains(&"theme".to_string()),
3408            "bundleName from full object must not leak: {results:?}"
3409        );
3410    }
3411
3412    #[test]
3413    fn object_nested_strings_extraction() {
3414        let source = r#"{
3415            "targets": {
3416                "build": {
3417                    "executor": "@angular/build:application"
3418                },
3419                "test": {
3420                    "executor": "@nx/vite:test"
3421                }
3422            }
3423        }"#;
3424        let results =
3425            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
3426        assert!(results.contains(&"@angular/build:application".to_string()));
3427        assert!(results.contains(&"@nx/vite:test".to_string()));
3428    }
3429
3430    #[test]
3431    fn require_strings_direct_call() {
3432        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
3433        let deps = extract_config_require_strings(source, &js_path(), "adapter");
3434        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
3435    }
3436
3437    #[test]
3438    fn require_strings_no_matching_key() {
3439        let source = r"module.exports = { other: require('something') };";
3440        let deps = extract_config_require_strings(source, &js_path(), "plugins");
3441        assert!(deps.is_empty());
3442    }
3443
3444    #[test]
3445    fn extract_imports_no_imports() {
3446        let source = r"export default {};";
3447        let imports = extract_imports(source, &js_path());
3448        assert!(imports.is_empty());
3449    }
3450
3451    #[test]
3452    fn extract_imports_side_effect_import() {
3453        let source = r"
3454            import 'polyfill';
3455            import './local-setup';
3456            export default {};
3457        ";
3458        let imports = extract_imports(source, &js_path());
3459        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
3460    }
3461
3462    #[test]
3463    fn extract_imports_mixed_specifiers() {
3464        let source = r"
3465            import defaultExport from 'module-a';
3466            import { named } from 'module-b';
3467            import * as ns from 'module-c';
3468            export default {};
3469        ";
3470        let imports = extract_imports(source, &js_path());
3471        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
3472    }
3473
3474    #[test]
3475    fn template_literal_in_string_or_array() {
3476        let source = r"export default { entry: `./src/index.ts` };";
3477        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
3478        assert_eq!(result, vec!["./src/index.ts"]);
3479    }
3480
3481    #[test]
3482    fn template_literal_in_config_string() {
3483        let source = r"export default { testDir: `./tests` };";
3484        let val = extract_config_string(source, &js_path(), &["testDir"]);
3485        assert_eq!(val, Some("./tests".to_string()));
3486    }
3487
3488    #[test]
3489    fn template_literal_command_recovers_static_command_tokens() {
3490        let source = r"
3491            const PORT = 3000;
3492            export default {
3493                webServer: {
3494                    command: `pnpm exec srvx --port ${PORT} --hostname 127.0.0.1`
3495                }
3496            };
3497        ";
3498        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3499        assert_eq!(
3500            val,
3501            Some("pnpm exec srvx --port   --hostname 127.0.0.1".to_string())
3502        );
3503    }
3504
3505    #[test]
3506    fn template_literal_command_skips_dynamic_prefix() {
3507        let source = r"
3508            export default {
3509                webServer: { command: `${serverCommand} && pnpm exec srvx` }
3510            };
3511        ";
3512        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3513        assert!(val.is_none());
3514    }
3515
3516    #[test]
3517    fn template_literal_command_skips_split_static_token() {
3518        let source = r"
3519            export default {
3520                webServer: { command: `pnpm exec sr${part}vx --port 3000` }
3521            };
3522        ";
3523        let val = extract_config_command(source, &ts_path(), &["webServer", "command"]);
3524        assert!(val.is_none());
3525    }
3526
3527    #[test]
3528    fn array_object_command_pairs_recover_template_command() {
3529        let source = r"
3530            const PORT = 3000;
3531            export default {
3532                webServer: [
3533                    {
3534                        command: `pnpm exec srvx --port ${PORT}`,
3535                        cwd: 'apps/web'
3536                    }
3537                ]
3538            };
3539        ";
3540        let pairs = extract_config_array_object_command_pairs(
3541            source,
3542            &ts_path(),
3543            &["webServer"],
3544            "command",
3545            "cwd",
3546        );
3547        assert_eq!(
3548            pairs,
3549            vec![(
3550                "pnpm exec srvx --port  ".to_string(),
3551                Some("apps/web".to_string())
3552            )]
3553        );
3554    }
3555
3556    #[test]
3557    fn nested_string_array_empty_path() {
3558        let source = r#"export default { items: ["a", "b"] };"#;
3559        let result = extract_config_string_array(source, &js_path(), &[]);
3560        assert!(result.is_empty());
3561    }
3562
3563    #[test]
3564    fn nested_string_empty_path() {
3565        let source = r#"export default { key: "val" };"#;
3566        let result = extract_config_string(source, &js_path(), &[]);
3567        assert!(result.is_none());
3568    }
3569
3570    #[test]
3571    fn object_keys_empty_path() {
3572        let source = r"export default { plugins: {} };";
3573        let result = extract_config_object_keys(source, &js_path(), &[]);
3574        assert!(result.is_empty());
3575    }
3576
3577    #[test]
3578    fn no_config_object_returns_empty() {
3579        let source = r"const x = 42;";
3580        let result = extract_config_string(source, &js_path(), &["key"]);
3581        assert!(result.is_none());
3582
3583        let arr = extract_config_string_array(source, &js_path(), &["items"]);
3584        assert!(arr.is_empty());
3585
3586        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
3587        assert!(keys.is_empty());
3588    }
3589
3590    #[test]
3591    fn property_with_string_key() {
3592        let source = r#"export default { "string-key": "value" };"#;
3593        let val = extract_config_string(source, &js_path(), &["string-key"]);
3594        assert_eq!(val, Some("value".to_string()));
3595    }
3596
3597    #[test]
3598    fn nested_navigation_through_non_object() {
3599        let source = r#"export default { level1: "not-an-object" };"#;
3600        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
3601        assert!(val.is_none());
3602    }
3603
3604    #[test]
3605    fn variable_reference_untyped() {
3606        let source = r#"
3607            const config = {
3608                testDir: "./tests"
3609            };
3610            export default config;
3611        "#;
3612        let val = extract_config_string(source, &js_path(), &["testDir"]);
3613        assert_eq!(val, Some("./tests".to_string()));
3614    }
3615
3616    #[test]
3617    fn variable_reference_with_type_annotation() {
3618        let source = r#"
3619            import type { StorybookConfig } from '@storybook/react-vite';
3620            const config: StorybookConfig = {
3621                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
3622                framework: "@storybook/react-vite"
3623            };
3624            export default config;
3625        "#;
3626        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
3627        assert_eq!(
3628            addons,
3629            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
3630        );
3631
3632        let framework = extract_config_string(source, &ts_path(), &["framework"]);
3633        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
3634    }
3635
3636    #[test]
3637    fn variable_reference_with_define_config() {
3638        let source = r#"
3639            import { defineConfig } from 'vitest/config';
3640            const config = defineConfig({
3641                test: {
3642                    include: ["**/*.test.ts"]
3643                }
3644            });
3645            export default config;
3646        "#;
3647        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
3648        assert_eq!(include, vec!["**/*.test.ts"]);
3649    }
3650
3651    #[test]
3652    fn ts_satisfies_direct_export() {
3653        let source = r#"
3654            export default {
3655                testDir: "./tests"
3656            } satisfies PlaywrightTestConfig;
3657        "#;
3658        let val = extract_config_string(source, &ts_path(), &["testDir"]);
3659        assert_eq!(val, Some("./tests".to_string()));
3660    }
3661
3662    #[test]
3663    fn ts_as_direct_export() {
3664        let source = r#"
3665            export default {
3666                testDir: "./tests"
3667            } as const;
3668        "#;
3669        let val = extract_config_string(source, &ts_path(), &["testDir"]);
3670        assert_eq!(val, Some("./tests".to_string()));
3671    }
3672
3673    // --- issue #811: resolve.alias as imported identifier / spread ---
3674
3675    fn aliases(source: &str) -> Vec<(String, String)> {
3676        extract_config_aliases(source, &js_path(), &["resolve", "alias"])
3677    }
3678
3679    #[test]
3680    fn aliases_inline_object_still_extracted() {
3681        // Regression: the resolver must not change inline-object behavior.
3682        let source = r#"
3683            export default defineConfig({
3684                resolve: { alias: { "@": "./src", utils: "../../utils" } }
3685            });
3686        "#;
3687        let mut got = aliases(source);
3688        got.sort();
3689        assert_eq!(
3690            got,
3691            vec![
3692                ("@".to_string(), "./src".to_string()),
3693                ("utils".to_string(), "../../utils".to_string()),
3694            ]
3695        );
3696    }
3697
3698    #[test]
3699    fn aliases_inline_array_still_extracted() {
3700        let source = r#"
3701            export default defineConfig({
3702                resolve: { alias: [{ find: "@", replacement: "./src" }] }
3703            });
3704        "#;
3705        assert_eq!(
3706            aliases(source),
3707            vec![("@".to_string(), "./src".to_string())]
3708        );
3709    }
3710
3711    #[test]
3712    fn aliases_local_const_array_identifier() {
3713        let source = r#"
3714            const sharedAliases = [{ find: "@", replacement: "./src" }];
3715            export default defineConfig({ resolve: { alias: sharedAliases } });
3716        "#;
3717        assert_eq!(
3718            aliases(source),
3719            vec![("@".to_string(), "./src".to_string())]
3720        );
3721    }
3722
3723    #[test]
3724    fn aliases_local_const_object_identifier() {
3725        let source = r#"
3726            const sharedAliases = { "@": "./src" };
3727            export default defineConfig({ resolve: { alias: sharedAliases } });
3728        "#;
3729        assert_eq!(
3730            aliases(source),
3731            vec![("@".to_string(), "./src".to_string())]
3732        );
3733    }
3734
3735    #[test]
3736    fn aliases_array_spread_of_identifiers_and_inline() {
3737        let source = r##"
3738            const a = [{ find: "@", replacement: "./src" }];
3739            const b = [{ find: "~", replacement: "./lib" }];
3740            export default defineConfig({
3741                resolve: { alias: [...a, ...b, { find: "#", replacement: "./test" }] }
3742            });
3743        "##;
3744        let mut got = aliases(source);
3745        got.sort();
3746        assert_eq!(
3747            got,
3748            vec![
3749                ("#".to_string(), "./test".to_string()),
3750                ("@".to_string(), "./src".to_string()),
3751                ("~".to_string(), "./lib".to_string()),
3752            ]
3753        );
3754    }
3755
3756    #[test]
3757    fn aliases_object_spread_of_identifier_and_inline() {
3758        let source = r#"
3759            const base = { "@": "./src" };
3760            export default defineConfig({
3761                resolve: { alias: { ...base, "~": "./lib" } }
3762            });
3763        "#;
3764        let mut got = aliases(source);
3765        got.sort();
3766        assert_eq!(
3767            got,
3768            vec![
3769                ("@".to_string(), "./src".to_string()),
3770                ("~".to_string(), "./lib".to_string()),
3771            ]
3772        );
3773    }
3774
3775    #[test]
3776    fn aliases_local_const_chained_identifier() {
3777        // `const a = b` indirection resolves through the chain.
3778        let source = r#"
3779            const real = [{ find: "@", replacement: "./src" }];
3780            const alias2 = real;
3781            export default defineConfig({ resolve: { alias: alias2 } });
3782        "#;
3783        assert_eq!(
3784            aliases(source),
3785            vec![("@".to_string(), "./src".to_string())]
3786        );
3787    }
3788
3789    #[test]
3790    fn aliases_imported_named_identifier_from_sibling() {
3791        let dir = tempfile::tempdir().unwrap();
3792        std::fs::write(
3793            dir.path().join("vite.shared.js"),
3794            r#"export const sharedAliases = [
3795                { find: "@", replacement: new URL("./src", import.meta.url).pathname },
3796            ];"#,
3797        )
3798        .unwrap();
3799        let config = dir.path().join("vite.config.js");
3800        let source = r#"
3801            import { defineConfig } from "vite";
3802            import { sharedAliases } from "./vite.shared.js";
3803            export default defineConfig({ resolve: { alias: sharedAliases } });
3804        "#;
3805        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3806        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3807    }
3808
3809    #[test]
3810    fn aliases_imported_extensionless_specifier_probed() {
3811        let dir = tempfile::tempdir().unwrap();
3812        std::fs::write(
3813            dir.path().join("aliases.mjs"),
3814            r#"export const sharedAliases = { "@": "./src" };"#,
3815        )
3816        .unwrap();
3817        let config = dir.path().join("vite.config.ts");
3818        let source = r#"
3819            import { sharedAliases } from "./aliases";
3820            export default defineConfig({ resolve: { alias: sharedAliases } });
3821        "#;
3822        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3823        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3824    }
3825
3826    #[test]
3827    fn aliases_imported_default_export_from_sibling() {
3828        let dir = tempfile::tempdir().unwrap();
3829        std::fs::write(
3830            dir.path().join("aliases.js"),
3831            r#"export default [{ find: "@", replacement: "./src" }];"#,
3832        )
3833        .unwrap();
3834        let config = dir.path().join("vite.config.js");
3835        let source = r#"
3836            import sharedAliases from "./aliases.js";
3837            export default defineConfig({ resolve: { alias: sharedAliases } });
3838        "#;
3839        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3840        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3841    }
3842
3843    #[test]
3844    fn aliases_imported_spread_from_two_siblings() {
3845        let dir = tempfile::tempdir().unwrap();
3846        std::fs::write(
3847            dir.path().join("a.js"),
3848            r#"export const a = [{ find: "@", replacement: "./src" }];"#,
3849        )
3850        .unwrap();
3851        std::fs::write(
3852            dir.path().join("b.js"),
3853            r#"export const b = [{ find: "~", replacement: "./lib" }];"#,
3854        )
3855        .unwrap();
3856        let config = dir.path().join("vite.config.js");
3857        let source = r#"
3858            import { a } from "./a.js";
3859            import { b } from "./b.js";
3860            export default defineConfig({ resolve: { alias: [...a, ...b] } });
3861        "#;
3862        let mut got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3863        got.sort();
3864        assert_eq!(
3865            got,
3866            vec![
3867                ("@".to_string(), "./src".to_string()),
3868                ("~".to_string(), "./lib".to_string()),
3869            ]
3870        );
3871    }
3872
3873    #[test]
3874    fn aliases_import_cycle_terminates() {
3875        // a.js imports from b.js and vice versa; resolution must not hang and
3876        // should still recover the literal pairs present.
3877        let dir = tempfile::tempdir().unwrap();
3878        std::fs::write(
3879            dir.path().join("a.js"),
3880            r#"import { b } from "./b.js";
3881               export const a = [{ find: "@", replacement: "./src" }, ...b];"#,
3882        )
3883        .unwrap();
3884        std::fs::write(
3885            dir.path().join("b.js"),
3886            r#"import { a } from "./a.js";
3887               export const b = [...a];"#,
3888        )
3889        .unwrap();
3890        let config = dir.path().join("vite.config.js");
3891        let source = r#"
3892            import { a } from "./a.js";
3893            export default defineConfig({ resolve: { alias: a } });
3894        "#;
3895        let got = extract_config_aliases(source, &config, &["resolve", "alias"]);
3896        assert_eq!(got, vec![("@".to_string(), "./src".to_string())]);
3897    }
3898
3899    #[test]
3900    fn aliases_non_relative_import_not_followed() {
3901        // A bare-package import is intentionally out of scope: no node_modules
3902        // read for an alias literal.
3903        let source = r#"
3904            import { sharedAliases } from "some-pkg";
3905            export default defineConfig({ resolve: { alias: sharedAliases } });
3906        "#;
3907        let dir = tempfile::tempdir().unwrap();
3908        let config = dir.path().join("vite.config.js");
3909        assert!(extract_config_aliases(source, &config, &["resolve", "alias"]).is_empty());
3910    }
3911
3912    #[test]
3913    fn aliases_object_array_value_takes_first_entry() {
3914        // tsconfig `compilerOptions.paths` maps each key to an ARRAY of targets;
3915        // the resolver must take the first, matching the long-standing non-kinded
3916        // behavior the TypeScript plugin depends on. Regression guard for the
3917        // array-value case that the kinded unification briefly dropped.
3918        let source = r#"
3919            export default {
3920                compilerOptions: { paths: { "@/*": ["./src/*"], "~/*": ["./lib/*", "./vendor/*"] } }
3921            };
3922        "#;
3923        let mut got = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
3924        got.sort();
3925        assert_eq!(
3926            got,
3927            vec![
3928                ("@/*".to_string(), "./src/*".to_string()),
3929                ("~/*".to_string(), "./lib/*".to_string()),
3930            ]
3931        );
3932    }
3933
3934    #[test]
3935    fn aliases_kinded_preserves_is_bare_through_resolution() {
3936        // The bare-string vs path discriminator must survive identifier + spread
3937        // resolution (the test.alias package-to-package gate depends on it).
3938        let source = r#"
3939            const a = [{ find: "lodash-es", replacement: "lodash" }];
3940            export default defineConfig({
3941                resolve: { alias: [...a, { find: "@", replacement: "./src" }] }
3942            });
3943        "#;
3944        let mut got = extract_config_aliases_kinded(source, &js_path(), &["resolve", "alias"]);
3945        got.sort();
3946        assert_eq!(
3947            got,
3948            vec![
3949                ("@".to_string(), "./src".to_string(), false),
3950                ("lodash-es".to_string(), "lodash".to_string(), true),
3951            ]
3952        );
3953    }
3954
3955    #[test]
3956    fn aliases_kinded_preserves_is_bare_through_imported_spread() {
3957        let dir = tempfile::tempdir().unwrap();
3958        std::fs::write(
3959            dir.path().join("aliases.js"),
3960            r#"export const packageAliases = [{ find: "lodash-es", replacement: "lodash" }];"#,
3961        )
3962        .unwrap();
3963        let config = dir.path().join("vite.config.js");
3964        let source = r#"
3965            import { packageAliases } from "./aliases.js";
3966            export default defineConfig({
3967                resolve: { alias: [...packageAliases, { find: "@", replacement: "./src" }] }
3968            });
3969        "#;
3970        let mut got = extract_config_aliases_kinded(source, &config, &["resolve", "alias"]);
3971        got.sort();
3972        assert_eq!(
3973            got,
3974            vec![
3975                ("@".to_string(), "./src".to_string(), false),
3976                ("lodash-es".to_string(), "lodash".to_string(), true),
3977            ]
3978        );
3979    }
3980}