|
1 | 1 | use std::{borrow::Cow, path::Path, sync::LazyLock}; |
2 | 2 |
|
3 | 3 | use ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string}; |
| 4 | +use ast_grep_core::{Doc, Node}; |
4 | 5 | use ast_grep_language::{LanguageExt, SupportLang}; |
5 | 6 | use regex::Regex; |
6 | 7 | use vite_error::Error; |
@@ -128,6 +129,93 @@ fn strip_schema_property(config: &str) -> Cow<'_, str> { |
128 | 129 | RE_SCHEMA.replace_all(config, "") |
129 | 130 | } |
130 | 131 |
|
| 132 | +/// Check whether `config_key` is already declared as a top-level property in |
| 133 | +/// the vite config's `defineConfig({...})` (or equivalent) object literal. |
| 134 | +/// |
| 135 | +/// Mirrors the six shapes the merger understands (see `generate_merge_rule`): |
| 136 | +/// `defineConfig({...})`, `defineConfig((p) => ({...}))`, `return {...}` |
| 137 | +/// inside a `defineConfig` callback, `export default {...}`, and the |
| 138 | +/// `satisfies` export variant. The `return $VAR` variant cannot be inspected |
| 139 | +/// statically — for that shape we conservatively report `false`, which is |
| 140 | +/// safe because the merger uses object spread (`{ key: ..., ...$VAR }`) so |
| 141 | +/// duplicate keys are resolved at runtime by JS spread semantics. |
| 142 | +/// |
| 143 | +/// Returns `true` only when the key appears as a **direct** member of one of |
| 144 | +/// those recognized object literals. Comments, string occurrences, nested |
| 145 | +/// keys (e.g. `plugins: [{ fmt: ... }]`), and unrelated objects are all |
| 146 | +/// ignored correctly. |
| 147 | +pub fn has_config_key(vite_config_content: &str, config_key: &str) -> Result<bool, Error> { |
| 148 | + let grep = SupportLang::TypeScript.ast_grep(vite_config_content); |
| 149 | + let root = grep.root(); |
| 150 | + |
| 151 | + for node in root.dfs() { |
| 152 | + if node.kind() != "pair" { |
| 153 | + continue; |
| 154 | + } |
| 155 | + let Some(key_node) = node.field("key") else { continue }; |
| 156 | + if !pair_key_matches(&key_node, config_key) { |
| 157 | + continue; |
| 158 | + } |
| 159 | + let Some(parent_object) = node.parent() else { continue }; |
| 160 | + if parent_object.kind() != "object" { |
| 161 | + continue; |
| 162 | + } |
| 163 | + if is_recognized_config_object(&parent_object) { |
| 164 | + return Ok(true); |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + Ok(false) |
| 169 | +} |
| 170 | + |
| 171 | +fn pair_key_matches<D: Doc>(key_node: &Node<'_, D>, config_key: &str) -> bool { |
| 172 | + let text = key_node.text(); |
| 173 | + match key_node.kind().as_ref() { |
| 174 | + "property_identifier" => text == config_key, |
| 175 | + "string" => text.trim_matches(|c| c == '"' || c == '\'' || c == '`') == config_key, |
| 176 | + _ => false, |
| 177 | + } |
| 178 | +} |
| 179 | + |
| 180 | +fn is_recognized_config_object<D: Doc>(object_node: &Node<'_, D>) -> bool { |
| 181 | + let Some(parent) = object_node.parent() else { return false }; |
| 182 | + match parent.kind().as_ref() { |
| 183 | + "export_statement" => true, |
| 184 | + // `export default { ... } satisfies T` — hop past the satisfies wrapper. |
| 185 | + "satisfies_expression" => parent.parent().is_some_and(|p| p.kind() == "export_statement"), |
| 186 | + "arguments" => parent.parent().is_some_and(|c| is_define_config_call(&c)), |
| 187 | + "parenthesized_expression" => is_define_config_arrow_body(&parent), |
| 188 | + "return_statement" => is_inside_define_config_callback(&parent), |
| 189 | + _ => false, |
| 190 | + } |
| 191 | +} |
| 192 | + |
| 193 | +fn is_define_config_call<D: Doc>(call_node: &Node<'_, D>) -> bool { |
| 194 | + call_node.kind() == "call_expression" |
| 195 | + && call_node.field("function").is_some_and(|f| f.text() == "defineConfig") |
| 196 | +} |
| 197 | + |
| 198 | +fn is_define_config_arrow_body<D: Doc>(paren_node: &Node<'_, D>) -> bool { |
| 199 | + paren_node |
| 200 | + .parent() |
| 201 | + .filter(|n| n.kind() == "arrow_function") |
| 202 | + .and_then(|n| n.parent()) |
| 203 | + .filter(|n| n.kind() == "arguments") |
| 204 | + .and_then(|n| n.parent()) |
| 205 | + .is_some_and(|c| is_define_config_call(&c)) |
| 206 | +} |
| 207 | + |
| 208 | +fn is_inside_define_config_callback<D: Doc>(node: &Node<'_, D>) -> bool { |
| 209 | + let mut current = node.parent(); |
| 210 | + while let Some(n) = current { |
| 211 | + if is_define_config_call(&n) { |
| 212 | + return true; |
| 213 | + } |
| 214 | + current = n.parent(); |
| 215 | + } |
| 216 | + false |
| 217 | +} |
| 218 | + |
131 | 219 | /// Check if the vite config uses a function callback pattern |
132 | 220 | fn check_function_callback(vite_config_content: &str) -> Result<bool, Error> { |
133 | 221 | // Match both sync and async arrow functions |
@@ -358,6 +446,197 @@ mod tests { |
358 | 446 |
|
359 | 447 | use super::*; |
360 | 448 |
|
| 449 | + // ── has_config_key ──────────────────────────────────────────────────── |
| 450 | + |
| 451 | + #[test] |
| 452 | + fn test_has_config_key_top_level_in_defineconfig() { |
| 453 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 454 | +
|
| 455 | +export default defineConfig({ |
| 456 | + fmt: { singleQuote: true }, |
| 457 | + lint: { rules: {} }, |
| 458 | +}); |
| 459 | +"#; |
| 460 | + assert!(has_config_key(cfg, "fmt").unwrap()); |
| 461 | + assert!(has_config_key(cfg, "lint").unwrap()); |
| 462 | + assert!(!has_config_key(cfg, "pack").unwrap()); |
| 463 | + assert!(!has_config_key(cfg, "staged").unwrap()); |
| 464 | + } |
| 465 | + |
| 466 | + #[test] |
| 467 | + fn test_has_config_key_quoted_key() { |
| 468 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 469 | +
|
| 470 | +export default defineConfig({ |
| 471 | + 'fmt': { singleQuote: true }, |
| 472 | + "lint": {}, |
| 473 | +}); |
| 474 | +"#; |
| 475 | + assert!(has_config_key(cfg, "fmt").unwrap()); |
| 476 | + assert!(has_config_key(cfg, "lint").unwrap()); |
| 477 | + } |
| 478 | + |
| 479 | + #[test] |
| 480 | + fn test_has_config_key_ignores_comment_mentions() { |
| 481 | + // The regex check was a false positive on these — AST check ignores them. |
| 482 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 483 | +
|
| 484 | +// fmt: configure formatter here |
| 485 | +/* lint: TODO wire this up */ |
| 486 | +export default defineConfig({ |
| 487 | + plugins: [], |
| 488 | +}); |
| 489 | +"#; |
| 490 | + assert!(!has_config_key(cfg, "fmt").unwrap()); |
| 491 | + assert!(!has_config_key(cfg, "lint").unwrap()); |
| 492 | + } |
| 493 | + |
| 494 | + #[test] |
| 495 | + fn test_has_config_key_ignores_string_literal_mentions() { |
| 496 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 497 | +
|
| 498 | +export default defineConfig({ |
| 499 | + plugins: [], |
| 500 | + description: 'has fmt: foo and lint: bar inside', |
| 501 | +}); |
| 502 | +"#; |
| 503 | + assert!(!has_config_key(cfg, "fmt").unwrap()); |
| 504 | + assert!(!has_config_key(cfg, "lint").unwrap()); |
| 505 | + } |
| 506 | + |
| 507 | + #[test] |
| 508 | + fn test_has_config_key_ignores_nested_keys() { |
| 509 | + // `fmt:` is a nested property inside `plugins[0].options`, not top-level. |
| 510 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 511 | +
|
| 512 | +export default defineConfig({ |
| 513 | + plugins: [ |
| 514 | + somePlugin({ |
| 515 | + fmt: 'auto', |
| 516 | + lint: { enabled: true }, |
| 517 | + }), |
| 518 | + ], |
| 519 | +}); |
| 520 | +"#; |
| 521 | + assert!(!has_config_key(cfg, "fmt").unwrap()); |
| 522 | + assert!(!has_config_key(cfg, "lint").unwrap()); |
| 523 | + } |
| 524 | + |
| 525 | + #[test] |
| 526 | + fn test_has_config_key_arrow_callback() { |
| 527 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 528 | +
|
| 529 | +export default defineConfig((env) => ({ |
| 530 | + fmt: { singleQuote: env.mode === 'production' }, |
| 531 | +})); |
| 532 | +"#; |
| 533 | + assert!(has_config_key(cfg, "fmt").unwrap()); |
| 534 | + assert!(!has_config_key(cfg, "lint").unwrap()); |
| 535 | + } |
| 536 | + |
| 537 | + #[test] |
| 538 | + fn test_has_config_key_return_block_callback() { |
| 539 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 540 | +
|
| 541 | +export default defineConfig(({ mode }) => { |
| 542 | + return { |
| 543 | + fmt: { singleQuote: true }, |
| 544 | + }; |
| 545 | +}); |
| 546 | +"#; |
| 547 | + assert!(has_config_key(cfg, "fmt").unwrap()); |
| 548 | + assert!(!has_config_key(cfg, "lint").unwrap()); |
| 549 | + } |
| 550 | + |
| 551 | + #[test] |
| 552 | + fn test_has_config_key_async_return_block_callback() { |
| 553 | + let cfg = r#" |
| 554 | +export default defineConfig(async ({ command, mode }) => { |
| 555 | + const data = await asyncFunction(); |
| 556 | + return { |
| 557 | + lint: { rules: {} }, |
| 558 | + }; |
| 559 | +}); |
| 560 | +"#; |
| 561 | + assert!(has_config_key(cfg, "lint").unwrap()); |
| 562 | + assert!(!has_config_key(cfg, "fmt").unwrap()); |
| 563 | + } |
| 564 | + |
| 565 | + #[test] |
| 566 | + fn test_has_config_key_plain_export() { |
| 567 | + let cfg = r#"export default { |
| 568 | + fmt: { singleQuote: true }, |
| 569 | +}; |
| 570 | +"#; |
| 571 | + assert!(has_config_key(cfg, "fmt").unwrap()); |
| 572 | + assert!(!has_config_key(cfg, "lint").unwrap()); |
| 573 | + } |
| 574 | + |
| 575 | + #[test] |
| 576 | + fn test_has_config_key_satisfies_export() { |
| 577 | + let cfg = r#"import type { UserConfig } from 'vite-plus'; |
| 578 | +
|
| 579 | +export default { |
| 580 | + lint: { rules: {} }, |
| 581 | +} satisfies UserConfig; |
| 582 | +"#; |
| 583 | + assert!(has_config_key(cfg, "lint").unwrap()); |
| 584 | + assert!(!has_config_key(cfg, "fmt").unwrap()); |
| 585 | + } |
| 586 | + |
| 587 | + #[test] |
| 588 | + fn test_has_config_key_return_variable_is_unknown() { |
| 589 | + // The merger handles this via object spread, so duplication is benign. |
| 590 | + // We conservatively report `false`. |
| 591 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 592 | +
|
| 593 | +export default defineConfig(({ mode }) => { |
| 594 | + const configObject = { fmt: { singleQuote: true } }; |
| 595 | + return configObject; |
| 596 | +}); |
| 597 | +"#; |
| 598 | + assert!(!has_config_key(cfg, "fmt").unwrap()); |
| 599 | + } |
| 600 | + |
| 601 | + #[test] |
| 602 | + fn test_has_config_key_arrow_wrapper_around_defineconfig() { |
| 603 | + // export default () => defineConfig({ ... }) — the wrapper is irrelevant; |
| 604 | + // detection follows the defineConfig argument object. |
| 605 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 606 | +
|
| 607 | +export default () => |
| 608 | + defineConfig({ |
| 609 | + fmt: { singleQuote: true }, |
| 610 | + }); |
| 611 | +"#; |
| 612 | + assert!(has_config_key(cfg, "fmt").unwrap()); |
| 613 | + } |
| 614 | + |
| 615 | + #[test] |
| 616 | + fn test_has_config_key_fate_template_shape() { |
| 617 | + // Mirrors create-fate's drizzle template — the bug that motivated this fix. |
| 618 | + let cfg = r#"import { defineConfig } from 'vite-plus'; |
| 619 | +
|
| 620 | +export default defineConfig({ |
| 621 | + fmt: { |
| 622 | + experimentalSortImports: { newlinesBetween: false }, |
| 623 | + ignorePatterns: ['coverage/', 'dist/'], |
| 624 | + singleQuote: true, |
| 625 | + }, |
| 626 | + lint: { |
| 627 | + extends: [nkzw], |
| 628 | + options: { typeAware: true, typeCheck: true }, |
| 629 | + rules: { '@typescript-eslint/no-explicit-any': 'off' }, |
| 630 | + }, |
| 631 | + staged: { '*': 'vp check --fix' }, |
| 632 | +}); |
| 633 | +"#; |
| 634 | + assert!(has_config_key(cfg, "fmt").unwrap()); |
| 635 | + assert!(has_config_key(cfg, "lint").unwrap()); |
| 636 | + assert!(has_config_key(cfg, "staged").unwrap()); |
| 637 | + assert!(!has_config_key(cfg, "pack").unwrap()); |
| 638 | + } |
| 639 | + |
361 | 640 | #[test] |
362 | 641 | fn test_check_function_callback() { |
363 | 642 | let simple_config = r#" |
|
0 commit comments