diff --git a/src/failures/getExpectTypeFailures.ts b/src/failures/getExpectTypeFailures.ts index 66ffdcf3..ea53608f 100644 --- a/src/failures/getExpectTypeFailures.ts +++ b/src/failures/getExpectTypeFailures.ts @@ -7,8 +7,8 @@ import { getLanguageServiceHost, getNodeForExpectType, matchModuloWhitespace, - matchReadonlyArray, } from "../utils/typescript.js"; +import { normalizedTypeToString } from "./normalizedTypeToString.js"; import { ExpectTypeFailures, UnmetExpectation } from "./types.js"; export function getExpectTypeFailures( @@ -45,19 +45,40 @@ export function getExpectTypeFailures( ts.TypeFormatFlags.NoTruncation, ); - if ( - !expected || - (actual !== expected && !matchReadonlyArray(actual, expected)) - ) { + typeAssertions.delete(line); + + const candidates = expected + ?.split(/\s*\|\|\s*/) + .map((s) => s.trim()) + .filter(Boolean); + + if (!candidates || !candidateTypeMatches(actual, candidates)) { unmetExpectations.push({ actual, assertion, node }); } - - typeAssertions.delete(line); } ts.forEachChild(node, iterate); }); + function candidateTypeMatches(actual: string, candidates: string[]) { + let actualNormalized: string | undefined; + + for (const candidate of candidates) { + if (candidate === actual) { + return true; + } + + actualNormalized ??= normalizedTypeToString(actual); + const candidateNormalized = normalizedTypeToString(candidate); + + if (actualNormalized === candidateNormalized) { + return true; + } + } + + return false; + } + const twoSlashFailureLines: number[] = []; if (twoSlashAssertions.length) { for (const assertion of twoSlashAssertions) { diff --git a/src/failures/normalizedTypeToString.ts b/src/failures/normalizedTypeToString.ts new file mode 100644 index 00000000..d2acb018 --- /dev/null +++ b/src/failures/normalizedTypeToString.ts @@ -0,0 +1,63 @@ +// Code based on DefinitelyTyped-tools implementation: +// https://github.com/microsoft/DefinitelyTyped-tools/blob/42484ff245f6f18018de729f12c9a28436daa08a/packages/eslint-plugin/src/rules/expect.ts#L466 + +import ts from "typescript"; + +export function normalizedTypeToString(type: string) { + const sourceFile = ts.createSourceFile( + "foo.ts", + `declare var x: ${type};`, + ts.ScriptTarget.Latest, + ); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const typeNode = (sourceFile.statements[0] as ts.VariableStatement) + .declarationList.declarations[0].type!; + + const printer = ts.createPrinter(); + function print(node: ts.Node) { + return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile); + } + + // TODO: pass undefined instead once all supported TS versions support it per: + // https://github.com/microsoft/TypeScript/pull/52941 + const context = ts.nullTransformationContext; + + function visit(node: ts.Node) { + node = ts.visitEachChild(node, visit, context); + + if (ts.isUnionTypeNode(node)) { + const types = node.types + .map((t) => [t, print(t)] as const) + .sort((a, b) => (a[1] < b[1] ? -1 : 1)) + .map((t) => t[0]); + return ts.factory.updateUnionTypeNode( + node, + ts.factory.createNodeArray(types), + ); + } + + if ( + ts.isTypeOperatorNode(node) && + node.operator === ts.SyntaxKind.ReadonlyKeyword && + ts.isArrayTypeNode(node.type) + ) { + // It's possible that this would conflict with a library which defines their own type with this name, + // but that's unlikely (and was not previously handled in a prior revision of type string normalization). + return ts.factory.createTypeReferenceNode("ReadonlyArray", [ + skipTypeParentheses(node.type.elementType), + ]); + } + + return node; + } + + const visited = visit(typeNode); + return print(visited); +} + +function skipTypeParentheses(node: ts.TypeNode): ts.TypeNode { + while (ts.isParenthesizedTypeNode(node)) { + node = node.type; + } + return node; +} diff --git a/src/rules/expect-type-snapshot.test.ts b/src/rules/expect-type-snapshot.test.ts index ab91ac10..f1e1808d 100644 --- a/src/rules/expect-type-snapshot.test.ts +++ b/src/rules/expect-type-snapshot.test.ts @@ -50,7 +50,6 @@ ruleTester.run("expect", expect, { filename, options: [{ disableExpectTypeSnapshotFix: true }], }, - // Snapshot has different type. { code: dedent` // $ExpectTypeSnapshot TypeSnapshotDoNotMatch @@ -64,17 +63,52 @@ ruleTester.run("expect", expect, { }, ], filename, + name: "Snapshot has different type", + options: [{ disableExpectTypeSnapshotFix: true }], + }, + { + code: dedent` + // $ExpectTypeSnapshot TypeSnapshotDoNotMatchOr + const configB = { d: 0 }; + `, + errors: [ + { + column: 1, + line: 2, + messageId: "TypeSnapshotDoNotMatch", + }, + ], + filename, + name: "Snapshot has different type than ||", options: [{ disableExpectTypeSnapshotFix: true }], }, ], valid: [ - // Snapshot matches. { code: dedent` // $ExpectTypeSnapshot SnapshotMatches const c = { a: 15, b: "b" as const, c: "c" }; `, filename, + name: "Snapshot matches", + options: [{ disableExpectTypeSnapshotFix: true }], + }, + { + code: dedent` + // $ExpectTypeSnapshot SnapshotMatchesOr + const c = { a: 15, b: "b" as const, c: "c" }; + `, + filename, + name: "Snapshot matches first || constituent", + options: [{ disableExpectTypeSnapshotFix: true }], + }, + { + code: dedent` + // $ExpectTypeSnapshot SnapshotMatchesOr + const c = { d: true }; + `, + filename, + name: "Snapshot matches second || constituent", options: [{ disableExpectTypeSnapshotFix: true }], }, ], diff --git a/src/rules/expect-type.test.ts b/src/rules/expect-type.test.ts index 277c9dcf..60ae92aa 100644 --- a/src/rules/expect-type.test.ts +++ b/src/rules/expect-type.test.ts @@ -9,7 +9,7 @@ ruleTester.run("expect", expect, { code: dedent` // $ExpectType number const t = 'a'; - `, +np `, errors: [ { column: 1, @@ -38,6 +38,20 @@ ruleTester.run("expect", expect, { code: dedent` // $ExpectType { a: number; b: "on"; } const t = { b: 'on' as const, a: 17 }; + `, + errors: [ + { + column: 1, + line: 2, + messageId: "TypesDoNotMatch", + }, + ], + filename, + }, + { + code: dedent` + // $ExpectType number || string; + const t = false; `, errors: [ { @@ -50,23 +64,86 @@ ruleTester.run("expect", expect, { }, ], valid: [ - // Primitive type { code: dedent` // $ExpectType number const t = 6 as number; `, filename, + name: "Primitive type", + }, + { + code: dedent` + // $ExpectType number + const t = 6 as (number); + `, + filename, + name: "Parenthesized primitive type", + }, + { + code: dedent` + // $ExpectType number | string + const t = 6 as number | string; + `, + filename, + name: "Union of primitive types, in order", + }, + { + code: dedent` + // $ExpectType string | number + const t = 6 as number | string; + `, + filename, + name: "Union of primitive types, out of order", + }, + { + code: dedent` + // $ExpectType string[] + const t = [] as string[] + `, + filename, + name: "readonly array", + }, + { + code: dedent` + // $ExpectType readonly string[] + const t = [] as readonly string[] + `, + filename, + name: "readonly array", + }, + { + code: dedent` + // $ExpectType readonly string[] + const t = [] as ReadonlyArray + `, + filename, + name: "readonly array and ReadonlyArray", + }, + { + code: dedent` + // $ExpectType ReadonlyArray + const t = [] as readonly string[] + `, + filename, + name: "ReadonlyArray and readonly array", + }, + { + code: dedent` + // $ExpectType ReadonlyArray + const t = [] as ReadonlyArray + `, + filename, + name: "ReadonlyArray", }, - // Complex type { code: dedent` // $ExpectType { a: number; b: "on"; } const t = { a: 17, b: 'on' as const }; `, filename, + name: "Complex type", }, - // Ignored TypeScript compiler complaints { code: dedent` function hasUnusedParam(unusedParam: number, implicitAnyParam) { @@ -75,6 +152,28 @@ ruleTester.run("expect", expect, { } `, filename, + name: "Ignored TypeScript compiler complaints", + }, + { + code: dedent` + // $ExpectType number || string + const t = 6 as number; + `, + filename, + }, + { + code: dedent` + // $ExpectType number || string | number + const t = 6 as number; + `, + filename, + }, + { + code: dedent` + // $ExpectType string | number || number + const t = 6 as number; + `, + filename, }, ], }); diff --git a/src/rules/sandbox/__type-snapshots__/file.ts.snap.json b/src/rules/sandbox/__type-snapshots__/file.ts.snap.json index 8b878642..3b3af648 100644 --- a/src/rules/sandbox/__type-snapshots__/file.ts.snap.json +++ b/src/rules/sandbox/__type-snapshots__/file.ts.snap.json @@ -1,4 +1,6 @@ { "SnapshotMatches": "{ a: number; b: \"b\"; c: string; }", - "TypeSnapshotDoNotMatch": "{ a: number; b: \"b\"; c: boolean; }" + "SnapshotMatchesOr": "{ a: number; b: \"b\"; c: string; } || { d: boolean }", + "TypeSnapshotDoNotMatch": "{ a: number; b: \"b\"; c: boolean; }", + "TypeSnapshotDoNotMatchOr": "{ a: number; b: \"b\"; c: boolean; } { d: boolean }" } diff --git a/src/types/typescript.d.ts b/src/types/typescript.d.ts new file mode 100644 index 00000000..ee57bf82 --- /dev/null +++ b/src/types/typescript.d.ts @@ -0,0 +1,5 @@ +import { TransformationContext } from "typescript"; + +declare module "typescript" { + export const nullTransformationContext: TransformationContext; +} diff --git a/src/utils/typescript.ts b/src/utils/typescript.ts index 1cc23f25..2ee61baa 100644 --- a/src/utils/typescript.ts +++ b/src/utils/typescript.ts @@ -49,59 +49,3 @@ export function matchModuloWhitespace( const normExpected = expected.replace(/[\n\r ]+/g, " ").trim(); return normActual === normExpected; } - -export function matchReadonlyArray(actual: string, expected: string) { - if (!(/\breadonly\b/.test(actual) && /\bReadonlyArray\b/.test(expected))) { - return false; - } - - const readonlyArrayRegExp = /\bReadonlyArray>>> - // A[]> - - let expectedPos = 0; - let actualPos = 0; - let depth = 0; - while (expectedPos < expected.length && actualPos < actual.length) { - const expectedChar = expected.charAt(expectedPos); - const actualChar = actual.charAt(actualPos); - if (expectedChar === actualChar) { - expectedPos++; - actualPos++; - continue; - } - - // check for end of readonly array - if ( - depth > 0 && - expectedChar === ">" && - actualChar === "[" && - actualPos < actual.length - 1 && - actual.charAt(actualPos + 1) === "]" - ) { - depth--; - expectedPos++; - actualPos += 2; - continue; - } - - // check for start of readonly array - readonlyArrayRegExp.lastIndex = expectedPos; - readonlyModifierRegExp.lastIndex = actualPos; - if ( - readonlyArrayRegExp.test(expected) && - readonlyModifierRegExp.test(actual) - ) { - depth++; - expectedPos += 14; // "ReadonlyArray<".length; - actualPos += 9; // "readonly ".length; - continue; - } - - return false; - } - - return true; -}