app.js

'use strict';

/**
 * Get basic application data. Such as components, actions, etc.
 * @module
**/

const _ = require('lodash');
const mPath = require('path');
const shell = require('shelljs');
const traverse = require('babel-traverse').default;
const vio = require('./vio');
const utils = require('./utils');
const refactor = require('./refactor');

const propsCache = {};
const depsCache = {};
function getRekitProps(file) {
  if (propsCache[file] && propsCache[file].content === vio.getContent(file)) {
    return propsCache[file].props;
  }
  const ast = vio.getAst(file);
  const ff = {}; // File features

  traverse(ast, {
    ImportDeclaration(path) {
      switch (path.node.source.value) {
        case 'react':
          ff.importReact = true;
          break;
        case 'redux':
          ff.importRedux = true;
          break;
        case 'react-redux':
          ff.importReactRedux = true;
          break;
        case './constants':
          ff.importConstant = true;
          ff.importMultipleConstants = path.node.specifiers.length > 3;
          break;
        default:
          break;
      }
    },
    ClassDeclaration(path) {
      if (
        path.node.superClass
        && path.node.body.body.some(n => n.type === 'ClassMethod' && n.key.name === 'render')
      ) {
        ff.hasClassAndRenderMethod = true;
      }
    },
    CallExpression(path) {
      if (path.node.callee.name === 'connect') {
        ff.connectCall = true;
      }
    },
    ExportNamedDeclaration(path) {
      if (_.get(path, 'node.declaration.id.name') === 'reducer') {
        ff.exportReducer = true;
      }
    }
  });
  const props = {
    component: ff.importReact && ff.hasClassAndRenderMethod && {
      connectToStore: ff.connectCall,
    },
    action: ff.exportReducer && ff.importConstant && {
      isAsync: ff.importMultipleConstants,
    }
  };

  if (props.component) props.type = 'component';
  else if (props.action) props.type = 'action';
  else props.type = 'misc';

  propsCache[file] = {
    content: vio.getContent(file),
    props,
  };
  return props;
}

/**
 * Get all features names (folder names).
 */
function getFeatures() {
  return _.toArray(shell.ls(utils.joinPath(utils.getProjectRoot(), 'src/features')));
}

/**
 * Get root route rules defined in src/common/routeConfig.js.
 */
function getRootRoutePath() {
  const targetPath = utils.mapSrcFile('common/routeConfig.js');
  const ast = vio.getAst(targetPath);
  let rootPath = '';
  traverse(ast, {
    ObjectExpression(path) {
      const node = path.node;
      const props = node.properties;
      if (!props.length) return;
      const obj = {};
      props.forEach((p) => {
        if (_.has(p, 'key.name') && !p.computed) {
          obj[p.key.name] = p;
        }
      });
      if (obj.path && obj.childRoutes && !rootPath) {
        rootPath = _.get(obj.path, 'value.value');
      }
    }
  });
  return rootPath;
}

/**
 * Get route rules defined in a feature.
 * @param {string} feature - The feature name.
 */
function getFeatureRoutes(feature) {
  const targetPath = utils.mapFeatureFile(feature, 'route.js');
  const ast = vio.getAst(targetPath);
  const arr = [];
  let rootPath = '';
  let indexRoute = null;

  traverse(ast, {
    ObjectExpression(path) {
      const node = path.node;
      const props = node.properties;
      if (!props.length) return;
      const obj = {};
      props.forEach((p) => {
        if (_.has(p, 'key.name') && !p.computed) {
          obj[p.key.name] = p;
        }
      });
      if (obj.path && obj.component) {
        // in a route config, if an object expression has both 'path' and 'component' property, then it's a route config
        arr.push({
          path: _.get(obj.path, 'value.value'), // only string literal supported
          component: _.get(obj.component, 'value.name'), // only identifier supported
          isIndex: !!obj.isIndex && _.get(obj.isIndex, 'value.value'), // suppose to be boolean
          node: {
            start: node.start,
            end: node.end,
          },
        });
      }
      if (obj.isIndex && obj.component && !indexRoute) {
        // only find the first index route
        indexRoute = {
          component: _.get(obj.component, 'value.name'),
        };
      }
      if (obj.path && obj.childRoutes && !rootPath) {
        rootPath = _.get(obj.path, 'value.value');
        if (!rootPath) rootPath = '$none'; // only find the first rootPath
      }
    }
  });
  const prjRootPath = getRootRoutePath();
  if (rootPath === '$none') rootPath = prjRootPath;
  else if (!/^\//.test(rootPath)) rootPath = prjRootPath + '/' + rootPath;
  rootPath = rootPath.replace(/\/+/, '/');
  arr.forEach((item) => {
    if (!/^\//.test(item.path)) {
      item.path = (rootPath + '/' + item.path).replace(/\/+/, '/');
    }
  });
  if (indexRoute) {
    indexRoute.path = rootPath;
    arr.unshift(indexRoute);
  }
  return arr;
}

/**
 * Get feature's components, actions, misc files and their dependencies.
**/
function getFeatureStructure(feature) {
  const dir = utils.joinPath(utils.getProjectRoot(), 'src/features', feature);
  const noneMisc = {};

  const components = shell.ls(dir + '/*.js').map((file) => {
    const props = getRekitProps(file);
    if (props && props.component) {
      noneMisc[file] = true;
      noneMisc[file.replace('.js', '.less')] = true;
      noneMisc[file.replace('.js', '.scss')] = true;
      return Object.assign({
        feature,
        name: mPath.basename(file).replace('.js', ''),
        type: 'component',
        file,
      }, props.component);
    }
    return null;
  }).filter(item => !!item).sort((a, b) => a.name.localeCompare(b.name));

  const actions = shell.ls(dir + '/redux/*.js').map((file) => {
    const props = getRekitProps(file);
    if (props && props.action) {
      noneMisc[file] = true;
      return Object.assign({
        feature,
        name: mPath.basename(file).replace('.js', ''),
        type: 'action',
        file,
      }, props.action);
    }
    return null;
  }).filter(item => !!item).sort((a, b) => a.name.localeCompare(b.name));

  function getMiscFiles(root) {
    const arr = [];
    shell.ls(root).forEach((file) => {
      const fullPath = utils.joinPath(root, file);
      if (shell.test('-d', fullPath)) {
        // is directory
        arr.push({
          feature,
          name: mPath.basename(fullPath),
          type: 'misc',
          file: fullPath,
          children: getMiscFiles(fullPath),
        });
      } else if (!noneMisc[fullPath]) {
        arr.push({
          feature,
          type: 'misc',
          name: mPath.basename(fullPath),
          file: fullPath,
        });
      }
    });
    return arr.sort((a, b) => {
      if (a.children && !b.children) return -1;
      if (!a.children && b.children) return 1;
      return a.name.localeCompare(b.name);
    });
  }

  return {
    actions,
    components,
    routes: getFeatureRoutes(feature),
    misc: getMiscFiles(dir),
  };
}

// Check a file if it's 'feature/redux/actions.js'.
function isActionEntry(modulePath) {
  return /src\/features\/[^/]+\/redux\/actions\.js$/.test(modulePath);
}

// Check a file if it's 'feature/index.js'
function isFeatureIndex(modulePath) {
  return /src\/features\/[^/]+(\/|\/index)?$/.test(modulePath);
}

// Check a file if it's 'feature/redux/constants.js'
function isConstantEntry(modulePath) {
  return /src\/features\/[^/]+\/redux\/constants\.js$/.test(modulePath);
}

function getEntryData(filePath) {
  // Summary:
  //  Get entry files content such as actions.js, index.js where usually define 'export { aaa, bbb } from 'xxx';

  const ast = vio.getAst(filePath);
  const feature = utils.getFeatureName(filePath); // many be empty
  const data = {
    file: filePath,
    feature,
    bySource: {},
    exported: {}
  };
  traverse(ast, {
    ExportNamedDeclaration(path) {
      const node = path.node;
      if (!node.source || !node.source.value) return;
      const sourceFile = `${refactor.resolveModulePath(filePath, node.source.value)}.js`; // from which file
      const specifiers = {};
      node.specifiers.forEach((specifier) => {
        specifiers[specifier.exported.name] = specifier.local && specifier.local.name || true;
        data.exported[specifier.exported.name] = sourceFile;
      });
      data.bySource[sourceFile] = specifiers;
    },
  });
  return data;
}

/**
 * Get dependencies of a module by path.
 * @param {string} modulePath - The full path of the module.
 * @alias module:app.getDeps
**/
function getDeps(filePath) {
  // Summary:
  //   Get dependencies of a module

  if (depsCache[filePath] && depsCache[filePath].content === vio.getContent(filePath)) {
    return depsCache[filePath].deps;
  }

  const ast = vio.getAst(filePath);

  const deps = {
    actions: [],
    components: [],
    misc: [],
    constants: [],
  };

  const namespaceActions = {}; // import * as xxx from 'actions';
  const namespaceIndex = {}; // import * as xxx from 'feature';

  function pushDep(type, data) {
    // Be sure no duplicated deps
    const exist = _.find(deps[type], { feature: data.feature, name: data.name });
    if (!exist) {
      deps.actions.push(data);
    }
  }

  const depFiles = [];

  traverse(ast, {
    ExportNamedDeclaration(path) {
      const depModule = _.get(path, 'node.source.value');
      if (!depModule || !refactor.isLocalModule(depModule)) return;
      const resolvedPath = refactor.resolveModulePath(filePath, depModule);
      // if (!isLocalModule(depModule)) return;
      const fullPath = resolvedPath + '.js';
      if (!shell.test('-e', fullPath)) return;  // only depends on js modules, no json or other support
      depFiles.push({
        name: mPath.basename(resolvedPath),
        file: fullPath,
      });
    },
    CallExpression(path) {
      if (_.get(path, 'node.callee.type') !== 'Import') return;
      const source = _.get(path, 'node.arguments[0].value');
      if (!source) return;
      const resolvedPath = refactor.resolveModulePath(filePath, source);
      const fullPath = resolvedPath + '.js';
      if (!shell.test('-e', fullPath)) return;  // only depends on js modules, no json or other support
      depFiles.push({
        name: mPath.basename(resolvedPath),
        file: fullPath,
        dynamic: true,
      });
    },
    ImportDeclaration(path) {
      const node = path.node;
      const depModule = node.source.value;
      const resolvedPath = refactor.resolveModulePath(filePath, depModule);
      // Only show deps of local modules
      if (!refactor.isLocalModule(depModule)) return;
      if (isFeatureIndex(resolvedPath)) {
        // Import from feature index
        const indexFile = resolvedPath + '.js';
        // if (!/index$/.test(indexFile)) {
        //   indexFile = utils.joinPath(resolvedPath, 'index');
        // }
        // indexFile += '.js';

        const indexEntry = getEntryData(indexFile);
        node.specifiers.forEach((specifier) => {
          if (specifier.type === 'ImportNamespaceSpecifier') {
            namespaceIndex[specifier.local.name] = indexEntry;
            return;
          }

          const importedName = specifier.imported.name;
          if (!indexEntry.exported[importedName]) {
            utils.warn(`Warning: can't find '${importedName}' from '${indexFile}'`);
            return;
          }

          depFiles.push({
            name: importedName,
            file: indexEntry.exported[importedName],
          });
        });
        return;
      }

      const fullPath = resolvedPath + '.js';
      if (!shell.test('-e', fullPath)) return;  // only depends on js modules, no json or other support

      // Import from actions
      if (isActionEntry(fullPath)) {
        const actionEntry = getEntryData(fullPath);// getActionEntry(utils.getFeatureName(fullPath));

        node.specifiers.forEach((specifier) => {
          if (specifier.type === 'ImportNamespaceSpecifier') {
            namespaceActions[specifier.local.name] = actionEntry;
            return;
          }

          const importedName = specifier.imported.name;
          if (!actionEntry.exported[importedName]) {
            utils.warn(`Warning: can't find '${importedName}' from '${fullPath}'`);
            return;
          }

          pushDep('actions', {
            feature: actionEntry.feature,
            type: 'action',
            name: importedName,
            file: actionEntry.exported[importedName],
          });
        });
        return;
      }

      if (isConstantEntry(fullPath)) {
        node.specifiers.forEach((specifier) => {
          deps.constants.push({
            name: specifier.imported.name,
            feature: utils.getFeatureName(fullPath),
            file: fullPath,
            type: 'constant',
          });
        });
        return;
      }

      depFiles.push({
        name: mPath.basename(resolvedPath),
        file: resolvedPath + '.js',
      });
    },

    MemberExpression(path) {
      // Find actions imported by NamespaceImport
      const node = path.node;
      const objName = _.get(node, 'object.property.name') || _.get(node, 'object.name'); // this.props.'actions'.fetchNavTree
      const propName = _.get(node, 'property.name'); // this.props.actions.'fetchNavTree'
      if (!objName || !propName) return;
      if (_.has(namespaceActions, objName)) {
        const actionEntry = namespaceActions[objName];
        if (!actionEntry.exported[propName]) return;

        pushDep('actions', {
          feature: actionEntry.feature,
          type: 'action',
          name: propName,
          file: actionEntry.exported[propName],
        });
      } else if (_.has(namespaceIndex, objName)) {
        const indexEntry = namespaceIndex[objName];
        if (!indexEntry.exported[propName]) return;
        depFiles.push({
          name: propName,
          file: indexEntry.exported[propName],
        });
      }
    },
  });

  depFiles.forEach((item) => {
    const props = getRekitProps(item.file);
    // Other files
    if (props.component) {
      const feature = utils.getFeatureName(item.file);
      if (feature && !_.find(deps.component, { feature, name: item.name })) {
        deps.components.push({
          feature,
          type: 'component',
          name: item.name,
          file: item.file,
        });
      }
    } else {
      deps.misc.push({
        feature: utils.getFeatureName(item.file) || null,
        type: 'misc',
        name: mPath.basename(item.file),
        file: item.file,
      });
    }
  });

  depsCache[filePath] = {
    content: vio.getContent(filePath),
    deps,
  };
  return deps;
}

/**
 * Get src files excepts features of a Rekit project.
**/
function getSrcFiles(dir) {
  // Summary
  //  Get files under src exclues features folder
  const prjRoot = utils.getProjectRoot();
  if (!dir) dir = utils.joinPath(prjRoot, 'src');

  return _.toArray(shell.ls(dir))
    .filter(file => utils.joinPath(prjRoot, 'src/features') !== utils.joinPath(dir, file)) // exclude features folder
    .map((file) => {
      file = utils.joinPath(dir, file);
      if (shell.test('-d', file)) {
        return {
          name: mPath.basename(file),
          type: 'misc',
          feature: null,
          file,
          children: getSrcFiles(file),
        };
      }
      let rekitProps = {};
      let deps = null;
      if (/\.js$/.test(file)) {
        rekitProps = getRekitProps(file);
        deps = getDeps(file);
      }
      return {
        name: mPath.basename(file),
        type: rekitProps.component ? 'component' : 'misc',
        feature: null,
        deps,
        file,
      };
    })
    .sort((a, b) => {
      if (a.children && !b.children) return -1;
      else if (!a.children && b.children) return 1;
      return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
    });
}

module.exports = {
  getRekitProps,
  getFeatures,
  getFeatureStructure,
  getDeps,
  getSrcFiles,
};