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