diff --git a/eslint.config.js b/eslint.config.js index 0022392e..6a49e6a7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,15 +9,16 @@ const common = { }, plugins: { jsdoc - } + }, }; export default [ // canonical, // canonicalJsdoc, + ...jsdoc.configs['examples-and-default-expressions'], { // Must be by itself - ignores: ['dist/**/*.js', '.ignore/**/*.js'], + ignores: ['dist/**', '.ignore/**/*.js'], }, { ...common, diff --git a/package.json b/package.json index 763b9385..3efe1de5 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "@types/chai": "^4.3.16", "@types/debug": "^4.1.12", "@types/eslint": "^8.56.10", + "@types/espree": "^10.1.0", "@types/esquery": "^1.5.4", "@types/estree": "^1.0.5", "@types/json-schema": "^7.0.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25ba9c7f..fb62eb0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: '@types/eslint': specifier: ^8.56.10 version: 8.56.10 + '@types/espree': + specifier: ^10.1.0 + version: 10.1.0 '@types/esquery': specifier: ^1.5.4 version: 1.5.4 @@ -1295,6 +1298,9 @@ packages: '@types/eslint@8.56.10': resolution: {integrity: sha512-Shavhk87gCtY2fhXDctcfS3e6FdxWkCx1iUZ9eEUbh7rTqlZT0/IzOkCOVt0fCjcFuZ9FPYfuezTBImfHCDBGQ==} + '@types/espree@10.1.0': + resolution: {integrity: sha512-uPQZdoUWWMuO6WS8/dwX1stZH/vOBa/wAniGnYEFI0IuU9RmLx6PLmo+VGfNOlbRc5I7hBsQc8H0zcdVI37kxg==} + '@types/esquery@1.5.4': resolution: {integrity: sha512-yYO4Q8H+KJHKW1rEeSzHxcZi90durqYgWVfnh5K6ZADVBjBv2e1NEveYX5yT2bffgN7RqzH3k9930m+i2yBoMA==} @@ -6091,7 +6097,7 @@ snapshots: '@babel/plugin-syntax-jsx@7.23.3(@babel/core@7.24.9)': dependencies: '@babel/core': 7.24.9 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.24.9)': dependencies: @@ -6411,9 +6417,9 @@ snapshots: '@babel/core': 7.24.9 '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-module-imports': 7.24.7 - '@babel/helper-plugin-utils': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-jsx': 7.23.3(@babel/core@7.24.9) - '@babel/types': 7.24.7 + '@babel/types': 7.24.9 transitivePeerDependencies: - supports-color @@ -7246,6 +7252,11 @@ snapshots: '@types/estree': 1.0.5 '@types/json-schema': 7.0.15 + '@types/espree@10.1.0': + dependencies: + acorn: 8.12.0 + eslint-visitor-keys: 4.0.0 + '@types/esquery@1.5.4': dependencies: '@types/estree': 1.0.5 @@ -8843,7 +8854,7 @@ snapshots: eslint-plugin-import-x@3.0.1(eslint@9.7.0)(typescript@5.5.3): dependencies: '@rtsao/scc': 1.1.0 - '@typescript-eslint/utils': 7.14.1(eslint@9.7.0)(typescript@5.5.3) + '@typescript-eslint/utils': 7.16.1(eslint@9.7.0)(typescript@5.5.3) debug: 4.3.5(supports-color@8.1.1) doctrine: 3.0.0 eslint: 9.7.0 diff --git a/src/getJsdocProcessorPlugin.js b/src/getJsdocProcessorPlugin.js new file mode 100644 index 00000000..34aee2dd --- /dev/null +++ b/src/getJsdocProcessorPlugin.js @@ -0,0 +1,602 @@ +// Todo: Support TS by fenced block type + +import {readFileSync} from 'fs'; +import * as espree from 'espree'; +import { + getRegexFromString, + forEachPreferredTag, + getTagDescription, + getPreferredTagName, + hasTag, +} from './jsdocUtils.js'; +import { + parseComment, +} from '@es-joy/jsdoccomment'; + +const {version} = JSON.parse( + // @ts-expect-error `Buffer` is ok for `JSON.parse` + readFileSync('./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*([\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] + * @property {Integer} [paddedIndent] + * @property {boolean} [checkDefaults] + * @property {boolean} [checkParams] + * @property {boolean} [checkExamples] + * @property {boolean} [checkProperties] + * @property {string} [matchingFileName] + * @property {string} [matchingFileNameDefaults] + * @property {string} [matchingFileNameParams] + * @property {string} [matchingFileNameProperties] + * @property {string} [exampleCodeRegex] + * @property {string} [rejectExampleCodeRegex] + * @property {"script"|"module"} [sourceType] + * @property {import('eslint').Linter.FlatConfigParserModule} [parser] + */ + +/** + * 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 { + exampleCodeRegex = null, + rejectExampleCodeRegex = null, + checkExamples = true, + checkDefaults = false, + checkParams = false, + checkProperties = false, + matchingFileName = null, + matchingFileNameDefaults = null, + matchingFileNameParams = null, + matchingFileNameProperties = null, + paddedIndent = 0, + captionRequired = false, + sourceType = 'module', + parser = undefined + } = options; + + /** @type {RegExp} */ + let exampleCodeRegExp; + /** @type {RegExp} */ + let rejectExampleCodeRegExp; + + if (exampleCodeRegex) { + exampleCodeRegExp = getRegexFromString(exampleCodeRegex); + } + + if (rejectExampleCodeRegex) { + rejectExampleCodeRegExp = getRegexFromString(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 = ({ + filename, + ext, + defaultFileName, + lines = 0, + cols = 0, + skipInit, + source, + targetTagName, + sources = [], + tag = { + line: 0, + }, + }) => { + 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({ + text: src, + filename: file, + }); + otherInfo.push({ + targetTagName, + ext, + codeStartLine, + codeStartCol, + nonJSPrefacingCols, + commentLineCols + }); + }; + + 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 { + ext, + defaultFileName, + 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({ + line: 1 + commentLineCols[0] + (tag.line ?? tag.source[0].number), + column: commentLineCols[1] + 1, + severity: 2, + message: `@${targetTagName} error - Caption is expected for examples.`, + ruleId: 'jsdoc/example-missing-caption' + }); + return; + } + + source = source.replace(hasCaptionRegex, ''); + const [ + lines, + cols, + ] = match ? getLinesCols(match[0]) : [ + 0, 0, + ]; + + if (exampleCodeRegex && !exampleCodeRegExp.test(source) || + rejectExampleCodeRegex && rejectExampleCodeRegExp.test(source) + ) { + 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 { + index, + '0': n0, + '1': n1, + } = 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 {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 Ok + ? parser.parseForESLint(text, { + ecmaVersion: 'latest', + sourceType, + comment: true + }).ast + : espree.parse(text, { + ecmaVersion: 'latest', + sourceType, + comment: true + }); + } catch (err) { + 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) => { + /* c8 ignore next -- Unsupporting processors only? */ + const [start] = 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 3 */ + } catch (err) { + console.log('err', filename, err); + } + }, + + /** + * @param {import('eslint').Linter.LintMessage[][]} messages + * @param {string} filename + */ + postprocess ([jsMessages, ...messages], filename) { + messages.forEach((message, idx) => { + const { + targetTagName, + codeStartLine, + codeStartCol, + nonJSPrefacingCols, + commentLineCols + } = otherInfo[idx]; + + message.forEach((msg) => { + const { + message, + ruleId, + severity, + fatal, + line, + column, + endColumn, + endLine, + + // Todo: Make fixable + // fix + // fix: {range: [number, number], text: string} + // suggestions: {desc: , messageId:, fix: }[], + } = msg; + + const [codeCtxLine, codeCtxColumn] = commentLineCols; + const startLine = codeCtxLine + codeStartLine + line; + const startCol = 1 + // Seems to need one more now + 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: ' : '') + + message; + 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; + }, + supportsAutofix: true + }, + }, + }; +}; diff --git a/src/index.js b/src/index.js index aa750fb6..3ae4d6ef 100644 --- a/src/index.js +++ b/src/index.js @@ -55,6 +55,8 @@ import tagLines from './rules/tagLines.js'; import textEscaping from './rules/textEscaping.js'; import validTypes from './rules/validTypes.js'; +import { getJsdocProcessorPlugin } from './getJsdocProcessorPlugin.js'; + /** * @type {import('eslint').ESLint.Plugin & { * configs: Record< @@ -274,4 +276,105 @@ index.configs['flat/recommended-typescript-error'] = createRecommendedTypeScript index.configs['flat/recommended-typescript-flavor'] = createRecommendedTypeScriptFlavorRuleset('warn', 'flat/recommended-typescript-flavor'); index.configs['flat/recommended-typescript-flavor-error'] = createRecommendedTypeScriptFlavorRuleset('error', 'flat/recommended-typescript-flavor-error'); +index.configs.examples = /** @type {import('eslint').Linter.FlatConfig[]} */ ([ + { + files: ['**/*.js'], + plugins: { + examples: getJsdocProcessorPlugin() + }, + processor: 'examples/examples', + }, + { + files: ['**/*.md/*.js'], + rules: { + // "always" newline rule at end unlikely in sample code + 'eol-last': 0, + + // Wouldn't generally expect example paths to resolve relative to JS file + 'import/no-unresolved': 0, + + // Snippets likely too short to always include import/export info + 'import/unambiguous': 0, + + 'jsdoc/require-file-overview': 0, + + // The end of a multiline comment would end the comment the example is in. + 'jsdoc/require-jsdoc': 0, + + // Unlikely to have inadvertent debugging within examples + 'no-console': 0, + + // Often wish to start `@example` code after newline; also may use + // empty lines for spacing + 'no-multiple-empty-lines': 0, + + // Many variables in examples will be `undefined` + 'no-undef': 0, + + // Common to define variables for clarity without always using them + 'no-unused-vars': 0, + + // See import/no-unresolved + 'node/no-missing-import': 0, + 'node/no-missing-require': 0, + + // Can generally look nicer to pad a little even if code imposes more stringency + 'padded-blocks': 0, + } + } +]); + +index.configs['default-expressions'] = /** @type {import('eslint').Linter.FlatConfig[]} */ ([ + { + files: ['**/*.js'], + plugins: { + examples: getJsdocProcessorPlugin({ + checkDefaults: true, + checkParams: true, + checkProperties: true + }) + }, + processor: 'examples/examples' + }, + { + files: ['**/*.jsdoc-defaults', '**/*.jsdoc-params', '**/*.jsdoc-properties'], + rules: { + ...index.configs.examples[1].rules, + 'chai-friendly/no-unused-expressions': 0, + 'no-empty-function': 0, + 'no-new': 0, + 'no-unused-expressions': 0, + quotes: [ + 'error', 'double', + ], + semi: [ + 'error', 'never', + ], + strict: 0 + }, + } +]); + +index.configs['examples-and-default-expressions'] = /** @type {import('eslint').Linter.FlatConfig[]} */ ([ + { + plugins: { + examples: getJsdocProcessorPlugin({ + checkDefaults: true, + checkParams: true, + checkProperties: true + }) + }, + }, + ...index.configs.examples.map((config) => { + delete config.plugins; + return config; + }), + ...index.configs['default-expressions'].map((config) => { + delete config.plugins; + return config; + }) +]); + +export { getJsdocProcessorPlugin }; + export default index; diff --git a/src/jsdocUtils.js b/src/jsdocUtils.js index 2afcfc4c..c599bc37 100644 --- a/src/jsdocUtils.js +++ b/src/jsdocUtils.js @@ -731,10 +731,10 @@ const getTags = (jsdoc, tagName) => { * @param {import('./iterateJsdoc.js').JsdocBlockWithInline} jsdoc * @param {{ * tagName: string, - * context: import('eslint').Rule.RuleContext, - * mode: ParserMode, - * report: import('./iterateJsdoc.js').Report - * tagNamePreference: TagNamePreference + * context?: import('eslint').Rule.RuleContext, + * mode?: ParserMode, + * report?: import('./iterateJsdoc.js').Report + * tagNamePreference?: TagNamePreference * skipReportingBlockedTag?: boolean, * allowObjectReturn?: boolean, * defaultMessage?: string, @@ -749,7 +749,9 @@ const getTags = (jsdoc, tagName) => { */ const getPreferredTagName = (jsdoc, { tagName, - context, mode, report, tagNamePreference, + context, mode, + tagNamePreference, + report = () => {}, skipReportingBlockedTag = false, allowObjectReturn = false, defaultMessage = `Unexpected tag \`@${tagName}\``, @@ -781,10 +783,10 @@ const getPreferredTagName = (jsdoc, { * targetTagName: string * ) => void} arrayHandler * @param {object} cfg - * @param {import('eslint').Rule.RuleContext} cfg.context - * @param {ParserMode} cfg.mode - * @param {import('./iterateJsdoc.js').Report} cfg.report - * @param {TagNamePreference} cfg.tagNamePreference + * @param {import('eslint').Rule.RuleContext} [cfg.context] + * @param {ParserMode} [cfg.mode] + * @param {import('./iterateJsdoc.js').Report} [cfg.report] + * @param {TagNamePreference} [cfg.tagNamePreference] * @param {boolean} [cfg.skipReportingBlockedTag] * @returns {void} */ @@ -794,7 +796,7 @@ const forEachPreferredTag = ( context, mode, report, tagNamePreference, skipReportingBlockedTag = false, - } + } = {} ) => { const targetTagName = /** @type {string|false} */ ( getPreferredTagName(jsdoc, { diff --git a/test/getJsdocProcessPlugin.js b/test/getJsdocProcessPlugin.js new file mode 100644 index 00000000..97cc9ca9 --- /dev/null +++ b/test/getJsdocProcessPlugin.js @@ -0,0 +1,741 @@ +import { + expect, +} from 'chai'; +import {parser as typescriptEslintParser} from 'typescript-eslint'; +import { + getJsdocProcessorPlugin +} from '../src/getJsdocProcessorPlugin.js'; + +/** + * @param {{ + * options?: import('../src/getJsdocProcessorPlugin.js').JsdocProcessorOptions, + * filename: string, + * text: string, + * result: (string|import('eslint').Linter.ProcessorFile)[] + * }} cfg + */ +function check ({options, filename, text, result}) { + const plugin = getJsdocProcessorPlugin(options); + const results = plugin.processors.examples.preprocess( + text, filename + ); + expect(results).to.deep.equal(result); +} + +describe('`getJsdocProcessorPlugin`', function () { + it('returns text and files', function () { + const filename = 'something.js'; + const text = ` + /** + * @example + * doSth('a'); + */ + function doSth () {} + `; + check({ + filename, + text, + result: [ + text, + { + text: `\ndoSth('a');`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (recovering from fatal error)', function () { + const filename = 'something.js'; + const text = `doSth(`; + check({ + filename, + text, + result: [ + text + ] + }); + }); + + it('returns text and files with `exampleCodeRegex`', function () { + const options = { + exampleCodeRegex: '```js([\\s\\S]*)```', + }; + const filename = 'something.js'; + const text = ` + /** + * @example + * \`\`\`js + * doSth('a'); + * \`\`\` + */ + function doSth () {} + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `\ndoSth('a');\n`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files with `exampleCodeRegex` (no parentheses)', function () { + const options = { + exampleCodeRegex: '// begin[\\s\\S]*// end', + }; + const filename = 'something.js'; + const text = ` + /** + * @example // begin + alert('hello') + // end + */ + function quux () { + + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `// begin\nalert('hello')\n// end`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files with missing caption', function () { + const options = { + captionRequired: true, + }; + const filename = 'something.js'; + const text = ` + /** + * @example Valid usage + * quux(); // does something useful + * + * @example + * quux('random unwanted arg'); // results in an error + */ + function quux () { + + } + `; + + const plugin = getJsdocProcessorPlugin(options); + const results = plugin.processors.examples.preprocess( + text, filename + ); + expect(results).to.deep.equal([ + text, + { + text: `\nquux(); // does something useful\n`, + filename: 'something.md/*.js' + } + ]); + + const postResults = plugin.processors.examples.postprocess( + [[]], filename + ); + expect(postResults.length).to.equal(1); + }); + + it('returns text and files (inline example)', function () { + const options = { + }; + const filename = 'something.js'; + const text = ` + /** + * @example alert('hello') + */ + function quux () { + + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `alert('hello')`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (no asterisk example)', function () { + const options = { + exampleCodeRegex: '```js([\\s\\S]*)```', + }; + const filename = 'something.js'; + const text = ` + /** + * @example \`\`\`js + alert('hello'); + \`\`\` + */ + function quux () { + + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `\nalert('hello');\n`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (with `rejectExampleCodeRegex`)', function () { + const options = { + rejectExampleCodeRegex: '^\\s*<.*>\\s*$', + }; + const filename = 'something.js'; + const text = ` + /** + * @example Not JavaScript + */ + function quux () { + + } + /** + * @example quux2(); + */ + function quux2 () { + + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `quux2();`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (with `matchingFileName`)', function () { + const options = { + matchingFileName: '../../jsdocUtils.js', + }; + const filename = 'something.js'; + const text = ` + /** + * @example const j = 5; + * quux2(); + */ + function quux2 () { + + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `const j = 5;\nquux2();`, + filename: '../../jsdocUtils.js' + } + ] + }); + }); + + it('returns text and files (with `paddedIndent`)', function () { + const options = { + paddedIndent: 2 + }; + const filename = 'something.js'; + const text = ` + /** + * @example const i = 5; + * quux2() + */ + function quux2 () { + + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `const i = 5;\nquux2()`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (with `parser`)', function () { + const options = { + parser: typescriptEslintParser + }; + const filename = 'something.js'; + const text = ` + /** + * @example + * const list: number[] = [1, 2, 3] + * quux(list); + */ + function quux () { + + } + `; + check({ + // @ts-expect-error Ok? + options, + filename, + text, + result: [ + text, + { + text: `\nconst list: number[] = [1, 2, 3]\nquux(list);`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (with multiple fenced blocks)', function () { + const options = { + exampleCodeRegex: '/^```(?:js|javascript)\\n([\\s\\S]*?)```$/gm', + }; + const filename = 'something.js'; + const text = ` + /** + * @example Say \`Hello!\` to the user. + * First, import the function: + * + * \`\`\`js + * import popup from './popup' + * const aConstInSameScope = 5; + * \`\`\` + * + * Then use it like this: + * + * \`\`\`js + * const aConstInSameScope = 7; + * popup('Hello!') + * \`\`\` + * + * Here is the result on macOS: + * + * ![Screenshot](path/to/screenshot.jpg) + */ + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `import popup from './popup'\nconst aConstInSameScope = 5;\n`, + filename: 'something.md/*.js' + }, + { + text: `const aConstInSameScope = 7;\npopup('Hello!')\n`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (for @default)', function () { + const options = { + checkDefaults: true, + }; + const filename = 'something.js'; + const text = ` + /** + * @default 'abc' + */ + const str = 'abc'; + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `('abc')`, + filename: 'something.jsdoc-defaults' + } + ] + }); + }); + + it('returns text and files (for @param)', function () { + const options = { + checkParams: true, + }; + const filename = 'something.js'; + const text = ` + /** + * @param {myType} [name='abc'] + */ + function quux () { + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `('abc')`, + filename: 'something.jsdoc-params' + } + ] + }); + }); + + it('returns text and files (for @property)', function () { + const options = { + checkProperties: true, + }; + const filename = 'something.js'; + const text = ` + /** + * @property {myType} [name='abc'] + */ + const obj = {}; + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `('abc')`, + filename: 'something.jsdoc-properties' + } + ] + }); + }); + + it('returns text and files (with caption)', function () { + const options = { + captionRequired: true, + }; + const filename = 'something.js'; + const text = ` + /** + * Test function. + * + * @example functionName (paramOne: string, paramTwo?: any, + * paramThree?: any): boolean test() + * + * @param {string} paramOne Parameter description. + * @param {any} [paramTwo] Parameter description. + * @param {any} [paramThree] Parameter description. + * @returns {boolean} Return description. + */ + const functionName = function (paramOne, paramTwo, + paramThree) { + return false; + }; + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: ` test()\n`, + filename: 'something.md/*.js' + } + ] + }); + }); + + it('returns text and files (with dummy filename)', function () { + const options = { + checkProperties: true, + }; + const filename = ''; + const text = ` + /** + * @example const i = 5; + */ + function quux2 () { + + } + `; + check({ + options, + filename, + text, + result: [ + text, + { + text: `const i = 5;`, + filename: 'dummy.md/*.js' + } + ] + }); + }); + + it('returns text and files (with empty default)', function () { + const options = { + checkDefaults: true, + }; + const filename = ''; + const text = ` + /** + * @default + */ + const str = 'abc'; + `; + check({ + options, + filename, + text, + result: [ + text + ] + }); + }); + + it('returns text and files (with property default missing)', function () { + const options = { + checkProperties: true, + }; + const filename = 'something.js'; + const text = ` + /** + * @property {myType} [name] + */ + const obj = {}; + `; + check({ + options, + filename, + text, + result: [ + text + ] + }); + }); + + it('returns text and files (with param default missing)', function () { + const options = { + checkParams: true, + }; + const filename = 'something.js'; + const text = ` + /** + * @param {myType} name + */ + function quux () { + } + `; + check({ + options, + filename, + text, + result: [ + text + ] + }); + }); + + it('returns text and files and postprocesses error', function () { + const options = { + }; + const filename = 'something.js'; + const text = ` +/** + * @example alert('a'); + */ + `; + + const plugin = getJsdocProcessorPlugin(options); + const results = plugin.processors.examples.preprocess( + text, filename + ); + expect(results).to.deep.equal([ + text, + { + text: `alert('a');`, + filename: 'something.md/*.js' + } + ]); + + const postResults = plugin.processors.examples.postprocess( + [[], [ + { + ruleId: 'no-alert', + severity: 2, + message: 'Unexpected alert.', + line: 1, + column: 1, + endLine: 1, + endColumn: 11 + } + ]], filename + ); + expect(postResults).to.deep.equal([ + { + ruleId: 'no-alert', + severity: 2, + message: '@example error (no-alert): Unexpected alert.', + line: 3, + column: 4, + endLine: 4, + endColumn: 14 + } + ]); + }); + + it('returns text and files and postprocesses warning', function () { + const options = { + }; + const filename = 'something.js'; + const text = ` +/** + * @example alert('a'); + */ + `; + + const plugin = getJsdocProcessorPlugin(options); + const results = plugin.processors.examples.preprocess( + text, filename + ); + expect(results).to.deep.equal([ + text, + { + text: `alert('a');`, + filename: 'something.md/*.js' + } + ]); + + const postResults = plugin.processors.examples.postprocess( + [[], [ + { + ruleId: 'no-alert', + severity: 1, + message: 'Unexpected alert.', + line: 1, + column: 1, + endLine: 1, + endColumn: 11 + } + ]], filename + ); + expect(postResults).to.deep.equal([ + { + ruleId: 'no-alert', + severity: 1, + message: '@example warning (no-alert): Unexpected alert.', + line: 3, + column: 4, + endLine: 4, + endColumn: 14 + } + ]); + }); + + it('returns text and files and postprocesses fatal error', function () { + const options = { + }; + const filename = 'something.js'; + const text = ` + /** + * @example + * alert( + */ + `; + + const plugin = getJsdocProcessorPlugin(options); + const results = plugin.processors.examples.preprocess( + text, filename + ); + expect(results).to.deep.equal([ + text, + { + text: `\nalert(`, + filename: 'something.md/*.js' + } + ]); + + const postResults = plugin.processors.examples.postprocess( + [[], [ + { + ruleId: null, + fatal: true, + severity: 2, + message: 'Parsing error: Unexpected token', + line: 2, + column: 7 + } + ]], filename + ); + expect(postResults).to.deep.equal([ + { + ruleId: null, + fatal: true, + severity: 2, + message: '@example error: Fatal: Parsing error: Unexpected token', + line: 4, + column: 16, + endLine: 4, + endColumn: 16 + } + ]); + }); + + it('returns text and files, with `checkExamples: false`', function () { + const options = { + checkExamples: false + }; + const filename = 'something.js'; + const text = ` + /** + * @example + * doSth('a'); + */ + function doSth () {} + `; + check({ + options, + filename, + text, + result: [ + text + ] + }); + }); +}); diff --git a/test/jsdocUtils.js b/test/jsdocUtils.js index b7bd33a2..27854c2e 100644 --- a/test/jsdocUtils.js +++ b/test/jsdocUtils.js @@ -8,6 +8,23 @@ import { */ describe('jsdocUtils', () => { + describe('getPreferredTagName()', () => { + context('report', () => { + jsdocUtils.getPreferredTagName({ + tags: [ + // @ts-expect-error Just a skeleton + { + tag: 'example', + }, + ] + }, { + tagName: 'example', + tagNamePreference: { + 'example': false + } + }); + }); + }); describe('getPreferredTagNameSimple()', () => { context('no preferences', () => { context('alias name', () => {