From 0a4b2f04b3c223280896fbaebb4f23ff186b5faf Mon Sep 17 00:00:00 2001 From: Rebecca Stevens Date: Sat, 14 Aug 2021 00:53:36 +1200 Subject: [PATCH] chore(prefer-readonly-type-declaration): add upstream utils to this project Once https://github.com/typescript-eslint/typescript-eslint/pull/3658 is merged, this commit can be reverted --- .eslintrc.json | 2 +- .nycrc | 3 +- .prettierignore | 1 + src/util/rule.ts | 5 +- .../eslint-typescript/isTypeReadonly.ts | 198 ++++++++++++++++++ .../upstream/eslint-typescript/nullThrows.ts | 28 +++ .../eslint-typescript/propertyTypes.ts | 38 ++++ 7 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/util/upstream/eslint-typescript/isTypeReadonly.ts create mode 100644 src/util/upstream/eslint-typescript/nullThrows.ts create mode 100644 src/util/upstream/eslint-typescript/propertyTypes.ts diff --git a/.eslintrc.json b/.eslintrc.json index cead4ea11..64160f1d1 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -26,7 +26,7 @@ "project": ["./tsconfig.json", "./tests/tsconfig.json", "./cz-adapter/tsconfig.json"], "sourceType": "module" }, - "ignorePatterns": ["build/", "coverage/", "lib/", "**/*.js"], + "ignorePatterns": ["build/", "coverage/", "lib/", "**/*.js", "src/util/upstream/**/*"], "rules": { "@typescript-eslint/no-unnecessary-condition": "off", "import/no-relative-parent-imports": "error", diff --git a/.nycrc b/.nycrc index 332fc26a4..9a2736fd9 100644 --- a/.nycrc +++ b/.nycrc @@ -4,7 +4,8 @@ "src/**/*" ], "exclude": [ - "src/util/conditional-imports/**/*" + "src/util/conditional-imports/**/*", + "src/util/upstream/**/*" ], "reporter": [ "lcov", diff --git a/.prettierignore b/.prettierignore index 1b763b1ba..9b814ab8b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,2 @@ CHANGELOG.md +src/util/upstream/**/* diff --git a/src/util/rule.ts b/src/util/rule.ts index c1ce3924a..ec45440c2 100644 --- a/src/util/rule.ts +++ b/src/util/rule.ts @@ -6,6 +6,8 @@ import type { Node, Type } from "typescript"; import { shouldIgnore } from "~/common/ignore-options"; import { version } from "~/package.json"; +import { isTypeReadonly } from "./upstream/eslint-typescript/isTypeReadonly"; + export type BaseOptions = object; // "url" will be set automatically. @@ -150,7 +152,8 @@ export function isReadonly>( const checker = parserServices.program.getTypeChecker(); const type = getTypeOfNode(node, context); - return ESLintUtils.isTypeReadonly(checker, type!); + // return ESLintUtils.isTypeReadonly(checker, type!); + return isTypeReadonly(checker, type!); } /** diff --git a/src/util/upstream/eslint-typescript/isTypeReadonly.ts b/src/util/upstream/eslint-typescript/isTypeReadonly.ts new file mode 100644 index 000000000..40c38b3a3 --- /dev/null +++ b/src/util/upstream/eslint-typescript/isTypeReadonly.ts @@ -0,0 +1,198 @@ +// @ts-nocheck + +import tsutils from "~/conditional-imports/tsutils"; +import ts from "~/conditional-imports/typescript"; + +import { nullThrows, NullThrowsReasons } from "./nullThrows"; +import { getTypeOfPropertyOfType } from "./propertyTypes"; + +const enum Readonlyness { + /** the type cannot be handled by the function */ + UnknownType = 1, + /** the type is mutable */ + Mutable = 2, + /** the type is readonly */ + Readonly = 3, +} + +function isTypeReadonlyArrayOrTuple( + checker: ts.TypeChecker, + type: ts.Type, + seenTypes: Set +): Readonlyness { + function checkTypeArguments(arrayType: ts.TypeReference): Readonlyness { + const typeArguments = checker.getTypeArguments + ? checker.getTypeArguments(arrayType) + : arrayType.typeArguments ?? []; + + // this shouldn't happen in reality as: + // - tuples require at least 1 type argument + // - ReadonlyArray requires at least 1 type argument + /* istanbul ignore if */ if (typeArguments.length === 0) { + return Readonlyness.Readonly; + } + + // validate the element types are also readonly + if ( + typeArguments.some( + (typeArg) => + isTypeReadonlyRecurser(checker, typeArg, seenTypes) === + Readonlyness.Mutable + ) + ) { + return Readonlyness.Mutable; + } + return Readonlyness.Readonly; + } + + if (checker.isArrayType(type)) { + const symbol = nullThrows( + type.getSymbol(), + NullThrowsReasons.MissingToken("symbol", "array type") + ); + const escapedName = symbol.getEscapedName(); + if (escapedName === "Array") { + return Readonlyness.Mutable; + } + + return checkTypeArguments(type); + } + + if (checker.isTupleType(type)) { + if (!type.target.readonly) { + return Readonlyness.Mutable; + } + + return checkTypeArguments(type); + } + + return Readonlyness.UnknownType; +} + +function isTypeReadonlyObject( + checker: ts.TypeChecker, + type: ts.Type, + seenTypes: Set +): Readonlyness { + function checkIndexSignature(kind: ts.IndexKind): Readonlyness { + const indexInfo = checker.getIndexInfoOfType(type, kind); + if (indexInfo) { + return indexInfo.isReadonly + ? Readonlyness.Readonly + : Readonlyness.Mutable; + } + + return Readonlyness.UnknownType; + } + + const properties = type.getProperties(); + if (properties.length) { + // ensure the properties are marked as readonly + for (const property of properties) { + if (!tsutils.isPropertyReadonlyInType(type, property.getEscapedName(), checker)) { + return Readonlyness.Mutable; + } + } + + // all properties were readonly + // now ensure that all of the values are readonly also. + + // do this after checking property readonly-ness as a perf optimization, + // as we might be able to bail out early due to a mutable property before + // doing this deep, potentially expensive check. + for (const property of properties) { + const propertyType = nullThrows( + getTypeOfPropertyOfType(checker, type, property), + NullThrowsReasons.MissingToken(`property "${property.name}"`, "type") + ); + + // handle recursive types. + // we only need this simple check, because a mutable recursive type will break via the above prop readonly check + if (seenTypes.has(propertyType)) { + continue; + } + + if ( + isTypeReadonlyRecurser(checker, propertyType, seenTypes) === + Readonlyness.Mutable + ) { + return Readonlyness.Mutable; + } + } + } + + const isStringIndexSigReadonly = checkIndexSignature(ts.IndexKind.String); + if (isStringIndexSigReadonly === Readonlyness.Mutable) { + return isStringIndexSigReadonly; + } + + const isNumberIndexSigReadonly = checkIndexSignature(ts.IndexKind.Number); + if (isNumberIndexSigReadonly === Readonlyness.Mutable) { + return isNumberIndexSigReadonly; + } + + return Readonlyness.Readonly; +} + +// a helper function to ensure the seenTypes map is always passed down, except by the external caller +function isTypeReadonlyRecurser( + checker: ts.TypeChecker, + type: ts.Type, + seenTypes: Set +): Readonlyness.Readonly | Readonlyness.Mutable { + seenTypes.add(type); + + if (tsutils.isUnionType(type)) { + // all types in the union must be readonly + const result = tsutils.unionTypeParts(type).every((t) => + isTypeReadonlyRecurser(checker, t, seenTypes) + ); + const readonlyness = result ? Readonlyness.Readonly : Readonlyness.Mutable; + return readonlyness; + } + + // all non-object, non-intersection types are readonly. + // this should only be primitive types + if (!tsutils.isObjectType(type) && !tsutils.isUnionOrIntersectionType(type)) { + return Readonlyness.Readonly; + } + + // pure function types are readonly + if ( + type.getCallSignatures().length > 0 && + type.getProperties().length === 0 + ) { + return Readonlyness.Readonly; + } + + const isReadonlyArray = isTypeReadonlyArrayOrTuple(checker, type, seenTypes); + if (isReadonlyArray !== Readonlyness.UnknownType) { + return isReadonlyArray; + } + + const isReadonlyObject = isTypeReadonlyObject(checker, type, seenTypes); + /* istanbul ignore else */ if ( + isReadonlyObject !== Readonlyness.UnknownType + ) { + return isReadonlyObject; + } + + throw new Error("Unhandled type"); +} + +/** + * Checks if the given type is readonly + */ +function isTypeReadonly(checker: ts.TypeChecker, type: ts.Type): boolean { + if (ts === undefined) { + throw new Error("TypeScript not found."); + } + if (tsutils === undefined) { + throw new Error("tsutils not found."); + } + return ( + isTypeReadonlyRecurser(checker, type, new Set()) === Readonlyness.Readonly + ); +} + +export { isTypeReadonly }; diff --git a/src/util/upstream/eslint-typescript/nullThrows.ts b/src/util/upstream/eslint-typescript/nullThrows.ts new file mode 100644 index 000000000..df644c2be --- /dev/null +++ b/src/util/upstream/eslint-typescript/nullThrows.ts @@ -0,0 +1,28 @@ +/** + * A set of common reasons for calling nullThrows + */ +const NullThrowsReasons = { + MissingParent: 'Expected node to have a parent.', + MissingToken: (token: string, thing: string) => + `Expected to find a ${token} for the ${thing}.`, +} as const; + +/** + * Assert that a value must not be null or undefined. + * This is a nice explicit alternative to the non-null assertion operator. + */ +function nullThrows(value: T | null | undefined, message: string): T { + // this function is primarily used to keep types happy in a safe way + // i.e. is used when we expect that a value is never nullish + // this means that it's pretty much impossible to test the below if... + + // so ignore it in coverage metrics. + /* istanbul ignore if */ + if (value === null || value === undefined) { + throw new Error(`Non-null Assertion Failed: ${message}`); + } + + return value; +} + +export { nullThrows, NullThrowsReasons }; diff --git a/src/util/upstream/eslint-typescript/propertyTypes.ts b/src/util/upstream/eslint-typescript/propertyTypes.ts new file mode 100644 index 000000000..1be35f5ed --- /dev/null +++ b/src/util/upstream/eslint-typescript/propertyTypes.ts @@ -0,0 +1,38 @@ +// @ts-nocheck + +import * as ts from "typescript"; + +export function getTypeOfPropertyOfName( + checker: ts.TypeChecker, + type: ts.Type, + name: string, + escapedName?: ts.__String +): ts.Type | undefined { + // Most names are directly usable in the checker and aren't different from escaped names + if (!escapedName || !name.startsWith("__")) { + return checker.getTypeOfPropertyOfType(type, name); + } + + // Symbolic names may differ in their escaped name compared to their human-readable name + // https://github.com/typescript-eslint/typescript-eslint/issues/2143 + const escapedProperty = type + .getProperties() + .find((property) => property.escapedName === escapedName); + + return escapedProperty + ? checker.getDeclaredTypeOfSymbol(escapedProperty) + : undefined; +} + +export function getTypeOfPropertyOfType( + checker: ts.TypeChecker, + type: ts.Type, + property: ts.Symbol +): ts.Type | undefined { + return getTypeOfPropertyOfName( + checker, + type, + property.getName(), + property.getEscapedName() + ); +}