Skip to main content

fallow_core/plugins/
config_parser.rs

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