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;
15
16use oxc_allocator::Allocator;
17#[allow(clippy::wildcard_imports, reason = "many AST types used")]
18use oxc_ast::ast::*;
19use oxc_parser::Parser;
20use oxc_span::SourceType;
21
22/// Extract all import source specifiers from JS/TS source code.
23#[must_use]
24pub fn extract_imports(source: &str, path: &Path) -> Vec<String> {
25    extract_from_source(source, path, |program| {
26        let mut sources = Vec::new();
27        for stmt in &program.body {
28            if let Statement::ImportDeclaration(decl) = stmt {
29                sources.push(decl.source.value.to_string());
30            }
31        }
32        Some(sources)
33    })
34    .unwrap_or_default()
35}
36
37/// Extract string array from a property at a nested path in a config's default export.
38#[must_use]
39pub fn extract_config_string_array(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
40    extract_from_source(source, path, |program| {
41        let obj = find_config_object(program)?;
42        get_nested_string_array_from_object(obj, prop_path)
43    })
44    .unwrap_or_default()
45}
46
47/// Extract a single string from a property at a nested path.
48#[must_use]
49pub fn extract_config_string(source: &str, path: &Path, prop_path: &[&str]) -> Option<String> {
50    extract_from_source(source, path, |program| {
51        let obj = find_config_object(program)?;
52        get_nested_string_from_object(obj, prop_path)
53    })
54}
55
56/// Extract string values from top-level properties of the default export/module.exports object.
57/// Returns all string literal values found for the given property key, recursively.
58///
59/// **Warning**: This recurses into nested objects/arrays. For config arrays that contain
60/// tuples like `["pkg-name", { options }]`, use [`extract_config_shallow_strings`] instead
61/// to avoid extracting option values as package names.
62#[must_use]
63pub fn extract_config_property_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
64    extract_from_source(source, path, |program| {
65        let obj = find_config_object(program)?;
66        let mut values = Vec::new();
67        if let Some(prop) = find_property(obj, key) {
68            collect_all_string_values(&prop.value, &mut values);
69        }
70        Some(values)
71    })
72    .unwrap_or_default()
73}
74
75/// Extract only top-level string values from a property's array.
76///
77/// Unlike [`extract_config_property_strings`], this does NOT recurse into nested
78/// objects or sub-arrays. Useful for config arrays with tuple elements like:
79/// `reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]`
80/// — only `"default"` and `"jest-junit"` are returned, not `"reports"`.
81#[must_use]
82pub fn extract_config_shallow_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
83    extract_from_source(source, path, |program| {
84        let obj = find_config_object(program)?;
85        let prop = find_property(obj, key)?;
86        Some(collect_shallow_string_values(&prop.value))
87    })
88    .unwrap_or_default()
89}
90
91/// Extract shallow strings from an array property inside a nested object path.
92///
93/// Navigates `outer_path` to find a nested object, then extracts shallow strings
94/// from the `key` property. Useful for configs like Vitest where reporters are at
95/// `test.reporters`: `{ test: { reporters: ["default", ["vitest-sonar-reporter", {...}]] } }`.
96#[must_use]
97pub fn extract_config_nested_shallow_strings(
98    source: &str,
99    path: &Path,
100    outer_path: &[&str],
101    key: &str,
102) -> Vec<String> {
103    extract_from_source(source, path, |program| {
104        let obj = find_config_object(program)?;
105        let nested = get_nested_expression(obj, outer_path)?;
106        if let Expression::ObjectExpression(nested_obj) = nested {
107            let prop = find_property(nested_obj, key)?;
108            Some(collect_shallow_string_values(&prop.value))
109        } else {
110            None
111        }
112    })
113    .unwrap_or_default()
114}
115
116/// Public wrapper for `find_config_object` for plugins that need manual AST walking.
117pub fn find_config_object_pub<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
118    find_config_object(program)
119}
120
121/// Extract keys of an object property at a nested path.
122///
123/// Useful for `PostCSS` config: `{ plugins: { autoprefixer: {}, tailwindcss: {} } }`
124/// → returns `["autoprefixer", "tailwindcss"]`.
125#[must_use]
126pub fn extract_config_object_keys(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
127    extract_from_source(source, path, |program| {
128        let obj = find_config_object(program)?;
129        get_nested_object_keys(obj, prop_path)
130    })
131    .unwrap_or_default()
132}
133
134/// Extract a value that may be a single string, a string array, or an object with string values.
135///
136/// Useful for Webpack `entry`, Rollup `input`, etc. that accept multiple formats:
137/// - `entry: "./src/index.js"` → `["./src/index.js"]`
138/// - `entry: ["./src/a.js", "./src/b.js"]` → `["./src/a.js", "./src/b.js"]`
139/// - `entry: { main: "./src/main.js" }` → `["./src/main.js"]`
140#[must_use]
141pub fn extract_config_string_or_array(
142    source: &str,
143    path: &Path,
144    prop_path: &[&str],
145) -> Vec<String> {
146    extract_from_source(source, path, |program| {
147        let obj = find_config_object(program)?;
148        get_nested_string_or_array(obj, prop_path)
149    })
150    .unwrap_or_default()
151}
152
153/// Extract string values from a property path, also searching inside array elements.
154///
155/// Navigates `array_path` to find an array expression, then for each object in the
156/// array, navigates `inner_path` to extract string values. Useful for configs like
157/// Vitest projects where values are nested in array elements:
158/// - `test.projects[*].test.setupFiles`
159#[must_use]
160pub fn extract_config_array_nested_string_or_array(
161    source: &str,
162    path: &Path,
163    array_path: &[&str],
164    inner_path: &[&str],
165) -> Vec<String> {
166    extract_from_source(source, path, |program| {
167        let obj = find_config_object(program)?;
168        let array_expr = get_nested_expression(obj, array_path)?;
169        let Expression::ArrayExpression(arr) = array_expr else {
170            return None;
171        };
172        let mut results = Vec::new();
173        for element in &arr.elements {
174            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
175                && let Some(values) = get_nested_string_or_array(element_obj, inner_path)
176            {
177                results.extend(values);
178            }
179        }
180        if results.is_empty() {
181            None
182        } else {
183            Some(results)
184        }
185    })
186    .unwrap_or_default()
187}
188
189/// Extract string values from a property path, searching inside all values of an object.
190///
191/// Navigates `object_path` to find an object expression, then for each property value
192/// (regardless of key name), navigates `inner_path` to extract string values. Useful for
193/// configs with dynamic keys like `angular.json`:
194/// - `projects.*.architect.build.options.styles`
195#[must_use]
196pub fn extract_config_object_nested_string_or_array(
197    source: &str,
198    path: &Path,
199    object_path: &[&str],
200    inner_path: &[&str],
201) -> Vec<String> {
202    extract_config_object_nested(source, path, object_path, |value_obj| {
203        get_nested_string_or_array(value_obj, inner_path)
204    })
205}
206
207/// Extract string values from a property path, searching inside all values of an object.
208///
209/// Like [`extract_config_object_nested_string_or_array`] but returns a single optional string
210/// per object value (useful for fields like `architect.build.options.main`).
211#[must_use]
212pub fn extract_config_object_nested_strings(
213    source: &str,
214    path: &Path,
215    object_path: &[&str],
216    inner_path: &[&str],
217) -> Vec<String> {
218    extract_config_object_nested(source, path, object_path, |value_obj| {
219        get_nested_string_from_object(value_obj, inner_path).map(|s| vec![s])
220    })
221}
222
223/// Shared helper for object-nested extraction.
224///
225/// Navigates `object_path` to find an object expression, then for each property value
226/// that is itself an object, calls `extract_fn` to produce string values.
227fn extract_config_object_nested(
228    source: &str,
229    path: &Path,
230    object_path: &[&str],
231    extract_fn: impl Fn(&ObjectExpression<'_>) -> Option<Vec<String>>,
232) -> Vec<String> {
233    extract_from_source(source, path, |program| {
234        let obj = find_config_object(program)?;
235        let obj_expr = get_nested_expression(obj, object_path)?;
236        let Expression::ObjectExpression(target_obj) = obj_expr else {
237            return None;
238        };
239        let mut results = Vec::new();
240        for prop in &target_obj.properties {
241            if let ObjectPropertyKind::ObjectProperty(p) = prop
242                && let Expression::ObjectExpression(value_obj) = &p.value
243                && let Some(values) = extract_fn(value_obj)
244            {
245                results.extend(values);
246            }
247        }
248        if results.is_empty() {
249            None
250        } else {
251            Some(results)
252        }
253    })
254    .unwrap_or_default()
255}
256
257/// Extract `require('...')` call argument strings from a property's value.
258///
259/// Handles direct require calls and arrays containing require calls or tuples:
260/// - `plugins: [require('autoprefixer')]`
261/// - `plugins: [require('postcss-import'), [require('postcss-preset-env'), { ... }]]`
262#[must_use]
263pub fn extract_config_require_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
264    extract_from_source(source, path, |program| {
265        let obj = find_config_object(program)?;
266        let prop = find_property(obj, key)?;
267        Some(collect_require_sources(&prop.value))
268    })
269    .unwrap_or_default()
270}
271
272// ── Internal helpers ──────────────────────────────────────────────
273
274/// Parse source and run an extraction function on the AST.
275///
276/// JSON files (`.json`, `.jsonc`) are parsed as JavaScript expressions wrapped in
277/// parentheses to produce an AST compatible with `find_config_object`. The native
278/// JSON source type in Oxc produces a different AST structure that our helpers
279/// don't handle.
280fn extract_from_source<T>(
281    source: &str,
282    path: &Path,
283    extractor: impl FnOnce(&Program) -> Option<T>,
284) -> Option<T> {
285    let source_type = SourceType::from_path(path).unwrap_or_default();
286    let alloc = Allocator::default();
287
288    // For JSON files, wrap in parens and parse as JS so the AST matches
289    // what find_config_object expects (ExpressionStatement → ObjectExpression).
290    let is_json = path
291        .extension()
292        .is_some_and(|ext| ext == "json" || ext == "jsonc");
293    if is_json {
294        let wrapped = format!("({source})");
295        let parsed = Parser::new(&alloc, &wrapped, SourceType::mjs()).parse();
296        return extractor(&parsed.program);
297    }
298
299    let parsed = Parser::new(&alloc, source, source_type).parse();
300    extractor(&parsed.program)
301}
302
303/// Find the "config object" — the object expression in the default export or module.exports.
304///
305/// Handles these patterns:
306/// - `export default { ... }`
307/// - `export default defineConfig({ ... })`
308/// - `export default defineConfig(async () => ({ ... }))`
309/// - `export default { ... } satisfies Config` / `export default { ... } as Config`
310/// - `const config = { ... }; export default config;`
311/// - `const config: Config = { ... }; export default config;`
312/// - `module.exports = { ... }`
313/// - Top-level JSON object (for .json files)
314fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
315    for stmt in &program.body {
316        match stmt {
317            // export default { ... } or export default defineConfig({ ... })
318            Statement::ExportDefaultDeclaration(decl) => {
319                // ExportDefaultDeclarationKind inherits Expression variants directly
320                let expr: Option<&Expression> = match &decl.declaration {
321                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
322                        return Some(obj);
323                    }
324                    _ => decl.declaration.as_expression(),
325                };
326                if let Some(expr) = expr {
327                    // Try direct extraction (handles defineConfig(), parens, TS annotations)
328                    if let Some(obj) = extract_object_from_expression(expr) {
329                        return Some(obj);
330                    }
331                    // Fallback: resolve identifier reference to variable declaration
332                    // Handles: const config: Type = { ... }; export default config;
333                    if let Some(name) = unwrap_to_identifier_name(expr) {
334                        return find_variable_init_object(program, name);
335                    }
336                }
337            }
338            // module.exports = { ... }
339            Statement::ExpressionStatement(expr_stmt) => {
340                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
341                    && is_module_exports_target(&assign.left)
342                {
343                    return extract_object_from_expression(&assign.right);
344                }
345            }
346            _ => {}
347        }
348    }
349
350    // JSON files: the program body might be a single expression statement
351    // Also handles JSON wrapped in parens: `({ ... })` (used for tsconfig.json parsing)
352    if program.body.len() == 1
353        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
354    {
355        match &expr_stmt.expression {
356            Expression::ObjectExpression(obj) => return Some(obj),
357            Expression::ParenthesizedExpression(paren) => {
358                if let Expression::ObjectExpression(obj) = &paren.expression {
359                    return Some(obj);
360                }
361            }
362            _ => {}
363        }
364    }
365
366    None
367}
368
369/// Extract an `ObjectExpression` from an expression, handling wrapper patterns.
370fn extract_object_from_expression<'a>(
371    expr: &'a Expression<'a>,
372) -> Option<&'a ObjectExpression<'a>> {
373    match expr {
374        // Direct object: `{ ... }`
375        Expression::ObjectExpression(obj) => Some(obj),
376        // Factory call: `defineConfig({ ... })`
377        Expression::CallExpression(call) => {
378            // Look for the first object argument
379            for arg in &call.arguments {
380                match arg {
381                    Argument::ObjectExpression(obj) => return Some(obj),
382                    // Arrow function body: `defineConfig(() => ({ ... }))`
383                    Argument::ArrowFunctionExpression(arrow) => {
384                        if arrow.expression
385                            && !arrow.body.statements.is_empty()
386                            && let Statement::ExpressionStatement(expr_stmt) =
387                                &arrow.body.statements[0]
388                        {
389                            return extract_object_from_expression(&expr_stmt.expression);
390                        }
391                    }
392                    _ => {}
393                }
394            }
395            None
396        }
397        // Parenthesized: `({ ... })`
398        Expression::ParenthesizedExpression(paren) => {
399            extract_object_from_expression(&paren.expression)
400        }
401        // TS type annotations: `{ ... } satisfies Config` or `{ ... } as Config`
402        Expression::TSSatisfiesExpression(ts_sat) => {
403            extract_object_from_expression(&ts_sat.expression)
404        }
405        Expression::TSAsExpression(ts_as) => extract_object_from_expression(&ts_as.expression),
406        _ => None,
407    }
408}
409
410/// Check if an assignment target is `module.exports`.
411fn is_module_exports_target(target: &AssignmentTarget) -> bool {
412    if let AssignmentTarget::StaticMemberExpression(member) = target
413        && let Expression::Identifier(obj) = &member.object
414    {
415        return obj.name == "module" && member.property.name == "exports";
416    }
417    false
418}
419
420/// Unwrap TS annotations and return the identifier name if the expression resolves to one.
421///
422/// Handles `config`, `config satisfies Type`, `config as Type`.
423fn unwrap_to_identifier_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
424    match expr {
425        Expression::Identifier(id) => Some(&id.name),
426        Expression::TSSatisfiesExpression(ts_sat) => unwrap_to_identifier_name(&ts_sat.expression),
427        Expression::TSAsExpression(ts_as) => unwrap_to_identifier_name(&ts_as.expression),
428        _ => None,
429    }
430}
431
432/// Find a top-level variable declaration by name and extract its init as an object expression.
433///
434/// Handles `const config = { ... }`, `const config: Type = { ... }`,
435/// and `const config = defineConfig({ ... })`.
436fn find_variable_init_object<'a>(
437    program: &'a Program,
438    name: &str,
439) -> Option<&'a ObjectExpression<'a>> {
440    for stmt in &program.body {
441        if let Statement::VariableDeclaration(decl) = stmt {
442            for declarator in &decl.declarations {
443                if let BindingPattern::BindingIdentifier(id) = &declarator.id
444                    && id.name == name
445                    && let Some(init) = &declarator.init
446                {
447                    return extract_object_from_expression(init);
448                }
449            }
450        }
451    }
452    None
453}
454
455/// Find a named property in an object expression.
456fn find_property<'a>(obj: &'a ObjectExpression<'a>, key: &str) -> Option<&'a ObjectProperty<'a>> {
457    for prop in &obj.properties {
458        if let ObjectPropertyKind::ObjectProperty(p) = prop
459            && property_key_matches(&p.key, key)
460        {
461            return Some(p);
462        }
463    }
464    None
465}
466
467/// Check if a property key matches a string.
468fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
469    match key {
470        PropertyKey::StaticIdentifier(id) => id.name == name,
471        PropertyKey::StringLiteral(s) => s.value == name,
472        _ => false,
473    }
474}
475
476/// Get a string value from an object property.
477fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
478    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
479}
480
481/// Get an array of strings from an object property.
482fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
483    find_property(obj, key)
484        .map(|p| expression_to_string_array(&p.value))
485        .unwrap_or_default()
486}
487
488/// Navigate a nested property path and get a string array.
489fn get_nested_string_array_from_object(
490    obj: &ObjectExpression,
491    path: &[&str],
492) -> Option<Vec<String>> {
493    if path.is_empty() {
494        return None;
495    }
496    if path.len() == 1 {
497        return Some(get_object_string_array_property(obj, path[0]));
498    }
499    // Navigate into nested object
500    let prop = find_property(obj, path[0])?;
501    if let Expression::ObjectExpression(nested) = &prop.value {
502        get_nested_string_array_from_object(nested, &path[1..])
503    } else {
504        None
505    }
506}
507
508/// Navigate a nested property path and get a string value.
509fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
510    if path.is_empty() {
511        return None;
512    }
513    if path.len() == 1 {
514        return get_object_string_property(obj, path[0]);
515    }
516    let prop = find_property(obj, path[0])?;
517    if let Expression::ObjectExpression(nested) = &prop.value {
518        get_nested_string_from_object(nested, &path[1..])
519    } else {
520        None
521    }
522}
523
524/// Convert an expression to a string if it's a string literal.
525fn expression_to_string(expr: &Expression) -> Option<String> {
526    match expr {
527        Expression::StringLiteral(s) => Some(s.value.to_string()),
528        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
529            // Template literal with no expressions: `\`value\``
530            t.quasis.first().map(|q| q.value.raw.to_string())
531        }
532        _ => None,
533    }
534}
535
536/// Convert an expression to a string array if it's an array of string literals.
537fn expression_to_string_array(expr: &Expression) -> Vec<String> {
538    match expr {
539        Expression::ArrayExpression(arr) => arr
540            .elements
541            .iter()
542            .filter_map(|el| match el {
543                ArrayExpressionElement::SpreadElement(_) => None,
544                _ => el.as_expression().and_then(expression_to_string),
545            })
546            .collect(),
547        _ => vec![],
548    }
549}
550
551/// Collect only top-level string values from an expression.
552///
553/// For arrays, extracts direct string elements and the first string element of sub-arrays
554/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
555fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
556    let mut values = Vec::new();
557    match expr {
558        Expression::StringLiteral(s) => {
559            values.push(s.value.to_string());
560        }
561        Expression::ArrayExpression(arr) => {
562            for el in &arr.elements {
563                if let Some(inner) = el.as_expression() {
564                    match inner {
565                        Expression::StringLiteral(s) => {
566                            values.push(s.value.to_string());
567                        }
568                        // Handle tuples: ["pkg-name", { options }] → extract first string
569                        Expression::ArrayExpression(sub_arr) => {
570                            if let Some(first) = sub_arr.elements.first()
571                                && let Some(first_expr) = first.as_expression()
572                                && let Some(s) = expression_to_string(first_expr)
573                            {
574                                values.push(s);
575                            }
576                        }
577                        _ => {}
578                    }
579                }
580            }
581        }
582        _ => {}
583    }
584    values
585}
586
587/// Recursively collect all string literal values from an expression tree.
588fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
589    match expr {
590        Expression::StringLiteral(s) => {
591            values.push(s.value.to_string());
592        }
593        Expression::ArrayExpression(arr) => {
594            for el in &arr.elements {
595                if let Some(expr) = el.as_expression() {
596                    collect_all_string_values(expr, values);
597                }
598            }
599        }
600        Expression::ObjectExpression(obj) => {
601            for prop in &obj.properties {
602                if let ObjectPropertyKind::ObjectProperty(p) = prop {
603                    collect_all_string_values(&p.value, values);
604                }
605            }
606        }
607        _ => {}
608    }
609}
610
611/// Convert a `PropertyKey` to a `String`.
612fn property_key_to_string(key: &PropertyKey) -> Option<String> {
613    match key {
614        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
615        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
616        _ => None,
617    }
618}
619
620/// Extract keys of an object at a nested property path.
621fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
622    if path.is_empty() {
623        return None;
624    }
625    let prop = find_property(obj, path[0])?;
626    if path.len() == 1 {
627        if let Expression::ObjectExpression(nested) = &prop.value {
628            let keys = nested
629                .properties
630                .iter()
631                .filter_map(|p| {
632                    if let ObjectPropertyKind::ObjectProperty(p) = p {
633                        property_key_to_string(&p.key)
634                    } else {
635                        None
636                    }
637                })
638                .collect();
639            return Some(keys);
640        }
641        return None;
642    }
643    if let Expression::ObjectExpression(nested) = &prop.value {
644        get_nested_object_keys(nested, &path[1..])
645    } else {
646        None
647    }
648}
649
650/// Navigate a nested property path and return the raw expression at the end.
651fn get_nested_expression<'a>(
652    obj: &'a ObjectExpression<'a>,
653    path: &[&str],
654) -> Option<&'a Expression<'a>> {
655    if path.is_empty() {
656        return None;
657    }
658    let prop = find_property(obj, path[0])?;
659    if path.len() == 1 {
660        return Some(&prop.value);
661    }
662    if let Expression::ObjectExpression(nested) = &prop.value {
663        get_nested_expression(nested, &path[1..])
664    } else {
665        None
666    }
667}
668
669/// Navigate a nested path and extract a string, string array, or object string values.
670fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
671    if path.is_empty() {
672        return None;
673    }
674    if path.len() == 1 {
675        let prop = find_property(obj, path[0])?;
676        return Some(expression_to_string_or_array(&prop.value));
677    }
678    let prop = find_property(obj, path[0])?;
679    if let Expression::ObjectExpression(nested) = &prop.value {
680        get_nested_string_or_array(nested, &path[1..])
681    } else {
682        None
683    }
684}
685
686/// Convert an expression to a `Vec<String>`, handling string, array, and object-with-string-values.
687fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
688    match expr {
689        Expression::StringLiteral(s) => vec![s.value.to_string()],
690        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
691            .quasis
692            .first()
693            .map(|q| vec![q.value.raw.to_string()])
694            .unwrap_or_default(),
695        Expression::ArrayExpression(arr) => arr
696            .elements
697            .iter()
698            .filter_map(|el| el.as_expression().and_then(expression_to_string))
699            .collect(),
700        Expression::ObjectExpression(obj) => obj
701            .properties
702            .iter()
703            .filter_map(|p| {
704                if let ObjectPropertyKind::ObjectProperty(p) = p {
705                    expression_to_string(&p.value)
706                } else {
707                    None
708                }
709            })
710            .collect(),
711        _ => vec![],
712    }
713}
714
715/// Collect `require('...')` argument strings from an expression.
716fn collect_require_sources(expr: &Expression) -> Vec<String> {
717    let mut sources = Vec::new();
718    match expr {
719        Expression::CallExpression(call) if is_require_call(call) => {
720            if let Some(s) = get_require_source(call) {
721                sources.push(s);
722            }
723        }
724        Expression::ArrayExpression(arr) => {
725            for el in &arr.elements {
726                if let Some(inner) = el.as_expression() {
727                    match inner {
728                        Expression::CallExpression(call) if is_require_call(call) => {
729                            if let Some(s) = get_require_source(call) {
730                                sources.push(s);
731                            }
732                        }
733                        // Tuple: [require('pkg'), options]
734                        Expression::ArrayExpression(sub_arr) => {
735                            if let Some(first) = sub_arr.elements.first()
736                                && let Some(Expression::CallExpression(call)) =
737                                    first.as_expression()
738                                && is_require_call(call)
739                                && let Some(s) = get_require_source(call)
740                            {
741                                sources.push(s);
742                            }
743                        }
744                        _ => {}
745                    }
746                }
747            }
748        }
749        _ => {}
750    }
751    sources
752}
753
754/// Check if a call expression is `require(...)`.
755fn is_require_call(call: &CallExpression) -> bool {
756    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
757}
758
759/// Get the first string argument of a `require()` call.
760fn get_require_source(call: &CallExpression) -> Option<String> {
761    call.arguments.first().and_then(|arg| {
762        if let Argument::StringLiteral(s) = arg {
763            Some(s.value.to_string())
764        } else {
765            None
766        }
767    })
768}
769
770#[cfg(test)]
771mod tests {
772    use super::*;
773    use std::path::PathBuf;
774
775    fn js_path() -> PathBuf {
776        PathBuf::from("config.js")
777    }
778
779    fn ts_path() -> PathBuf {
780        PathBuf::from("config.ts")
781    }
782
783    #[test]
784    fn extract_imports_basic() {
785        let source = r"
786            import foo from 'foo-pkg';
787            import { bar } from '@scope/bar';
788            export default {};
789        ";
790        let imports = extract_imports(source, &js_path());
791        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
792    }
793
794    #[test]
795    fn extract_default_export_object_property() {
796        let source = r#"export default { testDir: "./tests" };"#;
797        let val = extract_config_string(source, &js_path(), &["testDir"]);
798        assert_eq!(val, Some("./tests".to_string()));
799    }
800
801    #[test]
802    fn extract_define_config_property() {
803        let source = r#"
804            import { defineConfig } from 'vitest/config';
805            export default defineConfig({
806                test: {
807                    include: ["**/*.test.ts", "**/*.spec.ts"],
808                    setupFiles: ["./test/setup.ts"]
809                }
810            });
811        "#;
812        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
813        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
814
815        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
816        assert_eq!(setup, vec!["./test/setup.ts"]);
817    }
818
819    #[test]
820    fn extract_module_exports_property() {
821        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
822        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
823        assert_eq!(val, Some("jsdom".to_string()));
824    }
825
826    #[test]
827    fn extract_nested_string_array() {
828        let source = r#"
829            export default {
830                resolve: {
831                    alias: {
832                        "@": "./src"
833                    }
834                },
835                test: {
836                    include: ["src/**/*.test.ts"]
837                }
838            };
839        "#;
840        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
841        assert_eq!(include, vec!["src/**/*.test.ts"]);
842    }
843
844    #[test]
845    fn extract_addons_array() {
846        let source = r#"
847            export default {
848                addons: [
849                    "@storybook/addon-a11y",
850                    "@storybook/addon-docs",
851                    "@storybook/addon-links"
852                ]
853            };
854        "#;
855        let addons = extract_config_property_strings(source, &ts_path(), "addons");
856        assert_eq!(
857            addons,
858            vec![
859                "@storybook/addon-a11y",
860                "@storybook/addon-docs",
861                "@storybook/addon-links"
862            ]
863        );
864    }
865
866    #[test]
867    fn handle_empty_config() {
868        let source = "";
869        let result = extract_config_string(source, &js_path(), &["key"]);
870        assert_eq!(result, None);
871    }
872
873    // ── extract_config_object_keys tests ────────────────────────────
874
875    #[test]
876    fn object_keys_postcss_plugins() {
877        let source = r"
878            module.exports = {
879                plugins: {
880                    autoprefixer: {},
881                    tailwindcss: {},
882                    'postcss-import': {}
883                }
884            };
885        ";
886        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
887        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
888    }
889
890    #[test]
891    fn object_keys_nested_path() {
892        let source = r"
893            export default {
894                build: {
895                    plugins: {
896                        minify: {},
897                        compress: {}
898                    }
899                }
900            };
901        ";
902        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
903        assert_eq!(keys, vec!["minify", "compress"]);
904    }
905
906    #[test]
907    fn object_keys_empty_object() {
908        let source = r"export default { plugins: {} };";
909        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
910        assert!(keys.is_empty());
911    }
912
913    #[test]
914    fn object_keys_non_object_returns_empty() {
915        let source = r#"export default { plugins: ["a", "b"] };"#;
916        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
917        assert!(keys.is_empty());
918    }
919
920    // ── extract_config_string_or_array tests ────────────────────────
921
922    #[test]
923    fn string_or_array_single_string() {
924        let source = r#"export default { entry: "./src/index.js" };"#;
925        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
926        assert_eq!(result, vec!["./src/index.js"]);
927    }
928
929    #[test]
930    fn string_or_array_array() {
931        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
932        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
933        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
934    }
935
936    #[test]
937    fn string_or_array_object_values() {
938        let source =
939            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
940        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
941        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
942    }
943
944    #[test]
945    fn string_or_array_nested_path() {
946        let source = r#"
947            export default {
948                build: {
949                    rollupOptions: {
950                        input: ["./index.html", "./about.html"]
951                    }
952                }
953            };
954        "#;
955        let result = extract_config_string_or_array(
956            source,
957            &js_path(),
958            &["build", "rollupOptions", "input"],
959        );
960        assert_eq!(result, vec!["./index.html", "./about.html"]);
961    }
962
963    #[test]
964    fn string_or_array_template_literal() {
965        let source = r"export default { entry: `./src/index.js` };";
966        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
967        assert_eq!(result, vec!["./src/index.js"]);
968    }
969
970    // ── extract_config_require_strings tests ────────────────────────
971
972    #[test]
973    fn require_strings_array() {
974        let source = r"
975            module.exports = {
976                plugins: [
977                    require('autoprefixer'),
978                    require('postcss-import')
979                ]
980            };
981        ";
982        let deps = extract_config_require_strings(source, &js_path(), "plugins");
983        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
984    }
985
986    #[test]
987    fn require_strings_with_tuples() {
988        let source = r"
989            module.exports = {
990                plugins: [
991                    require('autoprefixer'),
992                    [require('postcss-preset-env'), { stage: 3 }]
993                ]
994            };
995        ";
996        let deps = extract_config_require_strings(source, &js_path(), "plugins");
997        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
998    }
999
1000    #[test]
1001    fn require_strings_empty_array() {
1002        let source = r"module.exports = { plugins: [] };";
1003        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1004        assert!(deps.is_empty());
1005    }
1006
1007    #[test]
1008    fn require_strings_no_require_calls() {
1009        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
1010        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1011        assert!(deps.is_empty());
1012    }
1013
1014    // ── JSON wrapped in parens (for tsconfig.json parsing) ──────────
1015
1016    #[test]
1017    fn json_wrapped_in_parens_string() {
1018        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
1019        let val = extract_config_string(source, &js_path(), &["extends"]);
1020        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
1021    }
1022
1023    #[test]
1024    fn json_wrapped_in_parens_nested_array() {
1025        let source =
1026            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
1027        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
1028        assert_eq!(types, vec!["node", "jest"]);
1029
1030        let include = extract_config_string_array(source, &js_path(), &["include"]);
1031        assert_eq!(include, vec!["src/**/*"]);
1032    }
1033
1034    #[test]
1035    fn json_wrapped_in_parens_object_keys() {
1036        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
1037        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1038        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
1039    }
1040
1041    // ── JSON file extension detection ────────────────────────────
1042
1043    fn json_path() -> PathBuf {
1044        PathBuf::from("config.json")
1045    }
1046
1047    #[test]
1048    fn json_file_parsed_correctly() {
1049        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
1050        let val = extract_config_string(source, &json_path(), &["key"]);
1051        assert_eq!(val, Some("value".to_string()));
1052
1053        let list = extract_config_string_array(source, &json_path(), &["list"]);
1054        assert_eq!(list, vec!["a", "b"]);
1055    }
1056
1057    #[test]
1058    fn jsonc_file_parsed_correctly() {
1059        let source = r#"{"key": "value"}"#;
1060        let path = PathBuf::from("tsconfig.jsonc");
1061        let val = extract_config_string(source, &path, &["key"]);
1062        assert_eq!(val, Some("value".to_string()));
1063    }
1064
1065    // ── defineConfig with arrow function ─────────────────────────
1066
1067    #[test]
1068    fn extract_define_config_arrow_function() {
1069        let source = r#"
1070            import { defineConfig } from 'vite';
1071            export default defineConfig(() => ({
1072                test: {
1073                    include: ["**/*.test.ts"]
1074                }
1075            }));
1076        "#;
1077        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1078        assert_eq!(include, vec!["**/*.test.ts"]);
1079    }
1080
1081    // ── module.exports with nested properties ────────────────────
1082
1083    #[test]
1084    fn module_exports_nested_string() {
1085        let source = r#"
1086            module.exports = {
1087                resolve: {
1088                    alias: {
1089                        "@": "./src"
1090                    }
1091                }
1092            };
1093        "#;
1094        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
1095        assert_eq!(val, Some("./src".to_string()));
1096    }
1097
1098    // ── extract_config_property_strings (recursive) ──────────────
1099
1100    #[test]
1101    fn property_strings_nested_objects() {
1102        let source = r#"
1103            export default {
1104                plugins: {
1105                    group1: { a: "val-a" },
1106                    group2: { b: "val-b" }
1107                }
1108            };
1109        "#;
1110        let values = extract_config_property_strings(source, &js_path(), "plugins");
1111        assert!(values.contains(&"val-a".to_string()));
1112        assert!(values.contains(&"val-b".to_string()));
1113    }
1114
1115    #[test]
1116    fn property_strings_missing_key_returns_empty() {
1117        let source = r#"export default { other: "value" };"#;
1118        let values = extract_config_property_strings(source, &js_path(), "missing");
1119        assert!(values.is_empty());
1120    }
1121
1122    // ── extract_config_shallow_strings ────────────────────────────
1123
1124    #[test]
1125    fn shallow_strings_tuple_array() {
1126        let source = r#"
1127            module.exports = {
1128                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
1129            };
1130        "#;
1131        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
1132        assert_eq!(values, vec!["default", "jest-junit"]);
1133        // "reports" should NOT be extracted (it's inside an options object)
1134        assert!(!values.contains(&"reports".to_string()));
1135    }
1136
1137    #[test]
1138    fn shallow_strings_single_string() {
1139        let source = r#"export default { preset: "ts-jest" };"#;
1140        let values = extract_config_shallow_strings(source, &js_path(), "preset");
1141        assert_eq!(values, vec!["ts-jest"]);
1142    }
1143
1144    #[test]
1145    fn shallow_strings_missing_key() {
1146        let source = r#"export default { other: "val" };"#;
1147        let values = extract_config_shallow_strings(source, &js_path(), "missing");
1148        assert!(values.is_empty());
1149    }
1150
1151    // ── extract_config_nested_shallow_strings tests ──────────────
1152
1153    #[test]
1154    fn nested_shallow_strings_vitest_reporters() {
1155        let source = r#"
1156            export default {
1157                test: {
1158                    reporters: ["default", "vitest-sonar-reporter"]
1159                }
1160            };
1161        "#;
1162        let values =
1163            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1164        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1165    }
1166
1167    #[test]
1168    fn nested_shallow_strings_tuple_format() {
1169        let source = r#"
1170            export default {
1171                test: {
1172                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
1173                }
1174            };
1175        "#;
1176        let values =
1177            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1178        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1179    }
1180
1181    #[test]
1182    fn nested_shallow_strings_missing_outer() {
1183        let source = r"export default { other: {} };";
1184        let values =
1185            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1186        assert!(values.is_empty());
1187    }
1188
1189    #[test]
1190    fn nested_shallow_strings_missing_inner() {
1191        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
1192        let values =
1193            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1194        assert!(values.is_empty());
1195    }
1196
1197    // ── extract_config_string_or_array edge cases ────────────────
1198
1199    #[test]
1200    fn string_or_array_missing_path() {
1201        let source = r"export default {};";
1202        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1203        assert!(result.is_empty());
1204    }
1205
1206    #[test]
1207    fn string_or_array_non_string_values() {
1208        // When values are not strings (e.g., numbers), they should be skipped
1209        let source = r"export default { entry: [42, true] };";
1210        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1211        assert!(result.is_empty());
1212    }
1213
1214    // ── extract_config_array_nested_string_or_array ──────────────
1215
1216    #[test]
1217    fn array_nested_extraction() {
1218        let source = r#"
1219            export default defineConfig({
1220                test: {
1221                    projects: [
1222                        {
1223                            test: {
1224                                setupFiles: ["./test/setup-a.ts"]
1225                            }
1226                        },
1227                        {
1228                            test: {
1229                                setupFiles: "./test/setup-b.ts"
1230                            }
1231                        }
1232                    ]
1233                }
1234            });
1235        "#;
1236        let results = extract_config_array_nested_string_or_array(
1237            source,
1238            &ts_path(),
1239            &["test", "projects"],
1240            &["test", "setupFiles"],
1241        );
1242        assert!(results.contains(&"./test/setup-a.ts".to_string()));
1243        assert!(results.contains(&"./test/setup-b.ts".to_string()));
1244    }
1245
1246    #[test]
1247    fn array_nested_empty_when_no_array() {
1248        let source = r#"export default { test: { projects: "not-an-array" } };"#;
1249        let results = extract_config_array_nested_string_or_array(
1250            source,
1251            &js_path(),
1252            &["test", "projects"],
1253            &["test", "setupFiles"],
1254        );
1255        assert!(results.is_empty());
1256    }
1257
1258    // ── extract_config_object_nested_string_or_array ─────────────
1259
1260    #[test]
1261    fn object_nested_extraction() {
1262        let source = r#"{
1263            "projects": {
1264                "app-one": {
1265                    "architect": {
1266                        "build": {
1267                            "options": {
1268                                "styles": ["src/styles.css"]
1269                            }
1270                        }
1271                    }
1272                }
1273            }
1274        }"#;
1275        let results = extract_config_object_nested_string_or_array(
1276            source,
1277            &json_path(),
1278            &["projects"],
1279            &["architect", "build", "options", "styles"],
1280        );
1281        assert_eq!(results, vec!["src/styles.css"]);
1282    }
1283
1284    // ── extract_config_object_nested_strings ─────────────────────
1285
1286    #[test]
1287    fn object_nested_strings_extraction() {
1288        let source = r#"{
1289            "targets": {
1290                "build": {
1291                    "executor": "@angular/build:application"
1292                },
1293                "test": {
1294                    "executor": "@nx/vite:test"
1295                }
1296            }
1297        }"#;
1298        let results =
1299            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
1300        assert!(results.contains(&"@angular/build:application".to_string()));
1301        assert!(results.contains(&"@nx/vite:test".to_string()));
1302    }
1303
1304    // ── extract_config_require_strings edge cases ────────────────
1305
1306    #[test]
1307    fn require_strings_direct_call() {
1308        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
1309        let deps = extract_config_require_strings(source, &js_path(), "adapter");
1310        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
1311    }
1312
1313    #[test]
1314    fn require_strings_no_matching_key() {
1315        let source = r"module.exports = { other: require('something') };";
1316        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1317        assert!(deps.is_empty());
1318    }
1319
1320    // ── extract_imports edge cases ───────────────────────────────
1321
1322    #[test]
1323    fn extract_imports_no_imports() {
1324        let source = r"export default {};";
1325        let imports = extract_imports(source, &js_path());
1326        assert!(imports.is_empty());
1327    }
1328
1329    #[test]
1330    fn extract_imports_side_effect_import() {
1331        let source = r"
1332            import 'polyfill';
1333            import './local-setup';
1334            export default {};
1335        ";
1336        let imports = extract_imports(source, &js_path());
1337        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
1338    }
1339
1340    #[test]
1341    fn extract_imports_mixed_specifiers() {
1342        let source = r"
1343            import defaultExport from 'module-a';
1344            import { named } from 'module-b';
1345            import * as ns from 'module-c';
1346            export default {};
1347        ";
1348        let imports = extract_imports(source, &js_path());
1349        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
1350    }
1351
1352    // ── Template literal support ─────────────────────────────────
1353
1354    #[test]
1355    fn template_literal_in_string_or_array() {
1356        let source = r"export default { entry: `./src/index.ts` };";
1357        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
1358        assert_eq!(result, vec!["./src/index.ts"]);
1359    }
1360
1361    #[test]
1362    fn template_literal_in_config_string() {
1363        let source = r"export default { testDir: `./tests` };";
1364        let val = extract_config_string(source, &js_path(), &["testDir"]);
1365        assert_eq!(val, Some("./tests".to_string()));
1366    }
1367
1368    // ── Empty/missing path navigation ────────────────────────────
1369
1370    #[test]
1371    fn nested_string_array_empty_path() {
1372        let source = r#"export default { items: ["a", "b"] };"#;
1373        let result = extract_config_string_array(source, &js_path(), &[]);
1374        assert!(result.is_empty());
1375    }
1376
1377    #[test]
1378    fn nested_string_empty_path() {
1379        let source = r#"export default { key: "val" };"#;
1380        let result = extract_config_string(source, &js_path(), &[]);
1381        assert!(result.is_none());
1382    }
1383
1384    #[test]
1385    fn object_keys_empty_path() {
1386        let source = r"export default { plugins: {} };";
1387        let result = extract_config_object_keys(source, &js_path(), &[]);
1388        assert!(result.is_empty());
1389    }
1390
1391    // ── No config object found ───────────────────────────────────
1392
1393    #[test]
1394    fn no_config_object_returns_empty() {
1395        // Source with no default export or module.exports
1396        let source = r"const x = 42;";
1397        let result = extract_config_string(source, &js_path(), &["key"]);
1398        assert!(result.is_none());
1399
1400        let arr = extract_config_string_array(source, &js_path(), &["items"]);
1401        assert!(arr.is_empty());
1402
1403        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1404        assert!(keys.is_empty());
1405    }
1406
1407    // ── String literal with string key property ──────────────────
1408
1409    #[test]
1410    fn property_with_string_key() {
1411        let source = r#"export default { "string-key": "value" };"#;
1412        let val = extract_config_string(source, &js_path(), &["string-key"]);
1413        assert_eq!(val, Some("value".to_string()));
1414    }
1415
1416    #[test]
1417    fn nested_navigation_through_non_object() {
1418        // Trying to navigate through a string value should return None
1419        let source = r#"export default { level1: "not-an-object" };"#;
1420        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
1421        assert!(val.is_none());
1422    }
1423
1424    // ── Variable reference resolution ───────────────────────────
1425
1426    #[test]
1427    fn variable_reference_untyped() {
1428        let source = r#"
1429            const config = {
1430                testDir: "./tests"
1431            };
1432            export default config;
1433        "#;
1434        let val = extract_config_string(source, &js_path(), &["testDir"]);
1435        assert_eq!(val, Some("./tests".to_string()));
1436    }
1437
1438    #[test]
1439    fn variable_reference_with_type_annotation() {
1440        let source = r#"
1441            import type { StorybookConfig } from '@storybook/react-vite';
1442            const config: StorybookConfig = {
1443                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
1444                framework: "@storybook/react-vite"
1445            };
1446            export default config;
1447        "#;
1448        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
1449        assert_eq!(
1450            addons,
1451            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
1452        );
1453
1454        let framework = extract_config_string(source, &ts_path(), &["framework"]);
1455        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
1456    }
1457
1458    #[test]
1459    fn variable_reference_with_define_config() {
1460        let source = r#"
1461            import { defineConfig } from 'vitest/config';
1462            const config = defineConfig({
1463                test: {
1464                    include: ["**/*.test.ts"]
1465                }
1466            });
1467            export default config;
1468        "#;
1469        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1470        assert_eq!(include, vec!["**/*.test.ts"]);
1471    }
1472
1473    // ── TS type annotation wrappers ─────────────────────────────
1474
1475    #[test]
1476    fn ts_satisfies_direct_export() {
1477        let source = r#"
1478            export default {
1479                testDir: "./tests"
1480            } satisfies PlaywrightTestConfig;
1481        "#;
1482        let val = extract_config_string(source, &ts_path(), &["testDir"]);
1483        assert_eq!(val, Some("./tests".to_string()));
1484    }
1485
1486    #[test]
1487    fn ts_as_direct_export() {
1488        let source = r#"
1489            export default {
1490                testDir: "./tests"
1491            } as const;
1492        "#;
1493        let val = extract_config_string(source, &ts_path(), &["testDir"]);
1494        assert_eq!(val, Some("./tests".to_string()));
1495    }
1496}