Skip to content

Commit

Permalink
fix: remove tsutils and tsutils-etc (#3)
Browse files Browse the repository at this point in the history
- Remove unmaintained `tsutils` and `tsutils-etc`.
- `tsutils` has been replaced by the community with `ts-api-utils`. See
ajafff/tsutils#145. Replacing it was fairly
simple.
- `tsutils-etc` relies on `tsutils`, and I haven't seen a
community-chosen replacement, so we're re-implementing the utilities
into this repo, similar to how we re-implemented `eslint-etc` utilities.
So utilities now exist in `ts-api-utils`, but `couldBeType` and
`couldBeFunction` had to be ported over.
- Pull in a dev dependency on `@typescript/vfs` to unit test
`couldBeType` based on the `tsutils-etc` unit tests for that function.
- Bump the minimum versions of TypeScript to >=4.2.0 to align with
`ts-api-utils` and `tslib` to ^2.1.0 to align with `rxjs`.
  • Loading branch information
JasonWeinzierl authored Nov 4, 2024
1 parent 804812c commit c64f9f6
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 131 deletions.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,21 @@
"@typescript-eslint/utils": "^8.12.2",
"common-tags": "^1.8.0",
"decamelize": "^5.0.0 || ^6.0.0",
"tslib": "^2.0.0",
"tsutils": "^3.0.0",
"tsutils-etc": "^1.4.2"
"ts-api-utils": "^1.3.0",
"tslib": "^2.1.0"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0",
"rxjs": ">=7.0.0",
"typescript": ">=4.0.0"
"typescript": ">=4.2.0"
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@stylistic/eslint-plugin": "^2.10.1",
"@types/common-tags": "^1.8.4",
"@types/node": "^18.18.0",
"@typescript-eslint/rule-tester": "^8.12.2",
"@typescript/vfs": "^1.6.0",
"@vitest/coverage-v8": "^2.1.4",
"@vitest/eslint-plugin": "^1.1.7",
"bumpp": "^9.8.0",
Expand Down
11 changes: 11 additions & 0 deletions src/etc/could-be-function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as ts from 'typescript';
import { couldBeType } from './could-be-type';

export function couldBeFunction(type: ts.Type): boolean {
return (
type.getCallSignatures().length > 0
|| couldBeType(type, 'Function')
|| couldBeType(type, 'ArrowFunction')
|| couldBeType(type, ts.InternalSymbolName.Function)
);
}
110 changes: 110 additions & 0 deletions src/etc/could-be-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';

export function couldBeType(
type: ts.Type,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
if (tsutils.isTypeReference(type)) {
type = type.target;
}

if (isType(type, name, qualified)) {
return true;
}

if (tsutils.isUnionOrIntersectionType(type)) {
return type.types.some(t => couldBeType(t, name, qualified));
}

const baseTypes = type.getBaseTypes();
if (baseTypes?.some(t => couldBeType(t, name, qualified))) {
return true;
}

if (couldImplement(type, name, qualified)) {
return true;
}

return false;
}

function isType(
type: ts.Type,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
if (!type.symbol) {
return false;
}
if (
qualified
&& !qualified.name.test(
qualified.typeChecker.getFullyQualifiedName(type.symbol),
)
) {
return false;
}
return typeof name === 'string'
? type.symbol.name === name
: Boolean(type.symbol.name.match(name));
}

function couldImplement(
type: ts.Type,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
const { symbol } = type;
if (symbol) {
const { valueDeclaration } = symbol;
if (valueDeclaration && ts.isClassDeclaration(valueDeclaration)) {
const { heritageClauses } = valueDeclaration;
if (heritageClauses) {
const implemented = heritageClauses.some(
({ token, types }) =>
token === ts.SyntaxKind.ImplementsKeyword
&& types.some(node => isMatchingNode(node, name, qualified)),
);
if (implemented) {
return true;
}
}
}
}
return false;
}

function isMatchingNode(
node: ts.ExpressionWithTypeArguments,
name: string | RegExp,
qualified?: {
name: RegExp;
typeChecker: ts.TypeChecker;
},
): boolean {
const { expression } = node;
if (qualified) {
const type = qualified.typeChecker.getTypeAtLocation(expression);
if (type) {
const qualifiedName = qualified.typeChecker.getFullyQualifiedName(
type.symbol,
);
if (!qualified.name.test(qualifiedName)) {
return false;
}
}
}
const text = expression.getText();
return typeof name === 'string' ? text === name : Boolean(text.match(name));
}
16 changes: 9 additions & 7 deletions src/etc/get-type-services.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ESLintUtils, TSESLint, TSESTree } from '@typescript-eslint/utils';
import * as tsutils from 'tsutils-etc';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';
import { couldBeFunction } from './could-be-function';
import { couldBeType as tsutilsEtcCouldBeType } from './could-be-type';
import { isArrowFunctionExpression, isFunctionDeclaration } from './is';

export function getTypeServices<
Expand All @@ -17,7 +19,7 @@ export function getTypeServices<
qualified?: { name: RegExp },
): boolean => {
const type = getType(node);
return tsutils.couldBeType(
return tsutilsEtcCouldBeType(
type,
name,
qualified ? { ...qualified, typeChecker } : undefined,
Expand Down Expand Up @@ -46,7 +48,7 @@ export function getTypeServices<
}
return Boolean(
tsTypeNode
&& tsutils.couldBeType(
&& tsutilsEtcCouldBeType(
typeChecker.getTypeAtLocation(tsTypeNode),
name,
qualified ? { ...qualified, typeChecker } : undefined,
Expand All @@ -66,7 +68,7 @@ export function getTypeServices<
if (isArrowFunctionExpression(node) || isFunctionDeclaration(node)) {
return true;
}
return tsutils.couldBeFunction(getType(node));
return couldBeFunction(getType(node));
},
couldBeMonoTypeOperatorFunction: (node: TSESTree.Node) =>
couldBeType(node, 'MonoTypeOperatorFunction'),
Expand All @@ -78,9 +80,9 @@ export function getTypeServices<
couldReturnType(node, 'Observable'),
couldReturnType,
getType,
isAny: (node: TSESTree.Node) => tsutils.isAny(getType(node)),
isReferenceType: (node: TSESTree.Node) => tsutils.isReferenceType(getType(node)),
isUnknown: (node: TSESTree.Node) => tsutils.isUnknown(getType(node)),
isAny: (node: TSESTree.Node) => tsutils.isIntrinsicAnyType(getType(node)),
isReferenceType: (node: TSESTree.Node) => tsutils.isTypeReference(getType(node)),
isUnknown: (node: TSESTree.Node) => tsutils.isIntrinsicUnknownType(getType(node)),
typeChecker,
};
}
2 changes: 2 additions & 0 deletions src/etc/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from './could-be-function';
export * from './could-be-type';
export * from './find-parent';
export * from './get-loc';
export * from './get-type-services';
Expand Down
8 changes: 4 additions & 4 deletions src/rules/no-unsafe-subject-next.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { TSESTree as es } from '@typescript-eslint/utils';
import * as tsutils from 'tsutils';
import { couldBeType, isReferenceType, isUnionType } from 'tsutils-etc';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';
import {
couldBeType,
getTypeServices,
isMemberExpression } from '../etc';
import { ruleCreator } from '../utils';
Expand Down Expand Up @@ -30,7 +30,7 @@ export const noUnsafeSubjectNext = ruleCreator({
) => {
if (node.arguments.length === 0 && isMemberExpression(node.callee)) {
const type = getType(node.callee.object);
if (isReferenceType(type) && couldBeType(type, 'Subject')) {
if (tsutils.isTypeReference(type) && couldBeType(type, 'Subject')) {
const [typeArg] = typeChecker.getTypeArguments(type);
if (tsutils.isTypeFlagSet(typeArg, ts.TypeFlags.Any)) {
return;
Expand All @@ -42,7 +42,7 @@ export const noUnsafeSubjectNext = ruleCreator({
return;
}
if (
isUnionType(typeArg)
tsutils.isUnionType(typeArg)
&& typeArg.types.some((t) =>
tsutils.isTypeFlagSet(t, ts.TypeFlags.Void),
)
Expand Down
8 changes: 4 additions & 4 deletions src/rules/throw-error.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { TSESTree as es, ESLintUtils } from '@typescript-eslint/utils';
import { couldBeFunction, couldBeType, isAny, isUnknown } from 'tsutils-etc';
import * as tsutils from 'ts-api-utils';
import * as ts from 'typescript';
import { getTypeServices } from '../etc';
import { couldBeFunction, couldBeType, getTypeServices } from '../etc';
import { ruleCreator } from '../utils';

export const throwErrorRule = ruleCreator({
Expand Down Expand Up @@ -32,8 +32,8 @@ export const throwErrorRule = ruleCreator({
type = program.getTypeChecker().getTypeAtLocation(annotation ?? body);
}
if (
!isAny(type)
&& !isUnknown(type)
!tsutils.isIntrinsicAnyType(type)
&& !tsutils.isIntrinsicUnknownType(type)
&& !couldBeType(type, /^(Error|DOMException)$/)
) {
context.report({
Expand Down
137 changes: 137 additions & 0 deletions tests/etc/could-be-type.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import * as ts from 'typescript';
import { couldBeType } from '../../src/etc/could-be-type';
import { createSourceFileAndTypeChecker } from './create-source-file-and-type-checker';

describe('couldBeType', () => {
it('should match a specific type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
let a: A;
`,
);
const node = (sourceFile.statements[1] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
});

it('should not match different types', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B {}
let b: B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(false);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match a base type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B extends A {}
let b: B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match an implemented interface', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
interface A { name: string; }
class B implements A { name = ""; }
let b: B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match an implemented generic interface', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
interface A<T> { value: T; }
class B<T> implements A<T> { constructor(public value: T) {} }
let b = new B<string>("B");
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match an intersection type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B {}
let ab: A & B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it('should match a union type', () => {
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
class A {}
class B {}
let ab: A | B;
`,
);
const node = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const type = typeChecker.getTypeAtLocation(node);

expect(couldBeType(type, 'A')).toBe(true);
expect(couldBeType(type, 'B')).toBe(true);
});

it.todo('should support fully-qualified types', () => {
// TODO: This test is disabled because we're failing to import from other files using @typescript/vfs. See env.languageService.getSemanticDiagnostics(fileName) for error message.
const { sourceFile, typeChecker } = createSourceFileAndTypeChecker(
`
import { A } from "./a";
class B {}
let a: A;
let b: B;
`,
);
const nodeA = (sourceFile.statements[2] as ts.VariableStatement).declarationList.declarations[0];
const nodeB = (sourceFile.statements[3] as ts.VariableStatement).declarationList.declarations[0];
const typeA = typeChecker.getTypeAtLocation(nodeA);
const typeB = typeChecker.getTypeAtLocation(nodeB);

expect(
couldBeType(typeA, 'A', {
name: /"a"/,
typeChecker,
}),
).toBe(true);
expect(
couldBeType(typeB, 'B', {
name: /"b"/,
typeChecker,
}),
).toBe(false);
});
});
Loading

0 comments on commit c64f9f6

Please sign in to comment.