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