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        // Handle objects: { "key": "value" } or { "key": ["pkg", { opts }] } → extract values
806        Expression::ObjectExpression(obj) => {
807            for prop in &obj.properties {
808                if let ObjectPropertyKind::ObjectProperty(p) = prop {
809                    match &p.value {
810                        Expression::StringLiteral(s) => {
811                            values.push(s.value.to_string());
812                        }
813                        // Handle tuples: { "key": ["pkg-name", { options }] }
814                        Expression::ArrayExpression(sub_arr) => {
815                            if let Some(first) = sub_arr.elements.first()
816                                && let Some(first_expr) = first.as_expression()
817                                && let Some(s) = expression_to_string(first_expr)
818                            {
819                                values.push(s);
820                            }
821                        }
822                        _ => {}
823                    }
824                }
825            }
826        }
827        _ => {}
828    }
829    values
830}
831
832/// Recursively collect all string literal values from an expression tree.
833fn collect_all_string_values(expr: &Expression, values: &mut Vec<String>) {
834    match expr {
835        Expression::StringLiteral(s) => {
836            values.push(s.value.to_string());
837        }
838        Expression::ArrayExpression(arr) => {
839            for el in &arr.elements {
840                if let Some(expr) = el.as_expression() {
841                    collect_all_string_values(expr, values);
842                }
843            }
844        }
845        Expression::ObjectExpression(obj) => {
846            for prop in &obj.properties {
847                if let ObjectPropertyKind::ObjectProperty(p) = prop {
848                    collect_all_string_values(&p.value, values);
849                }
850            }
851        }
852        _ => {}
853    }
854}
855
856/// Convert a `PropertyKey` to a `String`.
857fn property_key_to_string(key: &PropertyKey) -> Option<String> {
858    match key {
859        PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()),
860        PropertyKey::StringLiteral(s) => Some(s.value.to_string()),
861        _ => None,
862    }
863}
864
865/// Extract keys of an object at a nested property path.
866fn get_nested_object_keys(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
867    if path.is_empty() {
868        return None;
869    }
870    let prop = find_property(obj, path[0])?;
871    if path.len() == 1 {
872        if let Expression::ObjectExpression(nested) = &prop.value {
873            let keys = nested
874                .properties
875                .iter()
876                .filter_map(|p| {
877                    if let ObjectPropertyKind::ObjectProperty(p) = p {
878                        property_key_to_string(&p.key)
879                    } else {
880                        None
881                    }
882                })
883                .collect();
884            return Some(keys);
885        }
886        return None;
887    }
888    if let Expression::ObjectExpression(nested) = &prop.value {
889        get_nested_object_keys(nested, &path[1..])
890    } else {
891        None
892    }
893}
894
895/// Navigate a nested property path and return the raw expression at the end.
896fn get_nested_expression<'a>(
897    obj: &'a ObjectExpression<'a>,
898    path: &[&str],
899) -> Option<&'a Expression<'a>> {
900    if path.is_empty() {
901        return None;
902    }
903    let prop = find_property(obj, path[0])?;
904    if path.len() == 1 {
905        return Some(&prop.value);
906    }
907    if let Expression::ObjectExpression(nested) = &prop.value {
908        get_nested_expression(nested, &path[1..])
909    } else {
910        None
911    }
912}
913
914/// Navigate a nested path and extract a string, string array, or object string values.
915fn get_nested_string_or_array(obj: &ObjectExpression, path: &[&str]) -> Option<Vec<String>> {
916    if path.is_empty() {
917        return None;
918    }
919    if path.len() == 1 {
920        let prop = find_property(obj, path[0])?;
921        return Some(expression_to_string_or_array(&prop.value));
922    }
923    let prop = find_property(obj, path[0])?;
924    if let Expression::ObjectExpression(nested) = &prop.value {
925        get_nested_string_or_array(nested, &path[1..])
926    } else {
927        None
928    }
929}
930
931/// Convert an expression to a `Vec<String>`, handling string, array, and object-with-string-values.
932fn expression_to_string_or_array(expr: &Expression) -> Vec<String> {
933    match expr {
934        Expression::StringLiteral(s) => vec![s.value.to_string()],
935        Expression::TemplateLiteral(t) if t.expressions.is_empty() => t
936            .quasis
937            .first()
938            .map(|q| vec![q.value.raw.to_string()])
939            .unwrap_or_default(),
940        Expression::ArrayExpression(arr) => arr
941            .elements
942            .iter()
943            .filter_map(|el| el.as_expression().and_then(expression_to_string))
944            .collect(),
945        Expression::ObjectExpression(obj) => obj
946            .properties
947            .iter()
948            .filter_map(|p| {
949                if let ObjectPropertyKind::ObjectProperty(p) = p {
950                    expression_to_string(&p.value)
951                } else {
952                    None
953                }
954            })
955            .collect(),
956        _ => vec![],
957    }
958}
959
960/// Collect `require('...')` argument strings from an expression.
961fn collect_require_sources(expr: &Expression) -> Vec<String> {
962    let mut sources = Vec::new();
963    match expr {
964        Expression::CallExpression(call) if is_require_call(call) => {
965            if let Some(s) = get_require_source(call) {
966                sources.push(s);
967            }
968        }
969        Expression::ArrayExpression(arr) => {
970            for el in &arr.elements {
971                if let Some(inner) = el.as_expression() {
972                    match inner {
973                        Expression::CallExpression(call) if is_require_call(call) => {
974                            if let Some(s) = get_require_source(call) {
975                                sources.push(s);
976                            }
977                        }
978                        // Tuple: [require('pkg'), options]
979                        Expression::ArrayExpression(sub_arr) => {
980                            if let Some(first) = sub_arr.elements.first()
981                                && let Some(Expression::CallExpression(call)) =
982                                    first.as_expression()
983                                && is_require_call(call)
984                                && let Some(s) = get_require_source(call)
985                            {
986                                sources.push(s);
987                            }
988                        }
989                        _ => {}
990                    }
991                }
992            }
993        }
994        _ => {}
995    }
996    sources
997}
998
999/// Check if a call expression is `require(...)`.
1000fn is_require_call(call: &CallExpression) -> bool {
1001    matches!(&call.callee, Expression::Identifier(id) if id.name == "require")
1002}
1003
1004/// Get the first string argument of a `require()` call.
1005fn get_require_source(call: &CallExpression) -> Option<String> {
1006    call.arguments.first().and_then(|arg| {
1007        if let Argument::StringLiteral(s) = arg {
1008            Some(s.value.to_string())
1009        } else {
1010            None
1011        }
1012    })
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017    use super::*;
1018    use std::path::PathBuf;
1019
1020    fn js_path() -> PathBuf {
1021        PathBuf::from("config.js")
1022    }
1023
1024    fn ts_path() -> PathBuf {
1025        PathBuf::from("config.ts")
1026    }
1027
1028    #[test]
1029    fn extract_imports_basic() {
1030        let source = r"
1031            import foo from 'foo-pkg';
1032            import { bar } from '@scope/bar';
1033            export default {};
1034        ";
1035        let imports = extract_imports(source, &js_path());
1036        assert_eq!(imports, vec!["foo-pkg", "@scope/bar"]);
1037    }
1038
1039    #[test]
1040    fn extract_default_export_object_property() {
1041        let source = r#"export default { testDir: "./tests" };"#;
1042        let val = extract_config_string(source, &js_path(), &["testDir"]);
1043        assert_eq!(val, Some("./tests".to_string()));
1044    }
1045
1046    #[test]
1047    fn extract_define_config_property() {
1048        let source = r#"
1049            import { defineConfig } from 'vitest/config';
1050            export default defineConfig({
1051                test: {
1052                    include: ["**/*.test.ts", "**/*.spec.ts"],
1053                    setupFiles: ["./test/setup.ts"]
1054                }
1055            });
1056        "#;
1057        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1058        assert_eq!(include, vec!["**/*.test.ts", "**/*.spec.ts"]);
1059
1060        let setup = extract_config_string_array(source, &ts_path(), &["test", "setupFiles"]);
1061        assert_eq!(setup, vec!["./test/setup.ts"]);
1062    }
1063
1064    #[test]
1065    fn extract_module_exports_property() {
1066        let source = r#"module.exports = { testEnvironment: "jsdom" };"#;
1067        let val = extract_config_string(source, &js_path(), &["testEnvironment"]);
1068        assert_eq!(val, Some("jsdom".to_string()));
1069    }
1070
1071    #[test]
1072    fn extract_nested_string_array() {
1073        let source = r#"
1074            export default {
1075                resolve: {
1076                    alias: {
1077                        "@": "./src"
1078                    }
1079                },
1080                test: {
1081                    include: ["src/**/*.test.ts"]
1082                }
1083            };
1084        "#;
1085        let include = extract_config_string_array(source, &js_path(), &["test", "include"]);
1086        assert_eq!(include, vec!["src/**/*.test.ts"]);
1087    }
1088
1089    #[test]
1090    fn extract_addons_array() {
1091        let source = r#"
1092            export default {
1093                addons: [
1094                    "@storybook/addon-a11y",
1095                    "@storybook/addon-docs",
1096                    "@storybook/addon-links"
1097                ]
1098            };
1099        "#;
1100        let addons = extract_config_property_strings(source, &ts_path(), "addons");
1101        assert_eq!(
1102            addons,
1103            vec![
1104                "@storybook/addon-a11y",
1105                "@storybook/addon-docs",
1106                "@storybook/addon-links"
1107            ]
1108        );
1109    }
1110
1111    #[test]
1112    fn handle_empty_config() {
1113        let source = "";
1114        let result = extract_config_string(source, &js_path(), &["key"]);
1115        assert_eq!(result, None);
1116    }
1117
1118    // ── extract_config_object_keys tests ────────────────────────────
1119
1120    #[test]
1121    fn object_keys_postcss_plugins() {
1122        let source = r"
1123            module.exports = {
1124                plugins: {
1125                    autoprefixer: {},
1126                    tailwindcss: {},
1127                    'postcss-import': {}
1128                }
1129            };
1130        ";
1131        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1132        assert_eq!(keys, vec!["autoprefixer", "tailwindcss", "postcss-import"]);
1133    }
1134
1135    #[test]
1136    fn object_keys_nested_path() {
1137        let source = r"
1138            export default {
1139                build: {
1140                    plugins: {
1141                        minify: {},
1142                        compress: {}
1143                    }
1144                }
1145            };
1146        ";
1147        let keys = extract_config_object_keys(source, &js_path(), &["build", "plugins"]);
1148        assert_eq!(keys, vec!["minify", "compress"]);
1149    }
1150
1151    #[test]
1152    fn object_keys_empty_object() {
1153        let source = r"export default { plugins: {} };";
1154        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1155        assert!(keys.is_empty());
1156    }
1157
1158    #[test]
1159    fn object_keys_non_object_returns_empty() {
1160        let source = r#"export default { plugins: ["a", "b"] };"#;
1161        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1162        assert!(keys.is_empty());
1163    }
1164
1165    // ── extract_config_string_or_array tests ────────────────────────
1166
1167    #[test]
1168    fn string_or_array_single_string() {
1169        let source = r#"export default { entry: "./src/index.js" };"#;
1170        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1171        assert_eq!(result, vec!["./src/index.js"]);
1172    }
1173
1174    #[test]
1175    fn string_or_array_array() {
1176        let source = r#"export default { entry: ["./src/a.js", "./src/b.js"] };"#;
1177        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1178        assert_eq!(result, vec!["./src/a.js", "./src/b.js"]);
1179    }
1180
1181    #[test]
1182    fn string_or_array_object_values() {
1183        let source =
1184            r#"export default { entry: { main: "./src/main.js", vendor: "./src/vendor.js" } };"#;
1185        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1186        assert_eq!(result, vec!["./src/main.js", "./src/vendor.js"]);
1187    }
1188
1189    #[test]
1190    fn string_or_array_nested_path() {
1191        let source = r#"
1192            export default {
1193                build: {
1194                    rollupOptions: {
1195                        input: ["./index.html", "./about.html"]
1196                    }
1197                }
1198            };
1199        "#;
1200        let result = extract_config_string_or_array(
1201            source,
1202            &js_path(),
1203            &["build", "rollupOptions", "input"],
1204        );
1205        assert_eq!(result, vec!["./index.html", "./about.html"]);
1206    }
1207
1208    #[test]
1209    fn string_or_array_template_literal() {
1210        let source = r"export default { entry: `./src/index.js` };";
1211        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1212        assert_eq!(result, vec!["./src/index.js"]);
1213    }
1214
1215    // ── extract_config_require_strings tests ────────────────────────
1216
1217    #[test]
1218    fn require_strings_array() {
1219        let source = r"
1220            module.exports = {
1221                plugins: [
1222                    require('autoprefixer'),
1223                    require('postcss-import')
1224                ]
1225            };
1226        ";
1227        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1228        assert_eq!(deps, vec!["autoprefixer", "postcss-import"]);
1229    }
1230
1231    #[test]
1232    fn require_strings_with_tuples() {
1233        let source = r"
1234            module.exports = {
1235                plugins: [
1236                    require('autoprefixer'),
1237                    [require('postcss-preset-env'), { stage: 3 }]
1238                ]
1239            };
1240        ";
1241        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1242        assert_eq!(deps, vec!["autoprefixer", "postcss-preset-env"]);
1243    }
1244
1245    #[test]
1246    fn require_strings_empty_array() {
1247        let source = r"module.exports = { plugins: [] };";
1248        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1249        assert!(deps.is_empty());
1250    }
1251
1252    #[test]
1253    fn require_strings_no_require_calls() {
1254        let source = r#"module.exports = { plugins: ["a", "b"] };"#;
1255        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1256        assert!(deps.is_empty());
1257    }
1258
1259    #[test]
1260    fn extract_aliases_from_object_with_file_url_to_path() {
1261        let source = r#"
1262            import { defineConfig } from 'vite';
1263            import { fileURLToPath, URL } from 'node:url';
1264
1265            export default defineConfig({
1266                resolve: {
1267                    alias: {
1268                        "@": fileURLToPath(new URL("./src", import.meta.url))
1269                    }
1270                }
1271            });
1272        "#;
1273
1274        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
1275        assert_eq!(aliases, vec![("@".to_string(), "./src".to_string())]);
1276    }
1277
1278    #[test]
1279    fn extract_aliases_from_array_form() {
1280        let source = r#"
1281            export default {
1282                resolve: {
1283                    alias: [
1284                        { find: "@", replacement: "./src" },
1285                        { find: "$utils", replacement: "src/lib/utils" }
1286                    ]
1287                }
1288            };
1289        "#;
1290
1291        let aliases = extract_config_aliases(source, &ts_path(), &["resolve", "alias"]);
1292        assert_eq!(
1293            aliases,
1294            vec![
1295                ("@".to_string(), "./src".to_string()),
1296                ("$utils".to_string(), "src/lib/utils".to_string())
1297            ]
1298        );
1299    }
1300
1301    #[test]
1302    fn extract_array_object_strings_mixed_forms() {
1303        let source = r#"
1304            export default {
1305                components: [
1306                    "~/components",
1307                    { path: "@/feature-components" }
1308                ]
1309            };
1310        "#;
1311
1312        let values =
1313            extract_config_array_object_strings(source, &ts_path(), &["components"], "path");
1314        assert_eq!(
1315            values,
1316            vec![
1317                "~/components".to_string(),
1318                "@/feature-components".to_string()
1319            ]
1320        );
1321    }
1322
1323    #[test]
1324    fn normalize_config_path_relative_to_root() {
1325        let config_path = PathBuf::from("/project/vite.config.ts");
1326        let root = PathBuf::from("/project");
1327
1328        assert_eq!(
1329            normalize_config_path("./src/lib", &config_path, &root),
1330            Some("src/lib".to_string())
1331        );
1332        assert_eq!(
1333            normalize_config_path("/src/lib", &config_path, &root),
1334            Some("src/lib".to_string())
1335        );
1336    }
1337
1338    // ── JSON wrapped in parens (for tsconfig.json parsing) ──────────
1339
1340    #[test]
1341    fn json_wrapped_in_parens_string() {
1342        let source = r#"({"extends": "@tsconfig/node18/tsconfig.json"})"#;
1343        let val = extract_config_string(source, &js_path(), &["extends"]);
1344        assert_eq!(val, Some("@tsconfig/node18/tsconfig.json".to_string()));
1345    }
1346
1347    #[test]
1348    fn json_wrapped_in_parens_nested_array() {
1349        let source =
1350            r#"({"compilerOptions": {"types": ["node", "jest"]}, "include": ["src/**/*"]})"#;
1351        let types = extract_config_string_array(source, &js_path(), &["compilerOptions", "types"]);
1352        assert_eq!(types, vec!["node", "jest"]);
1353
1354        let include = extract_config_string_array(source, &js_path(), &["include"]);
1355        assert_eq!(include, vec!["src/**/*"]);
1356    }
1357
1358    #[test]
1359    fn json_wrapped_in_parens_object_keys() {
1360        let source = r#"({"plugins": {"autoprefixer": {}, "tailwindcss": {}}})"#;
1361        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1362        assert_eq!(keys, vec!["autoprefixer", "tailwindcss"]);
1363    }
1364
1365    // ── JSON file extension detection ────────────────────────────
1366
1367    fn json_path() -> PathBuf {
1368        PathBuf::from("config.json")
1369    }
1370
1371    #[test]
1372    fn json_file_parsed_correctly() {
1373        let source = r#"{"key": "value", "list": ["a", "b"]}"#;
1374        let val = extract_config_string(source, &json_path(), &["key"]);
1375        assert_eq!(val, Some("value".to_string()));
1376
1377        let list = extract_config_string_array(source, &json_path(), &["list"]);
1378        assert_eq!(list, vec!["a", "b"]);
1379    }
1380
1381    #[test]
1382    fn jsonc_file_parsed_correctly() {
1383        let source = r#"{"key": "value"}"#;
1384        let path = PathBuf::from("tsconfig.jsonc");
1385        let val = extract_config_string(source, &path, &["key"]);
1386        assert_eq!(val, Some("value".to_string()));
1387    }
1388
1389    // ── defineConfig with arrow function ─────────────────────────
1390
1391    #[test]
1392    fn extract_define_config_arrow_function() {
1393        let source = r#"
1394            import { defineConfig } from 'vite';
1395            export default defineConfig(() => ({
1396                test: {
1397                    include: ["**/*.test.ts"]
1398                }
1399            }));
1400        "#;
1401        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1402        assert_eq!(include, vec!["**/*.test.ts"]);
1403    }
1404
1405    // ── module.exports with nested properties ────────────────────
1406
1407    #[test]
1408    fn module_exports_nested_string() {
1409        let source = r#"
1410            module.exports = {
1411                resolve: {
1412                    alias: {
1413                        "@": "./src"
1414                    }
1415                }
1416            };
1417        "#;
1418        let val = extract_config_string(source, &js_path(), &["resolve", "alias", "@"]);
1419        assert_eq!(val, Some("./src".to_string()));
1420    }
1421
1422    // ── extract_config_property_strings (recursive) ──────────────
1423
1424    #[test]
1425    fn property_strings_nested_objects() {
1426        let source = r#"
1427            export default {
1428                plugins: {
1429                    group1: { a: "val-a" },
1430                    group2: { b: "val-b" }
1431                }
1432            };
1433        "#;
1434        let values = extract_config_property_strings(source, &js_path(), "plugins");
1435        assert!(values.contains(&"val-a".to_string()));
1436        assert!(values.contains(&"val-b".to_string()));
1437    }
1438
1439    #[test]
1440    fn property_strings_missing_key_returns_empty() {
1441        let source = r#"export default { other: "value" };"#;
1442        let values = extract_config_property_strings(source, &js_path(), "missing");
1443        assert!(values.is_empty());
1444    }
1445
1446    // ── extract_config_shallow_strings ────────────────────────────
1447
1448    #[test]
1449    fn shallow_strings_tuple_array() {
1450        let source = r#"
1451            module.exports = {
1452                reporters: ["default", ["jest-junit", { outputDirectory: "reports" }]]
1453            };
1454        "#;
1455        let values = extract_config_shallow_strings(source, &js_path(), "reporters");
1456        assert_eq!(values, vec!["default", "jest-junit"]);
1457        // "reports" should NOT be extracted (it's inside an options object)
1458        assert!(!values.contains(&"reports".to_string()));
1459    }
1460
1461    #[test]
1462    fn shallow_strings_single_string() {
1463        let source = r#"export default { preset: "ts-jest" };"#;
1464        let values = extract_config_shallow_strings(source, &js_path(), "preset");
1465        assert_eq!(values, vec!["ts-jest"]);
1466    }
1467
1468    #[test]
1469    fn shallow_strings_missing_key() {
1470        let source = r#"export default { other: "val" };"#;
1471        let values = extract_config_shallow_strings(source, &js_path(), "missing");
1472        assert!(values.is_empty());
1473    }
1474
1475    // ── extract_config_nested_shallow_strings tests ──────────────
1476
1477    #[test]
1478    fn nested_shallow_strings_vitest_reporters() {
1479        let source = r#"
1480            export default {
1481                test: {
1482                    reporters: ["default", "vitest-sonar-reporter"]
1483                }
1484            };
1485        "#;
1486        let values =
1487            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1488        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1489    }
1490
1491    #[test]
1492    fn nested_shallow_strings_tuple_format() {
1493        let source = r#"
1494            export default {
1495                test: {
1496                    reporters: ["default", ["vitest-sonar-reporter", { outputFile: "report.xml" }]]
1497                }
1498            };
1499        "#;
1500        let values =
1501            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1502        assert_eq!(values, vec!["default", "vitest-sonar-reporter"]);
1503    }
1504
1505    #[test]
1506    fn nested_shallow_strings_missing_outer() {
1507        let source = r"export default { other: {} };";
1508        let values =
1509            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1510        assert!(values.is_empty());
1511    }
1512
1513    #[test]
1514    fn nested_shallow_strings_missing_inner() {
1515        let source = r#"export default { test: { include: ["**/*.test.ts"] } };"#;
1516        let values =
1517            extract_config_nested_shallow_strings(source, &js_path(), &["test"], "reporters");
1518        assert!(values.is_empty());
1519    }
1520
1521    // ── extract_config_string_or_array edge cases ────────────────
1522
1523    #[test]
1524    fn string_or_array_missing_path() {
1525        let source = r"export default {};";
1526        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1527        assert!(result.is_empty());
1528    }
1529
1530    #[test]
1531    fn string_or_array_non_string_values() {
1532        // When values are not strings (e.g., numbers), they should be skipped
1533        let source = r"export default { entry: [42, true] };";
1534        let result = extract_config_string_or_array(source, &js_path(), &["entry"]);
1535        assert!(result.is_empty());
1536    }
1537
1538    // ── extract_config_array_nested_string_or_array ──────────────
1539
1540    #[test]
1541    fn array_nested_extraction() {
1542        let source = r#"
1543            export default defineConfig({
1544                test: {
1545                    projects: [
1546                        {
1547                            test: {
1548                                setupFiles: ["./test/setup-a.ts"]
1549                            }
1550                        },
1551                        {
1552                            test: {
1553                                setupFiles: "./test/setup-b.ts"
1554                            }
1555                        }
1556                    ]
1557                }
1558            });
1559        "#;
1560        let results = extract_config_array_nested_string_or_array(
1561            source,
1562            &ts_path(),
1563            &["test", "projects"],
1564            &["test", "setupFiles"],
1565        );
1566        assert!(results.contains(&"./test/setup-a.ts".to_string()));
1567        assert!(results.contains(&"./test/setup-b.ts".to_string()));
1568    }
1569
1570    #[test]
1571    fn array_nested_empty_when_no_array() {
1572        let source = r#"export default { test: { projects: "not-an-array" } };"#;
1573        let results = extract_config_array_nested_string_or_array(
1574            source,
1575            &js_path(),
1576            &["test", "projects"],
1577            &["test", "setupFiles"],
1578        );
1579        assert!(results.is_empty());
1580    }
1581
1582    // ── extract_config_object_nested_string_or_array ─────────────
1583
1584    #[test]
1585    fn object_nested_extraction() {
1586        let source = r#"{
1587            "projects": {
1588                "app-one": {
1589                    "architect": {
1590                        "build": {
1591                            "options": {
1592                                "styles": ["src/styles.css"]
1593                            }
1594                        }
1595                    }
1596                }
1597            }
1598        }"#;
1599        let results = extract_config_object_nested_string_or_array(
1600            source,
1601            &json_path(),
1602            &["projects"],
1603            &["architect", "build", "options", "styles"],
1604        );
1605        assert_eq!(results, vec!["src/styles.css"]);
1606    }
1607
1608    // ── extract_config_object_nested_strings ─────────────────────
1609
1610    #[test]
1611    fn object_nested_strings_extraction() {
1612        let source = r#"{
1613            "targets": {
1614                "build": {
1615                    "executor": "@angular/build:application"
1616                },
1617                "test": {
1618                    "executor": "@nx/vite:test"
1619                }
1620            }
1621        }"#;
1622        let results =
1623            extract_config_object_nested_strings(source, &json_path(), &["targets"], &["executor"]);
1624        assert!(results.contains(&"@angular/build:application".to_string()));
1625        assert!(results.contains(&"@nx/vite:test".to_string()));
1626    }
1627
1628    // ── extract_config_require_strings edge cases ────────────────
1629
1630    #[test]
1631    fn require_strings_direct_call() {
1632        let source = r"module.exports = { adapter: require('@sveltejs/adapter-node') };";
1633        let deps = extract_config_require_strings(source, &js_path(), "adapter");
1634        assert_eq!(deps, vec!["@sveltejs/adapter-node"]);
1635    }
1636
1637    #[test]
1638    fn require_strings_no_matching_key() {
1639        let source = r"module.exports = { other: require('something') };";
1640        let deps = extract_config_require_strings(source, &js_path(), "plugins");
1641        assert!(deps.is_empty());
1642    }
1643
1644    // ── extract_imports edge cases ───────────────────────────────
1645
1646    #[test]
1647    fn extract_imports_no_imports() {
1648        let source = r"export default {};";
1649        let imports = extract_imports(source, &js_path());
1650        assert!(imports.is_empty());
1651    }
1652
1653    #[test]
1654    fn extract_imports_side_effect_import() {
1655        let source = r"
1656            import 'polyfill';
1657            import './local-setup';
1658            export default {};
1659        ";
1660        let imports = extract_imports(source, &js_path());
1661        assert_eq!(imports, vec!["polyfill", "./local-setup"]);
1662    }
1663
1664    #[test]
1665    fn extract_imports_mixed_specifiers() {
1666        let source = r"
1667            import defaultExport from 'module-a';
1668            import { named } from 'module-b';
1669            import * as ns from 'module-c';
1670            export default {};
1671        ";
1672        let imports = extract_imports(source, &js_path());
1673        assert_eq!(imports, vec!["module-a", "module-b", "module-c"]);
1674    }
1675
1676    // ── Template literal support ─────────────────────────────────
1677
1678    #[test]
1679    fn template_literal_in_string_or_array() {
1680        let source = r"export default { entry: `./src/index.ts` };";
1681        let result = extract_config_string_or_array(source, &ts_path(), &["entry"]);
1682        assert_eq!(result, vec!["./src/index.ts"]);
1683    }
1684
1685    #[test]
1686    fn template_literal_in_config_string() {
1687        let source = r"export default { testDir: `./tests` };";
1688        let val = extract_config_string(source, &js_path(), &["testDir"]);
1689        assert_eq!(val, Some("./tests".to_string()));
1690    }
1691
1692    // ── Empty/missing path navigation ────────────────────────────
1693
1694    #[test]
1695    fn nested_string_array_empty_path() {
1696        let source = r#"export default { items: ["a", "b"] };"#;
1697        let result = extract_config_string_array(source, &js_path(), &[]);
1698        assert!(result.is_empty());
1699    }
1700
1701    #[test]
1702    fn nested_string_empty_path() {
1703        let source = r#"export default { key: "val" };"#;
1704        let result = extract_config_string(source, &js_path(), &[]);
1705        assert!(result.is_none());
1706    }
1707
1708    #[test]
1709    fn object_keys_empty_path() {
1710        let source = r"export default { plugins: {} };";
1711        let result = extract_config_object_keys(source, &js_path(), &[]);
1712        assert!(result.is_empty());
1713    }
1714
1715    // ── No config object found ───────────────────────────────────
1716
1717    #[test]
1718    fn no_config_object_returns_empty() {
1719        // Source with no default export or module.exports
1720        let source = r"const x = 42;";
1721        let result = extract_config_string(source, &js_path(), &["key"]);
1722        assert!(result.is_none());
1723
1724        let arr = extract_config_string_array(source, &js_path(), &["items"]);
1725        assert!(arr.is_empty());
1726
1727        let keys = extract_config_object_keys(source, &js_path(), &["plugins"]);
1728        assert!(keys.is_empty());
1729    }
1730
1731    // ── String literal with string key property ──────────────────
1732
1733    #[test]
1734    fn property_with_string_key() {
1735        let source = r#"export default { "string-key": "value" };"#;
1736        let val = extract_config_string(source, &js_path(), &["string-key"]);
1737        assert_eq!(val, Some("value".to_string()));
1738    }
1739
1740    #[test]
1741    fn nested_navigation_through_non_object() {
1742        // Trying to navigate through a string value should return None
1743        let source = r#"export default { level1: "not-an-object" };"#;
1744        let val = extract_config_string(source, &js_path(), &["level1", "level2"]);
1745        assert!(val.is_none());
1746    }
1747
1748    // ── Variable reference resolution ───────────────────────────
1749
1750    #[test]
1751    fn variable_reference_untyped() {
1752        let source = r#"
1753            const config = {
1754                testDir: "./tests"
1755            };
1756            export default config;
1757        "#;
1758        let val = extract_config_string(source, &js_path(), &["testDir"]);
1759        assert_eq!(val, Some("./tests".to_string()));
1760    }
1761
1762    #[test]
1763    fn variable_reference_with_type_annotation() {
1764        let source = r#"
1765            import type { StorybookConfig } from '@storybook/react-vite';
1766            const config: StorybookConfig = {
1767                addons: ["@storybook/addon-a11y", "@storybook/addon-docs"],
1768                framework: "@storybook/react-vite"
1769            };
1770            export default config;
1771        "#;
1772        let addons = extract_config_shallow_strings(source, &ts_path(), "addons");
1773        assert_eq!(
1774            addons,
1775            vec!["@storybook/addon-a11y", "@storybook/addon-docs"]
1776        );
1777
1778        let framework = extract_config_string(source, &ts_path(), &["framework"]);
1779        assert_eq!(framework, Some("@storybook/react-vite".to_string()));
1780    }
1781
1782    #[test]
1783    fn variable_reference_with_define_config() {
1784        let source = r#"
1785            import { defineConfig } from 'vitest/config';
1786            const config = defineConfig({
1787                test: {
1788                    include: ["**/*.test.ts"]
1789                }
1790            });
1791            export default config;
1792        "#;
1793        let include = extract_config_string_array(source, &ts_path(), &["test", "include"]);
1794        assert_eq!(include, vec!["**/*.test.ts"]);
1795    }
1796
1797    // ── TS type annotation wrappers ─────────────────────────────
1798
1799    #[test]
1800    fn ts_satisfies_direct_export() {
1801        let source = r#"
1802            export default {
1803                testDir: "./tests"
1804            } satisfies PlaywrightTestConfig;
1805        "#;
1806        let val = extract_config_string(source, &ts_path(), &["testDir"]);
1807        assert_eq!(val, Some("./tests".to_string()));
1808    }
1809
1810    #[test]
1811    fn ts_as_direct_export() {
1812        let source = r#"
1813            export default {
1814                testDir: "./tests"
1815            } as const;
1816        "#;
1817        let val = extract_config_string(source, &ts_path(), &["testDir"]);
1818        assert_eq!(val, Some("./tests".to_string()));
1819    }
1820}