UNPKG

eslint-plugin-jsdoc

Version:
551 lines (524 loc) 18.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.getJsdocProcessorPlugin = void 0; var _jsdocUtils = require("./jsdocUtils.cjs"); var _jsdoccomment = require("@es-joy/jsdoccomment"); var espree = _interopRequireWildcard(require("espree")); var _nodeFs = require("node:fs"); var _nodePath = require("node:path"); function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); } const { version } = JSON.parse( // @ts-expect-error `Buffer` is ok for `JSON.parse` (0, _nodeFs.readFileSync)((0, _nodePath.join)(__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] */ 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' ? (0, _jsdocUtils.getRegexFromString)(exampleCodeRegex) : exampleCodeRegex; } if (rejectExampleCodeRegex) { rejectExampleCodeRegExp = typeof rejectExampleCodeRegex === 'string' ? (0, _jsdocUtils.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'); (0, _jsdocUtils.forEachPreferredTag)(jsdoc, 'default', (tag, targetTagName) => { if (!tag.description.trim()) { return; } checkSource({ source: `(${(0, _jsdocUtils.getTagDescription)(tag)})`, targetTagName, ...filenameInfo }); }); } if (checkParams) { const filenameInfo = getFilenameInfo(matchingFileNameParams, 'jsdoc-params'); (0, _jsdocUtils.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'); (0, _jsdocUtils.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} */(0, _jsdocUtils.getPreferredTagName)(jsdoc, { tagName: 'example' }); if (!(0, _jsdocUtils.hasTag)(jsdoc, tagName)) { return textsAndFileNames; } const matchingFilenameInfo = getFilenameInfo(matchingFileName); (0, _jsdocUtils.forEachPreferredTag)(jsdoc, 'example', (tag, targetTagName) => { let source = /** @type {string} */(0, _jsdocUtils.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 (0, _jsdoccomment.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 } } }; }; exports.getJsdocProcessorPlugin = getJsdocProcessorPlugin; //# sourceMappingURL=getJsdocProcessorPlugin.cjs.map