Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(eslint-plugin): [prefer-function-type] handle this return #2437

Merged
merged 11 commits into from Sep 14, 2020
92 changes: 86 additions & 6 deletions packages/eslint-plugin/src/rules/prefer-function-type.ts
Expand Up @@ -61,8 +61,45 @@ export default util.createRule({
return false;
}
}
/**
* replaces uses of the 'this' keyword to refer to the interface name
* so the auto suggestion works as a recursive definition.
* Note that if the interface takes generics then 'interfaceName' would
* need to be formatted like `Array<T>` in order for the replacements to work
* @param start start index of source code of relevant text to replace
* @param origColonPos the position of the colon before replacement
* @param text text to replace, the call signature text specifically
* @param thisRefs list of nodes corresponding to the 'this' type
* @param interfaceName name of interface, to replace 'this' with.
* @returns the new colon position and new text after replacement
*/
function replaceThisRefs(
start: number,
origColonPos: number,
text: string,
thisRefs: TSESTree.TSThisType[],
interfaceName: string,
): [number, string] {
let newText = text;
let newColonPos = origColonPos;
// go over occurrences backward to not screw up text index.
for (let idx = thisRefs.length - 1; idx >= 0; idx--) {
// range is in span of text file, subtracting start gives it in index of text.
const [a, b] = thisRefs[idx].range.map(n => n - start);
if (a < origColonPos) {
// replacing `this` with the interface name before the colon changes the colon position
// forwards by the interface name minus the length of this
newColonPos += interfaceName.length - 4; /*'this'.length*/
}
newText = newText.slice(0, a) + interfaceName + newText.slice(b);
}
return [newColonPos, newText];
}

/**
* note that if parent is an interface with generics and tsThisTypes isn't empty
* then the 'this' references are not replaced, it is expected that checkMember
* will ensure that doesn't happen
* @param call The call signature causing the diagnostic
* @param parent The parent of the call
* @returns The suggestion to report
Expand All @@ -72,11 +109,27 @@ export default util.createRule({
| TSESTree.TSCallSignatureDeclaration
| TSESTree.TSConstructSignatureDeclaration,
parent: TSESTree.Node,
tsThisTypes?: TSESTree.TSThisType[],
): string {
const start = call.range[0];
const colonPos = call.returnType!.range[0] - start;
const text = sourceCode.getText().slice(start, call.range[1]);
let colonPos = call.returnType!.range[0] - start;
let text = sourceCode.getText().slice(start, call.range[1]);

// if there are references to 'this' replace them with interface name
if (
tsThisTypes !== undefined &&
tsThisTypes.length > 0 &&
parent.type === AST_NODE_TYPES.TSInterfaceDeclaration &&
typeof parent.typeParameters === 'undefined'
) {
[colonPos, text] = replaceThisRefs(
start,
colonPos,
text,
tsThisTypes,
parent.id.name,
);
}
let suggestion = `${text.slice(0, colonPos)} =>${text.slice(
colonPos + 1,
)}`;
Expand Down Expand Up @@ -105,13 +158,27 @@ export default util.createRule({
function checkMember(
member: TSESTree.TypeElement,
node: TSESTree.Node,
tsThisTypes?: TSESTree.TSThisType[],
tadhgmister marked this conversation as resolved.
Show resolved Hide resolved
): void {
if (
(tsThisTypes?.length ?? 0) > 0 &&
node.type === AST_NODE_TYPES.TSInterfaceDeclaration &&
typeof node.typeParameters !== 'undefined'
) {
// if the interface uses type parameters and refers to 'this' the suggested replacement
// is fairly complicated since now we'd have to replace each 'this' with `A<P,K>` etc.
// and the way it is replaced in renderSuggestion is just grabbing the entire parameter string
// including `extends` and defaults, so we'd have to actually parse through to re-construct
// the recursive type alias which for such an edge case doesn't seem worth implementing.
// TODO: should still probably make report without fix offered.
return;
}
if (
(member.type === AST_NODE_TYPES.TSCallSignatureDeclaration ||
member.type === AST_NODE_TYPES.TSConstructSignatureDeclaration) &&
typeof member.returnType !== 'undefined'
) {
const suggestion = renderSuggestion(member, node);
const suggestion = renderSuggestion(member, node, tsThisTypes);
const fixStart =
node.type === AST_NODE_TYPES.TSTypeLiteral
? node.range[0]
Expand Down Expand Up @@ -142,14 +209,27 @@ export default util.createRule({
});
}
}

let tsThisTypes: TSESTree.TSThisType[] | null = null;
return {
TSInterfaceDeclaration(node): void {
TSInterfaceDeclaration(): void {
// when entering an interface reset the count of `this`s to empty.
tsThisTypes = [];
},
'TSInterfaceDeclaration TSThisType'(node: TSESTree.TSThisType): void {
// inside an interface keep track of all ThisType references.
tsThisTypes?.push(node);
},
'TSInterfaceDeclaration:exit'(
node: TSESTree.TSInterfaceDeclaration,
): void {
if (!hasOneSupertype(node) && node.body.body.length === 1) {
checkMember(node.body.body[0], node);
checkMember(node.body.body[0], node, tsThisTypes ?? undefined);
tadhgmister marked this conversation as resolved.
Show resolved Hide resolved
}
// on exit check member and reset the array to nothing.
tsThisTypes = null;
},
'TSTypeLiteral[members.length = 1]'(node: TSESTree.TSTypeLiteral): void {
// we don't need to check for `this` inside type literals since it's not valid.
checkMember(node.members[0], node);
},
};
Expand Down
84 changes: 84 additions & 0 deletions packages/eslint-plugin/tests/rules/prefer-function-type.test.ts
Expand Up @@ -41,6 +41,11 @@ interface Foo {
}
interface Bar extends Function, Foo {
(): void;
}
`,
`
interface A<T> {
(arg: this): T;
}
`,
],
Expand Down Expand Up @@ -146,5 +151,84 @@ interface Foo<T> {
type Foo<T> = (bar: T) => string;
`,
},
{
code: `
interface Foo<T> {
(this: T): void;
}
`,
errors: [
{
messageId: 'functionTypeOverCallableType',
type: AST_NODE_TYPES.TSCallSignatureDeclaration,
},
tadhgmister marked this conversation as resolved.
Show resolved Hide resolved
],
output: `
type Foo<T> = (this: T) => void;
`,
},
{
code: `
type Foo<T> = { (this: string): T };
`,
errors: [
{
messageId: 'functionTypeOverCallableType',
type: AST_NODE_TYPES.TSCallSignatureDeclaration,
},
],
output: `
type Foo<T> = (this: string) => T;
`,
},
{
code: `
interface Foo {
(arg: this): void;
}
`,
errors: [
{
messageId: 'functionTypeOverCallableType',
type: AST_NODE_TYPES.TSCallSignatureDeclaration,
},
],
output: `
type Foo = (arg: Foo) => void;
`,
},

{
code: `
interface Foo {
(arg: number): this | undefined;
}
`,
errors: [
{
messageId: 'functionTypeOverCallableType',
type: AST_NODE_TYPES.TSCallSignatureDeclaration,
},
],
output: `
type Foo = (arg: number) => Foo | undefined;
`,
},
{
code: `
interface Foo {
(arg: this | Array<this>): Record<'a', this>;
}
`,
errors: [
{
messageId: 'functionTypeOverCallableType',
type: AST_NODE_TYPES.TSCallSignatureDeclaration,
},
],
output: `
type Foo = (arg: Foo | Array<Foo>) => Record<'a', Foo>;
`,
},
],
});