Source: angularJs/parser.js

'use strict';

/**
 * @module angularJs/parser
 */

var fs = require('fs');
var path = require('path');

var async = require('async');
var esprima = require('esprima');
var htmlMinifier = require('html-minifier-terser');

var ConfigExpression = process.requireApi('lib/angularJs/expressions/ConfigExpression.js');
var ElementExpression = process.requireApi('lib/angularJs/expressions/ElementExpression.js');
var expressionFactory = process.requireApi('lib/angularJs/expressions/expressionFactory.js');
var FilterExpression = process.requireApi('lib/angularJs/expressions/FilterExpression.js');
var InjectExpression = process.requireApi('lib/angularJs/expressions/InjectExpression.js');
var RouteExpression = process.requireApi('lib/angularJs/expressions/RouteExpression.js');
var fileSystem = process.requireApi('lib/fileSystem.js');
var utilApi = process.requireApi('lib/util.js');

/**
 * Fetches a script from a list of scripts.
 *
 * @method findScript
 * @param {Array} scripts The list of scripts
 * @param {String} property The script property used to identify the script to fetch
 * @param {String} value The expected value of the script property
 * @return {(Object|null)} The found script or null if not found
 * @private
 */
function findScript(scripts, property, value) {
  for (var i = 0; i < scripts.length; i++) {
    if ((Object.prototype.toString.call(scripts[i][property]) === '[object Array]' &&
        scripts[i][property].indexOf(value) > -1) ||
        (scripts[i][property] === value)
    ) {
      return scripts[i];
    }
  }

  return null;
}

/**
 * Browses a flat list of scripts to find a script longest dependency chains.
 *
 * Each script may have several dependencies, each dependency can also have several dependencies.
 * findLongestDependencyChains helps find the longest dependency chain of one of the script.
 * As the script may have several longest dependency chain, a list of chains is returned.
 *
 * A chain is an ordered list of script paths.
 *
 * This is recursive.
 *
 * @method findLongestDependencyChains
 * @param {Array} scripts The flat list of scripts with for each script:
 * @param {Array} scripts[].dependencies The list of dependency names of the script
 * @param {Array} scripts[].definitions The list of definition names of the script
 * @param {String} scripts[].path The script path
 * @param {Object} [script] The script to analyze (default to the first one of the list of scripts)
 * @param {Array} [modulesToIgnore] The list of module names to ignore to avoid circular dependencies
 * @return {Array} The longest dependency chains
 * @private
 */
function findLongestDependencyChains(scripts, script, modulesToIgnore) {
  var chains = [];

  if (!script) script = scripts[0];

  // Avoid circular dependencies
  if (modulesToIgnore && script.module && modulesToIgnore.indexOf(script.module) !== -1) return chains;

  // Get script dependencies
  if (script.dependencies && script.dependencies.length) {
    var longestChainLength;

    // Find dependency chains of the script
    script.dependencies.forEach(function(dependency) {
      var definitionScript = findScript(scripts, 'definitions', dependency);

      if (definitionScript)
        chains = chains.concat(findLongestDependencyChains(scripts, definitionScript, script.definitions));
    });

    if (chains.length > 0) {

      // Keep the longest chain(s)
      chains.sort(function(chain1, chain2) {
        // -1 : chain1 before chain2
        // 0 : nothing change
        // 1 : chain1 after chain2

        if (chain1.length > chain2.length)
          return -1;
        else if (chain1.length < chain2.length)
          return 1;
        else return 0;
      });

      longestChainLength = chains[0].length;

      chains = chains.filter(function(chain) {
        if (chain.length === longestChainLength) {
          chain.push(script.path);
          return true;
        }

        return false;
      });

      return chains;
    }
  }

  chains.push([script.path]);
  return chains;
}

/**
 * Retrieves CSS and JS files from tree of scripts in a flattened order.
 *
 * This is recursive.
 *
 * @method getTreeResources
 * @param {Object} node The node from where to start
 * @param {String} node.path The file path
 * @param {Array} node.styles The list of css / scss file paths
 * @return {module:angularJs/parser~getTreeResourcesReturnValue} The list of files by types
 * @private
 */
function getTreeResources(node) {
  var resources = {childrenCss: [], childrenJs: [], subChildrenCss: [], subChildrenJs: []};

  // Add css and js of node children then dedupe
  if (node.children) {
    node.children.forEach(function(subNode) {
      var subResources = getTreeResources(subNode);
      resources.childrenJs = utilApi.joinArray(resources.childrenJs, [subNode.path]);
      resources.childrenCss = utilApi.joinArray(resources.childrenCss, subNode.styles);
      resources.subChildrenCss = utilApi.joinArray(resources.subChildrenCss, subResources.childrenCss);
      resources.subChildrenJs = utilApi.joinArray(resources.subChildrenJs, subResources.childrenJs);
      resources.subChildrenCss = utilApi.joinArray(resources.subChildrenCss, subResources.subChildrenCss);
      resources.subChildrenJs = utilApi.joinArray(resources.subChildrenJs, subResources.subChildrenJs);
    });
  } else {

    // Add current node css and js then dedupe
    resources.childrenCss = utilApi.joinArray(resources.childrenCss, node.styles);
    resources.childrenJs = utilApi.joinArray(resources.childrenJs, [node.path]);

  }

  return resources;
}

/**
 * Builds the dependencies tree.
 *
 * @method buildTree
 * @static
 * @param {Array} scripts The flat list of scripts with for each script:
 * @param {Array} dependencies The list of dependency names of the script
 * @param {Array} definitions The list of definition names of the script
 * @param {String} path The script path
 * @return {Array} The list of scripts with their dependencies
 */
module.exports.buildTree = function(scripts) {
  var chains = [];
  var tree = {
    children: []
  };
  var currentTreeNode = tree;

  // Get the longest dependency chain for each script with the highest dependency
  // as the first element of the chain
  scripts.forEach(function(script) {
    chains = chains.concat(findLongestDependencyChains(scripts, script));
  });

  // Sort chains by length with longest chains first
  chains.sort(function(chain1, chain2) {
    // -1 : chain1 before chain2
    // 0 : nothing change
    // 1 : chain1 after chain2

    if (chain1.length > chain2.length)
      return -1;
    else if (chain1.length < chain2.length)
      return 1;
    else return 0;
  });

  // Add each chain to the tree
  chains.forEach(function(chain) {

    // Add each element of the chain as a child of its parent
    chain.forEach(function(scriptPath) {
      var currentScript = findScript(scripts, 'path', scriptPath);
      var alreadyExists = false;

      if (!currentTreeNode.children)
        currentTreeNode.children = [];

      // Check if current script does not exist in node children
      for (var i = 0; i < currentTreeNode.children.length; i++) {
        if (currentTreeNode.children[i].path === currentScript.path) {
          alreadyExists = true;
          break;
        }
      }

      // Add script to the tree
      if (!alreadyExists)
        currentTreeNode.children.push(currentScript);

      currentTreeNode = currentScript;
    });

    currentTreeNode = tree;
  });

  return tree;
};

/**
 * Finds out AngularJS definitions and dependencies for the given content.
 *
 * This is recursive.
 *
 * The following JavaScript expressions are used to identify definitions:
 *   - angular.module('moduleName', [])
 *   - angular.module('moduleName').component()
 *   - angular.module('moduleName').directive()
 *   - angular.module('moduleName').controller()
 *   - angular.module('moduleName').factory()
 *   - angular.module('moduleName').service()
 *   - angular.module('moduleName').constant()
 *   - angular.module('moduleName').service()
 *   - angular.module('moduleName').decorator()
 *   - angular.module('moduleName').filter()
 *   - angular.module('moduleName').config()
 *   - angular.module('moduleName').run()
 *
 * The following JavaScript expressions are used to identify dependencies:
 *   - MyAngularJsElement.$inject = ['Dependency1', 'Dependency2'];
 *   - angular.module('moduleName', ['DependencyModule'])
 *
 * The following JavaScript expressions are used to identify associated modules:
 *   - angular.module('moduleName')
 *
 * @method findDependencies
 * @static
 * @param {Object} expression The JavaScript expression to analyze
 */
module.exports.findDependencies = function(jsExpression) {
  var self = this;
  var expression;
  var results = {
    definitions: [],
    dependencies: [],
    module: null
  };
  if (!jsExpression) return results;

  /**
   * Merges results from sub expressions into results for the current expression.
   *
   * @param {Object} newResults Sub expressions results
   * @param {Array} [newResults.definitions] The list of definitions in sub expression
   * @param {Array} [newResults.dependencies] The list of dependencies in sub expression
   * @param {String} [newResults.module] The name of the module the definitions belong to
   */
  function mergeResults(newResults) {
    if (newResults.definitions)
      results.definitions = utilApi.joinArray(results.definitions, newResults.definitions);

    if (newResults.dependencies)
      results.dependencies = utilApi.joinArray(results.dependencies, newResults.dependencies);

    if (newResults.module)
      results.module = newResults.module;
  }

  if (jsExpression.type === 'CallExpression' && jsExpression.callee.type === 'MemberExpression') {
    if (Object.values(ElementExpression.ELEMENTS).indexOf(jsExpression.callee.property.name) > -1) {
      expression = expressionFactory.getElementExpression(jsExpression.callee.property.name, jsExpression);
      if (expression.isValid()) {
        var newResults = {
          definitions: expression.isDefinition() ? [expression.getName()] : [],
          dependencies: expression.getDependencies()
        };

        if (!expression.isDefinition() && expression.getElementType() === ElementExpression.ELEMENTS.MODULE)
          newResults.module = expression.getName();

        mergeResults(newResults);
      }
    } else if (jsExpression.callee.property.name === 'config' || jsExpression.callee.property.name === 'run') {
      expression = new ConfigExpression(jsExpression);

      if (expression.isValid()) {
        mergeResults({
          dependencies: expression.getDependencies()
        });
      }
    } else if (jsExpression.callee.property.name === 'when') {
      expression = new RouteExpression(jsExpression);

      if (expression.isValid()) {
        mergeResults({
          definitions: expression.getDefinitions(),
          dependencies: expression.getDependencies()
        });
      }
    }
  }

  if (jsExpression.type === 'AssignmentExpression' &&
      jsExpression.left.property &&
      jsExpression.left.property.name === '$inject'
  ) {
    expression = new InjectExpression(jsExpression);

    if (expression.isValid()) {
      mergeResults({
        dependencies: expression.getDependencies()
      });
    }
  }

  if (jsExpression.type === 'CallExpression' && jsExpression.callee.name === '$filter') {
    expression = new FilterExpression(jsExpression);

    if (expression.isValid()) {
      mergeResults({
        dependencies: [expression.getDependency()]
      });
    }
  }

  if (Object.prototype.toString.call(jsExpression) === '[object Object]') {
    for (var property in jsExpression)
      mergeResults(self.findDependencies(jsExpression[property]));
  } else if (Object.prototype.toString.call(jsExpression) === '[object Array]') {
    jsExpression.forEach(function(value) {
      mergeResults(self.findDependencies(value));
    });
  }

  return results;
};

/**
 * Generates an AngularJS file with all given templates directly loaded into $templateCache.
 *
 * @method generateTemplatesCache
 * @static
 * @param {Array} templatesPath The list of templates files paths to add to cache
 * @param {String} outputPath The path of the resulting AngularJS file
 * @param {String} moduleName The AngularJS module to use to load templates into $templateCache
 * @param {String} [prefix] A prefix to apply to each template name
 * @param {callback} callback Function to call when its done
 */
module.exports.generateTemplatesCache = function(templatesPaths, outputPath, moduleName, prefix, callback) {
  if (!moduleName) return callback(new TypeError('moduleName should be a string'));
  var script = '  \'use strict\';\n';

  var minifyFunctions = [];
  var minify = function(templatePath) {
    return function(callback) {
      fs.readFile(templatePath, function(readError, templateContent) {
        if (readError) return callback(readError);
        var minified = false;
        htmlMinifier.minify(templateContent.toString(), {
          collapseBooleanAttributes: true,
          collapseWhitespace: true,
          removeComments: true,
          removeEmptyAttributes: true,
          removeRedundantAttributes: true,
          removeScriptTypeAttributes: true,
          removeStyleLinkTypeAttributes: true
        }).then(function(minifiedTemplate) {
          minified = true;
          var parsedPath = path.parse(templatePath);
          callback(null, {
            name: (prefix ? prefix : '') + parsedPath.name + parsedPath.ext,
            template: minifiedTemplate
          });
        }).catch(function(minifyError) {
          if (!minified) callback(minifyError);
        });
      });
    };
  };
  var escapeTemplate = function(template) {
    return template.split(/^/gm).map(function(line) {
      var quote = '\'';

      line = line.replace(/\\/g, '\\\\');
      line = line.replace(/\n/g, '\\n');
      line = line.replace(/\r/g, '\\r');
      var quoteRegExp = new RegExp(quote, 'g');
      line = line.replace(quoteRegExp, '\\' + quote);

      return quote + line + quote;
    }).join(' +\n    ') || '""';
  };

  for (var templatePath of templatesPaths) {
    minifyFunctions.push(minify(templatePath));
  }

  async.parallel(minifyFunctions, function(error, minifiedTemplates) {
    if (error) return callback(error);

    for (var minifiedTemplate of minifiedTemplates) {
      script += '\n  $templateCache.put(\'' + minifiedTemplate.name + '\',\n    ' +
        escapeTemplate(minifiedTemplate.template) +
        '\n  );\n';
    }

    fileSystem.mkdir(path.dirname(outputPath), function(mkdirError) {
      if (mkdirError) return callback(mkdirError);

      fs.writeFile(
        outputPath,
        'angular.module(\'' + moduleName + '\')' +
        '.run([\'$templateCache\', function($templateCache) {\n' +
        script +
        '\n}]);\n',
        callback
      );
    });

  });
};

/**
 * Retrieves CSS and JS files from tree of scripts in a flattened order.
 *
 * @method getResources
 * @static
 * @param {Object} tree The tree of resources
 * @return {module:angularJs/parser~getResourcesReturnValue} The list of files by types
 */
module.exports.getResources = function(tree) {
  var resources = getTreeResources(tree);
  return {
    css: utilApi.joinArray(resources.childrenCss, resources.subChildrenCss),
    js: utilApi.joinArray(resources.childrenJs, resources.subChildrenJs)
  };
};

/**
 * Orders a list of components JavaScript and SCSS files.
 *
 * JavaScript and SCSS files are ordered in the way they should be laoded.
 *
 * @param {Array} sourcesFilesPaths The list of JavaScript and SCSS sources to order
 * @return {module:angularJs/parser~orderSourcesCallback} Function to call when its done
 */
module.exports.orderSources = function(sourcesFilesPaths, callback) {
  var self = this;
  var analyzeAsyncFunctions = [];
  var scripts = [];
  var styles = [];

  sourcesFilesPaths.forEach(function(sourceFilePath) {
    var pathDescriptor = path.parse(sourceFilePath);

    if (pathDescriptor.ext === '.js') {

      // JavaScript files
      analyzeAsyncFunctions.push(function(callback) {
        fs.readFile(sourceFilePath, function(error, content) {
          var contentString = content.toString();

          // Try to find parents of the source
          var programExpressions = esprima.parseScript(contentString);
          var script = self.findDependencies(programExpressions);
          script.path = sourceFilePath;
          script.styles = [];
          scripts.push(script);
          callback(error);
        });
      });
    } else if (pathDescriptor.ext === '.css' ||
               pathDescriptor.ext === '.scss'
    ) {

      // CSS / SCSS files
      styles.push(sourceFilePath);

    }
  });

  async.parallel(analyzeAsyncFunctions, function(error) {
    if (error) return callback(error);

    // Associate css files with scripts
    styles.forEach(function(style) {
      for (var i = 0; i < scripts.length; i++) {
        var originalScriptPath = scripts[i].path;
        var originalStylePath = style;
        if (path.dirname(originalScriptPath) === path.dirname(originalStylePath)) {
          scripts[i].styles.push(style);
          return;
        }
      }
    });

    callback(null, self.getResources(self.buildTree(scripts)));
  });
};

/**
 * @typedef {Object} module:angularJs/parser~getTreeResourcesReturnValue
 * @property {Array} childrenCss The list of children CSS files
 * @property {Array} childrenJs The list of children CSS files
 * @property {Array} subChildrenCss The list of sub children CSS files
 * @property {Array} subChildrenJs The list of sub children JS files
 */

/**
 * @typedef {Object} module:angularJs/parser~getResourcesReturnValue
 * @property {Array} css The list of css files in the right order
 * @property {Array} js The list of js files in the right order
 */

/**
 * @typedef {Object} module:angularJs/parser~orderSourcesCallback
 * @param {(Error|null)} error The error if an error occurred, null otherwise
 * @param {(Object|Undefined)} sources JavaScript and SCSS sources
 * @param {Array} sources.js JavaScript sources
 * @param {Array} sources.css SCSS sources
 */