const babelTypes = require('@babel/types');
/**
* Parses class methods and functions in order to detect the use of an _"inject
* directive"_ and replace it with a static property.
* This class works as a helper for a Babel plugin.
*
* @example
*
* // Input
* class MyService {
* constructor(depOne, depTwo) {
* 'inject';
* ...
* }
* }
* // Output
* class MyService {
* constructor(depOne, depTwo) {
* 'inject';
* ...
* }
* }
* MyService.inject = ['depOne', 'depTwo']
*
*/
class InjectDirectiveParser {
/**
* @param {Path} file The information of the File Babel is processing.
*/
constructor(file) {
/**
* A dictionary with the parser options.
*
* @type {Object}
* @property {string} directive The name of the directive it should find in order to
* apply the transformation.
* @property {string} property The name of the property where the dependencies will
* be added.
* @access protected
* @ignore
*/
this._options = this._parseOptions(file.opts);
/**
* The list of {@link Path} elements that were added via the _"parse methods"_. Once
* {@link InjectDirectiveParser#transform} gets called, they will be processed and the
* transformation applied.
*
* @type {Array}
* @access protected
* @ignore
*/
this._paths = [];
}
/**
* This is called from a {@link ParserCallback} when the object being processed is a
* class method.
*
* @param {Path} path The information of the object being processed.
*/
parseClassMethod(path) {
// Only check for constructor methods that have the directive.
if (path.node.kind === 'constructor' && this._hasDirective(path)) {
// Find the class declaration/expression for that constructor and add it to the list.
path.getAncestry().some((ancestor) => {
let stop = false;
if (ancestor.isClassDeclaration() || ancestor.isClassExpression()) {
stop = true;
this._addPath(ancestor);
}
return stop;
});
}
}
/**
* This is called from a {@link ParserCallback} when the object being processed is a
* function expression/declaration.
*
* @param {Path} path The information of the object being processed.
*/
parseFunction(path) {
// Check if it has the directive.
if (this._hasDirective(path)) {
// If it's a function declaration, add it to the list.
if (babelTypes.isFunctionDeclaration(path.node)) {
this._addPath(path);
} else if (babelTypes.isVariableDeclarator(path.parent)) {
/**
* otherwise, if it's an expression (being declared through a variable), add the parent
* path.
*/
this._addPath(path.parentPath);
}
}
}
/**
* This is called from {@link ProgramVisitorFinish}, it takes all the parsed elements and
* processes them in order to apply the transformations.
*/
transform() {
this._paths.forEach((path) => this._transformPath(path));
}
/**
* Adds a path to the list that will be processed, after checking that is not already
* there.
*
* @param {Path} path The path to add.
* @access protected
* @ignore
*/
_addPath(path) {
if (!this._pathExists(path)) {
this._paths.push(path);
}
}
/**
* Adds the property with the dependencies after an specific {@link Path}.
*
* @param {Array} params The list of parameters.
* @param {Path} path The reference {@link Path}.
* @param {string} name The name of the function/method/variable _"owner"_ of the
* property.
* @access protected
* @ignore
*/
_addPropertyAfterPath(params, path, name) {
// eslint-disable-next-line no-param-reassign
path.node.trailingComments = [];
path.parentPath.scope.crawl();
path.insertAfter(this._createPropertyExpression(name, params));
}
/**
* Tries to add the property with the dependencies after an specific {@link Path}. By
* _"try"_, it means that it will check if the function is hoisted and in that case it
* will try to add it on the top of the scope, otherwise, it will just add it after.
*
* @param {Array} params The list of parameters.
* @param {Path} path The reference {@link Path}.
* @param {string} name The name of the function/method/variable _"owner"_ of the
* property.
* @access protected
* @ignore
*/
_addPropertyBeforePath(params, path, name) {
const binding = path.scope.getBinding(name);
const expression = this._createPropertyExpression(name, params);
if (binding && binding.kind === 'hoisted') {
let block = binding.scope.getBlockParent().path;
if (block.isFunction()) {
block = block.get('body');
}
block.unshiftContainer('body', [expression]);
} else {
path.parentPath.scope.crawl();
path.insertAfter(expression);
}
}
/**
* Creates the declaration of the property and its value.
*
* @param {string} name The name of the function/method/variable _"owner"_ of the
* property.
* @param {Array} params The list of parameters the function/method receives.
* @returns {ExpressionStatement}
* @access protected
* @ignore
*/
_createPropertyExpression(name, params) {
const left = babelTypes.isNode(name) ? name : babelTypes.identifier(name);
const paramsAsString = params.map((param) =>
babelTypes.stringLiteral(this._getParamName(param)),
);
const list = babelTypes.arrayExpression(paramsAsString);
const member = babelTypes.memberExpression(
left,
babelTypes.identifier(this._options.property),
);
return babelTypes.expressionStatement(
babelTypes.assignmentExpression('=', member, list),
);
}
/**
* A helper function that generates a unique ID for a given {@link Path}. This is used
* by {@link InjectDirectiveParser#_addPath} when trying to identify if a path is
* already on the list.
*
* @param {Path} path The path for which the ID will be generated.
* @returns {string}
* @access protected
* @ignore
*/
_generatePathId(path) {
return `${path.node.start}-${path.node.end}`;
}
/**
* Given the {@link Node} of a class declaration/expression, this method will try to
* find the {@link Node} for its constructor.
*
* @param {Node} clsNode A class declaration/expression node.
* @returns {?Node}
* @access protected
* @ignore
*/
_getClassConstructor(clsNode) {
return clsNode.body.body.find((node) => node.kind === 'constructor');
}
/**
* Given a function/method parameter, this method will check if it's an actuall raw
* parameter,
* in which case it will return it as it is, or an {@link AssignmentExpression}, where
* the name is on the `left` property.
*
* @param {string | AssignmentExpression} param The parameter information.
* @returns {string}
* @access protected
* @ignore
*/
_getParamName(param) {
const newParam = babelTypes.isAssignmentPattern(param) ? param.left : param;
return newParam.name;
}
/**
* Checks whether a functon/method has the required directive.
*
* @param {Path} path The function/method path.
* @returns {boolean}
* @access protected
* @ignore
*/
_hasDirective(path) {
let result = false;
const { directives } = path.node.body;
if (directives && directives.length) {
result = directives.some(({ value }) => value.value === this._options.directive);
}
return result;
}
/**
* Checks whether a {@link Path} is already on the list that will be processed or not.
*
* @param {Path} path The path to check.
* @returns {boolean}
* @access protected
* @ignore
*/
_pathExists(path) {
const id = this._generatePathId(path);
return this._paths.some((pathItem) => this._generatePathId(pathItem) === id);
}
/**
* Generates a new set of options for the class by merging the received paramter and a
* set of defaults. This is called from the constructor, using the recived file options
* as overwrites.
*
* @param {Object} [options={}] The options to overwrite the default ones.
* @property {string} directive The name of the directive the parser will look for.
* @property {string} property The name of the property where the dependencies will be
* defined.
* @returns {Object}
* @access protected
* @ignore
*/
_parseOptions(options = {}) {
return {
directive: 'inject',
property: 'inject',
...options,
};
}
/**
* Processes and transform a given {@link Path} in order to add the required property.
*
* @param {Path} originalPath The path to transform.
* @access protected
* @ignore
*/
_transformPath(originalPath) {
let path = originalPath;
let { node } = path;
let topPath;
let name;
if (babelTypes.isVariableDeclarator(path.node)) {
topPath = path.parentPath;
({ name } = node.id);
node = node.init;
path = path.get('init');
} else {
topPath = path;
}
if (babelTypes.isExportDeclaration(topPath.parent)) {
topPath = topPath.parentPath;
}
if (babelTypes.isClass(node)) {
({ name } = node.id);
node = this._getClassConstructor(node);
topPath = this._findProperClassPath(topPath);
}
if (node.params.length) {
if (babelTypes.isFunctionExpression(node) || babelTypes.isClassMethod(node)) {
this._addPropertyAfterPath(node.params, topPath, name);
} else {
this._addPropertyBeforePath(node.params, path, node.id.name);
}
}
}
/**
* When used with decorators, Babel puts the class inside a nested sequence expression
* that applies the decorators, and if this plugin were to add the `inject` property
* right after the class parent, it can end up in the middle of the sequence and make
* the code invalid.
* This method validates if the class is inside a sequence and crawls the path all the
* way up to the variable declaration (child of the program).
*
* @param {Path} topPath The current {@link Path} that was going to be used for the
* class.
* @returns {Path}
* @access protected
* @ignore
*/
_findProperClassPath(topPath) {
let result = topPath;
if (
result.parent &&
babelTypes.isAssignmentExpression(result.parent) &&
result.parentPath.parent &&
babelTypes.isSequenceExpression(result.parentPath.parent)
) {
result = result.parentPath.parentPath;
while (result.parentPath && !babelTypes.isProgram(result.parentPath)) {
result = result.parentPath;
}
}
return result;
}
}
module.exports = InjectDirectiveParser;