diff --git a/src/rules/no-redundant-pipe.ts b/src/rules/no-redundant-pipe.ts new file mode 100644 index 0000000..a24bf7a --- /dev/null +++ b/src/rules/no-redundant-pipe.ts @@ -0,0 +1,113 @@ +import * as NonEmptyArray from "fp-ts/NonEmptyArray"; +import { AST_NODE_TYPES } from "@typescript-eslint/experimental-utils"; +import { flow, pipe } from "fp-ts/function"; +import * as O from "fp-ts/Option"; +import { + contextUtils, + createRule, + createSequenceExpressionFromCallExpressionWithExpressionArgs, + getCallExpressionWithExpressionArgs, + prettyPrint, + CallExpressionWithExpressionArgs, +} from "../utils"; + +const errorMessages = { + redundantPipeWithSingleArg: + "pipe can be removed because it takes only one argument", + redundantPipeWithSingleArgInsidePipe: + "pipe can be removed because it is used as the first argument inside another pipe", +}; + +export default createRule({ + name: "no-redundant-pipe", + meta: { + type: "suggestion", + fixable: "code", + hasSuggestions: true, + schema: [], + docs: { + description: "Remove redundant uses of pipe", + recommended: "warn", + }, + messages: { + ...errorMessages, + removePipe: "remove pipe", + }, + }, + defaultOptions: [], + create(context) { + const { isPipeExpression } = contextUtils(context); + + const getPipeCallExpressionWithExpressionArgs = flow( + O.fromPredicate(isPipeExpression), + /** + * We ignore pipe calls which contain a spread argument because these are never invalid. + */ + O.chain(getCallExpressionWithExpressionArgs) + ); + + type RedundantPipeCallAndMessage = { + redundantPipeCall: CallExpressionWithExpressionArgs; + errorMessageId: keyof typeof errorMessages; + }; + + const getRedundantPipeCall = ( + pipeCall: CallExpressionWithExpressionArgs + ): O.Option => { + const firstArg = pipe(pipeCall.args, NonEmptyArray.head); + + if (pipeCall.args.length === 1) { + const result: RedundantPipeCallAndMessage = { + redundantPipeCall: pipeCall, + errorMessageId: "redundantPipeWithSingleArg", + }; + return O.some(result); + } else if (firstArg.type === AST_NODE_TYPES.CallExpression) { + return pipe( + getPipeCallExpressionWithExpressionArgs(firstArg), + O.map( + (redundantPipeCall): RedundantPipeCallAndMessage => ({ + redundantPipeCall, + errorMessageId: "redundantPipeWithSingleArgInsidePipe", + }) + ) + ); + } else { + return O.none; + } + }; + + return { + CallExpression(node) { + pipe( + node, + getPipeCallExpressionWithExpressionArgs, + O.chain(getRedundantPipeCall), + O.map(({ redundantPipeCall, errorMessageId }) => { + context.report({ + node: redundantPipeCall.node, + messageId: errorMessageId, + suggest: [ + { + messageId: "removePipe", + fix(fixer) { + const sequenceExpression = + createSequenceExpressionFromCallExpressionWithExpressionArgs( + redundantPipeCall + ); + return [ + fixer.replaceText( + redundantPipeCall.node, + prettyPrint(sequenceExpression) + ), + ]; + }, + }, + ], + }); + }) + ); + }, + }; + }, +}); diff --git a/src/utils.ts b/src/utils.ts index ad821a2..1c9b072 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -362,6 +362,18 @@ export const contextUtils = < ); } + function isPipeExpression(node: TSESTree.CallExpression): boolean { + return pipe( + node, + calleeIdentifier, + option.exists( + (callee) => + callee.name === "pipe" && + isIdentifierImportedFrom(callee, /fp-ts\//, context) + ) + ); + } + function isPipeOrFlowExpression(node: TSESTree.CallExpression): boolean { return pipe( node, @@ -464,6 +476,7 @@ export const contextUtils = < return { addNamedImportIfNeeded, removeImportDeclaration, + isPipeExpression, isFlowExpression, isPipeOrFlowExpression, isIdentifierImportedFrom, @@ -488,7 +501,7 @@ const getArgumentExpression = ( const checkIsArgumentExpression = O.getRefinement(getArgumentExpression); -type CallExpressionWithExpressionArgs = { +export type CallExpressionWithExpressionArgs = { node: TSESTree.CallExpression; args: NonEmptyArray.NonEmptyArray; }; diff --git a/tests/rules/no-redundant-pipe.test.ts b/tests/rules/no-redundant-pipe.test.ts new file mode 100644 index 0000000..b56114c --- /dev/null +++ b/tests/rules/no-redundant-pipe.test.ts @@ -0,0 +1,169 @@ +import rule from "../../src/rules/no-redundant-pipe"; +import { ESLintUtils } from "@typescript-eslint/experimental-utils"; + +const ruleTester = new ESLintUtils.RuleTester({ + parser: "@typescript-eslint/parser", + parserOptions: { + sourceType: "module", + }, +}); + +ruleTester.run("no-redundant-pipe", rule, { + valid: [ + `import { pipe } from "fp-ts/function" + pipe(...x); + `, + `import { pipe } from "fp-ts/function" + pipe(value, fn); + `, + `import { pipe } from "fp-ts/function" + pipe(value, pipe(value2, fn)); + `, + ], + invalid: [ + { + code: ` +import { pipe } from "fp-ts/function" +pipe(value); +`, + errors: [ + { + messageId: "redundantPipeWithSingleArg", + suggestions: [ + { + messageId: "removePipe", + output: ` +import { pipe } from "fp-ts/function" +value; +`, + }, + ], + }, + ], + }, + { + code: ` +import { pipe } from "fp-ts/function" +pipe(pipe(pipe(value, fn1), fn2), fn3); +`, + errors: [ + { + messageId: "redundantPipeWithSingleArgInsidePipe", + suggestions: [ + { + messageId: "removePipe", + output: ` +import { pipe } from "fp-ts/function" +pipe(pipe(value, fn1), fn2, fn3); +`, + }, + ], + }, + { + messageId: "redundantPipeWithSingleArgInsidePipe", + suggestions: [ + { + messageId: "removePipe", + output: ` +import { pipe } from "fp-ts/function" +pipe(pipe(value, fn1, fn2), fn3); +`, + }, + ], + }, + ], + }, + { + code: ` +import { pipe } from "fp-ts/function" +pipe(pipe(value, fn1), fn2, fn3); +`, + errors: [ + { + messageId: "redundantPipeWithSingleArgInsidePipe", + suggestions: [ + { + messageId: "removePipe", + output: ` +import { pipe } from "fp-ts/function" +pipe(value, fn1, fn2, fn3); +`, + }, + ], + }, + ], + }, + { + code: ` +import { pipe } from "fp-ts/function" +pipe( + // foo + pipe(value, fn1), + fn2 +);`, + errors: [ + { + messageId: "redundantPipeWithSingleArgInsidePipe", + suggestions: [ + { + messageId: "removePipe", + output: ` +import { pipe } from "fp-ts/function" +pipe( + // foo + value, fn1, + fn2 +);`, + }, + ], + }, + ], + }, + { + code: ` +import { pipe } from "fp-ts/function" +pipe( + value, +); +`, + errors: [ + { + messageId: "redundantPipeWithSingleArg", + suggestions: [ + { + messageId: "removePipe", + output: ` +import { pipe } from "fp-ts/function" +value; +`, + }, + ], + }, + ], + }, + { + code: ` +import { pipe } from "fp-ts/function" +pipe( + // foo + value, +); +`, + errors: [ + { + messageId: "redundantPipeWithSingleArg", + suggestions: [ + { + messageId: "removePipe", + // TODO: ideally we would preserve the comment here but I'm not sure how + output: ` +import { pipe } from "fp-ts/function" +value; +`, + }, + ], + }, + ], + }, + ], +});