1 | /**
|
2 | * @fileoverview Validates JSDoc comments are syntactically correct
|
3 | * @author Nicholas C. Zakas
|
4 | */
|
5 | ;
|
6 |
|
7 | //------------------------------------------------------------------------------
|
8 | // Requirements
|
9 | //------------------------------------------------------------------------------
|
10 |
|
11 | const doctrine = require("doctrine");
|
12 |
|
13 | //------------------------------------------------------------------------------
|
14 | // Rule Definition
|
15 | //------------------------------------------------------------------------------
|
16 |
|
17 | module.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 | if (param.type === "AssignmentPattern") {
|
424 | param = param.left;
|
425 | }
|
426 |
|
427 | const name = param.name;
|
428 |
|
429 | // TODO(nzakas): Figure out logical things to do with destructured, default, rest params
|
430 | if (param.type === "Identifier") {
|
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 | };
|