import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; import * as t from '@babel/types'; import { promises as fs } from 'fs'; import { Change } from '../safety/change-preview.js'; export interface UnusedImportFix { file: string; line: number; importName: string; source: string; } export class UnusedImportsFixer { async findUnusedImports(filePath: string): Promise { const fixes: UnusedImportFix[] = []; const content = await fs.readFile(filePath, 'utf-8'); try { const ast = parse(content, { sourceType: 'module', plugins: ['jsx', 'typescript'], }); const imports = new Map(); const usedIdentifiers = new Set(); // First pass: collect all imports traverse(ast, { ImportDeclaration(path) { const line = path.node.loc?.start.line || 0; const source = path.node.source.value; path.node.specifiers.forEach((specifier: any) => { const localName = specifier.local.name; imports.set(localName, { line, source, node: path.node }); }); } }); // Second pass: find all used identifiers traverse(ast, { Identifier(path) { // Skip import declarations themselves if (path.isImportSpecifier() || path.isImportDefaultSpecifier() || path.isImportNamespaceSpecifier()) { return; } // Skip property keys in objects const parent = path.parent; if (parent && t.isProperty(parent) && parent.key === path.node) { return; } // Skip member expression properties (unless computed) if (parent && t.isMemberExpression(parent) && parent.property === path.node && !parent.computed) { return; } usedIdentifiers.add(path.node.name); }, JSXIdentifier(path) { const parent = path.parent; if (parent && (t.isJSXOpeningElement(parent) || t.isJSXClosingElement(parent))) { usedIdentifiers.add(path.node.name); } } }); // Find unused imports imports.forEach((importInfo, name) => { if (!usedIdentifiers.has(name)) { fixes.push({ file: filePath, line: importInfo.line, importName: name, source: importInfo.source }); } }); } catch (error) { // Parsing error, skip this file } return fixes; } async removeUnusedImports(filePath: string): Promise { const changes: Change[] = []; const content = await fs.readFile(filePath, 'utf-8'); const unusedImports = await this.findUnusedImports(filePath); if (unusedImports.length === 0) { return changes; } try { const ast = parse(content, { sourceType: 'module', plugins: ['jsx', 'typescript'], }); const lines = content.split('\n'); const newLines = [...lines]; const unusedNames = new Set(unusedImports.map(imp => imp.importName)); // Process each import declaration const self = this; traverse(ast, { ImportDeclaration(path) { const startLine = path.node.loc?.start.line || 0; const endLine = path.node.loc?.end.line || 0; const specifiers = path.node.specifiers; const remainingSpecifiers = specifiers.filter((spec: any) => !unusedNames.has(spec.local.name) ); if (remainingSpecifiers.length === 0 && specifiers.length > 0) { // Remove entire import for (let i = startLine - 1; i < endLine; i++) { if (newLines[i] !== null) { changes.push({ type: 'remove', file: filePath, line: i + 1, oldContent: lines[i], description: 'Remove entire unused import' }); newLines[i] = null as any; } } } else if (remainingSpecifiers.length < specifiers.length) { // Reconstruct import with only used specifiers const newImport = self.reconstructImport(path.node.source.value, remainingSpecifiers); changes.push({ type: 'replace', file: filePath, line: startLine, oldContent: lines[startLine - 1], newContent: newImport, description: 'Remove unused imports from statement' }); newLines[startLine - 1] = newImport; // Remove extra lines if multi-line import for (let i = startLine; i < endLine; i++) { if (newLines[i] !== null) { changes.push({ type: 'remove', file: filePath, line: i + 1, oldContent: lines[i], description: 'Remove entire unused import' }); newLines[i] = null as any; } } } } }); } catch (error) { // Parsing error } return changes; } private reconstructImport(source: string, specifiers: any[]): string { if (specifiers.length === 0) return ''; const parts: string[] = []; const defaultSpec = specifiers.find(s => t.isImportDefaultSpecifier(s)); const namedSpecs = specifiers.filter(s => t.isImportSpecifier(s)); if (defaultSpec) { parts.push(defaultSpec.local.name); } if (namedSpecs.length > 0) { const namedImports = namedSpecs.map(spec => { const imported = (spec.imported as t.Identifier).name; const local = spec.local.name; if (imported === local) { return imported; } return `${imported} as ${local}`; }).join(', '); if (defaultSpec) { parts.push(`{ ${namedImports} }`); } else { parts[0] = `{ ${namedImports} }`; } } return `import ${parts.join(', ')} from '${source}';`; } }