Skip to content

Commit

Permalink
feat(eslint-plugin): add fixer for use-pipe-transform-interface (#260)
Browse files Browse the repository at this point in the history
  • Loading branch information
rafaelss95 committed Jan 16, 2021
1 parent e1057dd commit e3f4db6
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 51 deletions.
98 changes: 84 additions & 14 deletions packages/eslint-plugin/src/rules/use-pipe-transform-interface.ts
@@ -1,45 +1,115 @@
import { TSESTree } from '@typescript-eslint/experimental-utils';
import { TSESTree, TSESLint } from '@typescript-eslint/experimental-utils';
import { createESLintRule } from '../utils/create-eslint-rule';
import { PIPE_CLASS_DECORATOR } from '../utils/selectors';
import {
AngularClassDecorators,
getDeclaredInterfaceName,
} from '../utils/utils';
import { getDeclaredInterfaceName, isImportDeclaration } from '../utils/utils';

type Options = [];
export type MessageIds = 'usePipeTransformInterface';
export const RULE_NAME = 'use-pipe-transform-interface';

const PIPE_TRANSFORM = 'PipeTransform';
const ANGULAR_CORE_MODULE_PATH = '@angular/core';

export default createESLintRule<Options, MessageIds>({
name: RULE_NAME,
meta: {
type: 'suggestion',
docs: {
description: `Ensures tht classes decorated with @${AngularClassDecorators.Pipe} implement ${PIPE_TRANSFORM} interface`,
description: `Ensures that Pipes implement \`${PIPE_TRANSFORM}\` interface`,
category: 'Best Practices',
recommended: 'error',
},
fixable: 'code',
schema: [],
messages: {
usePipeTransformInterface: `Classes decorated with @${AngularClassDecorators.Pipe} decorator should implement ${PIPE_TRANSFORM} interface`,
usePipeTransformInterface: `Pipes should implement \`${PIPE_TRANSFORM}\` interface`,
},
},
defaultOptions: [],
create(context) {
return {
[PIPE_CLASS_DECORATOR](node: TSESTree.Decorator) {
const classParent = node.parent as TSESTree.ClassDeclaration;
if (getDeclaredInterfaceName(classParent, PIPE_TRANSFORM)) {
return;
}
[PIPE_CLASS_DECORATOR]({
parent: classDeclaration,
}: TSESTree.Decorator & { parent: TSESTree.ClassDeclaration }) {
if (getDeclaredInterfaceName(classDeclaration, PIPE_TRANSFORM)) return;

const {
errorNode,
implementsNodeReplace,
implementsTextReplace,
} = getErrorSchemaOptions(classDeclaration);

context.report({
node: classParent,
node: errorNode,
messageId: 'usePipeTransformInterface',
fix: (fixer) => [
getImportFix(classDeclaration, fixer),
fixer.insertTextAfter(implementsNodeReplace, implementsTextReplace),
],
});
},
};
},
});

function getErrorSchemaOptions(classDeclaration: TSESTree.ClassDeclaration) {
const classDeclarationIdentifier = classDeclaration.id as TSESTree.Identifier;
const [
errorNode,
implementsNodeReplace,
implementsTextReplace,
] = classDeclaration.implements
? [
classDeclarationIdentifier,
getLast(classDeclaration.implements),
`, ${PIPE_TRANSFORM}`,
]
: [
classDeclarationIdentifier,
classDeclarationIdentifier,
` implements ${PIPE_TRANSFORM}`,
];

return { errorNode, implementsNodeReplace, implementsTextReplace } as const;
}

function getImportDeclaration(
node: TSESTree.ClassDeclaration,
module: string,
): TSESTree.ImportDeclaration | undefined {
let parentNode: TSESTree.Node | undefined = node;

while ((parentNode = parentNode.parent)) {
if (parentNode.type !== 'Program') continue;

return parentNode.body.find(
(node) => isImportDeclaration(node) && node.source.value === module,
) as TSESTree.ImportDeclaration | undefined;
}

return parentNode;
}

function getImportFix(
node: TSESTree.ClassDeclaration,
fixer: TSESLint.RuleFixer,
) {
const importDeclaration = getImportDeclaration(
node,
ANGULAR_CORE_MODULE_PATH,
);

if (!importDeclaration?.specifiers.length) {
return fixer.insertTextAfterRange(
[0, 0],
`import { ${PIPE_TRANSFORM} } from '${ANGULAR_CORE_MODULE_PATH}';\n`,
);
}

const lastImportSpecifier = getLast(importDeclaration.specifiers);

return fixer.insertTextAfter(lastImportSpecifier, `, ${PIPE_TRANSFORM}`);
}

function getLast<T extends readonly unknown[]>(items: T): T[0] {
return items.slice(-1)[0];
}
4 changes: 2 additions & 2 deletions packages/eslint-plugin/src/utils/utils.ts
Expand Up @@ -201,7 +201,7 @@ export function isMemberExpression(
return node.type === 'MemberExpression';
}

function isClassDeclaration(
export function isClassDeclaration(
node: TSESTree.Node,
): node is TSESTree.ClassDeclaration {
return node.type === 'ClassDeclaration';
Expand Down Expand Up @@ -233,7 +233,7 @@ export function isTemplateLiteral(
return node.type === 'TemplateLiteral';
}

function isImportDeclaration(
export function isImportDeclaration(
node: TSESTree.Node,
): node is TSESTree.ImportDeclaration {
return node.type === 'ImportDeclaration';
Expand Down
Expand Up @@ -20,65 +20,96 @@ const messageId: MessageIds = 'usePipeTransformInterface';
ruleTester.run(RULE_NAME, rule, {
valid: [
`
@Component({ template: 'test' })
export class TestComponent {}
@Pipe({ name: 'test' })
export class TestPipe implements PipeTransform {
transform(value: string) {}
}
`,
`
@OtherDecorator()
@Pipe({ name: 'test' })
@OtherDecorator() @Pipe({ name: 'test' })
export class TestPipe implements PipeTransform {
transform(value: string) {}
}
`,
`
@Pipe({ name: 'test' })
export class TestPipe implements ng.PipeTransform {
transform(value: string) {}
}
`,
`,
],
invalid: [
convertAnnotatedSourceToFailureCase({
description:
'it should fail if a class is decorated with @Pipe and has no interface implemented',
description: 'it should fail if a Pipe has no interface implemented',
annotatedSource: `
@Pipe({ name: 'test' })
export class TestPipe {
~~~~~
~~~~~~~~
transform(value: string) {}
}
~
`,
`,
messageId,
annotatedOutput: `import { PipeTransform } from '@angular/core';
@Pipe({ name: 'test' })
export class TestPipe implements PipeTransform {
~~~~~~~~
transform(value: string) {}
}
`,
}),
convertAnnotatedSourceToFailureCase({
description:
'it should fail if a class is decorated with @Pipe and does not implement the PipeTransform interface',
'it should fail if a Pipe implements a interface, but not the PipeTransform',
annotatedSource: `
import { HttpClient } from '@angular/common/http';
import { Component,
Pipe,
Directive } from '@angular/core';
@Pipe({ name: 'test' })
export class TestPipe implements AnInterface {
~~~~~
~~~~~~~~
transform(value: string) {}
}
~
`,
`,
messageId,
annotatedOutput: `
import { HttpClient } from '@angular/common/http';
import { Component,
Pipe,
Directive, PipeTransform } from '@angular/core';
@Pipe({ name: 'test' })
export class TestPipe implements AnInterface, PipeTransform {
~~~~~~~~
transform(value: string) {}
}
`,
}),
convertAnnotatedSourceToFailureCase({
description:
'it should fail if a class is decorated with @Pipe and other decorator and does not implement the PipeTransform interface',
'it should fail if a Pipe implements interfaces, but not the PipeTransform',
annotatedSource: `
@OtherDecorator()
@Pipe({ name: 'test' })
export class TestPipe implements AnInterface {
~~~~~
import { Pipe } from '@angular/core';
@OtherDecorator() @Pipe({ name: 'test' })
export class TestPipe implements AnInterface, AnotherInterface {
~~~~~~~~
transform(value: string) {}
}
~
`,
`,
messageId,
annotatedOutput: `
import { Pipe, PipeTransform } from '@angular/core';
@OtherDecorator() @Pipe({ name: 'test' })
export class TestPipe implements AnInterface, AnotherInterface, PipeTransform {
~~~~~~~~
transform(value: string) {}
}
`,
}),
],
});
Expand Up @@ -35,9 +35,10 @@ __ROOT__/v1014-multi-project-manual-config/src/app/example.component.ts
__ROOT__/v1014-multi-project-manual-config/src/app/example.pipe.ts
6:8 error Classes decorated with @Pipe decorator should implement PipeTransform interface @angular-eslint/use-pipe-transform-interface
6:14 error Pipes should implement \`PipeTransform\` interface @angular-eslint/use-pipe-transform-interface
✖ 1 problem (1 error, 0 warnings)
1 error and 0 warnings potentially fixable with the \`--fix\` option.
__ROOT__/v1014-multi-project-manual-config/src/app/multiple-inline-templates.page.ts
Expand Down
16 changes: 5 additions & 11 deletions packages/utils/src/test-helpers.ts
@@ -1,10 +1,4 @@
/**
* TODO: expose properly from @typescript-eslint/experimental-utils
*/
import {
TestCaseError,
InvalidTestCase,
} from '@typescript-eslint/experimental-utils/dist/ts-eslint';
import { TSESLint } from '@typescript-eslint/experimental-utils';

/**
* FROM CODELYZER
Expand Down Expand Up @@ -141,7 +135,7 @@ export function convertAnnotatedSourceToFailureCase<T extends string>({
data?: Record<string, any>;
options?: any;
annotatedOutput?: string;
}): InvalidTestCase<T, typeof options> {
}): TSESLint.InvalidTestCase<T, typeof options> {
if (!messageId && (!messages || !messages.length)) {
throw new Error(
'Either `messageId` or `messages` is required when configuring a failure case',
Expand All @@ -158,7 +152,7 @@ export function convertAnnotatedSourceToFailureCase<T extends string>({
}

let parsedSource = '';
const errors: TestCaseError<T>[] = messages.map(
const errors: TSESLint.TestCaseError<T>[] = messages.map(
({ char: currentValueChar, messageId }) => {
const otherChars = messages
.filter(({ char }) => char !== currentValueChar)
Expand All @@ -180,7 +174,7 @@ export function convertAnnotatedSourceToFailureCase<T extends string>({
);
}

const error: TestCaseError<T> = {
const error: TSESLint.TestCaseError<T> = {
messageId,
line: startPosition.line + 1,
column: startPosition.character + 1,
Expand All @@ -197,7 +191,7 @@ export function convertAnnotatedSourceToFailureCase<T extends string>({
},
);

const invalidTestCase: InvalidTestCase<T, typeof options> = {
const invalidTestCase: TSESLint.InvalidTestCase<T, typeof options> = {
code: parsedSource,
options,
errors,
Expand Down

0 comments on commit e3f4db6

Please sign in to comment.