import { Tree } from '@nx/devkit';
import { dirname, resolve } from 'path';
import * as ts from 'typescript';
import { FileChangeRecorder } from './file-change-recorder';
const enum ImportState {
UNMODIFIED = 0b0,
MODIFIED = 0b10,
ADDED = 0b100,
DELETED = 0b1000
}
interface ImportSpecifier {
name: ts.Identifier;
propertyName?: ts.ModuleExportName;
}
interface AnalyzedImport {
node: ts.ImportDeclaration;
moduleName: string;
name?: ts.Identifier;
specifiers?: ImportSpecifier[];
namespace?: boolean;
state: ImportState;
}
const hasFlag = (data: AnalyzedImport, flag: ImportState) => (data.state & flag) !== 0;
export class ImportManager {
private _usedIdentifierNames = new Map<ts.SourceFile, string[]>();
private _importCache = new Map<ts.SourceFile, AnalyzedImport[]>();
constructor(private _tree: Tree, private _printer: ts.Printer) {}
private _analyzeImportsIfNeeded(sourceFile: ts.SourceFile): AnalyzedImport[] {
if (this._importCache.has(sourceFile)) {
return this._importCache.get(sourceFile)!;
}
const result: AnalyzedImport[] = [];
for (const node of sourceFile.statements) {
if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier)) {
continue;
}
const moduleName = node.moduleSpecifier.text;
if (!node.importClause) {
result.push({ moduleName, node, state: ImportState.UNMODIFIED });
continue;
}
if (!node.importClause.namedBindings) {
result.push({
moduleName,
node,
name: node.importClause.name,
state: ImportState.UNMODIFIED
});
continue;
}
if (ts.isNamedImports(node.importClause.namedBindings)) {
result.push({
moduleName,
node,
specifiers: node.importClause.namedBindings.elements.map((el) => ({
name: el.name,
propertyName: el.propertyName
})),
state: ImportState.UNMODIFIED
});
} else {
result.push({
moduleName,
node,
name: node.importClause.namedBindings.name,
namespace: true,
state: ImportState.UNMODIFIED
});
}
}
this._importCache.set(sourceFile, result);
return result;
}
private _isModuleSpecifierMatching(
basePath: string,
specifier: string,
moduleName: string
): boolean {
return specifier.startsWith('.')
? resolve(basePath, specifier) === resolve(basePath, moduleName)
: specifier === moduleName;
}
deleteNamedBindingImport(sourceFile: ts.SourceFile, symbolName: string, moduleName: string) {
const sourceDir = dirname(sourceFile.fileName);
const fileImports = this._analyzeImportsIfNeeded(sourceFile);
for (const importData of fileImports) {
if (
!this._isModuleSpecifierMatching(sourceDir, importData.moduleName, moduleName) ||
!importData.specifiers
) {
continue;
}
const specifierIndex = importData.specifiers.findIndex(
(d) => (d.propertyName || d.name).text === symbolName
);
if (specifierIndex !== -1) {
importData.specifiers.splice(specifierIndex, 1);
if (importData.specifiers.length === 0) {
importData.state |= ImportState.DELETED;
} else {
importData.state |= ImportState.MODIFIED;
}
}
}
}
deleteImportByDeclaration(declaration: ts.ImportDeclaration) {
const fileImports = this._analyzeImportsIfNeeded(declaration.getSourceFile());
for (const importData of fileImports) {
if (importData.node === declaration) {
importData.state |= ImportState.DELETED;
}
}
}
addImportToSourceFile(
sourceFile: ts.SourceFile,
symbolName: string | null,
moduleName: string,
typeImport = false,
ignoreIdentifierCollisions: ts.Identifier[] = []
): ts.Expression {
const sourceDir = dirname(sourceFile.fileName);
const fileImports = this._analyzeImportsIfNeeded(sourceFile);
let existingImport: AnalyzedImport | null = null;
for (const importData of fileImports) {
if (!this._isModuleSpecifierMatching(sourceDir, importData.moduleName, moduleName)) {
continue;
}
if (!symbolName && !importData.namespace && !importData.specifiers) {
return ts.factory.createIdentifier(importData.name!.text);
}
if (importData.namespace && !typeImport) {
return ts.factory.createPropertyAccessExpression(
ts.factory.createIdentifier(importData.name!.text),
ts.factory.createIdentifier(symbolName || 'default')
);
} else if (importData.specifiers && symbolName) {
const existingSpecifier = importData.specifiers.find((s) =>
s.propertyName ? s.propertyName.text === symbolName : s.name.text === symbolName
);
if (existingSpecifier) {
return ts.factory.createIdentifier(existingSpecifier.name.text);
}
existingImport = importData;
}
}
if (existingImport) {
const propertyIdentifier = ts.factory.createIdentifier(symbolName!);
const generatedUniqueIdentifier = this._getUniqueIdentifier(
sourceFile,
symbolName!,
ignoreIdentifierCollisions
);
const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName;
const importName = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier;
existingImport.specifiers!.push({
name: importName,
propertyName: needsGeneratedUniqueName ? propertyIdentifier : undefined
});
existingImport.state |= ImportState.MODIFIED;
if (hasFlag(existingImport, ImportState.DELETED)) {
existingImport.state &= ~ImportState.DELETED;
}
return importName;
}
let identifier: ts.Identifier | null = null;
let newImport: AnalyzedImport | null = null;
if (symbolName) {
const propertyIdentifier = ts.factory.createIdentifier(symbolName);
const generatedUniqueIdentifier = this._getUniqueIdentifier(
sourceFile,
symbolName,
ignoreIdentifierCollisions
);
const needsGeneratedUniqueName = generatedUniqueIdentifier.text !== symbolName;
identifier = needsGeneratedUniqueName ? generatedUniqueIdentifier : propertyIdentifier;
const newImportDecl = ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(false, undefined, ts.factory.createNamedImports([])),
ts.factory.createStringLiteral(moduleName)
);
newImport = {
moduleName,
node: newImportDecl,
specifiers: [
{
propertyName: needsGeneratedUniqueName ? propertyIdentifier : undefined,
name: identifier
}
],
state: ImportState.ADDED
};
} else {
identifier = this._getUniqueIdentifier(
sourceFile,
'defaultExport',
ignoreIdentifierCollisions
);
const newImportDecl = ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(false, identifier, undefined),
ts.factory.createStringLiteral(moduleName)
);
newImport = {
moduleName,
node: newImportDecl,
name: identifier,
state: ImportState.ADDED
};
}
fileImports.push(newImport);
return identifier;
}
replaceImport(
sourceFile: ts.SourceFile,
symbolName: string,
oldModuleName: string,
newModuleName: string
) {
this.deleteNamedBindingImport(sourceFile, symbolName, oldModuleName);
const nodesToIgnore: ts.Identifier[] = [];
const nodeQueue: ts.Node[] = [sourceFile];
while (nodeQueue.length) {
const aNode = nodeQueue.shift()!;
if (ts.isIdentifier(aNode) && aNode.text === symbolName) {
nodesToIgnore.push(aNode);
}
nodeQueue.push(...aNode.getChildren());
}
this.addImportToSourceFile(sourceFile, symbolName, newModuleName, false, nodesToIgnore);
}
recordChanges() {
this._importCache.forEach((fileImports, sourceFile) => {
const recorder = new FileChangeRecorder(this._tree, sourceFile.fileName);
const lastUnmodifiedImport = fileImports
.reverse()
.find((i) => i.state === ImportState.UNMODIFIED);
const importStartIndex = lastUnmodifiedImport
? this._getEndPositionOfNode(lastUnmodifiedImport.node)
: 0;
fileImports.forEach((importData) => {
if (importData.state === ImportState.UNMODIFIED) {
return;
}
if (hasFlag(importData, ImportState.DELETED)) {
if (!hasFlag(importData, ImportState.ADDED)) {
const start = importData.node.getFullStart();
recorder.remove(start, start + importData.node.getFullWidth());
}
return;
}
if (importData.specifiers) {
const namedBindings = importData.node.importClause!.namedBindings as ts.NamedImports;
const importSpecifiers = importData.specifiers.map((s) =>
ts.factory.createImportSpecifier(false, s.propertyName, s.name)
);
const updatedBindings = ts.factory.updateNamedImports(namedBindings, importSpecifiers);
if (hasFlag(importData, ImportState.ADDED)) {
const updatedImport = ts.factory.updateImportDeclaration(
importData.node,
undefined,
ts.factory.createImportClause(false, undefined, updatedBindings),
ts.factory.createStringLiteral(importData.moduleName),
undefined
);
const newImportText = this._printer.printNode(
ts.EmitHint.Unspecified,
updatedImport,
sourceFile
);
recorder.insertLeft(
importStartIndex,
importStartIndex === 0 ? `${newImportText}\n` : `\n${newImportText}`
);
return;
} else if (hasFlag(importData, ImportState.MODIFIED)) {
const newNamedBindingsText = this._printer.printNode(
ts.EmitHint.Unspecified,
updatedBindings,
sourceFile
);
const start = namedBindings.getStart();
recorder.remove(start, start + namedBindings.getWidth());
recorder.insertRight(namedBindings.getStart(), newNamedBindingsText);
return;
}
} else if (hasFlag(importData, ImportState.ADDED)) {
const newImportText = this._printer.printNode(
ts.EmitHint.Unspecified,
importData.node,
sourceFile
);
recorder.insertLeft(
importStartIndex,
importStartIndex === 0 ? `${newImportText}\n` : `\n${newImportText}`
);
return;
}
throw Error('Unexpected import modification.');
});
if (recorder.hasChanged()) {
recorder.applyChanges();
}
});
}
correctNodePosition(node: ts.Node, offset: number, position: ts.LineAndCharacter) {
const sourceFile = node.getSourceFile();
if (!this._importCache.has(sourceFile)) {
return position;
}
const newPosition: ts.LineAndCharacter = { ...position };
const fileImports = this._importCache.get(sourceFile)!;
for (const importData of fileImports) {
const fullEnd = importData.node.getFullStart() + importData.node.getFullWidth();
if (offset > fullEnd && hasFlag(importData, ImportState.DELETED)) {
newPosition.line--;
} else if (offset > fullEnd && hasFlag(importData, ImportState.ADDED)) {
newPosition.line++;
}
}
return newPosition;
}
private _getUniqueIdentifier(
sourceFile: ts.SourceFile,
symbolName: string,
ignoreIdentifierCollisions: ts.Identifier[]
): ts.Identifier {
if (this._isUniqueIdentifierName(sourceFile, symbolName, ignoreIdentifierCollisions)) {
this._recordUsedIdentifier(sourceFile, symbolName);
return ts.factory.createIdentifier(symbolName);
}
let name: string | null = null;
let counter = 1;
do {
name = `${symbolName}_${counter++}`;
} while (!this._isUniqueIdentifierName(sourceFile, name, ignoreIdentifierCollisions));
this._recordUsedIdentifier(sourceFile, name!);
return ts.factory.createIdentifier(name!);
}
private _isUniqueIdentifierName(
sourceFile: ts.SourceFile,
name: string,
ignoreIdentifierCollisions: ts.Identifier[]
) {
if (
this._usedIdentifierNames.has(sourceFile) &&
this._usedIdentifierNames.get(sourceFile)!.indexOf(name) !== -1
) {
return false;
}
const nodeQueue: ts.Node[] = [sourceFile];
while (nodeQueue.length) {
const node = nodeQueue.shift()!;
if (
ts.isIdentifier(node) &&
node.text === name &&
!ignoreIdentifierCollisions.includes(node)
) {
return false;
}
nodeQueue.push(...node.getChildren());
}
return true;
}
private _recordUsedIdentifier(sourceFile: ts.SourceFile, identifierName: string) {
this._usedIdentifierNames.set(
sourceFile,
(this._usedIdentifierNames.get(sourceFile) || []).concat(identifierName)
);
}
private _getEndPositionOfNode(node: ts.Node) {
const nodeEndPos = node.getEnd();
const commentRanges = ts.getTrailingCommentRanges(node.getSourceFile().text, nodeEndPos);
if (!commentRanges || !commentRanges.length) {
return nodeEndPos;
}
return commentRanges[commentRanges.length - 1]!.end;
}
}