'use strict'; module.exports = angularRule; /** * Method names from an AngularJS module which can be chained. */ var angularChainableNames = [ 'animation', 'component', 'config', 'constant', 'controller', 'directive', 'factory', 'filter', 'provider', 'run', 'service', 'value' ]; /** * An angularRule defines a simplified interface for AngularJS component based rules. * * A full rule definition containing rules for all supported rules looks like this: * ```js * module.exports = angularRule(function(context) { * return { * 'angular?animation': function(configCallee, configFn) {}, * 'angular?component': function(componentCallee, componentObj) {}, * 'angular?config': function(configCallee, configFn) {}, * 'angular?controller': function(controllerCallee, controllerFn) {}, * 'angular?directive': function(directiveCallee, directiveFn) {}, * 'angular?factory': function(factoryCallee, factoryFn) {}, * 'angular?filter': function(filterCallee, filterFn) {}, * 'angular?inject': function(injectCallee, injectFn) {}, // inject() calls from angular-mocks * 'angular?run': function(runCallee, runFn) {}, * 'angular?service': function(serviceCallee, serviceFn) {}, * 'angular?provider': function(providerCallee, providerFn, provider$getFn) {} * }; * }) * ``` */ function angularRule(ruleDefinition) { var angularComponents; var angularModuleCalls; var angularModuleIdentifiers; var angularChainables; var injectCalls; return wrapper; function reset() { angularComponents = []; angularModuleCalls = []; angularModuleIdentifiers = []; angularChainables = []; injectCalls = []; } /** * A wrapper around the rule definition. */ function wrapper(context) { reset(); var ruleObject = ruleDefinition(context); injectCall(ruleObject, context, 'CallExpression:exit', checkCallee); injectCall(ruleObject, context, 'Program:exit', callAngularRules); return ruleObject; } /** * Makes sure an extra function gets called after custom defined rule has run. */ function injectCall(ruleObject, context, propName, toCallAlso) { var original = ruleObject[propName]; ruleObject[propName] = callBoth; function callBoth(node) { if (original) { original.call(ruleObject, node); } toCallAlso(ruleObject, context, node); } } /** * Collect expressions from an entire Angular module call chain expression statement and inject calls. * * This collects the following nodes: * ```js * angular.module() * ^^^^^^ * .animation('', function() {}) * ^^^^^^^^^ ^^^^^^^^^^ * .component('', {}) * ^^^^^^^^^ * .config(function() {}) * ^^^^^^ ^^^^^^^^^^ * .constant() * ^^^^^^^^ * .controller('', function() {}) * ^^^^^^^^^^ ^^^^^^^^^^ * .directive('', function() {}) * ^^^^^^^^^ ^^^^^^^^^^ * .factory('', function() {}) * ^^^^^^^ ^^^^^^^^^^ * .filter('', function() {}) * ^^^^^^ ^^^^^^^^^^ * .provider('', function() {}) * ^^^^^^^^ ^^^^^^^^^^ * .run('', function() {}) * ^^^ ^^^^^^^^^^ * .service('', function() {}) * ^^^^^^^ ^^^^^^^^^^ * .value(); * ^^^^^ * * inject(function() {}) * ^^^^^^ ^^^^^^^^^^ * ``` */ function checkCallee(ruleObject, context, callExpressionNode) { var callee = callExpressionNode.callee; if (callee.type === 'Identifier') { if (callee.name === 'inject') { // inject() // ^^^^^^ injectCalls.push({ callExpression: callExpressionNode, fn: findFunctionByNode(callExpressionNode, context.getScope()) }); } return; } if (callee.type === 'MemberExpression') { if (callee.object.name === 'angular' && callee.property.name === 'module') { // angular.module() // ^^^^^^ angularModuleCalls.push(callExpressionNode); } else if (angularChainableNames.indexOf(callee.property.name !== -1) && (angularModuleCalls.indexOf(callee.object) !== -1 || angularChainables.indexOf(callee.object) !== -1)) { // angular.module().factory().controller() // ^^^^^^^ ^^^^^^^^^^ angularChainables.push(callExpressionNode); angularComponents.push({ callExpression: callExpressionNode, fn: findFunctionByNode(callExpressionNode, context.getScope()) }); } else if (callee.object.type === 'Identifier') { // var app = angular.module(); app.factory() // ^^^^^^^ var scope = context.getScope(); var isAngularModule = scope.variables.some(function(variable) { if (callee.object.name !== variable.name) { return false; } return variable.identifiers.some(function(id) { return angularModuleIdentifiers.indexOf(id) !== -1; }); }); if (isAngularModule) { angularChainables.push(callExpressionNode); angularComponents.push({ callExpression: callExpressionNode, fn: findFunctionByNode(callExpressionNode, context.getScope()) }); } else { return; } } else { return; } if (callExpressionNode.parent.type === 'VariableDeclarator') { // var app = angular.module() // ^^^ angularModuleIdentifiers.push(callExpressionNode.parent.id); } } } /** * Find the function expression or function declaration by an Angular component callee. */ function findFunctionByNode(callExpressionNode, scope) { var node; if (callExpressionNode.callee.type === 'Identifier') { node = callExpressionNode.arguments[0]; } else if (callExpressionNode.callee.property.name === 'run' || callExpressionNode.callee.property.name === 'config') { node = callExpressionNode.arguments[0]; } else { node = callExpressionNode.arguments[1]; } if (!node) { return; } if (node.type === 'ArrayExpression') { node = node.elements[node.elements.length - 1] || {}; } if (node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionDeclaration') { return node; } if (node.type !== 'Identifier') { return; } var func; scope.variables.some(function(variable) { if (variable.name === node.name) { variable.defs.forEach(function(def) { if (def.node.type === 'FunctionDeclaration') { func = def.node; return true; } }); return true; } }); return func; } /** * Call the Angular specific rules defined by the rule definition. */ function callAngularRules(ruleObject, context) { angularComponents.forEach(function(component) { var name = component.callExpression.callee.property.name; var fn = ruleObject['angular?' + name]; if (!fn) { return; } fn.apply(ruleObject, assembleArguments(component, context)); }); var injectRule = ruleObject['angular?inject']; if (injectRule) { injectCalls.forEach(function(node) { injectRule.call(ruleObject, node.CallExpression, node.fn); }); } } /** * Assemble the arguments for an Angular callee check. */ function assembleArguments(node) { switch (node.callExpression.callee.property.name) { case 'animation': case 'component': case 'config': case 'controller': case 'directive': case 'factory': case 'filter': case 'run': case 'service': return [node.callExpression.callee, node.fn]; case 'provider': return assembleProviderArguments(node); } } /** * Assemble arguments for a provider rule. * * On top of a regular Angular component rule, the provider rule gets called with the $get function as its 3rd argument. */ function assembleProviderArguments(node) { return [node.callExpression, node.fn, findProviderGet(node.fn)]; } /** * Find the $get function of a provider based on the provider function body. */ function findProviderGet(providerFn) { if (!providerFn) { return; } var getFn; providerFn.body.body.some(function(statement) { var expression = statement.expression; if (!expression || expression.type !== 'AssignmentExpression') { return; } if (expression.left.type === 'MemberExpression' && expression.left.property.name === '$get') { getFn = expression.right; return true; } }); if (!getFn) { return; } if (getFn.type === 'ArrayExpression') { return getFn.elements[getFn.elements.length - 1]; } return getFn; } }