@line/ts-remove-unused
Advanced tools
Comparing version 0.6.2 to 0.7.0
1208
dist/cli.js
@@ -7,3 +7,3 @@ #!/usr/bin/env node | ||
// lib/remove.ts | ||
import ts5 from "typescript"; | ||
import ts7 from "typescript"; | ||
@@ -40,6 +40,13 @@ // lib/util/MemoryFileService.ts | ||
} | ||
eject() { | ||
const res = /* @__PURE__ */ new Map(); | ||
for (const [name, { content }] of this.#files) { | ||
res.set(name, content); | ||
} | ||
return res; | ||
} | ||
}; | ||
// lib/util/removeUnusedExport.ts | ||
import ts4 from "typescript"; | ||
import ts6 from "typescript"; | ||
@@ -81,22 +88,59 @@ // lib/util/applyTextChanges.ts | ||
import ts from "typescript"; | ||
var fixIdDelete = "unusedIdentifier_delete"; | ||
var fixIdDeleteImports = "unusedIdentifier_deleteImports"; | ||
var filterChanges = ({ | ||
sourceFile, | ||
textChanges | ||
}) => { | ||
const result = []; | ||
const visit = (node) => { | ||
if ((ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isFunctionDeclaration(node)) && node.parameters.length > 0) { | ||
const start = node.parameters[0]?.getStart(); | ||
const end = node.parameters[node.parameters.length - 1]?.getEnd(); | ||
if (typeof start === "number" && typeof end === "number") { | ||
result.push({ start, end }); | ||
} | ||
} | ||
node.forEachChild(visit); | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return textChanges.filter((change) => { | ||
const start = change.span.start; | ||
const end = change.span.start + change.span.length; | ||
return !result.some((r) => r.start <= start && end <= r.end); | ||
}); | ||
}; | ||
var applyCodeFix = ({ | ||
fixId, | ||
languageService, | ||
fileName | ||
}) => { | ||
const program = languageService.getProgram(); | ||
if (!program) { | ||
throw new Error("program not found"); | ||
} | ||
const sourceFile = program.getSourceFile(fileName); | ||
if (!sourceFile) { | ||
throw new Error(`source file not found: ${fileName}`); | ||
} | ||
let content = sourceFile.getFullText(); | ||
const actions = languageService.getCombinedCodeFix( | ||
{ | ||
type: "file", | ||
fileName | ||
}, | ||
fixId, | ||
{}, | ||
{} | ||
); | ||
for (const change of actions.changes) { | ||
const textChanges = fixId === fixIdDelete ? filterChanges({ sourceFile, textChanges: change.textChanges }) : change.textChanges; | ||
content = applyTextChanges(content, textChanges); | ||
} | ||
return content; | ||
}; | ||
// lib/util/getFileFromModuleSpecifierText.ts | ||
// lib/util/createDependencyGraph.ts | ||
import ts2 from "typescript"; | ||
var getFileFromModuleSpecifierText = ({ | ||
specifier, | ||
fileName, | ||
program, | ||
fileService | ||
}) => ts2.resolveModuleName(specifier, fileName, program.getCompilerOptions(), { | ||
fileExists(f) { | ||
return fileService.exists(f); | ||
}, | ||
readFile(f) { | ||
return fileService.get(f); | ||
} | ||
}).resolvedModule?.resolvedFileName; | ||
// lib/util/collectImports.ts | ||
import ts3 from "typescript"; | ||
// lib/util/Graph.ts | ||
@@ -161,30 +205,37 @@ var Graph = class { | ||
super(() => ({ | ||
depth: 0, | ||
hasReexport: false, | ||
fromDynamic: /* @__PURE__ */ new Set(), | ||
// will not be updated when we delete a file, | ||
// but it's fine since we only use it to find the used specifier from a given file path. | ||
wholeReexportSpecifier: /* @__PURE__ */ new Map() | ||
depth: 0 | ||
})); | ||
} | ||
deleteVertex(vertex) { | ||
const selected = this.vertexes.get(vertex); | ||
if (!selected) { | ||
return; | ||
eject() { | ||
const map = /* @__PURE__ */ new Map(); | ||
for (const [k, v] of this.vertexes.entries()) { | ||
map.set(k, { | ||
from: new Set(v.from), | ||
to: new Set(v.to), | ||
data: { | ||
depth: v.data.depth | ||
} | ||
}); | ||
} | ||
for (const v of selected.to) { | ||
const target = this.vertexes.get(v); | ||
if (!target) { | ||
continue; | ||
} | ||
target.data.fromDynamic.delete(vertex); | ||
} | ||
super.deleteVertex(vertex); | ||
return map; | ||
} | ||
}; | ||
// lib/util/collectImports.ts | ||
// lib/util/createDependencyGraph.ts | ||
var getFileFromModuleSpecifierText = ({ | ||
specifier, | ||
fileName, | ||
program, | ||
fileService | ||
}) => ts2.resolveModuleName(specifier, fileName, program.getCompilerOptions(), { | ||
fileExists(f) { | ||
return fileService.exists(f); | ||
}, | ||
readFile(f) { | ||
return fileService.get(f); | ||
} | ||
}).resolvedModule?.resolvedFileName; | ||
var getMatchingNode = (node) => { | ||
if (ts3.isImportDeclaration(node)) { | ||
if (ts3.isStringLiteral(node.moduleSpecifier)) { | ||
if (ts2.isImportDeclaration(node)) { | ||
if (ts2.isStringLiteral(node.moduleSpecifier)) { | ||
return { | ||
@@ -200,4 +251,4 @@ type: "import", | ||
} | ||
if (ts3.isExportDeclaration(node)) { | ||
if (node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
if (ts2.isExportDeclaration(node)) { | ||
if (node.moduleSpecifier && ts2.isStringLiteral(node.moduleSpecifier)) { | ||
const result = { | ||
@@ -215,4 +266,4 @@ type: "reexport", | ||
} | ||
if (ts3.isCallExpression(node) && node.expression.kind === ts3.SyntaxKind.ImportKeyword) { | ||
if (node.arguments[0] && ts3.isStringLiteral(node.arguments[0])) { | ||
if (ts2.isCallExpression(node) && node.expression.kind === ts2.SyntaxKind.ImportKeyword) { | ||
if (node.arguments[0] && ts2.isStringLiteral(node.arguments[0])) { | ||
return { | ||
@@ -230,3 +281,3 @@ type: "dynamicImport", | ||
}; | ||
var collectImports = ({ | ||
var createDependencyGraph = ({ | ||
fileService, | ||
@@ -239,3 +290,3 @@ program, | ||
const stack = []; | ||
const visited = /* @__PURE__ */ new Set(); | ||
const untouched = new Set(fileService.getFileNames()); | ||
for (const entrypoint of entrypoints) { | ||
@@ -250,6 +301,6 @@ stack.push({ file: entrypoint, depth: 0 }); | ||
const { file, depth } = item; | ||
if (visited.has(file)) { | ||
if (!untouched.has(file)) { | ||
continue; | ||
} | ||
visited.add(file); | ||
untouched.delete(file); | ||
const sourceFile = program.getSourceFile(file); | ||
@@ -259,3 +310,2 @@ if (!sourceFile) { | ||
} | ||
let hasReexport = false; | ||
const visit = (node) => { | ||
@@ -268,5 +318,2 @@ const match = getMatchingNode(node); | ||
if (match.specifier) { | ||
if (match.type === "reexport") { | ||
hasReexport = true; | ||
} | ||
const dest = getFileFromModuleSpecifierText({ | ||
@@ -278,10 +325,4 @@ specifier: match.specifier, | ||
}); | ||
if (match.type === "reexport" && dest && match.whole) { | ||
graph.vertexes.get(file)?.data.wholeReexportSpecifier.set(dest, match.specifier); | ||
} | ||
if (dest && files.has(dest)) { | ||
graph.addEdge(sourceFile.fileName, dest); | ||
if (match.type === "dynamicImport") { | ||
graph.vertexes.get(dest)?.data.fromDynamic.add(file); | ||
} | ||
stack.push({ file: dest, depth: depth + 1 }); | ||
@@ -295,6 +336,35 @@ } | ||
if (vertex) { | ||
vertex.data.hasReexport = hasReexport; | ||
vertex.data.depth = depth; | ||
} | ||
} | ||
for (const file of untouched.values()) { | ||
const sourceFile = program.getSourceFile(file); | ||
if (!sourceFile) { | ||
continue; | ||
} | ||
const visit = (node) => { | ||
const match = getMatchingNode(node); | ||
if (!match) { | ||
node.forEachChild(visit); | ||
return; | ||
} | ||
if (match.specifier) { | ||
const dest = getFileFromModuleSpecifierText({ | ||
specifier: match.specifier, | ||
program, | ||
fileName: sourceFile.fileName, | ||
fileService | ||
}); | ||
if (dest && files.has(dest)) { | ||
graph.addEdge(sourceFile.fileName, dest); | ||
} | ||
return; | ||
} | ||
}; | ||
sourceFile.forEachChild(visit); | ||
const vertex = graph.vertexes.get(file); | ||
if (vertex) { | ||
vertex.data.depth = Infinity; | ||
} | ||
} | ||
return graph; | ||
@@ -347,48 +417,416 @@ }; | ||
// lib/util/removeUnusedExport.ts | ||
// lib/util/findFileUsage.ts | ||
import ts4 from "typescript"; | ||
// lib/util/parseFile.ts | ||
import ts3 from "typescript"; | ||
// lib/util/memoize.ts | ||
var memoize = (fn2, { key }) => { | ||
const cache = /* @__PURE__ */ new Map(); | ||
return (...args) => { | ||
const k = key(...args); | ||
if (cache.has(k)) { | ||
return cache.get(k); | ||
} | ||
const result = fn2(...args); | ||
cache.set(k, result); | ||
return result; | ||
}; | ||
}; | ||
// lib/util/parseFile.ts | ||
var IGNORE_COMMENT = "ts-remove-unused-skip"; | ||
var disabledEditTracker = { | ||
start: () => { | ||
var getLeadingComment = (node) => { | ||
const fullText = node.getSourceFile().getFullText(); | ||
const ranges = ts3.getLeadingCommentRanges(fullText, node.getFullStart()); | ||
if (!ranges) { | ||
return ""; | ||
} | ||
return ranges.map((range) => fullText.slice(range.pos, range.end)).join(""); | ||
}; | ||
var resolve = ({ | ||
specifier, | ||
file, | ||
destFiles, | ||
options | ||
}) => ts3.resolveModuleName(specifier, file, options, { | ||
fileExists(f) { | ||
return destFiles.has(f); | ||
}, | ||
end: () => { | ||
}, | ||
delete: () => { | ||
}, | ||
removeExport: () => { | ||
readFile(f) { | ||
throw new Error(`Unexpected readFile call: ${f}`); | ||
} | ||
}).resolvedModule?.resolvedFileName; | ||
var getChange = (node) => { | ||
if (ts3.isExportDeclaration(node) || ts3.isExportAssignment(node)) { | ||
return { | ||
code: node.getFullText(), | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}; | ||
} | ||
if ((ts3.isFunctionDeclaration(node) || ts3.isClassDeclaration(node)) && !node.name) { | ||
return { | ||
code: node.getFullText(), | ||
isUnnamedDefaultExport: true, | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}; | ||
} | ||
const syntaxListIndex = node.getChildren().findIndex((n) => n.kind === ts3.SyntaxKind.SyntaxList); | ||
const syntaxList = node.getChildren()[syntaxListIndex]; | ||
const nextSibling = node.getChildren()[syntaxListIndex + 1]; | ||
if (!syntaxList || !nextSibling) { | ||
throw new Error("Unexpected syntax"); | ||
} | ||
return { | ||
code: node.getSourceFile().getFullText().slice(syntaxList.getStart(), nextSibling.getStart()), | ||
span: { | ||
start: syntaxList.getStart(), | ||
length: nextSibling.getStart() - syntaxList.getStart() | ||
} | ||
}; | ||
}; | ||
var getNecessaryFiles = ({ | ||
var fn = ({ | ||
file, | ||
content, | ||
destFiles, | ||
options = {} | ||
}) => { | ||
const imports = {}; | ||
const exports = []; | ||
const sourceFile = ts3.createSourceFile( | ||
file, | ||
content, | ||
ts3.ScriptTarget.ESNext, | ||
true | ||
); | ||
const visit = (node) => { | ||
if (ts3.isVariableStatement(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
const name = node.declarationList.declarations.map( | ||
(d) => d.name.getText() | ||
); | ||
exports.push({ | ||
kind: ts3.SyntaxKind.VariableStatement, | ||
name, | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isFunctionDeclaration(node) || ts3.isInterfaceDeclaration(node) || ts3.isClassDeclaration(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
if (node.modifiers?.some((m) => m.kind === ts3.SyntaxKind.DefaultKeyword)) { | ||
exports.push({ | ||
kind: node.kind, | ||
name: "default", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} else { | ||
exports.push({ | ||
kind: node.kind, | ||
name: node.name?.getText() || "", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isTypeAliasDeclaration(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
exports.push({ | ||
kind: node.kind, | ||
name: node.name.getText(), | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isExportAssignment(node) && !node.isExportEquals) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportAssignment, | ||
name: "default", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamedExports && !node.moduleSpecifier) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "named", | ||
// we always collect the name not the propertyName because its for exports | ||
name: node.exportClause.elements.map((element) => element.name.text), | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamedExports && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "named", | ||
// we always collect the name not the propertyName because its for exports | ||
name: node.exportClause.elements.map((element) => element.name.text), | ||
change: getChange(node), | ||
skip: false, | ||
start: node.getStart() | ||
}); | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
node.exportClause.elements.forEach((element) => { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push( | ||
element.propertyName?.text || element.name.text | ||
); | ||
}); | ||
} | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamespaceExport && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "namespace", | ||
name: node.exportClause.name.text, | ||
start: node.getStart(), | ||
change: getChange(node) | ||
}); | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
} | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && !node.exportClause && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "whole", | ||
file: resolved || null, | ||
specifier: node.moduleSpecifier.text, | ||
start: node.getStart(), | ||
change: getChange(node) | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push({ type: "wholeReexport", file }); | ||
} | ||
return; | ||
} | ||
if (ts3.isImportDeclaration(node) && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (!resolved) { | ||
return; | ||
} | ||
if (node.importClause?.namedBindings?.kind === ts3.SyntaxKind.NamespaceImport) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
return; | ||
} | ||
if (node.importClause?.namedBindings?.kind === ts3.SyntaxKind.NamedImports) { | ||
const namedImports = node.importClause?.namedBindings; | ||
namedImports.elements.forEach((element) => { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push( | ||
element.propertyName?.text || element.name.text | ||
); | ||
}); | ||
} | ||
if (node.importClause?.name) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("default"); | ||
} | ||
return; | ||
} | ||
if (ts3.isCallExpression(node) && node.expression.kind === ts3.SyntaxKind.ImportKeyword && node.arguments[0] && ts3.isStringLiteral(node.arguments[0])) { | ||
const resolved = resolve({ | ||
specifier: node.arguments[0].text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (!resolved) { | ||
return; | ||
} | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
return; | ||
} | ||
node.forEachChild(visit); | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return { imports, exports }; | ||
}; | ||
var parseFile = memoize(fn, { | ||
key: (arg) => JSON.stringify({ | ||
file: arg.file, | ||
content: arg.content, | ||
destFiles: Array.from(arg.destFiles).sort(), | ||
options: arg.options | ||
}) | ||
}); | ||
// lib/util/findFileUsage.ts | ||
var fallback = () => ({ | ||
from: /* @__PURE__ */ new Set(), | ||
to: /* @__PURE__ */ new Set(), | ||
data: { | ||
depth: Infinity, | ||
wholeReexportSpecifier: /* @__PURE__ */ new Map() | ||
} | ||
}); | ||
var ALL_EXPORTS_OF_UNKNOWN_FILE = "__all_exports_of_unknown_file__"; | ||
var getExportsOfFile = ({ | ||
targetFile, | ||
dependencyGraph, | ||
files | ||
vertexes, | ||
files, | ||
options | ||
}) => { | ||
if (!dependencyGraph.vertexes.has(targetFile)) { | ||
return new Set(files.filter((file) => !dependencyGraph.vertexes.has(file))); | ||
} | ||
const result = /* @__PURE__ */ new Set(); | ||
const result = []; | ||
const stack = [targetFile]; | ||
while (stack.length > 0) { | ||
const file = stack.pop(); | ||
if (!file) { | ||
while (stack.length) { | ||
const item = stack.pop(); | ||
if (!item) { | ||
break; | ||
} | ||
result.add(file); | ||
const vertex = dependencyGraph.vertexes.get(file); | ||
if (!vertex) { | ||
continue; | ||
const vertex = vertexes.get(item) || fallback(); | ||
const { exports } = parseFile({ | ||
file: item, | ||
content: files.get(item) || "", | ||
destFiles: new Set(vertex.to), | ||
options | ||
}); | ||
exports.forEach((it) => { | ||
if (it.kind === ts4.SyntaxKind.ExportDeclaration && it.type === "whole") { | ||
if (it.file) { | ||
stack.push(it.file); | ||
} else { | ||
result.push(ALL_EXPORTS_OF_UNKNOWN_FILE); | ||
} | ||
return; | ||
} | ||
result.push(...Array.isArray(it.name) ? it.name : [it.name]); | ||
}); | ||
} | ||
return new Set(result); | ||
}; | ||
var findFileUsage = ({ | ||
targetFile, | ||
options, | ||
vertexes, | ||
files | ||
}) => { | ||
const result = []; | ||
const exportsOfTargetFile = getExportsOfFile({ | ||
targetFile, | ||
vertexes, | ||
files, | ||
options | ||
}); | ||
const stack = []; | ||
vertexes.get(targetFile)?.from.forEach( | ||
(file) => stack.push({ | ||
file, | ||
to: targetFile | ||
}) | ||
); | ||
while (stack.length) { | ||
const item = stack.pop(); | ||
if (!item) { | ||
break; | ||
} | ||
for (const from of vertex.from) { | ||
result.add(from); | ||
const fromVertex = dependencyGraph.vertexes.get(from); | ||
if (fromVertex && fromVertex.data.hasReexport) { | ||
stack.push(from); | ||
continue; | ||
const { file, to } = item; | ||
const vertex = vertexes.get(file) || fallback(); | ||
const { imports } = parseFile({ | ||
file, | ||
content: files.get(file) || "", | ||
destFiles: new Set(vertex.to), | ||
options | ||
}); | ||
const list = imports[to] || []; | ||
list.forEach((it) => { | ||
if (typeof it === "object" && it.type === "wholeReexport") { | ||
const n = vertexes.get(it.file); | ||
if (!n) { | ||
return; | ||
} | ||
if (n.data.depth === 0) { | ||
result.push("*"); | ||
return; | ||
} | ||
vertexes.get(it.file)?.from.forEach((f) => { | ||
stack.push({ | ||
file: f, | ||
to: it.file | ||
}); | ||
}); | ||
return; | ||
} | ||
if (vertex.data.fromDynamic.has(from)) { | ||
stack.push(from); | ||
if (typeof it === "string") { | ||
result.push(it); | ||
return; | ||
} | ||
} | ||
}); | ||
} | ||
return result; | ||
if (exportsOfTargetFile.has(ALL_EXPORTS_OF_UNKNOWN_FILE)) { | ||
return new Set(result); | ||
} | ||
return new Set( | ||
result.filter((it) => exportsOfTargetFile.has(it) || it === "*") | ||
); | ||
}; | ||
// lib/util/createProgram.ts | ||
import ts5 from "typescript"; | ||
var createProgram = ({ | ||
@@ -404,3 +842,3 @@ fileService, | ||
} | ||
return ts4.createSourceFile( | ||
return ts5.createSourceFile( | ||
fileName, | ||
@@ -411,3 +849,3 @@ fileService.get(fileName), | ||
}, | ||
getDefaultLibFileName: (o) => ts4.getDefaultLibFilePath(o), | ||
getDefaultLibFileName: (o) => ts5.getDefaultLibFilePath(o), | ||
writeFile: (fileName, content) => { | ||
@@ -419,7 +857,7 @@ fileService.set(fileName, content); | ||
readFile: (fileName) => fileService.get(fileName), | ||
getCanonicalFileName: (fileName) => ts4.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), | ||
getCanonicalFileName: (fileName) => ts5.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), | ||
useCaseSensitiveFileNames: () => true, | ||
getNewLine: () => "\n" | ||
}; | ||
const program = ts4.createProgram( | ||
const program = ts5.createProgram( | ||
fileService.getFileNames(), | ||
@@ -431,78 +869,365 @@ options, | ||
}; | ||
var collectSkipFiles = ({ | ||
entrypoints, | ||
program, | ||
// lib/util/removeUnusedExport.ts | ||
var stripExportKeyword = (syntaxList) => { | ||
const file = ts6.createSourceFile( | ||
"tmp.ts", | ||
`${syntaxList} function f() {}`, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const transformer = (context) => (rootNode) => { | ||
const visitor = (node) => { | ||
if (ts6.isFunctionDeclaration(node)) { | ||
return ts6.factory.createFunctionDeclaration( | ||
node.modifiers?.filter( | ||
(v) => v.kind !== ts6.SyntaxKind.ExportKeyword && v.kind !== ts6.SyntaxKind.DefaultKeyword | ||
), | ||
node.asteriskToken, | ||
node.name, | ||
node.typeParameters, | ||
node.parameters, | ||
node.type, | ||
node.body | ||
); | ||
} | ||
return ts6.visitEachChild(node, visitor, context); | ||
}; | ||
return ts6.visitEachChild(rootNode, visitor, context); | ||
}; | ||
const result = ts6.transform(file, [transformer]).transformed[0]; | ||
const printer = ts6.createPrinter(); | ||
const code = result ? printer.printFile(result).trim() : ""; | ||
const pos = code.indexOf("function"); | ||
return code.slice(0, pos); | ||
}; | ||
var disabledEditTracker = { | ||
start: () => { | ||
}, | ||
end: () => { | ||
}, | ||
delete: () => { | ||
}, | ||
removeExport: () => { | ||
} | ||
}; | ||
var createLanguageService = ({ | ||
options, | ||
projectRoot, | ||
fileService | ||
}) => { | ||
const stack = [...entrypoints]; | ||
const result = []; | ||
while (stack.length > 0) { | ||
const file = stack.pop(); | ||
if (!file) { | ||
break; | ||
const languageService = ts6.createLanguageService({ | ||
getCompilationSettings() { | ||
return options; | ||
}, | ||
getScriptFileNames() { | ||
return fileService.getFileNames(); | ||
}, | ||
getScriptVersion(fileName) { | ||
return fileService.getVersion(fileName); | ||
}, | ||
getScriptSnapshot(fileName) { | ||
return ts6.ScriptSnapshot.fromString(fileService.get(fileName)); | ||
}, | ||
getCurrentDirectory() { | ||
return projectRoot; | ||
}, | ||
getDefaultLibFileName(o) { | ||
return ts6.getDefaultLibFileName(o); | ||
}, | ||
fileExists(name) { | ||
return fileService.exists(name); | ||
}, | ||
readFile(name) { | ||
return fileService.get(name); | ||
} | ||
const sourceFile = program.getSourceFile(file); | ||
if (!sourceFile) { | ||
continue; | ||
} | ||
const visit = (node) => { | ||
if (!ts4.isExportDeclaration(node)) { | ||
return; | ||
}); | ||
return languageService; | ||
}; | ||
var updateExportDeclaration = (code, unused) => { | ||
const sourceFile = ts6.createSourceFile( | ||
"tmp.ts", | ||
code, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const transformer = (context) => (rootNode) => { | ||
const visitor = (node) => { | ||
if (ts6.isExportSpecifier(node) && unused.includes(node.getText(sourceFile))) { | ||
return void 0; | ||
} | ||
if (!node.moduleSpecifier || !ts4.isStringLiteral(node.moduleSpecifier)) { | ||
return; | ||
} | ||
if (node.exportClause) { | ||
return; | ||
} | ||
const dest = getFileFromModuleSpecifierText({ | ||
specifier: node.moduleSpecifier.text, | ||
program, | ||
fileName: sourceFile.fileName, | ||
fileService | ||
}); | ||
if (dest) { | ||
stack.push(dest); | ||
result.push(dest); | ||
} | ||
return ts6.visitEachChild(node, visitor, context); | ||
}; | ||
sourceFile.forEachChild(visit); | ||
} | ||
return result; | ||
return ts6.visitEachChild(rootNode, visitor, context); | ||
}; | ||
const result = ts6.transform(sourceFile, [transformer]).transformed[0]; | ||
const printer = ts6.createPrinter(); | ||
const printed = result ? printer.printFile(result).replace(/\n$/, "") : ""; | ||
const leading = code.match(/^([\s]+)/)?.[0] || ""; | ||
return `${leading}${printed}`; | ||
}; | ||
var removeWholeExportSpecifier = (content, specifier, target) => { | ||
const sourceFile = ts4.createSourceFile( | ||
var getSpecifierPosition = (exportDeclaration) => { | ||
const sourceFile = ts6.createSourceFile( | ||
"tmp.ts", | ||
content, | ||
target ?? ts4.ScriptTarget.Latest | ||
exportDeclaration, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const result = []; | ||
const result = /* @__PURE__ */ new Map(); | ||
const visit = (node) => { | ||
if (result.length > 0) { | ||
return; | ||
if (ts6.isExportDeclaration(node) && node.exportClause?.kind === ts6.SyntaxKind.NamedExports) { | ||
node.exportClause.elements.forEach((element) => { | ||
result.set( | ||
element.name.text, | ||
element.getStart(sourceFile) - sourceFile.getStart() | ||
); | ||
}); | ||
} | ||
if (ts4.isExportDeclaration(node) && node.moduleSpecifier && ts4.isStringLiteral(node.moduleSpecifier) && !node.exportClause && node.moduleSpecifier.text === specifier) { | ||
result.push({ | ||
textChange: { | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return result; | ||
}; | ||
var processFile = ({ | ||
targetFile, | ||
files, | ||
vertexes, | ||
deleteUnusedFile, | ||
enableCodeFix, | ||
options, | ||
projectRoot | ||
}) => { | ||
const usage = findFileUsage({ | ||
targetFile, | ||
vertexes, | ||
files, | ||
options | ||
}); | ||
if (usage.has("*")) { | ||
return { | ||
operation: "edit", | ||
content: files.get(targetFile) || "", | ||
removedExports: [] | ||
}; | ||
} | ||
const { exports } = parseFile({ | ||
file: targetFile, | ||
content: files.get(targetFile) || "", | ||
options, | ||
destFiles: vertexes.get(targetFile)?.to || /* @__PURE__ */ new Set([]) | ||
}); | ||
if (usage.size === 0 && deleteUnusedFile && !exports.some((v) => "skip" in v && v.skip)) { | ||
return { | ||
operation: "delete" | ||
}; | ||
} | ||
const changes = []; | ||
const logs = []; | ||
exports.forEach((item) => { | ||
switch (item.kind) { | ||
case ts6.SyntaxKind.VariableStatement: { | ||
if (item.skip || item.name.every((it) => usage.has(it))) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
// todo: handle variable statement with multiple declarations properly | ||
code: item.name.join(", ") | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.FunctionDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: item.change.isUnnamedDefaultExport ? "" : stripExportKeyword(item.change.code), | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.InterfaceDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.TypeAliasDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.ExportAssignment: { | ||
if (item.skip || usage.has("default")) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: "default" | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.ExportDeclaration: { | ||
switch (item.type) { | ||
case "named": { | ||
if (item.skip || item.name.every((it) => usage.has(it))) { | ||
break; | ||
} | ||
const unused = item.name.filter((it) => !usage.has(it)); | ||
const count = item.name.length - unused.length; | ||
changes.push({ | ||
newText: count > 0 ? updateExportDeclaration(item.change.code, unused) : "", | ||
span: item.change.span | ||
}); | ||
const position = getSpecifierPosition(item.change.code); | ||
logs.push( | ||
...unused.map((it) => ({ | ||
fileName: targetFile, | ||
position: item.start + (position.get(it) || 0), | ||
code: it | ||
})) | ||
); | ||
break; | ||
} | ||
}, | ||
info: { | ||
position: node.getStart(sourceFile), | ||
code: node.getText(sourceFile) | ||
case "namespace": { | ||
if (usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case "whole": { | ||
if (!item.file) { | ||
break; | ||
} | ||
const parsed = parseFile({ | ||
file: item.file, | ||
content: files.get(item.file) || "", | ||
options, | ||
destFiles: vertexes.get(item.file)?.to || /* @__PURE__ */ new Set([]) | ||
}); | ||
const exported = parsed.exports.flatMap( | ||
(v) => "name" in v ? v.name : [] | ||
); | ||
if (exported.some((v) => usage.has(v))) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: `export * from '${item.specifier}';` | ||
}); | ||
break; | ||
} | ||
default: { | ||
throw new Error(`unexpected: ${item}`); | ||
} | ||
} | ||
break; | ||
} | ||
case ts6.SyntaxKind.ClassDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
default: { | ||
throw new Error(`unexpected: ${item}`); | ||
} | ||
} | ||
}); | ||
if (changes.length === 0) { | ||
const result2 = { | ||
operation: "edit", | ||
content: files.get(targetFile) || "", | ||
removedExports: logs | ||
}; | ||
return result2; | ||
} | ||
let content = applyTextChanges(files.get(targetFile) || "", changes); | ||
const fileService = new MemoryFileService(); | ||
fileService.set(targetFile, content); | ||
if (enableCodeFix && changes.length > 0) { | ||
const languageService = createLanguageService({ | ||
options, | ||
projectRoot, | ||
fileService | ||
}); | ||
while (true) { | ||
fileService.set(targetFile, content); | ||
const result2 = applyCodeFix({ | ||
fixId: fixIdDelete, | ||
fileName: targetFile, | ||
languageService | ||
}); | ||
if (result2 === content) { | ||
break; | ||
} | ||
content = result2; | ||
} | ||
}; | ||
sourceFile.forEachChild(visit); | ||
if (!result[0]) { | ||
return null; | ||
fileService.set(targetFile, content); | ||
content = applyCodeFix({ | ||
fixId: fixIdDeleteImports, | ||
fileName: targetFile, | ||
languageService | ||
}); | ||
} | ||
return { | ||
info: result[0].info, | ||
content: applyTextChanges(content, [result[0].textChange]) | ||
fileService.set(targetFile, content); | ||
const result = { | ||
operation: "edit", | ||
content: fileService.get(targetFile), | ||
removedExports: logs | ||
}; | ||
return result; | ||
}; | ||
@@ -521,8 +1246,3 @@ var removeUnusedExport = async ({ | ||
const program = createProgram({ fileService, options, projectRoot }); | ||
const skipFiles = collectSkipFiles({ | ||
entrypoints, | ||
program, | ||
fileService | ||
}); | ||
const dependencyGraph = collectImports({ | ||
const dependencyGraph = createDependencyGraph({ | ||
fileService, | ||
@@ -532,26 +1252,9 @@ program, | ||
}); | ||
let filesOutsideOfGraphHasSkipComment = false; | ||
const initialFiles = []; | ||
for (const file of fileService.getFileNames()) { | ||
if (entrypoints.includes(file)) { | ||
continue; | ||
} | ||
const vertex = dependencyGraph.vertexes.get(file); | ||
if (vertex) { | ||
initialFiles.push({ file, depth: vertex.data.depth }); | ||
continue; | ||
} | ||
if (fileService.get(file).includes(IGNORE_COMMENT)) { | ||
filesOutsideOfGraphHasSkipComment = true; | ||
} | ||
if (deleteUnusedFile && !filesOutsideOfGraphHasSkipComment && !skipFiles.includes(file)) { | ||
editTracker.start(file, fileService.get(file)); | ||
editTracker.delete(file); | ||
fileService.delete(file); | ||
continue; | ||
} | ||
initialFiles.push({ file, depth: -1 }); | ||
} | ||
const initialFiles = Array.from( | ||
fileService.getFileNames().filter((file) => !entrypoints.includes(file)) | ||
).map((file) => ({ | ||
file, | ||
depth: dependencyGraph.vertexes.get(file)?.data.depth || Infinity | ||
})); | ||
initialFiles.sort((a, b) => a.depth - b.depth); | ||
const wholeReexportsToBeDeleted = []; | ||
const taskManager = new TaskManager(async (c) => { | ||
@@ -562,23 +1265,2 @@ if (!fileService.exists(c.file)) { | ||
const vertex = dependencyGraph.vertexes.get(c.file); | ||
if (vertex && vertex.data.fromDynamic.size > 0) { | ||
await Promise.resolve(); | ||
if (c.signal.aborted) { | ||
return; | ||
} | ||
editTracker.start(c.file, fileService.get(c.file)); | ||
editTracker.end(c.file); | ||
return; | ||
} | ||
const necessaryFiles = getNecessaryFiles({ | ||
targetFile: c.file, | ||
dependencyGraph, | ||
files: fileService.getFileNames() | ||
}); | ||
const files = Array.from(necessaryFiles).reduce( | ||
(acc, cur) => ({ | ||
...acc, | ||
[cur]: fileService.get(cur) | ||
}), | ||
{} | ||
); | ||
await Promise.resolve(); | ||
@@ -588,5 +1270,7 @@ if (c.signal.aborted) { | ||
} | ||
const result = await pool.run({ | ||
file: c.file, | ||
files, | ||
const fn2 = pool ? pool.run.bind(pool) : processFile; | ||
const result = await fn2({ | ||
targetFile: c.file, | ||
vertexes: dependencyGraph.eject(), | ||
files: fileService.eject(), | ||
deleteUnusedFile, | ||
@@ -603,3 +1287,3 @@ enableCodeFix, | ||
editTracker.start(c.file, fileService.get(c.file)); | ||
if (skipFiles.includes(c.file)) { | ||
if (entrypoints.includes(c.file)) { | ||
editTracker.end(c.file); | ||
@@ -611,12 +1295,2 @@ break; | ||
if (vertex) { | ||
for (const v of vertex.from) { | ||
const target = dependencyGraph.vertexes.get(v); | ||
if (!target) { | ||
continue; | ||
} | ||
const specifier = target.data.wholeReexportSpecifier.get(c.file); | ||
if (target.data.hasReexport && specifier) { | ||
wholeReexportsToBeDeleted.push({ file: v, specifier }); | ||
} | ||
} | ||
dependencyGraph.deleteVertex(c.file); | ||
@@ -651,16 +1325,2 @@ if (recursive) { | ||
await taskManager.execute(initialFiles.map((v) => v.file)); | ||
for (const item of wholeReexportsToBeDeleted) { | ||
if (!fileService.exists(item.file)) { | ||
continue; | ||
} | ||
const content = fileService.get(item.file); | ||
const result = removeWholeExportSpecifier(content, item.specifier); | ||
if (!result) { | ||
continue; | ||
} | ||
fileService.set(item.file, result.content); | ||
editTracker.start(item.file, content); | ||
editTracker.removeExport(item.file, result.info); | ||
editTracker.end(item.file); | ||
} | ||
}; | ||
@@ -675,2 +1335,7 @@ | ||
import { relative } from "node:path"; | ||
// lib/util/formatCount.ts | ||
var formatCount = (count, singular, plural = `${singular}s`) => `${count} ${count === 1 ? singular : plural}`; | ||
// lib/util/CliEditTracker.ts | ||
var EditTrackerError = class extends Error { | ||
@@ -804,22 +1469,20 @@ }; | ||
const result = [ | ||
deleteCount > 0 ? `delete ${deleteCount} file(s)` : "", | ||
editCount > 0 ? `remove ${editCount} export(s)` : "" | ||
deleteCount > 0 ? `delete ${formatCount(deleteCount, "file")}` : "", | ||
editCount > 0 ? `remove ${formatCount(editCount, "export")}` : "" | ||
].filter((t) => !!t); | ||
if (result.length > 0) { | ||
this.#logger.write(chalk.red.bold(` | ||
\u2716 ${result.join(", ")} | ||
this.#logger.write(chalk.red.bold(`\u2716 ${result.join(", ")} | ||
`)); | ||
return; | ||
} | ||
this.#logger.write(chalk.green.bold("\n\u2714 all good!\n")); | ||
this.#logger.write(chalk.green.bold("\u2714 all good!\n")); | ||
return; | ||
} else { | ||
const result = [ | ||
deleteCount > 0 ? `deleted ${deleteCount} file(s)` : "", | ||
editCount > 0 ? `removed ${editCount} export(s)` : "" | ||
deleteCount > 0 ? `deleted ${formatCount(deleteCount, "file")}` : "", | ||
editCount > 0 ? `removed ${formatCount(editCount, "export")}` : "" | ||
].filter((t) => !!t); | ||
this.#logger.write( | ||
chalk.green.bold( | ||
` | ||
\u2714 ${result.length > 0 ? result.join(", ") : "all good!"} | ||
`\u2714 ${result.length > 0 ? result.join(", ") : "all good!"} | ||
` | ||
@@ -878,10 +1541,10 @@ ) | ||
run(arg) { | ||
return new Promise((resolve2, reject) => { | ||
return new Promise((resolve3, reject) => { | ||
const worker = this.#idle.pop(); | ||
if (!worker) { | ||
this.#queue.push({ resolve: resolve2, reject, arg }); | ||
this.#queue.push({ resolve: resolve3, reject, arg }); | ||
return; | ||
} | ||
worker.current = { | ||
resolve: resolve2, | ||
resolve: resolve3, | ||
reject | ||
@@ -912,5 +1575,5 @@ }; | ||
} | ||
const { resolve: resolve2, reject, arg } = this.#queue.shift(); | ||
const { resolve: resolve3, reject, arg } = this.#queue.shift(); | ||
worker.current = { | ||
resolve: resolve2, | ||
resolve: resolve3, | ||
reject | ||
@@ -954,3 +1617,2 @@ }; | ||
if (code === 0) { | ||
console.log("here"); | ||
return; | ||
@@ -974,2 +1636,5 @@ } | ||
// lib/util/regex.ts | ||
var dts = /\.d\.ts$/; | ||
// lib/remove.ts | ||
@@ -991,13 +1656,8 @@ var createNodeJsLogger = () => "isTTY" in stdout && stdout.isTTY ? { | ||
recursive = false, | ||
system = ts5.sys, | ||
system = ts7.sys, | ||
logger = createNodeJsLogger() | ||
}) => { | ||
const pool = new WorkerPool({ | ||
name: "processFile", | ||
url: globalThis.__INTERNAL_WORKER_URL__ || new URL("./worker.js", import.meta.url).href | ||
}); | ||
const editTracker = new CliEditTracker(logger, mode, projectRoot); | ||
const { config, error } = ts5.readConfigFile(configPath, system.readFile); | ||
const { config, error } = ts7.readConfigFile(configPath, system.readFile); | ||
const relativeToCwd = (fileName) => relative2(cwd(), fileName).replaceAll("\\", "/"); | ||
const { options, fileNames } = ts5.parseJsonConfigFileContent( | ||
const { options, fileNames } = ts7.parseJsonConfigFileContent( | ||
config, | ||
@@ -1008,7 +1668,4 @@ system, | ||
if (!error) { | ||
logger.write( | ||
`${chalk2.blue("tsconfig")} ${chalk2.gray("using")} ${relativeToCwd(configPath)} | ||
` | ||
); | ||
logger.write(`${chalk2.blue("tsconfig")} ${relativeToCwd(configPath)} | ||
`); | ||
} | ||
@@ -1022,10 +1679,31 @@ const fileService = new MemoryFileService(); | ||
); | ||
if (skip.filter((it) => it !== dts).length === 0) { | ||
logger.write( | ||
chalk2.bold.red( | ||
"At least one pattern must be specified for the skip option\n" | ||
) | ||
); | ||
system.exit(1); | ||
return; | ||
} | ||
if (entrypoints.length === 0) { | ||
logger.write(chalk2.bold.red("No files matched the skip pattern\n")); | ||
system.exit(1); | ||
return; | ||
} | ||
const editTracker = new CliEditTracker(logger, mode, projectRoot); | ||
editTracker.setTotal(fileNames.length - entrypoints.length); | ||
logger.write( | ||
chalk2.gray( | ||
`Project has ${fileNames.length} file(s), skipping ${entrypoints.length} file(s)... | ||
`Project has ${formatCount(fileNames.length, "file")}, skipping ${formatCount( | ||
entrypoints.length, | ||
"file" | ||
)} | ||
` | ||
) | ||
); | ||
const pool = new WorkerPool({ | ||
name: "processFile", | ||
url: globalThis.__INTERNAL_WORKER_URL__ || new URL("./worker.js", import.meta.url).href | ||
}); | ||
await removeUnusedExport({ | ||
@@ -1067,3 +1745,3 @@ fileService, | ||
import { createRequire } from "node:module"; | ||
import { resolve } from "node:path"; | ||
import { resolve as resolve2 } from "node:path"; | ||
import { cwd as cwd2 } from "node:process"; | ||
@@ -1084,6 +1762,6 @@ var cli = cac("ts-remove-unused"); | ||
if (!options["includeD-ts"]) { | ||
skip.push(new RegExp("\\.d\\.ts")); | ||
skip.push(dts); | ||
} | ||
remove({ | ||
configPath: resolve(options.project || "./tsconfig.json"), | ||
configPath: resolve2(options.project || "./tsconfig.json"), | ||
skip, | ||
@@ -1090,0 +1768,0 @@ mode: options.check ? "check" : "write", |
1202
dist/main.js
// lib/remove.ts | ||
import ts5 from "typescript"; | ||
import ts7 from "typescript"; | ||
@@ -34,6 +34,13 @@ // lib/util/MemoryFileService.ts | ||
} | ||
eject() { | ||
const res = /* @__PURE__ */ new Map(); | ||
for (const [name, { content }] of this.#files) { | ||
res.set(name, content); | ||
} | ||
return res; | ||
} | ||
}; | ||
// lib/util/removeUnusedExport.ts | ||
import ts4 from "typescript"; | ||
import ts6 from "typescript"; | ||
@@ -75,22 +82,59 @@ // lib/util/applyTextChanges.ts | ||
import ts from "typescript"; | ||
var fixIdDelete = "unusedIdentifier_delete"; | ||
var fixIdDeleteImports = "unusedIdentifier_deleteImports"; | ||
var filterChanges = ({ | ||
sourceFile, | ||
textChanges | ||
}) => { | ||
const result = []; | ||
const visit = (node) => { | ||
if ((ts.isArrowFunction(node) || ts.isMethodDeclaration(node) || ts.isFunctionDeclaration(node)) && node.parameters.length > 0) { | ||
const start = node.parameters[0]?.getStart(); | ||
const end = node.parameters[node.parameters.length - 1]?.getEnd(); | ||
if (typeof start === "number" && typeof end === "number") { | ||
result.push({ start, end }); | ||
} | ||
} | ||
node.forEachChild(visit); | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return textChanges.filter((change) => { | ||
const start = change.span.start; | ||
const end = change.span.start + change.span.length; | ||
return !result.some((r) => r.start <= start && end <= r.end); | ||
}); | ||
}; | ||
var applyCodeFix = ({ | ||
fixId, | ||
languageService, | ||
fileName | ||
}) => { | ||
const program = languageService.getProgram(); | ||
if (!program) { | ||
throw new Error("program not found"); | ||
} | ||
const sourceFile = program.getSourceFile(fileName); | ||
if (!sourceFile) { | ||
throw new Error(`source file not found: ${fileName}`); | ||
} | ||
let content = sourceFile.getFullText(); | ||
const actions = languageService.getCombinedCodeFix( | ||
{ | ||
type: "file", | ||
fileName | ||
}, | ||
fixId, | ||
{}, | ||
{} | ||
); | ||
for (const change of actions.changes) { | ||
const textChanges = fixId === fixIdDelete ? filterChanges({ sourceFile, textChanges: change.textChanges }) : change.textChanges; | ||
content = applyTextChanges(content, textChanges); | ||
} | ||
return content; | ||
}; | ||
// lib/util/getFileFromModuleSpecifierText.ts | ||
// lib/util/createDependencyGraph.ts | ||
import ts2 from "typescript"; | ||
var getFileFromModuleSpecifierText = ({ | ||
specifier, | ||
fileName, | ||
program, | ||
fileService | ||
}) => ts2.resolveModuleName(specifier, fileName, program.getCompilerOptions(), { | ||
fileExists(f) { | ||
return fileService.exists(f); | ||
}, | ||
readFile(f) { | ||
return fileService.get(f); | ||
} | ||
}).resolvedModule?.resolvedFileName; | ||
// lib/util/collectImports.ts | ||
import ts3 from "typescript"; | ||
// lib/util/Graph.ts | ||
@@ -155,30 +199,37 @@ var Graph = class { | ||
super(() => ({ | ||
depth: 0, | ||
hasReexport: false, | ||
fromDynamic: /* @__PURE__ */ new Set(), | ||
// will not be updated when we delete a file, | ||
// but it's fine since we only use it to find the used specifier from a given file path. | ||
wholeReexportSpecifier: /* @__PURE__ */ new Map() | ||
depth: 0 | ||
})); | ||
} | ||
deleteVertex(vertex) { | ||
const selected = this.vertexes.get(vertex); | ||
if (!selected) { | ||
return; | ||
eject() { | ||
const map = /* @__PURE__ */ new Map(); | ||
for (const [k, v] of this.vertexes.entries()) { | ||
map.set(k, { | ||
from: new Set(v.from), | ||
to: new Set(v.to), | ||
data: { | ||
depth: v.data.depth | ||
} | ||
}); | ||
} | ||
for (const v of selected.to) { | ||
const target = this.vertexes.get(v); | ||
if (!target) { | ||
continue; | ||
} | ||
target.data.fromDynamic.delete(vertex); | ||
} | ||
super.deleteVertex(vertex); | ||
return map; | ||
} | ||
}; | ||
// lib/util/collectImports.ts | ||
// lib/util/createDependencyGraph.ts | ||
var getFileFromModuleSpecifierText = ({ | ||
specifier, | ||
fileName, | ||
program, | ||
fileService | ||
}) => ts2.resolveModuleName(specifier, fileName, program.getCompilerOptions(), { | ||
fileExists(f) { | ||
return fileService.exists(f); | ||
}, | ||
readFile(f) { | ||
return fileService.get(f); | ||
} | ||
}).resolvedModule?.resolvedFileName; | ||
var getMatchingNode = (node) => { | ||
if (ts3.isImportDeclaration(node)) { | ||
if (ts3.isStringLiteral(node.moduleSpecifier)) { | ||
if (ts2.isImportDeclaration(node)) { | ||
if (ts2.isStringLiteral(node.moduleSpecifier)) { | ||
return { | ||
@@ -194,4 +245,4 @@ type: "import", | ||
} | ||
if (ts3.isExportDeclaration(node)) { | ||
if (node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
if (ts2.isExportDeclaration(node)) { | ||
if (node.moduleSpecifier && ts2.isStringLiteral(node.moduleSpecifier)) { | ||
const result = { | ||
@@ -209,4 +260,4 @@ type: "reexport", | ||
} | ||
if (ts3.isCallExpression(node) && node.expression.kind === ts3.SyntaxKind.ImportKeyword) { | ||
if (node.arguments[0] && ts3.isStringLiteral(node.arguments[0])) { | ||
if (ts2.isCallExpression(node) && node.expression.kind === ts2.SyntaxKind.ImportKeyword) { | ||
if (node.arguments[0] && ts2.isStringLiteral(node.arguments[0])) { | ||
return { | ||
@@ -224,3 +275,3 @@ type: "dynamicImport", | ||
}; | ||
var collectImports = ({ | ||
var createDependencyGraph = ({ | ||
fileService, | ||
@@ -233,3 +284,3 @@ program, | ||
const stack = []; | ||
const visited = /* @__PURE__ */ new Set(); | ||
const untouched = new Set(fileService.getFileNames()); | ||
for (const entrypoint of entrypoints) { | ||
@@ -244,6 +295,6 @@ stack.push({ file: entrypoint, depth: 0 }); | ||
const { file, depth } = item; | ||
if (visited.has(file)) { | ||
if (!untouched.has(file)) { | ||
continue; | ||
} | ||
visited.add(file); | ||
untouched.delete(file); | ||
const sourceFile = program.getSourceFile(file); | ||
@@ -253,3 +304,2 @@ if (!sourceFile) { | ||
} | ||
let hasReexport = false; | ||
const visit = (node) => { | ||
@@ -262,5 +312,2 @@ const match = getMatchingNode(node); | ||
if (match.specifier) { | ||
if (match.type === "reexport") { | ||
hasReexport = true; | ||
} | ||
const dest = getFileFromModuleSpecifierText({ | ||
@@ -272,10 +319,4 @@ specifier: match.specifier, | ||
}); | ||
if (match.type === "reexport" && dest && match.whole) { | ||
graph.vertexes.get(file)?.data.wholeReexportSpecifier.set(dest, match.specifier); | ||
} | ||
if (dest && files.has(dest)) { | ||
graph.addEdge(sourceFile.fileName, dest); | ||
if (match.type === "dynamicImport") { | ||
graph.vertexes.get(dest)?.data.fromDynamic.add(file); | ||
} | ||
stack.push({ file: dest, depth: depth + 1 }); | ||
@@ -289,6 +330,35 @@ } | ||
if (vertex) { | ||
vertex.data.hasReexport = hasReexport; | ||
vertex.data.depth = depth; | ||
} | ||
} | ||
for (const file of untouched.values()) { | ||
const sourceFile = program.getSourceFile(file); | ||
if (!sourceFile) { | ||
continue; | ||
} | ||
const visit = (node) => { | ||
const match = getMatchingNode(node); | ||
if (!match) { | ||
node.forEachChild(visit); | ||
return; | ||
} | ||
if (match.specifier) { | ||
const dest = getFileFromModuleSpecifierText({ | ||
specifier: match.specifier, | ||
program, | ||
fileName: sourceFile.fileName, | ||
fileService | ||
}); | ||
if (dest && files.has(dest)) { | ||
graph.addEdge(sourceFile.fileName, dest); | ||
} | ||
return; | ||
} | ||
}; | ||
sourceFile.forEachChild(visit); | ||
const vertex = graph.vertexes.get(file); | ||
if (vertex) { | ||
vertex.data.depth = Infinity; | ||
} | ||
} | ||
return graph; | ||
@@ -341,48 +411,416 @@ }; | ||
// lib/util/removeUnusedExport.ts | ||
// lib/util/findFileUsage.ts | ||
import ts4 from "typescript"; | ||
// lib/util/parseFile.ts | ||
import ts3 from "typescript"; | ||
// lib/util/memoize.ts | ||
var memoize = (fn2, { key }) => { | ||
const cache = /* @__PURE__ */ new Map(); | ||
return (...args) => { | ||
const k = key(...args); | ||
if (cache.has(k)) { | ||
return cache.get(k); | ||
} | ||
const result = fn2(...args); | ||
cache.set(k, result); | ||
return result; | ||
}; | ||
}; | ||
// lib/util/parseFile.ts | ||
var IGNORE_COMMENT = "ts-remove-unused-skip"; | ||
var disabledEditTracker = { | ||
start: () => { | ||
var getLeadingComment = (node) => { | ||
const fullText = node.getSourceFile().getFullText(); | ||
const ranges = ts3.getLeadingCommentRanges(fullText, node.getFullStart()); | ||
if (!ranges) { | ||
return ""; | ||
} | ||
return ranges.map((range) => fullText.slice(range.pos, range.end)).join(""); | ||
}; | ||
var resolve = ({ | ||
specifier, | ||
file, | ||
destFiles, | ||
options | ||
}) => ts3.resolveModuleName(specifier, file, options, { | ||
fileExists(f) { | ||
return destFiles.has(f); | ||
}, | ||
end: () => { | ||
}, | ||
delete: () => { | ||
}, | ||
removeExport: () => { | ||
readFile(f) { | ||
throw new Error(`Unexpected readFile call: ${f}`); | ||
} | ||
}).resolvedModule?.resolvedFileName; | ||
var getChange = (node) => { | ||
if (ts3.isExportDeclaration(node) || ts3.isExportAssignment(node)) { | ||
return { | ||
code: node.getFullText(), | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}; | ||
} | ||
if ((ts3.isFunctionDeclaration(node) || ts3.isClassDeclaration(node)) && !node.name) { | ||
return { | ||
code: node.getFullText(), | ||
isUnnamedDefaultExport: true, | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}; | ||
} | ||
const syntaxListIndex = node.getChildren().findIndex((n) => n.kind === ts3.SyntaxKind.SyntaxList); | ||
const syntaxList = node.getChildren()[syntaxListIndex]; | ||
const nextSibling = node.getChildren()[syntaxListIndex + 1]; | ||
if (!syntaxList || !nextSibling) { | ||
throw new Error("Unexpected syntax"); | ||
} | ||
return { | ||
code: node.getSourceFile().getFullText().slice(syntaxList.getStart(), nextSibling.getStart()), | ||
span: { | ||
start: syntaxList.getStart(), | ||
length: nextSibling.getStart() - syntaxList.getStart() | ||
} | ||
}; | ||
}; | ||
var getNecessaryFiles = ({ | ||
var fn = ({ | ||
file, | ||
content, | ||
destFiles, | ||
options = {} | ||
}) => { | ||
const imports = {}; | ||
const exports = []; | ||
const sourceFile = ts3.createSourceFile( | ||
file, | ||
content, | ||
ts3.ScriptTarget.ESNext, | ||
true | ||
); | ||
const visit = (node) => { | ||
if (ts3.isVariableStatement(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
const name = node.declarationList.declarations.map( | ||
(d) => d.name.getText() | ||
); | ||
exports.push({ | ||
kind: ts3.SyntaxKind.VariableStatement, | ||
name, | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isFunctionDeclaration(node) || ts3.isInterfaceDeclaration(node) || ts3.isClassDeclaration(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
if (node.modifiers?.some((m) => m.kind === ts3.SyntaxKind.DefaultKeyword)) { | ||
exports.push({ | ||
kind: node.kind, | ||
name: "default", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} else { | ||
exports.push({ | ||
kind: node.kind, | ||
name: node.name?.getText() || "", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isTypeAliasDeclaration(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
exports.push({ | ||
kind: node.kind, | ||
name: node.name.getText(), | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isExportAssignment(node) && !node.isExportEquals) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportAssignment, | ||
name: "default", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamedExports && !node.moduleSpecifier) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "named", | ||
// we always collect the name not the propertyName because its for exports | ||
name: node.exportClause.elements.map((element) => element.name.text), | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamedExports && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "named", | ||
// we always collect the name not the propertyName because its for exports | ||
name: node.exportClause.elements.map((element) => element.name.text), | ||
change: getChange(node), | ||
skip: false, | ||
start: node.getStart() | ||
}); | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
node.exportClause.elements.forEach((element) => { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push( | ||
element.propertyName?.text || element.name.text | ||
); | ||
}); | ||
} | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamespaceExport && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "namespace", | ||
name: node.exportClause.name.text, | ||
start: node.getStart(), | ||
change: getChange(node) | ||
}); | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
} | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && !node.exportClause && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "whole", | ||
file: resolved || null, | ||
specifier: node.moduleSpecifier.text, | ||
start: node.getStart(), | ||
change: getChange(node) | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push({ type: "wholeReexport", file }); | ||
} | ||
return; | ||
} | ||
if (ts3.isImportDeclaration(node) && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (!resolved) { | ||
return; | ||
} | ||
if (node.importClause?.namedBindings?.kind === ts3.SyntaxKind.NamespaceImport) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
return; | ||
} | ||
if (node.importClause?.namedBindings?.kind === ts3.SyntaxKind.NamedImports) { | ||
const namedImports = node.importClause?.namedBindings; | ||
namedImports.elements.forEach((element) => { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push( | ||
element.propertyName?.text || element.name.text | ||
); | ||
}); | ||
} | ||
if (node.importClause?.name) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("default"); | ||
} | ||
return; | ||
} | ||
if (ts3.isCallExpression(node) && node.expression.kind === ts3.SyntaxKind.ImportKeyword && node.arguments[0] && ts3.isStringLiteral(node.arguments[0])) { | ||
const resolved = resolve({ | ||
specifier: node.arguments[0].text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (!resolved) { | ||
return; | ||
} | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
return; | ||
} | ||
node.forEachChild(visit); | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return { imports, exports }; | ||
}; | ||
var parseFile = memoize(fn, { | ||
key: (arg) => JSON.stringify({ | ||
file: arg.file, | ||
content: arg.content, | ||
destFiles: Array.from(arg.destFiles).sort(), | ||
options: arg.options | ||
}) | ||
}); | ||
// lib/util/findFileUsage.ts | ||
var fallback = () => ({ | ||
from: /* @__PURE__ */ new Set(), | ||
to: /* @__PURE__ */ new Set(), | ||
data: { | ||
depth: Infinity, | ||
wholeReexportSpecifier: /* @__PURE__ */ new Map() | ||
} | ||
}); | ||
var ALL_EXPORTS_OF_UNKNOWN_FILE = "__all_exports_of_unknown_file__"; | ||
var getExportsOfFile = ({ | ||
targetFile, | ||
dependencyGraph, | ||
files | ||
vertexes, | ||
files, | ||
options | ||
}) => { | ||
if (!dependencyGraph.vertexes.has(targetFile)) { | ||
return new Set(files.filter((file) => !dependencyGraph.vertexes.has(file))); | ||
} | ||
const result = /* @__PURE__ */ new Set(); | ||
const result = []; | ||
const stack = [targetFile]; | ||
while (stack.length > 0) { | ||
const file = stack.pop(); | ||
if (!file) { | ||
while (stack.length) { | ||
const item = stack.pop(); | ||
if (!item) { | ||
break; | ||
} | ||
result.add(file); | ||
const vertex = dependencyGraph.vertexes.get(file); | ||
if (!vertex) { | ||
continue; | ||
const vertex = vertexes.get(item) || fallback(); | ||
const { exports } = parseFile({ | ||
file: item, | ||
content: files.get(item) || "", | ||
destFiles: new Set(vertex.to), | ||
options | ||
}); | ||
exports.forEach((it) => { | ||
if (it.kind === ts4.SyntaxKind.ExportDeclaration && it.type === "whole") { | ||
if (it.file) { | ||
stack.push(it.file); | ||
} else { | ||
result.push(ALL_EXPORTS_OF_UNKNOWN_FILE); | ||
} | ||
return; | ||
} | ||
result.push(...Array.isArray(it.name) ? it.name : [it.name]); | ||
}); | ||
} | ||
return new Set(result); | ||
}; | ||
var findFileUsage = ({ | ||
targetFile, | ||
options, | ||
vertexes, | ||
files | ||
}) => { | ||
const result = []; | ||
const exportsOfTargetFile = getExportsOfFile({ | ||
targetFile, | ||
vertexes, | ||
files, | ||
options | ||
}); | ||
const stack = []; | ||
vertexes.get(targetFile)?.from.forEach( | ||
(file) => stack.push({ | ||
file, | ||
to: targetFile | ||
}) | ||
); | ||
while (stack.length) { | ||
const item = stack.pop(); | ||
if (!item) { | ||
break; | ||
} | ||
for (const from of vertex.from) { | ||
result.add(from); | ||
const fromVertex = dependencyGraph.vertexes.get(from); | ||
if (fromVertex && fromVertex.data.hasReexport) { | ||
stack.push(from); | ||
continue; | ||
const { file, to } = item; | ||
const vertex = vertexes.get(file) || fallback(); | ||
const { imports } = parseFile({ | ||
file, | ||
content: files.get(file) || "", | ||
destFiles: new Set(vertex.to), | ||
options | ||
}); | ||
const list = imports[to] || []; | ||
list.forEach((it) => { | ||
if (typeof it === "object" && it.type === "wholeReexport") { | ||
const n = vertexes.get(it.file); | ||
if (!n) { | ||
return; | ||
} | ||
if (n.data.depth === 0) { | ||
result.push("*"); | ||
return; | ||
} | ||
vertexes.get(it.file)?.from.forEach((f) => { | ||
stack.push({ | ||
file: f, | ||
to: it.file | ||
}); | ||
}); | ||
return; | ||
} | ||
if (vertex.data.fromDynamic.has(from)) { | ||
stack.push(from); | ||
if (typeof it === "string") { | ||
result.push(it); | ||
return; | ||
} | ||
} | ||
}); | ||
} | ||
return result; | ||
if (exportsOfTargetFile.has(ALL_EXPORTS_OF_UNKNOWN_FILE)) { | ||
return new Set(result); | ||
} | ||
return new Set( | ||
result.filter((it) => exportsOfTargetFile.has(it) || it === "*") | ||
); | ||
}; | ||
// lib/util/createProgram.ts | ||
import ts5 from "typescript"; | ||
var createProgram = ({ | ||
@@ -398,3 +836,3 @@ fileService, | ||
} | ||
return ts4.createSourceFile( | ||
return ts5.createSourceFile( | ||
fileName, | ||
@@ -405,3 +843,3 @@ fileService.get(fileName), | ||
}, | ||
getDefaultLibFileName: (o) => ts4.getDefaultLibFilePath(o), | ||
getDefaultLibFileName: (o) => ts5.getDefaultLibFilePath(o), | ||
writeFile: (fileName, content) => { | ||
@@ -413,7 +851,7 @@ fileService.set(fileName, content); | ||
readFile: (fileName) => fileService.get(fileName), | ||
getCanonicalFileName: (fileName) => ts4.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), | ||
getCanonicalFileName: (fileName) => ts5.sys.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(), | ||
useCaseSensitiveFileNames: () => true, | ||
getNewLine: () => "\n" | ||
}; | ||
const program = ts4.createProgram( | ||
const program = ts5.createProgram( | ||
fileService.getFileNames(), | ||
@@ -425,78 +863,365 @@ options, | ||
}; | ||
var collectSkipFiles = ({ | ||
entrypoints, | ||
program, | ||
// lib/util/removeUnusedExport.ts | ||
var stripExportKeyword = (syntaxList) => { | ||
const file = ts6.createSourceFile( | ||
"tmp.ts", | ||
`${syntaxList} function f() {}`, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const transformer = (context) => (rootNode) => { | ||
const visitor = (node) => { | ||
if (ts6.isFunctionDeclaration(node)) { | ||
return ts6.factory.createFunctionDeclaration( | ||
node.modifiers?.filter( | ||
(v) => v.kind !== ts6.SyntaxKind.ExportKeyword && v.kind !== ts6.SyntaxKind.DefaultKeyword | ||
), | ||
node.asteriskToken, | ||
node.name, | ||
node.typeParameters, | ||
node.parameters, | ||
node.type, | ||
node.body | ||
); | ||
} | ||
return ts6.visitEachChild(node, visitor, context); | ||
}; | ||
return ts6.visitEachChild(rootNode, visitor, context); | ||
}; | ||
const result = ts6.transform(file, [transformer]).transformed[0]; | ||
const printer = ts6.createPrinter(); | ||
const code = result ? printer.printFile(result).trim() : ""; | ||
const pos = code.indexOf("function"); | ||
return code.slice(0, pos); | ||
}; | ||
var disabledEditTracker = { | ||
start: () => { | ||
}, | ||
end: () => { | ||
}, | ||
delete: () => { | ||
}, | ||
removeExport: () => { | ||
} | ||
}; | ||
var createLanguageService = ({ | ||
options, | ||
projectRoot, | ||
fileService | ||
}) => { | ||
const stack = [...entrypoints]; | ||
const result = []; | ||
while (stack.length > 0) { | ||
const file = stack.pop(); | ||
if (!file) { | ||
break; | ||
const languageService = ts6.createLanguageService({ | ||
getCompilationSettings() { | ||
return options; | ||
}, | ||
getScriptFileNames() { | ||
return fileService.getFileNames(); | ||
}, | ||
getScriptVersion(fileName) { | ||
return fileService.getVersion(fileName); | ||
}, | ||
getScriptSnapshot(fileName) { | ||
return ts6.ScriptSnapshot.fromString(fileService.get(fileName)); | ||
}, | ||
getCurrentDirectory() { | ||
return projectRoot; | ||
}, | ||
getDefaultLibFileName(o) { | ||
return ts6.getDefaultLibFileName(o); | ||
}, | ||
fileExists(name) { | ||
return fileService.exists(name); | ||
}, | ||
readFile(name) { | ||
return fileService.get(name); | ||
} | ||
const sourceFile = program.getSourceFile(file); | ||
if (!sourceFile) { | ||
continue; | ||
} | ||
const visit = (node) => { | ||
if (!ts4.isExportDeclaration(node)) { | ||
return; | ||
}); | ||
return languageService; | ||
}; | ||
var updateExportDeclaration = (code, unused) => { | ||
const sourceFile = ts6.createSourceFile( | ||
"tmp.ts", | ||
code, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const transformer = (context) => (rootNode) => { | ||
const visitor = (node) => { | ||
if (ts6.isExportSpecifier(node) && unused.includes(node.getText(sourceFile))) { | ||
return void 0; | ||
} | ||
if (!node.moduleSpecifier || !ts4.isStringLiteral(node.moduleSpecifier)) { | ||
return; | ||
} | ||
if (node.exportClause) { | ||
return; | ||
} | ||
const dest = getFileFromModuleSpecifierText({ | ||
specifier: node.moduleSpecifier.text, | ||
program, | ||
fileName: sourceFile.fileName, | ||
fileService | ||
}); | ||
if (dest) { | ||
stack.push(dest); | ||
result.push(dest); | ||
} | ||
return ts6.visitEachChild(node, visitor, context); | ||
}; | ||
sourceFile.forEachChild(visit); | ||
} | ||
return result; | ||
return ts6.visitEachChild(rootNode, visitor, context); | ||
}; | ||
const result = ts6.transform(sourceFile, [transformer]).transformed[0]; | ||
const printer = ts6.createPrinter(); | ||
const printed = result ? printer.printFile(result).replace(/\n$/, "") : ""; | ||
const leading = code.match(/^([\s]+)/)?.[0] || ""; | ||
return `${leading}${printed}`; | ||
}; | ||
var removeWholeExportSpecifier = (content, specifier, target) => { | ||
const sourceFile = ts4.createSourceFile( | ||
var getSpecifierPosition = (exportDeclaration) => { | ||
const sourceFile = ts6.createSourceFile( | ||
"tmp.ts", | ||
content, | ||
target ?? ts4.ScriptTarget.Latest | ||
exportDeclaration, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const result = []; | ||
const result = /* @__PURE__ */ new Map(); | ||
const visit = (node) => { | ||
if (result.length > 0) { | ||
return; | ||
if (ts6.isExportDeclaration(node) && node.exportClause?.kind === ts6.SyntaxKind.NamedExports) { | ||
node.exportClause.elements.forEach((element) => { | ||
result.set( | ||
element.name.text, | ||
element.getStart(sourceFile) - sourceFile.getStart() | ||
); | ||
}); | ||
} | ||
if (ts4.isExportDeclaration(node) && node.moduleSpecifier && ts4.isStringLiteral(node.moduleSpecifier) && !node.exportClause && node.moduleSpecifier.text === specifier) { | ||
result.push({ | ||
textChange: { | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return result; | ||
}; | ||
var processFile = ({ | ||
targetFile, | ||
files, | ||
vertexes, | ||
deleteUnusedFile, | ||
enableCodeFix, | ||
options, | ||
projectRoot | ||
}) => { | ||
const usage = findFileUsage({ | ||
targetFile, | ||
vertexes, | ||
files, | ||
options | ||
}); | ||
if (usage.has("*")) { | ||
return { | ||
operation: "edit", | ||
content: files.get(targetFile) || "", | ||
removedExports: [] | ||
}; | ||
} | ||
const { exports } = parseFile({ | ||
file: targetFile, | ||
content: files.get(targetFile) || "", | ||
options, | ||
destFiles: vertexes.get(targetFile)?.to || /* @__PURE__ */ new Set([]) | ||
}); | ||
if (usage.size === 0 && deleteUnusedFile && !exports.some((v) => "skip" in v && v.skip)) { | ||
return { | ||
operation: "delete" | ||
}; | ||
} | ||
const changes = []; | ||
const logs = []; | ||
exports.forEach((item) => { | ||
switch (item.kind) { | ||
case ts6.SyntaxKind.VariableStatement: { | ||
if (item.skip || item.name.every((it) => usage.has(it))) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
// todo: handle variable statement with multiple declarations properly | ||
code: item.name.join(", ") | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.FunctionDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: item.change.isUnnamedDefaultExport ? "" : stripExportKeyword(item.change.code), | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.InterfaceDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.TypeAliasDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.ExportAssignment: { | ||
if (item.skip || usage.has("default")) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: "default" | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.ExportDeclaration: { | ||
switch (item.type) { | ||
case "named": { | ||
if (item.skip || item.name.every((it) => usage.has(it))) { | ||
break; | ||
} | ||
const unused = item.name.filter((it) => !usage.has(it)); | ||
const count = item.name.length - unused.length; | ||
changes.push({ | ||
newText: count > 0 ? updateExportDeclaration(item.change.code, unused) : "", | ||
span: item.change.span | ||
}); | ||
const position = getSpecifierPosition(item.change.code); | ||
logs.push( | ||
...unused.map((it) => ({ | ||
fileName: targetFile, | ||
position: item.start + (position.get(it) || 0), | ||
code: it | ||
})) | ||
); | ||
break; | ||
} | ||
}, | ||
info: { | ||
position: node.getStart(sourceFile), | ||
code: node.getText(sourceFile) | ||
case "namespace": { | ||
if (usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case "whole": { | ||
if (!item.file) { | ||
break; | ||
} | ||
const parsed = parseFile({ | ||
file: item.file, | ||
content: files.get(item.file) || "", | ||
options, | ||
destFiles: vertexes.get(item.file)?.to || /* @__PURE__ */ new Set([]) | ||
}); | ||
const exported = parsed.exports.flatMap( | ||
(v) => "name" in v ? v.name : [] | ||
); | ||
if (exported.some((v) => usage.has(v))) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: `export * from '${item.specifier}';` | ||
}); | ||
break; | ||
} | ||
default: { | ||
throw new Error(`unexpected: ${item}`); | ||
} | ||
} | ||
break; | ||
} | ||
case ts6.SyntaxKind.ClassDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
default: { | ||
throw new Error(`unexpected: ${item}`); | ||
} | ||
} | ||
}); | ||
if (changes.length === 0) { | ||
const result2 = { | ||
operation: "edit", | ||
content: files.get(targetFile) || "", | ||
removedExports: logs | ||
}; | ||
return result2; | ||
} | ||
let content = applyTextChanges(files.get(targetFile) || "", changes); | ||
const fileService = new MemoryFileService(); | ||
fileService.set(targetFile, content); | ||
if (enableCodeFix && changes.length > 0) { | ||
const languageService = createLanguageService({ | ||
options, | ||
projectRoot, | ||
fileService | ||
}); | ||
while (true) { | ||
fileService.set(targetFile, content); | ||
const result2 = applyCodeFix({ | ||
fixId: fixIdDelete, | ||
fileName: targetFile, | ||
languageService | ||
}); | ||
if (result2 === content) { | ||
break; | ||
} | ||
content = result2; | ||
} | ||
}; | ||
sourceFile.forEachChild(visit); | ||
if (!result[0]) { | ||
return null; | ||
fileService.set(targetFile, content); | ||
content = applyCodeFix({ | ||
fixId: fixIdDeleteImports, | ||
fileName: targetFile, | ||
languageService | ||
}); | ||
} | ||
return { | ||
info: result[0].info, | ||
content: applyTextChanges(content, [result[0].textChange]) | ||
fileService.set(targetFile, content); | ||
const result = { | ||
operation: "edit", | ||
content: fileService.get(targetFile), | ||
removedExports: logs | ||
}; | ||
return result; | ||
}; | ||
@@ -515,8 +1240,3 @@ var removeUnusedExport = async ({ | ||
const program = createProgram({ fileService, options, projectRoot }); | ||
const skipFiles = collectSkipFiles({ | ||
entrypoints, | ||
program, | ||
fileService | ||
}); | ||
const dependencyGraph = collectImports({ | ||
const dependencyGraph = createDependencyGraph({ | ||
fileService, | ||
@@ -526,26 +1246,9 @@ program, | ||
}); | ||
let filesOutsideOfGraphHasSkipComment = false; | ||
const initialFiles = []; | ||
for (const file of fileService.getFileNames()) { | ||
if (entrypoints.includes(file)) { | ||
continue; | ||
} | ||
const vertex = dependencyGraph.vertexes.get(file); | ||
if (vertex) { | ||
initialFiles.push({ file, depth: vertex.data.depth }); | ||
continue; | ||
} | ||
if (fileService.get(file).includes(IGNORE_COMMENT)) { | ||
filesOutsideOfGraphHasSkipComment = true; | ||
} | ||
if (deleteUnusedFile && !filesOutsideOfGraphHasSkipComment && !skipFiles.includes(file)) { | ||
editTracker.start(file, fileService.get(file)); | ||
editTracker.delete(file); | ||
fileService.delete(file); | ||
continue; | ||
} | ||
initialFiles.push({ file, depth: -1 }); | ||
} | ||
const initialFiles = Array.from( | ||
fileService.getFileNames().filter((file) => !entrypoints.includes(file)) | ||
).map((file) => ({ | ||
file, | ||
depth: dependencyGraph.vertexes.get(file)?.data.depth || Infinity | ||
})); | ||
initialFiles.sort((a, b) => a.depth - b.depth); | ||
const wholeReexportsToBeDeleted = []; | ||
const taskManager = new TaskManager(async (c) => { | ||
@@ -556,23 +1259,2 @@ if (!fileService.exists(c.file)) { | ||
const vertex = dependencyGraph.vertexes.get(c.file); | ||
if (vertex && vertex.data.fromDynamic.size > 0) { | ||
await Promise.resolve(); | ||
if (c.signal.aborted) { | ||
return; | ||
} | ||
editTracker.start(c.file, fileService.get(c.file)); | ||
editTracker.end(c.file); | ||
return; | ||
} | ||
const necessaryFiles = getNecessaryFiles({ | ||
targetFile: c.file, | ||
dependencyGraph, | ||
files: fileService.getFileNames() | ||
}); | ||
const files = Array.from(necessaryFiles).reduce( | ||
(acc, cur) => ({ | ||
...acc, | ||
[cur]: fileService.get(cur) | ||
}), | ||
{} | ||
); | ||
await Promise.resolve(); | ||
@@ -582,5 +1264,7 @@ if (c.signal.aborted) { | ||
} | ||
const result = await pool.run({ | ||
file: c.file, | ||
files, | ||
const fn2 = pool ? pool.run.bind(pool) : processFile; | ||
const result = await fn2({ | ||
targetFile: c.file, | ||
vertexes: dependencyGraph.eject(), | ||
files: fileService.eject(), | ||
deleteUnusedFile, | ||
@@ -597,3 +1281,3 @@ enableCodeFix, | ||
editTracker.start(c.file, fileService.get(c.file)); | ||
if (skipFiles.includes(c.file)) { | ||
if (entrypoints.includes(c.file)) { | ||
editTracker.end(c.file); | ||
@@ -605,12 +1289,2 @@ break; | ||
if (vertex) { | ||
for (const v of vertex.from) { | ||
const target = dependencyGraph.vertexes.get(v); | ||
if (!target) { | ||
continue; | ||
} | ||
const specifier = target.data.wholeReexportSpecifier.get(c.file); | ||
if (target.data.hasReexport && specifier) { | ||
wholeReexportsToBeDeleted.push({ file: v, specifier }); | ||
} | ||
} | ||
dependencyGraph.deleteVertex(c.file); | ||
@@ -645,16 +1319,2 @@ if (recursive) { | ||
await taskManager.execute(initialFiles.map((v) => v.file)); | ||
for (const item of wholeReexportsToBeDeleted) { | ||
if (!fileService.exists(item.file)) { | ||
continue; | ||
} | ||
const content = fileService.get(item.file); | ||
const result = removeWholeExportSpecifier(content, item.specifier); | ||
if (!result) { | ||
continue; | ||
} | ||
fileService.set(item.file, result.content); | ||
editTracker.start(item.file, content); | ||
editTracker.removeExport(item.file, result.info); | ||
editTracker.end(item.file); | ||
} | ||
}; | ||
@@ -669,2 +1329,7 @@ | ||
import { relative } from "node:path"; | ||
// lib/util/formatCount.ts | ||
var formatCount = (count, singular, plural = `${singular}s`) => `${count} ${count === 1 ? singular : plural}`; | ||
// lib/util/CliEditTracker.ts | ||
var EditTrackerError = class extends Error { | ||
@@ -798,22 +1463,20 @@ }; | ||
const result = [ | ||
deleteCount > 0 ? `delete ${deleteCount} file(s)` : "", | ||
editCount > 0 ? `remove ${editCount} export(s)` : "" | ||
deleteCount > 0 ? `delete ${formatCount(deleteCount, "file")}` : "", | ||
editCount > 0 ? `remove ${formatCount(editCount, "export")}` : "" | ||
].filter((t) => !!t); | ||
if (result.length > 0) { | ||
this.#logger.write(chalk.red.bold(` | ||
\u2716 ${result.join(", ")} | ||
this.#logger.write(chalk.red.bold(`\u2716 ${result.join(", ")} | ||
`)); | ||
return; | ||
} | ||
this.#logger.write(chalk.green.bold("\n\u2714 all good!\n")); | ||
this.#logger.write(chalk.green.bold("\u2714 all good!\n")); | ||
return; | ||
} else { | ||
const result = [ | ||
deleteCount > 0 ? `deleted ${deleteCount} file(s)` : "", | ||
editCount > 0 ? `removed ${editCount} export(s)` : "" | ||
deleteCount > 0 ? `deleted ${formatCount(deleteCount, "file")}` : "", | ||
editCount > 0 ? `removed ${formatCount(editCount, "export")}` : "" | ||
].filter((t) => !!t); | ||
this.#logger.write( | ||
chalk.green.bold( | ||
` | ||
\u2714 ${result.length > 0 ? result.join(", ") : "all good!"} | ||
`\u2714 ${result.length > 0 ? result.join(", ") : "all good!"} | ||
` | ||
@@ -872,10 +1535,10 @@ ) | ||
run(arg) { | ||
return new Promise((resolve, reject) => { | ||
return new Promise((resolve2, reject) => { | ||
const worker = this.#idle.pop(); | ||
if (!worker) { | ||
this.#queue.push({ resolve, reject, arg }); | ||
this.#queue.push({ resolve: resolve2, reject, arg }); | ||
return; | ||
} | ||
worker.current = { | ||
resolve, | ||
resolve: resolve2, | ||
reject | ||
@@ -906,5 +1569,5 @@ }; | ||
} | ||
const { resolve, reject, arg } = this.#queue.shift(); | ||
const { resolve: resolve2, reject, arg } = this.#queue.shift(); | ||
worker.current = { | ||
resolve, | ||
resolve: resolve2, | ||
reject | ||
@@ -948,3 +1611,2 @@ }; | ||
if (code === 0) { | ||
console.log("here"); | ||
return; | ||
@@ -968,2 +1630,5 @@ } | ||
// lib/util/regex.ts | ||
var dts = /\.d\.ts$/; | ||
// lib/remove.ts | ||
@@ -985,13 +1650,8 @@ var createNodeJsLogger = () => "isTTY" in stdout && stdout.isTTY ? { | ||
recursive = false, | ||
system = ts5.sys, | ||
system = ts7.sys, | ||
logger = createNodeJsLogger() | ||
}) => { | ||
const pool = new WorkerPool({ | ||
name: "processFile", | ||
url: globalThis.__INTERNAL_WORKER_URL__ || new URL("./worker.js", import.meta.url).href | ||
}); | ||
const editTracker = new CliEditTracker(logger, mode, projectRoot); | ||
const { config, error } = ts5.readConfigFile(configPath, system.readFile); | ||
const { config, error } = ts7.readConfigFile(configPath, system.readFile); | ||
const relativeToCwd = (fileName) => relative2(cwd(), fileName).replaceAll("\\", "/"); | ||
const { options, fileNames } = ts5.parseJsonConfigFileContent( | ||
const { options, fileNames } = ts7.parseJsonConfigFileContent( | ||
config, | ||
@@ -1002,7 +1662,4 @@ system, | ||
if (!error) { | ||
logger.write( | ||
`${chalk2.blue("tsconfig")} ${chalk2.gray("using")} ${relativeToCwd(configPath)} | ||
` | ||
); | ||
logger.write(`${chalk2.blue("tsconfig")} ${relativeToCwd(configPath)} | ||
`); | ||
} | ||
@@ -1016,10 +1673,31 @@ const fileService = new MemoryFileService(); | ||
); | ||
if (skip.filter((it) => it !== dts).length === 0) { | ||
logger.write( | ||
chalk2.bold.red( | ||
"At least one pattern must be specified for the skip option\n" | ||
) | ||
); | ||
system.exit(1); | ||
return; | ||
} | ||
if (entrypoints.length === 0) { | ||
logger.write(chalk2.bold.red("No files matched the skip pattern\n")); | ||
system.exit(1); | ||
return; | ||
} | ||
const editTracker = new CliEditTracker(logger, mode, projectRoot); | ||
editTracker.setTotal(fileNames.length - entrypoints.length); | ||
logger.write( | ||
chalk2.gray( | ||
`Project has ${fileNames.length} file(s), skipping ${entrypoints.length} file(s)... | ||
`Project has ${formatCount(fileNames.length, "file")}, skipping ${formatCount( | ||
entrypoints.length, | ||
"file" | ||
)} | ||
` | ||
) | ||
); | ||
const pool = new WorkerPool({ | ||
name: "processFile", | ||
url: globalThis.__INTERNAL_WORKER_URL__ || new URL("./worker.js", import.meta.url).href | ||
}); | ||
await removeUnusedExport({ | ||
@@ -1026,0 +1704,0 @@ fileService, |
import { Graph } from './Graph.js'; | ||
export declare class DependencyGraph extends Graph<{ | ||
depth: number; | ||
hasReexport: boolean; | ||
fromDynamic: Set<string>; | ||
wholeReexportSpecifier: Map<string, string>; | ||
}> { | ||
constructor(); | ||
deleteVertex(vertex: string): void; | ||
eject(): Map<string, { | ||
to: Set<string>; | ||
from: Set<string>; | ||
data: { | ||
depth: number; | ||
}; | ||
}>; | ||
} | ||
export type Vertexes = ReturnType<DependencyGraph['eject']>; |
@@ -8,2 +8,3 @@ export interface FileService { | ||
exists(name: string): boolean; | ||
eject(): Map<string, string>; | ||
} |
@@ -11,2 +11,3 @@ import { FileService } from './FileService.js'; | ||
exists(name: string): boolean; | ||
eject(): Map<string, string>; | ||
} |
import ts from 'typescript'; | ||
import { FileService } from './FileService.js'; | ||
import { EditTracker } from './EditTracker.js'; | ||
import { Vertexes } from './DependencyGraph.js'; | ||
import { WorkerPool } from './WorkerPool.js'; | ||
type RemovedExport = { | ||
fileName: string; | ||
position: number; | ||
code: string; | ||
}; | ||
export declare const processFile: ({ file, files, deleteUnusedFile, enableCodeFix, options, projectRoot, }: { | ||
file: string; | ||
files: { | ||
[fileName: string]: string; | ||
}; | ||
export declare const processFile: ({ targetFile, files, vertexes, deleteUnusedFile, enableCodeFix, options, projectRoot, }: { | ||
targetFile: string; | ||
vertexes: Map<string, { | ||
to: Set<string>; | ||
from: Set<string>; | ||
data: { | ||
depth: number; | ||
}; | ||
}>; | ||
files: Map<string, string>; | ||
deleteUnusedFile: boolean; | ||
@@ -20,7 +21,11 @@ enableCodeFix: boolean; | ||
}) => { | ||
operation: "delete"; | ||
} | { | ||
operation: "edit"; | ||
content: string; | ||
removedExports: RemovedExport[]; | ||
removedExports: { | ||
fileName: string; | ||
position: number; | ||
code: string; | ||
}[]; | ||
} | { | ||
operation: "delete"; | ||
}; | ||
@@ -36,4 +41,27 @@ export declare const removeUnusedExport: ({ entrypoints, fileService, deleteUnusedFile, enableCodeFix, editTracker, options, projectRoot, pool, recursive, }: { | ||
recursive: boolean; | ||
pool: WorkerPool<typeof processFile>; | ||
pool?: WorkerPool<({ targetFile, files, vertexes, deleteUnusedFile, enableCodeFix, options, projectRoot, }: { | ||
targetFile: string; | ||
vertexes: Map<string, { | ||
to: Set<string>; | ||
from: Set<string>; | ||
data: { | ||
depth: number; | ||
}; | ||
}>; | ||
files: Map<string, string>; | ||
deleteUnusedFile: boolean; | ||
enableCodeFix: boolean; | ||
options: ts.CompilerOptions; | ||
projectRoot: string; | ||
}) => { | ||
operation: "edit"; | ||
content: string; | ||
removedExports: { | ||
fileName: string; | ||
position: number; | ||
code: string; | ||
}[]; | ||
} | { | ||
operation: "delete"; | ||
}> | undefined; | ||
}) => Promise<void>; | ||
export {}; |
// lib/util/removeUnusedExport.ts | ||
import ts4 from "typescript"; | ||
import ts6 from "typescript"; | ||
@@ -93,21 +93,5 @@ // lib/util/applyTextChanges.ts | ||
// lib/util/getFileFromModuleSpecifierText.ts | ||
// lib/util/createDependencyGraph.ts | ||
import ts2 from "typescript"; | ||
var getFileFromModuleSpecifierText = ({ | ||
specifier, | ||
fileName, | ||
program, | ||
fileService | ||
}) => ts2.resolveModuleName(specifier, fileName, program.getCompilerOptions(), { | ||
fileExists(f) { | ||
return fileService.exists(f); | ||
}, | ||
readFile(f) { | ||
return fileService.get(f); | ||
} | ||
}).resolvedModule?.resolvedFileName; | ||
// lib/util/collectImports.ts | ||
import ts3 from "typescript"; | ||
// lib/util/MemoryFileService.ts | ||
@@ -143,25 +127,36 @@ var MemoryFileService = class { | ||
} | ||
eject() { | ||
const res = /* @__PURE__ */ new Map(); | ||
for (const [name, { content }] of this.#files) { | ||
res.set(name, content); | ||
} | ||
return res; | ||
} | ||
}; | ||
// lib/util/removeUnusedExport.ts | ||
var findFirstNodeOfKind = (root, kind) => { | ||
let result; | ||
const visitor = (node) => { | ||
if (result) { | ||
return; | ||
// lib/util/findFileUsage.ts | ||
import ts4 from "typescript"; | ||
// lib/util/parseFile.ts | ||
import ts3 from "typescript"; | ||
// lib/util/memoize.ts | ||
var memoize = (fn2, { key }) => { | ||
const cache = /* @__PURE__ */ new Map(); | ||
return (...args) => { | ||
const k = key(...args); | ||
if (cache.has(k)) { | ||
return cache.get(k); | ||
} | ||
if (node.kind === kind) { | ||
result = node; | ||
return; | ||
} | ||
ts4.forEachChild(node, visitor); | ||
const result = fn2(...args); | ||
cache.set(k, result); | ||
return result; | ||
}; | ||
ts4.forEachChild(root, visitor); | ||
return result; | ||
}; | ||
// lib/util/parseFile.ts | ||
var IGNORE_COMMENT = "ts-remove-unused-skip"; | ||
var getLeadingComment = (node) => { | ||
const sourceFile = node.getSourceFile(); | ||
const fullText = sourceFile.getFullText(); | ||
const ranges = ts4.getLeadingCommentRanges(fullText, node.getFullStart()); | ||
const fullText = node.getSourceFile().getFullText(); | ||
const ranges = ts3.getLeadingCommentRanges(fullText, node.getFullStart()); | ||
if (!ranges) { | ||
@@ -172,200 +167,400 @@ return ""; | ||
}; | ||
var isTarget = (node) => { | ||
if (ts4.isExportAssignment(node) || ts4.isExportSpecifier(node)) { | ||
return true; | ||
var resolve = ({ | ||
specifier, | ||
file, | ||
destFiles, | ||
options | ||
}) => ts3.resolveModuleName(specifier, file, options, { | ||
fileExists(f) { | ||
return destFiles.has(f); | ||
}, | ||
readFile(f) { | ||
throw new Error(`Unexpected readFile call: ${f}`); | ||
} | ||
if (ts4.isVariableStatement(node) || ts4.isFunctionDeclaration(node) || ts4.isInterfaceDeclaration(node) || ts4.isTypeAliasDeclaration(node) || ts4.isClassDeclaration(node)) { | ||
const hasExportKeyword = !!findFirstNodeOfKind( | ||
node, | ||
ts4.SyntaxKind.ExportKeyword | ||
); | ||
if (!hasExportKeyword) { | ||
return false; | ||
} | ||
return true; | ||
}).resolvedModule?.resolvedFileName; | ||
var getChange = (node) => { | ||
if (ts3.isExportDeclaration(node) || ts3.isExportAssignment(node)) { | ||
return { | ||
code: node.getFullText(), | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}; | ||
} | ||
return false; | ||
}; | ||
var findReferences = (node, service) => { | ||
if (ts4.isVariableStatement(node)) { | ||
const variableDeclaration = findFirstNodeOfKind( | ||
node, | ||
ts4.SyntaxKind.VariableDeclaration | ||
); | ||
if (!variableDeclaration) { | ||
return void 0; | ||
} | ||
const references = service.findReferences( | ||
node.getSourceFile().fileName, | ||
variableDeclaration.getStart() | ||
); | ||
return references; | ||
if ((ts3.isFunctionDeclaration(node) || ts3.isClassDeclaration(node)) && !node.name) { | ||
return { | ||
code: node.getFullText(), | ||
isUnnamedDefaultExport: true, | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}; | ||
} | ||
if (ts4.isFunctionDeclaration(node) || ts4.isInterfaceDeclaration(node) || ts4.isTypeAliasDeclaration(node) || ts4.isExportSpecifier(node) || ts4.isClassDeclaration(node)) { | ||
return service.findReferences( | ||
node.getSourceFile().fileName, | ||
node.getStart() | ||
); | ||
const syntaxListIndex = node.getChildren().findIndex((n) => n.kind === ts3.SyntaxKind.SyntaxList); | ||
const syntaxList = node.getChildren()[syntaxListIndex]; | ||
const nextSibling = node.getChildren()[syntaxListIndex + 1]; | ||
if (!syntaxList || !nextSibling) { | ||
throw new Error("Unexpected syntax"); | ||
} | ||
if (ts4.isExportAssignment(node)) { | ||
const defaultKeyword = node.getChildren().find((n) => n.kind === ts4.SyntaxKind.DefaultKeyword); | ||
if (!defaultKeyword) { | ||
return void 0; | ||
return { | ||
code: node.getSourceFile().getFullText().slice(syntaxList.getStart(), nextSibling.getStart()), | ||
span: { | ||
start: syntaxList.getStart(), | ||
length: nextSibling.getStart() - syntaxList.getStart() | ||
} | ||
return service.findReferences( | ||
node.getSourceFile().fileName, | ||
defaultKeyword.getStart() | ||
); | ||
} | ||
throw new Error(`unexpected node type: ${node}`); | ||
}; | ||
}; | ||
var getReexportInFile = (file) => { | ||
const result = []; | ||
var fn = ({ | ||
file, | ||
content, | ||
destFiles, | ||
options = {} | ||
}) => { | ||
const imports = {}; | ||
const exports = []; | ||
const sourceFile = ts3.createSourceFile( | ||
file, | ||
content, | ||
ts3.ScriptTarget.ESNext, | ||
true | ||
); | ||
const visit = (node) => { | ||
if (ts4.isExportSpecifier(node)) { | ||
if (node.parent.parent.moduleSpecifier && ts4.isStringLiteral(node.parent.parent.moduleSpecifier)) { | ||
result.push(node); | ||
if (ts3.isVariableStatement(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
const name = node.declarationList.declarations.map( | ||
(d) => d.name.getText() | ||
); | ||
exports.push({ | ||
kind: ts3.SyntaxKind.VariableStatement, | ||
name, | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
node.forEachChild(visit); | ||
}; | ||
file.forEachChild(visit); | ||
return result; | ||
}; | ||
var getAncestorFiles = (node, references, fileService, program, fileName) => { | ||
const result = /* @__PURE__ */ new Set(); | ||
const referencesKeyValue = Object.fromEntries( | ||
references.map((v) => [v.definition.fileName, v]) | ||
); | ||
if (!node.parent.parent.moduleSpecifier || !ts4.isStringLiteral(node.parent.parent.moduleSpecifier)) { | ||
return result; | ||
} | ||
let specifier = node.parent.parent.moduleSpecifier.text; | ||
let currentFile = fileName; | ||
while (specifier) { | ||
const origin = getFileFromModuleSpecifierText({ | ||
specifier, | ||
fileName: currentFile, | ||
program, | ||
fileService | ||
}); | ||
if (!origin) { | ||
break; | ||
if (ts3.isFunctionDeclaration(node) || ts3.isInterfaceDeclaration(node) || ts3.isClassDeclaration(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
if (node.modifiers?.some((m) => m.kind === ts3.SyntaxKind.DefaultKeyword)) { | ||
exports.push({ | ||
kind: node.kind, | ||
name: "default", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} else { | ||
exports.push({ | ||
kind: node.kind, | ||
name: node.name?.getText() || "", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
result.add(origin); | ||
const referencedSymbol = referencesKeyValue[origin]; | ||
if (!referencedSymbol) { | ||
break; | ||
if (ts3.isTypeAliasDeclaration(node)) { | ||
const isExported = node.modifiers?.some( | ||
(m) => m.kind === ts3.SyntaxKind.ExportKeyword | ||
); | ||
if (isExported) { | ||
exports.push({ | ||
kind: node.kind, | ||
name: node.name.getText(), | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
} | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
const sourceFile = program.getSourceFile(origin); | ||
if (!sourceFile) { | ||
break; | ||
if (ts3.isExportAssignment(node) && !node.isExportEquals) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportAssignment, | ||
name: "default", | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
ts3.forEachChild(node, visit); | ||
return; | ||
} | ||
const reexportSpecifiers = getReexportInFile(sourceFile); | ||
const firstReferencedSymbol = referencedSymbol.references[0]; | ||
const reexportNode = firstReferencedSymbol ? reexportSpecifiers.find((r) => { | ||
const start = firstReferencedSymbol.textSpan.start; | ||
const end = start + firstReferencedSymbol.textSpan.length; | ||
return r.getStart() === start && r.getEnd() === end; | ||
}) : void 0; | ||
if (reexportNode) { | ||
if (!reexportNode.parent.parent.moduleSpecifier || !ts4.isStringLiteral(reexportNode.parent.parent.moduleSpecifier)) { | ||
throw new Error("unexpected reexportNode"); | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamedExports && !node.moduleSpecifier) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "named", | ||
// we always collect the name not the propertyName because its for exports | ||
name: node.exportClause.elements.map((element) => element.name.text), | ||
change: getChange(node), | ||
skip: !!getLeadingComment(node).includes(IGNORE_COMMENT), | ||
start: node.getStart() | ||
}); | ||
return; | ||
} | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamedExports && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "named", | ||
// we always collect the name not the propertyName because its for exports | ||
name: node.exportClause.elements.map((element) => element.name.text), | ||
change: getChange(node), | ||
skip: false, | ||
start: node.getStart() | ||
}); | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
node.exportClause.elements.forEach((element) => { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push( | ||
element.propertyName?.text || element.name.text | ||
); | ||
}); | ||
} | ||
specifier = reexportNode.parent.parent.moduleSpecifier.text; | ||
currentFile = origin; | ||
} else { | ||
specifier = null; | ||
return; | ||
} | ||
} | ||
return result; | ||
}; | ||
var getUnusedExports = (languageService, fileName, fileService) => { | ||
const nodes = []; | ||
let isUsed = false; | ||
const program = languageService.getProgram(); | ||
if (!program) { | ||
throw new Error("program not found"); | ||
} | ||
const sourceFile = program.getSourceFile(fileName); | ||
if (!sourceFile) { | ||
throw new Error("source file not found"); | ||
} | ||
const visit = (node) => { | ||
if (ts4.isExportDeclaration(node) && !node.exportClause) { | ||
isUsed = true; | ||
if (ts3.isExportDeclaration(node) && node.exportClause?.kind === ts3.SyntaxKind.NamespaceExport && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "namespace", | ||
name: node.exportClause.name.text, | ||
start: node.getStart(), | ||
change: getChange(node) | ||
}); | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
} | ||
return; | ||
} | ||
if (isTarget(node)) { | ||
if (getLeadingComment(node).includes(IGNORE_COMMENT)) { | ||
isUsed = true; | ||
return; | ||
if (ts3.isExportDeclaration(node) && !node.exportClause && node.moduleSpecifier && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
exports.push({ | ||
kind: ts3.SyntaxKind.ExportDeclaration, | ||
type: "whole", | ||
file: resolved || null, | ||
specifier: node.moduleSpecifier.text, | ||
start: node.getStart(), | ||
change: getChange(node) | ||
}); | ||
if (resolved) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push({ type: "wholeReexport", file }); | ||
} | ||
const references = findReferences(node, languageService); | ||
if (!references) { | ||
return; | ||
} | ||
if (ts3.isImportDeclaration(node) && ts3.isStringLiteral(node.moduleSpecifier)) { | ||
const resolved = resolve({ | ||
specifier: node.moduleSpecifier.text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (!resolved) { | ||
return; | ||
} | ||
if (ts4.isExportSpecifier(node) && node.parent.parent.moduleSpecifier) { | ||
const ancestors = getAncestorFiles( | ||
node, | ||
references, | ||
fileService, | ||
program, | ||
fileName | ||
); | ||
const count2 = references.flatMap((v) => v.references).filter( | ||
(v) => v.fileName !== fileName && !ancestors.has(v.fileName) | ||
).length; | ||
if (count2 > 0) { | ||
isUsed = true; | ||
} else { | ||
nodes.push(node); | ||
} | ||
if (node.importClause?.namedBindings?.kind === ts3.SyntaxKind.NamespaceImport) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
return; | ||
} | ||
const count = references.flatMap((v) => v.references).filter((v) => v.fileName !== fileName).length; | ||
if (count > 0) { | ||
isUsed = true; | ||
} else { | ||
nodes.push(node); | ||
if (node.importClause?.namedBindings?.kind === ts3.SyntaxKind.NamedImports) { | ||
const namedImports = node.importClause?.namedBindings; | ||
namedImports.elements.forEach((element) => { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push( | ||
element.propertyName?.text || element.name.text | ||
); | ||
}); | ||
} | ||
if (node.importClause?.name) { | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("default"); | ||
} | ||
return; | ||
} | ||
if (ts3.isCallExpression(node) && node.expression.kind === ts3.SyntaxKind.ImportKeyword && node.arguments[0] && ts3.isStringLiteral(node.arguments[0])) { | ||
const resolved = resolve({ | ||
specifier: node.arguments[0].text, | ||
destFiles, | ||
file, | ||
options | ||
}); | ||
if (!resolved) { | ||
return; | ||
} | ||
imports[resolved] ||= []; | ||
imports[resolved]?.push("*"); | ||
return; | ||
} | ||
node.forEachChild(visit); | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return { nodes, isUsed }; | ||
return { imports, exports }; | ||
}; | ||
var getUpdatedExportDeclaration = (exportDeclaration, removeTarget) => { | ||
const tmpFile = ts4.createSourceFile( | ||
"tmp.ts", | ||
exportDeclaration.getText(), | ||
exportDeclaration.getSourceFile().languageVersion | ||
var parseFile = memoize(fn, { | ||
key: (arg) => JSON.stringify({ | ||
file: arg.file, | ||
content: arg.content, | ||
destFiles: Array.from(arg.destFiles).sort(), | ||
options: arg.options | ||
}) | ||
}); | ||
// lib/util/findFileUsage.ts | ||
var fallback = () => ({ | ||
from: /* @__PURE__ */ new Set(), | ||
to: /* @__PURE__ */ new Set(), | ||
data: { | ||
depth: Infinity, | ||
wholeReexportSpecifier: /* @__PURE__ */ new Map() | ||
} | ||
}); | ||
var ALL_EXPORTS_OF_UNKNOWN_FILE = "__all_exports_of_unknown_file__"; | ||
var getExportsOfFile = ({ | ||
targetFile, | ||
vertexes, | ||
files, | ||
options | ||
}) => { | ||
const result = []; | ||
const stack = [targetFile]; | ||
while (stack.length) { | ||
const item = stack.pop(); | ||
if (!item) { | ||
break; | ||
} | ||
const vertex = vertexes.get(item) || fallback(); | ||
const { exports } = parseFile({ | ||
file: item, | ||
content: files.get(item) || "", | ||
destFiles: new Set(vertex.to), | ||
options | ||
}); | ||
exports.forEach((it) => { | ||
if (it.kind === ts4.SyntaxKind.ExportDeclaration && it.type === "whole") { | ||
if (it.file) { | ||
stack.push(it.file); | ||
} else { | ||
result.push(ALL_EXPORTS_OF_UNKNOWN_FILE); | ||
} | ||
return; | ||
} | ||
result.push(...Array.isArray(it.name) ? it.name : [it.name]); | ||
}); | ||
} | ||
return new Set(result); | ||
}; | ||
var findFileUsage = ({ | ||
targetFile, | ||
options, | ||
vertexes, | ||
files | ||
}) => { | ||
const result = []; | ||
const exportsOfTargetFile = getExportsOfFile({ | ||
targetFile, | ||
vertexes, | ||
files, | ||
options | ||
}); | ||
const stack = []; | ||
vertexes.get(targetFile)?.from.forEach( | ||
(file) => stack.push({ | ||
file, | ||
to: targetFile | ||
}) | ||
); | ||
const transformer = (context) => (rootNode) => { | ||
const visitor = (node) => { | ||
if (ts4.isExportSpecifier(node) && node.getText(tmpFile) === removeTarget.getText()) { | ||
return void 0; | ||
while (stack.length) { | ||
const item = stack.pop(); | ||
if (!item) { | ||
break; | ||
} | ||
const { file, to } = item; | ||
const vertex = vertexes.get(file) || fallback(); | ||
const { imports } = parseFile({ | ||
file, | ||
content: files.get(file) || "", | ||
destFiles: new Set(vertex.to), | ||
options | ||
}); | ||
const list = imports[to] || []; | ||
list.forEach((it) => { | ||
if (typeof it === "object" && it.type === "wholeReexport") { | ||
const n = vertexes.get(it.file); | ||
if (!n) { | ||
return; | ||
} | ||
if (n.data.depth === 0) { | ||
result.push("*"); | ||
return; | ||
} | ||
vertexes.get(it.file)?.from.forEach((f) => { | ||
stack.push({ | ||
file: f, | ||
to: it.file | ||
}); | ||
}); | ||
return; | ||
} | ||
return ts4.visitEachChild(node, visitor, context); | ||
}; | ||
return ts4.visitEachChild(rootNode, visitor, context); | ||
}; | ||
const result = ts4.transform(tmpFile, [transformer]).transformed[0]; | ||
const printer = ts4.createPrinter(); | ||
return result ? printer.printFile(result).trim() : ""; | ||
if (typeof it === "string") { | ||
result.push(it); | ||
return; | ||
} | ||
}); | ||
} | ||
if (exportsOfTargetFile.has(ALL_EXPORTS_OF_UNKNOWN_FILE)) { | ||
return new Set(result); | ||
} | ||
return new Set( | ||
result.filter((it) => exportsOfTargetFile.has(it) || it === "*") | ||
); | ||
}; | ||
// lib/util/createProgram.ts | ||
import ts5 from "typescript"; | ||
// lib/util/removeUnusedExport.ts | ||
var stripExportKeyword = (syntaxList) => { | ||
const file = ts4.createSourceFile( | ||
const file = ts6.createSourceFile( | ||
"tmp.ts", | ||
`${syntaxList.getText()} function f() {}`, | ||
syntaxList.getSourceFile().languageVersion | ||
`${syntaxList} function f() {}`, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const transformer = (context) => (rootNode) => { | ||
const visitor = (node) => { | ||
if (ts4.isFunctionDeclaration(node)) { | ||
return ts4.factory.createFunctionDeclaration( | ||
if (ts6.isFunctionDeclaration(node)) { | ||
return ts6.factory.createFunctionDeclaration( | ||
node.modifiers?.filter( | ||
(v) => v.kind !== ts4.SyntaxKind.ExportKeyword && v.kind !== ts4.SyntaxKind.DefaultKeyword | ||
(v) => v.kind !== ts6.SyntaxKind.ExportKeyword && v.kind !== ts6.SyntaxKind.DefaultKeyword | ||
), | ||
@@ -380,8 +575,8 @@ node.asteriskToken, | ||
} | ||
return ts4.visitEachChild(node, visitor, context); | ||
return ts6.visitEachChild(node, visitor, context); | ||
}; | ||
return ts4.visitEachChild(rootNode, visitor, context); | ||
return ts6.visitEachChild(rootNode, visitor, context); | ||
}; | ||
const result = ts4.transform(file, [transformer]).transformed[0]; | ||
const printer = ts4.createPrinter(); | ||
const result = ts6.transform(file, [transformer]).transformed[0]; | ||
const printer = ts6.createPrinter(); | ||
const code = result ? printer.printFile(result).trim() : ""; | ||
@@ -391,106 +586,2 @@ const pos = code.indexOf("function"); | ||
}; | ||
var getTextChanges = (languageService, fileName, fileService) => { | ||
const removedExports = []; | ||
const changes = []; | ||
let aborted = false; | ||
const { nodes, isUsed } = getUnusedExports( | ||
languageService, | ||
fileName, | ||
fileService | ||
); | ||
for (const node of nodes) { | ||
if (aborted === true) { | ||
break; | ||
} | ||
if (ts4.isExportSpecifier(node)) { | ||
const specifierCount = Array.from(node.parent.elements).length; | ||
if (specifierCount === 1) { | ||
changes.push({ | ||
newText: "", | ||
span: { | ||
start: node.parent.parent.getFullStart(), | ||
length: node.parent.parent.getFullWidth() | ||
} | ||
}); | ||
removedExports.push({ | ||
fileName, | ||
position: node.parent.parent.getStart(), | ||
code: node.parent.parent.getText() | ||
}); | ||
continue; | ||
} | ||
aborted = true; | ||
changes.push({ | ||
newText: getUpdatedExportDeclaration(node.parent.parent, node), | ||
span: { | ||
start: node.parent.parent.getStart(), | ||
length: node.parent.parent.getWidth() | ||
} | ||
}); | ||
const from = node.parent.parent.moduleSpecifier ? ` from ${node.parent.parent.moduleSpecifier.getText()}` : ""; | ||
removedExports.push({ | ||
fileName, | ||
position: node.getStart(), | ||
code: `export { ${node.getText()} }${from};` | ||
}); | ||
continue; | ||
} | ||
if (ts4.isExportAssignment(node)) { | ||
changes.push({ | ||
newText: "", | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}); | ||
removedExports.push({ | ||
fileName, | ||
position: node.getStart(), | ||
code: node.getText() | ||
}); | ||
continue; | ||
} | ||
if (ts4.isFunctionDeclaration(node) || ts4.isClassDeclaration(node)) { | ||
const identifier = node.getChildren().find((n) => n.kind === ts4.SyntaxKind.Identifier); | ||
if (!identifier) { | ||
changes.push({ | ||
newText: "", | ||
span: { | ||
start: node.getFullStart(), | ||
length: node.getFullWidth() | ||
} | ||
}); | ||
const code = node.getText().slice( | ||
0, | ||
ts4.isFunctionDeclaration(node) ? node.getText().indexOf(")") + 1 : node.getText().indexOf("{") - 1 | ||
); | ||
removedExports.push({ | ||
fileName, | ||
position: node.getStart(), | ||
code | ||
}); | ||
continue; | ||
} | ||
} | ||
const syntaxListIndex = node.getChildren().findIndex((n) => n.kind === ts4.SyntaxKind.SyntaxList); | ||
const syntaxList = node.getChildren()[syntaxListIndex]; | ||
const syntaxListNextSibling = node.getChildren()[syntaxListIndex + 1]; | ||
if (!syntaxList || !syntaxListNextSibling) { | ||
throw new Error("syntax list not found"); | ||
} | ||
changes.push({ | ||
newText: ts4.isFunctionDeclaration(node) ? stripExportKeyword(syntaxList) : "", | ||
span: { | ||
start: syntaxList.getStart(), | ||
length: syntaxListNextSibling.getStart() - syntaxList.getStart() | ||
} | ||
}); | ||
removedExports.push({ | ||
fileName, | ||
position: node.getStart(), | ||
code: findFirstNodeOfKind(node, ts4.SyntaxKind.Identifier)?.getText() || "" | ||
}); | ||
} | ||
return { changes, done: !aborted, isUsed, removedExports }; | ||
}; | ||
var createLanguageService = ({ | ||
@@ -501,3 +592,3 @@ options, | ||
}) => { | ||
const languageService = ts4.createLanguageService({ | ||
const languageService = ts6.createLanguageService({ | ||
getCompilationSettings() { | ||
@@ -513,3 +604,3 @@ return options; | ||
getScriptSnapshot(fileName) { | ||
return ts4.ScriptSnapshot.fromString(fileService.get(fileName)); | ||
return ts6.ScriptSnapshot.fromString(fileService.get(fileName)); | ||
}, | ||
@@ -520,3 +611,3 @@ getCurrentDirectory() { | ||
getDefaultLibFileName(o) { | ||
return ts4.getDefaultLibFileName(o); | ||
return ts6.getDefaultLibFileName(o); | ||
}, | ||
@@ -532,5 +623,47 @@ fileExists(name) { | ||
}; | ||
var updateExportDeclaration = (code, unused) => { | ||
const sourceFile = ts6.createSourceFile( | ||
"tmp.ts", | ||
code, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const transformer = (context) => (rootNode) => { | ||
const visitor = (node) => { | ||
if (ts6.isExportSpecifier(node) && unused.includes(node.getText(sourceFile))) { | ||
return void 0; | ||
} | ||
return ts6.visitEachChild(node, visitor, context); | ||
}; | ||
return ts6.visitEachChild(rootNode, visitor, context); | ||
}; | ||
const result = ts6.transform(sourceFile, [transformer]).transformed[0]; | ||
const printer = ts6.createPrinter(); | ||
const printed = result ? printer.printFile(result).replace(/\n$/, "") : ""; | ||
const leading = code.match(/^([\s]+)/)?.[0] || ""; | ||
return `${leading}${printed}`; | ||
}; | ||
var getSpecifierPosition = (exportDeclaration) => { | ||
const sourceFile = ts6.createSourceFile( | ||
"tmp.ts", | ||
exportDeclaration, | ||
ts6.ScriptTarget.Latest | ||
); | ||
const result = /* @__PURE__ */ new Map(); | ||
const visit = (node) => { | ||
if (ts6.isExportDeclaration(node) && node.exportClause?.kind === ts6.SyntaxKind.NamedExports) { | ||
node.exportClause.elements.forEach((element) => { | ||
result.set( | ||
element.name.text, | ||
element.getStart(sourceFile) - sourceFile.getStart() | ||
); | ||
}); | ||
} | ||
}; | ||
sourceFile.forEachChild(visit); | ||
return result; | ||
}; | ||
var processFile = ({ | ||
file, | ||
targetFile, | ||
files, | ||
vertexes, | ||
deleteUnusedFile, | ||
@@ -541,36 +674,218 @@ enableCodeFix, | ||
}) => { | ||
const removedExports = []; | ||
const fileService = new MemoryFileService(); | ||
for (const [fileName, content2] of Object.entries(files)) { | ||
fileService.set(fileName, content2); | ||
const usage = findFileUsage({ | ||
targetFile, | ||
vertexes, | ||
files, | ||
options | ||
}); | ||
if (usage.has("*")) { | ||
return { | ||
operation: "edit", | ||
content: files.get(targetFile) || "", | ||
removedExports: [] | ||
}; | ||
} | ||
const languageService = createLanguageService({ | ||
const { exports } = parseFile({ | ||
file: targetFile, | ||
content: files.get(targetFile) || "", | ||
options, | ||
projectRoot, | ||
fileService | ||
destFiles: vertexes.get(targetFile)?.to || /* @__PURE__ */ new Set([]) | ||
}); | ||
let content = fileService.get(file); | ||
let isUsed = false; | ||
do { | ||
const result2 = getTextChanges(languageService, file, fileService); | ||
removedExports.push(...result2.removedExports); | ||
isUsed = result2.isUsed; | ||
content = applyTextChanges(content, result2.changes); | ||
fileService.set(file, content); | ||
if (result2.done) { | ||
break; | ||
if (usage.size === 0 && deleteUnusedFile && !exports.some((v) => "skip" in v && v.skip)) { | ||
return { | ||
operation: "delete" | ||
}; | ||
} | ||
const changes = []; | ||
const logs = []; | ||
exports.forEach((item) => { | ||
switch (item.kind) { | ||
case ts6.SyntaxKind.VariableStatement: { | ||
if (item.skip || item.name.every((it) => usage.has(it))) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
// todo: handle variable statement with multiple declarations properly | ||
code: item.name.join(", ") | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.FunctionDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: item.change.isUnnamedDefaultExport ? "" : stripExportKeyword(item.change.code), | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.InterfaceDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.TypeAliasDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.ExportAssignment: { | ||
if (item.skip || usage.has("default")) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: "default" | ||
}); | ||
break; | ||
} | ||
case ts6.SyntaxKind.ExportDeclaration: { | ||
switch (item.type) { | ||
case "named": { | ||
if (item.skip || item.name.every((it) => usage.has(it))) { | ||
break; | ||
} | ||
const unused = item.name.filter((it) => !usage.has(it)); | ||
const count = item.name.length - unused.length; | ||
changes.push({ | ||
newText: count > 0 ? updateExportDeclaration(item.change.code, unused) : "", | ||
span: item.change.span | ||
}); | ||
const position = getSpecifierPosition(item.change.code); | ||
logs.push( | ||
...unused.map((it) => ({ | ||
fileName: targetFile, | ||
position: item.start + (position.get(it) || 0), | ||
code: it | ||
})) | ||
); | ||
break; | ||
} | ||
case "namespace": { | ||
if (usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
case "whole": { | ||
if (!item.file) { | ||
break; | ||
} | ||
const parsed = parseFile({ | ||
file: item.file, | ||
content: files.get(item.file) || "", | ||
options, | ||
destFiles: vertexes.get(item.file)?.to || /* @__PURE__ */ new Set([]) | ||
}); | ||
const exported = parsed.exports.flatMap( | ||
(v) => "name" in v ? v.name : [] | ||
); | ||
if (exported.some((v) => usage.has(v))) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: `export * from '${item.specifier}';` | ||
}); | ||
break; | ||
} | ||
default: { | ||
throw new Error(`unexpected: ${item}`); | ||
} | ||
} | ||
break; | ||
} | ||
case ts6.SyntaxKind.ClassDeclaration: { | ||
if (item.skip || usage.has(item.name)) { | ||
break; | ||
} | ||
changes.push({ | ||
newText: "", | ||
span: item.change.span | ||
}); | ||
logs.push({ | ||
fileName: targetFile, | ||
position: item.start, | ||
code: item.name | ||
}); | ||
break; | ||
} | ||
default: { | ||
throw new Error(`unexpected: ${item}`); | ||
} | ||
} | ||
} while (true); | ||
if (!isUsed && deleteUnusedFile) { | ||
}); | ||
if (changes.length === 0) { | ||
const result2 = { | ||
operation: "delete" | ||
operation: "edit", | ||
content: files.get(targetFile) || "", | ||
removedExports: logs | ||
}; | ||
return result2; | ||
} | ||
if (enableCodeFix) { | ||
let content = applyTextChanges(files.get(targetFile) || "", changes); | ||
const fileService = new MemoryFileService(); | ||
fileService.set(targetFile, content); | ||
if (enableCodeFix && changes.length > 0) { | ||
const languageService = createLanguageService({ | ||
options, | ||
projectRoot, | ||
fileService | ||
}); | ||
while (true) { | ||
fileService.set(file, content); | ||
fileService.set(targetFile, content); | ||
const result2 = applyCodeFix({ | ||
fixId: fixIdDelete, | ||
fileName: file, | ||
fileName: targetFile, | ||
languageService | ||
@@ -583,14 +898,14 @@ }); | ||
} | ||
fileService.set(file, content); | ||
fileService.set(targetFile, content); | ||
content = applyCodeFix({ | ||
fixId: fixIdDeleteImports, | ||
fileName: file, | ||
fileName: targetFile, | ||
languageService | ||
}); | ||
} | ||
fileService.set(file, content); | ||
fileService.set(targetFile, content); | ||
const result = { | ||
operation: "edit", | ||
content: fileService.get(file), | ||
removedExports | ||
content: fileService.get(targetFile), | ||
removedExports: logs | ||
}; | ||
@@ -597,0 +912,0 @@ return result; |
@@ -44,3 +44,3 @@ { | ||
}, | ||
"version": "0.6.2" | ||
"version": "0.7.0" | ||
} |
126
README.md
@@ -16,22 +16,29 @@ <h1 align="center">ts-remove-unused</h1> | ||
## Introduction | ||
## Install | ||
When TypeScript's `compilerOptions.noUnusedLocals` is enabled, it's possible to detect declarations that are not referenced in your file. | ||
```typescript | ||
// TypeScript will throw error: 'a' is declared but its value is never read. | ||
const a = 'a'; | ||
```bash | ||
npm install @line/ts-remove-unused | ||
``` | ||
However when this declaration is exported and is not referenced by any file in the project, it's difficult to recognize this. | ||
TypeScript is a peer dependency. | ||
```typescript | ||
// no errors will be reported even if the declaration is not used across the entire project. | ||
export const a = 'a'; | ||
## Quick Start | ||
1. ๐ Check your `tsconfig.json` โย Make sure `include` and `exclude` is configured thoroughly so that we can correctly detect what's "unused" in your project. | ||
2. ๐ Check your entrypoint files โ What's the file that is the starting point for your code? Without this information, all files will be recognized as unnecessary. Usually it is some file like `src/main.ts` or maybe a group of files like `src/pages/*`. | ||
3. ๐ Execute โ Set a regex pattern that matches your entry files for `--skip`. You can optionally set `--project` to a path to your custom `tsconfig.json` if necessary. | ||
```bash | ||
npx @line/ts-remove-unused --skip 'src/main\.ts$' | ||
``` | ||
This is when ts-remove-unused comes in handy. ts-remove-unused is a CLI tool built on top of TypeScript that finds unused exports and auto-fixes unused code. | ||
> [!WARNING] | ||
> THIS COMMAND WILL DELETE CODE FROM YOUR PROJECT. Using it in a git controlled environment is highly recommended. If you're just playing around use `--check`. | ||
Here are some examples of how ts-remove-unused auto-fixes unused code. | ||
## Examples | ||
Here are some examples of how this tool will auto-fixe unused code. | ||
<!-- prettier-ignore-start --> | ||
@@ -100,19 +107,13 @@ | ||
ts-remove-unused supports various types of exports including variable declarations (`export const`, `export let`), function declarations, class declarations, interface declarations, type alias declarations, default exports and more... | ||
ts-remove-unused works with all kinds of code: variables, functions, interfaces, classes, type aliases and more! | ||
Now you don't have to worry about removing unused code by yourself! | ||
## Usage | ||
## Install | ||
### Options | ||
```bash | ||
npm install @line/ts-remove-unused | ||
``` | ||
<!-- prettier-ignore-start --> | ||
TypeScript is a peer dependency so make sure that it's also installed. | ||
## Usage | ||
``` | ||
Usage: | ||
$ ts-remove-unused | ||
$ ts-remove-unused | ||
@@ -126,23 +127,33 @@ Commands: | ||
Options: | ||
--project <file> Path to your tsconfig.json | ||
--skip <regexp_pattern> Specify the regexp pattern to match files that should be skipped from transforming | ||
--include-d-ts Include .d.ts files in target for transformation | ||
--check Check if there are any unused exports without removing them | ||
-h, --help Display this message | ||
-v, --version Display version number | ||
--project <file> Path to your tsconfig.json | ||
--skip <regexp_pattern> Specify the regexp pattern to match files that should be skipped from transforming | ||
--include-d-ts Include .d.ts files in target for transformation | ||
--check Check if there are any unused exports without removing them | ||
--experimental-recursive Recursively process files until there are no issue | ||
-h, --help Display this message | ||
-v, --version Display version number | ||
``` | ||
<!-- prettier-ignore-end --> | ||
ts-remove-unused's behavior heavily depends on your `tsconfig.json`. TypeScript's compiler internally holds the list of project files by parsing relevant rules such as `include` and `exclude`. ts-remove-unused scans through this list and searches for references to determine if an export/file is "unused". You may need to maintain/update your `tsconfig` (or you can create another file for `--project`) so that the set of covered files are right. | ||
#### `--project` | ||
Here's an example of using the CLI. Your entry point file must be skipped or else every file will be removed. | ||
Specifies the `tsconfig.json` that is used to analyze your codebase. Defaults to `tsconfig.json` in your project root. | ||
```bash | ||
npx @line/ts-remove-unused --skip 'src/main\.ts' | ||
npx @line/ts-remove-unused --skip tsconfig.client.json | ||
``` | ||
> [!WARNING] | ||
> THIS COMMAND WILL DELETE CODE FROM YOUR PROJECT. Using it in a git controlled environment is highly recommended. If you're just playing around use `--check`. | ||
#### `--skip` | ||
### Options | ||
Skip files that match a given regex pattern. Note that you can pass multiple patterns. | ||
```bash | ||
npx @line/ts-remove-unused --skip 'src/main\.ts' --skip '/pages/' | ||
``` | ||
#### `--include-d-ts` | ||
By default, `.d.ts` files are skipped. If you want to include `.d.ts` files, use the `--include-d-ts` option. | ||
#### `--check` | ||
@@ -156,6 +167,2 @@ | ||
#### `--include-d-ts` | ||
By default, `.d.ts` files are skipped. If you want to include `.d.ts` files, use the `--include-d-ts` option. | ||
#### `--experimental-recursive` | ||
@@ -191,14 +198,39 @@ | ||
The `--skip` option is also available to skip files that match a given regex pattern. Note that you can pass multiple patterns. | ||
## Handling test files | ||
```bash | ||
npx @line/ts-remove-unused --skip 'src/main\.ts' --skip '/pages/' | ||
If you have a separate tsconfig for tests using [Project References](https://www.typescriptlang.org/docs/handbook/project-references.html), that would be great! ts-remove-unused will remove exports/files that exist for the sake of testing. | ||
If you pass a `tsconfig.json` to the CLI that includes both the implementation and the test files, ts-remove-unused will remove your test files since they are not referenced by your entry point file (which is specified in `--skip`). You can avoid tests being deleted by passing a pattern that matches your test files to `--skip` in the meantime, but the recommended way is to use project references to ensure your TypeScript config is more robust and strict (not just for using this tool). | ||
## Comparison | ||
### TypeScript | ||
If you enable `compilerOptions.noUnusedLocals`, declarations that are never read will be reported. | ||
```typescript | ||
// 'a' is declared but its value is never read. | ||
const a = 'a'; | ||
``` | ||
## How does ts-remove-unused handle test files? | ||
However, when you `export` it, no errors will be reported regardless of its usage within the project. ts-remove-unused's aim is to report/fix unused code while taking project wide usage into account. | ||
If you have a separate tsconfig for tests using [Project References](https://www.typescriptlang.org/docs/handbook/project-references.html), that would be great! ts-remove-unused will remove exports/files that exist for the sake of testing. | ||
### ESLint | ||
If you pass a `tsconfig.json` to the CLI that includes both the implementation and the test files, ts-remove-unused will remove your test files since they are not referenced by your entry point file (which is specified in `--skip`). You can avoid tests being deleted by passing a pattern that matches your test files to `--skip` in the meantime, but the recommended way is to use project references to ensure your TypeScript config is more robust and strict (not just for using this tool). | ||
ESLint will detect unused imports. Plugins such as `eslint-plugin-unused-imports` can also auto-fix this issue. | ||
```typescript | ||
// 'foo' is defined but never used. | ||
import { foo } from './foo'; | ||
``` | ||
However, we can't detect unused exports. ESLint's architecture works in a file by file basis and was never intended to provide linting based on project-wide usage stats. | ||
```typescript | ||
// a lint rule that detects if this export is used within the project is unlikely to be introduced | ||
export const a = 'a'; | ||
``` | ||
ts-remove-unused's main goal is to remove unused exports and delete unused modules, but it will also delete unused imports that are a result of removing an export declaration. | ||
## Author | ||
@@ -208,2 +240,6 @@ | ||
## Contributing | ||
Contributions are welcomed! | ||
## License | ||
@@ -210,0 +246,0 @@ |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
148731
29
4653
256