UNPKG

file-mover

Version:

Script to move files and update imports automatically

394 lines 18.9 kB
import { normalizePath, removeExtension, getMsImportPath, resolveImportPath, getModuleType, handleMonoRepoImportPathToAbsolutePath, isIndexFile, } from "./pathUtils.js"; import { parse } from "@babel/parser"; import traverseModule from "@babel/traverse"; import path from "path"; import { getPerformance } from "./performance/moveTracker.js"; import fs from "fs/promises"; // eslint-disable-next-line @typescript-eslint/no-explicit-any const traverse = traverseModule.default || traverseModule; export const isRelativeImport = (importPath) => typeof importPath === "string" && (importPath.startsWith("./") || importPath.startsWith("../")); // Helper function to check if import path is a monorepo package import export const isMonorepoPackageImport = (importPath) => typeof importPath === "string" && importPath.startsWith("@ms/"); export const getRelativeImportPath = (fromFile, toFile) => { let relativePath = normalizePath(path.relative(path.dirname(fromFile), path.dirname(toFile))); if (!relativePath) { relativePath = "."; } const ext = path.extname(toFile); let fileName = path.basename(toFile); // Keep extension such as .png so that it'd remain valid import path if (ext === ".ts" || ext === ".tsx") { fileName = path.basename(toFile, ext); } if (relativePath === ".") { return `./${fileName}`; } else { return `${relativePath}/${fileName}`; } }; export const checkIfFileIsPartOfMove = (filePath) => globalThis.appState.fileMoveMap.has(filePath); // Helper function to extract imports from AST node export const extractImportInfo = ({ pathNode, content, importPath, matchedUpdateToFilePath, }) => { return { line: pathNode.node.loc?.start.line || 0, originalLine: content.split("\n")[pathNode.node.loc?.start?.line ? pathNode.node.loc.start.line - 1 : 0]?.trim() || "", importPath, matchedText: pathNode.toString(), matchedUpdateToFilePath, }; }; export const generateImportPathVariations = (targetPath, config) => { const normalized = path.resolve(targetPath); const paths = new Set(); // Check if this is an index file const isIndexFileResult = isIndexFile(normalized); // Helper function to add path variations (with and without extension) const addPathVariations = (basePath) => { const withoutExt = removeExtension(basePath); paths.add(normalizePath(basePath)); paths.add(normalizePath(withoutExt)); // Add directory variations if this is an index file if (isIndexFileResult) { const dirPath = path.dirname(basePath); const dirPathWithoutExt = removeExtension(dirPath); paths.add(normalizePath(dirPath)); paths.add(normalizePath(dirPathWithoutExt)); } }; // Add variations for absolute path addPathVariations(normalized); // Handle MS import path const msImportPath = getMsImportPath(normalized); if (msImportPath) { paths.add(msImportPath); // Add directory-based MS import path variations if (isIndexFileResult) { const msDirPath = msImportPath.replace(/\/index$/, ""); if (msDirPath !== msImportPath) { paths.add(msDirPath); } } } // Add variations for relative to CWD path const relativeToCwd = path.relative(config.cwd, normalized); addPathVariations(relativeToCwd); return Array.from(paths); }; // Cache for parsed ASTs to avoid re-parsing the same files const astCache = new Map(); globalThis.astCache = astCache; export const findDependencyImports = (arg) => { const { content, targetImportPaths, currentFile } = arg; const imports = []; // Performance tracking const perf = getPerformance(globalThis.appState.verbose); const readTimer = perf.startTimer(`File read: ${currentFile}`); const parseTimer = perf.startTimer(`AST parse: ${currentFile}`); const matchTimer = perf.startTimer(`Import match: ${currentFile}`); // OPTIMIZATION: Check if we have a cached AST for this file let ast = astCache.get(currentFile); // Track cache performance perf.trackCacheLookup(); if (!ast) { try { ast = parse(content, { sourceType: "unambiguous", plugins: ["typescript", "jsx", "decorators-legacy", "classProperties", "dynamicImport"], }); // Cache the AST for potential reuse astCache.set(currentFile, ast); } catch (e) { if (globalThis.appState.verbose) { console.warn(`⚠️ Could not parse ${currentFile}: ${e instanceof Error ? e.message : String(e)}`); } // End timers even on error readTimer.end(); parseTimer.end(); matchTimer.end(); return imports; } } else { // Cache hit perf.trackCacheHit("ast"); } const parseTime = parseTimer.end(); traverse(ast, { ImportDeclaration: (pathNode) => { const importPath = pathNode.node.source?.value; const matchedUpdateToFilePath = matchesTarget({ importPath, targetImportPaths, currentFile }); if (typeof importPath === "string" && matchedUpdateToFilePath) { imports.push(extractImportInfo({ pathNode, content, importPath, matchedUpdateToFilePath })); } }, ExportAllDeclaration: (pathNode) => { const importPath = pathNode.node.source?.value; const matchedUpdateToFilePath = matchesTarget({ importPath, targetImportPaths, currentFile }); if (typeof importPath === "string" && matchedUpdateToFilePath) { imports.push(extractImportInfo({ pathNode, content, importPath, matchedUpdateToFilePath })); } }, CallExpression: (pathNode) => { const callee = pathNode.node.callee; // Handle require() calls if (callee.type === "Identifier" && callee.name === "require") { const arg0 = pathNode.node.arguments[0]; if (arg0 && arg0.type === "StringLiteral") { const importPath = arg0.value; const matchedUpdateToFilePath = matchesTarget({ importPath, targetImportPaths, currentFile }); if (typeof importPath === "string" && matchedUpdateToFilePath) { imports.push(extractImportInfo({ pathNode, content, importPath, matchedUpdateToFilePath })); } } } // Handle dynamic import() calls if (callee.type === "Import") { const arg0 = pathNode.node.arguments[0]; if (arg0 && arg0.type === "StringLiteral") { const importPath = arg0.value; const matchedUpdateToFilePath = matchesTarget({ importPath, targetImportPaths, currentFile }); if (typeof importPath === "string" && matchedUpdateToFilePath) { imports.push(extractImportInfo({ pathNode, content, importPath, matchedUpdateToFilePath })); } } } // Handle jest.mock() calls if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.object.name === "jest" && callee.property.type === "Identifier" && callee.property.name === "mock" && pathNode.node.arguments.length > 0 && pathNode.node.arguments[0].type === "StringLiteral") { const importPath = pathNode.node.arguments[0].value; const matchedUpdateToFilePath = matchesTarget({ importPath, targetImportPaths, currentFile }); if (typeof importPath === "string" && matchedUpdateToFilePath) { imports.push(extractImportInfo({ pathNode, content, importPath, matchedUpdateToFilePath })); } } // Handle Loadable() calls with dynamic imports if (callee.type === "Identifier" && callee.name === "Loadable") { // Look for dynamic import() calls within the Loadable arguments pathNode.traverse({ CallExpression: (nestedPathNode) => { const nestedCallee = nestedPathNode.node.callee; if (nestedCallee.type === "Import") { const arg0 = nestedPathNode.node.arguments[0]; if (arg0 && arg0.type === "StringLiteral") { const importPath = arg0.value; const matchedUpdateToFilePath = matchesTarget({ importPath, targetImportPaths, currentFile }); if (typeof importPath === "string" && matchedUpdateToFilePath) { imports.push(extractImportInfo({ pathNode: nestedPathNode, content, importPath, matchedUpdateToFilePath })); } } } }, }); } }, }); const readTime = readTimer.end(); const matchTime = matchTimer.end(); // Track detailed file analysis timing perf.trackFileAnalysis(currentFile, readTime, parseTime, matchTime, imports.length); return imports; }; export const fileMoveDirection = ({ oldPath, newPath }) => { const oldModuleType = getModuleType(oldPath); const newModuleType = getModuleType(newPath); if (oldModuleType.moduleType === newModuleType.moduleType && oldModuleType.moduleName === newModuleType.moduleName) { return "self"; } else if (oldModuleType.moduleType === "packages" && newModuleType.moduleType === "apps") { console.warn(`⚠️ Could not determine file move direction for ${oldPath}${newPath}`); return "unknown"; } else { return "betweenPackages"; } }; export const updateSrcToLib = (path) => { const matchGroups = path.match(/src\/(.*)$/); if (matchGroups) { return `lib/${matchGroups[1]}`; } return path; }; export const matchesTarget = ({ currentFile, importPath, targetImportPaths, }) => { // Track import path hits for reporting const currentCount = globalThis.appState.importPathHits.get(importPath) || 0; globalThis.appState.importPathHits.set(importPath, currentCount + 1); if (targetImportPaths.has(importPath)) { const newPath = globalThis.appState.fileMoveMap.get(targetImportPaths.get(importPath) || ""); return newPath || null; } const resolvedPath = normalizePath(resolveImportPath(currentFile, importPath)); if (targetImportPaths.has(resolvedPath)) { const newPath = globalThis.appState.fileMoveMap.get(targetImportPaths.get(resolvedPath) || ""); return newPath || null; } // const resolvedPathWithoutExt = normalizePath(removeExtension(resolvedPath)); // if (targetImportPaths.has(resolvedPathWithoutExt)) { // const newPath = globalThis.appState.fileMoveMap.get(targetImportPaths.get(resolvedPathWithoutExt) || ""); // return newPath || null; // } return null; }; //TODO: We shouldn't need to match with regex. We should be able to use the info from import analysis // to directly match and update the import statement. Since pref is minimal, defer this to later. export const createImportStatementRegexPatterns = (importPath) => { const escapeRegex = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // Static import: import ... from '...' const staticImportPattern = new RegExp(`from\\s+(['"\`])${escapeRegex(importPath)}\\1`, "g"); // Dynamic import: import('...') const dynamicImportPattern = new RegExp(`import\\(\\s*(['"\`])${escapeRegex(importPath)}\\1\\s*\\)`, "g"); // require('...') const requirePattern = new RegExp(`require\\(\\s*(['"\`])${escapeRegex(importPath)}\\1\\s*\\)`, "g"); return { staticImportPattern, dynamicImportPattern, requirePattern }; }; export const setFileContentIfRegexMatches = (fileContent, regex, replacement) => { if (regex.test(fileContent)) { return fileContent.replace(regex, replacement); } return null; }; // This I should separate this into two functions from many to one and one to many export const handlePackageImportsUpdate = ({ currentImportPath, currentFilePath, targetFileMoveToNewPath: TargetFileMoveToNewPath, fileContent, }) => { const fileDirection = fileMoveDirection({ oldPath: currentFilePath, newPath: TargetFileMoveToNewPath, }); let updatedImportPath = ""; let updated = false; let newRelativePath = getRelativeImportPath(currentFilePath, TargetFileMoveToNewPath); // Avoid bare import if (!newRelativePath.startsWith("../") && !newRelativePath.startsWith("./")) { newRelativePath = `./${newRelativePath}`; } const { staticImportPattern, dynamicImportPattern, requirePattern } = createImportStatementRegexPatterns(currentImportPath); if (fileDirection === "self") { if (isMonorepoPackageImport(currentImportPath)) { updatedImportPath = getRelativeImportPath(currentFilePath, handleMonoRepoImportPathToAbsolutePath(currentFilePath, TargetFileMoveToNewPath)); } else { updatedImportPath = newRelativePath; } } else if (fileDirection === "betweenPackages") { if (isMonorepoPackageImport(TargetFileMoveToNewPath)) { updatedImportPath = currentImportPath; } else { updatedImportPath = getMsImportPath(TargetFileMoveToNewPath); } } else { // We can't import package from app. User Have to move other dependencies to the app as well. console.warn(`⚠️ Currently not supported: ${currentFilePath}${TargetFileMoveToNewPath}`); return { updated, updatedFileContent: fileContent, updatedImportPath }; } // AST have walked through the file tree, we should just directly update over there // instead of search again const updatedContent = setFileContentIfRegexMatches(fileContent, staticImportPattern, `from $1${updatedImportPath}$1`) ?? setFileContentIfRegexMatches(fileContent, dynamicImportPattern, `import($1${updatedImportPath}$1)`) ?? setFileContentIfRegexMatches(fileContent, requirePattern, `require($1${updatedImportPath}$1)`); return { updated: !!updatedContent, updatedFileContent: updatedContent || fileContent, updatedImportPath, }; }; export const handleMovingFileImportsUpdate = ({ importPath, originalMovedFilePath, newMovedFilePath, fileContent, }) => { const targetImportFileAbsPath = path.resolve(path.dirname(originalMovedFilePath), importPath); // Check if the imported file has been moved const importFilePath = checkIfFileIsPartOfMove(targetImportFileAbsPath) ? globalThis.appState.fileMoveMap.get(targetImportFileAbsPath) || targetImportFileAbsPath : isRelativeImport(importPath) ? targetImportFileAbsPath : importPath; const fileDirection = fileMoveDirection({ oldPath: newMovedFilePath, newPath: importFilePath, }); let updatedImportPath = ""; let updated = false; let newRelativePath = getRelativeImportPath(newMovedFilePath, importFilePath); // Avoid bare import if (!newRelativePath.startsWith("../") && !newRelativePath.startsWith("./")) { newRelativePath = `./${newRelativePath}`; } const { staticImportPattern, dynamicImportPattern, requirePattern } = createImportStatementRegexPatterns(importPath); if (fileDirection === "self") { if (isMonorepoPackageImport(importPath)) { updatedImportPath = getRelativeImportPath(newMovedFilePath, handleMonoRepoImportPathToAbsolutePath(newMovedFilePath, importFilePath)); } else { updatedImportPath = newRelativePath; } } else if (fileDirection === "betweenPackages") { if (isMonorepoPackageImport(importPath)) { updatedImportPath = importPath; } else { updatedImportPath = getMsImportPath(importPath); } } else { // We can't import package from app. User Have to move other dependencies to the app as well. console.warn(`⚠️ Currently not supported: ${originalMovedFilePath}${importPath}`); return { updated, updatedFileContent: fileContent, updatedImportPath }; } // AST have walked through the file tree, we should just directly update over there // instead of search again const updatedContent = setFileContentIfRegexMatches(fileContent, staticImportPattern, `from $1${updatedImportPath}$1`) ?? setFileContentIfRegexMatches(fileContent, dynamicImportPattern, `import($1${updatedImportPath}$1)`) ?? setFileContentIfRegexMatches(fileContent, requirePattern, `require($1${updatedImportPath}$1)`); return { updated: !!updatedContent, updatedFileContent: updatedContent || fileContent, updatedImportPath, }; }; /** * Analyze which files import the target file (pure utility function without performance tracking) */ export async function analyzeImports(sourceFiles, targetImportPaths) { const results = []; // OPTIMIZATION: Use Promise.all for parallel file reading and analysis const analysisPromises = sourceFiles.map(async (file) => { try { console.log(`📂 Analyzing file: ${file} for imports to the move files`); const content = await fs.readFile(file, "utf8"); const imports = findDependencyImports({ content, targetImportPaths, currentFile: file, }); console.log(`📂 Analyzing ${file}: ${imports.length} import(s) found`); if (imports.length > 0) { return { file, imports }; } return null; } catch (error) { const normalizedFile = path.normalize(file); const scanningSelf = globalThis.appState.fileMoves.some(([fromPath]) => normalizedFile === fromPath); if (!scanningSelf) { console.warn(`⚠️ Could not read ${file}: ${error instanceof Error ? error.message : String(error)}`); } return null; } }); const analysisResults = await Promise.all(analysisPromises); // Filter out null results and add to results array for (const result of analysisResults) { if (result) { results.push(result); } } return results; } //# sourceMappingURL=importUtils.js.map