Skip to content

Commit

Permalink
feat: add versionsToTest option to support multiple TypeScript versio…
Browse files Browse the repository at this point in the history
…ns (#635)

## PR Checklist

- [x] Addresses an existing open issue: fixes #107
- [x] That issue was marked as [`status: accepting
prs`](https://github.com/JoshuaKGoldberg/eslint-plugin-expect-type/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22)
- [x] Steps in
[CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/eslint-plugin-expect-type/blob/main/.github/CONTRIBUTING.md)
were taken

## Overview

Switches all `ts from "typescript"` imports to be `import type`. A
`tsModule` is now passed around to all functions that need runtime
`ts.*` values.

Differs from the DefinitelyTyped-tools implementation by using
[`get-tsconfig`](https://www.npmjs.com/package/get-tsconfig) instead of
a manual `findUp` function.
  • Loading branch information
JoshuaKGoldberg authored Nov 29, 2024
1 parent 96810ea commit 153f83e
Show file tree
Hide file tree
Showing 23 changed files with 597 additions and 123 deletions.
27 changes: 27 additions & 0 deletions docs/rules/expect.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,30 @@ These snapshots will automatically update whenever `eslint --fix` is run.

Whether to disable `$ExpectTypeSnapshot` auto-fixing.
Defaults to `false`.

### `versionsToTest`

Array of TypeScript versions to test.
Defaults to only the installed version.

If provided, this must be an array of objects containing:

- `name: string`: Alias to refer to the TypeScript version
- `path: string`: Import path to `require()` TypeScript from

For example:

```json
[
{
"name": "current",
"path": "typescript"
},
{
"name": "5.0",
"path": "typescript50"
}
]
```

`versionsToTest` can be useful if you want to have a single lint job that checks multiple TypeScript versions (instead of a matrix of jobs).
9 changes: 8 additions & 1 deletion eslint.config.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ const tseslint = require("typescript-eslint");

module.exports = tseslint.config(
{
ignores: ["coverage", "lib", "node_modules", "pnpm-lock.yaml", "**/*.snap"],
ignores: [
"coverage",
"lib",
"node_modules",
"pnpm-lock.yaml",
"**/*.snap",
"src/rules/sandbox/**",
],
},
{
linterOptions: {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,8 @@
},
"dependencies": {
"@typescript-eslint/utils": "^6.10.0 || ^7.0.1 || ^8",
"fs-extra": "^11.1.1"
"fs-extra": "^11.1.1",
"get-tsconfig": "^4.8.1"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "4.4.1",
Expand Down Expand Up @@ -120,6 +121,7 @@
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"typescript-eslint": "^8.16.0",
"typescript54": "npm:typescript@5.4.5",
"vitest": "^2.1.6"
},
"peerDependencies": {
Expand Down
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/assertions/parseAssertions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ts from "typescript";
import type ts from "typescript";

import { getTypeSnapshot } from "../utils/snapshot.js";
import { parseTwoslashAssertion } from "./parseTwoslashAssertion.js";
Expand Down
31 changes: 15 additions & 16 deletions src/failures/getExpectTypeFailures.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,29 @@
import ts from "typescript";
import type ts from "typescript";

import { Assertions } from "../assertions/types.js";
import { getNodeAtPosition } from "../utils/locations.js";
import { lineOfPosition } from "../utils/locations.js";
import { getNodeAtPosition, lineOfPosition } from "../utils/locations.js";
import {
getLanguageServiceHost,
getNodeForExpectType,
matchModuloWhitespace,
} from "../utils/typescript.js";
import { ResolvedVersionToTest } from "../utils/versions.js";
import { normalizedTypeToString } from "./normalizedTypeToString.js";
import { ExpectTypeFailures, UnmetExpectation } from "./types.js";

export function getExpectTypeFailures(
sourceFile: ts.SourceFile,
assertions: Pick<Assertions, "twoSlashAssertions" | "typeAssertions">,
program: ts.Program,
{ program, sourceFile, tsModule, version }: ResolvedVersionToTest,
): ExpectTypeFailures {
const checker = program.getTypeChecker();
const languageService = ts.createLanguageService(
getLanguageServiceHost(program),
const languageService = tsModule.createLanguageService(
getLanguageServiceHost(program, tsModule),
);
const { twoSlashAssertions, typeAssertions } = assertions;
const unmetExpectations: UnmetExpectation[] = [];

// Match assertions to the first node that appears on the line they apply to.
ts.forEachChild(sourceFile, function iterate(node) {
tsModule.forEachChild(sourceFile, function iterate(node) {
const line = lineOfPosition(node.getStart(sourceFile), sourceFile);
const assertion = typeAssertions.get(line);
if (assertion !== undefined) {
Expand All @@ -33,16 +32,16 @@ export function getExpectTypeFailures(
let nodeToCheck = node;

// https://github.com/Microsoft/TypeScript/issues/14077
if (node.kind === ts.SyntaxKind.ExpressionStatement) {
if (node.kind === tsModule.SyntaxKind.ExpressionStatement) {
node = (node as ts.ExpressionStatement).expression;
}

nodeToCheck = getNodeForExpectType(node);
nodeToCheck = getNodeForExpectType(node, tsModule);
const type = checker.getTypeAtLocation(nodeToCheck);
const actual = checker.typeToString(
type,
/*enclosingDeclaration*/ undefined,
ts.TypeFormatFlags.NoTruncation,
tsModule.TypeFormatFlags.NoTruncation,
);

typeAssertions.delete(line);
Expand All @@ -53,11 +52,11 @@ export function getExpectTypeFailures(
.filter(Boolean);

if (!candidates || !candidateTypeMatches(actual, candidates)) {
unmetExpectations.push({ actual, assertion, node });
unmetExpectations.push({ actual, assertion, node, version });
}
}

ts.forEachChild(node, iterate);
tsModule.forEachChild(node, iterate);
});

function candidateTypeMatches(actual: string, candidates: string[]) {
Expand All @@ -68,8 +67,8 @@ export function getExpectTypeFailures(
return true;
}

actualNormalized ??= normalizedTypeToString(actual);
const candidateNormalized = normalizedTypeToString(candidate);
actualNormalized ??= normalizedTypeToString(actual, tsModule);
const candidateNormalized = normalizedTypeToString(candidate, tsModule);

if (actualNormalized === candidateNormalized) {
return true;
Expand All @@ -89,7 +88,7 @@ export function getExpectTypeFailures(
continue;
}

const node = getNodeAtPosition(sourceFile, position);
const node = getNodeAtPosition(sourceFile, position, tsModule);
if (!node) {
twoSlashFailureLines.push(
sourceFile.getLineAndCharacterOfPosition(position).line,
Expand Down
42 changes: 22 additions & 20 deletions src/failures/normalizedTypeToString.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,51 @@
// 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";
import type ts from "typescript";

export function normalizedTypeToString(type: string) {
const sourceFile = ts.createSourceFile(
import { TSModule } from "../utils/programs.js";

export function normalizedTypeToString(type: string, tsModule: TSModule) {
const sourceFile = tsModule.createSourceFile(
"foo.ts",
`declare var x: ${type};`,
ts.ScriptTarget.Latest,
tsModule.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();
const printer = tsModule.createPrinter();
function print(node: ts.Node) {
return printer.printNode(ts.EmitHint.Unspecified, node, sourceFile);
return printer.printNode(tsModule.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;
const context = tsModule.nullTransformationContext;

function visit(node: ts.Node) {
node = ts.visitEachChild(node, visit, context);
node = tsModule.visitEachChild(node, visit, context);

if (ts.isUnionTypeNode(node)) {
if (tsModule.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(
return tsModule.factory.updateUnionTypeNode(
node,
ts.factory.createNodeArray(types),
tsModule.factory.createNodeArray(types),
);
}

if (
ts.isTypeOperatorNode(node) &&
node.operator === ts.SyntaxKind.ReadonlyKeyword &&
ts.isArrayTypeNode(node.type)
tsModule.isTypeOperatorNode(node) &&
node.operator === tsModule.SyntaxKind.ReadonlyKeyword &&
tsModule.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", [
return tsModule.factory.createTypeReferenceNode("ReadonlyArray", [
skipTypeParentheses(node.type.elementType),
]);
}
Expand All @@ -53,11 +55,11 @@ export function normalizedTypeToString(type: string) {

const visited = visit(typeNode);
return print(visited);
}

function skipTypeParentheses(node: ts.TypeNode): ts.TypeNode {
while (ts.isParenthesizedTypeNode(node)) {
node = node.type;
function skipTypeParentheses(node: ts.TypeNode): ts.TypeNode {
while (tsModule.isParenthesizedTypeNode(node)) {
node = node.type;
}
return node;
}
return node;
}
3 changes: 2 additions & 1 deletion src/failures/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import ts from "typescript";
import type ts from "typescript";

import { Assertion } from "../assertions/types.js";

Expand All @@ -18,4 +18,5 @@ export interface UnmetExpectation {
actual: string;
assertion: Assertion;
node: ts.Node;
version?: string;
}
38 changes: 38 additions & 0 deletions src/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { TSESLint } from "@typescript-eslint/utils";

export const messages = {
CouldNotRequireTypeScript:
"Could not require TypeScript specified with name '{{ name }}' at path '{{ path }}': {{ error }}.",
DuplicateTSVersionName:
"Multiple TypeScript versions specified with name '{{ name }}'.",
ExpectedErrorNotFound: "Expected an error on this line, but found none.",
ExpectedErrorNotFoundForVersion:
"Expected an error for TypeScript version {{ version }} on this line, but found none.",
Multiple$ExpectTypeAssertions:
"This line has 2 or more $ExpectType assertions.",
OrphanAssertion: "Can not match a node to this assertion.",
SyntaxError: "Syntax Error: {{ message }}",
TypesDoNotMatch: "Expected type to be: {{ expected }}, got: {{ actual }}",
TypesDoNotMatchForVersion:
"Expected type for TypeScript version {{ version }} to be: {{ expected }}, got: {{ actual }}",
TypeSnapshotDoNotMatch:
"Expected type from snapshot to be: {{ expected }}, got: {{ actual }}",
TypeSnapshotDoNotMatchForVersion:
"Expected type for TypeScript version {{ version }} from snapshot to be: {{ expected }}, got: {{ actual }}",
TypeSnapshotNotFound:
"Type Snapshot not found. Please consider running ESLint in FIX mode: eslint --fix",
};

export type ExpectRuleContext = TSESLint.RuleContext<MessageIds, [Options]>;

export type MessageIds = keyof typeof messages;

export interface Options {
readonly disableExpectTypeSnapshotFix: boolean;
readonly versionsToTest?: VersionToTestOption[];
}

export interface VersionToTestOption {
name: string;
path: string;
}
4 changes: 2 additions & 2 deletions src/rules/expect-type.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ ruleTester.run("expect", expect, {
code: dedent`
//$ExpectType number
const t = 'a';
`,
`,
errors: [
{
column: 1,
Expand All @@ -70,7 +70,7 @@ ruleTester.run("expect", expect, {
code: dedent`
// $ExpectType { a: number; b: "on"; }
const t = { b: 'on' as const, a: 17 };
`,
`,
errors: [
{
column: 1,
Expand Down
Loading

0 comments on commit 153f83e

Please sign in to comment.