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, PathBuf};
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/// Extract alias mappings from an object or array-based alias config.
273///
274/// Supports common bundler config shapes like:
275/// - `resolve.alias = { "@": "./src" }`
276/// - `resolve.alias = [{ find: "@", replacement: "./src" }]`
277/// - `resolve.alias = [{ find: "@", replacement: fileURLToPath(new URL("./src", import.meta.url)) }]`
278#[must_use]
279pub fn extract_config_aliases(
280    source: &str,
281    path: &Path,
282    prop_path: &[&str],
283) -> Vec<(String, String)> {
284    extract_from_source(source, path, |program| {
285        let obj = find_config_object(program)?;
286        let expr = get_nested_expression(obj, prop_path)?;
287        let aliases = expression_to_alias_pairs(expr);
288        (!aliases.is_empty()).then_some(aliases)
289    })
290    .unwrap_or_default()
291}
292
293/// Extract string values from a nested array, supporting both string elements and
294/// object elements with a named string/path field.
295///
296/// Useful for configs like:
297/// - `components: ["~/components", { path: "~/feature-components" }]`
298#[must_use]
299pub fn extract_config_array_object_strings(
300    source: &str,
301    path: &Path,
302    array_path: &[&str],
303    key: &str,
304) -> Vec<String> {
305    extract_from_source(source, path, |program| {
306        let obj = find_config_object(program)?;
307        let array_expr = get_nested_expression(obj, array_path)?;
308        let Expression::ArrayExpression(arr) = array_expr else {
309            return None;
310        };
311
312        let mut results = Vec::new();
313        for element in &arr.elements {
314            let Some(expr) = element.as_expression() else {
315                continue;
316            };
317            match expr {
318                Expression::ObjectExpression(item) => {
319                    if let Some(prop) = find_property(item, key)
320                        && let Some(value) = expression_to_path_string(&prop.value)
321                    {
322                        results.push(value);
323                    }
324                }
325                _ => {
326                    if let Some(value) = expression_to_path_string(expr) {
327                        results.push(value);
328                    }
329                }
330            }
331        }
332
333        (!results.is_empty()).then_some(results)
334    })
335    .unwrap_or_default()
336}
337
338/// Normalize a config-relative path string to a project-root-relative path.
339///
340/// Handles values extracted from config files such as `"./src"`, `"src/lib"`,
341/// `"/src"`, or absolute filesystem paths under `root`.
342#[must_use]
343pub fn normalize_config_path(raw: &str, config_path: &Path, root: &Path) -> Option<String> {
344    if raw.is_empty() {
345        return None;
346    }
347
348    let candidate = if let Some(stripped) = raw.strip_prefix('/') {
349        lexical_normalize(&root.join(stripped))
350    } else {
351        let path = Path::new(raw);
352        if path.is_absolute() {
353            lexical_normalize(path)
354        } else {
355            let base = config_path.parent().unwrap_or(root);
356            lexical_normalize(&base.join(path))
357        }
358    };
359
360    let relative = candidate.strip_prefix(root).ok()?;
361    let normalized = relative.to_string_lossy().replace('\\', "/");
362    (!normalized.is_empty()).then_some(normalized)
363}
364
365// ── Internal helpers ──────────────────────────────────────────────
366
367/// Parse source and run an extraction function on the AST.
368///
369/// JSON files (`.json`, `.jsonc`) are parsed as JavaScript expressions wrapped in
370/// parentheses to produce an AST compatible with `find_config_object`. The native
371/// JSON source type in Oxc produces a different AST structure that our helpers
372/// don't handle.
373fn extract_from_source<T>(
374    source: &str,
375    path: &Path,
376    extractor: impl FnOnce(&Program) -> Option<T>,
377) -> Option<T> {
378    let source_type = SourceType::from_path(path).unwrap_or_default();
379    let alloc = Allocator::default();
380
381    // For JSON files, wrap in parens and parse as JS so the AST matches
382    // what find_config_object expects (ExpressionStatement → ObjectExpression).
383    let is_json = path
384        .extension()
385        .is_some_and(|ext| ext == "json" || ext == "jsonc");
386    if is_json {
387        let wrapped = format!("({source})");
388        let parsed = Parser::new(&alloc, &wrapped, SourceType::mjs()).parse();
389        return extractor(&parsed.program);
390    }
391
392    let parsed = Parser::new(&alloc, source, source_type).parse();
393    extractor(&parsed.program)
394}
395
396/// Find the "config object" — the object expression in the default export or module.exports.
397///
398/// Handles these patterns:
399/// - `export default { ... }`
400/// - `export default defineConfig({ ... })`
401/// - `export default defineConfig(async () => ({ ... }))`
402/// - `export default { ... } satisfies Config` / `export default { ... } as Config`
403/// - `const config = { ... }; export default config;`
404/// - `const config: Config = { ... }; export default config;`
405/// - `module.exports = { ... }`
406/// - Top-level JSON object (for .json files)
407fn find_config_object<'a>(program: &'a Program) -> Option<&'a ObjectExpression<'a>> {
408    for stmt in &program.body {
409        match stmt {
410            // export default { ... } or export default defineConfig({ ... })
411            Statement::ExportDefaultDeclaration(decl) => {
412                // ExportDefaultDeclarationKind inherits Expression variants directly
413                let expr: Option<&Expression> = match &decl.declaration {
414                    ExportDefaultDeclarationKind::ObjectExpression(obj) => {
415                        return Some(obj);
416                    }
417                    _ => decl.declaration.as_expression(),
418                };
419                if let Some(expr) = expr {
420                    // Try direct extraction (handles defineConfig(), parens, TS annotations)
421                    if let Some(obj) = extract_object_from_expression(expr) {
422                        return Some(obj);
423                    }
424                    // Fallback: resolve identifier reference to variable declaration
425                    // Handles: const config: Type = { ... }; export default config;
426                    if let Some(name) = unwrap_to_identifier_name(expr) {
427                        return find_variable_init_object(program, name);
428                    }
429                }
430            }
431            // module.exports = { ... }
432            Statement::ExpressionStatement(expr_stmt) => {
433                if let Expression::AssignmentExpression(assign) = &expr_stmt.expression
434                    && is_module_exports_target(&assign.left)
435                {
436                    return extract_object_from_expression(&assign.right);
437                }
438            }
439            _ => {}
440        }
441    }
442
443    // JSON files: the program body might be a single expression statement
444    // Also handles JSON wrapped in parens: `({ ... })` (used for tsconfig.json parsing)
445    if program.body.len() == 1
446        && let Statement::ExpressionStatement(expr_stmt) = &program.body[0]
447    {
448        match &expr_stmt.expression {
449            Expression::ObjectExpression(obj) => return Some(obj),
450            Expression::ParenthesizedExpression(paren) => {
451                if let Expression::ObjectExpression(obj) = &paren.expression {
452                    return Some(obj);
453                }
454            }
455            _ => {}
456        }
457    }
458
459    None
460}
461
462/// Extract an `ObjectExpression` from an expression, handling wrapper patterns.
463fn extract_object_from_expression<'a>(
464    expr: &'a Expression<'a>,
465) -> Option<&'a ObjectExpression<'a>> {
466    match expr {
467        // Direct object: `{ ... }`
468        Expression::ObjectExpression(obj) => Some(obj),
469        // Factory call: `defineConfig({ ... })`
470        Expression::CallExpression(call) => {
471            // Look for the first object argument
472            for arg in &call.arguments {
473                match arg {
474                    Argument::ObjectExpression(obj) => return Some(obj),
475                    // Arrow function body: `defineConfig(() => ({ ... }))`
476                    Argument::ArrowFunctionExpression(arrow) => {
477                        if arrow.expression
478                            && !arrow.body.statements.is_empty()
479                            && let Statement::ExpressionStatement(expr_stmt) =
480                                &arrow.body.statements[0]
481                        {
482                            return extract_object_from_expression(&expr_stmt.expression);
483                        }
484                    }
485                    _ => {}
486                }
487            }
488            None
489        }
490        // Parenthesized: `({ ... })`
491        Expression::ParenthesizedExpression(paren) => {
492            extract_object_from_expression(&paren.expression)
493        }
494        // TS type annotations: `{ ... } satisfies Config` or `{ ... } as Config`
495        Expression::TSSatisfiesExpression(ts_sat) => {
496            extract_object_from_expression(&ts_sat.expression)
497        }
498        Expression::TSAsExpression(ts_as) => extract_object_from_expression(&ts_as.expression),
499        _ => None,
500    }
501}
502
503/// Check if an assignment target is `module.exports`.
504fn is_module_exports_target(target: &AssignmentTarget) -> bool {
505    if let AssignmentTarget::StaticMemberExpression(member) = target
506        && let Expression::Identifier(obj) = &member.object
507    {
508        return obj.name == "module" && member.property.name == "exports";
509    }
510    false
511}
512
513/// Unwrap TS annotations and return the identifier name if the expression resolves to one.
514///
515/// Handles `config`, `config satisfies Type`, `config as Type`.
516fn unwrap_to_identifier_name<'a>(expr: &'a Expression<'a>) -> Option<&'a str> {
517    match expr {
518        Expression::Identifier(id) => Some(&id.name),
519        Expression::TSSatisfiesExpression(ts_sat) => unwrap_to_identifier_name(&ts_sat.expression),
520        Expression::TSAsExpression(ts_as) => unwrap_to_identifier_name(&ts_as.expression),
521        _ => None,
522    }
523}
524
525/// Find a top-level variable declaration by name and extract its init as an object expression.
526///
527/// Handles `const config = { ... }`, `const config: Type = { ... }`,
528/// and `const config = defineConfig({ ... })`.
529fn find_variable_init_object<'a>(
530    program: &'a Program,
531    name: &str,
532) -> Option<&'a ObjectExpression<'a>> {
533    for stmt in &program.body {
534        if let Statement::VariableDeclaration(decl) = stmt {
535            for declarator in &decl.declarations {
536                if let BindingPattern::BindingIdentifier(id) = &declarator.id
537                    && id.name == name
538                    && let Some(init) = &declarator.init
539                {
540                    return extract_object_from_expression(init);
541                }
542            }
543        }
544    }
545    None
546}
547
548/// Find a named property in an object expression.
549fn find_property<'a>(obj: &'a ObjectExpression<'a>, key: &str) -> Option<&'a ObjectProperty<'a>> {
550    for prop in &obj.properties {
551        if let ObjectPropertyKind::ObjectProperty(p) = prop
552            && property_key_matches(&p.key, key)
553        {
554            return Some(p);
555        }
556    }
557    None
558}
559
560/// Check if a property key matches a string.
561fn property_key_matches(key: &PropertyKey, name: &str) -> bool {
562    match key {
563        PropertyKey::StaticIdentifier(id) => id.name == name,
564        PropertyKey::StringLiteral(s) => s.value == name,
565        _ => false,
566    }
567}
568
569/// Get a string value from an object property.
570fn get_object_string_property(obj: &ObjectExpression, key: &str) -> Option<String> {
571    find_property(obj, key).and_then(|p| expression_to_string(&p.value))
572}
573
574/// Get an array of strings from an object property.
575fn get_object_string_array_property(obj: &ObjectExpression, key: &str) -> Vec<String> {
576    find_property(obj, key)
577        .map(|p| expression_to_string_array(&p.value))
578        .unwrap_or_default()
579}
580
581/// Navigate a nested property path and get a string array.
582fn get_nested_string_array_from_object(
583    obj: &ObjectExpression,
584    path: &[&str],
585) -> Option<Vec<String>> {
586    if path.is_empty() {
587        return None;
588    }
589    if path.len() == 1 {
590        return Some(get_object_string_array_property(obj, path[0]));
591    }
592    // Navigate into nested object
593    let prop = find_property(obj, path[0])?;
594    if let Expression::ObjectExpression(nested) = &prop.value {
595        get_nested_string_array_from_object(nested, &path[1..])
596    } else {
597        None
598    }
599}
600
601/// Navigate a nested property path and get a string value.
602fn get_nested_string_from_object(obj: &ObjectExpression, path: &[&str]) -> Option<String> {
603    if path.is_empty() {
604        return None;
605    }
606    if path.len() == 1 {
607        return get_object_string_property(obj, path[0]);
608    }
609    let prop = find_property(obj, path[0])?;
610    if let Expression::ObjectExpression(nested) = &prop.value {
611        get_nested_string_from_object(nested, &path[1..])
612    } else {
613        None
614    }
615}
616
617/// Convert an expression to a string if it's a string literal.
618fn expression_to_string(expr: &Expression) -> Option<String> {
619    match expr {
620        Expression::StringLiteral(s) => Some(s.value.to_string()),
621        Expression::TemplateLiteral(t) if t.expressions.is_empty() => {
622            // Template literal with no expressions: `\`value\``
623            t.quasis.first().map(|q| q.value.raw.to_string())
624        }
625        _ => None,
626    }
627}
628
629/// Convert an expression to a path-like string if it's statically recoverable.
630fn expression_to_path_string(expr: &Expression) -> Option<String> {
631    match expr {
632        Expression::ParenthesizedExpression(paren) => expression_to_path_string(&paren.expression),
633        Expression::TSAsExpression(ts_as) => expression_to_path_string(&ts_as.expression),
634        Expression::TSSatisfiesExpression(ts_sat) => expression_to_path_string(&ts_sat.expression),
635        Expression::CallExpression(call) => call_expression_to_path_string(call),
636        Expression::NewExpression(new_expr) => new_expression_to_path_string(new_expr),
637        _ => expression_to_string(expr),
638    }
639}
640
641fn call_expression_to_path_string(call: &CallExpression) -> Option<String> {
642    if matches!(&call.callee, Expression::Identifier(id) if id.name == "fileURLToPath") {
643        return call
644            .arguments
645            .first()
646            .and_then(Argument::as_expression)
647            .and_then(expression_to_path_string);
648    }
649
650    let callee_name = match &call.callee {
651        Expression::Identifier(id) => Some(id.name.as_str()),
652        Expression::StaticMemberExpression(member) => Some(member.property.name.as_str()),
653        _ => None,
654    }?;
655
656    if !matches!(callee_name, "resolve" | "join") {
657        return None;
658    }
659
660    let mut segments = Vec::new();
661    for (index, arg) in call.arguments.iter().enumerate() {
662        let expr = arg.as_expression()?;
663
664        if matches!(expr, Expression::Identifier(id) if id.name == "__dirname") {
665            if index == 0 {
666                continue;
667            }
668            return None;
669        }
670
671        segments.push(expression_to_string(expr)?);
672    }
673
674    (!segments.is_empty()).then(|| join_path_segments(&segments))
675}
676
677fn new_expression_to_path_string(new_expr: &NewExpression) -> Option<String> {
678    if !matches!(&new_expr.callee, Expression::Identifier(id) if id.name == "URL") {
679        return None;
680    }
681
682    let source = new_expr
683        .arguments
684        .first()
685        .and_then(Argument::as_expression)
686        .and_then(expression_to_string)?;
687
688    let base = new_expr
689        .arguments
690        .get(1)
691        .and_then(Argument::as_expression)?;
692    is_import_meta_url_expression(base).then_some(source)
693}
694
695fn is_import_meta_url_expression(expr: &Expression) -> bool {
696    if let Expression::StaticMemberExpression(member) = expr {
697        member.property.name == "url" && matches!(member.object, Expression::MetaProperty(_))
698    } else {
699        false
700    }
701}
702
703fn join_path_segments(segments: &[String]) -> String {
704    let mut joined = PathBuf::new();
705    for segment in segments {
706        joined.push(segment);
707    }
708    joined.to_string_lossy().replace('\\', "/")
709}
710
711fn expression_to_alias_pairs(expr: &Expression) -> Vec<(String, String)> {
712    match expr {
713        Expression::ObjectExpression(obj) => obj
714            .properties
715            .iter()
716            .filter_map(|prop| {
717                let ObjectPropertyKind::ObjectProperty(prop) = prop else {
718                    return None;
719                };
720                let find = property_key_to_string(&prop.key)?;
721                let replacement = expression_to_path_string(&prop.value)?;
722                Some((find, replacement))
723            })
724            .collect(),
725        Expression::ArrayExpression(arr) => arr
726            .elements
727            .iter()
728            .filter_map(|element| {
729                let Expression::ObjectExpression(obj) = element.as_expression()? else {
730                    return None;
731                };
732                let find = find_property(obj, "find")
733                    .and_then(|prop| expression_to_string(&prop.value))?;
734                let replacement = find_property(obj, "replacement")
735                    .and_then(|prop| expression_to_path_string(&prop.value))?;
736                Some((find, replacement))
737            })
738            .collect(),
739        _ => Vec::new(),
740    }
741}
742
743fn lexical_normalize(path: &Path) -> PathBuf {
744    let mut normalized = PathBuf::new();
745
746    for component in path.components() {
747        match component {
748            std::path::Component::CurDir => {}
749            std::path::Component::ParentDir => {
750                normalized.pop();
751            }
752            _ => normalized.push(component.as_os_str()),
753        }
754    }
755
756    normalized
757}
758
759/// Convert an expression to a string array if it's an array of string literals.
760fn expression_to_string_array(expr: &Expression) -> Vec<String> {
761    match expr {
762        Expression::ArrayExpression(arr) => arr
763            .elements
764            .iter()
765            .filter_map(|el| match el {
766                ArrayExpressionElement::SpreadElement(_) => None,
767                _ => el.as_expression().and_then(expression_to_string),
768            })
769            .collect(),
770        _ => vec![],
771    }
772}
773
774/// Collect only top-level string values from an expression.
775///
776/// For arrays, extracts direct string elements and the first string element of sub-arrays
777/// (to handle `["pkg-name", { options }]` tuples). Does NOT recurse into objects.
778fn collect_shallow_string_values(expr: &Expression) -> Vec<String> {
779    let mut values = Vec::new();
780    match expr {
781        Expression::StringLiteral(s) => {
782            values.push(s.value.to_string());
783        }
784        Expression::ArrayExpression(arr) => {
785            for el in &arr.elements {
786                if let Some(inner) = el.as_expression() {
787                    match inner {
788                        Expression::StringLiteral(s) => {
789                            values.push(s.value.to_string());
790                        }
791                        // Handle tuples: ["pkg-name", { options }] → extract first string
792                        Expression::ArrayExpression(sub_arr) => {
793                            if let Some(first) = sub_arr.elements.first()
794                                && let Some(first_expr) = first.as_expression()
795                                && let Some(s) = expression_to_string(first_expr)
796                            {
797                                values.push(s);
798                            }
799                        }
800                        _ => {}
801                    }
802                }
803            }
804        }
805        _ => {}
806    }
807    values
808}
809
810/// Recursively collect all string literal values from an expression tree.
811fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
812    match expr {
813        Expression::StringLiteral(s) => {
814            values.push(s.value.to_string());
815        }
816        Expression::ArrayExpression(arr) => {
817            for el in &arr.elements {
818                if let Some(expr) = el.as_expression() {
819                    collect_all_string_values(expr, values);
820                }
821            }
822        }
823        Expression::ObjectExpression(obj) => {
824            for prop in &obj.properties {
825                if let ObjectPropertyKind::ObjectProperty(p) = prop {
826                    collect_all_string_values(&p.value, values);
827                }
828            }
829        }
830        _ => {}
831    }
832}
833
834/// Convert a `PropertyKey` to a `String`.
835fn property_key_to_string(key: &PropertyKey) -> Option<String> {
836    match key {
837        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
838        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
839        _ => None,
840    }
841}
842
843/// Extract keys of an object at a nested property path.
844fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
845    if path.is_empty() {
846        return None;
847    }
848    let prop = find_property(obj, path[0])?;
849    if path.len() == 1 {
850        if let Expression::ObjectExpression(nested) = &prop.value {
851            let keys = nested
852                .properties
853                .iter()
854                .filter_map(|p| {
855                    if let ObjectPropertyKind::ObjectProperty(p) = p {
856                        property_key_to_string(&p.key)
857                    } else {
858                        None
859                    }
860                })
861                .collect();
862            return Some(keys);
863        }
864        return None;
865    }
866    if let Expression::ObjectExpression(nested) = &prop.value {
867        get_nested_object_keys(nested, &path[1..])
868    } else {
869        None
870    }
871}
872
873/// Navigate a nested property path and return the raw expression at the end.
874fn get_nested_expression<'a>(
875    obj: &'a ObjectExpression<'a>,
876    path: &[&str],
877) -> Option<&'a Expression<'a>> {
878    if path.is_empty() {
879        return None;
880    }
881    let prop = find_property(obj, path[0])?;
882    if path.len() == 1 {
883        return Some(&prop.value);
884    }
885    if let Expression::ObjectExpression(nested) = &prop.value {
886        get_nested_expression(nested, &path[1..])
887    } else {
888        None
889    }
890}
891
892/// Navigate a nested path and extract a string, string array, or object string values.
893fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
894    if path.is_empty() {
895        return None;
896    }
897    if path.len() == 1 {
898        let prop = find_property(obj, path[0])?;
899        return Some(expression_to_string_or_array(&prop.value));
900    }
901    let prop = find_property(obj, path[0])?;
902    if let Expression::ObjectExpression(nested) = &prop.value {
903        get_nested_string_or_array(nested, &path[1..])
904    } else {
905        None
906    }
907}
908
909/// Convert an expression to a `Vec<String>`, handling string, array, and object-with-string-values.
910fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
911    match expr {
912        Expression::StringLiteral(s) => vec![s.value.to_string()],
913        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
914            .quasis
915            .first()
916            .map(|q| vec![q.value.raw.to_string()])
917            .unwrap_or_default(),
918        Expression::ArrayExpression(arr) => arr
919            .elements
920            .iter()
921            .filter_map(|el| el.as_expression().and_then(expression_to_string))
922            .collect(),
923        Expression::ObjectExpression(obj) => obj
924            .properties
925            .iter()
926            .filter_map(|p| {
927                if let ObjectPropertyKind::ObjectProperty(p) = p {
928                    expression_to_string(&p.value)
929                } else {
930                    None
931                }
932            })
933            .collect(),
934        _ => vec![],
935    }
936}
937
938/// Collect `require('...')` argument strings from an expression.
939fn collect_require_sources(expr: &Expression) -> Vec<String> {
940    let mut sources = Vec::new();
941    match expr {
942        Expression::CallExpression(call) if is_require_call(call) => {
943            if let Some(s) = get_require_source(call) {
944                sources.push(s);
945            }
946        }
947        Expression::ArrayExpression(arr) => {
948            for el in &arr.elements {
949                if let Some(inner) = el.as_expression() {
950                    match inner {
951                        Expression::CallExpression(call) if is_require_call(call) => {
952                            if let Some(s) = get_require_source(call) {
953                                sources.push(s);
954                            }
955                        }
956                        // Tuple: [require('pkg'), options]
957                        Expression::ArrayExpression(sub_arr) => {
958                            if let Some(first) = sub_arr.elements.first()
959                                && let Some(Expression::CallExpression(call)) =
960                                    first.as_expression()
961                                && is_require_call(call)
962                                && let Some(s) = get_require_source(call)
963                            {
964                                sources.push(s);
965                            }
966                        }
967                        _ => {}
968                    }
969                }
970            }
971        }
972        _ => {}
973    }
974    sources
975}
976
977/// Check if a call expression is `require(...)`.
978fn is_require_call(call: &CallExpression) -> bool {
979    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
980}
981
982/// Get the first string argument of a `require()` call.
983fn get_require_source(call: &CallExpression) -> Option<String> {
984    call.arguments.first().and_then(|arg| {
985        if let Argument::StringLiteral(s) = arg {
986            Some(s.value.to_string())
987        } else {
988            None
989        }
990    })
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996    use std::path::PathBuf;
997
998    fn js_path() -> PathBuf {
999        PathBuf::from("config.js")
1000    }
1001
1002    fn ts_path() -> PathBuf {
1003        PathBuf::from("config.ts")
1004    }
1005
1006    #[test]
1007    fn extract_imports_basic() {
1008        let source = r"
1009            import foo from 'foo-pkg';
1010            import { bar } from '@scope/bar';
1011            export default {};
1012        ";
1013        let imports = extract_imports(source, &js_path());
1014        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
1015    }
1016
1017    #[test]
1018    fn extract_default_export_object_property() {
1019        let source = r#"export default { testDir: "./tests" };"#;
1020        let val = extract_config_string(source, &js_path(), &["testDir"]);
1021        assert_eq!(val, Some("./tests".to_string()));
1022    }
1023
1024    #[test]
1025    fn extract_define_config_property() {
1026        let source = r#"
1027            import { defineConfig } from 'vitest/config';
1028            export default defineConfig({
1029                test: {
1030                    include: ["**/*.test.ts", "**/*.spec.ts"],
1031                    setupFiles: ["./test/setup.ts"]
1032                }
1033            });
1034        "#;
1035        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1036        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
1037
1038        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
1039        assert_eq!(setup, vec!["./test/setup.ts"]);
1040    }
1041
1042    #[test]
1043    fn extract_module_exports_property() {
1044        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
1045        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
1046        assert_eq!(val, Some("jsdom".to_string()));
1047    }
1048
1049    #[test]
1050    fn extract_nested_string_array() {
1051        let source = r#"
1052            export default {
1053                resolve: {
1054                    alias: {
1055                        "@": "./src"
1056                    }
1057                },
1058                test: {
1059                    include: ["src/**/*.test.ts"]
1060                }
1061            };
1062        "#;
1063        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
1064        assert_eq!(include, vec!["src/**/*.test.ts"]);
1065    }
1066
1067    #[test]
1068    fn extract_addons_array() {
1069        let source = r#"
1070            export default {
1071                addons: [
1072                    "@storybook/addon-a11y",
1073                    "@storybook/addon-docs",
1074                    "@storybook/addon-links"
1075                ]
1076            };
1077        "#;
1078        let addons = extract_config_property_strings(source, &ts_path(), "addons");
1079        assert_eq!(
1080            addons,
1081            vec![
1082                "@storybook/addon-a11y",
1083                "@storybook/addon-docs",
1084                "@storybook/addon-links"
1085            ]
1086        );
1087    }
1088
1089    #[test]
1090    fn handle_empty_config() {
1091        let source = "";
1092        let result = extract_config_string(source, &js_path(), &["key"]);
1093        assert_eq!(result, None);
1094    }
1095
1096    // ── extract_config_object_keys tests ────────────────────────────
1097
1098    #[test]
1099    fn object_keys_postcss_plugins() {
1100        let source = r"
1101            module.exports = {
1102                plugins: {
1103                    autoprefixer: {},
1104                    tailwindcss: {},
1105                    'postcss-import': {}
1106                }
1107            };
1108        ";
1109        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1110        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
1111    }
1112
1113    #[test]
1114    fn object_keys_nested_path() {
1115        let source = r"
1116            export default {
1117                build: {
1118                    plugins: {
1119                        minify: {},
1120                        compress: {}
1121                    }
1122                }
1123            };
1124        ";
1125        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
1126        assert_eq!(keys, vec!["minify", "compress"]);
1127    }
1128
1129    #[test]
1130    fn object_keys_empty_object() {
1131        let source = r"export default { plugins: {} };";
1132        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1133        assert!(keys.is_empty());
1134    }
1135
1136    #[test]
1137    fn object_keys_non_object_returns_empty() {
1138        let source = r#"export default { plugins: ["a", "b"] };"#;
1139        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1140        assert!(keys.is_empty());
1141    }
1142
1143    // ── extract_config_string_or_array tests ────────────────────────
1144
1145    #[test]
1146    fn string_or_array_single_string() {
1147        let source = r#"export default { entry: "./src/index.js" };"#;
1148        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1149        assert_eq!(result, vec!["./src/index.js"]);
1150    }
1151
1152    #[test]
1153    fn string_or_array_array() {
1154        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
1155        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1156        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
1157    }
1158
1159    #[test]
1160    fn string_or_array_object_values() {
1161        let source =
1162            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
1163        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1164        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
1165    }
1166
1167    #[test]
1168    fn string_or_array_nested_path() {
1169        let source = r#"
1170            export default {
1171                build: {
1172                    rollupOptions: {
1173                        input: ["./index.html", "./about.html"]
1174                    }
1175                }
1176            };
1177        "#;
1178        let result = extract_config_string_or_array(
1179            source,
1180            &js_path(),
1181            &["build", "rollupOptions", "input"],
1182        );
1183        assert_eq!(result, vec!["./index.html", "./about.html"]);
1184    }
1185
1186    #[test]
1187    fn string_or_array_template_literal() {
1188        let source = r"export default { entry: `./src/index.js` };";
1189        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1190        assert_eq!(result, vec!["./src/index.js"]);
1191    }
1192
1193    // ── extract_config_require_strings tests ────────────────────────
1194
1195    #[test]
1196    fn require_strings_array() {
1197        let source = r"
1198            module.exports = {
1199                plugins: [
1200                    require('autoprefixer'),
1201                    require('postcss-import')
1202                ]
1203            };
1204        ";
1205        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1206        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
1207    }
1208
1209    #[test]
1210    fn require_strings_with_tuples() {
1211        let source = r"
1212            module.exports = {
1213                plugins: [
1214                    require('autoprefixer'),
1215                    [require('postcss-preset-env'), { stage: 3 }]
1216                ]
1217            };
1218        ";
1219        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1220        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
1221    }
1222
1223    #[test]
1224    fn require_strings_empty_array() {
1225        let source = r"module.exports = { plugins: [] };";
1226        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1227        assert!(deps.is_empty());
1228    }
1229
1230    #[test]
1231    fn require_strings_no_require_calls() {
1232        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
1233        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1234        assert!(deps.is_empty());
1235    }
1236
1237    #[test]
1238    fn extract_aliases_from_object_with_file_url_to_path() {
1239        let source = r#"
1240            import { defineConfig } from 'vite';
1241            import { fileURLToPath, URL } from 'node:url';
1242
1243            export default defineConfig({
1244                resolve: {
1245                    alias: {
1246                        "@": fileURLToPath(new URL("./src", import.meta.url))
1247                    }
1248                }
1249            });
1250        "#;
1251
1252        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
1253        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
1254    }
1255
1256    #[test]
1257    fn extract_aliases_from_array_form() {
1258        let source = r#"
1259            export default {
1260                resolve: {
1261                    alias: [
1262                        { find: "@", replacement: "./src" },
1263                        { find: "$utils", replacement: "src/lib/utils" }
1264                    ]
1265                }
1266            };
1267        "#;
1268
1269        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
1270        assert_eq!(
1271            aliases,
1272            vec![
1273                ("@".to_string(), "./src".to_string()),
1274                ("$utils".to_string(), "src/lib/utils".to_string())
1275            ]
1276        );
1277    }
1278
1279    #[test]
1280    fn extract_array_object_strings_mixed_forms() {
1281        let source = r#"
1282            export default {
1283                components: [
1284                    "~/components",
1285                    { path: "@/feature-components" }
1286                ]
1287            };
1288        "#;
1289
1290        let values =
1291            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
1292        assert_eq!(
1293            values,
1294            vec![
1295                "~/components".to_string(),
1296                "@/feature-components".to_string()
1297            ]
1298        );
1299    }
1300
1301    #[test]
1302    fn normalize_config_path_relative_to_root() {
1303        let config_path = PathBuf::from("/project/vite.config.ts");
1304        let root = PathBuf::from("/project");
1305
1306        assert_eq!(
1307            normalize_config_path("./src/lib", &config_path, &root),
1308            Some("src/lib".to_string())
1309        );
1310        assert_eq!(
1311            normalize_config_path("/src/lib", &config_path, &root),
1312            Some("src/lib".to_string())
1313        );
1314    }
1315
1316    // ── JSON wrapped in parens (for tsconfig.json parsing) ──────────
1317
1318    #[test]
1319    fn json_wrapped_in_parens_string() {
1320        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
1321        let val = extract_config_string(source, &js_path(), &["extends"]);
1322        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
1323    }
1324
1325    #[test]
1326    fn json_wrapped_in_parens_nested_array() {
1327        let source =
1328            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
1329        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
1330        assert_eq!(types, vec!["node", "jest"]);
1331
1332        let include = extract_config_string_array(source, &js_path(), &["include"]);
1333        assert_eq!(include, vec!["src/**/*"]);
1334    }
1335
1336    #[test]
1337    fn json_wrapped_in_parens_object_keys() {
1338        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
1339        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1340        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
1341    }
1342
1343    // ── JSON file extension detection ────────────────────────────
1344
1345    fn json_path() -> PathBuf {
1346        PathBuf::from("config.json")
1347    }
1348
1349    #[test]
1350    fn json_file_parsed_correctly() {
1351        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
1352        let val = extract_config_string(source, &json_path(), &["key"]);
1353        assert_eq!(val, Some("value".to_string()));
1354
1355        let list = extract_config_string_array(source, &json_path(), &["list"]);
1356        assert_eq!(list, vec!["a", "b"]);
1357    }
1358
1359    #[test]
1360    fn jsonc_file_parsed_correctly() {
1361        let source = r#"{"key": "value"}"#;
1362        let path = PathBuf::from("tsconfig.jsonc");
1363        let val = extract_config_string(source, &path, &["key"]);
1364        assert_eq!(val, Some("value".to_string()));
1365    }
1366
1367    // ── defineConfig with arrow function ─────────────────────────
1368
1369    #[test]
1370    fn extract_define_config_arrow_function() {
1371        let source = r#"
1372            import { defineConfig } from 'vite';
1373            export default defineConfig(() => ({
1374                test: {
1375                    include: ["**/*.test.ts"]
1376                }
1377            }));
1378        "#;
1379        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1380        assert_eq!(include, vec!["**/*.test.ts"]);
1381    }
1382
1383    // ── module.exports with nested properties ────────────────────
1384
1385    #[test]
1386    fn module_exports_nested_string() {
1387        let source = r#"
1388            module.exports = {
1389                resolve: {
1390                    alias: {
1391                        "@": "./src"
1392                    }
1393                }
1394            };
1395        "#;
1396        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
1397        assert_eq!(val, Some("./src".to_string()));
1398    }
1399
1400    // ── extract_config_property_strings (recursive) ──────────────
1401
1402    #[test]
1403    fn property_strings_nested_objects() {
1404        let source = r#"
1405            export default {
1406                plugins: {
1407                    group1: { a: "val-a" },
1408                    group2: { b: "val-b" }
1409                }
1410            };
1411        "#;
1412        let values = extract_config_property_strings(source, &js_path(), "plugins");
1413        assert!(values.contains(&"val-a".to_string()));
1414        assert!(values.contains(&"val-b".to_string()));
1415    }
1416
1417    #[test]
1418    fn property_strings_missing_key_returns_empty() {
1419        let source = r#"export default { other: "value" };"#;
1420        let values = extract_config_property_strings(source, &js_path(), "missing");
1421        assert!(values.is_empty());
1422    }
1423
1424    // ── extract_config_shallow_strings ────────────────────────────
1425
1426    #[test]
1427    fn shallow_strings_tuple_array() {
1428        let source = r#"
1429            module.exports = {
1430                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
1431            };
1432        "#;
1433        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
1434        assert_eq!(values, vec!["default", "jest-junit"]);
1435        // "reports" should NOT be extracted (it's inside an options object)
1436        assert!(!values.contains(&"reports".to_string()));
1437    }
1438
1439    #[test]
1440    fn shallow_strings_single_string() {
1441        let source = r#"export default { preset: "ts-jest" };"#;
1442        let values = extract_config_shallow_strings(source, &js_path(), "preset");
1443        assert_eq!(values, vec!["ts-jest"]);
1444    }
1445
1446    #[test]
1447    fn shallow_strings_missing_key() {
1448        let source = r#"export default { other: "val" };"#;
1449        let values = extract_config_shallow_strings(source, &js_path(), "missing");
1450        assert!(values.is_empty());
1451    }
1452
1453    // ── extract_config_nested_shallow_strings tests ──────────────
1454
1455    #[test]
1456    fn nested_shallow_strings_vitest_reporters() {
1457        let source = r#"
1458            export default {
1459                test: {
1460                    reporters: ["default", "vitest-sonar-reporter"]
1461                }
1462            };
1463        "#;
1464        let values =
1465            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1466        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1467    }
1468
1469    #[test]
1470    fn nested_shallow_strings_tuple_format() {
1471        let source = r#"
1472            export default {
1473                test: {
1474                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
1475                }
1476            };
1477        "#;
1478        let values =
1479            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1480        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1481    }
1482
1483    #[test]
1484    fn nested_shallow_strings_missing_outer() {
1485        let source = r"export default { other: {} };";
1486        let values =
1487            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1488        assert!(values.is_empty());
1489    }
1490
1491    #[test]
1492    fn nested_shallow_strings_missing_inner() {
1493        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
1494        let values =
1495            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1496        assert!(values.is_empty());
1497    }
1498
1499    // ── extract_config_string_or_array edge cases ────────────────
1500
1501    #[test]
1502    fn string_or_array_missing_path() {
1503        let source = r"export default {};";
1504        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1505        assert!(result.is_empty());
1506    }
1507
1508    #[test]
1509    fn string_or_array_non_string_values() {
1510        // When values are not strings (e.g., numbers), they should be skipped
1511        let source = r"export default { entry: [42, true] };";
1512        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1513        assert!(result.is_empty());
1514    }
1515
1516    // ── extract_config_array_nested_string_or_array ──────────────
1517
1518    #[test]
1519    fn array_nested_extraction() {
1520        let source = r#"
1521            export default defineConfig({
1522                test: {
1523                    projects: [
1524                        {
1525                            test: {
1526                                setupFiles: ["./test/setup-a.ts"]
1527                            }
1528                        },
1529                        {
1530                            test: {
1531                                setupFiles: "./test/setup-b.ts"
1532                            }
1533                        }
1534                    ]
1535                }
1536            });
1537        "#;
1538        let results = extract_config_array_nested_string_or_array(
1539            source,
1540            &ts_path(),
1541            &["test", "projects"],
1542            &["test", "setupFiles"],
1543        );
1544        assert!(results.contains(&"./test/setup-a.ts".to_string()));
1545        assert!(results.contains(&"./test/setup-b.ts".to_string()));
1546    }
1547
1548    #[test]
1549    fn array_nested_empty_when_no_array() {
1550        let source = r#"export default { test: { projects: "not-an-array" } };"#;
1551        let results = extract_config_array_nested_string_or_array(
1552            source,
1553            &js_path(),
1554            &["test", "projects"],
1555            &["test", "setupFiles"],
1556        );
1557        assert!(results.is_empty());
1558    }
1559
1560    // ── extract_config_object_nested_string_or_array ─────────────
1561
1562    #[test]
1563    fn object_nested_extraction() {
1564        let source = r#"{
1565            "projects": {
1566                "app-one": {
1567                    "architect": {
1568                        "build": {
1569                            "options": {
1570                                "styles": ["src/styles.css"]
1571                            }
1572                        }
1573                    }
1574                }
1575            }
1576        }"#;
1577        let results = extract_config_object_nested_string_or_array(
1578            source,
1579            &json_path(),
1580            &["projects"],
1581            &["architect", "build", "options", "styles"],
1582        );
1583        assert_eq!(results, vec!["src/styles.css"]);
1584    }
1585
1586    // ── extract_config_object_nested_strings ─────────────────────
1587
1588    #[test]
1589    fn object_nested_strings_extraction() {
1590        let source = r#"{
1591            "targets": {
1592                "build": {
1593                    "executor": "@angular/build:application"
1594                },
1595                "test": {
1596                    "executor": "@nx/vite:test"
1597                }
1598            }
1599        }"#;
1600        let results =
1601            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
1602        assert!(results.contains(&"@angular/build:application".to_string()));
1603        assert!(results.contains(&"@nx/vite:test".to_string()));
1604    }
1605
1606    // ── extract_config_require_strings edge cases ────────────────
1607
1608    #[test]
1609    fn require_strings_direct_call() {
1610        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
1611        let deps = extract_config_require_strings(source, &js_path(), "adapter");
1612        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
1613    }
1614
1615    #[test]
1616    fn require_strings_no_matching_key() {
1617        let source = r"module.exports = { other: require('something') };";
1618        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1619        assert!(deps.is_empty());
1620    }
1621
1622    // ── extract_imports edge cases ───────────────────────────────
1623
1624    #[test]
1625    fn extract_imports_no_imports() {
1626        let source = r"export default {};";
1627        let imports = extract_imports(source, &js_path());
1628        assert!(imports.is_empty());
1629    }
1630
1631    #[test]
1632    fn extract_imports_side_effect_import() {
1633        let source = r"
1634            import 'polyfill';
1635            import './local-setup';
1636            export default {};
1637        ";
1638        let imports = extract_imports(source, &js_path());
1639        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
1640    }
1641
1642    #[test]
1643    fn extract_imports_mixed_specifiers() {
1644        let source = r"
1645            import defaultExport from 'module-a';
1646            import { named } from 'module-b';
1647            import * as ns from 'module-c';
1648            export default {};
1649        ";
1650        let imports = extract_imports(source, &js_path());
1651        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
1652    }
1653
1654    // ── Template literal support ─────────────────────────────────
1655
1656    #[test]
1657    fn template_literal_in_string_or_array() {
1658        let source = r"export default { entry: `./src/index.ts` };";
1659        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
1660        assert_eq!(result, vec!["./src/index.ts"]);
1661    }
1662
1663    #[test]
1664    fn template_literal_in_config_string() {
1665        let source = r"export default { testDir: `./tests` };";
1666        let val = extract_config_string(source, &js_path(), &["testDir"]);
1667        assert_eq!(val, Some("./tests".to_string()));
1668    }
1669
1670    // ── Empty/missing path navigation ────────────────────────────
1671
1672    #[test]
1673    fn nested_string_array_empty_path() {
1674        let source = r#"export default { items: ["a", "b"] };"#;
1675        let result = extract_config_string_array(source, &js_path(), &[]);
1676        assert!(result.is_empty());
1677    }
1678
1679    #[test]
1680    fn nested_string_empty_path() {
1681        let source = r#"export default { key: "val" };"#;
1682        let result = extract_config_string(source, &js_path(), &[]);
1683        assert!(result.is_none());
1684    }
1685
1686    #[test]
1687    fn object_keys_empty_path() {
1688        let source = r"export default { plugins: {} };";
1689        let result = extract_config_object_keys(source, &js_path(), &[]);
1690        assert!(result.is_empty());
1691    }
1692
1693    // ── No config object found ───────────────────────────────────
1694
1695    #[test]
1696    fn no_config_object_returns_empty() {
1697        // Source with no default export or module.exports
1698        let source = r"const x = 42;";
1699        let result = extract_config_string(source, &js_path(), &["key"]);
1700        assert!(result.is_none());
1701
1702        let arr = extract_config_string_array(source, &js_path(), &["items"]);
1703        assert!(arr.is_empty());
1704
1705        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1706        assert!(keys.is_empty());
1707    }
1708
1709    // ── String literal with string key property ──────────────────
1710
1711    #[test]
1712    fn property_with_string_key() {
1713        let source = r#"export default { "string-key": "value" };"#;
1714        let val = extract_config_string(source, &js_path(), &["string-key"]);
1715        assert_eq!(val, Some("value".to_string()));
1716    }
1717
1718    #[test]
1719    fn nested_navigation_through_non_object() {
1720        // Trying to navigate through a string value should return None
1721        let source = r#"export default { level1: "not-an-object" };"#;
1722        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
1723        assert!(val.is_none());
1724    }
1725
1726    // ── Variable reference resolution ───────────────────────────
1727
1728    #[test]
1729    fn variable_reference_untyped() {
1730        let source = r#"
1731            const config = {
1732                testDir: "./tests"
1733            };
1734            export default config;
1735        "#;
1736        let val = extract_config_string(source, &js_path(), &["testDir"]);
1737        assert_eq!(val, Some("./tests".to_string()));
1738    }
1739
1740    #[test]
1741    fn variable_reference_with_type_annotation() {
1742        let source = r#"
1743            import type { StorybookConfig } from '@storybook/react-vite';
1744            const config: StorybookConfig = {
1745                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
1746                framework: "@storybook/react-vite"
1747            };
1748            export default config;
1749        "#;
1750        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
1751        assert_eq!(
1752            addons,
1753            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
1754        );
1755
1756        let framework = extract_config_string(source, &ts_path(), &["framework"]);
1757        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
1758    }
1759
1760    #[test]
1761    fn variable_reference_with_define_config() {
1762        let source = r#"
1763            import { defineConfig } from 'vitest/config';
1764            const config = defineConfig({
1765                test: {
1766                    include: ["**/*.test.ts"]
1767                }
1768            });
1769            export default config;
1770        "#;
1771        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1772        assert_eq!(include, vec!["**/*.test.ts"]);
1773    }
1774
1775    // ── TS type annotation wrappers ─────────────────────────────
1776
1777    #[test]
1778    fn ts_satisfies_direct_export() {
1779        let source = r#"
1780            export default {
1781                testDir: "./tests"
1782            } satisfies PlaywrightTestConfig;
1783        "#;
1784        let val = extract_config_string(source, &ts_path(), &["testDir"]);
1785        assert_eq!(val, Some("./tests".to_string()));
1786    }
1787
1788    #[test]
1789    fn ts_as_direct_export() {
1790        let source = r#"
1791            export default {
1792                testDir: "./tests"
1793            } as const;
1794        "#;
1795        let val = extract_config_string(source, &ts_path(), &["testDir"]);
1796        assert_eq!(val, Some("./tests".to_string()));
1797    }
1798}