refactor/importExport.js

'use strict';

const _ = require('lodash');
const traverse = require('babel-traverse').default;
const generate = require('babel-generator').default;
const babelTypes = require('babel-types');
const common = require('./common');
const identifier = require('./identifier');

const babelGeneratorOptions = {
  quotes: 'single',
};

function formatMultilineImport(importCode) {
  // format import statement to:
  // import {
  //   name1,
  //   name2,
  // } from './xxx';

  const m = importCode.match(/\{([^}]+)\}/);
  if (m) {
    const arr = _.compact(m[1].split(/, */).map(_.trim));
    if (arr.length) {
      return importCode.replace(/\{[^}]+\}/, `{\n  ${arr.join(',\n  ')},\n}`);
    }
  }
  return importCode;
}

/**
 * Import from a given module source. This methods operates import statement:
 * import defaultImmport, { namedImport ... } from './module-source';
 * It noly supports es6 modules import but not commonJS or AMD or others...
 * @param {string} ast - Which module to manage import statement.
 * @param {string} moduleSource - From which module source to add import from. If not found, the create an import line.
 * @index {string} defaultImport - The default import. If not need, pass it as null. The module should haven't import the default.
 * @index {string|array} namedImport - The named imports. If has imported, then do nothing.
 * @index {string} namespaceImport - The new function name.
 * @alias module:refactor.addImportFrom
 * @example
 * const refactor = require('rekit-core').refactor;
 * refactor.addImportFrom(file, './some-module', 'SomeModule', ['method1', 'method2']);
 * // it generates: import SomeModule, { method1, method2 } from './some-module';
**/
function addImportFrom(ast, moduleSource, defaultImport, namedImport, namespaceImport) {
  // Summary:
  //  Add import from source module. Such as import { xxx } from './x';
  let names = [];

  if (namedImport) {
    if (typeof namedImport === 'string') {
      names.push(namedImport);
    } else {
      names = names.concat(namedImport);
    }
  }

  const changes = [];
  const t = babelTypes;

  let targetImportPos = 0;
  let sourceExisted = false;
  traverse(ast, {
    ImportDeclaration(path) {
      const node = path.node;
      // multilines means whether to separate import specifiers into different lines
      const multilines = node.loc.start.line !== node.loc.end.line;
      targetImportPos = path.node.end + 1;

      if (!node.specifiers || !node.source || node.source.value !== moduleSource) return;
      sourceExisted = true;
      let newNames = [];
      const alreadyHaveDefaultImport = !!_.find(node.specifiers, { type: 'ImportDefaultSpecifier' });
      const alreadyHaveNamespaceImport = !!_.find(node.specifiers, { type: 'ImportNamespaceSpecifier' });
      if (defaultImport && !alreadyHaveDefaultImport) newNames.push(defaultImport);
      if (namespaceImport && !alreadyHaveNamespaceImport) newNames.push(namespaceImport);

      newNames = newNames.concat(names);

      // only add names which don't exist
      newNames = newNames.filter(n => !_.find(node.specifiers, s => s.local.name === n));

      if (newNames.length > 0) {
        const newSpecifiers = [].concat(node.specifiers);
        newNames.forEach((n) => {
          const local = t.identifier(n);
          const imported = local; // TODO: doesn't support local alias.
          if (n === defaultImport) {
            newSpecifiers.unshift(t.importDefaultSpecifier(local));
          } else if (n === namespaceImport) {
            newSpecifiers.push(t.importNamespaceSpecifier(local));
          } else {
            newSpecifiers.push(t.importSpecifier(local, imported));
          }
        });

        const newNode = Object.assign({}, node, { specifiers: newSpecifiers });
        let newCode = generate(newNode, babelGeneratorOptions).code;

        if (multilines) {
          newCode = formatMultilineImport(newCode);
        }
        changes.push({
          start: node.start,
          end: node.end,
          replacement: newCode,
        });
      }
    }
  });

  if (changes.length === 0 && !sourceExisted) {
    // add new import declaration if module source doesn't exist
    const specifiers = [];
    if (defaultImport) {
      specifiers.push(t.importDefaultSpecifier(t.identifier(defaultImport)));
    }
    if (namespaceImport) {
      specifiers.push(t.importNamespaceSpecifier(t.identifier(namespaceImport)));
    }

    names.forEach((n) => {
      const local = t.identifier(n);
      const imported = local;
      specifiers.push(t.importSpecifier(local, imported));
    });

    const node = t.importDeclaration(specifiers, t.stringLiteral(moduleSource));
    const code = generate(node, babelGeneratorOptions).code;
    changes.push({
      start: targetImportPos,
      end: targetImportPos,
      replacement: `${code}\n`,
    });
  }

  return changes;
}

/**
 * Export from a given module source. This methods operates export ... from statement:
 * @param {string} ast - Which module to manage export from statement.
 * @param {string} moduleSource - From which module source to add import from. If not found, the create an import line.
 * @index {string} defaultExport - The default import. If not need, pass it as null. The module should haven't import the default.
 * @index {string|array} namedExport - The named imports. If has imported, then do nothing.
 * @alias module:refactor.addExportFrom
**/
function addExportFrom(ast, moduleSource, defaultExport, namedExport) {
  // Summary:
  //  Add export from source module. Such as export { xxx } from './x';
  let names = [];

  if (namedExport) {
    if (typeof namedExport === 'string') {
      names.push(namedExport);
    } else {
      names = names.concat(namedExport);
    }
  }

  const changes = [];
  const t = babelTypes;

  let targetExportPos = 0;
  let sourceExisted = false;
  traverse(ast, {
    ExportNamedDeclaration(path) {
      const node = path.node;
      targetExportPos = path.node.end + 1;
      if (!node.specifiers || !node.source || node.source.value !== moduleSource) return;
      sourceExisted = true;
      let newNames = [];
      const alreadyHaveDefaultExport = !!_.find(node.specifiers, s => _.get(s, 'local.name') === 'default');
      if (defaultExport && !alreadyHaveDefaultExport) newNames.push(defaultExport);

      newNames = newNames.concat(names);

      // only add names which don't exist
      newNames = newNames.filter(n => !_.find(node.specifiers, s => (_.get(s, 'exported.name') || _.get(s, 'local.name')) === n));

      if (newNames.length > 0) {
        const newSpecifiers = [].concat(node.specifiers);
        newNames.forEach((n) => {
          const local = t.identifier(n);
          const exported = local; // TODO: doesn't support local alias.
          if (n === defaultExport) {
            newSpecifiers.unshift(t.exportSpecifier(t.identifier('default'), exported));
          } else {
            newSpecifiers.push(t.exportSpecifier(local, exported));
          }
        });

        const newNode = Object.assign({}, node, { specifiers: newSpecifiers });
        const newCode = generate(newNode, babelGeneratorOptions).code;
        changes.push({
          start: node.start,
          end: node.end,
          replacement: newCode,
        });
      }
    }
  });

  if (changes.length === 0 && !sourceExisted) {
    const specifiers = [];
    if (defaultExport) {
      specifiers.push(t.exportSpecifier(t.identifier('default'), t.identifier(defaultExport)));
    }

    names.forEach((n) => {
      const local = t.identifier(n);
      const exported = local;
      specifiers.push(t.exportSpecifier(local, exported));
    });

    const node = t.ExportNamedDeclaration(null, specifiers, t.stringLiteral(moduleSource));
    const code = generate(node, babelGeneratorOptions).code;
    changes.push({
      start: targetExportPos,
      end: targetExportPos,
      replacement: `${code}\n`,
    });
  }

  return changes;
}

function renameImportAsSpecifier(ast, oldName, newName) {
  let defNode = null;
  let changes = [];

  traverse(ast, {
    ImportSpecifier(path) {
      if (_.get(path.node, 'local.name') === oldName) {
        defNode = path.node.local;
      }
    }
  });
  if (defNode) {
    changes = changes.concat(identifier.renameIdentifier(ast, oldName, newName, defNode));
  }
  return changes;
}

function renameImportSpecifier(ast, oldName, newName, moduleSource) {
  // Summary:
  //  Rename the import(default, named) variable name and their reference.
  //  The simple example is to rename a component
  //  NOTE: only rename imported name!
  //  eg: import { A as A1 } from './A'; A -> B  import { B as A1 } from './A';
  //  import fetchList from './action';
  //  import { fetchList as fetchTopicList } from '../topic/action';

  let defNode = null;
  let changes = [];

  traverse(ast, {
    ImportDeclaration(path) {
      const node = path.node;
      if (moduleSource && _.get(node, 'source.value') !== moduleSource) return;
      // console.log(_.get(node, 'source.value'), moduleSource);
      node.specifiers.forEach((specifier) => {
        if (
          (specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportNamespaceSpecifier')
          && _.get(specifier, 'local.name') === oldName
        ) {
          defNode = specifier.local;
        }

        // only rename imported specifier
        if (specifier.type === 'ImportSpecifier' && _.get(specifier, 'imported.name') === oldName) {
          if (_.get(specifier, 'local.name') === oldName) {
            defNode = specifier.local;
          } else {
            changes.push({
              start: specifier.imported.start,
              end: specifier.imported.end,
              replacement: newName,
            });
          }
        }
      });
    }
  });
  if (defNode) {
    changes = changes.concat(identifier.renameIdentifier(ast, oldName, newName, defNode));
  }
  return changes;
}

function renameExportSpecifier(ast, oldName, newName, moduleSource) {
  // It only rename export specifier but not references.
  const changes = [];
  traverse(ast, {
    ExportSpecifier(path) {
      const node = path.node;
      if (moduleSource && _.get(path.parentPath.node, 'source.value') !== moduleSource) return;

      if (_.get(node, 'local.name') === oldName) {
        changes.push({
          start: node.local.start,
          end: node.local.end,
          replacement: newName,
        });
      } else if (_.get(node, 'exported.name') === oldName) {
        changes.push({
          start: node.exported.start,
          end: node.exported.end,
          replacement: newName,
        });
      }
    }
  });
  return changes;
}

function renameModuleSource(ast, oldModuleSource, newModuleSource) {
  // Summary:
  //  Rename the module source for import/export xx from moduleSource.
  //  It only compares the string rather that resolve to the absolute path.

  const changes = [];

  function renameSource(path) {
    const node = path.node;
    if (node.source && node.source.value === oldModuleSource) {
      changes.push({
        start: node.source.start + 1,
        end: node.source.end - 1,
        replacement: newModuleSource,
      });
    }
  }

  traverse(ast, {
    ImportDeclaration: renameSource,
    ExportNamedDeclaration: renameSource,
  });

  return changes;
}

function removeImportSpecifier(ast, name) {
  // Remove import specifier by local name

  let names = name;
  if (typeof name === 'string') {
    names = [name];
  }
  const changes = [];
  traverse(ast, {
    ImportDeclaration(path) {
      const node = path.node;
      const multilines = node.loc.start.line !== node.loc.end.line;
      if (!node.specifiers) return;
      const newSpecifiers = node.specifiers.filter(s => !names.includes(s.local.name));
      if (newSpecifiers.length === 0) {
        // no specifiers, should delete the import statement
        changes.push({
          start: node.start,
          end: node.end,
          replacement: '',
        });
      } else if (newSpecifiers.length !== node.specifiers.length) {
        // remove the specifier import
        const newNode = Object.assign({}, node, { specifiers: newSpecifiers });
        let newCode = generate(newNode, {}).code;
        if (multilines) newCode = formatMultilineImport(newCode);
        changes.push({
          start: node.start,
          end: node.end,
          replacement: newCode,
        });
      }
    }
  });

  return changes;
}

function removeImportBySource(ast, moduleSource) {
  const changes = [];

  function removeBySource(path) {
    const node = path.node;
    if (!node.source) return;
    if (node.source.value === moduleSource) {
      changes.push({
        start: node.start,
        end: node.end,
        replacement: '',
      });
    }
  }
  traverse(ast, {
    ExportNamedDeclaration: removeBySource,
    ImportDeclaration: removeBySource,
  });

  return changes;
}

module.exports = {
  addImportFrom: common.acceptFilePathForAst(addImportFrom),
  addExportFrom: common.acceptFilePathForAst(addExportFrom),

  renameImportSpecifier: common.acceptFilePathForAst(renameImportSpecifier),
  renameImportAsSpecifier: common.acceptFilePathForAst(renameImportAsSpecifier),
  renameExportSpecifier: common.acceptFilePathForAst(renameExportSpecifier),

  removeImportSpecifier: common.acceptFilePathForAst(removeImportSpecifier),
  // removeExportSpecifier: common.acceptFilePathForAst(removeExportSpecifier),
  removeImportBySource: common.acceptFilePathForAst(removeImportBySource),

  renameModuleSource: common.acceptFilePathForAst(renameModuleSource),
};