diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_wrapped_callback.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_wrapped_callback.md index cacb04b7b7..4e9d33ad43 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_wrapped_callback.md +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_merge_vite_config_ts/snapshots/migration_merge_wrapped_callback.md @@ -19,7 +19,8 @@ VITE+ - The Unified Toolchain for the Web ◇ Migrated . to Vite+ • Node pnpm -• 3 config updates applied, 1 file had imports rewritten +• 4 config updates applied, 1 file had imports rewritten +• Inline Vite plugins wrapped with lazyPlugins for check/lint/fmt ! Warnings: - Vitest v5: 1 review item @@ -35,7 +36,7 @@ import { join } from 'node:path'; import react from '@vitejs/plugin-react'; import { playwright } from 'vite-plus/test/browser-playwright'; -import { defineConfig } from 'vite-plus'; +import { defineConfig, lazyPlugins } from 'vite-plus'; import type { UserConfigFn } from 'vite-plus'; @@ -67,7 +68,7 @@ export default defineConfig((((() => { } ] }, - plugins: [react()], + plugins: lazyPlugins(() => [react()]), test: { dir: join(import.meta.dirname, 'test'), browser: { diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml index ffa1e18fc6..d2657c1079 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots.toml @@ -104,3 +104,13 @@ steps = [ { argv = ["vpt", "print-file", "dist/method/index.mjs"] }, { argv = ["vpt", "print-file", "dist/control/index.mjs"] }, ] + +[[case]] +name = "migration_pack_tsdown_023_wrapped" +vp = "global" +comment = "Upgrade a pack config whose config object is wrapped in a type assertion." +steps = [ + { argv = ["vpt", "write-file", "vite.config.ts", "import { defineConfig } from 'vite-plus';\nimport type { UserConfig } from 'vite-plus';\n\nexport default defineConfig({\n pack: {\n entry: 'src/index.ts',\n bundle: false,\n },\n} as UserConfig);\n"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_wrapped.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_wrapped.md new file mode 100644 index 0000000000..fa0916afce --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_pack_tsdown_023/snapshots/migration_pack_tsdown_023_wrapped.md @@ -0,0 +1,47 @@ +# migration_pack_tsdown_023_wrapped + +Upgrade a pack config whose config object is wrapped in a type assertion. + +## `vpt write-file vite.config.ts 'import { defineConfig } from '\''vite-plus'\''; +import type { UserConfig } from '\''vite-plus'\''; + +export default defineConfig({ + pack: { + entry: '\''src/index.ts'\'', + bundle: false, + }, +} as UserConfig); +'` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 file had imports rewritten +• Package manager settings configured +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; +import type { UserConfig } from 'vite-plus'; + +export default defineConfig({ + pack: { deps: { + // tsdown <0.23 compatibility: resolve external dependency subpaths. + // Remove to preserve subpath imports as written (the new default). + // https://tsdown.dev/options/dependencies#deps-resolvedepsubpath + resolveDepSubpath: true }, + entry: 'src/index.ts', + unbundle: true, + }, +} as UserConfig); +``` diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots.toml b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots.toml index a7bc4d9e41..3bcde1ec36 100644 --- a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots.toml +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots.toml @@ -19,3 +19,13 @@ steps = [ { argv = ["vpt", "print-file", "vite.config.ts"] }, { argv = ["vp", "migrate", "--no-interactive"] }, ] + +[[case]] +name = "migration_task_cache_fields_wrapped" +vp = "global" +comment = "Move task cache settings when the defineConfig callback is wrapped in a type assertion." +steps = [ + { argv = ["vpt", "write-file", "vite.config.ts", "import { defineConfig } from 'vite-plus';\nimport type { UserConfigFn } from 'vite-plus';\n\nexport default defineConfig((() => {\n return {\n run: { tasks: { build: { command: 'vp build', env: ['DEPLOY_TARGET'] } } },\n };\n}) as UserConfigFn);\n"], snapshot = false }, + { argv = ["vp", "migrate", "--no-interactive"] }, + { argv = ["vpt", "print-file", "vite.config.ts"] }, +] diff --git a/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields_wrapped.md b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields_wrapped.md new file mode 100644 index 0000000000..7325016af6 --- /dev/null +++ b/crates/vp_cli_snapshots/tests/cli_snapshots/fixtures/migration_task_cache_fields/snapshots/migration_task_cache_fields_wrapped.md @@ -0,0 +1,42 @@ +# migration_task_cache_fields_wrapped + +Move task cache settings when the defineConfig callback is wrapped in a type assertion. + +## `vpt write-file vite.config.ts 'import { defineConfig } from '\''vite-plus'\''; +import type { UserConfigFn } from '\''vite-plus'\''; + +export default defineConfig((() => { + return { + run: { tasks: { build: { command: '\''vp build'\'', env: ['\''DEPLOY_TARGET'\''] } } }, + }; +}) as UserConfigFn); +'` + + +## `vp migrate --no-interactive` + +``` +VITE+ - The Unified Toolchain for the Web + +◇ Updated . to Vite+ +• Node npm +• Dependencies: + vite-plus 0.2.0 → + vite → +• 1 config update applied +• Task cache settings moved under `cache` +• Package manager settings configured +``` + +## `vpt print-file vite.config.ts` + +``` +import { defineConfig } from 'vite-plus'; +import type { UserConfigFn } from 'vite-plus'; + +export default defineConfig((() => { + return { + run: { tasks: { build: { command: 'vp build', cache: { env: ['DEPLOY_TARGET'] } } } }, + }; +}) as UserConfigFn); +``` diff --git a/crates/vp_migration/src/pack_config.rs b/crates/vp_migration/src/pack_config.rs index 1f38d15aeb..14dd78957e 100644 --- a/crates/vp_migration/src/pack_config.rs +++ b/crates/vp_migration/src/pack_config.rs @@ -176,7 +176,12 @@ pub(crate) fn is_pack_object(object: &Node<'_, D>, standalone: bool) -> } let Some(parent) = value.parent() else { break }; match parent.kind().as_ref() { - "array" | "parenthesized_expression" | "satisfies_expression" | "as_expression" => { + "array" + | "parenthesized_expression" + | "satisfies_expression" + | "as_expression" + | "type_assertion" + | "non_null_expression" => { value = parent; } _ => break, @@ -890,6 +895,11 @@ mod tests { "export default defineConfig(() => ([{ bundle: false, dts: { tsgo: true } }]));", "export default defineConfig(() => (({ bundle: false, dts: { tsgo: true } }) satisfies UserConfig));", "export default defineConfig(() => { return { bundle: false, dts: { tsgo: true } }; });", + "export default defineConfig(() => ({ bundle: false, dts: { tsgo: true } }) as UserConfig);", + "export default defineConfig((() => ({ bundle: false, dts: { tsgo: true } })) as UserConfigFn);", + "export default defineConfig((() => ({ bundle: false, dts: { tsgo: true } })));", + "export default defineConfig((() => ({ bundle: false, dts: { tsgo: true } }))!);", + "export default defineConfig((() => { return { bundle: false, dts: { tsgo: true } }; }) satisfies UserConfigFn);", ] { let actual = rewrite_pack_config(input, true, true); assert!(actual.contains("unbundle: true"), "{actual}"); @@ -1075,6 +1085,34 @@ mod tests { ), ("export default { pack: ({ bundle: false } satisfies PackConfig) };", false), ("export default { pack: { \"bundle\": false, \"dts\": { \"tsgo\": true } } };", false), + ("export default defineConfig({ pack: { bundle: false } } as UserConfig);", false), + ("export default defineConfig(({ pack: { bundle: false } }));", false), + ("export default defineConfig({ pack: { bundle: false } });", false), + ("export default defineConfig({ pack: { bundle: false } });", false), + ( + "export default defineConfig(() => ({ pack: { bundle: false } }) as UserConfig);", + false, + ), + ( + "export default defineConfig(() => ({ pack: { bundle: false } } satisfies UserConfig));", + false, + ), + ( + "export default defineConfig((() => ({ pack: { bundle: false } })) as UserConfigFn);", + false, + ), + ( + "export default defineConfig(() => { return { pack: { bundle: false } } as UserConfig; });", + false, + ), + ( + "export default defineConfig(() => { return ({ pack: { bundle: false } }); });", + false, + ), + ( + "export default defineConfig(((() => { return { pack: { bundle: false } }; }) satisfies UserConfigFn)!);", + false, + ), ] { let actual = rewrite_pack_config(input, standalone, true); assert!(!actual.contains("bundle: false"), "{actual}"); @@ -1086,6 +1124,8 @@ mod tests { "export default { test: { pack: { bundle: false } } };", "export default defineConfig({ plugins: [{ config() { return { pack: { bundle: false } }; } }] });", "const config = { bundle: false }; export default config;", + "export default defineConfig(() => { function* pack() { return { pack: { bundle: false } }; } return pack().next().value; });", + "export default defineConfig(wrap((() => ({ pack: { bundle: false } })) as Fn));", ] { assert_eq!(rewrite_pack_config(input, false, true), input); } diff --git a/crates/vp_migration/src/task_cache.rs b/crates/vp_migration/src/task_cache.rs index 5a00b3d761..2460251ae4 100644 --- a/crates/vp_migration/src/task_cache.rs +++ b/crates/vp_migration/src/task_cache.rs @@ -6,7 +6,7 @@ use vp_error::Error; use crate::{ pack_config::can_edit_object, - vite_config::{is_define_config_call, is_direct_recognized_config_object, pair_key_matches}, + vite_config::{is_direct_recognized_config_object, pair_key_matches}, }; /// Task settings that Vite Task only accepts inside a task's `cache` object. @@ -95,42 +95,10 @@ fn task_name(object: &Node<'_, D>) -> Option<(String, bool)> { return None; } let key = task.field("key")?; - Some((key.text().trim_matches(['\'', '"']).to_owned(), is_direct_config(&config))) -} - -/// Whether `object` is a direct config object, looking through parentheses and -/// type assertions around it and around a `defineConfig` callback, as in -/// `defineConfig((env) => ({ ... }) as UserConfig)` and -/// `defineConfig(((env) => ({ ... })) as UserConfigFn)`. -fn is_direct_config(object: &Node<'_, D>) -> bool { - let outer = outermost_wrapper(object); - if is_direct_recognized_config_object(&outer) { - return true; - } - outer - .parent() - .filter(|arrow| { - arrow.kind() == "arrow_function" - && arrow.field("body").is_some_and(|body| body.range() == outer.range()) - }) - .and_then(|arrow| outermost_wrapper(&arrow).parent()) - .filter(|arguments| arguments.kind() == "arguments") - .and_then(|arguments| arguments.parent()) - .is_some_and(|call| is_define_config_call(&call)) -} - -/// The outermost parenthesized or type-asserted expression around `node`. -fn outermost_wrapper<'a, D: Doc>(node: &Node<'a, D>) -> Node<'a, D> { - let mut outer = node.clone(); - while let Some(parent) = outer.parent().filter(|parent| { - matches!( - parent.kind().as_ref(), - "parenthesized_expression" | "satisfies_expression" | "as_expression" - ) - }) { - outer = parent; - } - outer + Some(( + key.text().trim_matches(['\'', '"']).to_owned(), + is_direct_recognized_config_object(&config), + )) } /// Returns the pair whose value is `node`, looking through parentheses and @@ -140,7 +108,11 @@ fn value_pair<'a, D: Doc>(node: &Node<'a, D>) -> Option> { loop { let parent = value.parent()?; match parent.kind().as_ref() { - "parenthesized_expression" | "satisfies_expression" | "as_expression" => { + "parenthesized_expression" + | "satisfies_expression" + | "as_expression" + | "type_assertion" + | "non_null_expression" => { value = parent; } "pair" => { @@ -633,6 +605,13 @@ mod tests { "export default defineConfig(() => ({ run: { tasks: { build: { command: 'x', env: ['A'] } } } }) satisfies UserConfig);", "export default defineConfig(((env: Env) => ({ run: { tasks: { build: { command: 'x', env: ['A'] } } } })) as UserConfigFn);", "export default ({ run: { tasks: { build: { command: 'x', env: ['A'] } } } });", + "export default defineConfig({ run: { tasks: { build: { command: 'x', env: ['A'] } } } });", + "export default defineConfig({ run: { tasks: { build: { command: 'x', env: ['A'] } } } }!);", + "export default defineConfig((() => ({ run: { tasks: { build: { command: 'x', env: ['A'] } } } })));", + "export default defineConfig((() => ({ run: { tasks: { build: { command: 'x', env: ['A'] } } } }))!);", + "export default defineConfig((() => { return { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; }));", + "export default defineConfig((function () { return { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; }) as UserConfigFn);", + "export default defineConfig(((() => { return { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; }) satisfies UserConfigFn)!);", ] { let result = migrate(input); assert!(result.content.contains("cache: { env: ['A'] }"), "{}", result.content); @@ -665,6 +644,8 @@ mod tests { "export default mergeConfig(base, { run: { tasks: { build: { command: 'x', env: ['A'] } } } });", "export default withTests((() => ({ run: { tasks: { build: { command: 'x', env: ['A'] } } } })) as Fn);", "export function withTasks() { return { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; }", + "export default defineConfig(() => { function* tasks() { return { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; } return tasks().next().value; });", + "export default defineConfig(() => { const tasks = function* () { return { run: { tasks: { build: { command: 'x', env: ['A'] } } } }; }; return tasks().next().value; });", ] { let result = migrate(input); assert_eq!(result.content, input); diff --git a/crates/vp_migration/src/vite_config.rs b/crates/vp_migration/src/vite_config.rs index 41bb6889fc..a3273dd24d 100644 --- a/crates/vp_migration/src/vite_config.rs +++ b/crates/vp_migration/src/vite_config.rs @@ -619,28 +619,57 @@ static RE_NAMESPACE_LAZY_PLUGINS_IMPORT: LazyLock = LazyLock::new(|| Regex::new(r#"^\s*import\s+\*\s+as\s+lazyPlugins\b"#).unwrap()); /// A **direct** config object: the object literal that *is* the config — -/// `defineConfig({...})`'s argument, an `export default {...}` (with or -/// without `satisfies`), a `defineConfig` arrow body, or a direct `return` -/// in a `defineConfig` callback. Unlike [`is_recognized_config_object`], -/// returns inside nested functions (e.g. an inline plugin's `config()` hook) -/// do NOT match, so destructive edits never touch them. Used by transforms -/// that rewrite in place (`wrap_lazy_plugins`, `upsert_json_config`). +/// `defineConfig({...})`'s argument, an `export default {...}`, a +/// `defineConfig` arrow body, or a direct `return` in a `defineConfig` +/// callback. Parentheses and type wrappers around the object and the callback +/// are transparent. Unlike [`is_recognized_config_object`], returns inside +/// nested functions (e.g. an inline plugin's `config()` hook) do NOT match, so +/// destructive edits never touch them. Used by transforms that rewrite in +/// place (`wrap_lazy_plugins`, `upsert_json_config`). pub(crate) fn is_direct_recognized_config_object(object_node: &Node<'_, D>) -> bool { - let Some(parent) = object_node.parent() else { return false }; + let outer = outermost_wrapper(object_node); + let Some(parent) = outer.parent() else { return false }; match parent.kind().as_ref() { "export_statement" => true, - "satisfies_expression" => parent.parent().is_some_and(|p| { - p.kind() == "export_statement" - || (p.kind() == "arguments" - && p.parent().is_some_and(|c| is_define_config_call(&c))) - }), "arguments" => parent.parent().is_some_and(|c| is_define_config_call(&c)), - "parenthesized_expression" => is_define_config_arrow_body(&parent), + "arrow_function" => { + parent.field("body").is_some_and(|body| body.range() == outer.range()) + && is_define_config_callback(&parent) + } "return_statement" => is_direct_return_in_define_config_callback(&parent), _ => false, } } +/// The outermost parenthesized or type-asserted expression around `node`. +pub(crate) fn outermost_wrapper<'a, D: Doc>(node: &Node<'a, D>) -> Node<'a, D> { + let mut outer = node.clone(); + while let Some(parent) = outer.parent().filter(|parent| { + matches!( + parent.kind().as_ref(), + "parenthesized_expression" + | "as_expression" + | "satisfies_expression" + | "type_assertion" + | "non_null_expression" + ) + }) { + outer = parent; + } + outer +} + +/// Whether `function` is a callback passed to `defineConfig`, looking through +/// parentheses and type wrappers. +fn is_define_config_callback(function: &Node<'_, D>) -> bool { + matches!(function.kind().as_ref(), "arrow_function" | "function_expression") + && outermost_wrapper(function) + .parent() + .filter(|node| node.kind() == "arguments") + .and_then(|node| node.parent()) + .is_some_and(|call| is_define_config_call(&call)) +} + fn is_recognized_config_object(object_node: &Node<'_, D>) -> bool { let Some(parent) = object_node.parent() else { return false }; match parent.kind().as_ref() { @@ -680,31 +709,13 @@ fn is_inside_define_config_callback(node: &Node<'_, D>) -> bool { false } -fn is_direct_return_in_define_config_callback(return_node: &Node<'_, D>) -> bool { - let mut current = return_node.parent(); - while let Some(node) = current { - match node.kind().as_ref() { - "arrow_function" | "function_expression" => { - return node - .parent() - .filter(|parent| parent.kind() == "arguments") - .and_then(|parent| parent.parent()) - .is_some_and(|call| is_define_config_call(&call)); - } - "function_declaration" | "method_definition" => return false, - _ => current = node.parent(), - } - } - false -} - /// Whether a direct `defineConfig` callback returns something other than an /// object literal or an identifier, which the merge return rules cannot rewrite. fn has_unsupported_direct_return(content: &str) -> bool { let grep = SupportLang::TypeScript.ast_grep(content); grep.root().dfs().any(|node| { node.kind() == "return_statement" - && is_merge_rule_direct_return(&node) + && is_direct_return_in_define_config_callback(&node) && !node .children() .find(|child| child.is_named() && child.kind() != "comment") @@ -712,41 +723,24 @@ fn has_unsupported_direct_return(content: &str) -> bool { }) } -/// Mirrors the function boundary and wrapper rules of `merge-json-config-return`. -fn is_merge_rule_direct_return(return_node: &Node<'_, D>) -> bool { - let Some(function) = return_node.ancestors().find(|node| { - matches!( - node.kind().as_ref(), - "arrow_function" - | "function_expression" - | "function_declaration" - | "generator_function" - | "generator_function_declaration" - | "method_definition" - ) - }) else { - return false; - }; - if !matches!(function.kind().as_ref(), "arrow_function" | "function_expression") { - return false; - } - let mut current = function.parent(); - while let Some(node) = current.as_ref().filter(|node| { - matches!( - node.kind().as_ref(), - "parenthesized_expression" - | "as_expression" - | "satisfies_expression" - | "type_assertion" - | "non_null_expression" - ) - }) { - current = node.parent(); - } - current - .filter(|node| node.kind() == "arguments") - .and_then(|node| node.parent()) - .is_some_and(|call| is_define_config_call(&call)) +/// Whether the nearest enclosing function of `return_node` is a `defineConfig` +/// callback. Mirrors the function boundary and wrapper rules of +/// `merge-json-config-return`. +fn is_direct_return_in_define_config_callback(return_node: &Node<'_, D>) -> bool { + return_node + .ancestors() + .find(|node| { + matches!( + node.kind().as_ref(), + "arrow_function" + | "function_expression" + | "function_declaration" + | "generator_function" + | "generator_function_declaration" + | "method_definition" + ) + }) + .is_some_and(|function| is_define_config_callback(&function)) } fn node_has_descendant_kind(node: &Node<'_, D>, kind: &str) -> bool { @@ -2909,6 +2903,29 @@ export default defineConfig({ assert!(!result.content.contains(r#""@a""#)); } + #[test] + fn test_upsert_json_config_content_through_wrappers() { + for argument in [ + "{ base: '/' } as UserConfig", + "{ base: '/' }", + "({ base: '/' })!", + "() => ({ base: '/' }) as UserConfig", + "(() => ({ base: '/' })) as UserConfigFn", + "(() => { return { base: '/' }; })", + "(function () { return { base: '/' } satisfies UserConfig; })!", + ] { + let source = format!("export default defineConfig({argument});"); + let result = upsert_json_config_content(&source, "{}", "create").unwrap(); + assert!(result.updated, "{source}"); + assert_eq!(result.content.matches("create: {}").count(), 1, "{}", result.content); + } + + // A generator nested in the callback is a function boundary. + let source = "export default defineConfig(() => { function* g() { return { base: '/' }; } return g().next().value; });"; + let result = upsert_json_config_content(source, "{}", "create").unwrap(); + assert!(!result.updated, "{}", result.content); + } + #[test] fn test_upsert_json_config_content_replaces_shorthand_property() { // `{ create }` shorthand: the recomputed value replaces the variable