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