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;
17use oxc_ast::ast::*;
18use oxc_parser::Parser;
19use oxc_span::SourceType;
20
21/// Extract all import source specifiers from JS/TS source code.
22pub fn extract_imports(source: &str, path: &Path) -> Vec<String> {
23    extract_from_source(source, path, |program| {
24        let mut sources = Vec::new();
25        for stmt in &program.body {
26            if let Statement::ImportDeclaration(decl) = stmt {
27                sources.push(decl.source.value.to_string());
28            }
29        }
30        Some(sources)
31    })
32    .unwrap_or_default()
33}
34
35/// Extract string array from a property at a nested path in a config's default export.
36pub fn extract_config_string_array(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
37    extract_from_source(source, path, |program| {
38        let obj = find_config_object(program)?;
39        get_nested_string_array_from_object(obj, prop_path)
40    })
41    .unwrap_or_default()
42}
43
44/// Extract a single string from a property at a nested path.
45pub fn extract_config_string(source: &str, path: &Path, prop_path: &[&str]) -> Option<String> {
46    extract_from_source(source, path, |program| {
47        let obj = find_config_object(program)?;
48        get_nested_string_from_object(obj, prop_path)
49    })
50}
51
52/// Extract string values from top-level properties of the default export/module.exports object.
53/// Returns all string literal values found for the given property key, recursively.
54///
55/// **Warning**: This recurses into nested objects/arrays. For config arrays that contain
56/// tuples like `["pkg-name", { options }]`, use [`extract_config_shallow_strings`] instead
57/// to avoid extracting option values as package names.
58pub fn extract_config_property_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
59    extract_from_source(source, path, |program| {
60        let obj = find_config_object(program)?;
61        let mut values = Vec::new();
62        if let Some(prop) = find_property(obj, key) {
63            collect_all_string_values(&prop.value, &mut values);
64        }
65        Some(values)
66    })
67    .unwrap_or_default()
68}
69
70/// Extract only top-level string values from a property's array.
71///
72/// Unlike [`extract_config_property_strings`], this does NOT recurse into nested
73/// objects or sub-arrays. Useful for config arrays with tuple elements like:
74/// `reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]`
75/// — only `"default"` and `"jest-junit"` are returned, not `"reports"`.
76pub fn extract_config_shallow_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
77    extract_from_source(source, path, |program| {
78        let obj = find_config_object(program)?;
79        let prop = find_property(obj, key)?;
80        Some(collect_shallow_string_values(&prop.value))
81    })
82    .unwrap_or_default()
83}
84
85/// Public wrapper for `find_config_object` for plugins that need manual AST walking.
86pub fn find_config_object_pub<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
87    find_config_object(program)
88}
89
90/// Extract keys of an object property at a nested path.
91///
92/// Useful for `PostCSS` config: `{ plugins: { autoprefixer: {}, tailwindcss: {} } }`
93/// → returns `["autoprefixer", "tailwindcss"]`.
94pub fn extract_config_object_keys(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
95    extract_from_source(source, path, |program| {
96        let obj = find_config_object(program)?;
97        get_nested_object_keys(obj, prop_path)
98    })
99    .unwrap_or_default()
100}
101
102/// Extract a value that may be a single string, a string array, or an object with string values.
103///
104/// Useful for Webpack `entry`, Rollup `input`, etc. that accept multiple formats:
105/// - `entry: "./src/index.js"` → `["./src/index.js"]`
106/// - `entry: ["./src/a.js", "./src/b.js"]` → `["./src/a.js", "./src/b.js"]`
107/// - `entry: { main: "./src/main.js" }` → `["./src/main.js"]`
108pub fn extract_config_string_or_array(
109    source: &str,
110    path: &Path,
111    prop_path: &[&str],
112) -> Vec<String> {
113    extract_from_source(source, path, |program| {
114        let obj = find_config_object(program)?;
115        get_nested_string_or_array(obj, prop_path)
116    })
117    .unwrap_or_default()
118}
119
120/// Extract string values from a property path, also searching inside array elements.
121///
122/// Navigates `array_path` to find an array expression, then for each object in the
123/// array, navigates `inner_path` to extract string values. Useful for configs like
124/// Vitest projects where values are nested in array elements:
125/// - `test.projects[*].test.setupFiles`
126pub fn extract_config_array_nested_string_or_array(
127    source: &str,
128    path: &Path,
129    array_path: &[&str],
130    inner_path: &[&str],
131) -> Vec<String> {
132    extract_from_source(source, path, |program| {
133        let obj = find_config_object(program)?;
134        let array_expr = get_nested_expression(obj, array_path)?;
135        let Expression::ArrayExpression(arr) = array_expr else {
136            return None;
137        };
138        let mut results = Vec::new();
139        for element in &arr.elements {
140            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
141                && let Some(values) = get_nested_string_or_array(element_obj, inner_path)
142            {
143                results.extend(values);
144            }
145        }
146        if results.is_empty() {
147            None
148        } else {
149            Some(results)
150        }
151    })
152    .unwrap_or_default()
153}
154
155/// Extract string values from a property path, searching inside all values of an object.
156///
157/// Navigates `object_path` to find an object expression, then for each property value
158/// (regardless of key name), navigates `inner_path` to extract string values. Useful for
159/// configs with dynamic keys like `angular.json`:
160/// - `projects.*.architect.build.options.styles`
161pub fn extract_config_object_nested_string_or_array(
162    source: &str,
163    path: &Path,
164    object_path: &[&str],
165    inner_path: &[&str],
166) -> Vec<String> {
167    extract_config_object_nested(source, path, object_path, |value_obj| {
168        get_nested_string_or_array(value_obj, inner_path)
169    })
170}
171
172/// Extract string values from a property path, searching inside all values of an object.
173///
174/// Like [`extract_config_object_nested_string_or_array`] but returns a single optional string
175/// per object value (useful for fields like `architect.build.options.main`).
176pub fn extract_config_object_nested_strings(
177    source: &str,
178    path: &Path,
179    object_path: &[&str],
180    inner_path: &[&str],
181) -> Vec<String> {
182    extract_config_object_nested(source, path, object_path, |value_obj| {
183        get_nested_string_from_object(value_obj, inner_path).map(|s| vec![s])
184    })
185}
186
187/// Shared helper for object-nested extraction.
188///
189/// Navigates `object_path` to find an object expression, then for each property value
190/// that is itself an object, calls `extract_fn` to produce string values.
191fn extract_config_object_nested(
192    source: &str,
193    path: &Path,
194    object_path: &[&str],
195    extract_fn: impl Fn(&ObjectExpression<'_>) -> Option<Vec<String>>,
196) -> Vec<String> {
197    extract_from_source(source, path, |program| {
198        let obj = find_config_object(program)?;
199        let obj_expr = get_nested_expression(obj, object_path)?;
200        let Expression::ObjectExpression(target_obj) = obj_expr else {
201            return None;
202        };
203        let mut results = Vec::new();
204        for prop in &target_obj.properties {
205            if let ObjectPropertyKind::ObjectProperty(p) = prop
206                && let Expression::ObjectExpression(value_obj) = &p.value
207                && let Some(values) = extract_fn(value_obj)
208            {
209                results.extend(values);
210            }
211        }
212        if results.is_empty() {
213            None
214        } else {
215            Some(results)
216        }
217    })
218    .unwrap_or_default()
219}
220
221/// Extract `require('...')` call argument strings from a property's value.
222///
223/// Handles direct require calls and arrays containing require calls or tuples:
224/// - `plugins: [require('autoprefixer')]`
225/// - `plugins: [require('postcss-import'), [require('postcss-preset-env'), { ... }]]`
226pub fn extract_config_require_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
227    extract_from_source(source, path, |program| {
228        let obj = find_config_object(program)?;
229        let prop = find_property(obj, key)?;
230        Some(collect_require_sources(&prop.value))
231    })
232    .unwrap_or_default()
233}
234
235// ── Internal helpers ──────────────────────────────────────────────
236
237/// Parse source and run an extraction function on the AST.
238///
239/// JSON files (`.json`, `.jsonc`) are parsed as JavaScript expressions wrapped in
240/// parentheses to produce an AST compatible with `find_config_object`. The native
241/// JSON source type in Oxc produces a different AST structure that our helpers
242/// don't handle.
243fn extract_from_source<T>(
244    source: &str,
245    path: &Path,
246    extractor: impl FnOnce(&Program) -> Option<T>,
247) -> Option<T> {
248    let source_type = SourceType::from_path(path).unwrap_or_default();
249    let alloc = Allocator::default();
250
251    // For JSON files, wrap in parens and parse as JS so the AST matches
252    // what find_config_object expects (ExpressionStatement → ObjectExpression).
253    let is_json = path
254        .extension()
255        .is_some_and(|ext| ext == "json" || ext == "jsonc");
256    if is_json {
257        let wrapped = format!("({source})");
258        let parsed = Parser::new(&alloc, &wrapped, SourceType::mjs()).parse();
259        return extractor(&parsed.program);
260    }
261
262    let parsed = Parser::new(&alloc, source, source_type).parse();
263    extractor(&parsed.program)
264}
265
266/// Find the "config object" — the object expression in the default export or module.exports.
267///
268/// Handles these patterns:
269/// - `export default { ... }`
270/// - `export default defineConfig({ ... })`
271/// - `export default defineConfig(async () => ({ ... }))`
272/// - `module.exports = { ... }`
273/// - Top-level JSON object (for .json files)
274fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
275    for stmt in &program.body {
276        match stmt {
277            // export default { ... } or export default defineConfig({ ... })
278            Statement::ExportDefaultDeclaration(decl) => {
279                // ExportDefaultDeclarationKind inherits Expression variants directly
280                let expr: Option<&Expression> = match &decl.declaration {
281                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
282                        return Some(obj);
283                    }
284                    ExportDefaultDeclarationKind::CallExpression(_)
285                    | ExportDefaultDeclarationKind::ParenthesizedExpression(_) => {
286                        // Convert to expression reference for further extraction
287                        decl.declaration.as_expression()
288                    }
289                    _ => None,
290                };
291                if let Some(expr) = expr {
292                    return extract_object_from_expression(expr);
293                }
294            }
295            // module.exports = { ... }
296            Statement::ExpressionStatement(expr_stmt) => {
297                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
298                    && is_module_exports_target(&assign.left)
299                {
300                    return extract_object_from_expression(&assign.right);
301                }
302            }
303            _ => {}
304        }
305    }
306
307    // JSON files: the program body might be a single expression statement
308    // Also handles JSON wrapped in parens: `({ ... })` (used for tsconfig.json parsing)
309    if program.body.len() == 1
310        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
311    {
312        match &expr_stmt.expression {
313            Expression::ObjectExpression(obj) => return Some(obj),
314            Expression::ParenthesizedExpression(paren) => {
315                if let Expression::ObjectExpression(obj) = &paren.expression {
316                    return Some(obj);
317                }
318            }
319            _ => {}
320        }
321    }
322
323    None
324}
325
326/// Extract an `ObjectExpression` from an expression, handling wrapper patterns.
327fn extract_object_from_expression<'a>(
328    expr: &'a Expression<'a>,
329) -> Option<&'a ObjectExpression<'a>> {
330    match expr {
331        // Direct object: `{ ... }`
332        Expression::ObjectExpression(obj) => Some(obj),
333        // Factory call: `defineConfig({ ... })`
334        Expression::CallExpression(call) => {
335            // Look for the first object argument
336            for arg in &call.arguments {
337                match arg {
338                    Argument::ObjectExpression(obj) => return Some(obj),
339                    // Arrow function body: `defineConfig(() => ({ ... }))`
340                    Argument::ArrowFunctionExpression(arrow) => {
341                        if arrow.expression
342                            && !arrow.body.statements.is_empty()
343                            && let Statement::ExpressionStatement(expr_stmt) =
344                                &arrow.body.statements[0]
345                        {
346                            return extract_object_from_expression(&expr_stmt.expression);
347                        }
348                    }
349                    _ => {}
350                }
351            }
352            None
353        }
354        // Parenthesized: `({ ... })`
355        Expression::ParenthesizedExpression(paren) => {
356            extract_object_from_expression(&paren.expression)
357        }
358        _ => None,
359    }
360}
361
362/// Check if an assignment target is `module.exports`.
363fn is_module_exports_target(target: &AssignmentTarget) -> bool {
364    if let AssignmentTarget::StaticMemberExpression(member) = target
365        && let Expression::Identifier(obj) = &member.object
366    {
367        return obj.name == "module" && member.property.name == "exports";
368    }
369    false
370}
371
372/// Find a named property in an object expression.
373fn find_property<'a>(obj: &'a ObjectExpression<'a>, key: &str) -> Option<&'a ObjectProperty<'a>> {
374    for prop in &obj.properties {
375        if let ObjectPropertyKind::ObjectProperty(p) = prop
376            && property_key_matches(&p.key, key)
377        {
378            return Some(p);
379        }
380    }
381    None
382}
383
384/// Check if a property key matches a string.
385fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
386    match key {
387        PropertyKey::StaticIdentifier(id) => id.name == name,
388        PropertyKey::StringLiteral(s) => s.value == name,
389        _ => false,
390    }
391}
392
393/// Get a string value from an object property.
394fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
395    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
396}
397
398/// Get an array of strings from an object property.
399fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
400    find_property(obj, key)
401        .map(|p| expression_to_string_array(&p.value))
402        .unwrap_or_default()
403}
404
405/// Navigate a nested property path and get a string array.
406fn get_nested_string_array_from_object(
407    obj: &ObjectExpression,
408    path: &[&str],
409) -> Option<Vec<String>> {
410    if path.is_empty() {
411        return None;
412    }
413    if path.len() == 1 {
414        return Some(get_object_string_array_property(obj, path[0]));
415    }
416    // Navigate into nested object
417    let prop = find_property(obj, path[0])?;
418    if let Expression::ObjectExpression(nested) = &prop.value {
419        get_nested_string_array_from_object(nested, &path[1..])
420    } else {
421        None
422    }
423}
424
425/// Navigate a nested property path and get a string value.
426fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
427    if path.is_empty() {
428        return None;
429    }
430    if path.len() == 1 {
431        return get_object_string_property(obj, path[0]);
432    }
433    let prop = find_property(obj, path[0])?;
434    if let Expression::ObjectExpression(nested) = &prop.value {
435        get_nested_string_from_object(nested, &path[1..])
436    } else {
437        None
438    }
439}
440
441/// Convert an expression to a string if it's a string literal.
442fn expression_to_string(expr: &Expression) -> Option<String> {
443    match expr {
444        Expression::StringLiteral(s) => Some(s.value.to_string()),
445        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
446            // Template literal with no expressions: `\`value\``
447            t.quasis.first().map(|q| q.value.raw.to_string())
448        }
449        _ => None,
450    }
451}
452
453/// Convert an expression to a string array if it's an array of string literals.
454fn expression_to_string_array(expr: &Expression) -> Vec<String> {
455    match expr {
456        Expression::ArrayExpression(arr) => arr
457            .elements
458            .iter()
459            .filter_map(|el| match el {
460                ArrayExpressionElement::SpreadElement(_) => None,
461                _ => el.as_expression().and_then(expression_to_string),
462            })
463            .collect(),
464        _ => vec![],
465    }
466}
467
468/// Collect only top-level string values from an expression.
469///
470/// For arrays, extracts direct string elements and the first string element of sub-arrays
471/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
472fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
473    let mut values = Vec::new();
474    match expr {
475        Expression::StringLiteral(s) => {
476            values.push(s.value.to_string());
477        }
478        Expression::ArrayExpression(arr) => {
479            for el in &arr.elements {
480                if let Some(inner) = el.as_expression() {
481                    match inner {
482                        Expression::StringLiteral(s) => {
483                            values.push(s.value.to_string());
484                        }
485                        // Handle tuples: ["pkg-name", { options }] → extract first string
486                        Expression::ArrayExpression(sub_arr) => {
487                            if let Some(first) = sub_arr.elements.first()
488                                && let Some(first_expr) = first.as_expression()
489                                && let Some(s) = expression_to_string(first_expr)
490                            {
491                                values.push(s);
492                            }
493                        }
494                        _ => {}
495                    }
496                }
497            }
498        }
499        _ => {}
500    }
501    values
502}
503
504/// Recursively collect all string literal values from an expression tree.
505fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
506    match expr {
507        Expression::StringLiteral(s) => {
508            values.push(s.value.to_string());
509        }
510        Expression::ArrayExpression(arr) => {
511            for el in &arr.elements {
512                if let Some(expr) = el.as_expression() {
513                    collect_all_string_values(expr, values);
514                }
515            }
516        }
517        Expression::ObjectExpression(obj) => {
518            for prop in &obj.properties {
519                if let ObjectPropertyKind::ObjectProperty(p) = prop {
520                    collect_all_string_values(&p.value, values);
521                }
522            }
523        }
524        _ => {}
525    }
526}
527
528/// Convert a `PropertyKey` to a `String`.
529fn property_key_to_string(key: &PropertyKey) -> Option<String> {
530    match key {
531        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
532        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
533        _ => None,
534    }
535}
536
537/// Extract keys of an object at a nested property path.
538fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
539    if path.is_empty() {
540        return None;
541    }
542    let prop = find_property(obj, path[0])?;
543    if path.len() == 1 {
544        if let Expression::ObjectExpression(nested) = &prop.value {
545            let keys = nested
546                .properties
547                .iter()
548                .filter_map(|p| {
549                    if let ObjectPropertyKind::ObjectProperty(p) = p {
550                        property_key_to_string(&p.key)
551                    } else {
552                        None
553                    }
554                })
555                .collect();
556            return Some(keys);
557        }
558        return None;
559    }
560    if let Expression::ObjectExpression(nested) = &prop.value {
561        get_nested_object_keys(nested, &path[1..])
562    } else {
563        None
564    }
565}
566
567/// Navigate a nested property path and return the raw expression at the end.
568fn get_nested_expression<'a>(
569    obj: &'a ObjectExpression<'a>,
570    path: &[&str],
571) -> Option<&'a Expression<'a>> {
572    if path.is_empty() {
573        return None;
574    }
575    let prop = find_property(obj, path[0])?;
576    if path.len() == 1 {
577        return Some(&prop.value);
578    }
579    if let Expression::ObjectExpression(nested) = &prop.value {
580        get_nested_expression(nested, &path[1..])
581    } else {
582        None
583    }
584}
585
586/// Navigate a nested path and extract a string, string array, or object string values.
587fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
588    if path.is_empty() {
589        return None;
590    }
591    if path.len() == 1 {
592        let prop = find_property(obj, path[0])?;
593        return Some(expression_to_string_or_array(&prop.value));
594    }
595    let prop = find_property(obj, path[0])?;
596    if let Expression::ObjectExpression(nested) = &prop.value {
597        get_nested_string_or_array(nested, &path[1..])
598    } else {
599        None
600    }
601}
602
603/// Convert an expression to a `Vec<String>`, handling string, array, and object-with-string-values.
604fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
605    match expr {
606        Expression::StringLiteral(s) => vec![s.value.to_string()],
607        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
608            .quasis
609            .first()
610            .map(|q| vec![q.value.raw.to_string()])
611            .unwrap_or_default(),
612        Expression::ArrayExpression(arr) => arr
613            .elements
614            .iter()
615            .filter_map(|el| el.as_expression().and_then(expression_to_string))
616            .collect(),
617        Expression::ObjectExpression(obj) => obj
618            .properties
619            .iter()
620            .filter_map(|p| {
621                if let ObjectPropertyKind::ObjectProperty(p) = p {
622                    expression_to_string(&p.value)
623                } else {
624                    None
625                }
626            })
627            .collect(),
628        _ => vec![],
629    }
630}
631
632/// Collect `require('...')` argument strings from an expression.
633fn collect_require_sources(expr: &Expression) -> Vec<String> {
634    let mut sources = Vec::new();
635    match expr {
636        Expression::CallExpression(call) if is_require_call(call) => {
637            if let Some(s) = get_require_source(call) {
638                sources.push(s);
639            }
640        }
641        Expression::ArrayExpression(arr) => {
642            for el in &arr.elements {
643                if let Some(inner) = el.as_expression() {
644                    match inner {
645                        Expression::CallExpression(call) if is_require_call(call) => {
646                            if let Some(s) = get_require_source(call) {
647                                sources.push(s);
648                            }
649                        }
650                        // Tuple: [require('pkg'), options]
651                        Expression::ArrayExpression(sub_arr) => {
652                            if let Some(first) = sub_arr.elements.first()
653                                && let Some(Expression::CallExpression(call)) =
654                                    first.as_expression()
655                                && is_require_call(call)
656                                && let Some(s) = get_require_source(call)
657                            {
658                                sources.push(s);
659                            }
660                        }
661                        _ => {}
662                    }
663                }
664            }
665        }
666        _ => {}
667    }
668    sources
669}
670
671/// Check if a call expression is `require(...)`.
672fn is_require_call(call: &CallExpression) -> bool {
673    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
674}
675
676/// Get the first string argument of a `require()` call.
677fn get_require_source(call: &CallExpression) -> Option<String> {
678    call.arguments.first().and_then(|arg| {
679        if let Argument::StringLiteral(s) = arg {
680            Some(s.value.to_string())
681        } else {
682            None
683        }
684    })
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690    use std::path::PathBuf;
691
692    fn js_path() -> PathBuf {
693        PathBuf::from("config.js")
694    }
695
696    fn ts_path() -> PathBuf {
697        PathBuf::from("config.ts")
698    }
699
700    #[test]
701    fn extract_imports_basic() {
702        let source = r"
703            import foo from 'foo-pkg';
704            import { bar } from '@scope/bar';
705            export default {};
706        ";
707        let imports = extract_imports(source, &js_path());
708        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
709    }
710
711    #[test]
712    fn extract_default_export_object_property() {
713        let source = r#"export default { testDir: "./tests" };"#;
714        let val = extract_config_string(source, &js_path(), &["testDir"]);
715        assert_eq!(val, Some("./tests".to_string()));
716    }
717
718    #[test]
719    fn extract_define_config_property() {
720        let source = r#"
721            import { defineConfig } from 'vitest/config';
722            export default defineConfig({
723                test: {
724                    include: ["**/*.test.ts", "**/*.spec.ts"],
725                    setupFiles: ["./test/setup.ts"]
726                }
727            });
728        "#;
729        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
730        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
731
732        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
733        assert_eq!(setup, vec!["./test/setup.ts"]);
734    }
735
736    #[test]
737    fn extract_module_exports_property() {
738        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
739        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
740        assert_eq!(val, Some("jsdom".to_string()));
741    }
742
743    #[test]
744    fn extract_nested_string_array() {
745        let source = r#"
746            export default {
747                resolve: {
748                    alias: {
749                        "@": "./src"
750                    }
751                },
752                test: {
753                    include: ["src/**/*.test.ts"]
754                }
755            };
756        "#;
757        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
758        assert_eq!(include, vec!["src/**/*.test.ts"]);
759    }
760
761    #[test]
762    fn extract_addons_array() {
763        let source = r#"
764            export default {
765                addons: [
766                    "@storybook/addon-a11y",
767                    "@storybook/addon-docs",
768                    "@storybook/addon-links"
769                ]
770            };
771        "#;
772        let addons = extract_config_property_strings(source, &ts_path(), "addons");
773        assert_eq!(
774            addons,
775            vec![
776                "@storybook/addon-a11y",
777                "@storybook/addon-docs",
778                "@storybook/addon-links"
779            ]
780        );
781    }
782
783    #[test]
784    fn handle_empty_config() {
785        let source = "";
786        let result = extract_config_string(source, &js_path(), &["key"]);
787        assert_eq!(result, None);
788    }
789
790    // ── extract_config_object_keys tests ────────────────────────────
791
792    #[test]
793    fn object_keys_postcss_plugins() {
794        let source = r"
795            module.exports = {
796                plugins: {
797                    autoprefixer: {},
798                    tailwindcss: {},
799                    'postcss-import': {}
800                }
801            };
802        ";
803        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
804        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
805    }
806
807    #[test]
808    fn object_keys_nested_path() {
809        let source = r"
810            export default {
811                build: {
812                    plugins: {
813                        minify: {},
814                        compress: {}
815                    }
816                }
817            };
818        ";
819        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
820        assert_eq!(keys, vec!["minify", "compress"]);
821    }
822
823    #[test]
824    fn object_keys_empty_object() {
825        let source = r"export default { plugins: {} };";
826        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
827        assert!(keys.is_empty());
828    }
829
830    #[test]
831    fn object_keys_non_object_returns_empty() {
832        let source = r#"export default { plugins: ["a", "b"] };"#;
833        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
834        assert!(keys.is_empty());
835    }
836
837    // ── extract_config_string_or_array tests ────────────────────────
838
839    #[test]
840    fn string_or_array_single_string() {
841        let source = r#"export default { entry: "./src/index.js" };"#;
842        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
843        assert_eq!(result, vec!["./src/index.js"]);
844    }
845
846    #[test]
847    fn string_or_array_array() {
848        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
849        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
850        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
851    }
852
853    #[test]
854    fn string_or_array_object_values() {
855        let source =
856            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
857        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
858        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
859    }
860
861    #[test]
862    fn string_or_array_nested_path() {
863        let source = r#"
864            export default {
865                build: {
866                    rollupOptions: {
867                        input: ["./index.html", "./about.html"]
868                    }
869                }
870            };
871        "#;
872        let result = extract_config_string_or_array(
873            source,
874            &js_path(),
875            &["build", "rollupOptions", "input"],
876        );
877        assert_eq!(result, vec!["./index.html", "./about.html"]);
878    }
879
880    #[test]
881    fn string_or_array_template_literal() {
882        let source = r"export default { entry: `./src/index.js` };";
883        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
884        assert_eq!(result, vec!["./src/index.js"]);
885    }
886
887    // ── extract_config_require_strings tests ────────────────────────
888
889    #[test]
890    fn require_strings_array() {
891        let source = r"
892            module.exports = {
893                plugins: [
894                    require('autoprefixer'),
895                    require('postcss-import')
896                ]
897            };
898        ";
899        let deps = extract_config_require_strings(source, &js_path(), "plugins");
900        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
901    }
902
903    #[test]
904    fn require_strings_with_tuples() {
905        let source = r"
906            module.exports = {
907                plugins: [
908                    require('autoprefixer'),
909                    [require('postcss-preset-env'), { stage: 3 }]
910                ]
911            };
912        ";
913        let deps = extract_config_require_strings(source, &js_path(), "plugins");
914        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
915    }
916
917    #[test]
918    fn require_strings_empty_array() {
919        let source = r"module.exports = { plugins: [] };";
920        let deps = extract_config_require_strings(source, &js_path(), "plugins");
921        assert!(deps.is_empty());
922    }
923
924    #[test]
925    fn require_strings_no_require_calls() {
926        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
927        let deps = extract_config_require_strings(source, &js_path(), "plugins");
928        assert!(deps.is_empty());
929    }
930
931    // ── JSON wrapped in parens (for tsconfig.json parsing) ──────────
932
933    #[test]
934    fn json_wrapped_in_parens_string() {
935        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
936        let val = extract_config_string(source, &js_path(), &["extends"]);
937        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
938    }
939
940    #[test]
941    fn json_wrapped_in_parens_nested_array() {
942        let source =
943            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
944        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
945        assert_eq!(types, vec!["node", "jest"]);
946
947        let include = extract_config_string_array(source, &js_path(), &["include"]);
948        assert_eq!(include, vec!["src/**/*"]);
949    }
950
951    #[test]
952    fn json_wrapped_in_parens_object_keys() {
953        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
954        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
955        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
956    }
957}