utils.js

const path = require('path');
const fs = require('fs-extra');
const pkgJson = require('../package.json');
const { getChalk } = require('./esm');

/**
 * @typedef {import('path').ParsedPath} ParsedPath
 */

/**
 * @typedef {Object} AbsPathInfo
 * @property {string}  path       The complete, absolute, path to the file/folder.
 * @property {boolean} isFile     Whether or not the path is for a file.
 * @property {?string} extension  If the path is for a file, this will be its extension.
 */

/**
 * Logs messages prefixed with the name of the project and with a specified color.
 * Yes, this is a proxy-like function for `console.log` with `chalk`.
 *
 * @param {string}   color  The color from `chalk` that should be used.
 * @param {string[]} args   The list of messages to log.
 */
const log = (color, ...args) => {
  const chalk = getChalk();
  // eslint-disable-next-line no-console
  console.log(
    ...[`[${pkgJson.name}]`, ...args].map((item) => chalk.default[color](item)),
  );
};
/**
 * Given a list of file names and a directory, the function will try find the first file
 * that exists.
 *
 * @param {string[]} list       The list of files to test.
 * @param {string}   directory  The base directory where the paths will be tested.
 * @returns {Promise<?string>}
 */
const findFile = async (list, directory) => {
  let result;
  for (let i = 0; i < list.length; i++) {
    const test = path.join(directory, list[i]);
    // eslint-disable-next-line no-await-in-loop
    const exists = await fs.pathExists(test);
    if (exists) {
      result = test;
      break;
    }
  }

  return result || null;
};
/**
 * Given a list of file names and a directory, the function will try to find the first
 * file that exists.
 *
 * @param {string[]} list       The list of files to test.
 * @param {string}   directory  The base directory where the paths will be tested.
 * @returns {?string}
 */
const findFileSync = (list, directory) => {
  let result;
  for (let i = 0; i < list.length; i++) {
    const test = path.join(directory, list[i]);
    const exists = fs.pathExistsSync(test);
    if (exists) {
      result = test;
      break;
    }
  }

  return result || null;
};
/**
 * A special version of `path.parse` that validates if the file extension is `.js` or
 * `.mjs`, and if is not, in case it's something like `.config` or `.service`, it moves
 * the extension to the `name` and leaves `ext` empty.
 *
 * @param {string} filepath  The file path to parse.
 * @returns {ParsedPath}
 * @ignore
 */
const parseJSPath = (filepath) => {
  const result = path.parse(filepath);
  if (result.ext && !result.ext.match(/\.m?js$/i)) {
    result.name = `${result.name}${result.ext}`;
    result.ext = '';
  }

  return result;
};
/**
 * Tries to find the extension for a file import path.
 *
 * @param {string} absPath  The generated absolute path for the file.
 * @returns {Promise<?string>}
 * @ignore
 */
const findFileExtension = async (absPath) => {
  const info = parseJSPath(absPath);
  const name = info.name.replace(/\.$/, '');
  const file = await findFile([`${name}.mjs`, `${name}.js`], info.dir);

  return file ? path.parse(file).ext : null;
};
/**
 * Tries to find the extension for a file import path.
 *
 * @param {string} absPath  The generated absolute path for the file.
 * @returns {?string}
 * @ignore
 */
const findFileExtensionSync = (absPath) => {
  const info = parseJSPath(absPath);
  const name = info.name.replace(/\.$/, '');
  const file = findFileSync([`${name}.mjs`, `${name}.js`], info.dir);

  return file ? path.parse(file).ext : null;
};
/**
 * Given an the aboslute path for an import/require statement, the method will validate if
 * its for a folder, a file, and if it's for a file, it will complete its extension in
 * case it's missing.
 *
 * @param {string} absPath  The absolute path for the resource.
 * @returns {Promise<?AbsPathInfo>}
 */
const getAbsPathInfo = async (absPath) => {
  const info = parseJSPath(absPath);
  let result;
  if (info.ext) {
    result = {
      path: absPath,
      isFile: true,
      extension: info.ext,
    };
  } else {
    const exists = await fs.pathExists(absPath);
    if (exists) {
      result = {
        path: absPath.replace(/\/$/, ''),
        isFile: false,
        extension: null,
      };
    } else {
      const extension = await findFileExtension(absPath);
      if (extension) {
        result = {
          path: `${absPath}${extension}`,
          isFile: true,
          extension,
        };
      } else {
        result = null;
      }
    }
  }

  return result;
};
/**
 * Given an the aboslute path for an import/require statement, the method will validate if
 * its for a folder, a file, and if it's for a file, it will complete its extension in
 * case it's missing.
 *
 * @param {string} absPath  The absolute path for the resource.
 * @returns {?AbsPathInfo}
 */
const getAbsPathInfoSync = (absPath) => {
  const info = parseJSPath(absPath);
  let result;
  if (info.ext) {
    result = {
      path: absPath,
      isFile: true,
      extension: info.ext,
    };
  } else {
    const exists = fs.pathExistsSync(absPath);
    if (exists) {
      result = {
        path: absPath.replace(/\/$/, ''),
        isFile: false,
        extension: null,
      };
    } else {
      const extension = findFileExtensionSync(absPath);
      if (extension) {
        result = {
          path: `${absPath}${extension}`,
          isFile: true,
          extension,
        };
      } else {
        result = null;
      }
    }
  }

  return result;
};
/**
 * This function is just a proxy for `require` and it only exists to make testing the tool
 * easier: the test for this is just that returns the same as `require`, but on the files
 * that use it, with mocking this funcion is enough and there won't be any need for
 * `resetModules`.
 *
 * @param {string} modulePath  The path to the module to be required.
 * @returns {Object}
 */
const requireModule = (modulePath) => {
  // eslint-disable-next-line global-require, import/no-dynamic-require
  const result = require(modulePath);
  // And this variable only exists to avoid issues between the JSDoc block and ESLint.
  return result;
};

module.exports.log = log;
module.exports.findFile = findFile;
module.exports.findFileSync = findFileSync;
module.exports.getAbsPathInfo = getAbsPathInfo;
module.exports.getAbsPathInfoSync = getAbsPathInfoSync;
module.exports.requireModule = requireModule;