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