Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/vite_migration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,4 @@ mod vite_config;
pub use file_walker::{WalkResult, find_ts_files};
pub use import_rewriter::{BatchRewriteResult, rewrite_imports_in_directory};
pub use package::{rewrite_eslint, rewrite_prettier, rewrite_scripts};
pub use vite_config::{MergeResult, merge_json_config, merge_tsdown_config};
pub use vite_config::{MergeResult, has_config_key, merge_json_config, merge_tsdown_config};
279 changes: 279 additions & 0 deletions crates/vite_migration/src/vite_config.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use std::{borrow::Cow, path::Path, sync::LazyLock};

use ast_grep_config::{GlobalRules, RuleConfig, from_yaml_string};
use ast_grep_core::{Doc, Node};
use ast_grep_language::{LanguageExt, SupportLang};
use regex::Regex;
use vite_error::Error;
Expand Down Expand Up @@ -128,6 +129,93 @@ fn strip_schema_property(config: &str) -> Cow<'_, str> {
RE_SCHEMA.replace_all(config, "")
}

/// Check whether `config_key` is already declared as a top-level property in
/// the vite config's `defineConfig({...})` (or equivalent) object literal.
///
/// Mirrors the six shapes the merger understands (see `generate_merge_rule`):
/// `defineConfig({...})`, `defineConfig((p) => ({...}))`, `return {...}`
/// inside a `defineConfig` callback, `export default {...}`, and the
/// `satisfies` export variant. The `return $VAR` variant cannot be inspected
/// statically — for that shape we conservatively report `false`, which is
/// safe because the merger uses object spread (`{ key: ..., ...$VAR }`) so
/// duplicate keys are resolved at runtime by JS spread semantics.
///
/// Returns `true` only when the key appears as a **direct** member of one of
/// those recognized object literals. Comments, string occurrences, nested
/// keys (e.g. `plugins: [{ fmt: ... }]`), and unrelated objects are all
/// ignored correctly.
pub fn has_config_key(vite_config_content: &str, config_key: &str) -> Result<bool, Error> {
let grep = SupportLang::TypeScript.ast_grep(vite_config_content);
let root = grep.root();

for node in root.dfs() {
if node.kind() != "pair" {
continue;
}
let Some(key_node) = node.field("key") else { continue };
if !pair_key_matches(&key_node, config_key) {
continue;
}
let Some(parent_object) = node.parent() else { continue };
if parent_object.kind() != "object" {
continue;
}
if is_recognized_config_object(&parent_object) {
return Ok(true);
}
}

Ok(false)
}

fn pair_key_matches<D: Doc>(key_node: &Node<'_, D>, config_key: &str) -> bool {
let text = key_node.text();
match key_node.kind().as_ref() {
"property_identifier" => text == config_key,
"string" => text.trim_matches(|c| c == '"' || c == '\'' || c == '`') == config_key,
_ => false,
}
}

fn is_recognized_config_object<D: Doc>(object_node: &Node<'_, D>) -> bool {
let Some(parent) = object_node.parent() else { return false };
match parent.kind().as_ref() {
"export_statement" => true,
// `export default { ... } satisfies T` — hop past the satisfies wrapper.
"satisfies_expression" => parent.parent().is_some_and(|p| p.kind() == "export_statement"),
"arguments" => parent.parent().is_some_and(|c| is_define_config_call(&c)),
"parenthesized_expression" => is_define_config_arrow_body(&parent),
"return_statement" => is_inside_define_config_callback(&parent),
_ => false,
}
}

fn is_define_config_call<D: Doc>(call_node: &Node<'_, D>) -> bool {
call_node.kind() == "call_expression"
&& call_node.field("function").is_some_and(|f| f.text() == "defineConfig")
}

fn is_define_config_arrow_body<D: Doc>(paren_node: &Node<'_, D>) -> bool {
paren_node
.parent()
.filter(|n| n.kind() == "arrow_function")
.and_then(|n| n.parent())
.filter(|n| n.kind() == "arguments")
.and_then(|n| n.parent())
.is_some_and(|c| is_define_config_call(&c))
}

fn is_inside_define_config_callback<D: Doc>(node: &Node<'_, D>) -> bool {
let mut current = node.parent();
while let Some(n) = current {
if is_define_config_call(&n) {
return true;
}
current = n.parent();
}
false
}

/// Check if the vite config uses a function callback pattern
fn check_function_callback(vite_config_content: &str) -> Result<bool, Error> {
// Match both sync and async arrow functions
Expand Down Expand Up @@ -358,6 +446,197 @@ mod tests {

use super::*;

// ── has_config_key ────────────────────────────────────────────────────

#[test]
fn test_has_config_key_top_level_in_defineconfig() {
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig({
fmt: { singleQuote: true },
lint: { rules: {} },
});
"#;
assert!(has_config_key(cfg, "fmt").unwrap());
assert!(has_config_key(cfg, "lint").unwrap());
assert!(!has_config_key(cfg, "pack").unwrap());
assert!(!has_config_key(cfg, "staged").unwrap());
}

#[test]
fn test_has_config_key_quoted_key() {
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig({
'fmt': { singleQuote: true },
"lint": {},
});
"#;
assert!(has_config_key(cfg, "fmt").unwrap());
assert!(has_config_key(cfg, "lint").unwrap());
}

#[test]
fn test_has_config_key_ignores_comment_mentions() {
// The regex check was a false positive on these — AST check ignores them.
let cfg = r#"import { defineConfig } from 'vite-plus';

// fmt: configure formatter here
/* lint: TODO wire this up */
export default defineConfig({
plugins: [],
});
"#;
assert!(!has_config_key(cfg, "fmt").unwrap());
assert!(!has_config_key(cfg, "lint").unwrap());
}

#[test]
fn test_has_config_key_ignores_string_literal_mentions() {
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig({
plugins: [],
description: 'has fmt: foo and lint: bar inside',
});
"#;
assert!(!has_config_key(cfg, "fmt").unwrap());
assert!(!has_config_key(cfg, "lint").unwrap());
}

#[test]
fn test_has_config_key_ignores_nested_keys() {
// `fmt:` is a nested property inside `plugins[0].options`, not top-level.
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig({
plugins: [
somePlugin({
fmt: 'auto',
lint: { enabled: true },
}),
],
});
"#;
assert!(!has_config_key(cfg, "fmt").unwrap());
assert!(!has_config_key(cfg, "lint").unwrap());
}

#[test]
fn test_has_config_key_arrow_callback() {
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig((env) => ({
fmt: { singleQuote: env.mode === 'production' },
}));
"#;
assert!(has_config_key(cfg, "fmt").unwrap());
assert!(!has_config_key(cfg, "lint").unwrap());
}

#[test]
fn test_has_config_key_return_block_callback() {
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig(({ mode }) => {
return {
fmt: { singleQuote: true },
};
});
"#;
assert!(has_config_key(cfg, "fmt").unwrap());
assert!(!has_config_key(cfg, "lint").unwrap());
}

#[test]
fn test_has_config_key_async_return_block_callback() {
let cfg = r#"
export default defineConfig(async ({ command, mode }) => {
const data = await asyncFunction();
return {
lint: { rules: {} },
};
});
"#;
assert!(has_config_key(cfg, "lint").unwrap());
assert!(!has_config_key(cfg, "fmt").unwrap());
}

#[test]
fn test_has_config_key_plain_export() {
let cfg = r#"export default {
fmt: { singleQuote: true },
};
"#;
assert!(has_config_key(cfg, "fmt").unwrap());
assert!(!has_config_key(cfg, "lint").unwrap());
}

#[test]
fn test_has_config_key_satisfies_export() {
let cfg = r#"import type { UserConfig } from 'vite-plus';

export default {
lint: { rules: {} },
} satisfies UserConfig;
"#;
assert!(has_config_key(cfg, "lint").unwrap());
assert!(!has_config_key(cfg, "fmt").unwrap());
}

#[test]
fn test_has_config_key_return_variable_is_unknown() {
// The merger handles this via object spread, so duplication is benign.
// We conservatively report `false`.
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig(({ mode }) => {
const configObject = { fmt: { singleQuote: true } };
return configObject;
});
"#;
assert!(!has_config_key(cfg, "fmt").unwrap());
}

#[test]
fn test_has_config_key_arrow_wrapper_around_defineconfig() {
// export default () => defineConfig({ ... }) — the wrapper is irrelevant;
// detection follows the defineConfig argument object.
let cfg = r#"import { defineConfig } from 'vite-plus';

export default () =>
defineConfig({
fmt: { singleQuote: true },
});
"#;
assert!(has_config_key(cfg, "fmt").unwrap());
}

#[test]
fn test_has_config_key_fate_template_shape() {
// Mirrors create-fate's drizzle template — the bug that motivated this fix.
let cfg = r#"import { defineConfig } from 'vite-plus';

export default defineConfig({
fmt: {
experimentalSortImports: { newlinesBetween: false },
ignorePatterns: ['coverage/', 'dist/'],
singleQuote: true,
},
lint: {
extends: [nkzw],
options: { typeAware: true, typeCheck: true },
rules: { '@typescript-eslint/no-explicit-any': 'off' },
},
staged: { '*': 'vp check --fix' },
});
"#;
assert!(has_config_key(cfg, "fmt").unwrap());
assert!(has_config_key(cfg, "lint").unwrap());
assert!(has_config_key(cfg, "staged").unwrap());
assert!(!has_config_key(cfg, "pack").unwrap());
}

#[test]
fn test_check_function_callback() {
let simple_config = r#"
Expand Down
1 change: 1 addition & 0 deletions packages/cli/binding/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ if (!nativeBinding) {
module.exports = nativeBinding;
module.exports.detectWorkspace = nativeBinding.detectWorkspace;
module.exports.downloadPackageManager = nativeBinding.downloadPackageManager;
module.exports.hasConfigKey = nativeBinding.hasConfigKey;
module.exports.mergeJsonConfig = nativeBinding.mergeJsonConfig;
module.exports.mergeTsdownConfig = nativeBinding.mergeTsdownConfig;
module.exports.rewriteEslint = nativeBinding.rewriteEslint;
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/binding/index.d.cts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ export interface DownloadPackageManagerResult {
version: string;
}

/**
* Whether `config_key` is already declared as a top-level property in the
* vite config's `defineConfig({...})` (or equivalent) object literal.
*
* AST-based check covering the six shapes the merger understands; ignores
* comments, string literal occurrences, and nested keys. Returns `false`
* for unrecognized shapes (e.g. `return $VAR` from a callback).
*/
export declare function hasConfigKey(viteConfigPath: string, configKey: string): boolean;

/** Result returned by JavaScript resolver functions. */
export interface JsCommandResolvedResult {
binPath: string;
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/binding/src/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ pub fn merge_json_config(
})
}

/// Whether `config_key` is already declared as a top-level property in the
/// vite config's `defineConfig({...})` (or equivalent) object literal.
///
/// AST-based check covering the six shapes the merger understands; ignores
/// comments, string literal occurrences, and nested keys. Returns `false`
/// for unrecognized shapes (e.g. `return $VAR` from a callback).
#[napi]
pub fn has_config_key(vite_config_path: String, config_key: String) -> Result<bool> {
let content = std::fs::read_to_string(&vite_config_path).map_err(anyhow::Error::from)?;
Ok(vite_migration::has_config_key(&content, &config_key).map_err(anyhow::Error::from)?)
}

/// Error from batch import rewriting
#[napi(object)]
pub struct BatchRewriteError {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"singleQuote": false,
"printWidth": 80,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"rules": {
"no-unused-vars": "error"
},
"options": {
"typeAware": false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"devDependencies": {
"oxfmt": "1",
"oxlint": "1"
}
}
Loading
Loading