Skip to main content

fallow_core/plugins/
config_parser.rs

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