eslint-plugin-sentinel
Version:
A collection of ESLint rules specifically targeted for roblox-ts.
414 lines (407 loc) • 17.1 kB
JavaScript
import { AST_NODE_TYPES, AST_TOKEN_TYPES } from "@typescript-eslint/utils";
import { getStaticValue, isClosingParenToken, isOpeningParenToken, isParenthesized } from "@typescript-eslint/utils/ast-utils";
import { RuleCreator, getParserServices } from "@typescript-eslint/utils/eslint-utils";
import assert from "node:assert";
import { SignatureKind, TypePredicateKind } from "typescript";
import { Scope } from "@typescript-eslint/utils/ts-eslint";
//#region package.json
var version = "0.1.2";
var repository = {
"type": "git",
"url": "git+https://github.com/christopher-buss/eslint-plugin-sentinel.git"
};
//#endregion
//#region src/util.ts
const createEslintRule = RuleCreator((name) => {
const repoUrl = repository.url.replace(/\.git$/, "");
return `${repoUrl}/tree/v${version}/docs/rules/${name}.md`;
});
//#endregion
//#region src/rules/explicit-size-check/util.ts
var FixAbortError = class extends Error {
name = "FixAbortError";
};
function isIterable(object) {
return object !== null && typeof object === "object" && Symbol.iterator in object;
}
const fixOptions = { abort() {
throw new FixAbortError();
} };
function reportProblems(create$1) {
return (context) => {
const listeners = {};
const addListener = (selector, listener) => {
listeners[selector] ??= [];
listeners[selector].push(listener);
};
const contextProxy = new Proxy(context, { get(target, property, receiver) {
if (property === "on") return (selectorOrSelectors, listener) => {
const selectors = Array.isArray(selectorOrSelectors) ? selectorOrSelectors : [selectorOrSelectors];
for (const selector of selectors) addListener(selector, listener);
};
if (property === "onExit") return (selectorOrSelectors, listener) => {
const selectors = Array.isArray(selectorOrSelectors) ? selectorOrSelectors : [selectorOrSelectors];
for (const selector of selectors) addListener(`${selector}:exit`, listener);
};
return Reflect.get(target, property, receiver);
} });
for (const [selector, listener] of Object.entries(create$1(contextProxy))) addListener(selector, listener);
return Object.fromEntries(Object.entries(listeners).map(([selector, listenerList]) => {
return [selector, (...listenerArguments) => {
for (const listener of listenerList) reportListenerProblems(listener(...listenerArguments), context);
}];
}));
};
}
function reportListenerProblems(problems, context) {
if (!problems) return;
const problemList = isIterable(problems) ? Array.from(problems) : [problems];
for (const problem of problemList) {
if (typeof problem.fix === "function") problem.fix = wrapFixFunction(problem.fix);
if (Array.isArray(problem.suggest)) for (const suggest of problem.suggest) {
if (typeof suggest.fix === "function") suggest.fix = wrapFixFunction(suggest.fix);
suggest.data = {
...problem.data ?? {},
...suggest.data ?? {}
};
}
context.report(problem);
}
}
function wrapFixFunction(fix) {
return (fixer) => {
const result = fix(fixer, fixOptions);
if (isIterable(result)) try {
return Array.from(result);
} catch (err) {
if (err instanceof FixAbortError) return;
throw err;
}
return result;
};
}
//#endregion
//#region src/rules/explicit-size-check/rule.ts
const RULE_NAME$1 = "explicit-size-check";
const TYPE_NON_ZERO = "non-zero";
const TYPE_ZERO = "zero";
const MESSAGE_ID_SUGGESTION = "suggestion";
const messages = {
[MESSAGE_ID_SUGGESTION]: "Replace `.{{property}}()` with `.{{property}}() {{code}}`.",
[TYPE_NON_ZERO]: "Use `.{{property}}() {{code}}` when checking {{property}}() is not zero.",
[TYPE_ZERO]: "Use `.{{property}}() {{code}}` when checking {{property}}() is zero."
};
function isLiteral(node, value) {
if (node.type !== AST_NODE_TYPES.Literal) return false;
if (value === null) return node.raw === "null";
return node.value === value;
}
function isCompareLeft(node, operator, value) {
return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === operator && isLiteral(node.left, value);
}
function isCompareRight(node, operator, value) {
return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === operator && isLiteral(node.right, value);
}
const nonZeroStyles = new Map([["greater-than", {
code: "> 0",
test: (node) => isCompareRight(node, ">", 0)
}], ["not-equal", {
code: "!== 0",
test: (node) => isCompareRight(node, "!==", 0)
}]]);
function* fixSpaceAroundKeyword(fixer, node, sourceCode) {
const range = getParenthesizedRange(node, sourceCode);
const tokenBefore = sourceCode.getTokenBefore(node, { includeComments: true });
if (tokenBefore && range[0] === tokenBefore.range[1] && isProblematicToken(tokenBefore)) yield fixer.insertTextAfter(tokenBefore, " ");
const tokenAfter = sourceCode.getTokenAfter(node, { includeComments: true });
if (tokenAfter && range[1] === tokenAfter.range[0] && isProblematicToken(tokenAfter)) yield fixer.insertTextBefore(tokenAfter, " ");
}
function getParentheses(node, sourceCode) {
const count = getParenthesizedTimes(node, sourceCode);
if (count === 0) return [];
return [...sourceCode.getTokensBefore(node, {
count,
filter: isOpeningParenToken
}), ...sourceCode.getTokensAfter(node, {
count,
filter: isClosingParenToken
})];
}
function getParenthesizedRange(node, sourceCode) {
const parentheses = getParentheses(node, sourceCode);
const [start] = (parentheses[0] ?? node).range;
const [, end] = (parentheses.at(-1) ?? node).range;
return [start, end];
}
function getParenthesizedTimes(node, sourceCode) {
let times = 0;
while (isParenthesized(times + 1, node, sourceCode)) times++;
return times;
}
function isProblematicToken({ type, value }) {
return type === AST_TOKEN_TYPES.Keyword && /^[a-z]*$/.test(value) || type === AST_TOKEN_TYPES.Identifier && value === "of" || type === AST_TOKEN_TYPES.Identifier && value === "await";
}
const zeroStyle = {
code: "=== 0",
test: (node) => isCompareRight(node, "===", 0)
};
function create(context) {
const options = {
"non-zero": "greater-than",
...context.options[0]
};
const nonZeroStyle = nonZeroStyles.get(options["non-zero"]);
if (nonZeroStyle === void 0) throw new Error(`Invalid option for non-zero: ${options["non-zero"]}`);
const { sourceCode } = context;
const parserServices = getParserServices(context);
const checker = parserServices.program.getTypeChecker();
function getProblem({ autoFix, isZeroLengthCheck, node, sizeCallNode }) {
assert(nonZeroStyle);
const { code, test } = isZeroLengthCheck ? zeroStyle : nonZeroStyle;
if (test(node)) return;
let fixed = `${sourceCode.getText(sizeCallNode)} ${code}`;
if (!isParenthesized(node, sourceCode) && node.type === AST_NODE_TYPES.UnaryExpression && (node.parent.type === AST_NODE_TYPES.UnaryExpression || node.parent.type === AST_NODE_TYPES.AwaitExpression)) fixed = `(${fixed})`;
const { callee } = sizeCallNode;
if (callee.type !== AST_NODE_TYPES.MemberExpression || callee.property.type !== AST_NODE_TYPES.Identifier) return;
const fix = function* (fixer) {
yield fixer.replaceText(node, fixed);
yield* fixSpaceAroundKeyword(fixer, node, sourceCode);
};
const problem = {
data: {
code,
property: callee.property.name
},
messageId: isZeroLengthCheck ? TYPE_ZERO : TYPE_NON_ZERO,
node
};
if (autoFix) problem.fix = fix;
else problem.suggest = [{
fix,
messageId: MESSAGE_ID_SUGGESTION
}];
return problem;
}
return { CallExpression(callExpr) {
if (callExpr.callee.type !== AST_NODE_TYPES.MemberExpression || callExpr.callee.property.type !== AST_NODE_TYPES.Identifier || callExpr.callee.property.name !== "size" || callExpr.arguments.length > 0 || callExpr.callee.optional || callExpr.callee.object.type === AST_NODE_TYPES.ThisExpression) return;
const sizeCallNode = callExpr;
let node;
let autoFix = true;
const result = getSizeCheckNode(sizeCallNode);
const sizeCheckNode = result?.node;
let isZeroLengthCheck = result?.isZeroLengthCheck ?? false;
if (sizeCheckNode) {
const { isNegative, node: ancestor } = getBooleanAncestor(sizeCheckNode);
node = ancestor;
if (isNegative) isZeroLengthCheck = !isZeroLengthCheck;
} else {
const { isNegative, node: ancestor } = getBooleanAncestor(sizeCallNode);
if (isBooleanNode(ancestor, parserServices, checker)) {
isZeroLengthCheck = isNegative;
node = ancestor;
} else if (isLogicalExpression(sizeCallNode.parent) && !(sizeCallNode.parent.operator === "||" && isNodeValueNumber(sizeCallNode.parent.right, context))) {
isZeroLengthCheck = isNegative;
node = sizeCallNode;
autoFix = false;
}
}
if (!node) return;
return getProblem({
autoFix,
isZeroLengthCheck,
node,
sizeCallNode
});
} };
}
function getBooleanAncestor(node) {
let isNegative = false;
while (true) if (isLogicNotArgument(node)) {
isNegative = !isNegative;
if (node.parent) node = node.parent;
} else if (isBooleanCallArgument(node)) {
if (node.parent) node = node.parent;
} else break;
return {
isNegative,
node
};
}
function getSizeCheckNode(check) {
const node = check.parent;
if (isCompareRight(node, "===", 0) || isCompareRight(node, "==", 0) || isCompareRight(node, "<", 1) || isCompareLeft(node, "===", 0) || isCompareLeft(node, "==", 0) || isCompareLeft(node, ">", 1)) return {
isZeroLengthCheck: true,
node
};
if (isCompareRight(node, "!==", 0) || isCompareRight(node, "!=", 0) || isCompareRight(node, ">", 0) || isCompareRight(node, ">=", 1) || isCompareLeft(node, "!==", 0) || isCompareLeft(node, "!=", 0) || isCompareLeft(node, "<", 0) || isCompareLeft(node, "<=", 1)) return {
isZeroLengthCheck: false,
node
};
return void 0;
}
function isAssertionFunction(node, services, checker) {
const tsNode = services.esTreeNodeToTSNodeMap.get(node.callee);
const nodeType = checker.getTypeAtLocation(tsNode);
const signatures = checker.getSignaturesOfType(nodeType, SignatureKind.Call);
return signatures.some((signature) => {
const typePredicateInfo = checker.getTypePredicateOfSignature(signature);
if (!typePredicateInfo) return false;
return typePredicateInfo.kind === TypePredicateKind.AssertsIdentifier;
});
}
function isBooleanCall(node) {
return isCallExpression(node) && node.callee.type === AST_NODE_TYPES.Identifier && node.callee.name === "Boolean" && node.arguments.length === 1;
}
function isBooleanCallArgument(node) {
return node.parent !== void 0 && isCallExpression(node.parent) && isBooleanCall(node.parent) && node.parent.arguments[0] === node;
}
function isBooleanNode(node, services, checker) {
if (isLogicNot(node) || isLogicNotArgument(node) || isBooleanCall(node) || isBooleanCallArgument(node)) return true;
const { parent } = node;
if (parent === void 0) return false;
if ((parent.type === AST_NODE_TYPES.IfStatement || parent.type === AST_NODE_TYPES.ConditionalExpression || parent.type === AST_NODE_TYPES.WhileStatement || parent.type === AST_NODE_TYPES.DoWhileStatement || parent.type === AST_NODE_TYPES.ForStatement) && parent.test === node) return true;
if (parent.type === AST_NODE_TYPES.CallExpression && parent.arguments[0] === node && isAssertionFunction(parent, services, checker)) return true;
if (isLogicalExpression(parent)) return isBooleanNode(parent, services, checker);
return false;
}
function isCallExpression(node) {
return node.type === AST_NODE_TYPES.CallExpression;
}
function isLogicalExpression(node) {
return node.type === AST_NODE_TYPES.LogicalExpression && (node.operator === "&&" || node.operator === "||");
}
function isLogicNot(node) {
return isUnaryExpression(node) && node.operator === "!";
}
function isLogicNotArgument(node) {
return node.parent !== void 0 && isUnaryExpression(node.parent) && isLogicNot(node.parent) && node.parent.argument === node;
}
function isNodeValueNumber(node, context) {
if (isNumberLiteral(node)) return true;
const staticValue = getStaticValue(node, context.sourceCode.getScope(node));
return (staticValue && typeof staticValue.value === "number") ?? false;
}
function isNumberLiteral(node) {
return node.type === AST_NODE_TYPES.Literal && typeof node.value === "number";
}
function isUnaryExpression(node) {
return node.type === AST_NODE_TYPES.UnaryExpression;
}
const explicitSizeCheckRule = createEslintRule({
create: reportProblems(create),
defaultOptions: [{}],
meta: {
docs: {
description: "Enforce explicitly comparing the `size` property of a value",
recommended: false,
requiresTypeChecking: true
},
fixable: "code",
hasSuggestions: true,
messages,
schema: [{
additionalProperties: false,
properties: { "non-zero": {
default: "greater-than",
enum: [...nonZeroStyles.keys()],
type: "string"
} },
type: "object"
}],
type: "problem"
},
name: RULE_NAME$1
});
//#endregion
//#region src/rules/prefer-math-min-max/rule.ts
const RULE_NAME = "prefer-math-min-max";
function getExpressionText(node, sourceCode) {
const expressionNode = node.type === AST_NODE_TYPES.TSAsExpression ? node.expression : node;
if (node.type === AST_NODE_TYPES.TSAsExpression) return getExpressionText(expressionNode, sourceCode);
return sourceCode.getText(expressionNode);
}
function isNumberTypeAnnotation(typeAnnotation) {
if (typeAnnotation.type === AST_NODE_TYPES.TSNumberKeyword) return true;
if (typeAnnotation.type === AST_NODE_TYPES.TSTypeAnnotation && typeAnnotation.typeAnnotation.type === AST_NODE_TYPES.TSNumberKeyword) return true;
return !!(typeAnnotation.type === AST_NODE_TYPES.TSTypeReference && typeAnnotation.typeName.type === AST_NODE_TYPES.Identifier && typeAnnotation.typeName.name === "Number");
}
const preferMathMinMaxRule = createEslintRule({
create(context) {
return { ConditionalExpression(conditionalExpression) {
const { alternate, consequent, test } = conditionalExpression;
if (test.type !== AST_NODE_TYPES.BinaryExpression) return;
const { left, operator, right } = test;
const [leftText, rightText, alternateText, consequentText] = [
left,
right,
alternate,
consequent
].map((node) => getExpressionText(node, context.sourceCode));
const isGreaterOrEqual = operator === ">" || operator === ">=";
const isLessOrEqual = operator === "<" || operator === "<=";
let method;
if (isGreaterOrEqual && leftText === alternateText && rightText === consequentText || isLessOrEqual && leftText === consequentText && rightText === alternateText) method = "min";
else if (isGreaterOrEqual && leftText === consequentText && rightText === alternateText || isLessOrEqual && leftText === alternateText && rightText === consequentText) method = "max";
if (!method) return;
for (const node of [left, right]) {
let expressionNode = node;
if (expressionNode.type === AST_NODE_TYPES.TSAsExpression) {
if (!isNumberTypeAnnotation(expressionNode.typeAnnotation)) return;
expressionNode = expressionNode.expression;
}
if (expressionNode.type === AST_NODE_TYPES.Identifier) {
const variable = context.sourceCode.getScope(expressionNode).variables.find((variable_) => variable_.name === expressionNode.name);
for (const definition of variable?.defs ?? []) if (definition.type === Scope.DefinitionType.Parameter) {
const identifier = definition.name;
if (identifier.typeAnnotation?.type === AST_NODE_TYPES.TSTypeAnnotation && !isNumberTypeAnnotation(identifier.typeAnnotation)) return;
if (identifier.parent.type === AST_NODE_TYPES.AssignmentPattern && identifier.parent.right.type === AST_NODE_TYPES.Literal && typeof identifier.parent.right.value !== "number") return;
} else if (definition.type === Scope.DefinitionType.Variable) {
const variableDeclarator = definition.node;
if (variableDeclarator.id.typeAnnotation?.type === AST_NODE_TYPES.TSTypeAnnotation && !isNumberTypeAnnotation(variableDeclarator.id.typeAnnotation)) return;
if (variableDeclarator.init?.type === AST_NODE_TYPES.Literal && typeof variableDeclarator.init.value !== "number") return;
}
}
}
context.report({
data: { method },
fix(fixer) {
const { sourceCode } = context;
const argumentsText = [left, right].map((node) => {
return node.type === AST_NODE_TYPES.SequenceExpression ? `(${sourceCode.getText(node)})` : sourceCode.getText(node);
}).join(", ");
return fixer.replaceText(conditionalExpression, `math.${method}(${argumentsText})`);
},
messageId: RULE_NAME,
node: conditionalExpression
});
} };
},
defaultOptions: [],
meta: {
docs: {
description: "Require `math.min()` and `math.max()` over ternaries for simple comparisons",
recommended: false,
requiresTypeChecking: false
},
fixable: "code",
messages: { "prefer-math-min-max": "Use `math.{{method}}()` instead of the ternary operator." },
schema: [],
type: "suggestion"
},
name: RULE_NAME
});
//#endregion
//#region src/index.ts
const plugin = {
meta: {
name: "sentinel",
version
},
rules: {
"explicit-size-check": explicitSizeCheckRule,
"prefer-math-min-max": preferMathMinMaxRule
}
};
var src_default = plugin;
//#endregion
export { src_default as default };