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