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