eslint-plugin-jsdoc
Version:
JSDoc linting rules for ESLint.
373 lines (338 loc) • 9.3 kB
JavaScript
import alignTransform from '../alignTransform.js';
import iterateJsdoc from '../iterateJsdoc.js';
import {
transforms,
} from 'comment-parser';
const {
flow: commentFlow,
} = transforms;
/**
* @typedef {{
* postDelimiter: import('../iterateJsdoc.js').Integer,
* postHyphen: import('../iterateJsdoc.js').Integer,
* postName: import('../iterateJsdoc.js').Integer,
* postTag: import('../iterateJsdoc.js').Integer,
* postType: import('../iterateJsdoc.js').Integer,
* }} CustomSpacings
*/
/**
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {import('comment-parser').Spec & {
* line: import('../iterateJsdoc.js').Integer
* }} tag
* @param {CustomSpacings} customSpacings
*/
const checkNotAlignedPerTag = (utils, tag, customSpacings) => {
/*
start +
delimiter +
postDelimiter +
tag +
postTag +
type +
postType +
name +
postName +
description +
end +
lineEnd
*/
/**
* @typedef {"tag"|"type"|"name"|"description"} ContentProp
*/
/** @type {("postDelimiter"|"postTag"|"postType"|"postName")[]} */
let spacerProps;
/** @type {ContentProp[]} */
let contentProps;
const mightHaveNamepath = utils.tagMightHaveNamepath(tag.tag);
if (mightHaveNamepath) {
spacerProps = [
'postDelimiter', 'postTag', 'postType', 'postName',
];
contentProps = [
'tag', 'type', 'name', 'description',
];
} else {
spacerProps = [
'postDelimiter', 'postTag', 'postType',
];
contentProps = [
'tag', 'type', 'description',
];
}
const {
tokens,
} = tag.source[0];
/**
* @param {import('../iterateJsdoc.js').Integer} idx
* @param {(notRet: boolean, contentProp: ContentProp) => void} [callbck]
*/
const followedBySpace = (idx, callbck) => {
const nextIndex = idx + 1;
return spacerProps.slice(nextIndex).some((spacerProp, innerIdx) => {
const contentProp = contentProps[nextIndex + innerIdx];
const spacePropVal = tokens[spacerProp];
const ret = spacePropVal;
if (callbck) {
callbck(!ret, contentProp);
}
return ret && (callbck || !contentProp);
});
};
const postHyphenSpacing = customSpacings?.postHyphen ?? 1;
const exactHyphenSpacing = new RegExp(`^\\s*-\\s{${postHyphenSpacing},${postHyphenSpacing}}(?!\\s)`, 'u');
const hasNoHyphen = !(/^\s*-(?!$)(?=\s)/u).test(tokens.description);
const hasExactHyphenSpacing = exactHyphenSpacing.test(
tokens.description,
);
// If checking alignment on multiple lines, need to check other `source`
// items
// Go through `post*` spacing properties and exit to indicate problem if
// extra spacing detected
const ok = !spacerProps.some((spacerProp, idx) => {
const contentProp = contentProps[idx];
const contentPropVal = tokens[contentProp];
const spacerPropVal = tokens[spacerProp];
const spacing = customSpacings?.[spacerProp] || 1;
// There will be extra alignment if...
// 1. The spaces don't match the space it should have (1 or custom spacing) OR
return spacerPropVal.length !== spacing && spacerPropVal.length !== 0 ||
// 2. There is a (single) space, no immediate content, and yet another
// space is found subsequently (not separated by intervening content)
spacerPropVal && !contentPropVal && followedBySpace(idx);
}) && (hasNoHyphen || hasExactHyphenSpacing);
if (ok) {
return;
}
const fix = () => {
for (const [
idx,
spacerProp,
] of spacerProps.entries()) {
const contentProp = contentProps[idx];
const contentPropVal = tokens[contentProp];
if (contentPropVal) {
const spacing = customSpacings?.[spacerProp] || 1;
tokens[spacerProp] = ''.padStart(spacing, ' ');
followedBySpace(idx, (hasSpace, contentPrp) => {
if (hasSpace) {
tokens[contentPrp] = '';
}
});
} else {
tokens[spacerProp] = '';
}
}
if (!hasExactHyphenSpacing) {
const hyphenSpacing = /^\s*-\s+/u;
tokens.description = tokens.description.replace(
hyphenSpacing, '-' + ''.padStart(postHyphenSpacing, ' '),
);
}
utils.setTag(tag, tokens);
};
utils.reportJSDoc('Expected JSDoc block lines to not be aligned.', tag, fix, true);
};
/**
* @param {object} cfg
* @param {CustomSpacings} cfg.customSpacings
* @param {string} cfg.indent
* @param {import('comment-parser').Block} cfg.jsdoc
* @param {import('eslint').Rule.Node & {
* range: [number, number]
* }} cfg.jsdocNode
* @param {boolean} cfg.preserveMainDescriptionPostDelimiter
* @param {import('../iterateJsdoc.js').Report} cfg.report
* @param {string[]} cfg.tags
* @param {import('../iterateJsdoc.js').Utils} cfg.utils
* @param {string} cfg.wrapIndent
* @param {boolean} cfg.disableWrapIndent
* @returns {void}
*/
const checkAlignment = ({
customSpacings,
indent,
jsdoc,
jsdocNode,
preserveMainDescriptionPostDelimiter,
report,
tags,
utils,
wrapIndent,
disableWrapIndent,
}) => {
const transform = commentFlow(
alignTransform({
customSpacings,
indent,
preserveMainDescriptionPostDelimiter,
tags,
wrapIndent,
disableWrapIndent,
}),
);
const transformedJsdoc = transform(jsdoc);
const comment = '/*' +
/**
* @type {import('eslint').Rule.Node & {
* range: [number, number], value: string
* }}
*/ (jsdocNode).value + '*/';
const formatted = utils.stringify(transformedJsdoc)
.trimStart();
if (comment !== formatted) {
report(
'Expected JSDoc block lines to be aligned.',
/** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
return fixer.replaceText(jsdocNode, formatted);
},
);
}
};
export default iterateJsdoc(({
indent,
jsdoc,
jsdocNode,
report,
context,
utils,
}) => {
const {
tags: applicableTags = [
'param', 'arg', 'argument', 'property', 'prop', 'returns', 'return',
],
preserveMainDescriptionPostDelimiter,
customSpacings,
wrapIndent = '',
disableWrapIndent = false,
} = context.options[1] || {};
if (context.options[0] === 'always') {
// Skip if it contains only a single line.
if (!(
/**
* @type {import('eslint').Rule.Node & {
* range: [number, number], value: string
* }}
*/
(jsdocNode).value.includes('\n')
)) {
return;
}
checkAlignment({
customSpacings,
indent,
jsdoc,
jsdocNode,
preserveMainDescriptionPostDelimiter,
report,
tags: applicableTags,
utils,
wrapIndent,
disableWrapIndent,
});
return;
}
const foundTags = utils.getPresentTags(applicableTags);
if (context.options[0] !== 'any') {
for (const tag of foundTags) {
checkNotAlignedPerTag(
utils,
/**
* @type {import('comment-parser').Spec & {
* line: import('../iterateJsdoc.js').Integer
* }}
*/
(tag),
customSpacings,
);
}
}
for (const tag of foundTags) {
if (tag.source.length > 1) {
let idx = 0;
for (const {
tokens,
// Avoid the tag line
} of tag.source.slice(1)) {
idx++;
if (
!tokens.description ||
// Avoid first lines after multiline type
tokens.type ||
tokens.name
) {
continue;
}
// Don't include a single separating space/tab
if (!disableWrapIndent && tokens.postDelimiter.slice(1) !== wrapIndent) {
utils.reportJSDoc('Expected wrap indent', {
line: tag.source[0].number + idx,
}, () => {
tokens.postDelimiter = tokens.postDelimiter.charAt(0) + wrapIndent;
});
return;
}
}
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports invalid alignment of JSDoc block lines.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-line-alignment.md#repos-sticky-header',
},
fixable: 'whitespace',
schema: [
{
enum: [
'always', 'never', 'any',
],
type: 'string',
},
{
additionalProperties: false,
properties: {
customSpacings: {
additionalProperties: false,
properties: {
postDelimiter: {
type: 'integer',
},
postHyphen: {
type: 'integer',
},
postName: {
type: 'integer',
},
postTag: {
type: 'integer',
},
postType: {
type: 'integer',
},
},
},
preserveMainDescriptionPostDelimiter: {
default: false,
type: 'boolean',
},
tags: {
items: {
type: 'string',
},
type: 'array',
},
wrapIndent: {
type: 'string',
},
disableWrapIndent: {
type: 'boolean',
},
},
type: 'object',
},
],
type: 'layout',
},
});