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