Skip to main content

fallow_core/plugins/
config_parser.rs

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