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;
17#[allow(clippy::wildcard_imports, reason = "many AST types used")]
18use oxc_ast::ast::*;
19use oxc_parser::Parser;
20use oxc_span::SourceType;
21
22/// Extract all import source specifiers from JS/TS source code.
23#[must_use]
24pub fn extract_imports(source: &str, path: &Path) -> Vec<String> {
25    extract_from_source(source, path, |program| {
26        let mut sources = Vec::new();
27        for stmt in &program.body {
28            if let Statement::ImportDeclaration(decl) = stmt {
29                sources.push(decl.source.value.to_string());
30            }
31        }
32        Some(sources)
33    })
34    .unwrap_or_default()
35}
36
37/// Extract string array from a property at a nested path in a config's default export.
38#[must_use]
39pub fn extract_config_string_array(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
40    extract_from_source(source, path, |program| {
41        let obj = find_config_object(program)?;
42        get_nested_string_array_from_object(obj, prop_path)
43    })
44    .unwrap_or_default()
45}
46
47/// Extract a single string from a property at a nested path.
48#[must_use]
49pub fn extract_config_string(source: &str, path: &Path, prop_path: &[&str]) -> Option<String> {
50    extract_from_source(source, path, |program| {
51        let obj = find_config_object(program)?;
52        get_nested_string_from_object(obj, prop_path)
53    })
54}
55
56/// Extract string values from top-level properties of the default export/module.exports object.
57/// Returns all string literal values found for the given property key, recursively.
58///
59/// **Warning**: This recurses into nested objects/arrays. For config arrays that contain
60/// tuples like `["pkg-name", { options }]`, use [`extract_config_shallow_strings`] instead
61/// to avoid extracting option values as package names.
62#[must_use]
63pub fn extract_config_property_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
64    extract_from_source(source, path, |program| {
65        let obj = find_config_object(program)?;
66        let mut values = Vec::new();
67        if let Some(prop) = find_property(obj, key) {
68            collect_all_string_values(&prop.value, &mut values);
69        }
70        Some(values)
71    })
72    .unwrap_or_default()
73}
74
75/// Extract only top-level string values from a property's array.
76///
77/// Unlike [`extract_config_property_strings`], this does NOT recurse into nested
78/// objects or sub-arrays. Useful for config arrays with tuple elements like:
79/// `reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]`
80/// — only `"default"` and `"jest-junit"` are returned, not `"reports"`.
81#[must_use]
82pub fn extract_config_shallow_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
83    extract_from_source(source, path, |program| {
84        let obj = find_config_object(program)?;
85        let prop = find_property(obj, key)?;
86        Some(collect_shallow_string_values(&prop.value))
87    })
88    .unwrap_or_default()
89}
90
91/// Extract shallow strings from an array property inside a nested object path.
92///
93/// Navigates `outer_path` to find a nested object, then extracts shallow strings
94/// from the `key` property. Useful for configs like Vitest where reporters are at
95/// `test.reporters`: `{ test: { reporters: ["default", ["vitest-sonar-reporter", {...}]] } }`.
96#[must_use]
97pub fn extract_config_nested_shallow_strings(
98    source: &str,
99    path: &Path,
100    outer_path: &[&str],
101    key: &str,
102) -> Vec<String> {
103    extract_from_source(source, path, |program| {
104        let obj = find_config_object(program)?;
105        let nested = get_nested_expression(obj, outer_path)?;
106        if let Expression::ObjectExpression(nested_obj) = nested {
107            let prop = find_property(nested_obj, key)?;
108            Some(collect_shallow_string_values(&prop.value))
109        } else {
110            None
111        }
112    })
113    .unwrap_or_default()
114}
115
116/// Public wrapper for `find_config_object` for plugins that need manual AST walking.
117pub fn find_config_object_pub<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
118    find_config_object(program)
119}
120
121/// Extract keys of an object property at a nested path.
122///
123/// Useful for `PostCSS` config: `{ plugins: { autoprefixer: {}, tailwindcss: {} } }`
124/// → returns `["autoprefixer", "tailwindcss"]`.
125#[must_use]
126pub fn extract_config_object_keys(source: &str, path: &Path, prop_path: &[&str]) -> Vec<String> {
127    extract_from_source(source, path, |program| {
128        let obj = find_config_object(program)?;
129        get_nested_object_keys(obj, prop_path)
130    })
131    .unwrap_or_default()
132}
133
134/// Extract a value that may be a single string, a string array, or an object with string values.
135///
136/// Useful for Webpack `entry`, Rollup `input`, etc. that accept multiple formats:
137/// - `entry: "./src/index.js"` → `["./src/index.js"]`
138/// - `entry: ["./src/a.js", "./src/b.js"]` → `["./src/a.js", "./src/b.js"]`
139/// - `entry: { main: "./src/main.js" }` → `["./src/main.js"]`
140#[must_use]
141pub fn extract_config_string_or_array(
142    source: &str,
143    path: &Path,
144    prop_path: &[&str],
145) -> Vec<String> {
146    extract_from_source(source, path, |program| {
147        let obj = find_config_object(program)?;
148        get_nested_string_or_array(obj, prop_path)
149    })
150    .unwrap_or_default()
151}
152
153/// Extract string values from a property path, also searching inside array elements.
154///
155/// Navigates `array_path` to find an array expression, then for each object in the
156/// array, navigates `inner_path` to extract string values. Useful for configs like
157/// Vitest projects where values are nested in array elements:
158/// - `test.projects[*].test.setupFiles`
159#[must_use]
160pub fn extract_config_array_nested_string_or_array(
161    source: &str,
162    path: &Path,
163    array_path: &[&str],
164    inner_path: &[&str],
165) -> Vec<String> {
166    extract_from_source(source, path, |program| {
167        let obj = find_config_object(program)?;
168        let array_expr = get_nested_expression(obj, array_path)?;
169        let Expression::ArrayExpression(arr) = array_expr else {
170            return None;
171        };
172        let mut results = Vec::new();
173        for element in &arr.elements {
174            if let Some(Expression::ObjectExpression(element_obj)) = element.as_expression()
175                && let Some(values) = get_nested_string_or_array(element_obj, inner_path)
176            {
177                results.extend(values);
178            }
179        }
180        if results.is_empty() {
181            None
182        } else {
183            Some(results)
184        }
185    })
186    .unwrap_or_default()
187}
188
189/// Extract string values from a property path, searching inside all values of an object.
190///
191/// Navigates `object_path` to find an object expression, then for each property value
192/// (regardless of key name), navigates `inner_path` to extract string values. Useful for
193/// configs with dynamic keys like `angular.json`:
194/// - `projects.*.architect.build.options.styles`
195#[must_use]
196pub fn extract_config_object_nested_string_or_array(
197    source: &str,
198    path: &Path,
199    object_path: &[&str],
200    inner_path: &[&str],
201) -> Vec<String> {
202    extract_config_object_nested(source, path, object_path, |value_obj| {
203        get_nested_string_or_array(value_obj, inner_path)
204    })
205}
206
207/// Extract string values from a property path, searching inside all values of an object.
208///
209/// Like [`extract_config_object_nested_string_or_array`] but returns a single optional string
210/// per object value (useful for fields like `architect.build.options.main`).
211#[must_use]
212pub fn extract_config_object_nested_strings(
213    source: &str,
214    path: &Path,
215    object_path: &[&str],
216    inner_path: &[&str],
217) -> Vec<String> {
218    extract_config_object_nested(source, path, object_path, |value_obj| {
219        get_nested_string_from_object(value_obj, inner_path).map(|s| vec![s])
220    })
221}
222
223/// Shared helper for object-nested extraction.
224///
225/// Navigates `object_path` to find an object expression, then for each property value
226/// that is itself an object, calls `extract_fn` to produce string values.
227fn extract_config_object_nested(
228    source: &str,
229    path: &Path,
230    object_path: &[&str],
231    extract_fn: impl Fn(&ObjectExpression<'_>) -> Option<Vec<String>>,
232) -> Vec<String> {
233    extract_from_source(source, path, |program| {
234        let obj = find_config_object(program)?;
235        let obj_expr = get_nested_expression(obj, object_path)?;
236        let Expression::ObjectExpression(target_obj) = obj_expr else {
237            return None;
238        };
239        let mut results = Vec::new();
240        for prop in &target_obj.properties {
241            if let ObjectPropertyKind::ObjectProperty(p) = prop
242                && let Expression::ObjectExpression(value_obj) = &p.value
243                && let Some(values) = extract_fn(value_obj)
244            {
245                results.extend(values);
246            }
247        }
248        if results.is_empty() {
249            None
250        } else {
251            Some(results)
252        }
253    })
254    .unwrap_or_default()
255}
256
257/// Extract `require('...')` call argument strings from a property's value.
258///
259/// Handles direct require calls and arrays containing require calls or tuples:
260/// - `plugins: [require('autoprefixer')]`
261/// - `plugins: [require('postcss-import'), [require('postcss-preset-env'), { ... }]]`
262#[must_use]
263pub fn extract_config_require_strings(source: &str, path: &Path, key: &str) -> Vec<String> {
264    extract_from_source(source, path, |program| {
265        let obj = find_config_object(program)?;
266        let prop = find_property(obj, key)?;
267        Some(collect_require_sources(&prop.value))
268    })
269    .unwrap_or_default()
270}
271
272// ── Internal helpers ──────────────────────────────────────────────
273
274/// Parse source and run an extraction function on the AST.
275///
276/// JSON files (`.json`, `.jsonc`) are parsed as JavaScript expressions wrapped in
277/// parentheses to produce an AST compatible with `find_config_object`. The native
278/// JSON source type in Oxc produces a different AST structure that our helpers
279/// don't handle.
280fn extract_from_source<T>(
281    source: &str,
282    path: &Path,
283    extractor: impl FnOnce(&Program) -> Option<T>,
284) -> Option<T> {
285    let source_type = SourceType::from_path(path).unwrap_or_default();
286    let alloc = Allocator::default();
287
288    // For JSON files, wrap in parens and parse as JS so the AST matches
289    // what find_config_object expects (ExpressionStatement → ObjectExpression).
290    let is_json = path
291        .extension()
292        .is_some_and(|ext| ext == "json" || ext == "jsonc");
293    if is_json {
294        let wrapped = format!("({source})");
295        let parsed = Parser::new(&alloc, &wrapped, SourceType::mjs()).parse();
296        return extractor(&parsed.program);
297    }
298
299    let parsed = Parser::new(&alloc, source, source_type).parse();
300    extractor(&parsed.program)
301}
302
303/// Find the "config object" — the object expression in the default export or module.exports.
304///
305/// Handles these patterns:
306/// - `export default { ... }`
307/// - `export default defineConfig({ ... })`
308/// - `export default defineConfig(async () => ({ ... }))`
309/// - `module.exports = { ... }`
310/// - Top-level JSON object (for .json files)
311fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
312    for stmt in &program.body {
313        match stmt {
314            // export default { ... } or export default defineConfig({ ... })
315            Statement::ExportDefaultDeclaration(decl) => {
316                // ExportDefaultDeclarationKind inherits Expression variants directly
317                let expr: Option<&Expression> = match &decl.declaration {
318                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
319                        return Some(obj);
320                    }
321                    ExportDefaultDeclarationKind::CallExpression(_)
322                    | ExportDefaultDeclarationKind::ParenthesizedExpression(_) => {
323                        // Convert to expression reference for further extraction
324                        decl.declaration.as_expression()
325                    }
326                    _ => None,
327                };
328                if let Some(expr) = expr {
329                    return extract_object_from_expression(expr);
330                }
331            }
332            // module.exports = { ... }
333            Statement::ExpressionStatement(expr_stmt) => {
334                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
335                    && is_module_exports_target(&assign.left)
336                {
337                    return extract_object_from_expression(&assign.right);
338                }
339            }
340            _ => {}
341        }
342    }
343
344    // JSON files: the program body might be a single expression statement
345    // Also handles JSON wrapped in parens: `({ ... })` (used for tsconfig.json parsing)
346    if program.body.len() == 1
347        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
348    {
349        match &expr_stmt.expression {
350            Expression::ObjectExpression(obj) => return Some(obj),
351            Expression::ParenthesizedExpression(paren) => {
352                if let Expression::ObjectExpression(obj) = &paren.expression {
353                    return Some(obj);
354                }
355            }
356            _ => {}
357        }
358    }
359
360    None
361}
362
363/// Extract an `ObjectExpression` from an expression, handling wrapper patterns.
364fn extract_object_from_expression<'a>(
365    expr: &'a Expression<'a>,
366) -> Option<&'a ObjectExpression<'a>> {
367    match expr {
368        // Direct object: `{ ... }`
369        Expression::ObjectExpression(obj) => Some(obj),
370        // Factory call: `defineConfig({ ... })`
371        Expression::CallExpression(call) => {
372            // Look for the first object argument
373            for arg in &call.arguments {
374                match arg {
375                    Argument::ObjectExpression(obj) => return Some(obj),
376                    // Arrow function body: `defineConfig(() => ({ ... }))`
377                    Argument::ArrowFunctionExpression(arrow) => {
378                        if arrow.expression
379                            && !arrow.body.statements.is_empty()
380                            && let Statement::ExpressionStatement(expr_stmt) =
381                                &arrow.body.statements[0]
382                        {
383                            return extract_object_from_expression(&expr_stmt.expression);
384                        }
385                    }
386                    _ => {}
387                }
388            }
389            None
390        }
391        // Parenthesized: `({ ... })`
392        Expression::ParenthesizedExpression(paren) => {
393            extract_object_from_expression(&paren.expression)
394        }
395        _ => None,
396    }
397}
398
399/// Check if an assignment target is `module.exports`.
400fn is_module_exports_target(target: &AssignmentTarget) -> bool {
401    if let AssignmentTarget::StaticMemberExpression(member) = target
402        && let Expression::Identifier(obj) = &member.object
403    {
404        return obj.name == "module" && member.property.name == "exports";
405    }
406    false
407}
408
409/// Find a named property in an object expression.
410fn find_property<'a>(obj: &'a ObjectExpression<'a>, key: &str) -> Option<&'a ObjectProperty<'a>> {
411    for prop in &obj.properties {
412        if let ObjectPropertyKind::ObjectProperty(p) = prop
413            && property_key_matches(&p.key, key)
414        {
415            return Some(p);
416        }
417    }
418    None
419}
420
421/// Check if a property key matches a string.
422fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
423    match key {
424        PropertyKey::StaticIdentifier(id) => id.name == name,
425        PropertyKey::StringLiteral(s) => s.value == name,
426        _ => false,
427    }
428}
429
430/// Get a string value from an object property.
431fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
432    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
433}
434
435/// Get an array of strings from an object property.
436fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
437    find_property(obj, key)
438        .map(|p| expression_to_string_array(&p.value))
439        .unwrap_or_default()
440}
441
442/// Navigate a nested property path and get a string array.
443fn get_nested_string_array_from_object(
444    obj: &ObjectExpression,
445    path: &[&str],
446) -> Option<Vec<String>> {
447    if path.is_empty() {
448        return None;
449    }
450    if path.len() == 1 {
451        return Some(get_object_string_array_property(obj, path[0]));
452    }
453    // Navigate into nested object
454    let prop = find_property(obj, path[0])?;
455    if let Expression::ObjectExpression(nested) = &prop.value {
456        get_nested_string_array_from_object(nested, &path[1..])
457    } else {
458        None
459    }
460}
461
462/// Navigate a nested property path and get a string value.
463fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
464    if path.is_empty() {
465        return None;
466    }
467    if path.len() == 1 {
468        return get_object_string_property(obj, path[0]);
469    }
470    let prop = find_property(obj, path[0])?;
471    if let Expression::ObjectExpression(nested) = &prop.value {
472        get_nested_string_from_object(nested, &path[1..])
473    } else {
474        None
475    }
476}
477
478/// Convert an expression to a string if it's a string literal.
479fn expression_to_string(expr: &Expression) -> Option<String> {
480    match expr {
481        Expression::StringLiteral(s) => Some(s.value.to_string()),
482        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
483            // Template literal with no expressions: `\`value\``
484            t.quasis.first().map(|q| q.value.raw.to_string())
485        }
486        _ => None,
487    }
488}
489
490/// Convert an expression to a string array if it's an array of string literals.
491fn expression_to_string_array(expr: &Expression) -> Vec<String> {
492    match expr {
493        Expression::ArrayExpression(arr) => arr
494            .elements
495            .iter()
496            .filter_map(|el| match el {
497                ArrayExpressionElement::SpreadElement(_) => None,
498                _ => el.as_expression().and_then(expression_to_string),
499            })
500            .collect(),
501        _ => vec![],
502    }
503}
504
505/// Collect only top-level string values from an expression.
506///
507/// For arrays, extracts direct string elements and the first string element of sub-arrays
508/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
509fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
510    let mut values = Vec::new();
511    match expr {
512        Expression::StringLiteral(s) => {
513            values.push(s.value.to_string());
514        }
515        Expression::ArrayExpression(arr) => {
516            for el in &arr.elements {
517                if let Some(inner) = el.as_expression() {
518                    match inner {
519                        Expression::StringLiteral(s) => {
520                            values.push(s.value.to_string());
521                        }
522                        // Handle tuples: ["pkg-name", { options }] → extract first string
523                        Expression::ArrayExpression(sub_arr) => {
524                            if let Some(first) = sub_arr.elements.first()
525                                && let Some(first_expr) = first.as_expression()
526                                && let Some(s) = expression_to_string(first_expr)
527                            {
528                                values.push(s);
529                            }
530                        }
531                        _ => {}
532                    }
533                }
534            }
535        }
536        _ => {}
537    }
538    values
539}
540
541/// Recursively collect all string literal values from an expression tree.
542fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
543    match expr {
544        Expression::StringLiteral(s) => {
545            values.push(s.value.to_string());
546        }
547        Expression::ArrayExpression(arr) => {
548            for el in &arr.elements {
549                if let Some(expr) = el.as_expression() {
550                    collect_all_string_values(expr, values);
551                }
552            }
553        }
554        Expression::ObjectExpression(obj) => {
555            for prop in &obj.properties {
556                if let ObjectPropertyKind::ObjectProperty(p) = prop {
557                    collect_all_string_values(&p.value, values);
558                }
559            }
560        }
561        _ => {}
562    }
563}
564
565/// Convert a `PropertyKey` to a `String`.
566fn property_key_to_string(key: &PropertyKey) -> Option<String> {
567    match key {
568        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
569        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
570        _ => None,
571    }
572}
573
574/// Extract keys of an object at a nested property path.
575fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
576    if path.is_empty() {
577        return None;
578    }
579    let prop = find_property(obj, path[0])?;
580    if path.len() == 1 {
581        if let Expression::ObjectExpression(nested) = &prop.value {
582            let keys = nested
583                .properties
584                .iter()
585                .filter_map(|p| {
586                    if let ObjectPropertyKind::ObjectProperty(p) = p {
587                        property_key_to_string(&p.key)
588                    } else {
589                        None
590                    }
591                })
592                .collect();
593            return Some(keys);
594        }
595        return None;
596    }
597    if let Expression::ObjectExpression(nested) = &prop.value {
598        get_nested_object_keys(nested, &path[1..])
599    } else {
600        None
601    }
602}
603
604/// Navigate a nested property path and return the raw expression at the end.
605fn get_nested_expression<'a>(
606    obj: &'a ObjectExpression<'a>,
607    path: &[&str],
608) -> Option<&'a Expression<'a>> {
609    if path.is_empty() {
610        return None;
611    }
612    let prop = find_property(obj, path[0])?;
613    if path.len() == 1 {
614        return Some(&prop.value);
615    }
616    if let Expression::ObjectExpression(nested) = &prop.value {
617        get_nested_expression(nested, &path[1..])
618    } else {
619        None
620    }
621}
622
623/// Navigate a nested path and extract a string, string array, or object string values.
624fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
625    if path.is_empty() {
626        return None;
627    }
628    if path.len() == 1 {
629        let prop = find_property(obj, path[0])?;
630        return Some(expression_to_string_or_array(&prop.value));
631    }
632    let prop = find_property(obj, path[0])?;
633    if let Expression::ObjectExpression(nested) = &prop.value {
634        get_nested_string_or_array(nested, &path[1..])
635    } else {
636        None
637    }
638}
639
640/// Convert an expression to a `Vec<String>`, handling string, array, and object-with-string-values.
641fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
642    match expr {
643        Expression::StringLiteral(s) => vec![s.value.to_string()],
644        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
645            .quasis
646            .first()
647            .map(|q| vec![q.value.raw.to_string()])
648            .unwrap_or_default(),
649        Expression::ArrayExpression(arr) => arr
650            .elements
651            .iter()
652            .filter_map(|el| el.as_expression().and_then(expression_to_string))
653            .collect(),
654        Expression::ObjectExpression(obj) => obj
655            .properties
656            .iter()
657            .filter_map(|p| {
658                if let ObjectPropertyKind::ObjectProperty(p) = p {
659                    expression_to_string(&p.value)
660                } else {
661                    None
662                }
663            })
664            .collect(),
665        _ => vec![],
666    }
667}
668
669/// Collect `require('...')` argument strings from an expression.
670fn collect_require_sources(expr: &Expression) -> Vec<String> {
671    let mut sources = Vec::new();
672    match expr {
673        Expression::CallExpression(call) if is_require_call(call) => {
674            if let Some(s) = get_require_source(call) {
675                sources.push(s);
676            }
677        }
678        Expression::ArrayExpression(arr) => {
679            for el in &arr.elements {
680                if let Some(inner) = el.as_expression() {
681                    match inner {
682                        Expression::CallExpression(call) if is_require_call(call) => {
683                            if let Some(s) = get_require_source(call) {
684                                sources.push(s);
685                            }
686                        }
687                        // Tuple: [require('pkg'), options]
688                        Expression::ArrayExpression(sub_arr) => {
689                            if let Some(first) = sub_arr.elements.first()
690                                && let Some(Expression::CallExpression(call)) =
691                                    first.as_expression()
692                                && is_require_call(call)
693                                && let Some(s) = get_require_source(call)
694                            {
695                                sources.push(s);
696                            }
697                        }
698                        _ => {}
699                    }
700                }
701            }
702        }
703        _ => {}
704    }
705    sources
706}
707
708/// Check if a call expression is `require(...)`.
709fn is_require_call(call: &CallExpression) -> bool {
710    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
711}
712
713/// Get the first string argument of a `require()` call.
714fn get_require_source(call: &CallExpression) -> Option<String> {
715    call.arguments.first().and_then(|arg| {
716        if let Argument::StringLiteral(s) = arg {
717            Some(s.value.to_string())
718        } else {
719            None
720        }
721    })
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727    use std::path::PathBuf;
728
729    fn js_path() -> PathBuf {
730        PathBuf::from("config.js")
731    }
732
733    fn ts_path() -> PathBuf {
734        PathBuf::from("config.ts")
735    }
736
737    #[test]
738    fn extract_imports_basic() {
739        let source = r"
740            import foo from 'foo-pkg';
741            import { bar } from '@scope/bar';
742            export default {};
743        ";
744        let imports = extract_imports(source, &js_path());
745        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
746    }
747
748    #[test]
749    fn extract_default_export_object_property() {
750        let source = r#"export default { testDir: "./tests" };"#;
751        let val = extract_config_string(source, &js_path(), &["testDir"]);
752        assert_eq!(val, Some("./tests".to_string()));
753    }
754
755    #[test]
756    fn extract_define_config_property() {
757        let source = r#"
758            import { defineConfig } from 'vitest/config';
759            export default defineConfig({
760                test: {
761                    include: ["**/*.test.ts", "**/*.spec.ts"],
762                    setupFiles: ["./test/setup.ts"]
763                }
764            });
765        "#;
766        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
767        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
768
769        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
770        assert_eq!(setup, vec!["./test/setup.ts"]);
771    }
772
773    #[test]
774    fn extract_module_exports_property() {
775        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
776        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
777        assert_eq!(val, Some("jsdom".to_string()));
778    }
779
780    #[test]
781    fn extract_nested_string_array() {
782        let source = r#"
783            export default {
784                resolve: {
785                    alias: {
786                        "@": "./src"
787                    }
788                },
789                test: {
790                    include: ["src/**/*.test.ts"]
791                }
792            };
793        "#;
794        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
795        assert_eq!(include, vec!["src/**/*.test.ts"]);
796    }
797
798    #[test]
799    fn extract_addons_array() {
800        let source = r#"
801            export default {
802                addons: [
803                    "@storybook/addon-a11y",
804                    "@storybook/addon-docs",
805                    "@storybook/addon-links"
806                ]
807            };
808        "#;
809        let addons = extract_config_property_strings(source, &ts_path(), "addons");
810        assert_eq!(
811            addons,
812            vec![
813                "@storybook/addon-a11y",
814                "@storybook/addon-docs",
815                "@storybook/addon-links"
816            ]
817        );
818    }
819
820    #[test]
821    fn handle_empty_config() {
822        let source = "";
823        let result = extract_config_string(source, &js_path(), &["key"]);
824        assert_eq!(result, None);
825    }
826
827    // ── extract_config_object_keys tests ────────────────────────────
828
829    #[test]
830    fn object_keys_postcss_plugins() {
831        let source = r"
832            module.exports = {
833                plugins: {
834                    autoprefixer: {},
835                    tailwindcss: {},
836                    'postcss-import': {}
837                }
838            };
839        ";
840        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
841        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
842    }
843
844    #[test]
845    fn object_keys_nested_path() {
846        let source = r"
847            export default {
848                build: {
849                    plugins: {
850                        minify: {},
851                        compress: {}
852                    }
853                }
854            };
855        ";
856        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
857        assert_eq!(keys, vec!["minify", "compress"]);
858    }
859
860    #[test]
861    fn object_keys_empty_object() {
862        let source = r"export default { plugins: {} };";
863        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
864        assert!(keys.is_empty());
865    }
866
867    #[test]
868    fn object_keys_non_object_returns_empty() {
869        let source = r#"export default { plugins: ["a", "b"] };"#;
870        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
871        assert!(keys.is_empty());
872    }
873
874    // ── extract_config_string_or_array tests ────────────────────────
875
876    #[test]
877    fn string_or_array_single_string() {
878        let source = r#"export default { entry: "./src/index.js" };"#;
879        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
880        assert_eq!(result, vec!["./src/index.js"]);
881    }
882
883    #[test]
884    fn string_or_array_array() {
885        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
886        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
887        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
888    }
889
890    #[test]
891    fn string_or_array_object_values() {
892        let source =
893            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
894        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
895        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
896    }
897
898    #[test]
899    fn string_or_array_nested_path() {
900        let source = r#"
901            export default {
902                build: {
903                    rollupOptions: {
904                        input: ["./index.html", "./about.html"]
905                    }
906                }
907            };
908        "#;
909        let result = extract_config_string_or_array(
910            source,
911            &js_path(),
912            &["build", "rollupOptions", "input"],
913        );
914        assert_eq!(result, vec!["./index.html", "./about.html"]);
915    }
916
917    #[test]
918    fn string_or_array_template_literal() {
919        let source = r"export default { entry: `./src/index.js` };";
920        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
921        assert_eq!(result, vec!["./src/index.js"]);
922    }
923
924    // ── extract_config_require_strings tests ────────────────────────
925
926    #[test]
927    fn require_strings_array() {
928        let source = r"
929            module.exports = {
930                plugins: [
931                    require('autoprefixer'),
932                    require('postcss-import')
933                ]
934            };
935        ";
936        let deps = extract_config_require_strings(source, &js_path(), "plugins");
937        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
938    }
939
940    #[test]
941    fn require_strings_with_tuples() {
942        let source = r"
943            module.exports = {
944                plugins: [
945                    require('autoprefixer'),
946                    [require('postcss-preset-env'), { stage: 3 }]
947                ]
948            };
949        ";
950        let deps = extract_config_require_strings(source, &js_path(), "plugins");
951        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
952    }
953
954    #[test]
955    fn require_strings_empty_array() {
956        let source = r"module.exports = { plugins: [] };";
957        let deps = extract_config_require_strings(source, &js_path(), "plugins");
958        assert!(deps.is_empty());
959    }
960
961    #[test]
962    fn require_strings_no_require_calls() {
963        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
964        let deps = extract_config_require_strings(source, &js_path(), "plugins");
965        assert!(deps.is_empty());
966    }
967
968    // ── JSON wrapped in parens (for tsconfig.json parsing) ──────────
969
970    #[test]
971    fn json_wrapped_in_parens_string() {
972        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
973        let val = extract_config_string(source, &js_path(), &["extends"]);
974        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
975    }
976
977    #[test]
978    fn json_wrapped_in_parens_nested_array() {
979        let source =
980            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
981        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
982        assert_eq!(types, vec!["node", "jest"]);
983
984        let include = extract_config_string_array(source, &js_path(), &["include"]);
985        assert_eq!(include, vec!["src/**/*"]);
986    }
987
988    #[test]
989    fn json_wrapped_in_parens_object_keys() {
990        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
991        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
992        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
993    }
994
995    // ── JSON file extension detection ────────────────────────────
996
997    fn json_path() -> PathBuf {
998        PathBuf::from("config.json")
999    }
1000
1001    #[test]
1002    fn json_file_parsed_correctly() {
1003        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
1004        let val = extract_config_string(source, &json_path(), &["key"]);
1005        assert_eq!(val, Some("value".to_string()));
1006
1007        let list = extract_config_string_array(source, &json_path(), &["list"]);
1008        assert_eq!(list, vec!["a", "b"]);
1009    }
1010
1011    #[test]
1012    fn jsonc_file_parsed_correctly() {
1013        let source = r#"{"key": "value"}"#;
1014        let path = PathBuf::from("tsconfig.jsonc");
1015        let val = extract_config_string(source, &path, &["key"]);
1016        assert_eq!(val, Some("value".to_string()));
1017    }
1018
1019    // ── defineConfig with arrow function ─────────────────────────
1020
1021    #[test]
1022    fn extract_define_config_arrow_function() {
1023        let source = r#"
1024            import { defineConfig } from 'vite';
1025            export default defineConfig(() => ({
1026                test: {
1027                    include: ["**/*.test.ts"]
1028                }
1029            }));
1030        "#;
1031        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1032        assert_eq!(include, vec!["**/*.test.ts"]);
1033    }
1034
1035    // ── module.exports with nested properties ────────────────────
1036
1037    #[test]
1038    fn module_exports_nested_string() {
1039        let source = r#"
1040            module.exports = {
1041                resolve: {
1042                    alias: {
1043                        "@": "./src"
1044                    }
1045                }
1046            };
1047        "#;
1048        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
1049        assert_eq!(val, Some("./src".to_string()));
1050    }
1051
1052    // ── extract_config_property_strings (recursive) ──────────────
1053
1054    #[test]
1055    fn property_strings_nested_objects() {
1056        let source = r#"
1057            export default {
1058                plugins: {
1059                    group1: { a: "val-a" },
1060                    group2: { b: "val-b" }
1061                }
1062            };
1063        "#;
1064        let values = extract_config_property_strings(source, &js_path(), "plugins");
1065        assert!(values.contains(&"val-a".to_string()));
1066        assert!(values.contains(&"val-b".to_string()));
1067    }
1068
1069    #[test]
1070    fn property_strings_missing_key_returns_empty() {
1071        let source = r#"export default { other: "value" };"#;
1072        let values = extract_config_property_strings(source, &js_path(), "missing");
1073        assert!(values.is_empty());
1074    }
1075
1076    // ── extract_config_shallow_strings ────────────────────────────
1077
1078    #[test]
1079    fn shallow_strings_tuple_array() {
1080        let source = r#"
1081            module.exports = {
1082                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
1083            };
1084        "#;
1085        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
1086        assert_eq!(values, vec!["default", "jest-junit"]);
1087        // "reports" should NOT be extracted (it's inside an options object)
1088        assert!(!values.contains(&"reports".to_string()));
1089    }
1090
1091    #[test]
1092    fn shallow_strings_single_string() {
1093        let source = r#"export default { preset: "ts-jest" };"#;
1094        let values = extract_config_shallow_strings(source, &js_path(), "preset");
1095        assert_eq!(values, vec!["ts-jest"]);
1096    }
1097
1098    #[test]
1099    fn shallow_strings_missing_key() {
1100        let source = r#"export default { other: "val" };"#;
1101        let values = extract_config_shallow_strings(source, &js_path(), "missing");
1102        assert!(values.is_empty());
1103    }
1104
1105    // ── extract_config_nested_shallow_strings tests ──────────────
1106
1107    #[test]
1108    fn nested_shallow_strings_vitest_reporters() {
1109        let source = r#"
1110            export default {
1111                test: {
1112                    reporters: ["default", "vitest-sonar-reporter"]
1113                }
1114            };
1115        "#;
1116        let values =
1117            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1118        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1119    }
1120
1121    #[test]
1122    fn nested_shallow_strings_tuple_format() {
1123        let source = r#"
1124            export default {
1125                test: {
1126                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
1127                }
1128            };
1129        "#;
1130        let values =
1131            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1132        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1133    }
1134
1135    #[test]
1136    fn nested_shallow_strings_missing_outer() {
1137        let source = r"export default { other: {} };";
1138        let values =
1139            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1140        assert!(values.is_empty());
1141    }
1142
1143    #[test]
1144    fn nested_shallow_strings_missing_inner() {
1145        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
1146        let values =
1147            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1148        assert!(values.is_empty());
1149    }
1150
1151    // ── extract_config_string_or_array edge cases ────────────────
1152
1153    #[test]
1154    fn string_or_array_missing_path() {
1155        let source = r"export default {};";
1156        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1157        assert!(result.is_empty());
1158    }
1159
1160    #[test]
1161    fn string_or_array_non_string_values() {
1162        // When values are not strings (e.g., numbers), they should be skipped
1163        let source = r"export default { entry: [42, true] };";
1164        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1165        assert!(result.is_empty());
1166    }
1167
1168    // ── extract_config_array_nested_string_or_array ──────────────
1169
1170    #[test]
1171    fn array_nested_extraction() {
1172        let source = r#"
1173            export default defineConfig({
1174                test: {
1175                    projects: [
1176                        {
1177                            test: {
1178                                setupFiles: ["./test/setup-a.ts"]
1179                            }
1180                        },
1181                        {
1182                            test: {
1183                                setupFiles: "./test/setup-b.ts"
1184                            }
1185                        }
1186                    ]
1187                }
1188            });
1189        "#;
1190        let results = extract_config_array_nested_string_or_array(
1191            source,
1192            &ts_path(),
1193            &["test", "projects"],
1194            &["test", "setupFiles"],
1195        );
1196        assert!(results.contains(&"./test/setup-a.ts".to_string()));
1197        assert!(results.contains(&"./test/setup-b.ts".to_string()));
1198    }
1199
1200    #[test]
1201    fn array_nested_empty_when_no_array() {
1202        let source = r#"export default { test: { projects: "not-an-array" } };"#;
1203        let results = extract_config_array_nested_string_or_array(
1204            source,
1205            &js_path(),
1206            &["test", "projects"],
1207            &["test", "setupFiles"],
1208        );
1209        assert!(results.is_empty());
1210    }
1211
1212    // ── extract_config_object_nested_string_or_array ─────────────
1213
1214    #[test]
1215    fn object_nested_extraction() {
1216        let source = r#"{
1217            "projects": {
1218                "app-one": {
1219                    "architect": {
1220                        "build": {
1221                            "options": {
1222                                "styles": ["src/styles.css"]
1223                            }
1224                        }
1225                    }
1226                }
1227            }
1228        }"#;
1229        let results = extract_config_object_nested_string_or_array(
1230            source,
1231            &json_path(),
1232            &["projects"],
1233            &["architect", "build", "options", "styles"],
1234        );
1235        assert_eq!(results, vec!["src/styles.css"]);
1236    }
1237
1238    // ── extract_config_object_nested_strings ─────────────────────
1239
1240    #[test]
1241    fn object_nested_strings_extraction() {
1242        let source = r#"{
1243            "targets": {
1244                "build": {
1245                    "executor": "@angular/build:application"
1246                },
1247                "test": {
1248                    "executor": "@nx/vite:test"
1249                }
1250            }
1251        }"#;
1252        let results =
1253            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
1254        assert!(results.contains(&"@angular/build:application".to_string()));
1255        assert!(results.contains(&"@nx/vite:test".to_string()));
1256    }
1257
1258    // ── extract_config_require_strings edge cases ────────────────
1259
1260    #[test]
1261    fn require_strings_direct_call() {
1262        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
1263        let deps = extract_config_require_strings(source, &js_path(), "adapter");
1264        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
1265    }
1266
1267    #[test]
1268    fn require_strings_no_matching_key() {
1269        let source = r"module.exports = { other: require('something') };";
1270        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1271        assert!(deps.is_empty());
1272    }
1273
1274    // ── extract_imports edge cases ───────────────────────────────
1275
1276    #[test]
1277    fn extract_imports_no_imports() {
1278        let source = r"export default {};";
1279        let imports = extract_imports(source, &js_path());
1280        assert!(imports.is_empty());
1281    }
1282
1283    #[test]
1284    fn extract_imports_side_effect_import() {
1285        let source = r"
1286            import 'polyfill';
1287            import './local-setup';
1288            export default {};
1289        ";
1290        let imports = extract_imports(source, &js_path());
1291        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
1292    }
1293
1294    #[test]
1295    fn extract_imports_mixed_specifiers() {
1296        let source = r"
1297            import defaultExport from 'module-a';
1298            import { named } from 'module-b';
1299            import * as ns from 'module-c';
1300            export default {};
1301        ";
1302        let imports = extract_imports(source, &js_path());
1303        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
1304    }
1305
1306    // ── Template literal support ─────────────────────────────────
1307
1308    #[test]
1309    fn template_literal_in_string_or_array() {
1310        let source = r"export default { entry: `./src/index.ts` };";
1311        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
1312        assert_eq!(result, vec!["./src/index.ts"]);
1313    }
1314
1315    #[test]
1316    fn template_literal_in_config_string() {
1317        let source = r"export default { testDir: `./tests` };";
1318        let val = extract_config_string(source, &js_path(), &["testDir"]);
1319        assert_eq!(val, Some("./tests".to_string()));
1320    }
1321
1322    // ── Empty/missing path navigation ────────────────────────────
1323
1324    #[test]
1325    fn nested_string_array_empty_path() {
1326        let source = r#"export default { items: ["a", "b"] };"#;
1327        let result = extract_config_string_array(source, &js_path(), &[]);
1328        assert!(result.is_empty());
1329    }
1330
1331    #[test]
1332    fn nested_string_empty_path() {
1333        let source = r#"export default { key: "val" };"#;
1334        let result = extract_config_string(source, &js_path(), &[]);
1335        assert!(result.is_none());
1336    }
1337
1338    #[test]
1339    fn object_keys_empty_path() {
1340        let source = r"export default { plugins: {} };";
1341        let result = extract_config_object_keys(source, &js_path(), &[]);
1342        assert!(result.is_empty());
1343    }
1344
1345    // ── No config object found ───────────────────────────────────
1346
1347    #[test]
1348    fn no_config_object_returns_empty() {
1349        // Source with no default export or module.exports
1350        let source = r"const x = 42;";
1351        let result = extract_config_string(source, &js_path(), &["key"]);
1352        assert!(result.is_none());
1353
1354        let arr = extract_config_string_array(source, &js_path(), &["items"]);
1355        assert!(arr.is_empty());
1356
1357        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1358        assert!(keys.is_empty());
1359    }
1360
1361    // ── String literal with string key property ──────────────────
1362
1363    #[test]
1364    fn property_with_string_key() {
1365        let source = r#"export default { "string-key": "value" };"#;
1366        let val = extract_config_string(source, &js_path(), &["string-key"]);
1367        assert_eq!(val, Some("value".to_string()));
1368    }
1369
1370    #[test]
1371    fn nested_navigation_through_non_object() {
1372        // Trying to navigate through a string value should return None
1373        let source = r#"export default { level1: "not-an-object" };"#;
1374        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
1375        assert!(val.is_none());
1376    }
1377}