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// ── Internal helpers ──────────────────────────────────────────────
86
87/// Parse source and run an extraction function on the AST.
88fn extract_from_source<T>(
89    source: &str,
90    path: &Path,
91    extractor: impl FnOnce(&Program) -> Option<T>,
92) -> Option<T> {
93    let source_type = SourceType::from_path(path).unwrap_or_default();
94    let alloc = Allocator::default();
95    let parsed = Parser::new(&alloc, source, source_type).parse();
96    extractor(&parsed.program)
97}
98
99/// Find the "config object" — the object expression in the default export or module.exports.
100///
101/// Handles these patterns:
102/// - `export default { ... }`
103/// - `export default defineConfig({ ... })`
104/// - `export default defineConfig(async () => ({ ... }))`
105/// - `module.exports = { ... }`
106/// - Top-level JSON object (for .json files)
107fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
108    for stmt in &program.body {
109        match stmt {
110            // export default { ... } or export default defineConfig({ ... })
111            Statement::ExportDefaultDeclaration(decl) => {
112                // ExportDefaultDeclarationKind inherits Expression variants directly
113                let expr: Option<&Expression> = match &decl.declaration {
114                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
115                        return Some(obj);
116                    }
117                    ExportDefaultDeclarationKind::CallExpression(_)
118                    | ExportDefaultDeclarationKind::ParenthesizedExpression(_) => {
119                        // Convert to expression reference for further extraction
120                        decl.declaration.as_expression()
121                    }
122                    _ => None,
123                };
124                if let Some(expr) = expr {
125                    return extract_object_from_expression(expr);
126                }
127            }
128            // module.exports = { ... }
129            Statement::ExpressionStatement(expr_stmt) => {
130                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
131                    && is_module_exports_target(&assign.left)
132                {
133                    return extract_object_from_expression(&assign.right);
134                }
135            }
136            _ => {}
137        }
138    }
139
140    // JSON files: the program body might be a single expression statement
141    if program.body.len() == 1
142        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
143        && let Expression::ObjectExpression(obj) = &expr_stmt.expression
144    {
145        return Some(obj);
146    }
147
148    None
149}
150
151/// Extract an ObjectExpression from an expression, handling wrapper patterns.
152fn extract_object_from_expression<'a>(
153    expr: &'a Expression<'a>,
154) -> Option<&'a ObjectExpression<'a>> {
155    match expr {
156        // Direct object: `{ ... }`
157        Expression::ObjectExpression(obj) => Some(obj),
158        // Factory call: `defineConfig({ ... })`
159        Expression::CallExpression(call) => {
160            // Look for the first object argument
161            for arg in &call.arguments {
162                match arg {
163                    Argument::ObjectExpression(obj) => return Some(obj),
164                    // Arrow function body: `defineConfig(() => ({ ... }))`
165                    Argument::ArrowFunctionExpression(arrow) => {
166                        if arrow.expression
167                            && !arrow.body.statements.is_empty()
168                            && let Statement::ExpressionStatement(expr_stmt) =
169                                &arrow.body.statements[0]
170                        {
171                            return extract_object_from_expression(&expr_stmt.expression);
172                        }
173                    }
174                    _ => {}
175                }
176            }
177            None
178        }
179        // Parenthesized: `({ ... })`
180        Expression::ParenthesizedExpression(paren) => {
181            extract_object_from_expression(&paren.expression)
182        }
183        _ => None,
184    }
185}
186
187/// Check if an assignment target is `module.exports`.
188fn is_module_exports_target(target: &AssignmentTarget) -> bool {
189    if let AssignmentTarget::StaticMemberExpression(member) = target
190        && let Expression::Identifier(obj) = &member.object
191    {
192        return obj.name == "module" && member.property.name == "exports";
193    }
194    false
195}
196
197/// Find a named property in an object expression.
198fn find_property<'a>(obj: &'a ObjectExpression<'a>, key: &str) -> Option<&'a ObjectProperty<'a>> {
199    for prop in &obj.properties {
200        if let ObjectPropertyKind::ObjectProperty(p) = prop
201            && property_key_matches(&p.key, key)
202        {
203            return Some(p);
204        }
205    }
206    None
207}
208
209/// Check if a property key matches a string.
210fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
211    match key {
212        PropertyKey::StaticIdentifier(id) => id.name == name,
213        PropertyKey::StringLiteral(s) => s.value == name,
214        _ => false,
215    }
216}
217
218/// Get a string value from an object property.
219fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
220    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
221}
222
223/// Get an array of strings from an object property.
224fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
225    find_property(obj, key)
226        .map(|p| expression_to_string_array(&p.value))
227        .unwrap_or_default()
228}
229
230/// Navigate a nested property path and get a string array.
231fn get_nested_string_array_from_object(
232    obj: &ObjectExpression,
233    path: &[&str],
234) -> Option<Vec<String>> {
235    if path.is_empty() {
236        return None;
237    }
238    if path.len() == 1 {
239        return Some(get_object_string_array_property(obj, path[0]));
240    }
241    // Navigate into nested object
242    let prop = find_property(obj, path[0])?;
243    if let Expression::ObjectExpression(nested) = &prop.value {
244        get_nested_string_array_from_object(nested, &path[1..])
245    } else {
246        None
247    }
248}
249
250/// Navigate a nested property path and get a string value.
251fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
252    if path.is_empty() {
253        return None;
254    }
255    if path.len() == 1 {
256        return get_object_string_property(obj, path[0]);
257    }
258    let prop = find_property(obj, path[0])?;
259    if let Expression::ObjectExpression(nested) = &prop.value {
260        get_nested_string_from_object(nested, &path[1..])
261    } else {
262        None
263    }
264}
265
266/// Convert an expression to a string if it's a string literal.
267fn expression_to_string(expr: &Expression) -> Option<String> {
268    match expr {
269        Expression::StringLiteral(s) => Some(s.value.to_string()),
270        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
271            // Template literal with no expressions: `\`value\``
272            t.quasis.first().map(|q| q.value.raw.to_string())
273        }
274        _ => None,
275    }
276}
277
278/// Convert an expression to a string array if it's an array of string literals.
279fn expression_to_string_array(expr: &Expression) -> Vec<String> {
280    match expr {
281        Expression::ArrayExpression(arr) => arr
282            .elements
283            .iter()
284            .filter_map(|el| match el {
285                ArrayExpressionElement::SpreadElement(_) => None,
286                _ => {
287                    if let Some(expr) = el.as_expression() {
288                        expression_to_string(expr)
289                    } else {
290                        None
291                    }
292                }
293            })
294            .collect(),
295        _ => vec![],
296    }
297}
298
299/// Collect only top-level string values from an expression.
300///
301/// For arrays, extracts direct string elements and the first string element of sub-arrays
302/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
303fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
304    let mut values = Vec::new();
305    match expr {
306        Expression::StringLiteral(s) => {
307            values.push(s.value.to_string());
308        }
309        Expression::ArrayExpression(arr) => {
310            for el in &arr.elements {
311                if let Some(inner) = el.as_expression() {
312                    match inner {
313                        Expression::StringLiteral(s) => {
314                            values.push(s.value.to_string());
315                        }
316                        // Handle tuples: ["pkg-name", { options }] → extract first string
317                        Expression::ArrayExpression(sub_arr) => {
318                            if let Some(first) = sub_arr.elements.first()
319                                && let Some(first_expr) = first.as_expression()
320                                && let Some(s) = expression_to_string(first_expr)
321                            {
322                                values.push(s);
323                            }
324                        }
325                        _ => {}
326                    }
327                }
328            }
329        }
330        _ => {}
331    }
332    values
333}
334
335/// Recursively collect all string literal values from an expression tree.
336fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
337    match expr {
338        Expression::StringLiteral(s) => {
339            values.push(s.value.to_string());
340        }
341        Expression::ArrayExpression(arr) => {
342            for el in &arr.elements {
343                if let Some(expr) = el.as_expression() {
344                    collect_all_string_values(expr, values);
345                }
346            }
347        }
348        Expression::ObjectExpression(obj) => {
349            for prop in &obj.properties {
350                if let ObjectPropertyKind::ObjectProperty(p) = prop {
351                    collect_all_string_values(&p.value, values);
352                }
353            }
354        }
355        _ => {}
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use std::path::PathBuf;
363
364    fn js_path() -> PathBuf {
365        PathBuf::from("config.js")
366    }
367
368    fn ts_path() -> PathBuf {
369        PathBuf::from("config.ts")
370    }
371
372    #[test]
373    fn extract_imports_basic() {
374        let source = r#"
375            import foo from 'foo-pkg';
376            import { bar } from '@scope/bar';
377            export default {};
378        "#;
379        let imports = extract_imports(source, &js_path());
380        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
381    }
382
383    #[test]
384    fn extract_default_export_object_property() {
385        let source = r#"export default { testDir: "./tests" };"#;
386        let val = extract_config_string(source, &js_path(), &["testDir"]);
387        assert_eq!(val, Some("./tests".to_string()));
388    }
389
390    #[test]
391    fn extract_define_config_property() {
392        let source = r#"
393            import { defineConfig } from 'vitest/config';
394            export default defineConfig({
395                test: {
396                    include: ["**/*.test.ts", "**/*.spec.ts"],
397                    setupFiles: ["./test/setup.ts"]
398                }
399            });
400        "#;
401        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
402        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
403
404        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
405        assert_eq!(setup, vec!["./test/setup.ts"]);
406    }
407
408    #[test]
409    fn extract_module_exports_property() {
410        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
411        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
412        assert_eq!(val, Some("jsdom".to_string()));
413    }
414
415    #[test]
416    fn extract_nested_string_array() {
417        let source = r#"
418            export default {
419                resolve: {
420                    alias: {
421                        "@": "./src"
422                    }
423                },
424                test: {
425                    include: ["src/**/*.test.ts"]
426                }
427            };
428        "#;
429        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
430        assert_eq!(include, vec!["src/**/*.test.ts"]);
431    }
432
433    #[test]
434    fn extract_addons_array() {
435        let source = r#"
436            export default {
437                addons: [
438                    "@storybook/addon-a11y",
439                    "@storybook/addon-docs",
440                    "@storybook/addon-links"
441                ]
442            };
443        "#;
444        let addons = extract_config_property_strings(source, &ts_path(), "addons");
445        assert_eq!(
446            addons,
447            vec![
448                "@storybook/addon-a11y",
449                "@storybook/addon-docs",
450                "@storybook/addon-links"
451            ]
452        );
453    }
454
455    #[test]
456    fn handle_empty_config() {
457        let source = "";
458        let result = extract_config_string(source, &js_path(), &["key"]);
459        assert_eq!(result, None);
460    }
461}