Galooshi/import-js

View on GitHub
lib/Configuration.js

Summary

Maintainability
A
3 hrs
Test Coverage
A
94%
// @flow

import os from 'os';
import path from 'path';
import has from 'lodash/has';

import semver from 'semver';

import FileUtils from './FileUtils';
import JsModule from './JsModule';
import meteorEnvironment from './environments/meteorEnvironment';
import nodeEnvironment from './environments/nodeEnvironment';
import normalizePath from './normalizePath';
import version from './version';
import { validate, getDefaultConfig } from './configurationSchema.js';

const JSON_CONFIG_FILE = '.importjs.json';
const JS_CONFIG_FILES = ['.importjs.js', '.importjs.cjs', '.importjs.mjs'];

const DEFAULT_CONFIG = getDefaultConfig();

const DEPRECATED_CONFIGURATION_OPTIONS = [];

const ENVIRONMENTS = {
  node: nodeEnvironment,
  meteor: meteorEnvironment,
};

function checkConfiguration(config: Object): Array<string> {
  const result = validate(config);

  return result.messages;
}

function checkForDeprecatedConfiguration(config: Object): Array<string> {
  const messages = [];

  Object.keys(config).forEach((option: string) => {
    if (DEPRECATED_CONFIGURATION_OPTIONS.indexOf(option) !== -1) {
      messages.push(
        `Using ${option} to configure ImportJS is deprecated and ` +
          'will go away in a future version.',
      );
    }
  });

  return messages;
}

/**
 * Checks that the current version is bigger than the `minimumVersion`
 * defined in config.
 * @throws Error if current version is less than the `minimumVersion` defined
 * in config.
 */
function checkCurrentVersion(minimumVersion: string) {
  if (semver.gte(version(), minimumVersion)) {
    return;
  }

  throw Error(
    `The configuration file for this project requires version ${minimumVersion} or newer. You are using ${version()}.`,
  );
}

function mergedValue(values: Array<any>, key: string, options: Object): any {
  let mergedResult;
  for (let i = 0; i < values.length; i += 1) {
    let value = values[i];
    if (typeof value === 'function') {
      value = value(options);
    }
    // Prevent an endless loop of mergedValue calls
    // The mergableOptions key will get merged by skipping this check
    if (key !== 'mergableOptions') {
      const mergableOptions = options.config.get('mergableOptions');
      if (mergableOptions[key] !== true) {
        // This key shouldn't be merged
        return value;
      }
    }
    if (Array.isArray(value)) {
      mergedResult = (mergedResult || []).concat(value);
    } else if (typeof value === 'object') {
      mergedResult = { ...value, ...(mergedResult || {}) };
    } else {
      // Neither an object nor an array, so we just return the first value we
      // have.
      return value;
    }
  }
  return mergedResult;
}

/**
 * returns configuration from a JS file in home directory if it exists, or null
 */
function loadGlobalJsConfig(): ?Object {
  for (let i = 0; i < JS_CONFIG_FILES.length; i += 1) {
    const jsConfigFile = JS_CONFIG_FILES[i];
    const globalConfig = FileUtils.readJsFile(
      path.join(os.homedir(), jsConfigFile),
    );
    if (globalConfig) {
      return globalConfig;
    }
  }

  return null;
}

// Class that initializes configuration from a .importjs.js file
export default class Configuration {
  pathToCurrentFile: string;

  messages: Array<string>;

  configs: Array<Object>;

  workingDirectory: string;

  constructor(
    pathToCurrentFile: string,
    workingDirectory: string = process.cwd(),
  ) {
    this.workingDirectory = workingDirectory;
    this.pathToCurrentFile = normalizePath(pathToCurrentFile, workingDirectory);

    this.messages = [];
    this.configs = [];

    let userConfig;
    try {
      userConfig = this.loadUserConfig();
    } catch (error) {
      this.messages.push(
        `Unable to parse configuration file. Reason:\n${error.stack}`,
      );
    }

    if (userConfig) {
      this.configs.push(userConfig);
      this.messages.push(...checkConfiguration(userConfig));
      this.messages.push(...checkForDeprecatedConfiguration(userConfig));

      // Add configurations for the environments specified in the user config
      // file.
      // Don't use `this.get` because the config hasn't finished initalizing.
      // Use userConfig instead since it's the only one declared
      if (typeof userConfig.environments === 'function') {
        userConfig.environments = userConfig.environments({
          config: this,
          pathToCurrentFile: this.pathToCurrentFile,
        });
      }
      (userConfig.environments || []).forEach((environment: string) => {
        const envConfig = ENVIRONMENTS[environment];
        if (envConfig) {
          this.configs.push(envConfig);
        }
      });
    }

    this.configs.push(DEFAULT_CONFIG);

    checkCurrentVersion(this.get('minimumVersion'));
  }

  get(
    key: string,
    {
      pathToImportedModule,
      moduleName,
      importStatement,
    }: {
      pathToImportedModule?: string,
      moduleName?: string,
      importStatement?: string,
    } = {},
  ): any {
    const applyingConfigs = this.configs.filter((config: Object): boolean =>
      Object.prototype.hasOwnProperty.call(config, key),
    );

    return mergedValue(
      applyingConfigs.map((config: Object): any => config[key]),
      key,
      {
        pathToImportedModule,
        moduleName,
        config: this,
        pathToCurrentFile: this.pathToCurrentFile,
        importStatement,
      },
    );
  }

  loadUserConfig(): ?Object {
    return (
      this.loadLocalJsConfig() ||
      this.loadLocalJsonConfig() ||
      loadGlobalJsConfig()
    );
  }

  loadLocalJsConfig(): ?Object {
    for (let i = 0; i < JS_CONFIG_FILES.length; i += 1) {
      const jsConfigFile = JS_CONFIG_FILES[i];
      const jsConfig = FileUtils.readJsFile(
        path.join(this.workingDirectory, jsConfigFile),
      );

      if (jsConfig && Object.keys(jsConfig).length === 0) {
        // If you forget to use `module.exports`, the config object will be `{}`.
        // To prevent subtle errors from happening, we surface an error message to
        // the user.
        throw new Error(
          `Nothing exported from ${jsConfigFile}. You need to use \`module.exports\` to specify what gets exported from the file.`,
        );
      }

      if (jsConfig) {
        return jsConfig;
      }
    }

    return null;
  }

  loadLocalJsonConfig(): ?Object {
    const jsonConfig = FileUtils.readJsonFile(
      path.join(this.workingDirectory, JSON_CONFIG_FILE),
    );

    if (jsonConfig) {
      this.messages.push(
        'Using JSON to configure ImportJS is deprecated and will go away in a future version. Use an `.importjs.js` file instead.',
      );
      return jsonConfig;
    }

    return null;
  }

  resolveAlias(variableName: string): ?string {
    if (!has(this.get('aliases'), variableName)) {
      return null;
    }

    let importPath = this.get('aliases')[variableName];

    importPath = importPath.path || importPath; // path may be an object

    if (this.pathToCurrentFile !== './') {
      // aliases can have dynamic `{filename}` parts
      importPath = importPath.replace(
        /\{filename\}/,
        path.basename(
          this.pathToCurrentFile,
          path.extname(this.pathToCurrentFile),
        ),
      );
    }
    return importPath;
  }

  resolveNamedExports(variableName: string): ?JsModule {
    const allNamedExports = this.get('namedExports');
    const importPath = Object.keys(allNamedExports).find(
      (key: string): boolean =>
        allNamedExports[key].indexOf(variableName) !== -1,
    );

    if (!importPath) {
      return undefined;
    }

    const jsModule = new JsModule({
      importPath,
      hasNamedExports: true,
      variableName,
    });
    return jsModule;
  }
}