echo-fecs
Version:
Front End Code Style Suite
895 lines (775 loc) • 28.5 kB
JavaScript
/**
* @file Validates JSDoc comments are syntactically correct
* @author Nicholas C. Zakas
*/
'use strict';
//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
var doctrine = require('doctrine2');
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
module.exports = function (context) {
var options = context.options[0] || {};
var prefer = options.prefer || {};
var preferType = options.preferType || {};
var checkPreferType = Object.keys(preferType).length !== 0;
var PROMISE_TYPE = 'Promise.<resolveType[, rejectType]>';
var requireReturn = options.requireReturn !== false;
var requireAuthor = options.requireAuthor === true;
var requireFileDescription = options.requireFileDescription === true;
var requireParamDescription = options.requireParamDescription !== false;
var requireReturnType = options.requireReturnType !== false;
var requireReturnDescription = options.requireReturnDescription !== false;
var requireBlankLineAfterDescription = options.requireBlankLineAfterDescription === true;
var requireEmptyLineBeforeComment = options.requireEmptyLineBeforeComment === true;
var preferLineComment = options.preferLineComment !== false;
//--------------------------------------------------------------------------
// Helpers
//--------------------------------------------------------------------------
// Using a stack to store if a function returns or not (handling nested functions)
var fns = [];
/**
* Check if node type is a Class
*
* @param {ASTNode} node node to check.
* @return {boolean} True is its a class
* @private
*/
function isTypeClass(node) {
return node.type === 'ClassExpression' || node.type === 'ClassDeclaration';
}
/**
* When parsing a new function, store it in our function stack.
*
* @param {ASTNode} node current node.
* @private
*/
function startFunction(node) {
fns.push({
returnPresent: (node.type === 'ArrowFunctionExpression' && node.body.type !== 'BlockStatement')
|| isTypeClass(node)
});
}
/**
* Indicate that return has been found in the current function.
*
* @param {ASTNode} node The return node.
* @return {void}
* @private
*/
function addReturn(node) {
var functionState = fns[fns.length - 1];
if (functionState && node.argument !== null) {
functionState.returnPresent = true;
}
}
/**
* Check if return tag type is void or undefined
*
* @param {Object} tag JSDoc tag
* @return {boolean} True if its of type void or undefined
* @private
*/
function isValidReturnType(tag) {
return tag.type === null || tag.type.name === 'void' || tag.type.type === 'UndefinedLiteral';
}
var cachedJSDocNodes = {};
function getCacheKey(commentNode) {
var loc = commentNode.loc;
return [loc.start.line, loc.start.column, loc.end.line, loc.end.column].join('-');
}
/**
* Use doctrine to parse comment node.
*
* @param {ASTNode} commentNode comment node from AST
* @return {?doctrine.tag}
* @private
*/
function parseDoc(commentNode) {
var key = getCacheKey(commentNode);
if (key in cachedJSDocNodes) {
return cachedJSDocNodes[key];
}
try {
return (cachedJSDocNodes[key] = doctrine.parse(
commentNode.value,
{
strict: true,
unwrap: true,
sloppy: true,
lineNumbers: true,
recoverable: true
}
));
}
catch (ex) {
var line = commentNode.loc.start.line + (ex.line | 0) + 1;
var column = commentNode.loc.start.column + (ex.column | 0) + 1;
if (/braces/i.test(ex.message)) {
context.report(
commentNode,
{line: line, column: column},
'JSDoc type missing brace.baidu043'
);
}
else {
context.report(
commentNode,
{line: line, column: column},
'JSDoc syntax error: ' + ex.message + '.baidu999'
);
}
return (cachedJSDocNodes[key] = null);
}
}
/**
* Get the name of param node
*
* @param {ASTNode} node the param node
* @param {ASTNode[]} params the params
* @return {string}
*/
function getParamName(node, params) {
var type = node.type;
switch (type) {
case 'Property':
return node.key.name;
case 'RestElement':
return node.argument.name;
case 'AssignmentPattern':
return node.left.name;
case 'Identifier':
default:
return node.name;
}
}
/**
* check @file and @author
*
* @param {ASTNode} node root node
* @return {void}
* @private
*/
function checkFile(node) {
if (!requireAuthor && !requireFileDescription) {
return;
}
var fileCommentNode = node.comments.filter(
function (comment) {
return comment.type === 'Block' && comment.value[0] === '*';
}
)[0];
var hasFile;
var hasAuthor;
if (fileCommentNode) {
var jsdoc = parseDoc(fileCommentNode);
jsdoc && jsdoc.tags.forEach(function (tag) {
switch (tag.title) {
case 'file':
case 'fileoverview':
hasFile = true;
if (prefer.hasOwnProperty(tag.title) && tag.title !== prefer[tag.title]) {
var code = prefer[tag.title] === 'file' ? '045' : '998';
context.report(
fileCommentNode,
{
line: fileCommentNode.loc.start.line + tag.lineNumber,
column: fileCommentNode.loc.start.column + 1
},
'Use @{{name}} instead.baidu' + code,
{name: prefer[tag.title]}
);
}
break;
case 'author':
hasAuthor = true;
break;
}
});
}
if (!hasFile && requireFileDescription) {
context.report(
fileCommentNode || node,
{line: 1, column: 0},
'Missing JSDoc @file.baidu045'
);
}
if (!hasAuthor && requireAuthor) {
context.report(
fileCommentNode || node,
{line: 1, column: 0},
'Missing JSDoc @author.baidu046'
);
}
}
/**
* Check if type should be validated based on some exceptions
*
* @param {Object} type JSDoc tag
* @return {boolean} True if it can be validated
* @private
*/
function canTypeBeValidated(type) {
return type !== 'UndefinedLiteral' // {undefined} as there is no name property available.
&& type !== 'NullLiteral' // {null}
&& type !== 'NullableLiteral' // {?}
&& type !== 'FunctionType' // {function(a)}
&& type !== 'AllLiteral'; // {*}
}
/**
* Extract the current and expected type based on the input type object
*
* @param {Object} type JSDoc tag
* @return {Object} current and expected type object
* @private
*/
function getCurrentExpectedTypes(type) {
var currentType;
var expectedType;
if (!type.name) {
currentType = type.expression.name;
}
else {
currentType = type.name;
}
expectedType = preferType[currentType];
return {
currentType: currentType,
expectedType: expectedType
};
}
function getTypes(tag, typesToCheck, jsdocNode, pos, lines) {
var elements = [];
var type = tag.type;
if (type.type === 'ArrayType') {
elements = type.elements;
}
else if (type.type === 'TypeApplication') { // {Array.<String>}
elements = type.applications[0].type === 'UnionType'
? type.applications[0].elements
: type.applications;
if (type.expression && type.expression.name.toLowerCase() === 'promise') {
var name = type.expression.name;
var raw = lines[tag.lineNumber] + '';
// Promise.<resolveType, rejectType> nor Promise.<resolveType>
if (elements.length > 2) {
var column = 1 + raw.indexOf('{' + name);
if (raw.indexOf('Promise', column) > -1) {
column = raw.indexOf('Promise', column);
}
context.report(
jsdocNode,
{
line: pos.line,
column: column
},
'Expected JSDoc type name "{{name}}" but "{{jsdocName}}" found.baidu044',
{
name: PROMISE_TYPE,
jsdocName: raw.substring(column, raw.indexOf('>', column) + 1)
}
);
}
}
typesToCheck.push(getCurrentExpectedTypes(type));
}
else if (type.type === 'RecordType') { // {{20:String}}
elements = type.fields;
}
else if (type.type === 'UnionType') { // {String|number|Test}
elements = type.elements;
}
else {
typesToCheck.push(getCurrentExpectedTypes(type));
}
elements.forEach(function (type) {
type = type.value ? type.value : type; // we have to use type.value for RecordType
if (canTypeBeValidated(type.type)) {
getTypes({type: type}, typesToCheck, tag, pos, lines);
}
});
}
/**
* Check if return tag type is void or undefined
*
* @param {Object} tag JSDoc tag
* @param {Object} jsdocNode JSDoc node
* @param {Object} pos position of current tag
* @param {string[]} lines comment node source lines
* @private
*/
function validateTagType(tag, jsdocNode, pos, lines) {
if (!tag.type || !canTypeBeValidated(tag.type.type)) {
return;
}
var typesToCheck = [];
getTypes(tag, typesToCheck, jsdocNode, pos, lines);
typesToCheck.forEach(function (type) {
if (type.expectedType && type.expectedType !== type.currentType) {
context.report(
jsdocNode,
{
line: pos.line,
column: pos.column
},
'Use "{{expectedType}}" instead of "{{currentType}}".baidu044',
{
currentType: type.currentType,
expectedType: type.expectedType
}
);
}
});
}
/**
* Check basic syntax of jsdoc3
*
* @param {ASTNode} node current nonde
* @param {ASTNode} jsdocNode jsdoc node of current node
* @param {Object} jsdoc node parsed by doctrine
* @param {string[]} lines source code lines of jsdocNode
* @param {number} startLine the start line of jsdocNode
* @param {number} startColumn the start column of jsdocNode
* @return {Object} params object
*/
function checkBasicSyntax(node, jsdocNode, jsdoc, lines, startLine, startColumn) {
var hasReturns;
var hasConstructor;
var hasAbstract;
var hasOverride;
var isInterface;
var disallowReturn;
var functionData = fns.pop();
var params = Object.create(null);
jsdoc.tags.forEach(function (tag) {
var pos = {line: startLine + tag.lineNumber, column: startColumn};
switch (tag.title.toLowerCase()) {
case 'param':
case 'arg':
case 'argument':
if (!tag.type) {
context.report(
jsdocNode,
pos,
'Missing JSDoc parameter type for "{{name}}".baidu052',
{name: tag.name}
);
}
if (!tag.description && requireParamDescription) {
context.report(
jsdocNode,
pos,
'Missing JSDoc parameter description for "{{name}}".baidu053',
{name: tag.name}
);
}
if (params[tag.name]) {
context.report(
jsdocNode,
pos,
'Duplicate JSDoc parameter "{{name}}".baidu998',
{name: tag.name}
);
}
if (~tag.name.indexOf('.')) {
var namespaces = tag.name.split('.');
var firstName = namespaces.shift();
params[firstName] = params[firstName] || {};
params[firstName].keys = params[firstName].keys || {};
params[firstName].keys[namespaces.join('.')] = tag;
}
else {
params[tag.name] = tag;
}
break;
case 'return':
case 'returns':
hasReturns = true;
if (!requireReturn
&& !functionData.returnPresent
&& (tag.type == null || !isValidReturnType(tag))
) {
if (hasAbstract
|| jsdoc.tags.some(function (tag) {
return tag.title === 'abstract';
})
|| node.async
|| node.generator
) {
break;
}
disallowReturn = true;
context.report(
jsdocNode,
pos,
'Unexpected @' + tag.title + ' tag; function has no return statement.baidu998'
);
}
else {
if (requireReturnType && !tag.type) {
context.report(
jsdocNode,
pos,
'Missing JSDoc return type.baidu053'
);
}
if (requireReturnDescription && !tag.description && !isValidReturnType(tag)) {
context.report(
jsdocNode,
pos,
'Missing JSDoc return description.baidu053'
);
}
}
break;
case 'constructor':
case 'class':
hasConstructor = true;
break;
case 'abstract':
hasAbstract = true;
break;
case 'override':
case 'inheritdoc':
hasOverride = true;
break;
case 'interface':
isInterface = true;
break;
// no default
}
// check tag preferences
if (
prefer.hasOwnProperty(tag.title)
&& tag.title !== prefer[tag.title]
&& (!disallowReturn || disallowReturn && !/^returns?$/i.test(tag.title))
) {
context.report(
jsdocNode,
pos,
'Use @{{name}} instead.baidu' + (tag.title === 'constructor' ? '048' : '998'),
{name: prefer[tag.title]}
);
}
// validate the types
if (checkPreferType) {
validateTagType(tag, jsdocNode, pos, lines);
}
});
if (jsdoc.tags.length) {
if (options.requireDescription && !jsdoc.description) {
if (!hasOverride) {
context.report(
jsdocNode,
{line: startLine + 1, column: startColumn},
'Missing JSDoc description.baidu052'
);
}
}
else if (requireBlankLineAfterDescription && !/^\s+\*\s*$/.test(lines[jsdoc.tags[0].lineNumber - 1])) {
context.report(
jsdocNode,
{line: startLine + jsdoc.tags[0].lineNumber, column: startColumn},
'Expected a blank comment line between description and tags.baidu997'
);
}
}
// check for functions missing @return
if (
!hasOverride && !hasReturns && !hasConstructor && !isInterface
&& node.parent.kind !== 'get' && node.parent.kind !== 'constructor'
&& node.parent.kind !== 'set' && !isTypeClass(node)
) {
if (requireReturn || functionData.returnPresent) {
context.report(
jsdocNode,
'Missing JSDoc @' + (prefer.returns || 'returns') + ' for function.baidu052'
);
}
}
// check the parameters
var jsdocParams = Object.keys(params);
var paramIndex = 0;
var checkParams = function (param, name, parentName) {
var jsdocParam = jsdocParams[paramIndex];
var tag = params[jsdocParam];
if (jsdocParam === name) {
}
else if (tag && parentName) {
tag = tag.keys && tag.keys[parentName + '.' + name];
}
else if (tag && tag.keys) {
tag = tag.keys[name];
}
if (
tag
&& name
&& (tag.name && name !== tag.name.replace(/^.+\.(?=[^\.]+)/, ''))
) {
var row = params[jsdocParam].lineNumber;
var col = (lines[row] + '').indexOf(jsdocParam);
context.report(
jsdocNode,
{line: startLine + row, column: col},
'Expected JSDoc for "{{name}}" but found "{{jsdocName}}".baidu052',
{
name: name,
jsdocName: jsdocParam
}
);
}
else if (!hasOverride && name && !tag) {
context.report(
jsdocNode,
{line: startLine + 1, column: startColumn},
'Missing JSDoc for parameter "{{name}}".baidu052',
{name: name}
);
}
if (params[name]) {
paramIndex++;
}
};
var isArrayOrObjectPattern = function (type) {
return type === 'ArrayPattern' || type === 'ObjectPattern';
};
var namespace = [];
node.params && node.params.forEach(function check(param) {
var type = param.type;
var name = getParamName(param, params);
if (isArrayOrObjectPattern(type)) {
(param.elements || param.properties).forEach(check);
}
else if (type === 'Property' && isArrayOrObjectPattern(param.value.type)) {
if (options.requireObjectPatternParamBranchName) {
check(param.key);
}
namespace.push(getParamName(param.key));
check(param.value);
namespace.pop();
}
else {
checkParams(param, name, namespace.join('.'));
}
});
return params;
}
/**
* Validate the JSDoc node and output warnings if anything is wrong.
*
* @param {ASTNode} node The AST node to check.
* @return {void}
* @private
*/
function checkJSDoc(node) {
var jsdocNode = context.getJSDocComment(node);
// make sure only to validate JSDoc comments
if (jsdocNode) {
var jsdoc = parseDoc(jsdocNode);
if (!jsdoc) {
return;
}
var startLine = jsdocNode.loc.start.line;
var startColumn = jsdocNode.loc.start.column + 1;
var lines = jsdocNode.value.split(/\r?\n/);
if (startLine > 1) {
var codeBeforeLine = context.getSourceLines()[startLine - 2];
if (
requireEmptyLineBeforeComment
&& !/^\s*(\/[\/\*].*)?$/.test(codeBeforeLine) && !/\*\/\s*$/.test(codeBeforeLine)
) {
context.report(jsdocNode, 'Expected an empty line before JSDoc comment.baidu041');
}
}
if (options.matchDescription) {
var regex = new RegExp(options.matchDescription);
if (!regex.test(jsdoc.description)) {
context.report(
jsdocNode,
'JSDoc description does not satisfy the regex pattern({{regex}}).baidu997',
{regex: regex.toString()}
);
}
}
var params = checkBasicSyntax(node, jsdocNode, jsdoc, lines, startLine, startColumn);
var jsdocParams = Object.keys(params);
var key = jsdocParams[jsdocParams.length - 1];
var docParam = params[key];
if (!key || !docParam) {
return;
}
if (docParam.type == null || !node.params) {
return;
}
var param = node.params[node.params.length - 1];
if (!param || param.type !== 'RestElement') {
return;
}
var paramType = docParam.type.name || docParam.type.type;
if (paramType !== 'RestType') {
context.report(
jsdocNode,
{line: startLine + docParam.lineNumber, column: startColumn},
'Expected a rest type({...<Type>}) for rest parameter "{{name}}".baidu044',
{name: key}
);
}
}
else {
fns.pop();
}
}
/**
* 匹配各种 linter 和 istanbul 的注释
*
* @const
* @type {RegExp}
*/
var IGNORE_PATTERN = /^\s*(?=eslint|istanbul|jshint|jslint|jscs|globals?)/;
/**
* 匹配以 / 开始和结束的字符串
*
* @const
* @type {RegExp}
*/
var REGEX_PATTERN = /^\/.*\/$/;
/**
* 匹配 jsdoc 的开始标记
*
* @const
* @type {RegExp}
*/
var JSDOC_OPEN_PATTERN = /^\*\s*/;
var ignored = options.ignore || [];
if (!Array.isArray(ignored)) {
ignored = [ignored];
}
function isIgnored(value) {
return ignored.some(function (item) {
return REGEX_PATTERN.test(item)
? new RegExp(item.slice(1, -1)).test(value)
: item === value;
});
}
function checkBlockComments(node) {
node.comments.forEach(function (comment) {
// comment.type === 'Line'
if (comment.type !== 'Block') {
return;
}
var value = comment.value;
// JSDoc open tag
if (value.match(JSDOC_OPEN_PATTERN)) {
return;
}
// commands of Linter & istanbul
if (value.match(IGNORE_PATTERN)) {
return;
}
// ignore by configuration
if (isIgnored(value)) {
return;
}
var jsdoc = parseDoc(comment);
if (jsdoc && jsdoc.tags.length) {
context.report(comment, 'JSDoc opent tag should be `/**`, not `/*`.baidu040');
}
else if (preferLineComment) {
context.report(comment, 'Expected to use LineComment but saw BlockComment.baidu039');
}
});
}
//--------------------------------------------------------------------------
// Public
//--------------------------------------------------------------------------
return {
'Program': checkFile,
'ArrowFunctionExpression': startFunction,
'FunctionExpression': startFunction,
'FunctionDeclaration': startFunction,
'ClassExpression': startFunction,
'ClassDeclaration': startFunction,
'ArrowFunctionExpression:exit': checkJSDoc,
'FunctionExpression:exit': checkJSDoc,
'FunctionDeclaration:exit': checkJSDoc,
'ClassExpression:exit': checkJSDoc,
'ClassDeclaration:exit': checkJSDoc,
'ReturnStatement': addReturn,
'Program:exit': function (node) {
checkBlockComments(node);
cachedJSDocNodes = {};
fns.length = 0;
}
};
};
module.exports.schema = [
{
type: 'object',
properties: {
ignore: {
oneOf: [
{
type: 'string'
},
{
type: 'array',
items: {
type: 'string'
}
}
]
},
prefer: {
type: 'object',
additionalProperties: {
type: 'string'
}
},
preferType: {
type: 'object',
additionalProperties: {
type: 'string'
}
},
preferLineComment: {
type: 'boolean'
},
requireReturn: {
type: 'boolean'
},
requireAuthor: {
type: 'boolean'
},
requireDescription: {
type: 'boolean'
},
requireFileDescription: {
type: 'boolean'
},
requireParamDescription: {
type: 'boolean'
},
requireReturnType: {
type: 'boolean'
},
requireReturnDescription: {
type: 'boolean'
},
requireBlankLineAfterDescription: {
type: 'boolean'
},
requireEmptyLineBeforeComment: {
type: 'boolean'
},
requireObjectPatternParamBranchName: {
type: 'boolean'
},
matchDescription: {
type: 'string'
}
},
additionalProperties: false
}
];