Skip to content

Commit

Permalink
feat(typescript-estree): add type checker wrapper APIs to ParserServi…
Browse files Browse the repository at this point in the history
…cesWithTypeInformation (#6404)

* feat: remove partial type-information program

* fix docs test to handle naming-convention weirdness

* review comments

* fuck

* silence useless lint logs

* feat(typescript-estree): add type checker wrapper APIs to ParserServicesWithTypeInformation

* Add missing APIs to WebLinter

---------

Co-authored-by: Brad Zacher <brad.zacher@gmail.com>
  • Loading branch information
JoshuaKGoldberg and bradzacher committed Feb 5, 2023
1 parent 1c3f470 commit 62d5755
Show file tree
Hide file tree
Showing 55 changed files with 358 additions and 421 deletions.
35 changes: 21 additions & 14 deletions docs/Custom_Rules.mdx
Expand Up @@ -210,17 +210,23 @@ Read TypeScript's [Compiler APIs > Using the Type Checker](https://github.com/mi

The biggest addition typescript-eslint brings to ESLint rules is the ability to use TypeScript's type checker APIs.

`@typescript-eslint/utils` exports an `ESLintUtils` namespace containing a `getParserServices` function that takes in an ESLint context and returns a `parserServices` object.
`@typescript-eslint/utils` exports an `ESLintUtils` namespace containing a `getParserServices` function that takes in an ESLint context and returns a `services` object.

That `parserServices` object contains:
That `services` object contains:

- `program`: A full TypeScript `ts.Program` object
- `program`: A full TypeScript `ts.Program` object if type checking is enabled, or `null` otherwise
- `esTreeNodeToTSNodeMap`: Map of `@typescript-eslint/estree` `TSESTree.Node` nodes to their TypeScript `ts.Node` equivalents
- `tsNodeToESTreeNodeMap`: Map of TypeScript `ts.Node` nodes to their `@typescript-eslint/estree` `TSESTree.Node` equivalents

By mapping from ESTree nodes to TypeScript nodes and retrieving the TypeScript program from the parser services, rules are able to ask TypeScript for full type information on those nodes.
If type checking is enabled, that `services` object additionally contains:

This rule bans for-of looping over an enum by using the type-checker via typescript-eslint and TypeScript APIs:
- `getTypeAtLocation`: Wraps the type checker function, with a `TSESTree.Node` parameter instead of a `ts.Node`
- `getSymbolAtLocation`: Wraps the type checker function, with a `TSESTree.Node` parameter instead of a `ts.Node`

Those additional objects internally map from ESTree nodes to their TypeScript equivalents, then call to the TypeScript program.
By using the TypeScript program from the parser services, rules are able to ask TypeScript for full type information on those nodes.

This rule bans for-of looping over an enum by using the TypeScript type checker via typescript-eslint's services:

```ts
import { ESLintUtils } from '@typescript-eslint/utils';
Expand All @@ -231,17 +237,13 @@ export const rule = createRule({
create(context) {
return {
ForOfStatement(node) {
// 1. Grab the TypeScript program from parser services
const parserServices = ESLintUtils.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
// 1. Grab the parser services for the rule
const services = ESLintUtils.getParserServices(context);

// 2. Find the backing TS node for the ES node, then that TS type
const originalNode = parserServices.esTreeNodeToTSNodeMap.get(
node.right,
);
const nodeType = checker.getTypeAtLocation(originalNode);
// 2. Find the TS type for the ES node
const type = services.getTypeAtLocation(node);

// 3. Check the TS node type using the TypeScript APIs
// 3. Check the TS type using the TypeScript APIs
if (tsutils.isTypeFlagSet(nodeType, ts.TypeFlags.EnumLike)) {
context.report({
messageId: 'loopOverEnum',
Expand All @@ -267,6 +269,11 @@ export const rule = createRule({
});
```

:::note
Rules can retrieve their full backing TypeScript type checker with `services.program.getTypeChecker()`.
This can be necessary for TypeScript APIs not wrapped by the parser services.
:::

## Testing

`@typescript-eslint/utils` exports a `RuleTester` with a similar API to the built-in [ESLint `RuleTester`](https://eslint.org/docs/developer-guide/nodejs-api#ruletester).
Expand Down
Expand Up @@ -51,9 +51,7 @@ export default createRule({
},
defaultOptions: [],
create(context) {
const { program, esTreeNodeToTSNodeMap } =
ESLintUtils.getParserServices(context);
const checker = program.getTypeChecker();
const services = ESLintUtils.getParserServices(context);

return {
'MemberExpression[computed = false]'(
Expand All @@ -65,15 +63,13 @@ export default createRule({
}

// make sure the type name matches
const tsObjectNode = esTreeNodeToTSNodeMap.get(node.object);
const objectType = checker.getTypeAtLocation(tsObjectNode);
const objectType = services.getTypeAtLocation(node.object);
const objectSymbol = objectType.getSymbol();
if (objectSymbol?.getName() !== banned.type) {
continue;
}

const tsNode = esTreeNodeToTSNodeMap.get(node.property);
const symbol = checker.getSymbolAtLocation(tsNode);
const symbol = services.getSymbolAtLocation(node.property);
const decls = symbol?.getDeclarations();
const isFromTs = decls?.some(decl =>
decl.getSourceFile().fileName.includes('/node_modules/typescript/'),
Expand Down
Expand Up @@ -148,9 +148,8 @@ export default createRule<Options, MessageIds>({
],
create(context, [{ formatWithPrettier }]) {
const sourceCode = context.getSourceCode();
const { program, esTreeNodeToTSNodeMap } =
ESLintUtils.getParserServices(context);
const checker = program.getTypeChecker();
const services = ESLintUtils.getParserServices(context);
const checker = services.program.getTypeChecker();

const checkedObjects = new Set<TSESTree.ObjectExpression>();

Expand Down Expand Up @@ -522,7 +521,7 @@ export default createRule<Options, MessageIds>({

const type = getContextualType(
checker,
esTreeNodeToTSNodeMap.get(node),
services.esTreeNodeToTSNodeMap.get(node),
);
if (!type) {
return;
Expand Down
4 changes: 2 additions & 2 deletions packages/eslint-plugin-tslint/src/rules/config.ts
Expand Up @@ -104,8 +104,8 @@ export default createRule<Options, MessageIds>({
) {
const fileName = context.getFilename();
const sourceCode = context.getSourceCode().text;
const parserServices = ESLintUtils.getParserServices(context);
const program = parserServices.program;
const services = ESLintUtils.getParserServices(context);
const program = services.program;

/**
* Create an instance of TSLint
Expand Down
18 changes: 9 additions & 9 deletions packages/eslint-plugin/src/rules/await-thenable.ts
Expand Up @@ -19,19 +19,19 @@ export default util.createRule({
defaultOptions: [],

create(context) {
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
const services = util.getParserServices(context);
const checker = services.program.getTypeChecker();

return {
AwaitExpression(node): void {
const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);
const type = checker.getTypeAtLocation(originalNode.expression);
const type = services.getTypeAtLocation(node.argument);
if (util.isTypeAnyType(type) || util.isTypeUnknownType(type)) {
return;
}

const originalNode = services.esTreeNodeToTSNodeMap.get(node);

if (
!util.isTypeAnyType(type) &&
!util.isTypeUnknownType(type) &&
!tsutils.isThenableType(checker, originalNode.expression, type)
) {
if (!tsutils.isThenableType(checker, originalNode.expression, type)) {
context.report({
messageId: 'await',
node,
Expand Down
7 changes: 3 additions & 4 deletions packages/eslint-plugin/src/rules/consistent-type-exports.ts
Expand Up @@ -69,7 +69,7 @@ export default util.createRule<Options, MessageIds>({
create(context, [{ fixMixedExportsWithInlineTypeSpecifier }]) {
const sourceCode = context.getSourceCode();
const sourceExportsMap: { [key: string]: SourceExports } = {};
const parserServices = util.getParserServices(context);
const services = util.getParserServices(context);

/**
* Helper for identifying if an export specifier resolves to a
Expand All @@ -81,9 +81,8 @@ export default util.createRule<Options, MessageIds>({
function isSpecifierTypeBased(
specifier: TSESTree.ExportSpecifier,
): boolean | undefined {
const checker = parserServices.program.getTypeChecker();
const node = parserServices.esTreeNodeToTSNodeMap.get(specifier.exported);
const symbol = checker.getSymbolAtLocation(node);
const checker = services.program.getTypeChecker();
const symbol = services.getSymbolAtLocation(specifier.exported);
const aliasedSymbol = checker.getAliasedSymbol(symbol!);

if (!aliasedSymbol || aliasedSymbol.escapedName === 'unknown') {
Expand Down
14 changes: 4 additions & 10 deletions packages/eslint-plugin/src/rules/dot-notation.ts
Expand Up @@ -67,9 +67,7 @@ export default createRule<Options, MessageIds>({
],
create(context, [options]) {
const rules = baseRule.create(context);

const { program, esTreeNodeToTSNodeMap } = getParserServices(context);
const typeChecker = program.getTypeChecker();
const services = getParserServices(context);

const allowPrivateClassPropertyAccess =
options.allowPrivateClassPropertyAccess;
Expand All @@ -78,7 +76,7 @@ export default createRule<Options, MessageIds>({
const allowIndexSignaturePropertyAccess =
(options.allowIndexSignaturePropertyAccess ?? false) ||
tsutils.isCompilerOptionEnabled(
program.getCompilerOptions(),
services.program.getCompilerOptions(),
// @ts-expect-error - TS is refining the type to never for some reason
'noPropertyAccessFromIndexSignature',
);
Expand All @@ -92,9 +90,7 @@ export default createRule<Options, MessageIds>({
node.computed
) {
// for perf reasons - only fetch symbols if we have to
const propertySymbol = typeChecker.getSymbolAtLocation(
esTreeNodeToTSNodeMap.get(node.property),
);
const propertySymbol = services.getSymbolAtLocation(node.property);
const modifierKind = getModifiers(
propertySymbol?.getDeclarations()?.[0],
)?.[0].kind;
Expand All @@ -110,9 +106,7 @@ export default createRule<Options, MessageIds>({
propertySymbol === undefined &&
allowIndexSignaturePropertyAccess
) {
const objectType = typeChecker.getTypeAtLocation(
esTreeNodeToTSNodeMap.get(node.object),
);
const objectType = services.getTypeAtLocation(node.object);
const indexType = objectType
.getNonNullableType()
.getStringIndexType();
Expand Down
Expand Up @@ -435,11 +435,10 @@ function isCorrectType(
return true;
}

const { esTreeNodeToTSNodeMap, program } = util.getParserServices(context);
const checker = program.getTypeChecker();
const tsNode = esTreeNodeToTSNodeMap.get(node);
const type = checker
.getTypeAtLocation(tsNode)
const services = util.getParserServices(context);
const checker = services.program.getTypeChecker();
const type = services
.getTypeAtLocation(node)
// remove null and undefined from the type, as we don't care about it here
.getNonNullableType();

Expand Down
27 changes: 10 additions & 17 deletions packages/eslint-plugin/src/rules/no-base-to-string.ts
Expand Up @@ -52,8 +52,8 @@ export default util.createRule<Options, MessageIds>({
},
],
create(context, [option]) {
const parserServices = util.getParserServices(context);
const typeChecker = parserServices.program.getTypeChecker();
const services = util.getParserServices(context);
const checker = services.program.getTypeChecker();
const ignoredTypeNames = option.ignoredTypeNames ?? [];

function checkExpression(node: TSESTree.Expression, type?: ts.Type): void {
Expand All @@ -62,10 +62,7 @@ export default util.createRule<Options, MessageIds>({
}

const certainty = collectToStringCertainty(
type ??
typeChecker.getTypeAtLocation(
parserServices.esTreeNodeToTSNodeMap.get(node),
),
type ?? services.getTypeAtLocation(node),
);
if (certainty === Usefulness.Always) {
return;
Expand All @@ -82,7 +79,7 @@ export default util.createRule<Options, MessageIds>({
}

function collectToStringCertainty(type: ts.Type): Usefulness {
const toString = typeChecker.getPropertyOfType(type, 'toString');
const toString = checker.getPropertyOfType(type, 'toString');
const declarations = toString?.getDeclarations();
if (!toString || !declarations || declarations.length === 0) {
return Usefulness.Always;
Expand All @@ -96,7 +93,7 @@ export default util.createRule<Options, MessageIds>({
return Usefulness.Always;
}

if (ignoredTypeNames.includes(util.getTypeName(typeChecker, type))) {
if (ignoredTypeNames.includes(util.getTypeName(checker, type))) {
return Usefulness.Always;
}

Expand Down Expand Up @@ -155,17 +152,13 @@ export default util.createRule<Options, MessageIds>({
'AssignmentExpression[operator = "+="], BinaryExpression[operator = "+"]'(
node: TSESTree.AssignmentExpression | TSESTree.BinaryExpression,
): void {
const leftType = typeChecker.getTypeAtLocation(
parserServices.esTreeNodeToTSNodeMap.get(node.left),
);
const rightType = typeChecker.getTypeAtLocation(
parserServices.esTreeNodeToTSNodeMap.get(node.right),
);

if (util.getTypeName(typeChecker, leftType) === 'string') {
const leftType = services.getTypeAtLocation(node.left);
const rightType = services.getTypeAtLocation(node.right);

if (util.getTypeName(checker, leftType) === 'string') {
checkExpression(node.right, rightType);
} else if (
util.getTypeName(typeChecker, rightType) === 'string' &&
util.getTypeName(checker, rightType) === 'string' &&
node.left.type !== AST_NODE_TYPES.PrivateIdentifier
) {
checkExpression(node.left, leftType);
Expand Down
Expand Up @@ -80,10 +80,8 @@ export default util.createRule<Options, MessageId>({
| TSESTree.CallExpression
| TSESTree.TaggedTemplateExpression,
): void {
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node);
const type = util.getConstrainedTypeAtLocation(checker, tsNode);
const services = util.getParserServices(context);
const type = util.getConstrainedTypeAtLocation(services, node);
if (!tsutils.isTypeFlagSet(type, ts.TypeFlags.VoidLike)) {
// not a void expression
return;
Expand Down
12 changes: 5 additions & 7 deletions packages/eslint-plugin/src/rules/no-floating-promises.ts
Expand Up @@ -65,8 +65,8 @@ export default util.createRule<Options, MessageId>({
],

create(context, [options]) {
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
const services = util.getParserServices(context);
const checker = services.program.getTypeChecker();

return {
ExpressionStatement(node): void {
Expand All @@ -89,7 +89,7 @@ export default util.createRule<Options, MessageId>({
{
messageId: 'floatingFixVoid',
fix(fixer): TSESLint.RuleFix | TSESLint.RuleFix[] {
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(
const tsNode = services.esTreeNodeToTSNodeMap.get(
node.expression,
);
if (isHigherPrecedenceThanUnary(tsNode)) {
Expand Down Expand Up @@ -124,7 +124,7 @@ export default util.createRule<Options, MessageId>({
'await',
);
}
const tsNode = parserServices.esTreeNodeToTSNodeMap.get(
const tsNode = services.esTreeNodeToTSNodeMap.get(
node.expression,
);
if (isHigherPrecedenceThanUnary(tsNode)) {
Expand Down Expand Up @@ -191,9 +191,7 @@ export default util.createRule<Options, MessageId>({
}

// Check the type. At this point it can't be unhandled if it isn't a promise
if (
!isPromiseLike(checker, parserServices.esTreeNodeToTSNodeMap.get(node))
) {
if (!isPromiseLike(checker, services.esTreeNodeToTSNodeMap.get(node))) {
return false;
}

Expand Down
10 changes: 3 additions & 7 deletions packages/eslint-plugin/src/rules/no-for-in-array.ts
Expand Up @@ -21,14 +21,10 @@ export default util.createRule({
create(context) {
return {
ForInStatement(node): void {
const parserServices = util.getParserServices(context);
const checker = parserServices.program.getTypeChecker();
const originalNode = parserServices.esTreeNodeToTSNodeMap.get(node);
const services = util.getParserServices(context);
const checker = services.program.getTypeChecker();

const type = util.getConstrainedTypeAtLocation(
checker,
originalNode.expression,
);
const type = util.getConstrainedTypeAtLocation(services, node.right);

if (
util.isTypeArrayTypeOrUnionOfArrayTypes(type, checker) ||
Expand Down

0 comments on commit 62d5755

Please sign in to comment.