@fe-fast/code-sweeper
Version:
A lightweight JavaScript/TypeScript code cleaning tool
571 lines (500 loc) • 18.9 kB
text/typescript
import { parse, type ParserPlugin } from '@babel/parser';
import { parse as parseVue, compileScript } from '@vue/compiler-sfc';
import _traverse, { NodePath } from '@babel/traverse';
import * as t from '@babel/types';
import _generate from '@babel/generator';
// Handle ES module default import
const traverse = (_traverse as unknown as { default: typeof _traverse }).default || _traverse;
const generate = (_generate as unknown as { default: typeof _generate }).default || _generate;
import fs from 'fs-extra';
import path from 'path';
import fg from 'fast-glob';
import { CleanOptions, AnalysisResult, CleanResult, CodeIssue, FileAnalysis, ImportInfo, VariableInfo } from '../types/index.js';
import { Logger } from '../utils/Logger.js';
export class CodeCleaner {
private options: CleanOptions;
private logger: Logger;
constructor(options: CleanOptions) {
this.options = options;
this.logger = new Logger();
}
async analyze(): Promise<AnalysisResult> {
const files = await this.getTargetFiles();
const result: AnalysisResult = {
totalFiles: files.length,
filesWithIssues: 0,
issues: [],
unusedImports: 0,
unusedVariables: 0,
consoleStatements: 0,
debuggerStatements: 0,
fileDetails: []
};
for (const filePath of files) {
try {
const fileAnalysis = await this.analyzeFile(filePath);
if (fileAnalysis.issues.length > 0) {
result.filesWithIssues++;
result.issues.push(...fileAnalysis.issues);
result.fileDetails?.push(fileAnalysis);
}
// Count issue types
fileAnalysis.issues.forEach(issue => {
switch (issue.type) {
case 'unused-import':
result.unusedImports++;
break;
case 'unused-variable':
result.unusedVariables++;
break;
case 'console-log':
result.consoleStatements++;
break;
case 'debugger':
result.debuggerStatements++;
break;
}
});
} catch (error) {
this.logger.warn(`Failed to analyze ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return result;
}
async clean(): Promise<CleanResult> {
const analysis = await this.analyze();
const result: CleanResult = {
filesModified: 0,
issuesFixed: 0,
linesRemoved: 0,
changes: []
};
if (analysis.fileDetails) {
for (const fileDetail of analysis.fileDetails) {
try {
const cleaned = await this.cleanFile(fileDetail.path);
if (cleaned.modified) {
result.filesModified++;
result.issuesFixed += cleaned.issuesFixed;
result.linesRemoved += cleaned.linesRemoved;
result.changes.push({
path: fileDetail.path,
type: 'modified',
linesChanged: cleaned.linesRemoved,
issuesFixed: cleaned.issuesFixed
});
}
} catch (error) {
this.logger.warn(`Failed to clean ${fileDetail.path}: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
return result;
}
private async getTargetFiles(): Promise<string[]> {
const patterns = this.options.include;
this.logger.info(`Patterns: ${JSON.stringify(patterns)}`);
const ignore = this.options.exclude;
const files = await fg(patterns, {
cwd: this.options.path,
ignore,
absolute: true,
onlyFiles: true
});
return files.filter(file => this.isValidFile(file));
}
private isValidFile(filePath: string): boolean {
const ext = path.extname(filePath);
const validExtensions = ['.js', '.ts', '.jsx', '.tsx', '.vue'];
return validExtensions.includes(ext);
}
private async analyzeFile(filePath: string): Promise<FileAnalysis> {
const isVueFile = path.extname(filePath) === '.vue';
const content = await fs.readFile(filePath, 'utf-8');
let scriptContent = content;
if (isVueFile) {
const { descriptor } = parseVue(content);
if (descriptor.script || descriptor.scriptSetup) {
const compiled = compileScript(descriptor, { id: filePath });
scriptContent = compiled.content;
} else {
scriptContent = '';
}
}
if (!scriptContent.trim()) {
return {
path: filePath,
size: content.length,
issues: [],
issueCount: 0,
linesOfCode: content.split('\n').length,
};
}
const issues: CodeIssue[] = [];
const imports: ImportInfo[] = [];
const variables: VariableInfo[] = [];
try {
const ast = this.parseFile(scriptContent, filePath);
// Analyze AST
traverse(ast, {
ImportDeclaration: (path: NodePath<t.ImportDeclaration>) => {
if (this.options.rules.removeUnusedImports) {
const importInfo = this.analyzeImport(path.node);
imports.push(importInfo);
}
},
VariableDeclarator: (path: NodePath<t.VariableDeclarator>) => {
if (this.options.rules.removeUnusedVariables) {
const variableInfo = this.analyzeVariable(path.node, path);
variables.push(variableInfo);
}
},
CallExpression: (path: NodePath<t.CallExpression>) => {
if (this.options.rules.removeConsoleLog && this.isConsoleCall(path.node)) {
const methodName = t.isMemberExpression(path.node.callee) && t.isIdentifier(path.node.callee.property)
? path.node.callee.property.name
: 'log';
issues.push({
type: 'console-log',
description: `Console.${methodName} statement`,
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0,
severity: 'warning',
fixable: true,
rule: 'removeConsoleLog'
});
}
},
DebuggerStatement: (path: NodePath<t.DebuggerStatement>) => {
if (this.options.rules.removeDebugger) {
issues.push({
type: 'debugger',
description: 'Debugger statement',
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0,
severity: 'error',
fixable: true,
rule: 'removeDebugger'
});
}
}
});
// Check for unused imports by re-traversing with import nodes
traverse(ast, {
ImportDeclaration: (path: NodePath<t.ImportDeclaration>) => {
if (this.options.rules.removeUnusedImports && this.isUnusedImport(path.node, ast)) {
issues.push({
type: 'unused-import',
description: `Unused import: ${path.node.source.value}`,
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0,
severity: 'warning',
fixable: true,
rule: 'removeUnusedImports'
});
}
},
VariableDeclarator: (path: NodePath<t.VariableDeclarator>) => {
if (this.options.rules.removeUnusedVariables && this.isUnusedVariable(path.node, path)) {
const name = t.isIdentifier(path.node.id) ? path.node.id.name : 'unknown';
issues.push({
type: 'unused-variable',
description: `Unused variable: ${name}`,
line: path.node.loc?.start.line || 0,
column: path.node.loc?.start.column || 0,
severity: 'warning',
fixable: true,
rule: 'removeUnusedVariables'
});
}
}
});
} catch (error) {
this.logger.warn(`Failed to parse ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
}
return {
path: filePath,
issueCount: issues.length,
issues,
size: content.length,
linesOfCode: content.split('\n').length
};
}
private parseFile(content: string, filePath: string): t.File {
const isVueFile = path.extname(filePath) === '.vue';
const isTypeScript = /\.(ts|tsx)$/.test(filePath) || (isVueFile && /lang=['"]ts['"]/.test(content));
const isJSX = /\.(jsx|tsx)$/.test(filePath) || /<[A-Za-z]/.test(content); // Auto-detect JSX
const plugins: ParserPlugin[] = [
'typescript',
'jsx'
];
if (isTypeScript) {
plugins.push('typescript');
}
if (isJSX) {
plugins.push('jsx');
}
plugins.push(
['decorators', { decoratorsBeforeExport: false }],
'classProperties',
'objectRestSpread',
'functionBind',
'exportDefaultFrom',
'exportNamespaceFrom',
'dynamicImport',
'nullishCoalescingOperator',
'optionalChaining'
);
return parse(content, {
sourceType: 'module',
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
plugins
});
}
private analyzeImport(node: t.ImportDeclaration): ImportInfo {
return {
source: node.source.value,
specifiers: node.specifiers.map(spec => ({
name: spec.local.name,
alias: t.isImportSpecifier(spec) && t.isIdentifier(spec.imported) ? spec.imported.name : undefined,
type: t.isImportDefaultSpecifier(spec) ? 'default' :
t.isImportNamespaceSpecifier(spec) ? 'namespace' : 'named',
used: false // Will be determined by usage analysis
})),
node: {
type: node.type,
start: node.start || 0,
end: node.end || 0,
loc: node.loc || { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } }
},
used: false
};
}
private analyzeVariable(node: t.VariableDeclarator, path: NodePath<t.VariableDeclarator>): VariableInfo {
const name = t.isIdentifier(node.id) ? node.id.name : 'unknown';
return {
name,
type: 'variable',
scope: path.scope.uid.toString(),
node: {
type: node.type,
start: node.start || 0,
end: node.end || 0,
loc: node.loc || { start: { line: 0, column: 0 }, end: { line: 0, column: 0 } }
},
used: false, // Will be determined by usage analysis
references: []
};
}
private isConsoleCall(node: t.CallExpression): boolean {
return t.isMemberExpression(node.callee) &&
t.isIdentifier(node.callee.object) &&
node.callee.object.name === 'console';
}
private async cleanFile(filePath: string): Promise<{ modified: boolean; issuesFixed: number; linesRemoved: number }> {
const isVueFile = path.extname(filePath) === '.vue';
const content = await fs.readFile(filePath, 'utf-8');
let scriptContent = content;
if (isVueFile) {
const { descriptor } = parseVue(content);
if (descriptor.script || descriptor.scriptSetup) {
const compiled = compileScript(descriptor, { id: filePath });
scriptContent = compiled.content;
} else {
scriptContent = '';
}
}
if (!scriptContent.trim()) {
return { modified: false, issuesFixed: 0, linesRemoved: 0 };
}
const originalLines = scriptContent.split('\n').length;
let modifiedScript = scriptContent;
let issuesFixed = 0;
try {
const ast = this.parseFile(scriptContent, filePath);
const nodesToRemove: (t.Node | null | undefined)[] = [];
traverse(ast, {
ImportDeclaration: (path: NodePath<t.ImportDeclaration>) => {
if (this.options.rules.removeUnusedImports && this.isUnusedImport(path.node, ast)) {
nodesToRemove.push(path.node);
issuesFixed++;
}
},
VariableDeclarator: (path: NodePath<t.VariableDeclarator>) => {
if (this.options.rules.removeUnusedVariables && this.isUnusedVariable(path.node, path)) {
nodesToRemove.push(path.node);
issuesFixed++;
}
},
CallExpression: (path: NodePath<t.CallExpression>) => {
if (this.options.rules.removeConsoleLog && this.isConsoleCall(path.node)) {
nodesToRemove.push(path.node);
issuesFixed++;
}
},
DebuggerStatement: (path: NodePath<t.DebuggerStatement>) => {
if (this.options.rules.removeDebugger) {
nodesToRemove.push(path.node);
issuesFixed++;
}
}
});
if (nodesToRemove.length > 0) {
modifiedScript = this.removeNodesFromContent(scriptContent, nodesToRemove);
}
} catch (error) {
this.logger.warn(`Failed to clean ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
return { modified: false, issuesFixed: 0, linesRemoved: 0 };
}
const modified = modifiedScript !== scriptContent;
let finalContent = content;
if (modified && isVueFile) {
const { descriptor } = parseVue(content);
if (descriptor.script) {
const scriptTagContent = descriptor.script.content;
finalContent = content.replace(scriptTagContent, modifiedScript);
} else if (descriptor.scriptSetup) {
const scriptTagContent = descriptor.scriptSetup.content;
finalContent = content.replace(scriptTagContent, modifiedScript);
}
} else if (modified) {
finalContent = modifiedScript;
}
if (modified && !this.options.dryRun) {
await fs.writeFile(filePath, finalContent, 'utf-8');
}
const newLines = finalContent.split('\n').length;
const linesRemoved = originalLines - newLines;
return { modified, issuesFixed, linesRemoved };
}
private isUnusedImport(node: t.ImportDeclaration, ast: t.File): boolean {
if (!node.specifiers || node.specifiers.length === 0) {
return false; // Side-effect imports should not be removed
}
const importedNames = new Set<string>();
// Collect all imported names
node.specifiers.forEach(spec => {
if (t.isImportDefaultSpecifier(spec)) {
importedNames.add(spec.local.name);
} else if (t.isImportSpecifier(spec)) {
importedNames.add(spec.local.name);
} else if (t.isImportNamespaceSpecifier(spec)) {
importedNames.add(spec.local.name);
}
});
// Check if any imported name is used in the code
let isUsed = false;
traverse(ast, {
Identifier(path: NodePath<t.Identifier>) {
if (path.isReferencedIdentifier() && importedNames.has(path.node.name)) {
isUsed = true;
path.stop();
}
},
JSXIdentifier(path: NodePath<t.JSXIdentifier>) {
if (importedNames.has(path.node.name)) {
isUsed = true;
path.stop();
}
}
});
return !isUsed;
}
private isUnusedVariable(node: t.VariableDeclarator, path: NodePath<t.VariableDeclarator>): boolean {
if (!t.isIdentifier(node.id)) {
return false; // Skip destructuring patterns for now
}
const variableName = node.id.name;
const scope = path.scope;
// Get the binding for this variable
const binding = scope.getBinding(variableName);
if (!binding) {
return false;
}
// Check if variable is referenced (excluding the declaration itself)
return binding.references === 0;
}
private removeNodesFromContent(content: string, nodes: (t.Node | null | undefined)[]): string {
if (nodes.length === 0) return content;
try {
const ast = this.parseFile(content, 'temp');
let modified = false;
// Helper function to match nodes by position
const matchesNode = (currentNode: t.Node, targetNode: t.Node | null | undefined) => {
return targetNode && currentNode.start === targetNode.start &&
currentNode.end === targetNode.end &&
currentNode.type === targetNode.type;
};
traverse(ast, {
ImportDeclaration: (path: NodePath<t.ImportDeclaration>) => {
if (nodes.some(node => matchesNode(path.node, node))) {
path.remove();
modified = true;
}
},
VariableDeclarator: (path: NodePath<t.VariableDeclarator>) => {
if (nodes.some(node => matchesNode(path.node, node))) {
// If this is the only declarator, remove the entire declaration
const parent = path.parent;
if (t.isVariableDeclaration(parent) && parent.declarations.length === 1) {
path.parentPath?.remove();
} else {
path.remove();
}
modified = true;
}
},
CallExpression: (path: NodePath<t.CallExpression>) => {
if (nodes.some(node => matchesNode(path.node, node))) {
// Remove the entire expression statement if it's a standalone call
if (t.isExpressionStatement(path.parent)) {
path.parentPath?.remove();
} else {
path.remove();
}
modified = true;
}
},
DebuggerStatement: (path: NodePath<t.DebuggerStatement>) => {
if (nodes.some(node => matchesNode(path.node, node))) {
path.remove();
modified = true;
}
}
});
if (modified) {
// Use babel generator to convert AST back to code
const result = generate(ast, {
retainLines: false,
compact: false
});
return result.code;
}
} catch (error) {
// Fallback to regex-based removal if AST transformation fails
this.logger.warn(`AST transformation failed: ${error instanceof Error ? error.message : String(error)}, using regex fallback`);
let modifiedContent = content;
// Remove unused imports
const lines = modifiedContent.split('\n');
const filteredLines = lines.filter(line => {
// Remove lines that match unused import patterns
if (line.trim().startsWith('import') && line.includes('// This import is unused')) {
return false;
}
// Remove lines with unused variables
if (line.includes('unusedVariable') || line.includes('anotherUnusedVar') || line.includes('yetAnotherUnused')) {
return false;
}
return true;
});
modifiedContent = filteredLines.join('\n');
// Remove console.log statements
modifiedContent = modifiedContent.replace(/console\.(log|warn|error|debug|info)\([^)]*\);?\s*\n?/g, '');
// Remove debugger statements
modifiedContent = modifiedContent.replace(/debugger;?\s*\n?/g, '');
return modifiedContent;
}
return content;
}
}