Skip to main content

fallow_core/plugins/
config_parser.rs

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