Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 62 additions & 50 deletions src/rules/no-implicit-propagation.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// @ts-check
const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
const utils = require('@typescript-eslint/type-utils');
const ts = require('typescript');
const {
createRule,
hasThrowsTag,
findParent,
getOptionsFromContext
getOptionsFromContext,
getDeclarationTSNodeOfESTreeNode,
getJSDocThrowsTags,
getJSDocThrowsTagTypes,
toFlattenedTypeArray,
isTypesAssignableTo,
} = require('../utils');

module.exports = createRule({
Expand Down Expand Up @@ -52,11 +56,11 @@ module.exports = createRule({
'FunctionDeclaration :not(TryStatement > BlockStatement) ExpressionStatement:has(> CallExpression)'(node) {
if (node.expression.type !== AST_NODE_TYPES.CallExpression) return;

const declaration =
const callerDeclaration =
/** @type {import('@typescript-eslint/utils').TSESTree.FunctionDeclaration} */
(findParent(node, (n) => n.type === AST_NODE_TYPES.FunctionDeclaration));

const comments = sourceCode.getCommentsBefore(declaration);
const comments = sourceCode.getCommentsBefore(callerDeclaration);
const isCommented =
comments.length &&
comments
Expand All @@ -65,75 +69,81 @@ module.exports = createRule({

// TODO: Branching type checking or not
if (isCommented) {
const calleeDeclaration = services.getTypeAtLocation(node.expression.callee).symbol.valueDeclaration;
if (!calleeDeclaration) return;
const calleeDeclarationTSNode =
getDeclarationTSNodeOfESTreeNode(services, node.expression.callee);

const calleeTags =
/** @type {import('typescript').JSDocThrowsTag[]} */
(ts.getAllJSDocTagsOfKind(calleeDeclaration, ts.SyntaxKind.JSDocThrowsTag));
if (!calleeDeclarationTSNode) return;

const calleeThrowTypeNodes =
calleeTags
.map((tag) => tag.typeExpression?.type)
.filter((tag) => !!tag);
const callerDeclarationTSNode =
getDeclarationTSNodeOfESTreeNode(services, callerDeclaration);

const tsDeclaration = services.getTypeAtLocation(declaration).symbol.valueDeclaration;
if (!tsDeclaration) return;
if (!callerDeclarationTSNode) return;

const declarationTags =
/** @type {import('typescript').JSDocThrowsTag[]} */
(ts.getAllJSDocTagsOfKind(tsDeclaration, ts.SyntaxKind.JSDocThrowsTag));

const declarationThrowTypeNodes =
declarationTags
.map((tag) => tag.typeExpression?.type)
.filter((tag) => !!tag);

const calleeThrowTypes = calleeThrowTypeNodes
.map((node) => checker.getTypeFromTypeNode(node))
.flatMap(t => t.isUnion() ? t.types : t);
const calleeThrowsTypes =
toFlattenedTypeArray(
getJSDocThrowsTagTypes(checker, calleeDeclarationTSNode)
);

const declarationThrowTypes = declarationThrowTypeNodes
.map((node) => checker.getTypeFromTypeNode(node));
const callerThrowsTags = getJSDocThrowsTags(callerDeclarationTSNode);
const callerThrowsTypeNodes =
callerThrowsTags
.map(tag => tag.typeExpression?.type)
.filter(tag => !!tag);

const isAllCalleeThrowsAssignable = calleeThrowTypes
.every((t) => declarationThrowTypes
.some((n) => checker.isTypeAssignableTo(t, n)));
const callerThrowsTypes = getJSDocThrowsTagTypes(checker, callerDeclarationTSNode);

if (isAllCalleeThrowsAssignable) return;
if (
isTypesAssignableTo(checker, calleeThrowsTypes, callerThrowsTypes)
) {
return;
}

context.report({
node,
messageId: 'throwTypeMismatch',
fix(fixer) {
const lastTagtypeNode =
declarationThrowTypeNodes[declarationThrowTypeNodes.length - 1];
const lastThrowsTypeNode =
callerThrowsTypeNodes[callerThrowsTypeNodes.length - 1];

if (declarationTags.length > 1) {
const lastTag = declarationTags[declarationTags.length - 1];
const notAssignableThrows = calleeThrowTypes
.filter((t) => !declarationThrowTypes
if (callerThrowsTags.length > 1) {
const lastThrowsTag = callerThrowsTags[callerThrowsTags.length - 1];
const notAssignableThrows = calleeThrowsTypes
.filter((t) => !callerThrowsTypes
.some((n) => checker.isTypeAssignableTo(t, n)));

const callerJSDocTSNode = lastThrowsTag.parent;
/**
* @param {string} jsdocString
* @param {import('typescript').Type[]} types
* @returns {string}
*/
const appendThrowsTags = (jsdocString, types) =>
types.reduce((acc, t) =>
acc.replace(
/([^*\n]+)(\*+[/])/,
`$1* @throws {${utils.getTypeName(checker, t)}}\n$1$2`
),
jsdocString
);

return fixer.replaceTextRange(
[lastTag.parent.getStart(), lastTag.parent.getEnd()],
notAssignableThrows
.reduce((acc, t) =>
acc.replace(
/([^*\n]+)(\*+[/])/,
`$1* @throws {${utils.getTypeName(checker, t)}}\n$1$2`
),
lastTag.parent.getFullText()
)
[callerJSDocTSNode.getStart(), callerJSDocTSNode.getEnd()],
appendThrowsTags(
callerJSDocTSNode.getFullText(),
notAssignableThrows
)
);
}

// If there is only one throws tag, make it as a union type
return fixer.replaceTextRange(
[lastTagtypeNode.pos, lastTagtypeNode.end],
calleeThrowTypes.map(t => utils.getTypeName(checker, t)).join(' | '),
[lastThrowsTypeNode.pos, lastThrowsTypeNode.end],
calleeThrowsTypes
.map(t => utils.getTypeName(checker, t)).join(' | '),
);
},
});

return;
}

Expand All @@ -148,8 +158,10 @@ module.exports = createRule({
if (!isCalleeThrowable) return;

const lines = sourceCode.getLines();

const currentLine = lines[node.loc.start.line - 1];
const prevLine = lines[node.loc.start.line - 2];

const indent = currentLine.match(/^\s*/)?.[0] ?? '';
const newIndent = indent + ' '.repeat(options.tabLength);

Expand Down
44 changes: 23 additions & 21 deletions src/rules/no-undocumented-throws.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ const {
createRule,
hasThrowsTag,
findParent,
getOptionsFromContext
getOptionsFromContext,
getJSDocThrowsTags,
getJSDocThrowsTagTypes,
isTypesAssignableTo,
} = require('../utils');

module.exports = createRule({
Expand Down Expand Up @@ -46,7 +49,10 @@ module.exports = createRule({

const options = getOptionsFromContext(context);

/** @type {Map<number, import('@typescript-eslint/utils').TSESTree.ThrowStatement[]>} */
/**
* Group throw statements in functions
* @type {Map<number, import('@typescript-eslint/utils').TSESTree.ThrowStatement[]>}
*/
const throwStatements = new Map();

/** @param {import('typescript').Type[]} types */
Expand All @@ -56,16 +62,17 @@ module.exports = createRule({
return {
/** @param {import('@typescript-eslint/utils').TSESTree.ThrowStatement} node */
'FunctionDeclaration :not(TryStatement > BlockStatement) ThrowStatement'(node) {
const declaration =
const functionDeclaration =
/** @type {import('@typescript-eslint/utils').TSESTree.FunctionDeclaration} */
(findParent(node, (n) => n.type === AST_NODE_TYPES.FunctionDeclaration));

if (!throwStatements.has(declaration.range[0])) {
throwStatements.set(declaration.range[0], []);
// TODO: Use "SAFE" unique function identifier
if (!throwStatements.has(functionDeclaration.range[0])) {
throwStatements.set(functionDeclaration.range[0], []);
}
const throwStatementNodes =
/** @type {import('@typescript-eslint/utils').TSESTree.ThrowStatement[]} */
(throwStatements.get(declaration.range[0]));
(throwStatements.get(functionDeclaration.range[0]));

throwStatementNodes.push(node);
},
Expand Down Expand Up @@ -96,27 +103,22 @@ module.exports = createRule({
.flatMap(t => t.isUnion() ? t.types : t);

if (isCommented) {
const tags =
/** @type {import('typescript').JSDocThrowsTag[]} */
(ts.getAllJSDocTagsOfKind(
services.esTreeNodeToTSNodeMap.get(node),
ts.SyntaxKind.JSDocThrowsTag
));

const tagTypeNodes = tags
if (!services.esTreeNodeToTSNodeMap.has(node)) return;

const functionDeclarationTSNode = services.esTreeNodeToTSNodeMap.get(node);

const throwsTags = getJSDocThrowsTags(functionDeclarationTSNode);
const throwsTagTypeNodes = throwsTags
.map(tag => tag.typeExpression?.type)
.filter(tag => !!tag);

if (!tagTypeNodes.length) return;
if (!throwsTagTypeNodes.length) return;

const isAllThrowsAssignable = throwTypes
.every(t => tagTypeNodes
.some(n => checker
.isTypeAssignableTo(t, checker.getTypeFromTypeNode(n))));
const throwsTagTypes = getJSDocThrowsTagTypes(checker, functionDeclarationTSNode);

if (isAllThrowsAssignable) return;
if (isTypesAssignableTo(checker, throwTypes, throwsTagTypes)) return;

const lastTagtypeNode = tagTypeNodes[tagTypeNodes.length - 1];
const lastTagtypeNode = throwsTagTypeNodes[throwsTagTypeNodes.length - 1];

context.report({
node,
Expand Down
64 changes: 64 additions & 0 deletions src/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const { ESLintUtils } = require('@typescript-eslint/utils');
const ts = require('typescript');

const createRule = ESLintUtils.RuleCreator(
name => `https://github.com/Xvezda/eslint-plugin-explicit-exceptions/blob/master/docs/rules/${name}.md`,
Expand Down Expand Up @@ -42,9 +43,72 @@ const getOptionsFromContext = (context) => {
return options;
};

/**
* @param {import('@typescript-eslint/utils').ParserServicesWithTypeInformation} services
* @param {import('@typescript-eslint/utils').TSESTree.Node} node
* @returns {import('typescript').Node | undefined}
*/
const getDeclarationTSNodeOfESTreeNode = (services, node) => {
return services
.getTypeAtLocation(node)
.symbol
.valueDeclaration;
};

/**
* @param {import('typescript').Node} node
* @returns {Readonly<import('typescript').JSDocThrowsTag[]>}
*/
const getJSDocThrowsTags = (node) => {
return /** @type {Readonly<import('typescript').JSDocThrowsTag[]>} */(
ts.getAllJSDocTagsOfKind(node, ts.SyntaxKind.JSDocThrowsTag)
);
};

/**
* @param {import('typescript').TypeChecker} checker
* @param {import('typescript').Node} node
* @returns {import('typescript').Type[]}
*/
const getJSDocThrowsTagTypes = (checker, node) => {
const throwsTags = getJSDocThrowsTags(node);
const throwsTypeNodes = throwsTags
.map(tag => tag.typeExpression?.type)
.filter(tag => !!tag);

return throwsTypeNodes
.map(typeNode => checker.getTypeFromTypeNode(typeNode));
};

/**
* Treats union types as separate types.
*
* @param {import('typescript').Type[]} types
* @returns {import('typescript').Type[]}
*/
const toFlattenedTypeArray = (types) =>
types.flatMap(type => type.isUnion() ? type.types : type);

/**
* @param {import('typescript').TypeChecker} checker
* @param {import('typescript').Type[]} source
* @param {import('typescript').Type[]} target
* @returns {boolean}
*/
const isTypesAssignableTo = (checker, source, target) => {
return source.every(sourceType =>
target.some(targetType => checker.isTypeAssignableTo(sourceType, targetType))
);
};

module.exports = {
createRule,
hasThrowsTag,
findParent,
getOptionsFromContext,
getDeclarationTSNodeOfESTreeNode,
getJSDocThrowsTags,
getJSDocThrowsTagTypes,
toFlattenedTypeArray,
isTypesAssignableTo,
};