diff --git a/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts b/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts index fdf021f31a8..65a11484bf3 100644 --- a/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts +++ b/packages/eslint-plugin/src/rules/prefer-readonly-parameter-types.ts @@ -2,8 +2,6 @@ import { TSESTree, AST_NODE_TYPES, } from '@typescript-eslint/experimental-utils'; -import { isObjectType, isUnionType, unionTypeParts } from 'tsutils'; -import * as ts from 'typescript'; import * as util from '../util'; export default util.createRule({ @@ -31,134 +29,6 @@ export default util.createRule({ const { esTreeNodeToTSNodeMap, program } = util.getParserServices(context); const checker = program.getTypeChecker(); - /** - * Returns: - * - null if the type is not an array or tuple, - * - true if the type is a readonly array or readonly tuple, - * - false if the type is a mutable array or mutable tuple. - */ - function isTypeReadonlyArrayOrTuple(type: ts.Type): boolean | null { - function checkTypeArguments(arrayType: ts.TypeReference): boolean { - const typeArguments = checker.getTypeArguments(arrayType); - if (typeArguments.length === 0) { - // this shouldn't happen in reality as: - // - tuples require at least 1 type argument - // - ReadonlyArray requires at least 1 type argument - return true; - } - - // validate the element types are also readonly - if (typeArguments.some(typeArg => !isTypeReadonly(typeArg))) { - return false; - } - return true; - } - - if (checker.isArrayType(type)) { - const symbol = util.nullThrows( - type.getSymbol(), - util.NullThrowsReasons.MissingToken('symbol', 'array type'), - ); - const escapedName = symbol.getEscapedName(); - if (escapedName === 'Array' && escapedName !== 'ReadonlyArray') { - return false; - } - - return checkTypeArguments(type); - } - - if (checker.isTupleType(type)) { - if (!type.target.readonly) { - return false; - } - - return checkTypeArguments(type); - } - - return null; - } - - /** - * Returns: - * - null if the type is not an object, - * - true if the type is an object with only readonly props, - * - false if the type is an object with at least one mutable prop. - */ - function isTypeReadonlyObject(type: ts.Type): boolean | null { - function checkIndex(kind: ts.IndexKind): boolean | null { - const indexInfo = checker.getIndexInfoOfType(type, kind); - if (indexInfo) { - return indexInfo.isReadonly ? true : false; - } - - return null; - } - - const isStringIndexReadonly = checkIndex(ts.IndexKind.String); - if (isStringIndexReadonly !== null) { - return isStringIndexReadonly; - } - - const isNumberIndexReadonly = checkIndex(ts.IndexKind.Number); - if (isNumberIndexReadonly !== null) { - return isNumberIndexReadonly; - } - - const properties = type.getProperties(); - if (properties.length) { - // NOTES: - // - port isReadonlySymbol - https://github.com/Microsoft/TypeScript/blob/4212484ae18163df867f53dab19a8cc0c6000793/src/compiler/checker.ts#L26285 - // eslint-disable-next-line @typescript-eslint/ban-ts-ignore - // @ts-ignore - declare function isReadonlySymbol(symbol: ts.Symbol): boolean; - - for (const property of properties) { - if (!isReadonlySymbol(property)) { - return false; - } - } - - // all properties were readonly - return true; - } - - return false; - } - - /** - * Checks if the given type is readonly - */ - function isTypeReadonly(type: ts.Type): boolean { - if (isUnionType(type)) { - return unionTypeParts(type).every(t => isTypeReadonly(t)); - } - - // all non-object types are readonly - if (!isObjectType(type)) { - return true; - } - - // pure function types are readonly - if ( - type.getCallSignatures().length > 0 && - type.getProperties().length === 0 - ) { - return true; - } - - const isReadonlyArray = isTypeReadonlyArrayOrTuple(type); - if (isReadonlyArray !== null) { - return isReadonlyArray; - } - - const isReadonlyObject = isTypeReadonlyObject(type); - if (isReadonlyObject !== null) { - return isReadonlyObject; - } - - throw new Error('Unhandled type'); - } - return { 'ArrowFunctionExpression, FunctionDeclaration, FunctionExpression, TSEmptyBodyFunctionExpression'( node: @@ -174,7 +44,7 @@ export default util.createRule({ : param; const tsNode = esTreeNodeToTSNodeMap.get(actualParam); const type = checker.getTypeAtLocation(tsNode); - const isReadOnly = isTypeReadonly(type); + const isReadOnly = util.isTypeReadonly(checker, type); if (!isReadOnly) { return context.report({ diff --git a/packages/eslint-plugin/src/util/index.ts b/packages/eslint-plugin/src/util/index.ts index 7fde0f41e71..381012acfec 100644 --- a/packages/eslint-plugin/src/util/index.ts +++ b/packages/eslint-plugin/src/util/index.ts @@ -2,6 +2,7 @@ import { ESLintUtils } from '@typescript-eslint/experimental-utils'; export * from './astUtils'; export * from './createRule'; +export * from './isTypeReadonly'; export * from './misc'; export * from './nullThrows'; export * from './types'; diff --git a/packages/eslint-plugin/src/util/isTypeReadonly/index.ts b/packages/eslint-plugin/src/util/isTypeReadonly/index.ts new file mode 100644 index 00000000000..06eae5475d5 --- /dev/null +++ b/packages/eslint-plugin/src/util/isTypeReadonly/index.ts @@ -0,0 +1,147 @@ +import { + isObjectType, + isUnionType, + isUnionOrIntersectionType, + unionTypeParts, +} from 'tsutils'; +import * as ts from 'typescript'; +import { nullThrows, NullThrowsReasons } from '..'; +import { isReadonlySymbol } from './isReadonlySymbol'; + +/** + * Returns: + * - null if the type is not an array or tuple, + * - true if the type is a readonly array or readonly tuple, + * - false if the type is a mutable array or mutable tuple. + */ +function isTypeReadonlyArrayOrTuple( + checker: ts.TypeChecker, + type: ts.Type, +): boolean | null { + function checkTypeArguments(arrayType: ts.TypeReference): boolean { + const typeArguments = checker.getTypeArguments(arrayType); + if (typeArguments.length === 0) { + // this shouldn't happen in reality as: + // - tuples require at least 1 type argument + // - ReadonlyArray requires at least 1 type argument + return true; + } + + // validate the element types are also readonly + if (typeArguments.some(typeArg => !isTypeReadonly(checker, typeArg))) { + return false; + } + return true; + } + + if (checker.isArrayType(type)) { + const symbol = nullThrows( + type.getSymbol(), + NullThrowsReasons.MissingToken('symbol', 'array type'), + ); + const escapedName = symbol.getEscapedName(); + if (escapedName === 'Array' && escapedName !== 'ReadonlyArray') { + return false; + } + + return checkTypeArguments(type); + } + + if (checker.isTupleType(type)) { + if (!type.target.readonly) { + return false; + } + + return checkTypeArguments(type); + } + + return null; +} + +/** + * Returns: + * - null if the type is not an object, + * - true if the type is an object with only readonly props, + * - false if the type is an object with at least one mutable prop. + */ +function isTypeReadonlyObject( + checker: ts.TypeChecker, + type: ts.Type, +): boolean | null { + function checkIndexSignature(kind: ts.IndexKind): boolean | null { + const indexInfo = checker.getIndexInfoOfType(type, kind); + if (indexInfo) { + return indexInfo.isReadonly ? true : false; + } + + return null; + } + + const properties = type.getProperties(); + if (properties.length) { + for (const property of properties) { + // eslint-disable-next-line @typescript-eslint/ban-ts-ignore + // @ts-ignore + const x = checker.isReadonlySymbol(property); + const y = isReadonlySymbol(property); + if (x !== y) { + throw new Error('FUCK'); + } + if (!isReadonlySymbol(property)) { + return false; + } + } + + // all properties were readonly + } + + const isStringIndexSigReadonly = checkIndexSignature(ts.IndexKind.String); + if (isStringIndexSigReadonly === false) { + return isStringIndexSigReadonly; + } + + const isNumberIndexSigReadonly = checkIndexSignature(ts.IndexKind.Number); + if (isNumberIndexSigReadonly === false) { + return isNumberIndexSigReadonly; + } + + return true; +} + +/** + * Checks if the given type is readonly + */ +function isTypeReadonly(checker: ts.TypeChecker, type: ts.Type): boolean { + if (isUnionType(type)) { + // all types in the union must be readonly + return unionTypeParts(type).every(t => isTypeReadonly(checker, t)); + } + + // all non-object, non-intersection types are readonly. + // this should only be primitive types + if (!isObjectType(type) && !isUnionOrIntersectionType(type)) { + return true; + } + + // pure function types are readonly + if ( + type.getCallSignatures().length > 0 && + type.getProperties().length === 0 + ) { + return true; + } + + const isReadonlyArray = isTypeReadonlyArrayOrTuple(checker, type); + if (isReadonlyArray !== null) { + return isReadonlyArray; + } + + const isReadonlyObject = isTypeReadonlyObject(checker, type); + if (isReadonlyObject !== null) { + return isReadonlyObject; + } + + throw new Error('Unhandled type'); +} + +export { isTypeReadonly }; diff --git a/packages/eslint-plugin/src/util/isTypeReadonly/isReadonlySymbol.ts b/packages/eslint-plugin/src/util/isTypeReadonly/isReadonlySymbol.ts new file mode 100644 index 00000000000..69b55945938 --- /dev/null +++ b/packages/eslint-plugin/src/util/isTypeReadonly/isReadonlySymbol.ts @@ -0,0 +1,82 @@ +// - this code is ported from typescript's type checker +// Starting at https://github.com/Microsoft/TypeScript/blob/4212484ae18163df867f53dab19a8cc0c6000793/src/compiler/checker.ts#L26285 + +import * as ts from 'typescript'; + +// #region internal types used for isReadonlySymbol + +// we can't use module augmentation because typescript uses export = ts +/* eslint-disable @typescript-eslint/ban-ts-ignore */ + +// CheckFlags is actually const enum +// https://github.com/Microsoft/TypeScript/blob/236012e47b26fee210caa9cbd2e072ef9e99f9ae/src/compiler/types.ts#L4038 +const enum CheckFlags { + Readonly = 1 << 3, +} +type GetCheckFlags = (symbol: ts.Symbol) => CheckFlags; +// @ts-ignore +const getCheckFlags: GetCheckFlags = ts.getCheckFlags; + +type GetDeclarationModifierFlagsFromSymbol = (s: ts.Symbol) => ts.ModifierFlags; +const getDeclarationModifierFlagsFromSymbol: GetDeclarationModifierFlagsFromSymbol = + // @ts-ignore + ts.getDeclarationModifierFlagsFromSymbol; + +/* eslint-enable @typescript-eslint/ban-ts-ignore */ + +// function getDeclarationNodeFlagsFromSymbol(s: ts.Symbol): ts.NodeFlags { +// return s.valueDeclaration ? ts.getCombinedNodeFlags(s.valueDeclaration) : 0; +// } + +// #endregion + +function isReadonlySymbol(symbol: ts.Symbol): boolean { + // The following symbols are considered read-only: + // Properties with a 'readonly' modifier + // Variables declared with 'const' + // Get accessors without matching set accessors + // Enum members + // Unions and intersections of the above (unions and intersections eagerly set isReadonly on creation) + + // transient readonly property + if (getCheckFlags(symbol) & CheckFlags.Readonly) { + console.log('check flags is truthy'); + return true; + } + + // Properties with a 'readonly' modifier + if ( + symbol.flags & ts.SymbolFlags.Property && + getDeclarationModifierFlagsFromSymbol(symbol) & ts.ModifierFlags.Readonly + ) { + return true; + } + + // Variables declared with 'const' + // if ( + // symbol.flags & ts.SymbolFlags.Variable && + // getDeclarationNodeFlagsFromSymbol(symbol) & ts.NodeFlags.Const + // ) { + // return true; + // } + + // Get accessors without matching set accessors + if ( + symbol.flags & ts.SymbolFlags.Accessor && + !(symbol.flags & ts.SymbolFlags.SetAccessor) + ) { + return true; + } + + // Enum members + if (symbol.flags & ts.SymbolFlags.EnumMember) { + return true; + } + + return false; + + // TODO - maybe add this check? + // || symbol.declarations.some(isReadonlyAssignmentDeclaration) +} + +export { isReadonlySymbol }; diff --git a/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts b/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts index 1b48d070fde..8d85b687b6a 100644 --- a/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts +++ b/packages/eslint-plugin/tests/rules/prefer-readonly-parameter-types.test.ts @@ -65,10 +65,27 @@ const weirdIntersections = [ }; function foo(arg: Test) {} `, + ` + type Test = string & number; + function foo(arg: Test) {} + `, ]; ruleTester.run('prefer-readonly-parameter-types', rule, { valid: [ + ` + type Test = (readonly string[]) & { + property: boolean + }; + function foo(arg: Test) {} + `, + ` + interface Test extends ReadonlyArray { + property: boolean + } + function foo(arg: Test) {} + `, + 'function foo(arg: { readonly a: string }) {}', 'function foo() {}', // primitives @@ -92,8 +109,26 @@ ruleTester.run('prefer-readonly-parameter-types', rule, { // objects - // weird intersections + // weird other cases ...weirdIntersections.map(code => code), + ` + class Foo { + readonly bang = 1; + } + interface Foo { + readonly prop: string; + } + interface Foo { + readonly prop2: string; + } + function foo(arg: Foo) {} + `, + ` + class Foo { + method() {} + } + function foo(arg: Readonly) {} + `, ], invalid: [ // arrays diff --git a/packages/eslint-plugin/typings/typescript.d.ts b/packages/eslint-plugin/typings/typescript.d.ts index bf379a245b8..12b12c88953 100644 --- a/packages/eslint-plugin/typings/typescript.d.ts +++ b/packages/eslint-plugin/typings/typescript.d.ts @@ -1,4 +1,4 @@ -import { TypeChecker, Type } from 'typescript'; +import 'typescript'; declare module 'typescript' { interface TypeChecker {