Skip to content

Commit

Permalink
chore(prefer-readonly-type-declaration): add upstream utils to this p…
Browse files Browse the repository at this point in the history
…roject

Once typescript-eslint/typescript-eslint#3658 is merged, this commit can be
reverted
  • Loading branch information
RebeccaStevens committed Aug 13, 2021
1 parent 57ff8e0 commit 7587887
Show file tree
Hide file tree
Showing 6 changed files with 268 additions and 3 deletions.
2 changes: 1 addition & 1 deletion .eslintrc.json
Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion .nycrc
Expand Up @@ -4,7 +4,8 @@
"src/**/*"
],
"exclude": [
"src/util/conditional-imports/**/*"
"src/util/conditional-imports/**/*",
"src/util/upstream/**/*"
],
"reporter": [
"lcov",
Expand Down
5 changes: 4 additions & 1 deletion src/util/rule.ts
Expand Up @@ -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.
Expand Down Expand Up @@ -150,7 +152,8 @@ export function isReadonly<Context extends RuleContext<string, BaseOptions>>(

const checker = parserServices.program.getTypeChecker();
const type = getTypeOfNode(node, context);
return ESLintUtils.isTypeReadonly(checker, type!);
// return ESLintUtils.isTypeReadonly(checker, type!);
return isTypeReadonly(checker, type!);
}

/**
Expand Down
195 changes: 195 additions & 0 deletions src/util/upstream/eslint-typescript/isTypeReadonly.ts
@@ -0,0 +1,195 @@
// @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<ts.Type>
): Readonlyness {
function checkTypeArguments(arrayType: ts.TypeReference): Readonlyness {
const typeArguments = checker.getTypeArguments(arrayType);
// 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<ts.Type>
): 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<ts.Type>
): 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 };
28 changes: 28 additions & 0 deletions 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<T>(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 };
38 changes: 38 additions & 0 deletions 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()
);
}

0 comments on commit 7587887

Please sign in to comment.