File

libs/nx/src/migrations/update-validation-errors-imports/cherry-picked/import-manager.ts

Description

Import manager that can be used to add or remove TypeScript imports within source files. The manager ensures that multiple transformations are applied properly without shifted offsets and that existing imports are re-used.

Index

Methods

Constructor

constructor(_tree: Tree, _printer: ts.Printer)
Parameters :
Name Type Optional
_tree Tree No
_printer ts.Printer No

Methods

addImportToSourceFile
addImportToSourceFile(sourceFile: ts.SourceFile, symbolName: string | null, moduleName: string, typeImport, ignoreIdentifierCollisions: ts.Identifier[])

Adds an import to the given source file and returns the TypeScript expression that can be used to access the newly imported symbol.

Whenever an import is added to a source file, it's recommended that the returned expression is used to reference th symbol. This is necessary because the symbol could be aliased if it would collide with existing imports in source file.

the default export is requested. the import manager checks for import collisions.

Parameters :
Name Type Optional Default value Description
sourceFile ts.SourceFile No

Source file to which the import should be added.

symbolName string | null No

Name of the symbol that should be imported. Can be null if the default export is requested.

moduleName string No

Name of the module of which the symbol should be imported.

typeImport No false

Whether the symbol is a type.

ignoreIdentifierCollisions ts.Identifier[] No []

List of identifiers which can be ignored when the import manager checks for import collisions.

Returns : ts.Expression
correctNodePosition
correctNodePosition(node: ts.Node, offset: number, position: ts.LineAndCharacter)

Corrects the line and character position of a given node. Since nodes of source files are immutable and we sometimes make changes to the containing source file, the node position might shift (e.g. if we add a new import before).

This method can be used to retrieve a corrected position of the given node. This is helpful when printing out error messages which should reflect the new state of source files.

Parameters :
Name Type Optional
node ts.Node No
offset number No
position ts.LineAndCharacter No
Returns : ts.LineAndCharacter
deleteImportByDeclaration
deleteImportByDeclaration(declaration: ts.ImportDeclaration)

Deletes the import that matches the given import declaration if found.

Parameters :
Name Type Optional
declaration ts.ImportDeclaration No
Returns : void
deleteNamedBindingImport
deleteNamedBindingImport(sourceFile: ts.SourceFile, symbolName: string, moduleName: string)

Deletes a given named binding import from the specified source file.

Parameters :
Name Type Optional
sourceFile ts.SourceFile No
symbolName string No
moduleName string No
Returns : void
recordChanges
recordChanges()

Applies the recorded changes in the update recorders of the corresponding source files. The changes are applied separately after all changes have been recorded because otherwise file offsets will change and the source files would need to be re-parsed after each change.

Returns : void
replaceImport
replaceImport(sourceFile: ts.SourceFile, symbolName: string, oldModuleName: string, newModuleName: string)
Parameters :
Name Type Optional
sourceFile ts.SourceFile No
symbolName string No
oldModuleName string No
newModuleName string No
Returns : void
import { Tree } from '@nx/devkit';
import { dirname, resolve } from 'path';
import * as ts from 'typescript';
import { FileChangeRecorder } from './file-change-recorder';

/** Enum describing the possible states of an analyzed import. */
const enum ImportState {
  UNMODIFIED = 0b0,
  MODIFIED = 0b10,
  ADDED = 0b100,
  DELETED = 0b1000
}

/** Interface describing an import specifier. */
interface ImportSpecifier {
  name: ts.Identifier;
  propertyName?: ts.ModuleExportName;
}

/** Interface describing an analyzed import. */
interface AnalyzedImport {
  node: ts.ImportDeclaration;
  moduleName: string;
  name?: ts.Identifier;
  specifiers?: ImportSpecifier[];
  namespace?: boolean;
  state: ImportState;
}

/** Checks whether an analyzed import has the given import flag set. */
const hasFlag = (data: AnalyzedImport, flag: ImportState) => (data.state & flag) !== 0;

/**
 * Import manager that can be used to add or remove TypeScript imports within source
 * files. The manager ensures that multiple transformations are applied properly
 * without shifted offsets and that existing imports are re-used.
 */
export class ImportManager {
  /** Map of source-files and their previously used identifier names. */
  private _usedIdentifierNames = new Map<ts.SourceFile, string[]>();

  /** Map of source files and their analyzed imports. */
  private _importCache = new Map<ts.SourceFile, AnalyzedImport[]>();

  constructor(private _tree: Tree, private _printer: ts.Printer) {}

  /**
   * Analyzes the import of the specified source file if needed. In order to perform
   * modifications to imports of a source file, we store all imports in memory and
   * update the source file once all changes have been made. This is essential to
   * ensure that we can re-use newly added imports and not break file offsets.
   */
  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;

      // Handles side-effect imports which do neither have a name or
      // specifiers. e.g. `import "my-package";`
      if (!node.importClause) {
        result.push({ moduleName, node, state: ImportState.UNMODIFIED });
        continue;
      }

      // Handles imports resolving to default exports of a module.
      // e.g. `import moment from "moment";`
      if (!node.importClause.namedBindings) {
        result.push({
          moduleName,
          node,
          name: node.importClause.name,
          state: ImportState.UNMODIFIED
        });
        continue;
      }

      // Handles imports with individual symbol specifiers.
      // e.g. `import {A, B, C} from "my-module";`
      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 {
        // Handles namespaced imports. e.g. `import * as core from "my-pkg";`
        result.push({
          moduleName,
          node,
          name: node.importClause.namedBindings.name,
          namespace: true,
          state: ImportState.UNMODIFIED
        });
      }
    }
    this._importCache.set(sourceFile, result);
    return result;
  }

  /**
   * Checks whether the given specifier, which can be relative to the base path,
   * matches the passed module name.
   */
  private _isModuleSpecifierMatching(
    basePath: string,
    specifier: string,
    moduleName: string
  ): boolean {
    return specifier.startsWith('.')
      ? resolve(basePath, specifier) === resolve(basePath, moduleName)
      : specifier === moduleName;
  }

  /** Deletes a given named binding import from the specified source file. */
  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 the import does no longer contain any specifiers after the removal of the
        // given symbol, we can just mark the whole import for deletion. Otherwise, we mark
        // it as modified so that it will be re-printed.
        if (importData.specifiers.length === 0) {
          importData.state |= ImportState.DELETED;
        } else {
          importData.state |= ImportState.MODIFIED;
        }
      }
    }
  }

  /** Deletes the import that matches the given import declaration if found. */
  deleteImportByDeclaration(declaration: ts.ImportDeclaration) {
    const fileImports = this._analyzeImportsIfNeeded(declaration.getSourceFile());
    for (const importData of fileImports) {
      if (importData.node === declaration) {
        importData.state |= ImportState.DELETED;
      }
    }
  }

  /**
   * Adds an import to the given source file and returns the TypeScript expression that
   * can be used to access the newly imported symbol.
   *
   * Whenever an import is added to a source file, it's recommended that the returned
   * expression is used to reference th symbol. This is necessary because the symbol
   * could be aliased if it would collide with existing imports in source file.
   *
   * @param sourceFile Source file to which the import should be added.
   * @param symbolName Name of the symbol that should be imported. Can be null if
   *    the default export is requested.
   * @param moduleName Name of the module of which the symbol should be imported.
   * @param typeImport Whether the symbol is a type.
   * @param ignoreIdentifierCollisions List of identifiers which can be ignored when
   *    the import manager checks for import collisions.
   */
  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 no symbol name has been specified, the default import is requested. In that
      // case we search for non-namespace and non-specifier imports.
      if (!symbolName && !importData.namespace && !importData.specifiers) {
        return ts.factory.createIdentifier(importData.name!.text);
      }

      // In case a "Type" symbol is imported, we can't use namespace imports
      // because these only export symbols available at runtime (no types)
      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);
        }

        // In case the symbol could not be found in an existing import, we
        // keep track of the import declaration as it can be updated to include
        // the specified symbol name without having to create a new import.
        existingImport = importData;
      }
    }

    // If there is an existing import that matches the specified module, we
    // just update the import specifiers to also import the requested symbol.
    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)) {
        // unset the deleted flag if the import is pending deletion, but
        // can now be used for the new imported symbol.
        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);

    // addImportToSourceFile contains a check to ensure that an identifier is unique. As this is just replacing the
    // import identifier, it needs to be ensured that all existing nodes with the same identifier are ignored.
    // Unfortunately, it is not enough to just add the import node itself here.
    // The check also reacts to the usage of an identifier within the file. For example as a type for a variable.
    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);
  }

  /**
   * Applies the recorded changes in the update recorders of the corresponding source files.
   * The changes are applied separately after all changes have been recorded because otherwise
   * file offsets will change and the source files would need to be re-parsed after each change.
   */
  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)) {
          // Imports which do not exist in source file, can be just skipped as
          // we do not need any replacement to delete the import.
          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);

          // In case an import has been added newly, we need to print the whole import
          // declaration and insert it at the import start index. Otherwise, we just
          // update the named bindings to not re-print the whole import (which could
          // cause unnecessary formatting changes)
          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;
        }

        // we should never hit this, but we rather want to print a custom exception
        // instead of just skipping imports silently.
        throw Error('Unexpected import modification.');
      });

      if (recorder.hasChanged()) {
        recorder.applyChanges();
      }
    });
  }

  /**
   * Corrects the line and character position of a given node. Since nodes of
   * source files are immutable and we sometimes make changes to the containing
   * source file, the node position might shift (e.g. if we add a new import before).
   *
   * This method can be used to retrieve a corrected position of the given node. This
   * is helpful when printing out error messages which should reflect the new state of
   * source files.
   */
  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();
      // Subtract or add lines based on whether an import has been deleted or removed
      // before the actual node offset.
      if (offset > fullEnd && hasFlag(importData, ImportState.DELETED)) {
        newPosition.line--;
      } else if (offset > fullEnd && hasFlag(importData, ImportState.ADDED)) {
        newPosition.line++;
      }
    }
    return newPosition;
  }

  /**
   * Returns an unique identifier name for the specified symbol name.
   * @param sourceFile Source file to check for identifier collisions.
   * @param symbolName Name of the symbol for which we want to generate an unique name.
   * @param ignoreIdentifierCollisions List of identifiers which should be ignored when
   *    checking for identifier collisions in the given source file.
   */
  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!);
  }

  /**
   * Checks whether the specified identifier name is used within the given source file.
   * @param sourceFile Source file to check for identifier collisions.
   * @param name Name of the identifier which is checked for its uniqueness.
   * @param ignoreIdentifierCollisions List of identifiers which should be ignored when
   *    checking for identifier collisions in the given source file.
   */
  private _isUniqueIdentifierName(
    sourceFile: ts.SourceFile,
    name: string,
    ignoreIdentifierCollisions: ts.Identifier[]
  ) {
    if (
      this._usedIdentifierNames.has(sourceFile) &&
      this._usedIdentifierNames.get(sourceFile)!.indexOf(name) !== -1
    ) {
      return false;
    }

    // Walk through the source file and search for an identifier matching
    // the given name. In that case, it's not guaranteed that this name
    // is unique in the given declaration scope and we just 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;
  }

  /**
   * Records that the given identifier is used within the specified source file. This
   * is necessary since we do not apply changes to source files per change, but still
   * want to avoid conflicts with newly imported symbols.
   */
  private _recordUsedIdentifier(sourceFile: ts.SourceFile, identifierName: string) {
    this._usedIdentifierNames.set(
      sourceFile,
      (this._usedIdentifierNames.get(sourceFile) || []).concat(identifierName)
    );
  }

  /**
   * Determines the full end of a given node. By default the end position of a node is
   * before all trailing comments. This could mean that generated imports shift comments.
   */
  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;
  }
}

results matching ""

    No results matching ""