Skip to content

Commit

Permalink
feat: Finish prefer-interface intersections.
Browse files Browse the repository at this point in the history
  • Loading branch information
cartant committed Feb 5, 2021
1 parent 5a1cb12 commit 12fab8d
Show file tree
Hide file tree
Showing 2 changed files with 110 additions and 23 deletions.
103 changes: 81 additions & 22 deletions source/rules/prefer-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,12 @@ import {
TSESLint as eslint,
TSESTree as es,
} from "@typescript-eslint/experimental-utils";
import { getParent, getParserServices, getTypeServices } from "eslint-etc";
import * as ts from "typescript";
import {
getParent,
getParserServices,
getTypeServices,
isIdentifier,
} from "eslint-etc";
import { ruleCreator } from "../utils";

function isExportNamedDeclaration(
Expand Down Expand Up @@ -56,13 +60,33 @@ const rule = ruleCreator({
name: "prefer-interface",
create: (context, unused: typeof defaultOptions) => {
const [
{ allowIntersection = false, allowLocal = false } = {},
{ allowIntersection = true, allowLocal = false } = {},
] = context.options;
const { esTreeNodeToTSNodeMap } = getParserServices(context);
let typeChecker: ts.TypeChecker | undefined;
try {
({ typeChecker } = getTypeServices(context));
} catch (error) {}

function formatTypeParameters(
typeParameters?:
| es.TSTypeParameterDeclaration
| es.TSTypeParameterInstantiation
): string {
return typeParameters
? context.getSourceCode().getText(typeParameters)
: "";
}

function formatTypeReferences(
typeReferences: es.TSTypeReference[]
): string {
return typeReferences
.map((typeReference) => {
if (!isIdentifier(typeReference.typeName)) {
throw new Error("Expected typeName to be an identifier.");
}
const parameters = formatTypeParameters(typeReference.typeParameters);
return `${typeReference.typeName.name}${parameters}`;
})
.join(", ");
}

return {
"TSTypeAliasDeclaration > TSFunctionType": (
functionTypeNode: es.TSFunctionType
Expand All @@ -74,12 +98,12 @@ const rule = ruleCreator({
return;
}
function fix(fixer: eslint.RuleFixer) {
const interfaceTypeParameters = typeAliasNode.typeParameters
? context.getSourceCode().getText(typeAliasNode.typeParameters)
: "";
const functionTypeParameters = functionTypeNode.typeParameters
? context.getSourceCode().getText(functionTypeNode.typeParameters)
: "";
const interfaceTypeParameters = formatTypeParameters(
typeAliasNode.typeParameters
);
const functionTypeParameters = formatTypeParameters(
functionTypeNode.typeParameters
);
const params = functionTypeNode.params
.map((param) => context.getSourceCode().getText(param))
.join(",");
Expand Down Expand Up @@ -112,6 +136,8 @@ const rule = ruleCreator({
if (allowIntersection) {
return;
}
const { esTreeNodeToTSNodeMap } = getParserServices(context);
const { typeChecker } = getTypeServices(context);
const typeAliasNode = getParent(
intersectionTypeNode
) as es.TSTypeAliasDeclaration;
Expand All @@ -137,17 +163,52 @@ const rule = ruleCreator({
const type = typeChecker.getTypeFromTypeNode(
esTreeNodeToTSNodeMap.get(reference)
);
if (!type.isClassOrInterface() || type.isClass()) {
// It seems like it ought to be possible to use the isClass and
// isClassOrInterface methods here, but the isClassOrInterface method
// return false for generic interfaces.
if (type.isUnion()) {
return;
}
}
if (literals.length > 1) {
return;
} else if (literals.length === 1) {
// TODO: is there a literal? extend type references and use literal members as the interface body
}
let fix: (fixer: eslint.RuleFixer) => any;
if (literals.length === 1) {
fix = function (fixer: eslint.RuleFixer) {
const parameters = formatTypeParameters(
typeAliasNode.typeParameters
);
const bases = formatTypeReferences(references);
const literal = context.getSourceCode().getText(literals[0]);
return fixer.replaceText(
typeAliasNode,
`interface ${typeAliasNode.id.name}${parameters} extends ${bases} ${literal}`
);
};
} else {
// TODO: extend type references
fix = function (fixer: eslint.RuleFixer) {
const parameters = formatTypeParameters(
typeAliasNode.typeParameters
);
const bases = formatTypeReferences(references);
return fixer.replaceText(
typeAliasNode,
`interface ${typeAliasNode.id.name}${parameters} extends ${bases} {}`
);
};
}
context.report({
fix,
messageId: "forbidden",
node: typeAliasNode.id,
suggest: [
{
fix,
messageId: "suggest",
},
],
});
},
"TSTypeAliasDeclaration > TSTypeLiteral": (
typeLiteralNode: es.TSTypeLiteral
Expand All @@ -159,13 +220,11 @@ const rule = ruleCreator({
return;
}
function fix(fixer: eslint.RuleFixer) {
const typeParameters = typeAliasNode.typeParameters
? context.getSourceCode().getText(typeAliasNode.typeParameters)
: "";
const parameters = formatTypeParameters(typeAliasNode.typeParameters);
const literal = context.getSourceCode().getText(typeLiteralNode);
return fixer.replaceText(
typeAliasNode,
`interface ${typeAliasNode.id.name}${typeParameters} ${literal}`
`interface ${typeAliasNode.id.name}${parameters} ${literal}`
);
}
context.report({
Expand Down
30 changes: 29 additions & 1 deletion tests/rules/prefer-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { fromFixture } from "eslint-etc";
import rule = require("../../source/rules/prefer-interface");
import { ruleTester } from "../utils";

ruleTester({ types: true }).run("prefer-interface", rule, {
ruleTester({ types: false }).run("prefer-interface", rule, {
valid: [
`type T = string;`,
`type T = string | number;`,
Expand Down Expand Up @@ -161,6 +161,12 @@ ruleTester({ types: true }).run("prefer-interface", rule, {
`,
}
),
],
});

ruleTester({ types: true }).run("prefer-interface", rule, {
valid: [],
invalid: [
fromFixture(
stripIndent`
// interface intersection
Expand All @@ -170,6 +176,7 @@ ruleTester({ types: true }).run("prefer-interface", rule, {
~ [forbidden]
`,
{
options: [{ allowIntersection: false }],
output: stripIndent`
// interface intersection
interface Name { name: string; }
Expand All @@ -178,6 +185,24 @@ ruleTester({ types: true }).run("prefer-interface", rule, {
`,
}
),
fromFixture(
stripIndent`
// generic interface intersection
interface Name<S> { name: S; }
interface Age<N> { age: N; }
type T<S, N> = Name<S> & Age<N>;
~ [forbidden]
`,
{
options: [{ allowIntersection: false }],
output: stripIndent`
// generic interface intersection
interface Name<S> { name: S; }
interface Age<N> { age: N; }
interface T<S, N> extends Name<S>, Age<N> {}
`,
}
),
fromFixture(
stripIndent`
// interface-literal intersection
Expand All @@ -186,6 +211,7 @@ ruleTester({ types: true }).run("prefer-interface", rule, {
~ [forbidden]
`,
{
options: [{ allowIntersection: false }],
output: stripIndent`
// interface-literal intersection
interface Name { name: string; }
Expand All @@ -201,6 +227,7 @@ ruleTester({ types: true }).run("prefer-interface", rule, {
~ [forbidden]
`,
{
options: [{ allowIntersection: false }],
output: stripIndent`
// literal-interface intersection
interface Age { age: number; }
Expand All @@ -217,6 +244,7 @@ ruleTester({ types: true }).run("prefer-interface", rule, {
~ [forbidden]
`,
{
options: [{ allowIntersection: false }],
output: stripIndent`
// interface-literal-interface intersection
interface Name { name: string; }
Expand Down

0 comments on commit 12fab8d

Please sign in to comment.