UNPKG

eslint-plugin-sentinel

Version:

A collection of ESLint rules specifically targeted for roblox-ts.

414 lines (407 loc) 17.1 kB
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 };