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