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/// Extract keys of an object property at a nested path.
238///
239/// Useful for `PostCSS` config: `{ plugins: { autoprefixer: {}, tailwindcss: {} } }`
240/// → returns `["autoprefixer", "tailwindcss"]`.
241#[must_use]
242pub fn extract_config_object_keys(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
243    extract_from_source(source, path, |program| {
244        let obj = find_config_object(program)?;
245        get_nested_object_keys(obj, prop_path)
246    })
247    .unwrap_or_default()
248}
249
250/// Extract a value that may be a single string, a string array, or an object with string/array values.
251///
252/// Useful for Webpack `entry`, Rollup `input`, etc. that accept multiple formats:
253/// - `entry: "./src/index.js"` → `["./src/index.js"]`
254/// - `entry: ["./src/a.js", "./src/b.js"]` → `["./src/a.js", "./src/b.js"]`
255/// - `entry: { main: "./src/main.js" }` → `["./src/main.js"]`
256/// - `entry: { main: ["./src/polyfill.js", "./src/main.js"] }` → `["./src/polyfill.js", "./src/main.js"]`
257/// - `entry: { main: { import: "./src/main.js" } }` → `["./src/main.js"]`
258#[must_use]
259pub fn extract_config_string_or_array(
260    source: &str,
261    path: &Path,
262    prop_path: &[&str],
263) -> Vec<String> {
264    extract_from_source(source, path, |program| {
265        let obj = find_config_object(program)?;
266        get_nested_string_or_array(obj, prop_path)
267    })
268    .unwrap_or_default()
269}
270
271/// Extract a statically recoverable path-like string from a property path.
272#[must_use]
273pub fn extract_config_path_string(source: &str, path: &Path, prop_path: &[&str]) -> Option<String> {
274    extract_from_source(source, path, |program| {
275        let obj = find_config_object(program)?;
276        let expr = get_nested_expression(obj, prop_path)?;
277        expression_to_path_string(expr)
278    })
279}
280
281/// Extract string values from a property path, also searching inside array elements.
282///
283/// Navigates `array_path` to find an array expression, then for each object in the
284/// array, navigates `inner_path` to extract string values. Useful for configs like
285/// Vitest projects where values are nested in array elements:
286/// - `test.projects[*].test.setupFiles`
287#[must_use]
288pub fn extract_config_array_nested_string_or_array(
289    source: &str,
290    path: &Path,
291    array_path: &[&str],
292    inner_path: &[&str],
293) -> Vec<String> {
294    extract_from_source(source, path, |program| {
295        let obj = find_config_object(program)?;
296        let array_expr = get_nested_expression(obj, array_path)?;
297        let Expression::ArrayExpression(arr) = array_expr else {
298            return None;
299        };
300        let mut results = Vec::new();
301        for element in &arr.elements {
302            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
303                && let Some(values) = get_nested_string_or_array(element_obj, inner_path)
304            {
305                results.extend(values);
306            }
307        }
308        if results.is_empty() {
309            None
310        } else {
311            Some(results)
312        }
313    })
314    .unwrap_or_default()
315}
316
317/// Extract string values from a property path, searching inside all values of an object.
318///
319/// Navigates `object_path` to find an object expression, then for each property value
320/// (regardless of key name), navigates `inner_path` to extract string values. Useful for
321/// configs with dynamic keys like `angular.json`:
322/// - `projects.*.architect.build.options.styles`
323#[must_use]
324pub fn extract_config_object_nested_string_or_array(
325    source: &str,
326    path: &Path,
327    object_path: &[&str],
328    inner_path: &[&str],
329) -> Vec<String> {
330    extract_config_object_nested(source, path, object_path, |value_obj| {
331        get_nested_string_or_array(value_obj, inner_path)
332    })
333}
334
335/// Extract string values from a property path, searching inside all values of an object.
336///
337/// Like [`extract_config_object_nested_string_or_array`] but returns a single optional string
338/// per object value (useful for fields like `architect.build.options.main`).
339#[must_use]
340pub fn extract_config_object_nested_strings(
341    source: &str,
342    path: &Path,
343    object_path: &[&str],
344    inner_path: &[&str],
345) -> Vec<String> {
346    extract_config_object_nested(source, path, object_path, |value_obj| {
347        get_nested_string_from_object(value_obj, inner_path).map(|s| vec![s])
348    })
349}
350
351/// Shared helper for object-nested extraction.
352///
353/// Navigates `object_path` to find an object expression, then for each property value
354/// that is itself an object, calls `extract_fn` to produce string values.
355fn extract_config_object_nested(
356    source: &str,
357    path: &Path,
358    object_path: &[&str],
359    extract_fn: impl Fn(&ObjectExpression<'_>) -> Option<Vec<String>>,
360) -> Vec<String> {
361    extract_from_source(source, path, |program| {
362        let obj = find_config_object(program)?;
363        let obj_expr = get_nested_expression(obj, object_path)?;
364        let Expression::ObjectExpression(target_obj) = obj_expr else {
365            return None;
366        };
367        let mut results = Vec::new();
368        for prop in &target_obj.properties {
369            if let ObjectPropertyKind::ObjectProperty(p) = prop
370                && let Expression::ObjectExpression(value_obj) = &p.value
371                && let Some(values) = extract_fn(value_obj)
372            {
373                results.extend(values);
374            }
375        }
376        if results.is_empty() {
377            None
378        } else {
379            Some(results)
380        }
381    })
382    .unwrap_or_default()
383}
384
385/// Extract `require('...')` call argument strings from a property's value.
386///
387/// Handles direct require calls and arrays containing require calls or tuples:
388/// - `plugins: [require('autoprefixer')]`
389/// - `plugins: [require('postcss-import'), [require('postcss-preset-env'), { ... }]]`
390#[must_use]
391pub fn extract_config_require_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
392    extract_from_source(source, path, |program| {
393        let obj = find_config_object(program)?;
394        let prop = find_property(obj, key)?;
395        Some(collect_require_sources(&prop.value))
396    })
397    .unwrap_or_default()
398}
399
400/// Extract alias mappings from an object or array-based alias config.
401///
402/// Supports common bundler config shapes like:
403/// - `resolve.alias = { "@": "./src" }`
404/// - `resolve.alias = [{ find: "@", replacement: "./src" }]`
405/// - `resolve.alias = [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }]`
406#[must_use]
407pub fn extract_config_aliases(
408    source: &str,
409    path: &Path,
410    prop_path: &[&str],
411) -> Vec<(String, String)> {
412    extract_from_source(source, path, |program| {
413        let obj = find_config_object(program)?;
414        let expr = get_nested_expression(obj, prop_path)?;
415        let aliases = expression_to_alias_pairs(expr);
416        (!aliases.is_empty()).then_some(aliases)
417    })
418    .unwrap_or_default()
419}
420
421/// Extract string values from a nested array, supporting both string elements and
422/// object elements with a named string/path field.
423///
424/// Useful for configs like:
425/// - `components: ["~/components", { path: "~/feature-components" }]`
426#[must_use]
427pub fn extract_config_array_object_strings(
428    source: &str,
429    path: &Path,
430    array_path: &[&str],
431    key: &str,
432) -> Vec<String> {
433    extract_from_source(source, path, |program| {
434        let obj = find_config_object(program)?;
435        let array_expr = get_nested_expression(obj, array_path)?;
436        let Expression::ArrayExpression(arr) = array_expr else {
437            return None;
438        };
439
440        let mut results = Vec::new();
441        for element in &arr.elements {
442            let Some(expr) = element.as_expression() else {
443                continue;
444            };
445            match expr {
446                Expression::ObjectExpression(item) => {
447                    if let Some(prop) = find_property(item, key)
448                        && let Some(value) = expression_to_path_string(&prop.value)
449                    {
450                        results.push(value);
451                    }
452                }
453                _ => {
454                    if let Some(value) = expression_to_path_string(expr) {
455                        results.push(value);
456                    }
457                }
458            }
459        }
460
461        (!results.is_empty()).then_some(results)
462    })
463    .unwrap_or_default()
464}
465
466/// Extract static specifiers from thunk-wrapped dynamic imports inside an
467/// array property.
468///
469/// Captures the `SPEC` argument from each `() => import('SPEC')` element of
470/// an array nested under `prop_path` in the config's default-exported object.
471///
472/// # The pattern
473///
474/// Configs and registries that need to defer module evaluation commonly hold
475/// arrays of *thunks* — zero-argument arrow functions whose body is a single
476/// dynamic import:
477///
478/// ```ts
479/// export default defineConfig({
480///     modules: [
481///         () => import('./feature-a'),
482///         { file: () => import('./feature-b'), enabled: true },
483///     ],
484/// })
485/// ```
486///
487/// `import('SPEC')` is the ECMAScript dynamic-import expression (TC39
488/// dynamic-import proposal, shipped in ES2020): a runtime module loader call
489/// that returns a `Promise<Module>`. Wrapping it in `() => import('SPEC')`
490/// turns "load module X now" into "value that, when invoked, loads module X"
491/// — a thunk the host can call lazily.
492///
493/// The technique predates any single framework. It's the same shape used by
494/// route-level code-splitting (`Vue Router`, `React Router`, `Next.js`),
495/// `React.lazy`, Webpack's documented dynamic-import code-splitting recipes,
496/// and any registry that wants to keep boot cheap, break import cycles, or
497/// let bundlers tree-shake unused branches. Configs that adopt the pattern
498/// can therefore declare large module graphs without forcing eager
499/// evaluation of every entry at config parse time.
500///
501/// # Recognised array element shapes
502///
503/// - Concise arrow: `() => import('SPEC')`
504/// - Block-body arrow with explicit return: `() => { return import('SPEC') }`
505/// - Object form with a `file` property holding the arrow:
506///   `{ file: () => import('SPEC'), /* peer fields */ }`
507///
508/// Non-matching elements (string literals, variables, template-string
509/// specifiers, computed expressions) are silently skipped: callers receive
510/// only the statically-resolvable specifiers, in source order.
511#[must_use]
512pub fn extract_lazy_imports_in_array(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
513    extract_from_source(source, path, |program| {
514        let obj = find_config_object(program)?;
515        let array_expr = get_nested_expression(obj, prop_path)?;
516        let Expression::ArrayExpression(arr) = array_expr else {
517            return None;
518        };
519        let mut specs = Vec::new();
520        for element in &arr.elements {
521            let Some(expr) = element.as_expression() else {
522                continue;
523            };
524            if let Some(spec) = lazy_import_specifier(expr) {
525                specs.push(spec);
526            }
527        }
528        (!specs.is_empty()).then_some(specs)
529    })
530    .unwrap_or_default()
531}
532
533/// Read a lazy-import specifier from a single array element expression.
534///
535/// Two outer shapes are accepted at this level (array-element navigation):
536/// - A bare callable: `() => import('SPEC')` or the function-expression
537///   equivalent.
538/// - An object with a `file` property holding the callable:
539///   `{ file: () => import('SPEC'), /* peer fields */ }`.
540///
541/// The actual callable → import peeling is delegated to
542/// [`extract_import_from_callable`], which is shared with the visitor-side
543/// dynamic-import helpers so all three navigation pipelines stay in lockstep
544/// when ECMAScript adds new wrapper shapes.
545fn lazy_import_specifier(expr: &Expression<'_>) -> Option<String> {
546    let callable = match expr {
547        Expression::ObjectExpression(obj) => &find_property(obj, "file")?.value,
548        _ => expr,
549    };
550    let import_expr = extract_import_from_callable(callable)?;
551    expression_to_string(&import_expr.source)
552}
553
554/// Extract a string-like option from a plugin tuple inside a config plugin array.
555///
556/// Supports config shapes like:
557/// - `{ expo: { plugins: [["expo-router", { root: "src/app" }]] } }`
558/// - `export default { expo: { plugins: [["expo-router", { root: "./src/app" }]] } }`
559/// - `{ plugins: [["expo-router", { root: "./src/routes" }]] }`
560#[must_use]
561pub fn extract_config_plugin_option_string(
562    source: &str,
563    path: &Path,
564    plugins_path: &[&str],
565    plugin_name: &str,
566    option_key: &str,
567) -> Option<String> {
568    extract_from_source(source, path, |program| {
569        let obj = find_config_object(program)?;
570        let plugins_expr = get_nested_expression(obj, plugins_path)?;
571        let Expression::ArrayExpression(plugins) = plugins_expr else {
572            return None;
573        };
574
575        for entry in &plugins.elements {
576            let Some(Expression::ArrayExpression(tuple)) = entry.as_expression() else {
577                continue;
578            };
579            let Some(plugin_expr) = tuple
580                .elements
581                .first()
582                .and_then(ArrayExpressionElement::as_expression)
583            else {
584                continue;
585            };
586            if expression_to_string(plugin_expr).as_deref() != Some(plugin_name) {
587                continue;
588            }
589
590            let Some(options_expr) = tuple
591                .elements
592                .get(1)
593                .and_then(ArrayExpressionElement::as_expression)
594            else {
595                continue;
596            };
597            let Expression::ObjectExpression(options_obj) = options_expr else {
598                continue;
599            };
600            let option = find_property(options_obj, option_key)?;
601            return expression_to_path_string(&option.value);
602        }
603
604        None
605    })
606}
607
608/// Extract a string-like option from the first plugin array path that contains it.
609#[must_use]
610pub fn extract_config_plugin_option_string_from_paths(
611    source: &str,
612    path: &Path,
613    plugin_paths: &[&[&str]],
614    plugin_name: &str,
615    option_key: &str,
616) -> Option<String> {
617    plugin_paths.iter().find_map(|plugins_path| {
618        extract_config_plugin_option_string(source, path, plugins_path, plugin_name, option_key)
619    })
620}
621
622/// Normalize a config-relative path string to a project-root-relative path.
623///
624/// Handles values extracted from config files such as `"./src"`, `"src/lib"`,
625/// `"/src"`, or absolute filesystem paths under `root`.
626#[must_use]
627pub fn normalize_config_path(raw: &str, config_path: &Path, root: &Path) -> Option<String> {
628    if raw.is_empty() {
629        return None;
630    }
631
632    let candidate = if let Some(stripped) = raw.strip_prefix('/') {
633        lexical_normalize(&root.join(stripped))
634    } else {
635        let path = Path::new(raw);
636        if path.is_absolute() {
637            lexical_normalize(path)
638        } else {
639            let base = config_path.parent().unwrap_or(root);
640            lexical_normalize(&base.join(path))
641        }
642    };
643
644    let relative = candidate.strip_prefix(root).ok()?;
645    let normalized = relative.to_string_lossy().replace('\\', "/");
646    (!normalized.is_empty()).then_some(normalized)
647}
648
649// ── Internal helpers ──────────────────────────────────────────────
650
651/// Parse source and run an extraction function on the AST.
652///
653/// JSON files (`.json`, `.jsonc`) are parsed as JavaScript expressions wrapped in
654/// parentheses to produce an AST compatible with `find_config_object`. The native
655/// JSON source type in Oxc produces a different AST structure that our helpers
656/// don't handle.
657fn extract_from_source<T>(
658    source: &str,
659    path: &Path,
660    extractor: impl FnOnce(&Program) -> Option<T>,
661) -> Option<T> {
662    let source_type = SourceType::from_path(path).unwrap_or_default();
663    let alloc = Allocator::default();
664
665    // For JSON files, wrap in parens and parse as JS so the AST matches
666    // what find_config_object expects (ExpressionStatement → ObjectExpression).
667    let is_json = path
668        .extension()
669        .is_some_and(|ext| ext == "json" || ext == "jsonc");
670    if is_json {
671        let wrapped = format!("({source})");
672        let parsed = Parser::new(&alloc, &wrapped, SourceType::mjs()).parse();
673        return extractor(&parsed.program);
674    }
675
676    let parsed = Parser::new(&alloc, source, source_type).parse();
677    extractor(&parsed.program)
678}
679
680/// Find the "config object" — the object expression in the default export or module.exports.
681///
682/// Handles these patterns:
683/// - `export default { ... }`
684/// - `export default defineConfig({ ... })`
685/// - `export default defineConfig(async () => ({ ... }))`
686/// - `export default { ... } satisfies Config` / `export default { ... } as Config`
687/// - `const config = { ... }; export default config;`
688/// - `const config: Config = { ... }; export default config;`
689/// - `module.exports = { ... }`
690/// - Top-level JSON object (for .json files)
691fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
692    for stmt in &program.body {
693        match stmt {
694            // export default { ... } or export default defineConfig({ ... })
695            Statement::ExportDefaultDeclaration(decl) => {
696                // ExportDefaultDeclarationKind inherits Expression variants directly
697                let expr: Option<&Expression> = match &decl.declaration {
698                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
699                        return Some(obj);
700                    }
701                    ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
702                        return extract_object_from_function(func);
703                    }
704                    _ => decl.declaration.as_expression(),
705                };
706                if let Some(expr) = expr {
707                    // Try direct extraction (handles defineConfig(), parens, TS annotations)
708                    if let Some(obj) = extract_object_from_expression(expr) {
709                        return Some(obj);
710                    }
711                    // Fallback: resolve identifier reference to variable declaration
712                    // Handles: const config: Type = { ... }; export default config;
713                    if let Some(name) = unwrap_to_identifier_name(expr) {
714                        return find_variable_init_object(program, name);
715                    }
716                }
717            }
718            // module.exports = { ... }
719            Statement::ExpressionStatement(expr_stmt) => {
720                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
721                    && is_module_exports_target(&assign.left)
722                {
723                    return extract_object_from_expression(&assign.right);
724                }
725            }
726            _ => {}
727        }
728    }
729
730    // JSON files: the program body might be a single expression statement
731    // Also handles JSON wrapped in parens: `({ ... })` (used for tsconfig.json parsing)
732    if program.body.len() == 1
733        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
734    {
735        match &expr_stmt.expression {
736            Expression::ObjectExpression(obj) => return Some(obj),
737            Expression::ParenthesizedExpression(paren) => {
738                if let Expression::ObjectExpression(obj) = &paren.expression {
739                    return Some(obj);
740                }
741            }
742            _ => {}
743        }
744    }
745
746    None
747}
748
749/// Extract an `ObjectExpression` from an expression, handling wrapper patterns.
750fn extract_object_from_expression<'a>(
751    expr: &'a Expression<'a>,
752) -> Option<&'a ObjectExpression<'a>> {
753    match expr {
754        // Direct object: `{ ... }`
755        Expression::ObjectExpression(obj) => Some(obj),
756        // Factory call: `defineConfig({ ... })`
757        Expression::CallExpression(call) => {
758            // Look for the first object argument
759            for arg in &call.arguments {
760                match arg {
761                    Argument::ObjectExpression(obj) => return Some(obj),
762                    // Arrow function body: `defineConfig(() => ({ ... }))`
763                    Argument::ArrowFunctionExpression(arrow) => {
764                        if arrow.expression
765                            && !arrow.body.statements.is_empty()
766                            && let Statement::ExpressionStatement(expr_stmt) =
767                                &arrow.body.statements[0]
768                        {
769                            return extract_object_from_expression(&expr_stmt.expression);
770                        }
771                    }
772                    _ => {}
773                }
774            }
775            None
776        }
777        // Parenthesized: `({ ... })`
778        Expression::ParenthesizedExpression(paren) => {
779            extract_object_from_expression(&paren.expression)
780        }
781        // TS type annotations: `{ ... } satisfies Config` or `{ ... } as Config`
782        Expression::TSSatisfiesExpression(ts_sat) => {
783            extract_object_from_expression(&ts_sat.expression)
784        }
785        Expression::TSAsExpression(ts_as) => extract_object_from_expression(&ts_as.expression),
786        Expression::ArrowFunctionExpression(arrow) => extract_object_from_arrow_function(arrow),
787        Expression::FunctionExpression(func) => extract_object_from_function(func),
788        _ => None,
789    }
790}
791
792fn extract_object_from_arrow_function<'a>(
793    arrow: &'a ArrowFunctionExpression<'a>,
794) -> Option<&'a ObjectExpression<'a>> {
795    if arrow.expression {
796        arrow.body.statements.first().and_then(|stmt| {
797            if let Statement::ExpressionStatement(expr_stmt) = stmt {
798                extract_object_from_expression(&expr_stmt.expression)
799            } else {
800                None
801            }
802        })
803    } else {
804        extract_object_from_function_body(&arrow.body)
805    }
806}
807
808fn extract_object_from_function<'a>(func: &'a Function<'a>) -> Option<&'a ObjectExpression<'a>> {
809    func.body
810        .as_ref()
811        .and_then(|body| extract_object_from_function_body(body))
812}
813
814fn extract_object_from_function_body<'a>(
815    body: &'a FunctionBody<'a>,
816) -> Option<&'a ObjectExpression<'a>> {
817    for stmt in &body.statements {
818        if let Statement::ReturnStatement(ret) = stmt
819            && let Some(argument) = &ret.argument
820            && let Some(obj) = extract_object_from_expression(argument)
821        {
822            return Some(obj);
823        }
824    }
825    None
826}
827
828/// Check if an assignment target is `module.exports`.
829fn is_module_exports_target(target: &AssignmentTarget) -> bool {
830    if let AssignmentTarget::StaticMemberExpression(member) = target
831        && let Expression::Identifier(obj) = &member.object
832    {
833        return obj.name == "module" && member.property.name == "exports";
834    }
835    false
836}
837
838/// Unwrap TS annotations and return the identifier name if the expression resolves to one.
839///
840/// Handles `config`, `config satisfies Type`, `config as Type`.
841fn unwrap_to_identifier_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
842    match expr {
843        Expression::Identifier(id) => Some(&id.name),
844        Expression::TSSatisfiesExpression(ts_sat) => unwrap_to_identifier_name(&ts_sat.expression),
845        Expression::TSAsExpression(ts_as) => unwrap_to_identifier_name(&ts_as.expression),
846        _ => None,
847    }
848}
849
850/// Find a top-level variable declaration by name and extract its init as an object expression.
851///
852/// Handles `const config = { ... }`, `const config: Type = { ... }`,
853/// and `const config = defineConfig({ ... })`.
854fn find_variable_init_object<'a>(
855    program: &'a Program,
856    name: &str,
857) -> Option<&'a ObjectExpression<'a>> {
858    for stmt in &program.body {
859        if let Statement::VariableDeclaration(decl) = stmt {
860            for declarator in &decl.declarations {
861                if let BindingPattern::BindingIdentifier(id) = &declarator.id
862                    && id.name == name
863                    && let Some(init) = &declarator.init
864                {
865                    return extract_object_from_expression(init);
866                }
867            }
868        }
869    }
870    None
871}
872
873/// Find a named property in an object expression.
874pub(crate) fn find_property<'a>(
875    obj: &'a ObjectExpression<'a>,
876    key: &str,
877) -> Option<&'a ObjectProperty<'a>> {
878    for prop in &obj.properties {
879        if let ObjectPropertyKind::ObjectProperty(p) = prop
880            && property_key_matches(&p.key, key)
881        {
882            return Some(p);
883        }
884    }
885    None
886}
887
888/// Check if a property key matches a string.
889pub(crate) fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
890    match key {
891        PropertyKey::StaticIdentifier(id) => id.name == name,
892        PropertyKey::StringLiteral(s) => s.value == name,
893        _ => false,
894    }
895}
896
897/// Get a string value from an object property.
898fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
899    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
900}
901
902/// Get an array of strings from an object property.
903fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
904    find_property(obj, key)
905        .map(|p| expression_to_string_array(&p.value))
906        .unwrap_or_default()
907}
908
909/// Navigate a nested property path and get a string array.
910fn get_nested_string_array_from_object(
911    obj: &ObjectExpression,
912    path: &[&str],
913) -> Option<Vec<String>> {
914    if path.is_empty() {
915        return None;
916    }
917    if path.len() == 1 {
918        return Some(get_object_string_array_property(obj, path[0]));
919    }
920    // Navigate into nested object
921    let prop = find_property(obj, path[0])?;
922    if let Expression::ObjectExpression(nested) = &prop.value {
923        get_nested_string_array_from_object(nested, &path[1..])
924    } else {
925        None
926    }
927}
928
929/// Navigate a nested property path and get a string value.
930fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
931    if path.is_empty() {
932        return None;
933    }
934    if path.len() == 1 {
935        return get_object_string_property(obj, path[0]);
936    }
937    let prop = find_property(obj, path[0])?;
938    if let Expression::ObjectExpression(nested) = &prop.value {
939        get_nested_string_from_object(nested, &path[1..])
940    } else {
941        None
942    }
943}
944
945/// Convert an expression to a string if it's a string literal.
946pub(crate) fn expression_to_string(expr: &Expression) -> Option<String> {
947    match expr {
948        Expression::StringLiteral(s) => Some(s.value.to_string()),
949        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
950            // Template literal with no expressions: `\`value\``
951            t.quasis.first().map(|q| q.value.raw.to_string())
952        }
953        _ => None,
954    }
955}
956
957/// Convert an expression to a path-like string if it's statically recoverable.
958pub(crate) fn expression_to_path_string(expr: &Expression) -> Option<String> {
959    match expr {
960        Expression::ParenthesizedExpression(paren) => expression_to_path_string(&paren.expression),
961        Expression::TSAsExpression(ts_as) => expression_to_path_string(&ts_as.expression),
962        Expression::TSSatisfiesExpression(ts_sat) => expression_to_path_string(&ts_sat.expression),
963        Expression::CallExpression(call) => call_expression_to_path_string(call),
964        Expression::NewExpression(new_expr) => new_expression_to_path_string(new_expr),
965        _ => expression_to_string(expr),
966    }
967}
968
969fn call_expression_to_path_string(call: &CallExpression) -> Option<String> {
970    if matches!(&call.callee, Expression::Identifier(id) if id.name == "fileURLToPath") {
971        return call
972            .arguments
973            .first()
974            .and_then(Argument::as_expression)
975            .and_then(expression_to_path_string);
976    }
977
978    let callee_name = match &call.callee {
979        Expression::Identifier(id) => Some(id.name.as_str()),
980        Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
981        _ => None,
982    }?;
983
984    if !matches!(callee_name, "resolve" | "join") {
985        return None;
986    }
987
988    let mut segments = Vec::new();
989    for (index, arg) in call.arguments.iter().enumerate() {
990        let expr = arg.as_expression()?;
991
992        if matches!(expr, Expression::Identifier(id) if id.name == "__dirname") {
993            if index == 0 {
994                continue;
995            }
996            return None;
997        }
998
999        segments.push(expression_to_string(expr)?);
1000    }
1001
1002    (!segments.is_empty()).then(|| join_path_segments(&segments))
1003}
1004
1005fn new_expression_to_path_string(new_expr: &NewExpression) -> Option<String> {
1006    if !matches!(&new_expr.callee, Expression::Identifier(id) if id.name == "URL") {
1007        return None;
1008    }
1009
1010    let source = new_expr
1011        .arguments
1012        .first()
1013        .and_then(Argument::as_expression)
1014        .and_then(expression_to_string)?;
1015
1016    let base = new_expr
1017        .arguments
1018        .get(1)
1019        .and_then(Argument::as_expression)?;
1020    is_import_meta_url_expression(base).then_some(source)
1021}
1022
1023fn is_import_meta_url_expression(expr: &Expression) -> bool {
1024    if let Expression::StaticMemberExpression(member) = expr {
1025        member.property.name == "url" && matches!(member.object, Expression::MetaProperty(_))
1026    } else {
1027        false
1028    }
1029}
1030
1031fn join_path_segments(segments: &[String]) -> String {
1032    let mut joined = PathBuf::new();
1033    for segment in segments {
1034        joined.push(segment);
1035    }
1036    joined.to_string_lossy().replace('\\', "/")
1037}
1038
1039fn expression_to_alias_pairs(expr: &Expression) -> Vec<(String, String)> {
1040    match expr {
1041        Expression::ObjectExpression(obj) => obj
1042            .properties
1043            .iter()
1044            .filter_map(|prop| {
1045                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
1046                    return None;
1047                };
1048                let find = property_key_to_string(&prop.key)?;
1049                let replacement = expression_to_path_values(&prop.value).into_iter().next()?;
1050                Some((find, replacement))
1051            })
1052            .collect(),
1053        Expression::ArrayExpression(arr) => arr
1054            .elements
1055            .iter()
1056            .filter_map(|element| {
1057                let Expression::ObjectExpression(obj) = element.as_expression()? else {
1058                    return None;
1059                };
1060                let find = find_property(obj, "find")
1061                    .and_then(|prop| expression_to_string(&prop.value))?;
1062                let replacement = find_property(obj, "replacement")
1063                    .and_then(|prop| expression_to_path_string(&prop.value))?;
1064                Some((find, replacement))
1065            })
1066            .collect(),
1067        _ => Vec::new(),
1068    }
1069}
1070
1071fn lexical_normalize(path: &Path) -> PathBuf {
1072    let mut normalized = PathBuf::new();
1073
1074    for component in path.components() {
1075        match component {
1076            std::path::Component::CurDir => {}
1077            std::path::Component::ParentDir => {
1078                normalized.pop();
1079            }
1080            _ => normalized.push(component.as_os_str()),
1081        }
1082    }
1083
1084    normalized
1085}
1086
1087/// Convert an expression to a string array if it's an array of string literals.
1088fn expression_to_string_array(expr: &Expression) -> Vec<String> {
1089    match expr {
1090        Expression::ArrayExpression(arr) => arr
1091            .elements
1092            .iter()
1093            .filter_map(|el| match el {
1094                ArrayExpressionElement::SpreadElement(_) => None,
1095                _ => el.as_expression().and_then(expression_to_string),
1096            })
1097            .collect(),
1098        _ => vec![],
1099    }
1100}
1101
1102/// Collect only top-level string values from an expression.
1103///
1104/// For arrays, extracts direct string elements and the first string element of sub-arrays
1105/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
1106fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
1107    let mut values = Vec::new();
1108    match expr {
1109        Expression::StringLiteral(s) => {
1110            values.push(s.value.to_string());
1111        }
1112        Expression::ArrayExpression(arr) => {
1113            for el in &arr.elements {
1114                if let Some(inner) = el.as_expression() {
1115                    match inner {
1116                        Expression::StringLiteral(s) => {
1117                            values.push(s.value.to_string());
1118                        }
1119                        // Handle tuples: ["pkg-name", { options }] → extract first string
1120                        Expression::ArrayExpression(sub_arr) => {
1121                            if let Some(first) = sub_arr.elements.first()
1122                                && let Some(first_expr) = first.as_expression()
1123                                && let Some(s) = expression_to_string(first_expr)
1124                            {
1125                                values.push(s);
1126                            }
1127                        }
1128                        _ => {}
1129                    }
1130                }
1131            }
1132        }
1133        // Handle objects: { "key": "value" } or { "key": ["pkg", { opts }] } → extract values
1134        Expression::ObjectExpression(obj) => {
1135            for prop in &obj.properties {
1136                if let ObjectPropertyKind::ObjectProperty(p) = prop {
1137                    match &p.value {
1138                        Expression::StringLiteral(s) => {
1139                            values.push(s.value.to_string());
1140                        }
1141                        // Handle tuples: { "key": ["pkg-name", { options }] }
1142                        Expression::ArrayExpression(sub_arr) => {
1143                            if let Some(first) = sub_arr.elements.first()
1144                                && let Some(first_expr) = first.as_expression()
1145                                && let Some(s) = expression_to_string(first_expr)
1146                            {
1147                                values.push(s);
1148                            }
1149                        }
1150                        _ => {}
1151                    }
1152                }
1153            }
1154        }
1155        _ => {}
1156    }
1157    values
1158}
1159
1160/// Collect top-level string values, plus a named string property from object entries.
1161fn collect_shallow_string_or_object_property_values(
1162    expr: &Expression,
1163    object_property: &str,
1164) -> Vec<String> {
1165    match expr {
1166        Expression::ArrayExpression(arr) => arr
1167            .elements
1168            .iter()
1169            .filter_map(|element| {
1170                element
1171                    .as_expression()
1172                    .and_then(|expr| shallow_string_or_object_property(expr, object_property))
1173            })
1174            .collect(),
1175        _ => shallow_string_or_object_property(expr, object_property)
1176            .into_iter()
1177            .collect(),
1178    }
1179}
1180
1181fn shallow_string_or_object_property(expr: &Expression, object_property: &str) -> Option<String> {
1182    match expr {
1183        Expression::ParenthesizedExpression(paren) => {
1184            shallow_string_or_object_property(&paren.expression, object_property)
1185        }
1186        Expression::TSSatisfiesExpression(ts_sat) => {
1187            shallow_string_or_object_property(&ts_sat.expression, object_property)
1188        }
1189        Expression::TSAsExpression(ts_as) => {
1190            shallow_string_or_object_property(&ts_as.expression, object_property)
1191        }
1192        Expression::ArrayExpression(sub_arr) => sub_arr
1193            .elements
1194            .first()
1195            .and_then(ArrayExpressionElement::as_expression)
1196            .and_then(expression_to_string),
1197        Expression::ObjectExpression(obj) => {
1198            find_property(obj, object_property).and_then(|prop| expression_to_string(&prop.value))
1199        }
1200        _ => expression_to_string(expr),
1201    }
1202}
1203
1204/// Recursively collect all string literal values from an expression tree.
1205fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
1206    match expr {
1207        Expression::StringLiteral(s) => {
1208            values.push(s.value.to_string());
1209        }
1210        Expression::ArrayExpression(arr) => {
1211            for el in &arr.elements {
1212                if let Some(expr) = el.as_expression() {
1213                    collect_all_string_values(expr, values);
1214                }
1215            }
1216        }
1217        Expression::ObjectExpression(obj) => {
1218            for prop in &obj.properties {
1219                if let ObjectPropertyKind::ObjectProperty(p) = prop {
1220                    collect_all_string_values(&p.value, values);
1221                }
1222            }
1223        }
1224        _ => {}
1225    }
1226}
1227
1228/// Convert a `PropertyKey` to a `String`.
1229fn property_key_to_string(key: &PropertyKey) -> Option<String> {
1230    match key {
1231        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
1232        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
1233        _ => None,
1234    }
1235}
1236
1237/// Extract keys of an object at a nested property path.
1238fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
1239    if path.is_empty() {
1240        return None;
1241    }
1242    let prop = find_property(obj, path[0])?;
1243    if path.len() == 1 {
1244        if let Expression::ObjectExpression(nested) = &prop.value {
1245            let keys = nested
1246                .properties
1247                .iter()
1248                .filter_map(|p| {
1249                    if let ObjectPropertyKind::ObjectProperty(p) = p {
1250                        property_key_to_string(&p.key)
1251                    } else {
1252                        None
1253                    }
1254                })
1255                .collect();
1256            return Some(keys);
1257        }
1258        return None;
1259    }
1260    if let Expression::ObjectExpression(nested) = &prop.value {
1261        get_nested_object_keys(nested, &path[1..])
1262    } else {
1263        None
1264    }
1265}
1266
1267/// Navigate a nested property path and return the raw expression at the end.
1268fn get_nested_expression<'a>(
1269    obj: &'a ObjectExpression<'a>,
1270    path: &[&str],
1271) -> Option<&'a Expression<'a>> {
1272    if path.is_empty() {
1273        return None;
1274    }
1275    let prop = find_property(obj, path[0])?;
1276    if path.len() == 1 {
1277        return Some(&prop.value);
1278    }
1279    if let Expression::ObjectExpression(nested) = &prop.value {
1280        get_nested_expression(nested, &path[1..])
1281    } else {
1282        None
1283    }
1284}
1285
1286/// Navigate a nested path and extract a string, string array, or object string/array values.
1287fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
1288    if path.is_empty() {
1289        return None;
1290    }
1291    if path.len() == 1 {
1292        let prop = find_property(obj, path[0])?;
1293        return Some(expression_to_string_or_array(&prop.value));
1294    }
1295    let prop = find_property(obj, path[0])?;
1296    if let Expression::ObjectExpression(nested) = &prop.value {
1297        get_nested_string_or_array(nested, &path[1..])
1298    } else {
1299        None
1300    }
1301}
1302
1303/// Convert an expression to a `Vec<String>`, handling string, array, object-with-string/array values,
1304/// and Webpack 5 entry descriptors (`{ import: "..." }`).
1305///
1306/// Array elements that are object literals are inspected for an `input` property
1307/// (Angular CLI schema for `styles`/`scripts`/`polyfills`:
1308/// `{ "input": "src/x.scss", "bundleName": "x", "inject": false }`). Extracting
1309/// `input` prevents object-form entries from being silently dropped. See #126.
1310fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
1311    match expr {
1312        Expression::StringLiteral(s) => vec![s.value.to_string()],
1313        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
1314            .quasis
1315            .first()
1316            .map(|q| vec![q.value.raw.to_string()])
1317            .unwrap_or_default(),
1318        Expression::ArrayExpression(arr) => arr
1319            .elements
1320            .iter()
1321            .filter_map(|el| el.as_expression())
1322            .flat_map(|e| match e {
1323                Expression::ObjectExpression(obj) => find_property(obj, "input")
1324                    .map(|p| expression_to_string_or_array(&p.value))
1325                    .unwrap_or_default(),
1326                _ => expression_to_string(e).into_iter().collect(),
1327            })
1328            .collect(),
1329        Expression::ObjectExpression(obj) => obj
1330            .properties
1331            .iter()
1332            .flat_map(|p| {
1333                if let ObjectPropertyKind::ObjectProperty(p) = p {
1334                    match &p.value {
1335                        Expression::ArrayExpression(_) => expression_to_string_or_array(&p.value),
1336                        Expression::ObjectExpression(value_obj) => {
1337                            find_property(value_obj, "import")
1338                                .map(|import_prop| {
1339                                    expression_to_string_or_array(&import_prop.value)
1340                                })
1341                                .unwrap_or_default()
1342                        }
1343                        _ => expression_to_string(&p.value).into_iter().collect(),
1344                    }
1345                } else {
1346                    Vec::new()
1347                }
1348            })
1349            .collect(),
1350        _ => vec![],
1351    }
1352}
1353
1354/// Collect `require('...')` argument strings from an expression.
1355fn collect_require_sources(expr: &Expression) -> Vec<String> {
1356    let mut sources = Vec::new();
1357    match expr {
1358        Expression::CallExpression(call) if is_require_call(call) => {
1359            if let Some(s) = get_require_source(call) {
1360                sources.push(s);
1361            }
1362        }
1363        Expression::ArrayExpression(arr) => {
1364            for el in &arr.elements {
1365                if let Some(inner) = el.as_expression() {
1366                    match inner {
1367                        Expression::CallExpression(call) if is_require_call(call) => {
1368                            if let Some(s) = get_require_source(call) {
1369                                sources.push(s);
1370                            }
1371                        }
1372                        // Tuple: [require('pkg'), options]
1373                        Expression::ArrayExpression(sub_arr) => {
1374                            if let Some(first) = sub_arr.elements.first()
1375                                && let Some(Expression::CallExpression(call)) =
1376                                    first.as_expression()
1377                                && is_require_call(call)
1378                                && let Some(s) = get_require_source(call)
1379                            {
1380                                sources.push(s);
1381                            }
1382                        }
1383                        _ => {}
1384                    }
1385                }
1386            }
1387        }
1388        _ => {}
1389    }
1390    sources
1391}
1392
1393/// Check if a call expression is `require(...)`.
1394fn is_require_call(call: &CallExpression) -> bool {
1395    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
1396}
1397
1398/// Get the first string argument of a `require()` call.
1399fn get_require_source(call: &CallExpression) -> Option<String> {
1400    call.arguments.first().and_then(|arg| {
1401        if let Argument::StringLiteral(s) = arg {
1402            Some(s.value.to_string())
1403        } else {
1404            None
1405        }
1406    })
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411    use super::*;
1412    use std::path::PathBuf;
1413
1414    fn js_path() -> PathBuf {
1415        PathBuf::from("config.js")
1416    }
1417
1418    fn ts_path() -> PathBuf {
1419        PathBuf::from("config.ts")
1420    }
1421
1422    #[test]
1423    fn extract_lazy_imports_bare_arrows() {
1424        let source = r"
1425            import { defineConfig } from '@adonisjs/core/app'
1426            export default defineConfig({
1427                preloads: [
1428                    () => import('#start/routes'),
1429                    () => import('#start/kernel'),
1430                ],
1431            })
1432        ";
1433        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["preloads"]);
1434        assert_eq!(specs, vec!["#start/routes", "#start/kernel"]);
1435    }
1436
1437    #[test]
1438    fn extract_lazy_imports_object_form_with_file_key() {
1439        let source = r"
1440            export default defineConfig({
1441                providers: [
1442                    () => import('@adonisjs/core/providers/app_provider'),
1443                    {
1444                        file: () => import('@adonisjs/core/providers/repl_provider'),
1445                        environment: ['repl', 'test'],
1446                    },
1447                ],
1448            })
1449        ";
1450        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
1451        assert_eq!(
1452            specs,
1453            vec![
1454                "@adonisjs/core/providers/app_provider",
1455                "@adonisjs/core/providers/repl_provider",
1456            ]
1457        );
1458    }
1459
1460    #[test]
1461    fn extract_lazy_imports_block_body_with_return() {
1462        // Less common but legal: explicit return body. Still supported.
1463        let source = r"
1464            export default defineConfig({
1465                commands: [
1466                    () => { return import('@adonisjs/core/commands') },
1467                ],
1468            })
1469        ";
1470        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
1471        assert_eq!(specs, vec!["@adonisjs/core/commands"]);
1472    }
1473
1474    #[test]
1475    fn extract_lazy_imports_skips_unknown_element_shapes() {
1476        // Mixed array with strings, numbers, objects without `file` — these
1477        // are not lazy imports and must be silently ignored.
1478        let source = r"
1479            export default defineConfig({
1480                commands: [
1481                    'string-entry',
1482                    42,
1483                    { other: 'value' },
1484                    () => import('@adonisjs/lucid/commands'),
1485                ],
1486            })
1487        ";
1488        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["commands"]);
1489        assert_eq!(specs, vec!["@adonisjs/lucid/commands"]);
1490    }
1491
1492    #[test]
1493    fn extract_lazy_imports_missing_property_returns_empty() {
1494        let source = r"
1495            export default defineConfig({
1496                preloads: [() => import('#start/routes')],
1497            })
1498        ";
1499        let specs = extract_lazy_imports_in_array(source, &ts_path(), &["providers"]);
1500        assert!(specs.is_empty());
1501    }
1502
1503    #[test]
1504    fn extract_imports_basic() {
1505        let source = r"
1506            import foo from 'foo-pkg';
1507            import { bar } from '@scope/bar';
1508            export default {};
1509        ";
1510        let imports = extract_imports(source, &js_path());
1511        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
1512    }
1513
1514    #[test]
1515    fn extract_default_export_object_property() {
1516        let source = r#"export default { testDir: "./tests" };"#;
1517        let val = extract_config_string(source, &js_path(), &["testDir"]);
1518        assert_eq!(val, Some("./tests".to_string()));
1519    }
1520
1521    #[test]
1522    fn extract_define_config_property() {
1523        let source = r#"
1524            import { defineConfig } from 'vitest/config';
1525            export default defineConfig({
1526                test: {
1527                    include: ["**/*.test.ts", "**/*.spec.ts"],
1528                    setupFiles: ["./test/setup.ts"]
1529                }
1530            });
1531        "#;
1532        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1533        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
1534
1535        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
1536        assert_eq!(setup, vec!["./test/setup.ts"]);
1537    }
1538
1539    #[test]
1540    fn extract_module_exports_property() {
1541        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
1542        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
1543        assert_eq!(val, Some("jsdom".to_string()));
1544    }
1545
1546    #[test]
1547    fn extract_nested_string_array() {
1548        let source = r#"
1549            export default {
1550                resolve: {
1551                    alias: {
1552                        "@": "./src"
1553                    }
1554                },
1555                test: {
1556                    include: ["src/**/*.test.ts"]
1557                }
1558            };
1559        "#;
1560        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
1561        assert_eq!(include, vec!["src/**/*.test.ts"]);
1562    }
1563
1564    #[test]
1565    fn extract_addons_array() {
1566        let source = r#"
1567            export default {
1568                addons: [
1569                    "@storybook/addon-a11y",
1570                    "@storybook/addon-docs",
1571                    "@storybook/addon-links"
1572                ]
1573            };
1574        "#;
1575        let addons = extract_config_property_strings(source, &ts_path(), "addons");
1576        assert_eq!(
1577            addons,
1578            vec![
1579                "@storybook/addon-a11y",
1580                "@storybook/addon-docs",
1581                "@storybook/addon-links"
1582            ]
1583        );
1584    }
1585
1586    #[test]
1587    fn handle_empty_config() {
1588        let source = "";
1589        let result = extract_config_string(source, &js_path(), &["key"]);
1590        assert_eq!(result, None);
1591    }
1592
1593    // ── extract_config_object_keys tests ────────────────────────────
1594
1595    #[test]
1596    fn object_keys_postcss_plugins() {
1597        let source = r"
1598            module.exports = {
1599                plugins: {
1600                    autoprefixer: {},
1601                    tailwindcss: {},
1602                    'postcss-import': {}
1603                }
1604            };
1605        ";
1606        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1607        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
1608    }
1609
1610    #[test]
1611    fn object_keys_nested_path() {
1612        let source = r"
1613            export default {
1614                build: {
1615                    plugins: {
1616                        minify: {},
1617                        compress: {}
1618                    }
1619                }
1620            };
1621        ";
1622        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
1623        assert_eq!(keys, vec!["minify", "compress"]);
1624    }
1625
1626    #[test]
1627    fn object_keys_empty_object() {
1628        let source = r"export default { plugins: {} };";
1629        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1630        assert!(keys.is_empty());
1631    }
1632
1633    #[test]
1634    fn object_keys_non_object_returns_empty() {
1635        let source = r#"export default { plugins: ["a", "b"] };"#;
1636        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1637        assert!(keys.is_empty());
1638    }
1639
1640    // ── extract_config_string_or_array tests ────────────────────────
1641
1642    #[test]
1643    fn string_or_array_single_string() {
1644        let source = r#"export default { entry: "./src/index.js" };"#;
1645        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1646        assert_eq!(result, vec!["./src/index.js"]);
1647    }
1648
1649    #[test]
1650    fn string_or_array_array() {
1651        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
1652        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1653        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
1654    }
1655
1656    #[test]
1657    fn string_or_array_object_values() {
1658        let source =
1659            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
1660        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1661        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
1662    }
1663
1664    #[test]
1665    fn string_or_array_object_array_values() {
1666        let source = r#"export default { entry: { app: ["./src/polyfill.js", "./src/app.js"] } };"#;
1667        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1668        assert_eq!(result, vec!["./src/polyfill.js", "./src/app.js"]);
1669    }
1670
1671    #[test]
1672    fn string_or_array_webpack_entry_descriptors() {
1673        let source = r#"
1674            export default {
1675                entry: {
1676                    app: {
1677                        import: "./src/app.js",
1678                        filename: "pages/app.js",
1679                        dependOn: "shared",
1680                    },
1681                    admin: {
1682                        import: ["./src/admin-polyfill.js", "./src/admin.js"],
1683                        runtime: "runtime",
1684                    },
1685                    shared: ["react", "react-dom"],
1686                },
1687            };
1688        "#;
1689        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1690        assert_eq!(
1691            result,
1692            vec![
1693                "./src/app.js",
1694                "./src/admin-polyfill.js",
1695                "./src/admin.js",
1696                "react",
1697                "react-dom"
1698            ]
1699        );
1700    }
1701
1702    #[test]
1703    fn string_or_array_nested_path() {
1704        let source = r#"
1705            export default {
1706                build: {
1707                    rollupOptions: {
1708                        input: ["./index.html", "./about.html"]
1709                    }
1710                }
1711            };
1712        "#;
1713        let result = extract_config_string_or_array(
1714            source,
1715            &js_path(),
1716            &["build", "rollupOptions", "input"],
1717        );
1718        assert_eq!(result, vec!["./index.html", "./about.html"]);
1719    }
1720
1721    #[test]
1722    fn string_or_array_template_literal() {
1723        let source = r"export default { entry: `./src/index.js` };";
1724        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1725        assert_eq!(result, vec!["./src/index.js"]);
1726    }
1727
1728    // ── extract_config_require_strings tests ────────────────────────
1729
1730    #[test]
1731    fn require_strings_array() {
1732        let source = r"
1733            module.exports = {
1734                plugins: [
1735                    require('autoprefixer'),
1736                    require('postcss-import')
1737                ]
1738            };
1739        ";
1740        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1741        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
1742    }
1743
1744    #[test]
1745    fn require_strings_with_tuples() {
1746        let source = r"
1747            module.exports = {
1748                plugins: [
1749                    require('autoprefixer'),
1750                    [require('postcss-preset-env'), { stage: 3 }]
1751                ]
1752            };
1753        ";
1754        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1755        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
1756    }
1757
1758    #[test]
1759    fn require_strings_empty_array() {
1760        let source = r"module.exports = { plugins: [] };";
1761        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1762        assert!(deps.is_empty());
1763    }
1764
1765    #[test]
1766    fn require_strings_no_require_calls() {
1767        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
1768        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1769        assert!(deps.is_empty());
1770    }
1771
1772    #[test]
1773    fn extract_aliases_from_object_with_file_url_to_path() {
1774        let source = r#"
1775            import { defineConfig } from 'vite';
1776            import { fileURLToPath, URL } from 'node:url';
1777
1778            export default defineConfig({
1779                resolve: {
1780                    alias: {
1781                        "@": fileURLToPath(new URL("./src", import.meta.url))
1782                    }
1783                }
1784            });
1785        "#;
1786
1787        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
1788        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
1789    }
1790
1791    #[test]
1792    fn extract_aliases_from_array_form() {
1793        let source = r#"
1794            export default {
1795                resolve: {
1796                    alias: [
1797                        { find: "@", replacement: "./src" },
1798                        { find: "$utils", replacement: "src/lib/utils" }
1799                    ]
1800                }
1801            };
1802        "#;
1803
1804        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
1805        assert_eq!(
1806            aliases,
1807            vec![
1808                ("@".to_string(), "./src".to_string()),
1809                ("$utils".to_string(), "src/lib/utils".to_string())
1810            ]
1811        );
1812    }
1813
1814    #[test]
1815    fn extract_aliases_from_object_with_array_values() {
1816        let source = r#"
1817            ({
1818                compilerOptions: {
1819                    paths: {
1820                        "@/*": ["./src/*"],
1821                        "@shared/*": ["./shared/*", "./fallback/*"]
1822                    }
1823                }
1824            })
1825        "#;
1826
1827        let aliases = extract_config_aliases(source, &js_path(), &["compilerOptions", "paths"]);
1828        assert_eq!(
1829            aliases,
1830            vec![
1831                ("@/*".to_string(), "./src/*".to_string()),
1832                ("@shared/*".to_string(), "./shared/*".to_string())
1833            ]
1834        );
1835    }
1836
1837    #[test]
1838    fn extract_array_object_strings_mixed_forms() {
1839        let source = r#"
1840            export default {
1841                components: [
1842                    "~/components",
1843                    { path: "@/feature-components" }
1844                ]
1845            };
1846        "#;
1847
1848        let values =
1849            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
1850        assert_eq!(
1851            values,
1852            vec![
1853                "~/components".to_string(),
1854                "@/feature-components".to_string()
1855            ]
1856        );
1857    }
1858
1859    #[test]
1860    fn extract_config_plugin_option_string_from_json() {
1861        let source = r#"{
1862            "expo": {
1863                "plugins": [
1864                    ["expo-router", { "root": "src/app" }]
1865                ]
1866            }
1867        }"#;
1868
1869        let value = extract_config_plugin_option_string(
1870            source,
1871            &json_path(),
1872            &["expo", "plugins"],
1873            "expo-router",
1874            "root",
1875        );
1876
1877        assert_eq!(value, Some("src/app".to_string()));
1878    }
1879
1880    #[test]
1881    fn extract_config_plugin_option_string_from_top_level_plugins() {
1882        let source = r#"{
1883            "plugins": [
1884                ["expo-router", { "root": "./src/routes" }]
1885            ]
1886        }"#;
1887
1888        let value = extract_config_plugin_option_string_from_paths(
1889            source,
1890            &json_path(),
1891            &[&["plugins"], &["expo", "plugins"]],
1892            "expo-router",
1893            "root",
1894        );
1895
1896        assert_eq!(value, Some("./src/routes".to_string()));
1897    }
1898
1899    #[test]
1900    fn extract_config_plugin_option_string_from_ts_config() {
1901        let source = r"
1902            export default {
1903                expo: {
1904                    plugins: [
1905                        ['expo-router', { root: './src/app' }]
1906                    ]
1907                }
1908            };
1909        ";
1910
1911        let value = extract_config_plugin_option_string(
1912            source,
1913            &ts_path(),
1914            &["expo", "plugins"],
1915            "expo-router",
1916            "root",
1917        );
1918
1919        assert_eq!(value, Some("./src/app".to_string()));
1920    }
1921
1922    #[test]
1923    fn extract_config_plugin_option_string_returns_none_when_plugin_missing() {
1924        let source = r#"{
1925            "expo": {
1926                "plugins": [
1927                    ["expo-font", {}]
1928                ]
1929            }
1930        }"#;
1931
1932        let value = extract_config_plugin_option_string(
1933            source,
1934            &json_path(),
1935            &["expo", "plugins"],
1936            "expo-router",
1937            "root",
1938        );
1939
1940        assert_eq!(value, None);
1941    }
1942
1943    #[test]
1944    fn normalize_config_path_relative_to_root() {
1945        let config_path = PathBuf::from("/project/vite.config.ts");
1946        let root = PathBuf::from("/project");
1947
1948        assert_eq!(
1949            normalize_config_path("./src/lib", &config_path, &root),
1950            Some("src/lib".to_string())
1951        );
1952        assert_eq!(
1953            normalize_config_path("/src/lib", &config_path, &root),
1954            Some("src/lib".to_string())
1955        );
1956    }
1957
1958    // ── JSON wrapped in parens (for tsconfig.json parsing) ──────────
1959
1960    #[test]
1961    fn json_wrapped_in_parens_string() {
1962        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
1963        let val = extract_config_string(source, &js_path(), &["extends"]);
1964        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
1965    }
1966
1967    #[test]
1968    fn json_wrapped_in_parens_nested_array() {
1969        let source =
1970            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
1971        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
1972        assert_eq!(types, vec!["node", "jest"]);
1973
1974        let include = extract_config_string_array(source, &js_path(), &["include"]);
1975        assert_eq!(include, vec!["src/**/*"]);
1976    }
1977
1978    #[test]
1979    fn json_wrapped_in_parens_object_keys() {
1980        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
1981        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1982        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
1983    }
1984
1985    // ── JSON file extension detection ────────────────────────────
1986
1987    fn json_path() -> PathBuf {
1988        PathBuf::from("config.json")
1989    }
1990
1991    #[test]
1992    fn json_file_parsed_correctly() {
1993        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
1994        let val = extract_config_string(source, &json_path(), &["key"]);
1995        assert_eq!(val, Some("value".to_string()));
1996
1997        let list = extract_config_string_array(source, &json_path(), &["list"]);
1998        assert_eq!(list, vec!["a", "b"]);
1999    }
2000
2001    #[test]
2002    fn jsonc_file_parsed_correctly() {
2003        let source = r#"{"key": "value"}"#;
2004        let path = PathBuf::from("tsconfig.jsonc");
2005        let val = extract_config_string(source, &path, &["key"]);
2006        assert_eq!(val, Some("value".to_string()));
2007    }
2008
2009    // ── defineConfig with arrow function ─────────────────────────
2010
2011    #[test]
2012    fn extract_define_config_arrow_function() {
2013        let source = r#"
2014            import { defineConfig } from 'vite';
2015            export default defineConfig(() => ({
2016                test: {
2017                    include: ["**/*.test.ts"]
2018                }
2019            }));
2020        "#;
2021        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
2022        assert_eq!(include, vec!["**/*.test.ts"]);
2023    }
2024
2025    #[test]
2026    fn extract_config_from_default_export_function_declaration() {
2027        let source = r#"
2028            export default function createConfig() {
2029                return {
2030                    clientModules: ["./src/client/global.js"]
2031                };
2032            }
2033        "#;
2034
2035        let client_modules = extract_config_string_array(source, &ts_path(), &["clientModules"]);
2036        assert_eq!(client_modules, vec!["./src/client/global.js"]);
2037    }
2038
2039    #[test]
2040    fn extract_config_from_default_export_async_function_declaration() {
2041        let source = r#"
2042            export default async function createConfigAsync() {
2043                return {
2044                    docs: {
2045                        path: "knowledge"
2046                    }
2047                };
2048            }
2049        "#;
2050
2051        let docs_path = extract_config_string(source, &ts_path(), &["docs", "path"]);
2052        assert_eq!(docs_path, Some("knowledge".to_string()));
2053    }
2054
2055    #[test]
2056    fn extract_config_from_exported_arrow_function_identifier() {
2057        let source = r#"
2058            const config = async () => {
2059                return {
2060                    themes: ["classic"]
2061                };
2062            };
2063
2064            export default config;
2065        "#;
2066
2067        let themes = extract_config_shallow_strings(source, &ts_path(), "themes");
2068        assert_eq!(themes, vec!["classic"]);
2069    }
2070
2071    // ── module.exports with nested properties ────────────────────
2072
2073    #[test]
2074    fn module_exports_nested_string() {
2075        let source = r#"
2076            module.exports = {
2077                resolve: {
2078                    alias: {
2079                        "@": "./src"
2080                    }
2081                }
2082            };
2083        "#;
2084        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
2085        assert_eq!(val, Some("./src".to_string()));
2086    }
2087
2088    // ── extract_config_property_strings (recursive) ──────────────
2089
2090    #[test]
2091    fn property_strings_nested_objects() {
2092        let source = r#"
2093            export default {
2094                plugins: {
2095                    group1: { a: "val-a" },
2096                    group2: { b: "val-b" }
2097                }
2098            };
2099        "#;
2100        let values = extract_config_property_strings(source, &js_path(), "plugins");
2101        assert!(values.contains(&"val-a".to_string()));
2102        assert!(values.contains(&"val-b".to_string()));
2103    }
2104
2105    #[test]
2106    fn property_strings_missing_key_returns_empty() {
2107        let source = r#"export default { other: "value" };"#;
2108        let values = extract_config_property_strings(source, &js_path(), "missing");
2109        assert!(values.is_empty());
2110    }
2111
2112    // ── extract_config_shallow_strings ────────────────────────────
2113
2114    #[test]
2115    fn shallow_strings_tuple_array() {
2116        let source = r#"
2117            module.exports = {
2118                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
2119            };
2120        "#;
2121        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
2122        assert_eq!(values, vec!["default", "jest-junit"]);
2123        // "reports" should NOT be extracted (it's inside an options object)
2124        assert!(!values.contains(&"reports".to_string()));
2125    }
2126
2127    #[test]
2128    fn shallow_strings_single_string() {
2129        let source = r#"export default { preset: "ts-jest" };"#;
2130        let values = extract_config_shallow_strings(source, &js_path(), "preset");
2131        assert_eq!(values, vec!["ts-jest"]);
2132    }
2133
2134    #[test]
2135    fn shallow_strings_missing_key() {
2136        let source = r#"export default { other: "val" };"#;
2137        let values = extract_config_shallow_strings(source, &js_path(), "missing");
2138        assert!(values.is_empty());
2139    }
2140
2141    #[test]
2142    fn shallow_strings_or_object_property_alias_objects() {
2143        let source = r#"
2144            export default {
2145                jsPlugins: [
2146                    "eslint-plugin-playwright",
2147                    ["eslint-plugin-regexp", { rules: {} }],
2148                    { name: "short", specifier: "eslint-plugin-with-long-name" }
2149                ]
2150            };
2151        "#;
2152        let values = extract_config_shallow_strings_or_object_property(
2153            source,
2154            &ts_path(),
2155            "jsPlugins",
2156            "specifier",
2157        );
2158        assert_eq!(
2159            values,
2160            vec![
2161                "eslint-plugin-playwright",
2162                "eslint-plugin-regexp",
2163                "eslint-plugin-with-long-name"
2164            ]
2165        );
2166    }
2167
2168    // ── extract_config_nested_shallow_strings tests ──────────────
2169
2170    #[test]
2171    fn nested_shallow_strings_vitest_reporters() {
2172        let source = r#"
2173            export default {
2174                test: {
2175                    reporters: ["default", "vitest-sonar-reporter"]
2176                }
2177            };
2178        "#;
2179        let values =
2180            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
2181        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
2182    }
2183
2184    #[test]
2185    fn nested_shallow_strings_tuple_format() {
2186        let source = r#"
2187            export default {
2188                test: {
2189                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
2190                }
2191            };
2192        "#;
2193        let values =
2194            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
2195        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
2196    }
2197
2198    #[test]
2199    fn nested_shallow_strings_missing_outer() {
2200        let source = r"export default { other: {} };";
2201        let values =
2202            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
2203        assert!(values.is_empty());
2204    }
2205
2206    #[test]
2207    fn nested_shallow_strings_missing_inner() {
2208        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
2209        let values =
2210            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
2211        assert!(values.is_empty());
2212    }
2213
2214    // ── extract_config_string_or_array edge cases ────────────────
2215
2216    #[test]
2217    fn string_or_array_missing_path() {
2218        let source = r"export default {};";
2219        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2220        assert!(result.is_empty());
2221    }
2222
2223    #[test]
2224    fn string_or_array_non_string_values() {
2225        // When values are not strings (e.g., numbers), they should be skipped
2226        let source = r"export default { entry: [42, true] };";
2227        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
2228        assert!(result.is_empty());
2229    }
2230
2231    // ── extract_config_array_nested_string_or_array ──────────────
2232
2233    #[test]
2234    fn array_nested_extraction() {
2235        let source = r#"
2236            export default defineConfig({
2237                test: {
2238                    projects: [
2239                        {
2240                            test: {
2241                                setupFiles: ["./test/setup-a.ts"]
2242                            }
2243                        },
2244                        {
2245                            test: {
2246                                setupFiles: "./test/setup-b.ts"
2247                            }
2248                        }
2249                    ]
2250                }
2251            });
2252        "#;
2253        let results = extract_config_array_nested_string_or_array(
2254            source,
2255            &ts_path(),
2256            &["test", "projects"],
2257            &["test", "setupFiles"],
2258        );
2259        assert!(results.contains(&"./test/setup-a.ts".to_string()));
2260        assert!(results.contains(&"./test/setup-b.ts".to_string()));
2261    }
2262
2263    #[test]
2264    fn array_nested_empty_when_no_array() {
2265        let source = r#"export default { test: { projects: "not-an-array" } };"#;
2266        let results = extract_config_array_nested_string_or_array(
2267            source,
2268            &js_path(),
2269            &["test", "projects"],
2270            &["test", "setupFiles"],
2271        );
2272        assert!(results.is_empty());
2273    }
2274
2275    // ── extract_config_object_nested_string_or_array ─────────────
2276
2277    #[test]
2278    fn object_nested_extraction() {
2279        let source = r#"{
2280            "projects": {
2281                "app-one": {
2282                    "architect": {
2283                        "build": {
2284                            "options": {
2285                                "styles": ["src/styles.css"]
2286                            }
2287                        }
2288                    }
2289                }
2290            }
2291        }"#;
2292        let results = extract_config_object_nested_string_or_array(
2293            source,
2294            &json_path(),
2295            &["projects"],
2296            &["architect", "build", "options", "styles"],
2297        );
2298        assert_eq!(results, vec!["src/styles.css"]);
2299    }
2300
2301    #[test]
2302    fn array_with_object_input_form_extracted() {
2303        // Angular CLI schema allows both string and object forms in `styles`:
2304        //   "styles": ["src/styles.scss", { "input": "src/theme.scss", "inject": false }]
2305        // The object form declares bundle-name / inject options for vendor
2306        // stylesheets. Previously the array branch silently dropped object
2307        // elements. See #126.
2308        let source = r#"{
2309            "projects": {
2310                "app": {
2311                    "architect": {
2312                        "build": {
2313                            "options": {
2314                                "styles": [
2315                                    "src/styles.scss",
2316                                    { "input": "src/theme.scss", "bundleName": "theme", "inject": false },
2317                                    { "bundleName": "lazy-only" }
2318                                ]
2319                            }
2320                        }
2321                    }
2322                }
2323            }
2324        }"#;
2325        let results = extract_config_object_nested_string_or_array(
2326            source,
2327            &json_path(),
2328            &["projects"],
2329            &["architect", "build", "options", "styles"],
2330        );
2331        assert!(
2332            results.contains(&"src/styles.scss".to_string()),
2333            "string form must still work: {results:?}"
2334        );
2335        assert!(
2336            results.contains(&"src/theme.scss".to_string()),
2337            "object form with `input` must be extracted: {results:?}"
2338        );
2339        // Object without `input` has nothing to extract; must NOT leak
2340        // unrelated property values (e.g., `bundleName`).
2341        assert!(
2342            !results.contains(&"lazy-only".to_string()),
2343            "bundleName must not be misinterpreted as a path: {results:?}"
2344        );
2345        assert!(
2346            !results.contains(&"theme".to_string()),
2347            "bundleName from full object must not leak: {results:?}"
2348        );
2349    }
2350
2351    // ── extract_config_object_nested_strings ─────────────────────
2352
2353    #[test]
2354    fn object_nested_strings_extraction() {
2355        let source = r#"{
2356            "targets": {
2357                "build": {
2358                    "executor": "@angular/build:application"
2359                },
2360                "test": {
2361                    "executor": "@nx/vite:test"
2362                }
2363            }
2364        }"#;
2365        let results =
2366            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
2367        assert!(results.contains(&"@angular/build:application".to_string()));
2368        assert!(results.contains(&"@nx/vite:test".to_string()));
2369    }
2370
2371    // ── extract_config_require_strings edge cases ────────────────
2372
2373    #[test]
2374    fn require_strings_direct_call() {
2375        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
2376        let deps = extract_config_require_strings(source, &js_path(), "adapter");
2377        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
2378    }
2379
2380    #[test]
2381    fn require_strings_no_matching_key() {
2382        let source = r"module.exports = { other: require('something') };";
2383        let deps = extract_config_require_strings(source, &js_path(), "plugins");
2384        assert!(deps.is_empty());
2385    }
2386
2387    // ── extract_imports edge cases ───────────────────────────────
2388
2389    #[test]
2390    fn extract_imports_no_imports() {
2391        let source = r"export default {};";
2392        let imports = extract_imports(source, &js_path());
2393        assert!(imports.is_empty());
2394    }
2395
2396    #[test]
2397    fn extract_imports_side_effect_import() {
2398        let source = r"
2399            import 'polyfill';
2400            import './local-setup';
2401            export default {};
2402        ";
2403        let imports = extract_imports(source, &js_path());
2404        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
2405    }
2406
2407    #[test]
2408    fn extract_imports_mixed_specifiers() {
2409        let source = r"
2410            import defaultExport from 'module-a';
2411            import { named } from 'module-b';
2412            import * as ns from 'module-c';
2413            export default {};
2414        ";
2415        let imports = extract_imports(source, &js_path());
2416        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
2417    }
2418
2419    // ── Template literal support ─────────────────────────────────
2420
2421    #[test]
2422    fn template_literal_in_string_or_array() {
2423        let source = r"export default { entry: `./src/index.ts` };";
2424        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
2425        assert_eq!(result, vec!["./src/index.ts"]);
2426    }
2427
2428    #[test]
2429    fn template_literal_in_config_string() {
2430        let source = r"export default { testDir: `./tests` };";
2431        let val = extract_config_string(source, &js_path(), &["testDir"]);
2432        assert_eq!(val, Some("./tests".to_string()));
2433    }
2434
2435    // ── Empty/missing path navigation ────────────────────────────
2436
2437    #[test]
2438    fn nested_string_array_empty_path() {
2439        let source = r#"export default { items: ["a", "b"] };"#;
2440        let result = extract_config_string_array(source, &js_path(), &[]);
2441        assert!(result.is_empty());
2442    }
2443
2444    #[test]
2445    fn nested_string_empty_path() {
2446        let source = r#"export default { key: "val" };"#;
2447        let result = extract_config_string(source, &js_path(), &[]);
2448        assert!(result.is_none());
2449    }
2450
2451    #[test]
2452    fn object_keys_empty_path() {
2453        let source = r"export default { plugins: {} };";
2454        let result = extract_config_object_keys(source, &js_path(), &[]);
2455        assert!(result.is_empty());
2456    }
2457
2458    // ── No config object found ───────────────────────────────────
2459
2460    #[test]
2461    fn no_config_object_returns_empty() {
2462        // Source with no default export or module.exports
2463        let source = r"const x = 42;";
2464        let result = extract_config_string(source, &js_path(), &["key"]);
2465        assert!(result.is_none());
2466
2467        let arr = extract_config_string_array(source, &js_path(), &["items"]);
2468        assert!(arr.is_empty());
2469
2470        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
2471        assert!(keys.is_empty());
2472    }
2473
2474    // ── String literal with string key property ──────────────────
2475
2476    #[test]
2477    fn property_with_string_key() {
2478        let source = r#"export default { "string-key": "value" };"#;
2479        let val = extract_config_string(source, &js_path(), &["string-key"]);
2480        assert_eq!(val, Some("value".to_string()));
2481    }
2482
2483    #[test]
2484    fn nested_navigation_through_non_object() {
2485        // Trying to navigate through a string value should return None
2486        let source = r#"export default { level1: "not-an-object" };"#;
2487        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
2488        assert!(val.is_none());
2489    }
2490
2491    // ── Variable reference resolution ───────────────────────────
2492
2493    #[test]
2494    fn variable_reference_untyped() {
2495        let source = r#"
2496            const config = {
2497                testDir: "./tests"
2498            };
2499            export default config;
2500        "#;
2501        let val = extract_config_string(source, &js_path(), &["testDir"]);
2502        assert_eq!(val, Some("./tests".to_string()));
2503    }
2504
2505    #[test]
2506    fn variable_reference_with_type_annotation() {
2507        let source = r#"
2508            import type { StorybookConfig } from '@storybook/react-vite';
2509            const config: StorybookConfig = {
2510                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
2511                framework: "@storybook/react-vite"
2512            };
2513            export default config;
2514        "#;
2515        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
2516        assert_eq!(
2517            addons,
2518            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
2519        );
2520
2521        let framework = extract_config_string(source, &ts_path(), &["framework"]);
2522        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
2523    }
2524
2525    #[test]
2526    fn variable_reference_with_define_config() {
2527        let source = r#"
2528            import { defineConfig } from 'vitest/config';
2529            const config = defineConfig({
2530                test: {
2531                    include: ["**/*.test.ts"]
2532                }
2533            });
2534            export default config;
2535        "#;
2536        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
2537        assert_eq!(include, vec!["**/*.test.ts"]);
2538    }
2539
2540    // ── TS type annotation wrappers ─────────────────────────────
2541
2542    #[test]
2543    fn ts_satisfies_direct_export() {
2544        let source = r#"
2545            export default {
2546                testDir: "./tests"
2547            } satisfies PlaywrightTestConfig;
2548        "#;
2549        let val = extract_config_string(source, &ts_path(), &["testDir"]);
2550        assert_eq!(val, Some("./tests".to_string()));
2551    }
2552
2553    #[test]
2554    fn ts_as_direct_export() {
2555        let source = r#"
2556            export default {
2557                testDir: "./tests"
2558            } as const;
2559        "#;
2560        let val = extract_config_string(source, &ts_path(), &["testDir"]);
2561        assert_eq!(val, Some("./tests".to_string()));
2562    }
2563}