| import iterateJsdoc from '../iterateJsdoc.js'; |
| import escapeStringRegexp from 'escape-string-regexp'; |
| |
| const otherDescriptiveTags = new Set([ |
| 'classdesc', 'deprecated', 'exception', 'file', 'fileoverview', 'overview', |
| // 'copyright' and 'see' might be good addition, but as the former may be |
| // sensitive text, and the latter may have just a link, they are not |
| // included by default |
| 'summary', 'throws', 'todo', 'yield', 'yields', |
| ]); |
| |
| /** |
| * @param {string} text |
| * @returns {string[]} |
| */ |
| const extractParagraphs = (text) => { |
| return text.split(/(?<![;:])\n\n+/v); |
| }; |
| |
| /** |
| * @param {string} text |
| * @param {string|RegExp} abbreviationsRegex |
| * @returns {string[]} |
| */ |
| const extractSentences = (text, abbreviationsRegex) => { |
| const txt = text |
| // Remove all {} tags. |
| .replaceAll(/(?<!^)\{[\s\S]*?\}\s*/gv, '') |
| |
| // Remove custom abbreviations |
| .replace(abbreviationsRegex, ''); |
| |
| const sentenceEndGrouping = /([.?!])(?:\s+|$)/gv; |
| |
| const puncts = [ |
| ...txt.matchAll(sentenceEndGrouping), |
| ].map((sentEnd) => { |
| return sentEnd[0]; |
| }); |
| |
| return txt |
| .split(/[.?!](?:\s+|$)/v) |
| |
| // Re-add the dot. |
| .map((sentence, idx) => { |
| return !puncts[idx] && /^\s*$/v.test(sentence) ? sentence : `${sentence}${puncts[idx] || ''}`; |
| }); |
| }; |
| |
| /** |
| * @param {string} text |
| * @returns {boolean} |
| */ |
| const isNewLinePrecededByAPeriod = (text) => { |
| /** @type {boolean} */ |
| let lastLineEndsSentence; |
| |
| const lines = text.split('\n'); |
| |
| return !lines.some((line) => { |
| if (lastLineEndsSentence === false && /^[A-Z][a-z]/v.test(line)) { |
| return true; |
| } |
| |
| lastLineEndsSentence = /[.:?!\|]$/v.test(line); |
| |
| return false; |
| }); |
| }; |
| |
| /** |
| * @param {string} str |
| * @returns {boolean} |
| */ |
| const isCapitalized = (str) => { |
| return str[0] === str[0].toUpperCase(); |
| }; |
| |
| /** |
| * @param {string} str |
| * @returns {boolean} |
| */ |
| const isTable = (str) => { |
| return str.charAt(0) === '|'; |
| }; |
| |
| /** |
| * @param {string} str |
| * @returns {string} |
| */ |
| const capitalize = (str) => { |
| return str.charAt(0).toUpperCase() + str.slice(1); |
| }; |
| |
| /** |
| * @param {string} description |
| * @param {import('../iterateJsdoc.js').Report} reportOrig |
| * @param {import('eslint').Rule.Node} jsdocNode |
| * @param {string|RegExp} abbreviationsRegex |
| * @param {import('eslint').SourceCode} sourceCode |
| * @param {import('comment-parser').Spec|{ |
| * line: import('../iterateJsdoc.js').Integer |
| * }} tag |
| * @param {boolean} newlineBeforeCapsAssumesBadSentenceEnd |
| * @returns {boolean} |
| */ |
| const validateDescription = ( |
| description, reportOrig, jsdocNode, abbreviationsRegex, |
| sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd, |
| ) => { |
| if (!description || (/^\n+$/v).test(description)) { |
| return false; |
| } |
| |
| const descriptionNoHeadings = description.replaceAll(/^\s*#[^\n]*(\n|$)/gmv, ''); |
| |
| const paragraphs = extractParagraphs(descriptionNoHeadings).filter(Boolean); |
| |
| return paragraphs.some((paragraph, parIdx) => { |
| const sentences = extractSentences(paragraph, abbreviationsRegex); |
| |
| const fix = /** @type {import('eslint').Rule.ReportFixer} */ (fixer) => { |
| let text = sourceCode.getText(jsdocNode); |
| |
| if (!/[.:?!]$/v.test(paragraph)) { |
| const line = paragraph.split('\n').findLast(Boolean); |
| text = text.replace(new RegExp(`${escapeStringRegexp( |
| /** @type {string} */ |
| (line), |
| )}$`, 'mv'), `${line}.`); |
| } |
| |
| for (const sentence of sentences.filter((sentence_) => { |
| return !(/^\s*$/v).test(sentence_) && !isCapitalized(sentence_) && |
| !isTable(sentence_); |
| })) { |
| const beginning = sentence.split('\n')[0]; |
| |
| if ('tag' in tag && tag.tag) { |
| const reg = new RegExp(`(@${escapeStringRegexp(tag.tag)}.*)${escapeStringRegexp(beginning)}`, 'v'); |
| |
| text = text.replace(reg, (_$0, $1) => { |
| return $1 + capitalize(beginning); |
| }); |
| } else { |
| text = text.replace(new RegExp('((?:[.?!]|\\*|\\})\\s*)' + escapeStringRegexp(beginning), 'v'), '$1' + capitalize(beginning)); |
| } |
| } |
| |
| return fixer.replaceText(jsdocNode, text); |
| }; |
| |
| /** |
| * @param {string} msg |
| * @param {import('eslint').Rule.ReportFixer | null | undefined} fixer |
| * @param {{ |
| * line?: number | undefined; |
| * column?: number | undefined; |
| * } | (import('comment-parser').Spec & { |
| * line?: number | undefined; |
| * column?: number | undefined; |
| * })} tagObj |
| * @returns {void} |
| */ |
| const report = (msg, fixer, tagObj) => { |
| if ('line' in tagObj) { |
| /** |
| * @type {{ |
| * line: number; |
| * }} |
| */ (tagObj).line += parIdx * 2; |
| } else { |
| /** @type {import('comment-parser').Spec} */ ( |
| tagObj |
| ).source[0].number += parIdx * 2; |
| } |
| |
| // Avoid errors if old column doesn't exist here |
| tagObj.column = 0; |
| reportOrig(msg, fixer, tagObj); |
| }; |
| |
| if (sentences.some((sentence) => { |
| return (/^[.?!]$/v).test(sentence); |
| })) { |
| report('Sentences must be more than punctuation.', null, tag); |
| } |
| |
| if (sentences.some((sentence) => { |
| return !(/^\s*$/v).test(sentence) && !isCapitalized(sentence) && !isTable(sentence); |
| })) { |
| report('Sentences should start with an uppercase character.', fix, tag); |
| } |
| |
| const paragraphNoAbbreviations = paragraph.replace(abbreviationsRegex, ''); |
| |
| if (!/(?:[.?!\|]|```)\s*$/v.test(paragraphNoAbbreviations)) { |
| report('Sentences must end with a period.', fix, tag); |
| return true; |
| } |
| |
| if (newlineBeforeCapsAssumesBadSentenceEnd && !isNewLinePrecededByAPeriod(paragraphNoAbbreviations)) { |
| report('A line of text is started with an uppercase character, but the preceding line does not end the sentence.', null, tag); |
| |
| return true; |
| } |
| |
| return false; |
| }); |
| }; |
| |
| export default iterateJsdoc(({ |
| context, |
| jsdoc, |
| jsdocNode, |
| report, |
| sourceCode, |
| utils, |
| }) => { |
| const /** @type {{abbreviations: string[], newlineBeforeCapsAssumesBadSentenceEnd: boolean}} */ { |
| abbreviations = [], |
| newlineBeforeCapsAssumesBadSentenceEnd = false, |
| } = context.options[0] || {}; |
| |
| const abbreviationsRegex = abbreviations.length ? |
| new RegExp('\\b' + abbreviations.map((abbreviation) => { |
| return escapeStringRegexp(abbreviation.replaceAll(/\.$/gv, '') + '.'); |
| }).join('|') + '(?:$|\\s)', 'gv') : |
| ''; |
| |
| let { |
| description, |
| } = utils.getDescription(); |
| |
| const indices = [ |
| ...description.matchAll(/```[\s\S]*```/gv), |
| ].map((match) => { |
| const { |
| index, |
| } = match; |
| const [ |
| { |
| length, |
| }, |
| ] = match; |
| return { |
| index, |
| length, |
| }; |
| }).toReversed(); |
| |
| for (const { |
| index, |
| length, |
| } of indices) { |
| description = description.slice(0, index) + |
| description.slice(/** @type {import('../iterateJsdoc.js').Integer} */ ( |
| index |
| ) + length); |
| } |
| |
| if (validateDescription(description, report, jsdocNode, abbreviationsRegex, sourceCode, { |
| line: jsdoc.source[0].number + 1, |
| }, newlineBeforeCapsAssumesBadSentenceEnd)) { |
| return; |
| } |
| |
| utils.forEachPreferredTag('description', (matchingJsdocTag) => { |
| const desc = `${matchingJsdocTag.name} ${utils.getTagDescription(matchingJsdocTag)}`.trim(); |
| validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, matchingJsdocTag, newlineBeforeCapsAssumesBadSentenceEnd); |
| }, true); |
| |
| const { |
| tagsWithNames, |
| } = utils.getTagsByType(jsdoc.tags); |
| const tagsWithoutNames = utils.filterTags(({ |
| tag: tagName, |
| }) => { |
| return otherDescriptiveTags.has(tagName) || |
| utils.hasOptionTag(tagName) && !tagsWithNames.some(({ |
| tag, |
| }) => { |
| // If user accidentally adds tags with names (or like `returns` |
| // get parsed as having names), do not add to this list |
| return tag === tagName; |
| }); |
| }); |
| |
| tagsWithNames.some((tag) => { |
| const desc = /** @type {string} */ ( |
| utils.getTagDescription(tag) |
| ).replace(/^- /v, '').trimEnd(); |
| |
| return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd); |
| }); |
| |
| tagsWithoutNames.some((tag) => { |
| const desc = `${tag.name} ${utils.getTagDescription(tag)}`.trim(); |
| |
| return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd); |
| }); |
| }, { |
| iterateAllJsdocs: true, |
| meta: { |
| docs: { |
| description: 'Requires that block description, explicit `@description`, and `@param`/`@returns` tag descriptions are written in complete sentences.', |
| url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-description-complete-sentence.md#repos-sticky-header', |
| }, |
| fixable: 'code', |
| schema: [ |
| { |
| additionalProperties: false, |
| properties: { |
| abbreviations: { |
| description: `You can provide an \`abbreviations\` options array to avoid such strings of text |
| being treated as sentence endings when followed by dots. The \`.\` is not |
| necessary at the end of the array items.`, |
| items: { |
| type: 'string', |
| }, |
| type: 'array', |
| }, |
| newlineBeforeCapsAssumesBadSentenceEnd: { |
| description: `When \`false\` (the new default), we will not assume capital letters after |
| newlines are an incorrect way to end the sentence (they may be proper |
| nouns, for example).`, |
| type: 'boolean', |
| }, |
| tags: { |
| description: `If you want additional tags to be checked for their descriptions, you may |
| add them within this option. |
| |
| \`\`\`js |
| { |
| 'jsdoc/require-description-complete-sentence': ['error', { |
| tags: ['see', 'copyright'] |
| }] |
| } |
| \`\`\` |
| |
| The tags \`@param\`/\`@arg\`/\`@argument\` and \`@property\`/\`@prop\` will be properly |
| parsed to ensure that the checked "description" text includes only the text |
| after the name. |
| |
| All other tags will treat the text following the tag name, a space, and |
| an optional curly-bracketed type expression (and another space) as part of |
| its "description" (e.g., for \`@returns {someType} some description\`, the |
| description is \`some description\` while for \`@some-tag xyz\`, the description |
| is \`xyz\`).`, |
| items: { |
| type: 'string', |
| }, |
| type: 'array', |
| }, |
| }, |
| type: 'object', |
| }, |
| ], |
| type: 'suggestion', |
| }, |
| }); |