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