/*
* Copyright 2025 the original author or authors.
*
* Licensed under the Moderne Source Available License (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://docs.moderne.io/licensing/moderne-source-available-license
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Option, Recipe } from "../../recipe";
import { TreeVisitor } from "../../visitor";
import { ExecutionContext } from "../../execution";
import { JavaScriptVisitor, JS } from "../index";
import { maybeAddImport } from "../add-import";
import { J, isIdentifier, Type } from "../../java";
import { create as produce, Draft } from "mutative";
/**
* Changes an import from one module to another, updating all type attributions.
*
* This recipe is useful for:
* - Library migrations (e.g., moving `act` from `react-dom/test-utils` to `react`)
* - Module restructuring (e.g., split packages)
* - Renaming exported members
*
* @example
* // Migrate act import from react-dom/test-utils to react
* const recipe = new ChangeImport({
* oldModule: "react-dom/test-utils",
* oldMember: "act",
* newModule: "react"
* });
* // Before: import { act } from 'react-dom/test-utils';
* // After: import { act } from 'react';
*
* @example
* // Change a named import to a different name
* const recipe = new ChangeImport({
* oldModule: "lodash",
* oldMember: "extend",
* newModule: "lodash",
* newMember: "assign"
* });
* // Before: import { extend } from 'lodash';
* // After: import { assign } from 'lodash';
*/
export class ChangeImport extends Recipe {
readonly name = "org.openrewrite.javascript.change-import";
readonly displayName = "Change import";
readonly description = "Changes an import from one module/member to another, updating all type attributions.";
@Option({
displayName: "Old module",
description: "The module to change imports from",
example: "react-dom/test-utils"
})
oldModule!: string;
@Option({
displayName: "Old member",
description: "The member to change (or 'default' for default imports, '*' for namespace imports)",
example: "act"
})
oldMember!: string;
@Option({
displayName: "New module",
description: "The module to change imports to",
example: "react"
})
newModule!: string;
@Option({
displayName: "New member",
description: "The new member name. If not specified, keeps the same member name.",
example: "act",
required: false
})
newMember?: string;
@Option({
displayName: "New alias",
description: "Optional alias for the new import. Required when newMember is 'default' or '*'.",
required: false
})
newAlias?: string;
constructor(options?: {
oldModule?: string;
oldMember?: string;
newModule?: string;
newMember?: string;
newAlias?: string;
}) {
super(options);
}
async editor(): Promise> {
const oldModule = this.oldModule;
const oldMember = this.oldMember;
const newModule = this.newModule;
const newMember = this.newMember ?? oldMember;
const newAlias = this.newAlias;
// Build the old and new FQNs for type attribution updates
const oldFqn = oldMember === 'default' || oldMember === '*'
? oldModule
: `${oldModule}.${oldMember}`;
const newFqn = newMember === 'default' || newMember === '*'
? newModule
: `${newModule}.${newMember}`;
return new class extends JavaScriptVisitor {
private hasOldImport = false;
private oldAlias?: string;
private transformedImport = false;
override async visitJsCompilationUnit(cu: JS.CompilationUnit, ctx: ExecutionContext): Promise {
// Reset tracking for each file
this.hasOldImport = false;
this.oldAlias = undefined;
this.transformedImport = false;
// First pass: check if the old import exists and capture any alias
for (const statement of cu.statements) {
const stmt = statement.element ?? statement;
if (stmt.kind === JS.Kind.Import) {
const jsImport = stmt as JS.Import;
const aliasInfo = this.checkForOldImport(jsImport);
if (aliasInfo.found) {
this.hasOldImport = true;
this.oldAlias = aliasInfo.alias;
break;
}
}
}
// Visit the compilation unit (this will transform imports via visitJsImport)
let result = await super.visitJsCompilationUnit(cu, ctx) as JS.CompilationUnit;
// If we transformed an import but need to add to existing import from new module,
// or if we only removed a member from a multi-import, use maybeAddImport
if (this.hasOldImport && !this.transformedImport) {
const aliasToUse = newAlias ?? this.oldAlias;
if (newMember === 'default') {
maybeAddImport(this, {
module: newModule,
member: 'default',
alias: aliasToUse,
onlyIfReferenced: false
});
} else if (newMember === '*') {
maybeAddImport(this, {
module: newModule,
member: '*',
alias: aliasToUse,
onlyIfReferenced: false
});
} else if (aliasToUse && aliasToUse !== newMember) {
maybeAddImport(this, {
module: newModule,
member: newMember,
alias: aliasToUse,
onlyIfReferenced: false
});
} else {
maybeAddImport(this, {
module: newModule,
member: newMember,
onlyIfReferenced: false
});
}
}
return result;
}
override async visitImportDeclaration(jsImport: JS.Import, ctx: ExecutionContext): Promise {
let imp = await super.visitImportDeclaration(jsImport, ctx) as JS.Import;
if (!this.hasOldImport) {
return imp;
}
const aliasInfo = this.checkForOldImport(imp);
if (!aliasInfo.found) {
return imp;
}
// Check if this is the only import from the old module
const namedImports = this.getNamedImports(imp);
const isOnlyImport = namedImports.length === 1 ||
(oldMember === 'default' && !imp.importClause?.namedBindings) ||
(oldMember === '*');
if (isOnlyImport) {
// Transform the module specifier in place
this.transformedImport = true;
return produce(imp, draft => {
if (draft.moduleSpecifier) {
const literal = draft.moduleSpecifier.element as Draft;
literal.value = newModule;
// Update valueSource to preserve quote style
const originalSource = literal.valueSource || `"${oldModule}"`;
const quoteChar = originalSource.startsWith("'") ? "'" : '"';
literal.valueSource = `${quoteChar}${newModule}${quoteChar}`;
}
// If we're also renaming the member, update the import specifier
if (newMember !== oldMember && oldMember !== 'default' && oldMember !== '*') {
const importClause = draft.importClause;
if (importClause?.namedBindings?.kind === JS.Kind.NamedImports) {
const namedImports = importClause.namedBindings as Draft;
for (const elem of namedImports.elements.elements) {
const specifier = elem.element;
if (specifier.specifier.kind === J.Kind.Identifier &&
specifier.specifier.simpleName === oldMember) {
specifier.specifier.simpleName = newMember;
} else if (specifier.specifier.kind === JS.Kind.Alias) {
const aliasNode = specifier.specifier as Draft;
const propertyName = aliasNode.propertyName.element;
if (propertyName.kind === J.Kind.Identifier &&
propertyName.simpleName === oldMember) {
propertyName.simpleName = newMember;
}
}
}
}
}
});
} else {
// Remove just the specific member from the import
// maybeAddImport will add the new import
return this.removeNamedImportMember(imp, oldMember, ctx);
}
}
private async removeNamedImportMember(imp: JS.Import, memberToRemove: string, _ctx: ExecutionContext): Promise {
return produce(imp, draft => {
const importClause = draft.importClause;
if (!importClause?.namedBindings) return;
if (importClause.namedBindings.kind !== JS.Kind.NamedImports) return;
const namedImports = importClause.namedBindings as Draft;
const elements = namedImports.elements.elements;
const filteredElements = elements.filter(elem => {
const specifier = elem.element;
const specifierNode = specifier.specifier;
if (specifierNode.kind === J.Kind.Identifier) {
return specifierNode.simpleName !== memberToRemove;
}
if (specifierNode.kind === JS.Kind.Alias) {
const alias = specifierNode as JS.Alias;
const propertyName = alias.propertyName.element;
if (propertyName.kind === J.Kind.Identifier) {
return propertyName.simpleName !== memberToRemove;
}
}
return true;
});
namedImports.elements.elements = filteredElements;
});
}
private getNamedImports(imp: JS.Import): string[] {
const imports: string[] = [];
const importClause = imp.importClause;
if (!importClause) return imports;
const namedBindings = importClause.namedBindings;
if (!namedBindings || namedBindings.kind !== JS.Kind.NamedImports) return imports;
const namedImports = namedBindings as JS.NamedImports;
for (const elem of namedImports.elements.elements) {
const specifier = elem.element;
const specifierNode = specifier.specifier;
if (isIdentifier(specifierNode)) {
imports.push(specifierNode.simpleName);
} else if (specifierNode.kind === JS.Kind.Alias) {
const alias = specifierNode as JS.Alias;
const propertyName = alias.propertyName.element;
if (isIdentifier(propertyName)) {
imports.push(propertyName.simpleName);
}
}
}
return imports;
}
override async visitIdentifier(identifier: J.Identifier, ctx: ExecutionContext): Promise {
let ident = await super.visitIdentifier(identifier, ctx) as J.Identifier;
if (!this.hasOldImport) {
return ident;
}
// Check and update type attribution
let changed = false;
// Update type if it references the old module
const updatedType = this.updateType(ident.type);
if (updatedType !== ident.type) {
changed = true;
}
// Update fieldType if it references the old module
// fieldType is specifically Type.Variable, so we need to handle it specially
let updatedFieldType: Type.Variable | undefined = ident.fieldType;
if (ident.fieldType) {
const updated = this.updateVariableType(ident.fieldType);
if (updated !== ident.fieldType) {
updatedFieldType = updated;
changed = true;
}
}
if (changed) {
return produce(ident, draft => {
if (updatedType !== ident.type) {
draft.type = updatedType;
}
if (updatedFieldType !== ident.fieldType) {
draft.fieldType = updatedFieldType;
}
});
}
return ident;
}
override async visitMethodInvocation(method: J.MethodInvocation, ctx: ExecutionContext): Promise {
let m = await super.visitMethodInvocation(method, ctx) as J.MethodInvocation;
if (!this.hasOldImport) {
return m;
}
// Update methodType if it references the old module
const updatedMethodType = this.updateMethodType(m.methodType);
if (updatedMethodType !== m.methodType) {
return produce(m, draft => {
draft.methodType = updatedMethodType;
});
}
return m;
}
override async visitFieldAccess(fieldAccess: J.FieldAccess, ctx: ExecutionContext): Promise {
let fa = await super.visitFieldAccess(fieldAccess, ctx) as J.FieldAccess;
if (!this.hasOldImport) {
return fa;
}
// Update type if it references the old module
const updatedType = this.updateType(fa.type);
if (updatedType !== fa.type) {
return produce(fa, draft => {
draft.type = updatedType;
});
}
return fa;
}
override async visitFunctionCall(functionCall: JS.FunctionCall, ctx: ExecutionContext): Promise {
let fc = await super.visitFunctionCall(functionCall, ctx) as JS.FunctionCall;
if (!this.hasOldImport) {
return fc;
}
// Update methodType if it references the old module
const updatedMethodType = this.updateMethodType(fc.methodType);
if (updatedMethodType !== fc.methodType) {
return produce(fc, draft => {
draft.methodType = updatedMethodType;
});
}
return fc;
}
override async visitNewClass(newClass: J.NewClass, ctx: ExecutionContext): Promise {
let nc = await super.visitNewClass(newClass, ctx) as J.NewClass;
if (!this.hasOldImport) {
return nc;
}
let changed = false;
// Update methodType if it references the old module
const updatedMethodType = this.updateMethodType(nc.methodType);
if (updatedMethodType !== nc.methodType) {
changed = true;
}
// Update constructorType if it references the old module
const updatedConstructorType = this.updateMethodType(nc.constructorType);
if (updatedConstructorType !== nc.constructorType) {
changed = true;
}
// Update type if it references the old module
const updatedType = this.updateType(nc.type);
if (updatedType !== nc.type) {
changed = true;
}
if (changed) {
return produce(nc, draft => {
if (updatedMethodType !== nc.methodType) {
draft.methodType = updatedMethodType;
}
if (updatedConstructorType !== nc.constructorType) {
draft.constructorType = updatedConstructorType;
}
if (updatedType !== nc.type) {
draft.type = updatedType;
}
});
}
return nc;
}
/**
* Update a type if it references the old module
*/
private updateType(type: Type | undefined): Type | undefined {
if (!type) return type;
switch (type.kind) {
case Type.Kind.Class:
case Type.Kind.ShallowClass:
return this.updateClassType(type as Type.Class);
case Type.Kind.Method:
return this.updateMethodType(type as Type.Method);
case Type.Kind.Variable:
return this.updateVariableType(type as Type.Variable);
case Type.Kind.Parameterized:
return this.updateParameterizedType(type as Type.Parameterized);
case Type.Kind.Array:
return this.updateArrayType(type as Type.Array);
default:
return type;
}
}
/**
* Update a Class type if its FQN references the old module
*/
private updateClassType(classType: Type.Class): Type.Class {
let changed = false;
let newFullyQualifiedName = classType.fullyQualifiedName;
let newOwningClass = classType.owningClass;
// Check if the FQN matches or starts with the old module
if (classType.fullyQualifiedName === oldFqn) {
newFullyQualifiedName = newFqn;
changed = true;
} else if (classType.fullyQualifiedName === oldModule) {
newFullyQualifiedName = newModule;
changed = true;
} else if (classType.fullyQualifiedName.startsWith(oldModule + '.')) {
newFullyQualifiedName = newModule + classType.fullyQualifiedName.substring(oldModule.length);
changed = true;
}
// Recursively update owningClass
if (classType.owningClass) {
const updatedOwningClass = this.updateClassType(classType.owningClass);
if (updatedOwningClass !== classType.owningClass) {
newOwningClass = updatedOwningClass;
changed = true;
}
}
if (changed) {
// Type objects are marked as non-draftable, so we manually create new objects
return {
...classType,
fullyQualifiedName: newFullyQualifiedName,
owningClass: newOwningClass
} as Type.Class;
}
return classType;
}
/**
* Update a Method type if its declaringType references the old module
*/
private updateMethodType(methodType: Type.Method | undefined): Type.Method | undefined {
if (!methodType) return methodType;
// Update the declaring type
if (Type.isFullyQualified(methodType.declaringType)) {
const declaringTypeFqn = Type.FullyQualified.getFullyQualifiedName(methodType.declaringType);
if (declaringTypeFqn === oldModule ||
declaringTypeFqn === oldFqn ||
declaringTypeFqn.startsWith(oldModule + '.')) {
// Need to update the declaring type
const updatedDeclaringType = this.updateType(methodType.declaringType) as Type.FullyQualified;
// Also update the method name if we're renaming the member
const updatedName = (oldMember !== 'default' && oldMember !== '*' &&
methodType.name === oldMember && newMember !== oldMember)
? newMember
: methodType.name;
// Type objects are marked as non-draftable, so we manually create new objects
return {
...methodType,
declaringType: updatedDeclaringType,
name: updatedName
} as Type.Method;
}
}
return methodType;
}
/**
* Update a Variable type if its owner references the old module
*/
private updateVariableType(variableType: Type.Variable): Type.Variable {
let changed = false;
let newOwner = variableType.owner;
let newInnerType = variableType.type;
// Update owner if it references the old module
if (variableType.owner) {
const updatedOwner = this.updateType(variableType.owner);
if (updatedOwner !== variableType.owner) {
newOwner = updatedOwner;
changed = true;
}
}
// Update inner type if it references the old module
const updatedInnerType = this.updateType(variableType.type);
if (updatedInnerType !== variableType.type) {
newInnerType = updatedInnerType!;
changed = true;
}
if (changed) {
// Type objects are marked as non-draftable, so we manually create new objects
return {
...variableType,
owner: newOwner,
type: newInnerType
} as Type.Variable;
}
return variableType;
}
/**
* Update a Parameterized type if its base type references the old module
*/
private updateParameterizedType(paramType: Type.Parameterized): Type.Parameterized {
let changed = false;
let newBaseType = paramType.type;
let newTypeParams = paramType.typeParameters;
// Update base type
if (Type.isFullyQualified(paramType.type)) {
const updatedType = this.updateType(paramType.type) as Type.FullyQualified;
if (updatedType !== paramType.type) {
newBaseType = updatedType;
changed = true;
}
}
// Update type parameters
const updatedParams = paramType.typeParameters.map(tp => this.updateType(tp)!);
if (updatedParams.some((p, i) => p !== paramType.typeParameters[i])) {
newTypeParams = updatedParams;
changed = true;
}
if (changed) {
// Type objects are marked as non-draftable, so we manually create new objects
return {
...paramType,
type: newBaseType,
typeParameters: newTypeParams
} as Type.Parameterized;
}
return paramType;
}
/**
* Update an Array type if its element type references the old module
*/
private updateArrayType(arrayType: Type.Array): Type.Array {
const updatedElemType = this.updateType(arrayType.elemType);
if (updatedElemType !== arrayType.elemType) {
// Type objects are marked as non-draftable, so we manually create new objects
return {
...arrayType,
elemType: updatedElemType!
} as Type.Array;
}
return arrayType;
}
private checkForOldImport(jsImport: JS.Import): { found: boolean; alias?: string } {
// Check if this import is from the old module
const moduleSpecifier = jsImport.moduleSpecifier;
if (!moduleSpecifier) return { found: false };
const literal = moduleSpecifier.element;
if (literal.kind !== J.Kind.Literal) return { found: false };
const value = (literal as J.Literal).value;
if (value !== oldModule) return { found: false };
const importClause = jsImport.importClause;
if (!importClause) {
// Side-effect import - not what we're looking for
return { found: false };
}
// Check for default import
if (oldMember === 'default') {
if (importClause.name) {
const nameElem = importClause.name.element;
if (isIdentifier(nameElem)) {
return { found: true, alias: nameElem.simpleName };
}
}
return { found: false };
}
// Check for namespace import
if (oldMember === '*') {
const namedBindings = importClause.namedBindings;
if (namedBindings?.kind === JS.Kind.Alias) {
const alias = namedBindings as JS.Alias;
if (isIdentifier(alias.alias)) {
return { found: true, alias: alias.alias.simpleName };
}
}
return { found: false };
}
// Check for named imports
const namedBindings = importClause.namedBindings;
if (!namedBindings) return { found: false };
if (namedBindings.kind !== JS.Kind.NamedImports) return { found: false };
const namedImports = namedBindings as JS.NamedImports;
const elements = namedImports.elements.elements;
for (const elem of elements) {
const specifier = elem.element;
const specifierNode = specifier.specifier;
// Handle direct import: import { act }
if (isIdentifier(specifierNode) && specifierNode.simpleName === oldMember) {
return { found: true };
}
// Handle aliased import: import { act as something }
if (specifierNode.kind === JS.Kind.Alias) {
const alias = specifierNode as JS.Alias;
const propertyName = alias.propertyName.element;
if (isIdentifier(propertyName) && propertyName.simpleName === oldMember) {
if (isIdentifier(alias.alias)) {
return { found: true, alias: alias.alias.simpleName };
}
}
}
}
return { found: false };
}
}();
}
}