UNPKG

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