requireDescriptionCompleteSentence.js 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. "use strict";
  2. Object.defineProperty(exports, "__esModule", {
  3. value: true
  4. });
  5. exports.default = void 0;
  6. var _iterateJsdoc = _interopRequireDefault(require("../iterateJsdoc.js"));
  7. var _escapeStringRegexp = _interopRequireDefault(require("escape-string-regexp"));
  8. function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
  9. const otherDescriptiveTags = new Set([
  10. // 'copyright' and 'see' might be good addition, but as the former may be
  11. // sensitive text, and the latter may have just a link, they are not
  12. // included by default
  13. 'summary', 'file', 'fileoverview', 'overview', 'classdesc', 'todo', 'deprecated', 'throws', 'exception', 'yields', 'yield']);
  14. /**
  15. * @param {string} text
  16. * @returns {string[]}
  17. */
  18. const extractParagraphs = text => {
  19. return text.split(/(?<![;:])\n\n/u);
  20. };
  21. /**
  22. * @param {string} text
  23. * @param {string|RegExp} abbreviationsRegex
  24. * @returns {string[]}
  25. */
  26. const extractSentences = (text, abbreviationsRegex) => {
  27. const txt = text
  28. // Remove all {} tags.
  29. .replaceAll(/(?<!^)\{[\s\S]*?\}\s*/gu, '')
  30. // Remove custom abbreviations
  31. .replace(abbreviationsRegex, '');
  32. const sentenceEndGrouping = /([.?!])(?:\s+|$)/ug;
  33. const puncts = [...txt.matchAll(sentenceEndGrouping)].map(sentEnd => {
  34. return sentEnd[0];
  35. });
  36. return txt.split(/[.?!](?:\s+|$)/u)
  37. // Re-add the dot.
  38. .map((sentence, idx) => {
  39. return !puncts[idx] && /^\s*$/u.test(sentence) ? sentence : `${sentence}${puncts[idx] || ''}`;
  40. });
  41. };
  42. /**
  43. * @param {string} text
  44. * @returns {boolean}
  45. */
  46. const isNewLinePrecededByAPeriod = text => {
  47. /** @type {boolean} */
  48. let lastLineEndsSentence;
  49. const lines = text.split('\n');
  50. return !lines.some(line => {
  51. if (lastLineEndsSentence === false && /^[A-Z][a-z]/u.test(line)) {
  52. return true;
  53. }
  54. lastLineEndsSentence = /[.:?!|]$/u.test(line);
  55. return false;
  56. });
  57. };
  58. /**
  59. * @param {string} str
  60. * @returns {boolean}
  61. */
  62. const isCapitalized = str => {
  63. return str[0] === str[0].toUpperCase();
  64. };
  65. /**
  66. * @param {string} str
  67. * @returns {boolean}
  68. */
  69. const isTable = str => {
  70. return str.charAt(0) === '|';
  71. };
  72. /**
  73. * @param {string} str
  74. * @returns {string}
  75. */
  76. const capitalize = str => {
  77. return str.charAt(0).toUpperCase() + str.slice(1);
  78. };
  79. /**
  80. * @param {string} description
  81. * @param {import('../iterateJsdoc.js').Report} reportOrig
  82. * @param {import('eslint').Rule.Node} jsdocNode
  83. * @param {string|RegExp} abbreviationsRegex
  84. * @param {import('eslint').SourceCode} sourceCode
  85. * @param {import('comment-parser').Spec|{
  86. * line: import('../iterateJsdoc.js').Integer
  87. * }} tag
  88. * @param {boolean} newlineBeforeCapsAssumesBadSentenceEnd
  89. * @returns {boolean}
  90. */
  91. const validateDescription = (description, reportOrig, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd) => {
  92. if (!description || /^\n+$/u.test(description)) {
  93. return false;
  94. }
  95. const paragraphs = extractParagraphs(description).filter(Boolean);
  96. return paragraphs.some((paragraph, parIdx) => {
  97. const sentences = extractSentences(paragraph, abbreviationsRegex);
  98. const fix = /** @type {import('eslint').Rule.ReportFixer} */fixer => {
  99. let text = sourceCode.getText(jsdocNode);
  100. if (!/[.:?!]$/u.test(paragraph)) {
  101. const line = paragraph.split('\n').filter(Boolean).pop();
  102. text = text.replace(new RegExp(`${(0, _escapeStringRegexp.default)( /** @type {string} */
  103. line)}$`, 'mu'), `${line}.`);
  104. }
  105. for (const sentence of sentences.filter(sentence_ => {
  106. return !/^\s*$/u.test(sentence_) && !isCapitalized(sentence_) && !isTable(sentence_);
  107. })) {
  108. const beginning = sentence.split('\n')[0];
  109. if ('tag' in tag && tag.tag) {
  110. const reg = new RegExp(`(@${(0, _escapeStringRegexp.default)(tag.tag)}.*)${(0, _escapeStringRegexp.default)(beginning)}`, 'u');
  111. text = text.replace(reg, (_$0, $1) => {
  112. return $1 + capitalize(beginning);
  113. });
  114. } else {
  115. text = text.replace(new RegExp('((?:[.?!]|\\*|\\})\\s*)' + (0, _escapeStringRegexp.default)(beginning), 'u'), '$1' + capitalize(beginning));
  116. }
  117. }
  118. return fixer.replaceText(jsdocNode, text);
  119. };
  120. /**
  121. * @param {string} msg
  122. * @param {import('eslint').Rule.ReportFixer | null | undefined} fixer
  123. * @param {{
  124. * line?: number | undefined;
  125. * column?: number | undefined;
  126. * } | (import('comment-parser').Spec & {
  127. * line?: number | undefined;
  128. * column?: number | undefined;
  129. * })} tagObj
  130. * @returns {void}
  131. */
  132. const report = (msg, fixer, tagObj) => {
  133. if ('line' in tagObj) {
  134. /**
  135. * @type {{
  136. * line: number;
  137. * }}
  138. */
  139. tagObj.line += parIdx * 2;
  140. } else {
  141. /** @type {import('comment-parser').Spec} */tagObj.source[0].number += parIdx * 2;
  142. }
  143. // Avoid errors if old column doesn't exist here
  144. tagObj.column = 0;
  145. reportOrig(msg, fixer, tagObj);
  146. };
  147. if (sentences.some(sentence => {
  148. return /^[.?!]$/u.test(sentence);
  149. })) {
  150. report('Sentences must be more than punctuation.', null, tag);
  151. }
  152. if (sentences.some(sentence => {
  153. return !/^\s*$/u.test(sentence) && !isCapitalized(sentence) && !isTable(sentence);
  154. })) {
  155. report('Sentences should start with an uppercase character.', fix, tag);
  156. }
  157. const paragraphNoAbbreviations = paragraph.replace(abbreviationsRegex, '');
  158. if (!/(?:[.?!|]|```)\s*$/u.test(paragraphNoAbbreviations)) {
  159. report('Sentences must end with a period.', fix, tag);
  160. return true;
  161. }
  162. if (newlineBeforeCapsAssumesBadSentenceEnd && !isNewLinePrecededByAPeriod(paragraphNoAbbreviations)) {
  163. report('A line of text is started with an uppercase character, but the preceding line does not end the sentence.', null, tag);
  164. return true;
  165. }
  166. return false;
  167. });
  168. };
  169. var _default = exports.default = (0, _iterateJsdoc.default)(({
  170. sourceCode,
  171. context,
  172. jsdoc,
  173. report,
  174. jsdocNode,
  175. utils
  176. }) => {
  177. const /** @type {{abbreviations: string[], newlineBeforeCapsAssumesBadSentenceEnd: boolean}} */{
  178. abbreviations = [],
  179. newlineBeforeCapsAssumesBadSentenceEnd = false
  180. } = context.options[0] || {};
  181. const abbreviationsRegex = abbreviations.length ? new RegExp('\\b' + abbreviations.map(abbreviation => {
  182. return (0, _escapeStringRegexp.default)(abbreviation.replaceAll(/\.$/ug, '') + '.');
  183. }).join('|') + '(?:$|\\s)', 'gu') : '';
  184. let {
  185. description
  186. } = utils.getDescription();
  187. const indices = [...description.matchAll(/```[\s\S]*```/gu)].map(match => {
  188. const {
  189. index
  190. } = match;
  191. const [{
  192. length
  193. }] = match;
  194. return {
  195. index,
  196. length
  197. };
  198. }).reverse();
  199. for (const {
  200. index,
  201. length
  202. } of indices) {
  203. description = description.slice(0, index) + description.slice( /** @type {import('../iterateJsdoc.js').Integer} */index + length);
  204. }
  205. if (validateDescription(description, report, jsdocNode, abbreviationsRegex, sourceCode, {
  206. line: jsdoc.source[0].number + 1
  207. }, newlineBeforeCapsAssumesBadSentenceEnd)) {
  208. return;
  209. }
  210. utils.forEachPreferredTag('description', matchingJsdocTag => {
  211. const desc = `${matchingJsdocTag.name} ${utils.getTagDescription(matchingJsdocTag)}`.trim();
  212. validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, matchingJsdocTag, newlineBeforeCapsAssumesBadSentenceEnd);
  213. }, true);
  214. const {
  215. tagsWithNames
  216. } = utils.getTagsByType(jsdoc.tags);
  217. const tagsWithoutNames = utils.filterTags(({
  218. tag: tagName
  219. }) => {
  220. return otherDescriptiveTags.has(tagName) || utils.hasOptionTag(tagName) && !tagsWithNames.some(({
  221. tag
  222. }) => {
  223. // If user accidentally adds tags with names (or like `returns`
  224. // get parsed as having names), do not add to this list
  225. return tag === tagName;
  226. });
  227. });
  228. tagsWithNames.some(tag => {
  229. const desc = /** @type {string} */utils.getTagDescription(tag).replace(/^- /u, '').trimEnd();
  230. return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd);
  231. });
  232. tagsWithoutNames.some(tag => {
  233. const desc = `${tag.name} ${utils.getTagDescription(tag)}`.trim();
  234. return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd);
  235. });
  236. }, {
  237. iterateAllJsdocs: true,
  238. meta: {
  239. docs: {
  240. description: 'Requires that block description, explicit `@description`, and `@param`/`@returns` tag descriptions are written in complete sentences.',
  241. url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-description-complete-sentence.md#repos-sticky-header'
  242. },
  243. fixable: 'code',
  244. schema: [{
  245. additionalProperties: false,
  246. properties: {
  247. abbreviations: {
  248. items: {
  249. type: 'string'
  250. },
  251. type: 'array'
  252. },
  253. newlineBeforeCapsAssumesBadSentenceEnd: {
  254. type: 'boolean'
  255. },
  256. tags: {
  257. items: {
  258. type: 'string'
  259. },
  260. type: 'array'
  261. }
  262. },
  263. type: 'object'
  264. }],
  265. type: 'suggestion'
  266. }
  267. });
  268. module.exports = exports.default;
  269. //# sourceMappingURL=requireDescriptionCompleteSentence.js.map