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', () => {