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                _ => el.as_expression().and_then(expression_to_string),
344            })
345            .collect(),
346        _ => vec![],
347    }
348}
349
350/// Collect only top-level string values from an expression.
351///
352/// For arrays, extracts direct string elements and the first string element of sub-arrays
353/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
354fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
355    let mut values = Vec::new();
356    match expr {
357        Expression::StringLiteral(s) => {
358            values.push(s.value.to_string());
359        }
360        Expression::ArrayExpression(arr) => {
361            for el in &arr.elements {
362                if let Some(inner) = el.as_expression() {
363                    match inner {
364                        Expression::StringLiteral(s) => {
365                            values.push(s.value.to_string());
366                        }
367                        // Handle tuples: ["pkg-name", { options }] → extract first string
368                        Expression::ArrayExpression(sub_arr) => {
369                            if let Some(first) = sub_arr.elements.first()
370                                && let Some(first_expr) = first.as_expression()
371                                && let Some(s) = expression_to_string(first_expr)
372                            {
373                                values.push(s);
374                            }
375                        }
376                        _ => {}
377                    }
378                }
379            }
380        }
381        _ => {}
382    }
383    values
384}
385
386/// Recursively collect all string literal values from an expression tree.
387fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
388    match expr {
389        Expression::StringLiteral(s) => {
390            values.push(s.value.to_string());
391        }
392        Expression::ArrayExpression(arr) => {
393            for el in &arr.elements {
394                if let Some(expr) = el.as_expression() {
395                    collect_all_string_values(expr, values);
396                }
397            }
398        }
399        Expression::ObjectExpression(obj) => {
400            for prop in &obj.properties {
401                if let ObjectPropertyKind::ObjectProperty(p) = prop {
402                    collect_all_string_values(&p.value, values);
403                }
404            }
405        }
406        _ => {}
407    }
408}
409
410/// Convert a `PropertyKey` to a `String`.
411fn property_key_to_string(key: &PropertyKey) -> Option<String> {
412    match key {
413        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
414        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
415        _ => None,
416    }
417}
418
419/// Extract keys of an object at a nested property path.
420fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
421    if path.is_empty() {
422        return None;
423    }
424    let prop = find_property(obj, path[0])?;
425    if path.len() == 1 {
426        if let Expression::ObjectExpression(nested) = &prop.value {
427            let keys = nested
428                .properties
429                .iter()
430                .filter_map(|p| {
431                    if let ObjectPropertyKind::ObjectProperty(p) = p {
432                        property_key_to_string(&p.key)
433                    } else {
434                        None
435                    }
436                })
437                .collect();
438            return Some(keys);
439        }
440        return None;
441    }
442    if let Expression::ObjectExpression(nested) = &prop.value {
443        get_nested_object_keys(nested, &path[1..])
444    } else {
445        None
446    }
447}
448
449/// Navigate a nested path and extract a string, string array, or object string values.
450fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
451    if path.is_empty() {
452        return None;
453    }
454    if path.len() == 1 {
455        let prop = find_property(obj, path[0])?;
456        return Some(expression_to_string_or_array(&prop.value));
457    }
458    let prop = find_property(obj, path[0])?;
459    if let Expression::ObjectExpression(nested) = &prop.value {
460        get_nested_string_or_array(nested, &path[1..])
461    } else {
462        None
463    }
464}
465
466/// Convert an expression to a `Vec<String>`, handling string, array, and object-with-string-values.
467fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
468    match expr {
469        Expression::StringLiteral(s) => vec![s.value.to_string()],
470        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
471            .quasis
472            .first()
473            .map(|q| vec![q.value.raw.to_string()])
474            .unwrap_or_default(),
475        Expression::ArrayExpression(arr) => arr
476            .elements
477            .iter()
478            .filter_map(|el| el.as_expression().and_then(expression_to_string))
479            .collect(),
480        Expression::ObjectExpression(obj) => obj
481            .properties
482            .iter()
483            .filter_map(|p| {
484                if let ObjectPropertyKind::ObjectProperty(p) = p {
485                    expression_to_string(&p.value)
486                } else {
487                    None
488                }
489            })
490            .collect(),
491        _ => vec![],
492    }
493}
494
495/// Collect `require('...')` argument strings from an expression.
496fn collect_require_sources(expr: &Expression) -> Vec<String> {
497    let mut sources = Vec::new();
498    match expr {
499        Expression::CallExpression(call) if is_require_call(call) => {
500            if let Some(s) = get_require_source(call) {
501                sources.push(s);
502            }
503        }
504        Expression::ArrayExpression(arr) => {
505            for el in &arr.elements {
506                if let Some(inner) = el.as_expression() {
507                    match inner {
508                        Expression::CallExpression(call) if is_require_call(call) => {
509                            if let Some(s) = get_require_source(call) {
510                                sources.push(s);
511                            }
512                        }
513                        // Tuple: [require('pkg'), options]
514                        Expression::ArrayExpression(sub_arr) => {
515                            if let Some(first) = sub_arr.elements.first()
516                                && let Some(Expression::CallExpression(call)) =
517                                    first.as_expression()
518                                && is_require_call(call)
519                                && let Some(s) = get_require_source(call)
520                            {
521                                sources.push(s);
522                            }
523                        }
524                        _ => {}
525                    }
526                }
527            }
528        }
529        _ => {}
530    }
531    sources
532}
533
534/// Check if a call expression is `require(...)`.
535fn is_require_call(call: &CallExpression) -> bool {
536    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
537}
538
539/// Get the first string argument of a `require()` call.
540fn get_require_source(call: &CallExpression) -> Option<String> {
541    call.arguments.first().and_then(|arg| {
542        if let Argument::StringLiteral(s) = arg {
543            Some(s.value.to_string())
544        } else {
545            None
546        }
547    })
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553    use std::path::PathBuf;
554
555    fn js_path() -> PathBuf {
556        PathBuf::from("config.js")
557    }
558
559    fn ts_path() -> PathBuf {
560        PathBuf::from("config.ts")
561    }
562
563    #[test]
564    fn extract_imports_basic() {
565        let source = r#"
566            import foo from 'foo-pkg';
567            import { bar } from '@scope/bar';
568            export default {};
569        "#;
570        let imports = extract_imports(source, &js_path());
571        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
572    }
573
574    #[test]
575    fn extract_default_export_object_property() {
576        let source = r#"export default { testDir: "./tests" };"#;
577        let val = extract_config_string(source, &js_path(), &["testDir"]);
578        assert_eq!(val, Some("./tests".to_string()));
579    }
580
581    #[test]
582    fn extract_define_config_property() {
583        let source = r#"
584            import { defineConfig } from 'vitest/config';
585            export default defineConfig({
586                test: {
587                    include: ["**/*.test.ts", "**/*.spec.ts"],
588                    setupFiles: ["./test/setup.ts"]
589                }
590            });
591        "#;
592        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
593        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
594
595        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
596        assert_eq!(setup, vec!["./test/setup.ts"]);
597    }
598
599    #[test]
600    fn extract_module_exports_property() {
601        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
602        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
603        assert_eq!(val, Some("jsdom".to_string()));
604    }
605
606    #[test]
607    fn extract_nested_string_array() {
608        let source = r#"
609            export default {
610                resolve: {
611                    alias: {
612                        "@": "./src"
613                    }
614                },
615                test: {
616                    include: ["src/**/*.test.ts"]
617                }
618            };
619        "#;
620        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
621        assert_eq!(include, vec!["src/**/*.test.ts"]);
622    }
623
624    #[test]
625    fn extract_addons_array() {
626        let source = r#"
627            export default {
628                addons: [
629                    "@storybook/addon-a11y",
630                    "@storybook/addon-docs",
631                    "@storybook/addon-links"
632                ]
633            };
634        "#;
635        let addons = extract_config_property_strings(source, &ts_path(), "addons");
636        assert_eq!(
637            addons,
638            vec![
639                "@storybook/addon-a11y",
640                "@storybook/addon-docs",
641                "@storybook/addon-links"
642            ]
643        );
644    }
645
646    #[test]
647    fn handle_empty_config() {
648        let source = "";
649        let result = extract_config_string(source, &js_path(), &["key"]);
650        assert_eq!(result, None);
651    }
652
653    // ── extract_config_object_keys tests ────────────────────────────
654
655    #[test]
656    fn object_keys_postcss_plugins() {
657        let source = r#"
658            module.exports = {
659                plugins: {
660                    autoprefixer: {},
661                    tailwindcss: {},
662                    'postcss-import': {}
663                }
664            };
665        "#;
666        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
667        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
668    }
669
670    #[test]
671    fn object_keys_nested_path() {
672        let source = r#"
673            export default {
674                build: {
675                    plugins: {
676                        minify: {},
677                        compress: {}
678                    }
679                }
680            };
681        "#;
682        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
683        assert_eq!(keys, vec!["minify", "compress"]);
684    }
685
686    #[test]
687    fn object_keys_empty_object() {
688        let source = r#"export default { plugins: {} };"#;
689        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
690        assert!(keys.is_empty());
691    }
692
693    #[test]
694    fn object_keys_non_object_returns_empty() {
695        let source = r#"export default { plugins: ["a", "b"] };"#;
696        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
697        assert!(keys.is_empty());
698    }
699
700    // ── extract_config_string_or_array tests ────────────────────────
701
702    #[test]
703    fn string_or_array_single_string() {
704        let source = r#"export default { entry: "./src/index.js" };"#;
705        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
706        assert_eq!(result, vec!["./src/index.js"]);
707    }
708
709    #[test]
710    fn string_or_array_array() {
711        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
712        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
713        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
714    }
715
716    #[test]
717    fn string_or_array_object_values() {
718        let source =
719            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
720        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
721        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
722    }
723
724    #[test]
725    fn string_or_array_nested_path() {
726        let source = r#"
727            export default {
728                build: {
729                    rollupOptions: {
730                        input: ["./index.html", "./about.html"]
731                    }
732                }
733            };
734        "#;
735        let result = extract_config_string_or_array(
736            source,
737            &js_path(),
738            &["build", "rollupOptions", "input"],
739        );
740        assert_eq!(result, vec!["./index.html", "./about.html"]);
741    }
742
743    #[test]
744    fn string_or_array_template_literal() {
745        let source = r#"export default { entry: `./src/index.js` };"#;
746        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
747        assert_eq!(result, vec!["./src/index.js"]);
748    }
749
750    // ── extract_config_require_strings tests ────────────────────────
751
752    #[test]
753    fn require_strings_array() {
754        let source = r#"
755            module.exports = {
756                plugins: [
757                    require('autoprefixer'),
758                    require('postcss-import')
759                ]
760            };
761        "#;
762        let deps = extract_config_require_strings(source, &js_path(), "plugins");
763        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
764    }
765
766    #[test]
767    fn require_strings_with_tuples() {
768        let source = r#"
769            module.exports = {
770                plugins: [
771                    require('autoprefixer'),
772                    [require('postcss-preset-env'), { stage: 3 }]
773                ]
774            };
775        "#;
776        let deps = extract_config_require_strings(source, &js_path(), "plugins");
777        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
778    }
779
780    #[test]
781    fn require_strings_empty_array() {
782        let source = r#"module.exports = { plugins: [] };"#;
783        let deps = extract_config_require_strings(source, &js_path(), "plugins");
784        assert!(deps.is_empty());
785    }
786
787    #[test]
788    fn require_strings_no_require_calls() {
789        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
790        let deps = extract_config_require_strings(source, &js_path(), "plugins");
791        assert!(deps.is_empty());
792    }
793
794    // ── JSON wrapped in parens (for tsconfig.json parsing) ──────────
795
796    #[test]
797    fn json_wrapped_in_parens_string() {
798        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
799        let val = extract_config_string(source, &js_path(), &["extends"]);
800        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
801    }
802
803    #[test]
804    fn json_wrapped_in_parens_nested_array() {
805        let source =
806            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
807        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
808        assert_eq!(types, vec!["node", "jest"]);
809
810        let include = extract_config_string_array(source, &js_path(), &["include"]);
811        assert_eq!(include, vec!["src/**/*"]);
812    }
813
814    #[test]
815    fn json_wrapped_in_parens_object_keys() {
816        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
817        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
818        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
819    }
820}