diff --git a/gulpfile.js b/gulpfile.js index c71052ca..5eb7da16 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -18,7 +18,7 @@ gulp.task('test', function(cb) { .pipe(istanbul()) // Covering files .pipe(istanbul.hookRequire()) // Force `require` to return covered files .on('finish', function() { - gulp.src(['test/*.js']) + gulp.src(['test/**/*.js']) .pipe(mocha()) .pipe(istanbul.writeReports()) // Creating the reports after tests runned .on('end', cb); diff --git a/package.json b/package.json index d8fe835f..cbe34ef4 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,10 @@ }, "homepage": "https://github.com/Gillespie59/eslint-plugin-angularjs", "devDependencies": { - "chai": "^3.2.0", + "chai": "^3.3.0", "coveralls": "^2.11.4", "eslint": "^1.3.1", + "espree": "^2.2.5", "gulp": "^3.9.0", "gulp-eslint": "^1.0.0", "gulp-istanbul": "^0.10.0", diff --git a/rules/utils/utils.js b/rules/utils/utils.js index 12425125..9d15c572 100644 --- a/rules/utils/utils.js +++ b/rules/utils/utils.js @@ -1,7 +1,64 @@ 'use strict'; -// this will recursively grab the callee until it hits an Identifier +var scopeProperties = [ + '$id', + '$parent', + '$root', + '$destroy', + '$broadcast', + '$emit', + '$on', + '$applyAsync', + '$apply', + '$evalAsync', + '$eval', + '$digest', + '$watchCollection', + '$watchGroup', + '$watch', + '$new' +]; + + +module.exports = { + // Properties + scopeProperties: scopeProperties, + + // Functions + convertPrefixToRegex: convertPrefixToRegex, + convertStringToRegex: convertStringToRegex, + isTypeOfStatement: isTypeOfStatement, + isToStringStatement: isToStringStatement, + isArrayType: isArrayType, + isFunctionType: isFunctionType, + isIdentifierType: isIdentifierType, + isMemberExpression: isMemberExpression, + isLiteralType: isLiteralType, + isEmptyFunction: isEmptyFunction, + isRegexp: isRegexp, + isStringRegexp: isStringRegexp, + isAngularComponent: isAngularComponent, + isAngularControllerDeclaration: isAngularControllerDeclaration, + isAngularFilterDeclaration: isAngularFilterDeclaration, + isAngularDirectiveDeclaration: isAngularDirectiveDeclaration, + isAngularServiceDeclaration: isAngularServiceDeclaration, + isAngularModuleDeclaration: isAngularModuleDeclaration, + isAngularModuleGetter: isAngularModuleGetter, + isAngularRunSection: isAngularRunSection, + isAngularConfigSection: isAngularConfigSection, + isRouteDefinition: isRouteDefinition, + isUIRouterStateDefinition: isUIRouterStateDefinition, + findIdentiferInScope: findIdentiferInScope, + getControllerDefinition: getControllerDefinition +}; + + +/** + * Recursively grab the callee until an Identifier is found. + * + * @todo Needs better documentation. + */ function getCallingIdentifier(calleeObject) { if (calleeObject.type && calleeObject.type === 'Identifier') { return calleeObject; @@ -12,190 +69,430 @@ function getCallingIdentifier(calleeObject) { return null; } -module.exports = { +/** + * Convert a prefix string to a RegExp. + * + * `'/app/'` → `/app.*\/` + * + * @param {string} prefix + * @returns {RegExp} + */ +function convertPrefixToRegex(prefix) { + if (typeof prefix !== 'string') { + return prefix; + } - convertPrefixToRegex: function(prefix) { - if (typeof prefix !== 'string') { - return prefix; - } + if (prefix[0] === '/' && prefix[prefix.length - 1] === '/') { + prefix = prefix.substring(1, prefix.length - 2); + } - if (prefix[0] === '/' && prefix[prefix.length - 1] === '/') { - prefix = prefix.substring(1, prefix.length - 2); - } + return new RegExp(prefix + '.*'); +} - return new RegExp(prefix + '.*'); - }, +/** + * Convert a string to a RegExp. + * + * `'app'` → `/app/` + * `'/app/'` → `/app/` + * + * @param {string} prefix + * @returns {RegExp} + */ +function convertStringToRegex(string) { + if (string[0] === '/' && string[string.length - 1] === '/') { + string = string.substring(1, string.length - 2); + } + return new RegExp(string); +} - convertStringToRegex: function(string) { - if (string[0] === '/' && string[string.length - 1] === '/') { - string = string.substring(1, string.length - 2); - } - return new RegExp(string); - }, - - isTypeOfStatement: function(node) { - return node.type === 'Identifier' || (node.type === 'UnaryExpression' && node.operator === 'typeof'); - }, - - isToStringStatement: function(node) { - return node.type === 'CallExpression' && - node.callee.type === 'MemberExpression' && - node.callee.object.type === 'MemberExpression' && - node.callee.object.property.name === 'toString' && - node.callee.property.name === 'call' && - node.callee.object.object.type === 'MemberExpression' && - node.callee.object.object.object.name === 'Object' && - node.callee.object.object.property.name === 'prototype'; - }, - - isArrayType: function(node) { - return node !== undefined && node.type === 'ArrayExpression'; - }, - - isFunctionType: function(node) { - return node !== undefined && node.type === 'FunctionExpression'; - }, - - isIdentifierType: function(node) { - return node !== undefined && node.type === 'Identifier'; - }, - - isMemberExpression: function(node) { - return node !== undefined && node.type === 'MemberExpression'; - }, - - isLiteralType: function(node) { - return node !== undefined && node.type === 'Literal'; - }, - - isEmptyFunction: function(fn) { - return fn.body.body.length === 0; - }, - - isRegexp: function(regexp) { - return toString.call(regexp) === '[object RegExp]'; - }, - - isStringRegexp: function(string) { - return string[0] === '/' && string[string.length - 1] === '/'; - }, - - isAngularComponent: function(node) { - return node.arguments !== undefined && node.arguments.length === 2 && this.isLiteralType(node.arguments[0]) && (this.isIdentifierType(node.arguments[1]) || this.isFunctionType(node.arguments[1]) || this.isArrayType(node.arguments[1])); - }, - - isAngularControllerDeclaration: function(node) { - return this.isAngularComponent(node) && - this.isMemberExpression(node.callee) && - node.callee.property.name === 'controller'; - }, - - isAngularFilterDeclaration: function(node) { - return this.isAngularComponent(node) && - this.isMemberExpression(node.callee) && - node.callee.property.name === 'filter'; - }, - - isAngularDirectiveDeclaration: function(node) { - return this.isAngularComponent(node) && - this.isMemberExpression(node.callee) && - node.callee.property.name === 'directive'; - }, - - isAngularServiceDeclaration: function(node) { - return this.isAngularComponent(node) && - this.isMemberExpression(node.callee) && - node.callee.object.name !== '$provide' && - (node.callee.property.name === 'provider' || - node.callee.property.name === 'service' || - node.callee.property.name === 'factory' || - node.callee.property.name === 'constant' || - node.callee.property.name === 'value'); - }, - - isAngularModuleDeclaration: function(node) { - return this.isAngularComponent(node) && - this.isMemberExpression(node.callee) && - node.callee.property.name === 'module'; - }, - - isAngularModuleGetter: function(node) { - return node.arguments !== undefined && - node.arguments.length > 0 && - this.isLiteralType(node.arguments[0]) && - node.callee.type === 'MemberExpression' && - node.callee.property.name === 'module'; - }, - - isAngularRunSection: function(node) { - return this.isMemberExpression(node.callee) && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'run' && - (node.callee.object.type === 'Identifier' && - node.callee.object.name !== 'mocha'); - }, - - isAngularConfigSection: function(node) { - return this.isMemberExpression(node.callee) && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'config'; - }, - - isRouteDefinition: function(node) { - // the route def function is .when(), so when we find that, go up through the chain and make sure - // $routeProvider is the calling object - if (node.callee.property && node.callee.property.name === 'when') { - var callObject = getCallingIdentifier(node.callee.object); - return callObject && callObject.name === '$routeProvider'; - } - return false; - }, - - isUIRouterStateDefinition: function(node) { - // the state def function is .state(), so when we find that, go up through the chain and make sure - // $stateProvider is the calling object - if (node.callee.property && node.callee.property.name === 'state') { - var callObject = getCallingIdentifier(node.callee.object); - return callObject && callObject.name === '$stateProvider'; - } - return false; - }, - - scopeProperties: ['$id', '$parent', '$root', '$destroy', '$broadcast', '$emit', '$on', '$applyAsync', '$apply', - '$evalAsync', '$eval', '$digest', '$watchCollection', '$watchGroup', '$watch', '$new'], - - findIdentiferInScope: function(context, identifier) { - var identifierNode = null; - context.getScope().variables.forEach(function(variable) { - if (variable.name === identifier.name) { - identifierNode = variable.defs[0].node; - if (identifierNode.type === 'VariableDeclarator') { - identifierNode = identifierNode.init; - } - } - }); - return identifierNode; - }, - - getControllerDefinition: function(context, node) { - var controllerArg = node.arguments[1]; - - // Three ways of creating a controller function: function expression, - // variable name that references a function, and an array with a function - // as the last item - if (this.isFunctionType(controllerArg)) { - return controllerArg; - } - if (this.isArrayType(controllerArg)) { - controllerArg = controllerArg.elements[controllerArg.elements.length - 1]; +/** + * @todo Missing documentation + */ +function isTypeOfStatement(node) { + return node.type === 'Identifier' || (node.type === 'UnaryExpression' && node.operator === 'typeof'); +} - if (this.isIdentifierType(controllerArg)) { - return this.findIdentiferInScope(context, controllerArg); +/** + * @todo Missing documentation + * + * @param {Object} node The node to check. + * @returns {boolean} Whether or not the node is a `toString` statement. + */ +function isToStringStatement(node) { + return node.type === 'CallExpression' && + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'MemberExpression' && + node.callee.object.property.name === 'toString' && + node.callee.property.name === 'call' && + node.callee.object.object.type === 'MemberExpression' && + node.callee.object.object.object.name === 'Object' && + node.callee.object.object.property.name === 'prototype'; +} + +/** + * Check whether or not a node is an ArrayExpression. + * + * @param {Object} node The node to check. + * @returns {boolean} Whether or not the node is an ArrayExpression. + */ +function isArrayType(node) { + return node !== undefined && node.type === 'ArrayExpression'; +} + +/** + * Check whether or not a node is an FunctionExpression. + * + * @param {Object} node The node to check. + * @returns {boolean} Whether or not the node is an FunctionExpression. + */ +function isFunctionType(node) { + return node !== undefined && node.type === 'FunctionExpression'; +} + +/** + * Check whether or not a node is an Identifier. + * + * @param {Object} node The node to check. + * @returns {boolean} Whether or not the node is an Identifier. + */ +function isIdentifierType(node) { + return node !== undefined && node.type === 'Identifier'; +} + +/** + * Check whether or not a node is an MemberExpression. + * + * @param {Object} node The node to check. + * @returns {boolean} Whether or not the node is an MemberExpression. + */ +function isMemberExpression(node) { + return node !== undefined && node.type === 'MemberExpression'; +} + +/** + * Check whether or not a node is an Literal. + * + * @param {Object} node The node to check. + * @returns {boolean} Whether or not the node is an Literal. + */ +function isLiteralType(node) { + return node !== undefined && node.type === 'Literal'; +} + +/** + * Check whether or not a node is an isEmptyFunction. + * + * @param {Object} node The node to check. + * @returns {boolean} Whether or not the node is an isEmptyFunction. + */ +function isEmptyFunction(fn) { + return fn.body.body.length === 0; +} + +/** + * Check whether or not an object is a RegExp object. + * + * @param regexp The object for which to check if it is a RegExp object. + * @returns {boolean} Shether or not an object is a RegExp object. + */ +function isRegexp(regexp) { + return toString.call(regexp) === '[object RegExp]'; +} + +/** + * Check whether or not a string resembles a regular expression. + * + * A string is considered a regular expression if it starts and ends with `/`. + * + * @param {string} The string to check. + * @returns {boolean} Whether or not a string resembles a regular expression. + */ +function isStringRegexp(string) { + return string[0] === '/' && string[string.length - 1] === '/'; +} + +/** + * Check if a CallExpression node somewhat resembles an Angular component. + * + * The following are considered Angular components + * ```js + * app.factory('kittenService', function() {}) + * ^^^^^^^ + * app.factory('kittenService', kittenService) + * ^^^^^^^ + * app.factory('kittenService', []) + * ^^^^^^^ + * asyncFn('value', callback) + * ^^^^^^^ + * ``` + * + * @todo FIXME + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node somewhat resembles an Angular component. + */ +function isAngularComponent(node) { + return node.arguments !== undefined && + node.arguments.length === 2 && + isLiteralType(node.arguments[0]) && + (isIdentifierType(node.arguments[1]) || + isFunctionType(node.arguments[1]) || + isArrayType(node.arguments[1])); +} + +/** + * Check whether a CallExpression node defines an Angular controller. + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines an Angular controller. + */ +function isAngularControllerDeclaration(node) { + return isAngularComponent(node) && + isMemberExpression(node.callee) && + node.callee.property.name === 'controller'; +} + +/** + * Check whether a CallExpression node defines an Angular filter. + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines an Angular filter. + */ +function isAngularFilterDeclaration(node) { + return isAngularComponent(node) && + isMemberExpression(node.callee) && + node.callee.property.name === 'filter'; +} + +/** + * Check whether a CallExpression node defines an Angular directive. + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines an Angular directive. + */ +function isAngularDirectiveDeclaration(node) { + return isAngularComponent(node) && + isMemberExpression(node.callee) && + node.callee.property.name === 'directive'; +} + +/** + * Check whether a node defines an Angular service. + * + * The following are considered services + * ```js + * app.provider('kittenServiceProvider', function() {}) + * ^^^^^^^^ + * app.factory('kittenService', function() {}) + * ^^^^^^^ + * app.service('kittenService', function() {}) + * ^^^^^^^ + * app.constant('KITTENS', function() {}) + * ^^^^^^^^ + * app.value('KITTENS', function() {}) + * ^^^^^ + * ``` + * + * The following are not considered services + * ```js + * $provide.factory('kittenService', function() {}) + * app.constant('KITTENS', 'meow') + * app.value('KITTENS', 'purr') + * this.$get = function() {} + * ``` + * + * @todo FIXME + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines an Angular controller. + */ +function isAngularServiceDeclaration(node) { + return isAngularComponent(node) && + isMemberExpression(node.callee) && + node.callee.object.name !== '$provide' && + (node.callee.property.name === 'provider' || + node.callee.property.name === 'service' || + node.callee.property.name === 'factory' || + node.callee.property.name === 'constant' || + node.callee.property.name === 'value'); +} + +/** + * Check whether a CallExpression node declares an Angular module. + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node declares an Angular module. + */ +function isAngularModuleDeclaration(node) { + return isAngularComponent(node) && + isMemberExpression(node.callee) && + node.callee.property.name === 'module'; +} + +/** + * Check whether a CallExpression node gets or declares an Angular module. + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node gets or declares an Angular module. + */ +function isAngularModuleGetter(node) { + return node.arguments !== undefined && + node.arguments.length > 0 && + isLiteralType(node.arguments[0]) && + node.callee.type === 'MemberExpression' && + node.callee.property.name === 'module'; +} + +/** + * Check whether a CallExpression node defines an Angular run function. + * + * The following are considered run functions + * ```js + * app.run() + * ^^^ + * app.run(function() {}) + * ^^^ + * ``` + * + * The following are not considered run functions + * ```js + * angular.module('myApp').run(function() {}) + * angular.module('myApp', []).run(function() {}) + * mocha.run() + * ``` + * + * @todo FIXME + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines an Angular run function. + */ +function isAngularRunSection(node) { + return isMemberExpression(node.callee) && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'run' && + (node.callee.object.type === 'Identifier' && + node.callee.object.name !== 'mocha'); +} + +/** + * Check whether a CallExpression node defines an Angular config function. + * + * The following are considered config functions + * ```js + * app.config() + * ^^^^^^ + * app.config(function() {}) + * ^^^^^^ + * ``` + * + * The following are not considered run functions + * ```js + * angular.module('myApp').config(function() {}) + * angular.module('myApp', []).config(function() {}) + * ``` + * + * @todo FIXME + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines an Angular config function. + */ +function isAngularConfigSection(node) { + return isMemberExpression(node.callee) && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'config'; +} + +/** + * Check whether a CallExpression node defines a route using $routeProvider. + * + * The following are considered routes: + * ```js + * $routeProvider.when() + * ^^^^ + * ``` + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines a route. + */ +function isRouteDefinition(node) { + // the route def function is .when(), so when we find that, go up through the chain and make sure + // $routeProvider is the calling object + if (node.callee.property && node.callee.property.name === 'when') { + var callObject = getCallingIdentifier(node.callee.object); + return callObject && callObject.name === '$routeProvider'; + } + return false; +} + +/** + * Check whether a CallExpression node defines a state using $stateProvider. + * + * The following are considered states: + * ```js + * $stateProvider.state() + * ^^^^^ + * ``` + * + * @param {Object} node The CallExpression node to check. + * @returns {boolean} Whether or not the node defines a state. + */ +function isUIRouterStateDefinition(node) { + // the state def function is .state(), so when we find that, go up through the chain and make sure + // $stateProvider is the calling object + if (node.callee.property && node.callee.property.name === 'state') { + var callObject = getCallingIdentifier(node.callee.object); + return callObject && callObject.name === '$stateProvider'; + } + return false; +} + +/** + * Find an identifier node in the current scope. + * + * @param {Object} context The context to use to get the scope. + * @param {Object} identifier The identifier node to look up. + * + * @returns {Object} The node declaring the identifier. + */ +function findIdentiferInScope(context, identifier) { + var identifierNode = null; + context.getScope().variables.forEach(function(variable) { + if (variable.name === identifier.name) { + identifierNode = variable.defs[0].node; + if (identifierNode.type === 'VariableDeclarator') { + identifierNode = identifierNode.init; } - return controllerArg; } - if (this.isIdentifierType(controllerArg)) { - return this.findIdentiferInScope(context, controllerArg); + }); + return identifierNode; +} + +/** + * Find the function definition of a controller in the current context. + * + * @param {Object} context The context to use to find the controller declaration. + * @param {Object} node The Angular controller call to look up the declaration for. + * + * @returns {Object} The identifier declaring the controller function. + */ +function getControllerDefinition(context, node) { + var controllerArg = node.arguments[1]; + + // Three ways of creating a controller function: function expression, + // variable name that references a function, and an array with a function + // as the last item + if (isFunctionType(controllerArg)) { + return controllerArg; + } + if (isArrayType(controllerArg)) { + controllerArg = controllerArg.elements[controllerArg.elements.length - 1]; + + if (isIdentifierType(controllerArg)) { + return findIdentiferInScope(context, controllerArg); } + return controllerArg; } -}; + if (isIdentifierType(controllerArg)) { + return findIdentiferInScope(context, controllerArg); + } +} diff --git a/test/utils/utils.js b/test/utils/utils.js new file mode 100644 index 00000000..c7a77639 --- /dev/null +++ b/test/utils/utils.js @@ -0,0 +1,98 @@ +'use strict'; + +/* eslint-env mocha */ +/* eslint-disable no-unused-expressions */ + +var espree = require('espree'); +var expect = require('chai').expect; + +var utils = require('../../rules/utils/utils'); + + +describe('convertPrefixToRegex', function() { + it('should not handle non-string objects', function() { + var obj = {}; + expect(utils.convertPrefixToRegex(obj) === obj).to.be.true; + }); + + xit('should not convert a string ending and starting with a / to a Regex', function() { + expect(utils.convertPrefixToRegex('/app/'.source)).to.equal('app.*'); + }); + + it('should not convert a regulat string a Regex', function() { + expect(utils.convertPrefixToRegex('app').source).to.equal('app.*'); + }); +}); + +describe('convertStringToRegex', function() { + xit('should not convert a string ending and starting with a / to a Regex', function() { + expect(utils.convertStringToRegex('/app/'.source)).to.equal('app'); + }); + + it('should not convert a regulat string a Regex', function() { + expect(utils.convertStringToRegex('app').source).to.equal('app'); + }); +}); + +describe('isAngularControllerDeclaration', function() { + it('should return true if the function call chained from a module definition declares a controller', function() { + var ast = espree.parse('angular.module("", []).controller("", function() {});'); + expect(utils.isAngularControllerDeclaration(ast.body[0].expression)).to.be.true; + }); + + it('should return true if the function call chained from a module getter declares a controller', function() { + var ast = espree.parse('angular.module("").controller("", function() {});'); + expect(utils.isAngularControllerDeclaration(ast.body[0].expression)).to.be.true; + }); + + xit('should return false if a controller function from some variable is called', function() { + var ast = espree.parse('app.controller("", function() {});'); + expect(utils.isAngularControllerDeclaration(ast.body[0].expression)).to.be.false; + }); + + it('should return true if a referenced angular module declares a controller', function() { + var ast = espree.parse('var app = angular.module("");app.controller("", function() {});'); + expect(utils.isAngularControllerDeclaration(ast.body[1].expression)).to.be.true; + }); + + it('should return false if too few arguments are passed', function() { + var ast = espree.parse('angular.module("").controller("");'); + expect(utils.isAngularControllerDeclaration(ast.body[0].expression)).to.be.false; + }); +}); + +describe('isAngularModuleDeclaration', function() { + it('should return true for an Angular module declaration', function() { + var ast = espree.parse('angular.module("", []);'); + expect(utils.isAngularModuleDeclaration(ast.body[0].expression)).to.be.true; + }); + + it('should return false for an Angular module getter', function() { + var ast = espree.parse('angular.module("");'); + expect(utils.isAngularModuleDeclaration(ast.body[0].expression)).to.be.false; + }); +}); + +describe('isAngularModuleGetter', function() { + xit('should return false for an Angular module declaration', function() { + var ast = espree.parse('angular.module("", []);'); + expect(utils.isAngularModuleGetter(ast.body[0].expression)).to.be.false; + }); + + it('should return true for an Angular module getter', function() { + var ast = espree.parse('angular.module("");'); + expect(utils.isAngularModuleGetter(ast.body[0].expression)).to.be.true; + }); +}); + +describe('isAngularRunSection', function() { + xit('should return true if the call defines a run function', function() { + var ast = espree.parse('angular.module("").run(function() {});'); + expect(utils.isAngularRunSection(ast.body[0].expression)).to.be.true; + }); + + xit('should return false is a run is called on a random object', function() { + var ast = espree.parse('app.run();'); + expect(utils.isAngularRunSection(ast.body[0].expression)).to.be.false; + }); +});