UNPKG

eslint-plugin-jsdoc

Version:
653 lines (584 loc) 18.6 kB
import { forEachPreferredTag, getPreferredTagName, getRegexFromString, getTagDescription, hasTag, } from './jsdocUtils.js'; import { parseComment, } from '@es-joy/jsdoccomment'; import * as espree from 'espree'; import { readFileSync, } from 'node:fs'; import { join, } from 'node:path'; const { version, } = JSON.parse( // @ts-expect-error `Buffer` is ok for `JSON.parse` readFileSync(join(import.meta.dirname, '../package.json')), ); // const zeroBasedLineIndexAdjust = -1; const likelyNestedJSDocIndentSpace = 1; const preTagSpaceLength = 1; // If a space is present, we should ignore it const firstLinePrefixLength = preTagSpaceLength; const hasCaptionRegex = /^\s*<caption>([\s\S]*?)<\/caption>/u; /** * @param {string} str * @returns {string} */ const escapeStringRegexp = (str) => { return str.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&'); }; /** * @param {string} str * @param {string} ch * @returns {import('./iterateJsdoc.js').Integer} */ const countChars = (str, ch) => { return (str.match(new RegExp(escapeStringRegexp(ch), 'gu')) || []).length; }; /** * @param {string} text * @returns {[ * import('./iterateJsdoc.js').Integer, * import('./iterateJsdoc.js').Integer * ]} */ const getLinesCols = (text) => { const matchLines = countChars(text, '\n'); const colDelta = matchLines ? text.slice(text.lastIndexOf('\n') + 1).length : text.length; return [ matchLines, colDelta, ]; }; /** * @typedef {number} Integer */ /** * @typedef {object} JsdocProcessorOptions * @property {boolean} [captionRequired] Require captions for example tags * @property {Integer} [paddedIndent] See docs * @property {boolean} [checkDefaults] See docs * @property {boolean} [checkParams] See docs * @property {boolean} [checkExamples] See docs * @property {boolean} [checkProperties] See docs * @property {string} [matchingFileName] See docs * @property {string} [matchingFileNameDefaults] See docs * @property {string} [matchingFileNameParams] See docs * @property {string} [matchingFileNameProperties] See docs * @property {string|RegExp} [exampleCodeRegex] See docs * @property {string|RegExp} [rejectExampleCodeRegex] See docs * @property {string[]} [allowedLanguagesToProcess] See docs * @property {"script"|"module"} [sourceType] See docs * @property {import('eslint').Linter.ESTreeParser|import('eslint').Linter.NonESTreeParser} [parser] See docs */ /** * We use a function for the ability of the user to pass in a config, but * without requiring all users of the plugin to do so. * @param {JsdocProcessorOptions} [options] */ export const getJsdocProcessorPlugin = (options = {}) => { const { allowedLanguagesToProcess = [ 'js', 'ts', 'javascript', 'typescript', ], captionRequired = false, checkDefaults = false, checkExamples = true, checkParams = false, checkProperties = false, exampleCodeRegex = null, matchingFileName = null, matchingFileNameDefaults = null, matchingFileNameParams = null, matchingFileNameProperties = null, paddedIndent = 0, parser = undefined, rejectExampleCodeRegex = null, sourceType = 'module', } = options; /** @type {RegExp} */ let exampleCodeRegExp; /** @type {RegExp} */ let rejectExampleCodeRegExp; if (exampleCodeRegex) { exampleCodeRegExp = typeof exampleCodeRegex === 'string' ? getRegexFromString(exampleCodeRegex) : exampleCodeRegex; } if (rejectExampleCodeRegex) { rejectExampleCodeRegExp = typeof rejectExampleCodeRegex === 'string' ? getRegexFromString(rejectExampleCodeRegex) : rejectExampleCodeRegex; } /** * @type {{ * targetTagName: string, * ext: string, * codeStartLine: number, * codeStartCol: number, * nonJSPrefacingCols: number, * commentLineCols: [number, number] * }[]} */ const otherInfo = []; /** @type {import('eslint').Linter.LintMessage[]} */ let extraMessages = []; /** * @param {import('./iterateJsdoc.js').JsdocBlockWithInline} jsdoc * @param {string} jsFileName * @param {[number, number]} commentLineCols */ const getTextsAndFileNames = (jsdoc, jsFileName, commentLineCols) => { /** * @type {{ * text: string, * filename: string|null|undefined * }[]} */ const textsAndFileNames = []; /** * @param {{ * filename: string|null, * defaultFileName: string|undefined, * source: string, * targetTagName: string, * rules?: import('eslint').Linter.RulesRecord|undefined, * lines?: import('./iterateJsdoc.js').Integer, * cols?: import('./iterateJsdoc.js').Integer, * skipInit?: boolean, * ext: string, * sources?: { * nonJSPrefacingCols: import('./iterateJsdoc.js').Integer, * nonJSPrefacingLines: import('./iterateJsdoc.js').Integer, * string: string, * }[], * tag?: import('comment-parser').Spec & { * line?: import('./iterateJsdoc.js').Integer, * }|{ * line: import('./iterateJsdoc.js').Integer, * } * }} cfg */ const checkSource = ({ cols = 0, defaultFileName, ext, filename, lines = 0, skipInit, source, sources = [], tag = { line: 0, }, targetTagName, }) => { if (!skipInit) { sources.push({ nonJSPrefacingCols: cols, nonJSPrefacingLines: lines, string: source, }); } /** * @param {{ * nonJSPrefacingCols: import('./iterateJsdoc.js').Integer, * nonJSPrefacingLines: import('./iterateJsdoc.js').Integer, * string: string * }} cfg */ const addSourceInfo = function ({ nonJSPrefacingCols, nonJSPrefacingLines, string, }) { const src = paddedIndent ? string.replaceAll(new RegExp(`(^|\n) {${paddedIndent}}(?!$)`, 'gu'), '\n') : string; // Programmatic ESLint API: https://eslint.org/docs/developer-guide/nodejs-api const file = filename || defaultFileName; if (!('line' in tag)) { tag.line = tag.source[0].number; } // NOTE: `tag.line` can be 0 if of form `/** @tag ... */` const codeStartLine = /** * @type {import('comment-parser').Spec & { * line: import('./iterateJsdoc.js').Integer, * }} */ (tag).line + nonJSPrefacingLines; const codeStartCol = likelyNestedJSDocIndentSpace; textsAndFileNames.push({ filename: file, text: src, }); otherInfo.push({ codeStartCol, codeStartLine, commentLineCols, ext, nonJSPrefacingCols, targetTagName, }); }; for (const targetSource of sources) { addSourceInfo(targetSource); } }; /** * * @param {string|null} filename * @param {string} [ext] Since `eslint-plugin-markdown` v2, and * ESLint 7, this is the default which other JS-fenced rules will used. * Formerly "md" was the default. * @returns {{ * defaultFileName: string|undefined, * filename: string|null, * ext: string * }} */ const getFilenameInfo = (filename, ext = 'md/*.js') => { let defaultFileName; if (!filename) { if (typeof jsFileName === 'string' && jsFileName.includes('.')) { defaultFileName = jsFileName.replace(/\.[^.]*$/u, `.${ext}`); } else { defaultFileName = `dummy.${ext}`; } } return { defaultFileName, ext, filename, }; }; if (checkDefaults) { const filenameInfo = getFilenameInfo(matchingFileNameDefaults, 'jsdoc-defaults'); forEachPreferredTag(jsdoc, 'default', (tag, targetTagName) => { if (!tag.description.trim()) { return; } checkSource({ source: `(${getTagDescription(tag)})`, targetTagName, ...filenameInfo, }); }); } if (checkParams) { const filenameInfo = getFilenameInfo(matchingFileNameParams, 'jsdoc-params'); forEachPreferredTag(jsdoc, 'param', (tag, targetTagName) => { if (!tag.default || !tag.default.trim()) { return; } checkSource({ source: `(${tag.default})`, targetTagName, ...filenameInfo, }); }); } if (checkProperties) { const filenameInfo = getFilenameInfo(matchingFileNameProperties, 'jsdoc-properties'); forEachPreferredTag(jsdoc, 'property', (tag, targetTagName) => { if (!tag.default || !tag.default.trim()) { return; } checkSource({ source: `(${tag.default})`, targetTagName, ...filenameInfo, }); }); } if (!checkExamples) { return textsAndFileNames; } const tagName = /** @type {string} */ (getPreferredTagName(jsdoc, { tagName: 'example', })); if (!hasTag(jsdoc, tagName)) { return textsAndFileNames; } const matchingFilenameInfo = getFilenameInfo(matchingFileName); forEachPreferredTag(jsdoc, 'example', (tag, targetTagName) => { let source = /** @type {string} */ (getTagDescription(tag)); const match = source.match(hasCaptionRegex); if (captionRequired && (!match || !match[1].trim())) { extraMessages.push({ column: commentLineCols[1] + 1, line: 1 + commentLineCols[0] + (tag.line ?? tag.source[0].number), message: `@${targetTagName} error - Caption is expected for examples.`, ruleId: 'jsdoc/example-missing-caption', severity: 2, }); return; } source = source.replace(hasCaptionRegex, ''); const [ lines, cols, ] = match ? getLinesCols(match[0]) : [ 0, 0, ]; if (exampleCodeRegex && !exampleCodeRegExp.test(source) || rejectExampleCodeRegex && rejectExampleCodeRegExp.test(source) ) { return; } // If `allowedLanguagesToProcess` is falsy, all languages should be processed. if (allowedLanguagesToProcess) { const matches = (/^\s*```(?<language>\S+)([\s\S]*)```\s*$/u).exec(source); if (matches?.groups && !allowedLanguagesToProcess.includes( matches.groups.language.toLowerCase(), )) { return; } } const sources = []; let skipInit = false; if (exampleCodeRegex) { let nonJSPrefacingCols = 0; let nonJSPrefacingLines = 0; let startingIndex = 0; let lastStringCount = 0; let exampleCode; exampleCodeRegExp.lastIndex = 0; while ((exampleCode = exampleCodeRegExp.exec(source)) !== null) { const { '0': n0, '1': n1, index, } = exampleCode; // Count anything preceding user regex match (can affect line numbering) const preMatch = source.slice(startingIndex, index); const [ preMatchLines, colDelta, ] = getLinesCols(preMatch); let nonJSPreface; let nonJSPrefaceLineCount; if (n1) { const idx = n0.indexOf(n1); nonJSPreface = n0.slice(0, idx); nonJSPrefaceLineCount = countChars(nonJSPreface, '\n'); } else { nonJSPreface = ''; nonJSPrefaceLineCount = 0; } nonJSPrefacingLines += lastStringCount + preMatchLines + nonJSPrefaceLineCount; // Ignore `preMatch` delta if newlines here if (nonJSPrefaceLineCount) { const charsInLastLine = nonJSPreface.slice(nonJSPreface.lastIndexOf('\n') + 1).length; nonJSPrefacingCols += charsInLastLine; } else { nonJSPrefacingCols += colDelta + nonJSPreface.length; } const string = n1 || n0; sources.push({ nonJSPrefacingCols, nonJSPrefacingLines, string, }); startingIndex = exampleCodeRegExp.lastIndex; lastStringCount = countChars(string, '\n'); if (!exampleCodeRegExp.global) { break; } } skipInit = true; } checkSource({ cols, lines, skipInit, source, sources, tag, targetTagName, ...matchingFilenameInfo, }); }); return textsAndFileNames; }; // See https://eslint.org/docs/latest/extend/plugins#processors-in-plugins // See https://eslint.org/docs/latest/extend/custom-processors // From https://github.com/eslint/eslint/issues/14745#issuecomment-869457265 /* { "files": ["*.js", "*.ts"], "processor": "jsdoc/example" // a pretended value here }, { "files": [ "*.js/*_jsdoc-example.js", "*.ts/*_jsdoc-example.js", "*.js/*_jsdoc-example.ts" ], "rules": { // specific rules for examples in jsdoc only here // And other rules for `.js` and `.ts` will also be enabled for them } } */ return { meta: { name: 'eslint-plugin-jsdoc/processor', version, }, processors: { examples: { meta: { name: 'eslint-plugin-jsdoc/preprocessor', version, }, /** * @param {import('eslint').Linter.LintMessage[][]} messages * @param {string} filename */ postprocess ([ jsMessages, ...messages // eslint-disable-next-line no-unused-vars -- Placeholder ], filename) { for (const [ idx, message, ] of messages.entries()) { const { codeStartCol, codeStartLine, commentLineCols, nonJSPrefacingCols, targetTagName, } = otherInfo[idx]; for (const msg of message) { const { column, endColumn, endLine, fatal, line, message: messageText, ruleId, severity, // Todo: Make fixable // fix // fix: {range: [number, number], text: string} // suggestions: {desc: , messageId:, fix: }[], } = msg; const [ codeCtxLine, codeCtxColumn, ] = commentLineCols; const startLine = codeCtxLine + codeStartLine + line; // Seems to need one more now const startCol = 1 + codeCtxColumn + codeStartCol + ( // This might not work for line 0, but line 0 is unlikely for examples line <= 1 ? nonJSPrefacingCols + firstLinePrefixLength : preTagSpaceLength ) + column; msg.message = '@' + targetTagName + ' ' + (severity === 2 ? 'error' : 'warning') + (ruleId ? ' (' + ruleId + ')' : '') + ': ' + (fatal ? 'Fatal: ' : '') + messageText; msg.line = startLine; msg.column = startCol; msg.endLine = endLine ? startLine + endLine : startLine; // added `- column` to offset what `endColumn` already seemed to include msg.endColumn = endColumn ? startCol - column + endColumn : startCol; } } const ret = [ ...jsMessages, ].concat(...messages, ...extraMessages); extraMessages = []; return ret; }, /** * @param {string} text * @param {string} filename */ preprocess (text, filename) { try { let ast; // May be running a second time so catch and ignore try { ast = parser ? // @ts-expect-error Should be present parser.parseForESLint(text, { comment: true, ecmaVersion: 'latest', sourceType, }).ast : espree.parse(text, { comment: true, ecmaVersion: 'latest', sourceType, }); } catch { return [ text, ]; } /** @type {[number, number][]} */ const commentLineCols = []; const jsdocComments = /** @type {import('estree').Comment[]} */ ( /** * @type {import('estree').Program & { * comments?: import('estree').Comment[] * }} */ (ast).comments ).filter((comment) => { return (/^\*\s/u).test(comment.value); }).map((comment) => { const [ start, /* c8 ignore next -- Unsupporting processors only? */ ] = comment.range ?? []; const textToStart = text.slice(0, start); const [ lines, cols, ] = getLinesCols(textToStart); // const lines = [...textToStart.matchAll(/\n/gu)].length // const lastLinePos = textToStart.lastIndexOf('\n'); // const cols = lastLinePos === -1 // ? 0 // : textToStart.slice(lastLinePos).length; commentLineCols.push([ lines, cols, ]); return parseComment(comment); }); return [ text, ...jsdocComments.flatMap((jsdoc, idx) => { return getTextsAndFileNames( jsdoc, filename, commentLineCols[idx], ); }).filter(Boolean), ]; /* c8 ignore next 6 */ } catch (error) { // eslint-disable-next-line no-console -- Debugging console.log('err', filename, error); } return []; }, // Todo: Reenable supportsAutofix: false, }, }, }; };