UNPKG

19.3 kBJavaScriptView Raw
1/**
2 * @fileoverview Validates JSDoc comments are syntactically correct
3 * @author Nicholas C. Zakas
4 */
5"use strict";
6
7//------------------------------------------------------------------------------
8// Requirements
9//------------------------------------------------------------------------------
10
11const doctrine = require("doctrine");
12
13//------------------------------------------------------------------------------
14// Rule Definition
15//------------------------------------------------------------------------------
16
17module.exports = {
18 meta: {
19 docs: {
20 description: "enforce valid JSDoc comments",
21 category: "Possible Errors",
22 recommended: false,
23 url: "https://eslint.org/docs/rules/valid-jsdoc"
24 },
25
26 schema: [
27 {
28 type: "object",
29 properties: {
30 prefer: {
31 type: "object",
32 additionalProperties: {
33 type: "string"
34 }
35 },
36 preferType: {
37 type: "object",
38 additionalProperties: {
39 type: "string"
40 }
41 },
42 requireReturn: {
43 type: "boolean"
44 },
45 requireParamDescription: {
46 type: "boolean"
47 },
48 requireReturnDescription: {
49 type: "boolean"
50 },
51 matchDescription: {
52 type: "string"
53 },
54 requireReturnType: {
55 type: "boolean"
56 }
57 },
58 additionalProperties: false
59 }
60 ],
61
62 fixable: "code"
63 },
64
65 create(context) {
66
67 const options = context.options[0] || {},
68 prefer = options.prefer || {},
69 sourceCode = context.getSourceCode(),
70
71 // these both default to true, so you have to explicitly make them false
72 requireReturn = options.requireReturn !== false,
73 requireParamDescription = options.requireParamDescription !== false,
74 requireReturnDescription = options.requireReturnDescription !== false,
75 requireReturnType = options.requireReturnType !== false,
76 preferType = options.preferType || {},
77 checkPreferType = Object.keys(preferType).length !== 0;
78
79 //--------------------------------------------------------------------------
80 // Helpers
81 //--------------------------------------------------------------------------
82
83 // Using a stack to store if a function returns or not (handling nested functions)
84 const fns = [];
85
86 /**
87 * Check if node type is a Class
88 * @param {ASTNode} node node to check.
89 * @returns {boolean} True is its a class
90 * @private
91 */
92 function isTypeClass(node) {
93 return node.type === "ClassExpression" || node.type === "ClassDeclaration";
94 }
95
96 /**
97 * When parsing a new function, store it in our function stack.
98 * @param {ASTNode} node A function node to check.
99 * @returns {void}
100 * @private
101 */
102 function startFunction(node) {
103 fns.push({
104 returnPresent: (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") ||
105 isTypeClass(node)
106 });
107 }
108
109 /**
110 * Indicate that return has been found in the current function.
111 * @param {ASTNode} node The return node.
112 * @returns {void}
113 * @private
114 */
115 function addReturn(node) {
116 const functionState = fns[fns.length - 1];
117
118 if (functionState && node.argument !== null) {
119 functionState.returnPresent = true;
120 }
121 }
122
123 /**
124 * Check if return tag type is void or undefined
125 * @param {Object} tag JSDoc tag
126 * @returns {boolean} True if its of type void or undefined
127 * @private
128 */
129 function isValidReturnType(tag) {
130 return tag.type === null || tag.type.name === "void" || tag.type.type === "UndefinedLiteral";
131 }
132
133 /**
134 * Check if type should be validated based on some exceptions
135 * @param {Object} type JSDoc tag
136 * @returns {boolean} True if it can be validated
137 * @private
138 */
139 function canTypeBeValidated(type) {
140 return type !== "UndefinedLiteral" && // {undefined} as there is no name property available.
141 type !== "NullLiteral" && // {null}
142 type !== "NullableLiteral" && // {?}
143 type !== "FunctionType" && // {function(a)}
144 type !== "AllLiteral"; // {*}
145 }
146
147 /**
148 * Extract the current and expected type based on the input type object
149 * @param {Object} type JSDoc tag
150 * @returns {{currentType: Doctrine.Type, expectedTypeName: string}} The current type annotation and
151 * the expected name of the annotation
152 * @private
153 */
154 function getCurrentExpectedTypes(type) {
155 let currentType;
156
157 if (type.name) {
158 currentType = type;
159 } else if (type.expression) {
160 currentType = type.expression;
161 }
162
163 return {
164 currentType,
165 expectedTypeName: currentType && preferType[currentType.name]
166 };
167 }
168
169 /**
170 * Gets the location of a JSDoc node in a file
171 * @param {Token} jsdocComment The comment that this node is parsed from
172 * @param {{range: number[]}} parsedJsdocNode A tag or other node which was parsed from this comment
173 * @returns {{start: SourceLocation, end: SourceLocation}} The 0-based source location for the tag
174 */
175 function getAbsoluteRange(jsdocComment, parsedJsdocNode) {
176 return {
177 start: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[0]),
178 end: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[1])
179 };
180 }
181
182 /**
183 * Validate type for a given JSDoc node
184 * @param {Object} jsdocNode JSDoc node
185 * @param {Object} type JSDoc tag
186 * @returns {void}
187 * @private
188 */
189 function validateType(jsdocNode, type) {
190 if (!type || !canTypeBeValidated(type.type)) {
191 return;
192 }
193
194 const typesToCheck = [];
195 let elements = [];
196
197 switch (type.type) {
198 case "TypeApplication": // {Array.<String>}
199 elements = type.applications[0].type === "UnionType" ? type.applications[0].elements : type.applications;
200 typesToCheck.push(getCurrentExpectedTypes(type));
201 break;
202 case "RecordType": // {{20:String}}
203 elements = type.fields;
204 break;
205 case "UnionType": // {String|number|Test}
206 case "ArrayType": // {[String, number, Test]}
207 elements = type.elements;
208 break;
209 case "FieldType": // Array.<{count: number, votes: number}>
210 if (type.value) {
211 typesToCheck.push(getCurrentExpectedTypes(type.value));
212 }
213 break;
214 default:
215 typesToCheck.push(getCurrentExpectedTypes(type));
216 }
217
218 elements.forEach(validateType.bind(null, jsdocNode));
219
220 typesToCheck.forEach(typeToCheck => {
221 if (typeToCheck.expectedTypeName &&
222 typeToCheck.expectedTypeName !== typeToCheck.currentType.name) {
223 context.report({
224 node: jsdocNode,
225 message: "Use '{{expectedTypeName}}' instead of '{{currentTypeName}}'.",
226 loc: getAbsoluteRange(jsdocNode, typeToCheck.currentType),
227 data: {
228 currentTypeName: typeToCheck.currentType.name,
229 expectedTypeName: typeToCheck.expectedTypeName
230 },
231 fix(fixer) {
232 return fixer.replaceTextRange(
233 typeToCheck.currentType.range.map(indexInComment => jsdocNode.range[0] + 2 + indexInComment),
234 typeToCheck.expectedTypeName
235 );
236 }
237 });
238 }
239 });
240 }
241
242 /**
243 * Validate the JSDoc node and output warnings if anything is wrong.
244 * @param {ASTNode} node The AST node to check.
245 * @returns {void}
246 * @private
247 */
248 function checkJSDoc(node) {
249 const jsdocNode = sourceCode.getJSDocComment(node),
250 functionData = fns.pop(),
251 paramTagsByName = Object.create(null),
252 paramTags = [];
253 let hasReturns = false,
254 returnsTag,
255 hasConstructor = false,
256 isInterface = false,
257 isOverride = false,
258 isAbstract = false;
259
260 // make sure only to validate JSDoc comments
261 if (jsdocNode) {
262 let jsdoc;
263
264 try {
265 jsdoc = doctrine.parse(jsdocNode.value, {
266 strict: true,
267 unwrap: true,
268 sloppy: true,
269 range: true
270 });
271 } catch (ex) {
272
273 if (/braces/i.test(ex.message)) {
274 context.report({ node: jsdocNode, message: "JSDoc type missing brace." });
275 } else {
276 context.report({ node: jsdocNode, message: "JSDoc syntax error." });
277 }
278
279 return;
280 }
281
282 jsdoc.tags.forEach(tag => {
283
284 switch (tag.title.toLowerCase()) {
285
286 case "param":
287 case "arg":
288 case "argument":
289 paramTags.push(tag);
290 break;
291
292 case "return":
293 case "returns":
294 hasReturns = true;
295 returnsTag = tag;
296 break;
297
298 case "constructor":
299 case "class":
300 hasConstructor = true;
301 break;
302
303 case "override":
304 case "inheritdoc":
305 isOverride = true;
306 break;
307
308 case "abstract":
309 case "virtual":
310 isAbstract = true;
311 break;
312
313 case "interface":
314 isInterface = true;
315 break;
316
317 // no default
318 }
319
320 // check tag preferences
321 if (prefer.hasOwnProperty(tag.title) && tag.title !== prefer[tag.title]) {
322 const entireTagRange = getAbsoluteRange(jsdocNode, tag);
323
324 context.report({
325 node: jsdocNode,
326 message: "Use @{{name}} instead.",
327 loc: {
328 start: entireTagRange.start,
329 end: {
330 line: entireTagRange.start.line,
331 column: entireTagRange.start.column + `@${tag.title}`.length
332 }
333 },
334 data: { name: prefer[tag.title] },
335 fix(fixer) {
336 return fixer.replaceTextRange(
337 [
338 jsdocNode.range[0] + tag.range[0] + 3,
339 jsdocNode.range[0] + tag.range[0] + tag.title.length + 3
340 ],
341 prefer[tag.title]
342 );
343 }
344 });
345 }
346
347 // validate the types
348 if (checkPreferType && tag.type) {
349 validateType(jsdocNode, tag.type);
350 }
351 });
352
353 paramTags.forEach(param => {
354 if (!param.type) {
355 context.report({
356 node: jsdocNode,
357 message: "Missing JSDoc parameter type for '{{name}}'.",
358 loc: getAbsoluteRange(jsdocNode, param),
359 data: { name: param.name }
360 });
361 }
362 if (!param.description && requireParamDescription) {
363 context.report({
364 node: jsdocNode,
365 message: "Missing JSDoc parameter description for '{{name}}'.",
366 loc: getAbsoluteRange(jsdocNode, param),
367 data: { name: param.name }
368 });
369 }
370 if (paramTagsByName[param.name]) {
371 context.report({
372 node: jsdocNode,
373 message: "Duplicate JSDoc parameter '{{name}}'.",
374 loc: getAbsoluteRange(jsdocNode, param),
375 data: { name: param.name }
376 });
377 } else if (param.name.indexOf(".") === -1) {
378 paramTagsByName[param.name] = param;
379 }
380 });
381
382 if (hasReturns) {
383 if (!requireReturn && !functionData.returnPresent && (returnsTag.type === null || !isValidReturnType(returnsTag)) && !isAbstract) {
384 context.report({
385 node: jsdocNode,
386 message: "Unexpected @{{title}} tag; function has no return statement.",
387 loc: getAbsoluteRange(jsdocNode, returnsTag),
388 data: {
389 title: returnsTag.title
390 }
391 });
392 } else {
393 if (requireReturnType && !returnsTag.type) {
394 context.report({ node: jsdocNode, message: "Missing JSDoc return type." });
395 }
396
397 if (!isValidReturnType(returnsTag) && !returnsTag.description && requireReturnDescription) {
398 context.report({ node: jsdocNode, message: "Missing JSDoc return description." });
399 }
400 }
401 }
402
403 // check for functions missing @returns
404 if (!isOverride && !hasReturns && !hasConstructor && !isInterface &&
405 node.parent.kind !== "get" && node.parent.kind !== "constructor" &&
406 node.parent.kind !== "set" && !isTypeClass(node)) {
407 if (requireReturn || functionData.returnPresent) {
408 context.report({
409 node: jsdocNode,
410 message: "Missing JSDoc @{{returns}} for function.",
411 data: {
412 returns: prefer.returns || "returns"
413 }
414 });
415 }
416 }
417
418 // check the parameters
419 const jsdocParamNames = Object.keys(paramTagsByName);
420
421 if (node.params) {
422 node.params.forEach((param, paramsIndex) => {
423 const bindingParam = param.type === "AssignmentPattern"
424 ? param.left
425 : param;
426
427 // TODO(nzakas): Figure out logical things to do with destructured, default, rest params
428 if (bindingParam.type === "Identifier") {
429 const name = bindingParam.name;
430
431 if (jsdocParamNames[paramsIndex] && (name !== jsdocParamNames[paramsIndex])) {
432 context.report({
433 node: jsdocNode,
434 message: "Expected JSDoc for '{{name}}' but found '{{jsdocName}}'.",
435 loc: getAbsoluteRange(jsdocNode, paramTagsByName[jsdocParamNames[paramsIndex]]),
436 data: {
437 name,
438 jsdocName: jsdocParamNames[paramsIndex]
439 }
440 });
441 } else if (!paramTagsByName[name] && !isOverride) {
442 context.report({
443 node: jsdocNode,
444 message: "Missing JSDoc for parameter '{{name}}'.",
445 data: {
446 name
447 }
448 });
449 }
450 }
451 });
452 }
453
454 if (options.matchDescription) {
455 const regex = new RegExp(options.matchDescription);
456
457 if (!regex.test(jsdoc.description)) {
458 context.report({ node: jsdocNode, message: "JSDoc description does not satisfy the regex pattern." });
459 }
460 }
461
462 }
463
464 }
465
466 //--------------------------------------------------------------------------
467 // Public
468 //--------------------------------------------------------------------------
469
470 return {
471 ArrowFunctionExpression: startFunction,
472 FunctionExpression: startFunction,
473 FunctionDeclaration: startFunction,
474 ClassExpression: startFunction,
475 ClassDeclaration: startFunction,
476 "ArrowFunctionExpression:exit": checkJSDoc,
477 "FunctionExpression:exit": checkJSDoc,
478 "FunctionDeclaration:exit": checkJSDoc,
479 "ClassExpression:exit": checkJSDoc,
480 "ClassDeclaration:exit": checkJSDoc,
481 ReturnStatement: addReturn
482 };
483
484 }
485};