diff --git a/.README/rules/check-lines-alignment.md b/.README/rules/check-lines-alignment.md new file mode 100644 index 000000000..c9338a6ab --- /dev/null +++ b/.README/rules/check-lines-alignment.md @@ -0,0 +1,64 @@ +### `check-lines-alignment` + +Reports invalid alignment of JSDoc block lines. This is a +[standard recommended to WordPress code](https://make.wordpress.org/core/handbook/best-practices/inline-documentation-standards/javascript/#aligning-comments), for example. + +#### Options + +This rule allows one optional string argument. If it is `"always"` then a +problem is raised when the lines are not aligned. If it is `"never"` then +a problem should be raised when there is more than one space between the +lines parts. Only the non-default `"always"` is implemented for now. + +||| +|---|---| +|Context|everywhere| +|Options|(a string matching `"always"|"never"`)| +|Tags|`param`, `arg`, `argument`, `property`, `prop`| + +The following patterns are considered problems: + +````js +/** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ +const fn = ( lorem, sit ) => {} +// Options: ["always"] +// Message: Expected JSDoc block lines to be aligned. + +/** + * My object. + * + * @typedef {Object} MyObject + * + * @property {string} lorem Description. + * @property {int} sit Description multi words. + */ +// Options: ["always"] +// Message: Expected JSDoc block lines to be aligned. + +The following patterns are not considered problems: + +````js +/** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ +const fn = ( lorem, sit ) => {} +// Options: ["always"] + +/** + * My object. + * + * @typedef {Object} MyObject + * + * @property {string} lorem Description. + * @property {int} sit Description multi words. + */ +// Options: ["always"] +```` diff --git a/src/index.js b/src/index.js index b9492dda1..464d576a0 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ import checkAccess from './rules/checkAccess'; import checkAlignment from './rules/checkAlignment'; import checkExamples from './rules/checkExamples'; import checkIndentation from './rules/checkIndentation'; +import checkLinesAlignment from './rules/checkLinesAlignment'; import checkParamNames from './rules/checkParamNames'; import checkPropertyNames from './rules/checkPropertyNames'; import checkSyntax from './rules/checkSyntax'; @@ -47,6 +48,7 @@ export default { 'jsdoc/check-alignment': 'warn', 'jsdoc/check-examples': 'off', 'jsdoc/check-indentation': 'off', + 'jsdoc/check-lines-alignment': 'off', 'jsdoc/check-param-names': 'warn', 'jsdoc/check-property-names': 'warn', 'jsdoc/check-syntax': 'off', @@ -88,6 +90,7 @@ export default { 'check-alignment': checkAlignment, 'check-examples': checkExamples, 'check-indentation': checkIndentation, + 'check-lines-alignment': checkLinesAlignment, 'check-param-names': checkParamNames, 'check-property-names': checkPropertyNames, 'check-syntax': checkSyntax, diff --git a/src/rules/checkLinesAlignment.js b/src/rules/checkLinesAlignment.js new file mode 100644 index 000000000..2f0eb65c3 --- /dev/null +++ b/src/rules/checkLinesAlignment.js @@ -0,0 +1,212 @@ +import { + set, +} from 'lodash'; +import iterateJsdoc from '../iterateJsdoc'; + +/** + * Aux method until we consider the dev envs support `String.prototype.matchAll` (Node 12+). + * + * @param {string} string String that will be checked. + * @param {RegExp} regexp Regular expression to run. + * @param {Function} callback Function to be called each iteration. + * @param {int} limit Limit of matches that we want to exec. + * + * @todo [engine:node@>=12]: Remove function and use `String.prototype.matchAll` instead. + */ +const matchAll = (string, regexp, callback, limit) => { + let result; + let index = 0; + + while ((result = regexp.exec(string)) && index <= limit - 1) { + // eslint-disable-next-line promise/prefer-await-to-callbacks + callback(result, index++); + } +}; + +/** + * Get the full description from a line. + * + * @param {string} lineString The line string. + * + * @returns {string} The full description. + */ +const getFullDescription = (lineString) => { + return /(?:\S+\s+){4}(.*)/.exec(lineString)[1]; +}; + +/** + * Get the expected positions for each part. + * + * @param {int[]} partsMaxLength Max length of each part. + * @param {int} indentLevel JSDoc indent level. + * + * @returns {int[]} Expected position for each part. + */ +const getExpectedPositions = (partsMaxLength, indentLevel) => { + // eslint-disable-next-line unicorn/no-reduce + return partsMaxLength.reduce( + (acc, cur, index) => { + return [...acc, cur + acc[index] + 1]; + }, + [indentLevel], + ); +}; + +/** + * Check is not aligned. + * + * @param {int[]} expectedPositions Expected position for each part. + * @param {Array[]} partsMatrix Parts matrix. + * + * @returns {boolean} + */ +const isNotAligned = (expectedPositions, partsMatrix) => { + return partsMatrix.some((line) => { + return line.some( + ({position}, partIndex) => { + return position !== expectedPositions[partIndex]; + }, + ); + }); +}; + +/** + * Fix function creator for the report. It creates a function which fix + * the JSDoc with the correct alignment. + * + * @param {object} comment Comment node. + * @param {int[]} expectedPositions Array with the expected positions. + * @param {Array[]} partsMatrix Parts matrix. + * @param {RegExp} lineRegExp Line regular expression. + * @param {string} tagIndentation Tag indentation. + * + * @returns {Function} Function which fixes the JSDoc alignment. + */ +const createFixer = (comment, expectedPositions, partsMatrix, lineRegExp, tagIndentation) => { + return (fixer) => { + let lineIndex = 0; + + // Replace every line with the correct spacings. + const fixed = comment.value.replace(lineRegExp, () => { + // eslint-disable-next-line unicorn/no-reduce + return partsMatrix[lineIndex++].reduce( + (acc, {string}, index) => { + const spacings = ' '.repeat(expectedPositions[index] - acc.length); + + return acc + (index === 0 ? tagIndentation : spacings) + string; + }, + '', + ); + }); + + return fixer.replaceText(comment, '/*' + fixed + '*/'); + }; +}; + +/** + * Check comment per tag. + * + * @param {object} comment Comment node. + * @param {string} tag Tag string. + * @param {string} tagIndentation Tag indentation. + * @param {Function} report Report function. + */ +const checkCommentPerTag = (comment, tag, tagIndentation, report) => { + const lineRegExp = new RegExp(`.*@${tag}[\\s].*`, 'gm'); + const lines = comment.value.match(lineRegExp); + + if (!lines) { + return; + } + + /** + * A matrix containing the current position and the string of each part for each line. + * 0 - Asterisk. + * 1 - Tag. + * 2 - Type. + * 3 - Variable name. + * 4 - Description (Optional). + */ + const partsMatrix = []; + + /** + * The max length of each part, comparing all the lines. + */ + const partsMaxLength = []; + + // Loop (lines x parts) to populate partsMatrix and partsMaxLength. + lines.forEach((lineString, lineIndex) => { + // All line parts until the first word of the description (if description exists). + matchAll( + lineString, + /\S+/g, + ({0: match, index: position}, partIndex) => { + set(partsMatrix, [lineIndex, partIndex], { + position, + string: partIndex === 4 ? getFullDescription(lineString) : match, + }); + + const partLength = match.length; + const maxLength = partsMaxLength[partIndex]; + + partsMaxLength[partIndex] = maxLength > partLength ? maxLength : partLength; + }, + 5, + ); + }); + + const expectedPositions = getExpectedPositions(partsMaxLength, tagIndentation.length); + + if (isNotAligned(expectedPositions, partsMatrix)) { + report( + 'Expected JSDoc block lines to be aligned.', + createFixer( + comment, + expectedPositions, + partsMatrix, + lineRegExp, + tagIndentation, + ), + ); + } +}; + +export default iterateJsdoc(({ + jsdocNode, + report, + context, + indent, +}) => { + if (context.options[0] === 'never') { + report('The `never` option is not yet implemented for this rule.'); + + return; + } + + if (context.options[0] !== 'always') { + return; + } + + // `indent` is whitespace from line 1 (`/**`), so slice and account for "/". + const tagIndentation = indent + ' '; + + ['param', 'arg', 'argument', 'property', 'prop'].forEach((tag) => { + checkCommentPerTag(jsdocNode, tag, tagIndentation, report); + }); +}, { + iterateAllJsdocs: true, + meta: { + docs: { + description: 'Reports invalid alignment of JSDoc block lines.', + url: 'https://github.com/gajus/eslint-plugin-jsdoc#eslint-plugin-jsdoc-rules-check-lines-alignment', + }, + fixable: 'whitespace', + schema: [ + { + enum: ['always', 'never'], + type: 'string', + }, + ], + type: 'layout', + }, +}); diff --git a/test/rules/assertions/checkLinesAlignment.js b/test/rules/assertions/checkLinesAlignment.js new file mode 100644 index 000000000..b58917690 --- /dev/null +++ b/test/rules/assertions/checkLinesAlignment.js @@ -0,0 +1,564 @@ +export default { + invalid: [ + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem - Description. + * @param {int} sit - Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @param {string} lorem - Description. + * @param {int} sit - Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + function fn( lorem, sit ) {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + function fn( lorem, sit ) {} + `, + }, + { + code: ` + const object = { + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + fn( lorem, sit ) {} + } + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + const object = { + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + fn( lorem, sit ) {} + } + `, + }, + { + code: ` + class ClassName { + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + fn( lorem, sit ) {} + } + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + class ClassName { + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + fn( lorem, sit ) {} + } + `, + }, + { + code: ` + /** + * Function description. + * + * @arg {string} lorem Description. + * @arg {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * Function description. + * + * @arg {string} lorem Description. + * @arg {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + { + code: ` + /** + * @namespace + * @property {object} defaults Description. + * @property {int} defaults.lorem Description multi words. + */ + const config = { + defaults: { + lorem: 1 + } + } + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * @namespace + * @property {object} defaults Description. + * @property {int} defaults.lorem Description multi words. + */ + const config = { + defaults: { + lorem: 1 + } + } + `, + }, + { + code: ` + /** + * My object. + * + * @typedef {Object} MyObject + * + * @property {string} lorem Description. + * @property {int} sit Description multi words. + */ + `, + errors: [ + { + message: 'Expected JSDoc block lines to be aligned.', + type: 'Block', + }, + ], + options: [ + 'always', + ], + output: ` + /** + * My object. + * + * @typedef {Object} MyObject + * + * @property {string} lorem Description. + * @property {int} sit Description multi words. + */ + `, + }, + { + code: ` + /** + * Not implemented yet. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + errors: [ + { + message: 'The `never` option is not yet implemented for this rule.', + type: 'Block', + }, + ], + options: [ + 'never', + ], + }, + ], + valid: [ + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem - Description. + * @param {int} sit - Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * @param {string} lorem Description. + * @param {int} sit + */ + const fn = ( lorem, sit ) => {} + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * @param {int} sit + * @param {string} lorem Description. + */ + const fn = ( lorem, sit ) => {} + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * No params. + */ + const fn = () => {} + `, + options: [ + 'always', + ], + }, + { + code: ` + const fn = ( lorem, sit ) => {} + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + function fn( lorem, sit ) {} + `, + options: [ + 'always', + ], + }, + { + code: ` + const object = { + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + fn( lorem, sit ) {}, + } + `, + options: [ + 'always', + ], + }, + { + code: ` + class ClassName { + /** + * Function description. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + fn( lorem, sit ) {} + } + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * Function description. + * + * @arg {string} lorem Description. + * @arg {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * @namespace + * @property {object} defaults Description. + * @property {int} defaults.lorem Description multi words. + */ + const config = { + defaults: { + lorem: 1 + } + } + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * My object. + * + * @typedef {Object} MyObject + * + * @property {string} lorem Description. + * @property {int} sit Description multi words. + */ + `, + options: [ + 'always', + ], + }, + { + code: ` + /** + * Not validating without option. + * + * @param {string} lorem Description. + * @param {int} sit Description multi words. + */ + const fn = ( lorem, sit ) => {} + `, + }, + ], +}; diff --git a/test/rules/index.js b/test/rules/index.js index 5882f9476..09f4622dd 100644 --- a/test/rules/index.js +++ b/test/rules/index.js @@ -9,6 +9,7 @@ const ruleTester = new RuleTester(); (process.env.npm_config_rule ? process.env.npm_config_rule.split(',') : [ 'check-access', 'check-alignment', + 'check-lines-alignment', 'check-examples', 'check-indentation', 'check-param-names',