eslint-plugin-zk/src/rules/tsdocValidation.ts
import { AST_TOKEN_TYPES, AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils';
// import { createRule, getParserServices } from '../util';
import {
DocBlock,
DocComment,
TextRange,
TSDocConfiguration,
TSDocParser,
TSDocTagDefinition,
TSDocTagSyntaxKind,
TSDocMessageId,
DocParamBlock,
} from '@microsoft/tsdoc';
// import ts from 'typescript';
import { createRule } from '../util';
import { getForSourceFile } from './tsdocConfigCache';
/**
* Variables containing the substring "TSDoc" is written as "tsdoc" or "Tsdoc".
* Here, any mentioning of "TSDoc" refers to a TSDoc block comment.
*/
type PropertyNameNode = TSESTree.Node & { key: { type: AST_NODE_TYPES, value?: TSESTree.Literal['value'] } };
type PropertyNameNonComputedNode = TSESTree.Node & { key: TSESTree.Identifier | TSESTree.StringLiteral }
const restOfLinePattern = /.*\n/y;
const indentPattern = /(\s*)\S/y;
const sincePattern = /\s+\d+\.\d+\.\d+\s+/y;
/**
* ```
* /\s+\w+(\.\w+)*(\s+-)?\s+(\w+)?/y.exec(' 13eee_ee123.1.3\n')
* [' 13eee_ee123.1.3\n', '.3', undefined, undefined, ... ]
*
* /\s+\w+(\.\w+)*(\s+-)?\s+(\w+)?/y.exec(' 13eee_ee123.1.3 - ')
* [' 13eee_ee123.1.3 - ', '.3', ' -', undefined, ... ]
*
* /\s+\w+(\.\w+)*(\s+-)?\s+(\w+)?/y.exec(' 13eee_ee123.1.3 - 23')
* [' 13eee_ee123.1.3 - 23', '.3', ' -', '23', ... ]
*
* /\s+\w+(\.\w+)*(\s+-)?\s+(\w+)?/y.exec(' 13eee_ee123.1.3 23')
* [' 13eee_ee123.1.3 23', '.3', undefined, '23', ... ]
* ```
*/
const javadocParamTypePattern = /\s+\w+(\.\w+)*(\s+-)?\s+(\w+)?/y;
const uselessParamPattern = /\s*(\w+(\.\w+)*)?\s*-?\s*\n/y;
const topOfFile = { line: 1, column: 1 };
const builtinTsdocMessages = Object.fromEntries(
new TSDocConfiguration().allTsdocMessageIds
.map(messageId => [messageId, `${messageId}: {{unformattedText}}`])
) as Record<TSDocMessageId, string>;
export const tsdocValidation = createRule({
name: 'tsdocValidation',
meta: {
type: 'problem',
docs: {
description: 'TSDoc Validation.',
recommended: 'error',
},
fixable: 'code',
hasSuggestions: true,
messages: {
...builtinTsdocMessages,
cannotLoadTsdocConfig: 'Cannot load tsdoc.json: {{message}}',
cannotApplyTsdocConfig: 'Cannot apply tsdoc.json: {{message}}',
multipleTsdocs: 'Found multiple TSDoc associated with me; lines {{tsdocLines}}. Leave at most one TSDoc comment, or consider separating the declaration overloads.',
annotateInternal: 'Add @internal to the TSDoc, as a name that starts with an underscore should not be exposed.',
noAnnotateInternal: 'Remove @internal from the TSDoc, as a name that does not start with an underscore should be public.',
sinceFormat: '@since should be followed by a semver (MAJOR.MINOR.PATCH).',
paramFormatUnrecognizable: 'The format for this @param block is plain wrong.',
transformJavadocParam: 'Transform the JavaDoc pattern "@param{{from}}" to the TSDoc pattern "@param{{to}}".',
uselessParam: 'Remove this useless @param block.',
tsdocHeaderNewline: 'The start (`/**`) of a multi-line TSDoc should be on a separate line. "{{restOfLine}}"'
},
schema: []
},
defaultOptions: [],
create(context) {
const tsdocConfig = getTsdocConfig();
if (!tsdocConfig) {
return {};
}
const tsdocParser = new TSDocParser(tsdocConfig);
const sourceCode = context.getSourceCode();
return {
Program() { // Surface all builtin TSDoc errors.
for (const comment of sourceCode.getAllComments()) {
const textRange = getTsdocRange(comment);
if (!textRange) {
continue;
}
const { messages } = tsdocParser.parseRange(textRange).log;
for (const { textRange, messageId, unformattedText } of messages) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(textRange.pos),
end: sourceCode.getLocFromIndex(textRange.end)
},
messageId,
data: {
unformattedText
}
});
}
}
},
MethodDefinition(node){
transformMethod(node, node);
} ,
Property(node) {
switch (node.value.type) {
case AST_NODE_TYPES.FunctionExpression:
transformMethod(node, node);
break;
}
},
FunctionDeclaration: transformFunction,
// TSDeclareFunction: transformFunction,
// TSAbstractMethodDefinition: transformFunction,
PropertyDefinition: markPropertyInternal,
TSPropertySignature(node) {
if (node.parent?.type === AST_NODE_TYPES.TSInterfaceBody) {
markPropertyInternal(node);
}
},
TSMethodSignature(node) {
if (node.parent?.type === AST_NODE_TYPES.TSInterfaceBody) {
markPropertyInternal(node);
}
},
TSAbstractPropertyDefinition: markPropertyInternal,
TSEnumDeclaration(node) {
markInternal(node.id, node.id.name);
},
TSNamespaceExportDeclaration(node) {
markInternal(node.id, node.id.name);
}
};
function markPropertyInternal(node: TSESTree.Node & { key: TSESTree.PropertyName, computed?: boolean}) {
if (!node.computed && node.key.type === AST_NODE_TYPES.Identifier) {
if (!sourceCode.getCommentsBefore(node).some(comment =>
comment.value.includes('@internal')
)) {
markInternal(node, node.key.name);
}
}
}
function transformFunction(node: TSESTree.FunctionDeclaration | TSESTree.TSDeclareFunction): void {
if (node.id) {
const { parent } = node;
transformMethod({
...node,
key: node.id,
},
parent && parent.type === AST_NODE_TYPES.ExportNamedDeclaration ? parent : node);
}
}
function transformMethod(node: PropertyNameNode, commentNode: TSESTree.Node | TSESTree.Token): void {
if (!isPropertyNameNonComputed(node)) {
return;
}
const tsdoc = ensureSingleTsdoc(node, commentNode);
if (!tsdoc) {
return;
}
ensureTsdocHeaderNewline(tsdoc);
const { docComment } = tsdocParser.parseRange(tsdoc);
ensureInternal(node, docComment, tsdoc);
for (const block of docComment.customBlocks) {
switch (block.blockTag.tagName) {
case '@since':
validateSince(block);
break;
}
}
for (const param of docComment.params) {
transformJavadocParam(param);
}
}
function getTsdocConfig(): TSDocConfiguration | undefined {
const tsdocConfig = new TSDocConfiguration();
try {
const tsdocConfigFile = getForSourceFile(context.getFilename());
if (!tsdocConfigFile.fileNotFound) {
if (tsdocConfigFile.hasErrors) {
throw Error(`\n${tsdocConfigFile.getErrorSummary()}`);
}
try {
tsdocConfigFile.configureParser(tsdocConfig);
} catch (e) {
context.report({
loc: topOfFile,
messageId: 'cannotApplyTsdocConfig',
data: {
message: (e as Error).message
}
});
return;
}
}
} catch (e) {
context.report({
loc: topOfFile,
messageId: 'cannotLoadTsdocConfig',
data: {
message: (e as Error).message
}
});
return;
}
return tsdocConfig;
}
function getTsdocRange(comment: TSESTree.Comment): TextRange | undefined {
const textRange = TextRange.fromStringRange(sourceCode.text, ...comment.range);
if (
comment.type === AST_TOKEN_TYPES.Block &&
textRange.buffer.startsWith('/**', textRange.pos) &&
textRange.length >= '/***/'.length
) {
return textRange;
}
return;
}
function isPropertyNameNonComputed(node: PropertyNameNode): node is PropertyNameNonComputedNode {
switch (node.key.type) {
case AST_NODE_TYPES.Identifier:
return true;
case AST_NODE_TYPES.Literal:
return typeof node.key.value === 'string';
default: // Ignore nodes of other types.
return false;
}
}
function getPropertyNameNonComputed(node: PropertyNameNonComputedNode): string {
if (node.key.type === AST_NODE_TYPES.Identifier) {
return node.key.name;
}
return node.key.value;
}
function ensureSingleTsdoc(node: PropertyNameNonComputedNode, commentNode: TSESTree.Node | TSESTree.Token): TextRange | undefined {
const comments = sourceCode.getCommentsBefore(commentNode);
const tsdocs = new Array<TextRange>();
const tsdocLocs = new Array<string>();
for (const comment of comments) {
const textRange = getTsdocRange(comment);
if (textRange) {
tsdocs.push(textRange);
const startLine = comment.loc.start.line;
const endLine = comment.loc.end.line;
tsdocLocs.push(startLine === endLine ? `${startLine}` : `${startLine}-${endLine}`);
}
}
if (tsdocs.length > 1) {
context.report({
node: node.key,
messageId: 'multipleTsdocs',
data: {
tsdocLines: tsdocLocs.join(', '),
},
});
return;
}
if (tsdocs.length === 0) {
markInternal(node, getPropertyNameNonComputed(node));
}
return tsdocs[0];
}
function ensureTsdocHeaderNewline(tsdoc: TextRange): void {
const startOfDoc = sourceCode.getLocFromIndex(tsdoc.pos);
if (startOfDoc.line === sourceCode.getLocFromIndex(tsdoc.end).line) {
return; // TSDoc is single line.
}
let firstNonAsterisk = tsdoc.pos + 3;
while (tsdoc.buffer[firstNonAsterisk] === '*') {
++firstNonAsterisk;
}
restOfLinePattern.lastIndex = firstNonAsterisk;
const matchResult = restOfLinePattern.exec(tsdoc.buffer);
if (!matchResult) {
return;
}
const restOfLine = matchResult[0];
if (!restOfLine || restOfLine.startsWith('\n')) {
return;
}
context.report({
loc: {
start: startOfDoc,
end: {
line: startOfDoc.line,
column: startOfDoc.column + 3,
}
},
messageId: 'tsdocHeaderNewline',
data: {
restOfLine,
},
fix(fixer) {
const nextNewline = firstNonAsterisk + restOfLine.length;
const nextAsterisk = tsdoc.buffer.indexOf('*', nextNewline);
const indent = tsdoc.buffer.substring(nextNewline, nextAsterisk);
return fixer.replaceTextRange([firstNonAsterisk, nextNewline], `\n${indent}*${restOfLine}`);
}
});
}
function markInternal(node: TSESTree.Node & { key?: TSESTree.Node }, name: string) {
if (!name.startsWith('_')) {
return;
}
const {text} = sourceCode;
const newline = text.lastIndexOf('\n', node.range[0]);
indentPattern.lastIndex = newline + 1;
const matchResult = indentPattern.exec(text);
if (!matchResult) {
return;
}
const indent = matchResult[1];
if (!indent) {
return;
}
context.report({
node: node.key ?? node,
messageId: 'annotateInternal',
fix(fixer) {
return fixer.insertTextAfterRange([newline, newline + 1], `${indent}/** @internal */\n`);
}
});
}
function ensureInternal(node: PropertyNameNonComputedNode, docComment: DocComment, tsdoc: TextRange): void {
const name = getPropertyNameNonComputed(node);
const isAnnotatedInternal = docComment.modifierTagSet.isInternal();
if (name.startsWith('_')) {
if (!isAnnotatedInternal) {
context.report({
node: node.key,
messageId: 'annotateInternal',
fix(fixer) {
let beforeLastAsterisk = tsdoc.end - 2;
while (tsdoc.buffer[beforeLastAsterisk] === '*') {
--beforeLastAsterisk;
}
if (sourceCode.getLocFromIndex(tsdoc.pos).line === sourceCode.getLocFromIndex(tsdoc.end).line) {
// Single line TSDoc comment.
const tag = tsdoc.buffer[beforeLastAsterisk] === ' ' ? '@internal ' : ' @internal ';
return fixer.insertTextAfterRange([beforeLastAsterisk, beforeLastAsterisk + 1], tag);
}
//// Multi-line TSDoc comment.
// docComment.modifierTagSet.addTag(new DocBlockTag({
// tagName: '@internal',
// configuration: tsdocConfiguration,
// }));
// return fixer.replaceTextRange([tsdoc.pos, tsdoc.end], parserContext.docComment.emitAsTsdoc());
//// The commented code above is a simple fix that will destroy the original comment format.
const lastNewline = tsdoc.buffer.lastIndexOf('\n', beforeLastAsterisk);
const indent = tsdoc.buffer.substring(lastNewline + 1, beforeLastAsterisk + 1);
return fixer.insertTextAfterRange([lastNewline, lastNewline + 1], `${indent}* @internal\n`);
}
});
}
} else if (isAnnotatedInternal && !name.endsWith('_')) {
const internal = docComment.modifierTagSet.tryGetTag(new TSDocTagDefinition({
tagName: '@internal',
syntaxKind: TSDocTagSyntaxKind.ModifierTag,
}));
const textRange = internal!.getTokenSequence().getContainingTextRange();
context.report({
loc: {
start: sourceCode.getLocFromIndex(textRange.pos),
end: sourceCode.getLocFromIndex(textRange.end),
},
messageId: 'noAnnotateInternal',
fix(fixer) {
return fixer.removeRange([textRange.pos, textRange.end]);
}
});
}
}
function validateSince(block: DocBlock): void {
const textRange = block.blockTag.getTokenSequence().getContainingTextRange();
sincePattern.lastIndex = textRange.end;
if (!sincePattern.test(textRange.buffer)) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(textRange.pos),
end: sourceCode.getLocFromIndex(textRange.end),
},
messageId: 'sinceFormat',
});
}
}
function transformJavadocParam(param: DocParamBlock): void {
const { pos: start, end, buffer } = param.blockTag.getTokenSequence().getContainingTextRange();
uselessParamPattern.lastIndex = end;
if (uselessParamPattern.test(buffer)) {
const lineEnd = uselessParamPattern.lastIndex;
context.report({
loc: {
start: sourceCode.getLocFromIndex(start),
end: sourceCode.getLocFromIndex(lineEnd),
},
messageId: 'uselessParam',
fix(fixer) {
return fixer.removeRange([
buffer.lastIndexOf('\n', start) + 1,
lineEnd,
]);
}
});
return;
}
javadocParamTypePattern.lastIndex = end;
const matchResult = javadocParamTypePattern.exec(buffer);
if (!matchResult) {
context.report({
loc: {
start: sourceCode.getLocFromIndex(start),
end: sourceCode.getLocFromIndex(end),
},
messageId: 'paramFormatUnrecognizable',
});
return;
}
const [from, _, hyphen, secondItem] = matchResult;
if (hyphen || !secondItem) {
return;
}
const replacementEnd = javadocParamTypePattern.lastIndex;
const to = ` ${secondItem} -`;
context.report({
loc: {
start: sourceCode.getLocFromIndex(start),
end: sourceCode.getLocFromIndex(replacementEnd),
},
messageId: 'transformJavadocParam',
data: { from, to },
fix(fixer) {
return fixer.replaceTextRange([end, replacementEnd], to);
}
});
}
}
});
/**
* The format of `Comment.value` returned by `getCommentsBefore`:
* 1. Single line comments will have their `//` stripped off. Leaving everything else intact.
* 2. Multi-line comments will have their enclosing delimiters stripped off. Leaving everything else intact.
*/