Galooshi/import-js

View on GitHub
lib/ImportStatements.js

Summary

Maintainability
A
2 hrs
Test Coverage
A
100%
// @flow

// Class that sorts ImportStatements as they are pushed in
import flattenDeep from 'lodash/flattenDeep';
import partition from 'lodash/partition';
import sortBy from 'lodash/sortBy';
import uniqBy from 'lodash/uniqBy';

import Configuration from './Configuration';
import ImportStatement from './ImportStatement';

const STYLE_SIDE_EFFECT = 'side-effect';
const STYLE_IMPORT = 'import';
const STYLE_CONST = 'const';
const STYLE_VAR = 'var';
const STYLE_CUSTOM = 'custom';

// Order is significant here
const STYLES = Object.freeze([
  STYLE_SIDE_EFFECT,
  STYLE_IMPORT,
  STYLE_CONST,
  STYLE_VAR,
  STYLE_CUSTOM,
]);

const PATH_TYPE_CORE_MODULE = 'core_module';
const PATH_TYPE_PACKAGE = 'package';
const PATH_TYPE_NON_RELATIVE = 'non_relative';
const PATH_TYPE_RELATIVE = 'relative';

// Order is significant here
const PATH_TYPES = Object.freeze([
  PATH_TYPE_CORE_MODULE,
  PATH_TYPE_PACKAGE,
  PATH_TYPE_NON_RELATIVE,
  PATH_TYPE_RELATIVE,
]);

const GROUPINGS_ARRAY = Object.freeze(
  flattenDeep(
    STYLES.map((style: string): Array<string> =>
      PATH_TYPES.map((location: string): string => `${style} ${location}`),
    ),
  ),
);

const GROUPINGS = {};
GROUPINGS_ARRAY.forEach((group: string, index: number) => {
  GROUPINGS[group] = index;
});
Object.freeze(GROUPINGS);

/**
 * Determine import path type (e.g. 'package, 'non-relative', 'relative')
 */
function importStatementPathType(
  importStatement: ImportStatement,
  packageDependencies: Set<string>,
  coreModules: Array<string>,
): string {
  if (importStatement.path.startsWith('.')) {
    return PATH_TYPE_RELATIVE;
  }

  if (coreModules.indexOf(importStatement.path) !== -1) {
    return PATH_TYPE_CORE_MODULE;
  }

  // Match if any of the packageDependencies exactly match path or match the
  // start of the path up to a path divider. This is so that imports for
  // modules inside package dependencies end up in the right group
  // (PATH_TYPE_PACKAGE).
  if (
    Array.from(packageDependencies).some(
      (pkg: string): boolean =>
        importStatement.path === pkg ||
        importStatement.path.startsWith(`${pkg}/`),
    )
  ) {
    return PATH_TYPE_PACKAGE;
  }

  return PATH_TYPE_NON_RELATIVE;
}

/**
 * Determine import statement style (e.g. 'import', 'const', 'var', or
 * 'custom')
 */
function importStatementStyle(importStatement: ImportStatement): string {
  if (importStatement.hasSideEffects) {
    return STYLE_SIDE_EFFECT;
  }

  if (importStatement.declarationKeyword === 'import') {
    return STYLE_IMPORT;
  }

  if (importStatement.importFunction === 'require') {
    if (importStatement.declarationKeyword === 'const') {
      return STYLE_CONST;
    }
    if (importStatement.declarationKeyword === 'var') {
      return STYLE_VAR;
    }
  }

  return STYLE_CUSTOM;
}

function importStatementGroupIndex(
  importStatement: ImportStatement,
  packageDependencies: Set<string>,
  coreModules: Array<string>,
): number {
  const style = importStatementStyle(importStatement);
  const pathType = importStatementPathType(
    importStatement,
    packageDependencies,
    coreModules,
  );

  return GROUPINGS[`${style} ${pathType}`];
}

export default class ImportStatements {
  imports: Object;

  config: Configuration;

  constructor(config: Configuration, imports: Object = {}) {
    this.config = config;
    this.imports = imports;
  }

  clone(): ImportStatements {
    return new ImportStatements(this.config, { ...this.imports });
  }

  /**
   * Method added to make it behave like an array.
   */
  forEach(callback: Function) {
    Object.keys(this.imports).forEach((key: string) => {
      callback(this.imports[key]);
    });
  }

  /**
   * Method added to make it behave like an array.
   */
  find(callback: Function): ?ImportStatement {
    const key = Object.keys(this.imports).find((key: string): boolean =>
      callback(this.imports[key]),
    );
    if (!key) {
      return undefined;
    }
    return this.imports[key];
  }

  push(...importStatements: Array<ImportStatement>): ImportStatements {
    importStatements.forEach((importStatement: ImportStatement) => {
      const existingStatement = this.imports[importStatement.path];
      if (existingStatement) {
        // Import already exists, so this line is likely one of a named imports
        // pair. Combine it into the same ImportStatement.
        existingStatement.merge(importStatement);
      } else {
        // This is a new import, so we just add it to the hash.
        this.imports[importStatement.path] = importStatement;
      }
    });

    return this; // for chaining
  }

  empty(): boolean {
    return this.size() === 0;
  }

  size(): number {
    return Object.keys(this.imports).length;
  }

  deleteVariables(variableNames: Array<string>): ImportStatements {
    Object.keys(this.imports).forEach((key: string) => {
      const importStatement = this.imports[key];
      variableNames.forEach((variableName: string) => {
        importStatement.deleteVariable(variableName);
      });
      if (importStatement.isEmpty()) {
        delete this.imports[key];
      }
    });

    return this; // for chaining
  }

  /**
   * Convert the import statements into an array of strings, with an empty
   * string between each group.
   */
  toArray(): Array<string> {
    const maxLineLength = this.config.get('maxLineLength');
    const tab = this.config.get('tab');

    const strings = [];
    this._toGroups().forEach((group: Array<ImportStatement>) => {
      group.forEach((importStatement: ImportStatement) => {
        const importStrings = importStatement
          .toImportStrings(maxLineLength, tab)
          .map((importString: string): string =>
            this.config.get('importStatementFormatter', {
              importStatement: importString,
              moduleName: importStatement.path,
            }),
          );
        strings.push(...importStrings);
      });

      if (this.config.get('emptyLineBetweenGroups')) {
        strings.push(''); // Add a blank line between groups.
      }
    });

    // We don't want to include a trailing newline at the end of all the
    // groups here.
    if (strings[strings.length - 1] === '') {
      strings.pop();
    }

    return strings;
  }

  /**
   * Sort the import statements by path and group them based on our heuristic
   * of style and path type.
   */
  _toGroups(): Array<Array<ImportStatement>> {
    const groups = [];

    const importsArray = Object.keys(this.imports).map(
      (key: string): ImportStatement => this.imports[key],
    );

    // There's a chance we have duplicate imports (can happen when switching
    // declaration_keyword for instance). By first sorting imports so that new
    // ones are first, then removing duplicates, we guarantee that we delete
    // the old ones that are now redundant.
    let result = partition(
      importsArray,
      (importStatement: ImportStatement): boolean =>
        !importStatement.isParsedAndUntouched(),
    );
    result = flattenDeep(result);

    if (this.config.get('sortImports')) {
      result = sortBy(result, (is: ImportStatement): Array<string> =>
        is.toNormalized(),
      );
    }

    result = uniqBy(result, (is: ImportStatement): Array<string> =>
      is.toNormalized(),
    );

    if (!this.config.get('groupImports')) {
      return [result];
    }

    const packageDependencies = this.config.get('packageDependencies');
    const coreModules = this.config.get('coreModules');
    result.forEach((importStatement: ImportStatement) => {
      // Figure out what group to put this import statement in
      const groupIndex = importStatementGroupIndex(
        importStatement,
        packageDependencies,
        coreModules,
      );

      // Add the import statement to the group
      groups[groupIndex] = groups[groupIndex] || [];
      groups[groupIndex].push(importStatement);
    });

    if (groups.length) {
      groups.filter(Boolean); // compact
    }
    return groups;
  }
}