├── .eslintrc.json ├── .github ├── dependabot.yml ├── release-drafter.yml └── workflows │ ├── auto-approve-prs.yml │ └── release-drafter.yml ├── .gitignore ├── .mergify.yml ├── README.md ├── ci ├── pipeline.yml └── tasks │ ├── release.sh │ ├── release.yml │ ├── test.sh │ └── test.yml ├── docs └── rules │ ├── no-discarded-pure-expression.md │ ├── no-lib-imports.md │ ├── no-module-imports.md │ ├── no-pipeable.md │ ├── no-redundant-flow.md │ ├── prefer-bimap.md │ ├── prefer-chain.md │ └── prefer-traverse.md ├── jest.config.js ├── package.json ├── src ├── index.ts ├── rules │ ├── no-discarded-pure-expression.ts │ ├── no-lib-imports.ts │ ├── no-module-imports.ts │ ├── no-pipeable.ts │ ├── no-redundant-flow.ts │ ├── prefer-bimap.ts │ ├── prefer-chain.ts │ └── prefer-traverse.ts └── utils.ts ├── tests ├── fixtures │ └── fp-ts-project │ │ ├── .gitignore │ │ ├── file.ts │ │ ├── package.json │ │ ├── react.tsx │ │ ├── tsconfig.json │ │ └── yarn.lock └── rules │ ├── no-discarded-pure-expression.test.ts │ ├── no-lib-imports.test.ts │ ├── no-module-imports.test.ts │ ├── no-pipeable.test.ts │ ├── no-redundant-flow.test.ts │ ├── prefer-bimap.test.ts │ ├── prefer-chain.test.ts │ └── prefer-traverse.test.ts ├── tsconfig.json └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": ["fp-ts"], 4 | "extends": "plugin:fp-ts/all", 5 | "rules": { 6 | "fp-ts/no-module-imports": [ 7 | "error", 8 | { 9 | "allowTypes": true 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES 5 | 6 | categories: 7 | - title: "🐞 Bug fixes" 8 | labels: 9 | - "bug" 10 | - title: "🔧 Dependency updates" 11 | labels: 12 | - "dependencies" 13 | - title: "🧹 Chores" 14 | labels: 15 | - "chore" 16 | -------------------------------------------------------------------------------- /.github/workflows/auto-approve-prs.yml: -------------------------------------------------------------------------------- 1 | name: Auto-approve dependabot PRs 2 | on: pull_request 3 | 4 | jobs: 5 | auto-approve-prs: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: auto approve dependabot pull requests 9 | uses: hmarr/auto-approve-action@v2.0.0 10 | if: 11 | github.actor == 'dependabot[bot]' || github.actor == 12 | 'dependabot-preview[bot]' 13 | with: 14 | github-token: "${{ secrets.GITHUB_TOKEN }}" 15 | -------------------------------------------------------------------------------- /.github/workflows/release-drafter.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v5 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # Snowpack dependency directory (https://snowpack.dev/) 45 | web_modules/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | .parcel-cache 78 | 79 | # Next.js build output 80 | .next 81 | out 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and not Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | 108 | # Stores VSCode versions used for testing VSCode extensions 109 | .vscode-test 110 | 111 | # yarn v2 112 | .yarn/cache 113 | .yarn/unplugged 114 | .yarn/build-state.yml 115 | .yarn/install-state.gz 116 | .pnp.* 117 | 118 | # project 119 | lib 120 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: CI passes and is @types package from dependabot 3 | conditions: 4 | - "#status-success=2" 5 | - author~=^dependabot(|-preview)\[bot\]$ 6 | - title~=^Bump @types/(\w+) from .*$ 7 | - base=main 8 | actions: 9 | merge: 10 | method: merge 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![badge](https://concourse.our.buildo.io/api/v1/teams/buildo/pipelines/eslint-plugin-fp-ts/badge) 2 | ![npm](https://img.shields.io/npm/dm/eslint-plugin-fp-ts) 3 | ![npm](https://img.shields.io/npm/v/eslint-plugin-fp-ts) 4 | 5 | # eslint-plugin-fp-ts 6 | 7 | A collection of ESLint rules for [fp-ts](https://github.com/gcanti/fp-ts) 8 | 9 | ## Installation 10 | 11 | Assuming [ESlint](https://github.com/eslint/eslint) is installed locally in your 12 | project: 13 | 14 | ```sh 15 | # npm 16 | npm install --save-dev eslint-plugin-fp-ts 17 | 18 | # yarn 19 | yarn add --dev eslint-plugin-fp-ts 20 | ``` 21 | 22 | Then enable the plugin in your `.eslintrc` config 23 | 24 | ```json 25 | { 26 | "plugins": ["fp-ts"] 27 | } 28 | ``` 29 | 30 | and enable the rules you want, for example 31 | 32 | ```json 33 | { 34 | "plugins": ["fp-ts"], 35 | "rules": { 36 | "fp-ts/no-lib-imports": "error" 37 | } 38 | } 39 | ``` 40 | 41 | If you want to enable rules that require type information (see the table below), 42 | then you will also need to add some extra info: 43 | 44 | ```js 45 | module.exports = { 46 | plugins: ["fp-ts"], 47 | parserOptions: { 48 | tsconfigRootDir: __dirname, 49 | project: ["./tsconfig.json"], 50 | }, 51 | rules: { 52 | "fp-ts/no-discarded-pure-expression": "error", 53 | }, 54 | }; 55 | ``` 56 | 57 | If your project is a multi-package monorepo, you can follow the instructions 58 | [here](https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/MONOREPO.md). 59 | 60 | > ⚠️ Note that you will need to make the ESLint config file a .js file, due to 61 | > the need of setting `tsconfigRootDir` to `__dirname`. This is necessary to 62 | > make both editor integrations and the CLI work with the correct path. More 63 | > info here: https://github.com/typescript-eslint/typescript-eslint/issues/251 64 | 65 | ## List of supported rules 66 | 67 | | Rule | Description | Fixable | Requires type-checking | 68 | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | :-----: | :--------------------: | 69 | | [fp-ts/no-lib-imports](docs/rules/no-lib-imports.md) | Disallow imports from `fp-ts/lib/` | 🔧 | | 70 | | [fp-ts/no-pipeable](docs/rules/no-pipeable.md) | Disallow imports from the `pipeable` module | 🔧 | | 71 | | [fp-ts/no-module-imports](docs/rules/no-module-imports.md) | Disallow imports from fp-ts modules | 🔧 | | 72 | | [fp-ts/no-redundant-flow](docs/rules/no-redundant-flow.md) | Remove redundant uses of `flow` | 🔧 | | 73 | | [fp-ts/prefer-traverse](docs/rules/prefer-traverse.md) | Replace `map` + `sequence` with `traverse` | 💡 | | 74 | | [fp-ts/prefer-chain](docs/rules/prefer-chain.md) | Replace `map` + `flatten` with `chain` | 💡 | | 75 | | [fp-ts/prefer-bimap](docs/rules/prefer-bimap.md) | Replace `map` + `mapLeft` with `bimap` | 💡 | | 76 | | [fp-ts/no-discarded-pure-expression](docs/rules/no-discarded-pure-expression.md) | Disallow expressions returning pure data types (like `Task` or `IO`) in statement position | 💡 | 🦄 | 77 | 78 | ### Fixable legend: 79 | 80 | 🔧 = auto-fixable via `--fix` (or via the appropriate editor configuration) 81 | 82 | 💡 = provides in-editor suggestions that need to be applied manually 83 | 84 | ## Configurations 85 | 86 | ### Recommended 87 | 88 | The plugin defines a `recommended` configuration with some reasonable defaults. 89 | 90 | To use it, add it to the `extends` clause of your `.eslintrc` file: 91 | 92 | ```json 93 | { 94 | "extends": ["plugin:fp-ts/recommended"] 95 | } 96 | ``` 97 | 98 | The rules included in this configuration are: 99 | 100 | - [fp-ts/no-lib-imports](docs/rules/no-lib-imports.md) 101 | - [fp-ts/no-pipeable](docs/rules/no-pipeable.md) 102 | 103 | ### Recommended requiring type-checking 104 | 105 | We also provide a `recommended-requiring-type-checking` which includes 106 | recommended rules which require type information. 107 | 108 | This configuration needs to be included _in addition_ to the `recommended` one: 109 | 110 | ``` 111 | { 112 | "extends": [ 113 | "plugin:fp-ts/recommended", 114 | "plugin:fp-ts/recommended-requiring-type-checking" 115 | ] 116 | } 117 | ``` 118 | 119 | > 👉 You can read more about linting with type information, including 120 | > performance considerations 121 | > [here](https://github.com/typescript-eslint/typescript-eslint/blob/master/docs/getting-started/linting/TYPED_LINTING.md) 122 | 123 | ### All 124 | 125 | The plugin also defines an `all` configuration which includes every available 126 | rule. 127 | 128 | To use it, add it to the `extends` clause of your `.eslintrc` file: 129 | 130 | ```json 131 | { 132 | "extends": ["plugin:fp-ts/all"] 133 | } 134 | ``` 135 | -------------------------------------------------------------------------------- /ci/pipeline.yml: -------------------------------------------------------------------------------- 1 | resource_types: 2 | - name: pull-request 3 | type: docker-image 4 | source: 5 | repository: teliaoss/github-pr-resource 6 | 7 | resources: 8 | - name: main 9 | type: git 10 | icon: github 11 | webhook_token: 0gd4XZNL4Y94zYDLql3C 12 | check_every: 24h 13 | source: 14 | uri: git@github.com:buildo/eslint-plugin-fp-ts 15 | branch: main 16 | private_key: ((private-key)) 17 | 18 | - name: release 19 | type: git 20 | icon: github 21 | webhook_token: 0gd4XZNL4Y94zYDLql3C 22 | check_every: 24h 23 | source: 24 | uri: git@github.com:buildo/eslint-plugin-fp-ts 25 | branch: main 26 | tag_filter: v* 27 | private_key: ((private-key)) 28 | 29 | - name: pr 30 | type: pull-request 31 | icon: github 32 | webhook_token: 0gd4XZNL4Y94zYDLql3C 33 | check_every: 24h 34 | source: 35 | repository: buildo/eslint-plugin-fp-ts 36 | access_token: ((github-token)) 37 | 38 | jobs: 39 | - name: update-pipeline 40 | plan: 41 | - get: eslint-plugin-fp-ts 42 | resource: main 43 | trigger: true 44 | - set_pipeline: self 45 | file: eslint-plugin-fp-ts/ci/pipeline.yml 46 | 47 | - name: release 48 | plan: 49 | - get: eslint-plugin-fp-ts 50 | resource: release 51 | trigger: true 52 | - task: release 53 | file: eslint-plugin-fp-ts/ci/tasks/release.yml 54 | 55 | - name: pr 56 | public: true 57 | plan: 58 | - get: eslint-plugin-fp-ts 59 | resource: pr 60 | trigger: true 61 | version: every 62 | - put: pr 63 | params: 64 | path: eslint-plugin-fp-ts 65 | status: pending 66 | context: concourse 67 | - task: test 68 | file: eslint-plugin-fp-ts/ci/tasks/test.yml 69 | on_success: 70 | put: pr 71 | params: 72 | path: eslint-plugin-fp-ts 73 | status: success 74 | context: concourse 75 | on_failure: 76 | put: pr 77 | params: 78 | path: eslint-plugin-fp-ts 79 | status: failure 80 | context: concourse 81 | -------------------------------------------------------------------------------- /ci/tasks/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | LATEST_TAG=$(git describe --tags) 6 | VERSION=${LATEST_TAG#v} 7 | 8 | mkdir -p $HOME/.ssh 9 | ssh-keyscan github.com >> $HOME/.ssh/known_hosts 10 | echo "$SSH_PRIVATE_KEY" > $HOME/.ssh/id_rsa 11 | chmod 400 $HOME/.ssh/id_rsa 12 | 13 | git config --global user.email "nemobot@buildo.io" 14 | git config --global user.name "Nemobot" 15 | 16 | yarn install --no-progress 17 | yarn version --no-git-tag-version --new-version $VERSION 18 | 19 | git add . 20 | git commit -m "v$VERSION" 21 | git push origin HEAD:main 22 | 23 | echo "//registry.yarnpkg.com/:_authToken=$NPM_TOKEN" >> $HOME/.npmrc 24 | yarn publish --non-interactive 25 | -------------------------------------------------------------------------------- /ci/tasks/release.yml: -------------------------------------------------------------------------------- 1 | platform: linux 2 | 3 | image_resource: 4 | type: docker-image 5 | source: 6 | repository: node 7 | 8 | inputs: 9 | - name: eslint-plugin-fp-ts 10 | 11 | caches: 12 | - path: eslint-plugin-fp-ts/node_modules 13 | 14 | params: 15 | NPM_TOKEN: ((npm_token)) 16 | SSH_PRIVATE_KEY: ((private-key)) 17 | 18 | run: 19 | path: ci/tasks/release.sh 20 | dir: eslint-plugin-fp-ts 21 | -------------------------------------------------------------------------------- /ci/tasks/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | yarn install --no-progress 6 | yarn preversion 7 | -------------------------------------------------------------------------------- /ci/tasks/test.yml: -------------------------------------------------------------------------------- 1 | platform: linux 2 | 3 | image_resource: 4 | type: docker-image 5 | source: 6 | repository: node 7 | 8 | inputs: 9 | - name: eslint-plugin-fp-ts 10 | 11 | caches: 12 | - path: eslint-plugin-fp-ts/node_modules 13 | 14 | run: 15 | path: ci/tasks/test.sh 16 | dir: eslint-plugin-fp-ts 17 | -------------------------------------------------------------------------------- /docs/rules/no-discarded-pure-expression.md: -------------------------------------------------------------------------------- 1 | # Disallow expressions returning pure data types (like `Task` or `IO`) where `void` or `unknown` is expected or in statement position (fp-ts/no-discarded-pure-expression) 2 | 3 | Expressions which return a pure data type, such as `IO`, `Task` and their 4 | variants, should normally be passed as an argument, returned, or run. 5 | 6 | Failing to do so causes the program represented by `IO` or `Task` to never be 7 | run, leading to surprising behavior which is normally difficult to debug. 8 | 9 | This rule covers two common scenarios that are common programming errors: 10 | 11 | - returning pure data types where `void` or `unknown` is expected (for instance, 12 | in event handlers) without running them 13 | 14 | - writing expressions that return pure data types in statement position (without 15 | returning them or running them) 16 | 17 | **💡 Fixable**: This rule provides in-editor suggested fixes. 18 | 19 | ## Rule Details 20 | 21 | Examples of **incorrect** code for this rule: 22 | 23 | ```ts 24 | import { task } from "fp-ts"; 25 | 26 | declare const myCommand: (n: number) => Task; 27 | 28 | function woops() { 29 | myCommand(1); // the task will never run, since is not being run nor returned 30 | } 31 | ``` 32 | 33 | ```ts 34 | declare const MyComponent: (props: { handler: () => void }) => JSX.Element; 35 | 36 | declare const myCommand: (n: number) => Task; 37 | 38 | export function Foo() { 39 | return ( 40 | myCommand(1)} // bug, the Task will never execute 42 | />; 43 | ) 44 | } 45 | ``` 46 | 47 | ```ts 48 | import { task } from "fp-ts"; 49 | 50 | declare function foo(arg1: number, callbackUnknown: () => unknown): void; 51 | 52 | declare const myCommand: (n: number) => Task; 53 | 54 | foo( 55 | 2, 56 | () => myCommand(1) // bug, the Task will never execute 57 | ); 58 | ``` 59 | 60 | Examples of **correct** code for this rule: 61 | 62 | ```ts 63 | import { task } from "fp-ts"; 64 | 65 | declare const myCommand: (n: number) => Task; 66 | 67 | function ok() { 68 | return myCommand(1); 69 | } 70 | ``` 71 | 72 | ```ts 73 | declare const MyComponent: (props: { handler: () => void }) => JSX.Element; 74 | 75 | declare const myCommand: (n: number) => Task; 76 | 77 | export function Foo() { 78 | return ( 79 | myCommand(1)()} 81 | />; 82 | ) 83 | } 84 | ``` 85 | 86 | ```ts 87 | import { task } from "fp-ts"; 88 | 89 | declare function foo(arg1: number, callbackUnknown: () => unknown): void; 90 | 91 | declare const myCommand: (n: number) => Task; 92 | 93 | foo(2, () => myCommand(1)()); 94 | ``` 95 | -------------------------------------------------------------------------------- /docs/rules/no-lib-imports.md: -------------------------------------------------------------------------------- 1 | # Disallow imports from 'fp-ts/lib' (fp-ts/no-lib-imports) 2 | 3 | Disallow imports from the `fp-ts/lib` module. `fp-ts` exports modules directly 4 | without the `lib` prefix, which improves ergonomics and tree-shakeability. 5 | 6 | > Note: this change was introduced in fp-ts 2.8.0. If you are using an older 7 | > version, do not enable this rule 8 | 9 | **🔧 Fixable**: This rule is automatically fixable using the `--fix` flag on the 10 | command line. 11 | 12 | ## Rule Details 13 | 14 | Example of **incorrect** code for this rule: 15 | 16 | ```ts 17 | import { Option } from "fp-ts/lib/Option"; 18 | ``` 19 | 20 | Example of **correct** code for this rule: 21 | 22 | ```ts 23 | import { Option } from "fp-ts/Option"; 24 | import { option } from "fp-ts"; 25 | ``` 26 | -------------------------------------------------------------------------------- /docs/rules/no-module-imports.md: -------------------------------------------------------------------------------- 1 | # Disallow imports from fp-ts modules (fp-ts/no-module-imports) 2 | 3 | Disallow imports from fp-ts modules, such as `fp-ts/Option`. 4 | 5 | The `function` module is an exception and it's allowed nonetheless, since it's 6 | not exported from fp-ts's index. 7 | 8 | **🔧 Fixable**: This rule is automatically fixable using the `--fix` flag on the 9 | command line. 10 | 11 | ## Rule Details 12 | 13 | Possible configurations: 14 | 15 | _`allowTypes`_ (boolean) 16 | 17 | - `false`: disallow importing any member from a module. This is the default 18 | value. 19 | - `true`: allow importing type members from a module. 20 | 21 | _`allowedModules`_ (string[]) 22 | 23 | List of allowed modules, defaults to `["function", "pipeable"]`. 24 | 25 | Example of **incorrect** code for this rule, when configured with 26 | `{ allowTypes: false }` 27 | 28 | ```ts 29 | import { Option, some } from "fp-ts/Option"; 30 | 31 | const x: Option = some(42); 32 | ``` 33 | 34 | Example of **correct** code for this rule, when configured with 35 | `{ allowTypes: false }`: 36 | 37 | ```ts 38 | import { option } from "fp-ts"; 39 | 40 | const x: option.Option = option.some(42); 41 | ``` 42 | 43 | Example of **correct** code for this rule, when configured with 44 | `{ allowTypes: true }` 45 | 46 | ```ts 47 | import { option } from "fp-ts"; 48 | import { Option } from "fp-ts/Option"; 49 | 50 | const x: Option = option.some(42); 51 | ``` 52 | 53 | Example of **correct** code for this rule, when configured with 54 | `{ allowedModules: ["Option"] }`: 55 | 56 | ```ts 57 | import { Option, some } from "fp-ts/Option"; 58 | 59 | const x: Option = some(42); 60 | ``` 61 | -------------------------------------------------------------------------------- /docs/rules/no-pipeable.md: -------------------------------------------------------------------------------- 1 | # Disallow imports from the 'pipeable' module (fp-ts/no-pipeable) 2 | 3 | Disallow imports from the `pipeable` module. `pipeable` has been deprecated and 4 | it will be removed in future versions of fp-ts. It's recommended to import 5 | `pipe` from the `function` module instead. 6 | 7 | **🔧 Fixable**: This rule is automatically fixable using the `--fix` flag on the 8 | command line. 9 | 10 | > Note: the autofix is available only when importing `pipe` 11 | 12 | ## Rule Details 13 | 14 | Example of **incorrect** code for this rule: 15 | 16 | ```ts 17 | import { pipe } from "fp-ts/pipeable"; 18 | ``` 19 | 20 | Example of **correct** code for this rule: 21 | 22 | ```ts 23 | import { pipe } from "fp-ts/function"; 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/rules/no-redundant-flow.md: -------------------------------------------------------------------------------- 1 | # Remove redundant uses of flow (fp-ts/no-redundant-flow) 2 | 3 | Suggest removing `flow` when it only has one argument. This can happen after a 4 | refactoring that removed some combinators from a flow expression. 5 | 6 | **💡 Fixable**: This rule provides in-editor suggested fixes. 7 | 8 | ## Rule Details 9 | 10 | Example of **incorrect** code for this rule: 11 | 12 | ```ts 13 | import { flow } from "fp-ts/function"; 14 | import { some, Option } from "fp-ts/Option"; 15 | 16 | const f: (n: number): Option = flow(some); 17 | ``` 18 | 19 | Example of **correct** code for this rule: 20 | 21 | ```ts 22 | import { flow } from "fp-ts/function"; 23 | import { some, filter, Option } from "fp-ts/Option"; 24 | 25 | const f: (n: number): Option = 26 | flow( 27 | some, 28 | filter((n) => n > 2) 29 | ); 30 | ``` 31 | -------------------------------------------------------------------------------- /docs/rules/prefer-bimap.md: -------------------------------------------------------------------------------- 1 | # Replace map + mapLeft with bimap (fp-ts/prefer-bimap) 2 | 3 | Suggest replacing the combination of `map` followed by `mapLeft` (or vice-versa) 4 | with `bimap`. 5 | 6 | **💡 Fixable**: This rule provides in-editor suggested fixes. 7 | 8 | ## Rule Details 9 | 10 | Examples of **incorrect** code for this rule: 11 | 12 | ```ts 13 | import { pipe } from "fp-ts/function"; 14 | import { either } from "fp-ts"; 15 | 16 | pipe( 17 | getResult(), 18 | either.map((a) => a + 1), 19 | either.mapLeft((e) => e + 1) 20 | ); 21 | ``` 22 | 23 | ```ts 24 | import { pipe } from "fp-ts/function"; 25 | import { either } from "fp-ts"; 26 | 27 | pipe( 28 | getResult(), 29 | either.mapLeft((e) => e + 1), 30 | either.map((a) => a + 1) 31 | ); 32 | ``` 33 | 34 | Example of **correct** code for this rule: 35 | 36 | ```ts 37 | import { pipe } from "fp-ts/function"; 38 | import { either } from "fp-ts"; 39 | 40 | pipe( 41 | getResult(), 42 | either.bimap( 43 | (e) => e + 1, 44 | (a) => a + 1 45 | ) 46 | ); 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/rules/prefer-chain.md: -------------------------------------------------------------------------------- 1 | # Replace map + flatten with chain (fp-ts/prefer-chain) 2 | 3 | Suggest replacing the combination of `map` (or `mapWithIndex`) followed by 4 | `flatten` with `chain` (or `chainWithIndex`). 5 | 6 | **💡 Fixable**: This rule provides in-editor suggested fixes. 7 | 8 | ## Rule Details 9 | 10 | Examples of **incorrect** code for this rule: 11 | 12 | ```ts 13 | import { pipe } from "fp-ts/function"; 14 | import { map, flatten } from "fp-ts/Array"; 15 | 16 | pipe( 17 | [1, 2, 3], 18 | map((n) => [n, n + 1]), 19 | flatten 20 | ); 21 | ``` 22 | 23 | ```ts 24 | import { pipe } from "fp-ts/function"; 25 | import { mapWithIndex, flatten } from "fp-ts/Array"; 26 | 27 | pipe( 28 | [1, 2, 3], 29 | mapWithIndex((i, n) => [i, n]), 30 | flatten 31 | ); 32 | ``` 33 | 34 | Examples of **correct** code for this rule: 35 | 36 | ```ts 37 | import { pipe } from "fp-ts/function"; 38 | import { chain } from "fp-ts/Array"; 39 | 40 | pipe( 41 | [1, 2, 3], 42 | chain((n) => [n, n + 1]) 43 | ); 44 | ``` 45 | 46 | ```ts 47 | import { pipe } from "fp-ts/function"; 48 | import { chainWithIndex } from "fp-ts/Array"; 49 | 50 | pipe( 51 | [1, 2, 3], 52 | chainWithIndex((i, n) => [i, n]) 53 | ); 54 | ``` 55 | -------------------------------------------------------------------------------- /docs/rules/prefer-traverse.md: -------------------------------------------------------------------------------- 1 | # Replace map + sequence with traverse (fp-ts/prefer-traverse) 2 | 3 | Suggest replacing the combination of `map` (or `mapWithIndex`) followed by 4 | `sequence` with `traverse` (or `traverseWithIndex`). 5 | 6 | **💡 Fixable**: This rule provides in-editor suggested fixes. 7 | 8 | ## Rule Details 9 | 10 | Examples of **incorrect** code for this rule: 11 | 12 | ```ts 13 | import { pipe } from "fp-ts/pipeable"; 14 | import { map, sequence } from "fp-ts/Array"; 15 | import { option, some } from "fp-ts/Option"; 16 | 17 | pipe( 18 | [1, 2, 3], 19 | map((n) => some(n)), 20 | sequence(option) 21 | ); 22 | ``` 23 | 24 | ```ts 25 | import { pipe } from "fp-ts/pipeable"; 26 | import { mapWithIndex, sequence } from "fp-ts/Array"; 27 | import { option, some } from "fp-ts/Option"; 28 | 29 | pipe( 30 | [1, 2, 3], 31 | mapWithIndex((i) => some(i)), 32 | sequence(option) 33 | ); 34 | ``` 35 | 36 | Examples of **correct** code for this rule: 37 | 38 | ```ts 39 | import { pipe } from "fp-ts/pipeable"; 40 | import { traverse } from "fp-ts/Array"; 41 | import { option, some } from "fp-ts/Option"; 42 | 43 | pipe( 44 | [1, 2, 3], 45 | traverse(option)((n) => some(n)) 46 | ); 47 | ``` 48 | 49 | ```ts 50 | import { pipe } from "fp-ts/pipeable"; 51 | import { traverseWithIndex } from "fp-ts/Array"; 52 | import { option, some } from "fp-ts/Option"; 53 | 54 | pipe( 55 | [1, 2, 3], 56 | traverseWithIndex(option)((i) => some(i)) 57 | ); 58 | ``` 59 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node', 3 | testRegex: ['./tests/.+\\.test\\.ts$', './tests/.+\\.spec\\.ts$'], 4 | transform: { 5 | '^.+\\.(t|j)sx?$': [ 6 | '@swc/jest', 7 | { 8 | jsc: { 9 | target: 'es2019', 10 | transform: { 11 | react: { 12 | runtime: 'automatic', 13 | }, 14 | }, 15 | }, 16 | }, 17 | ], 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-fp-ts", 3 | "version": "0.4.0", 4 | "description": "fp-ts ESLint rules", 5 | "keywords": [ 6 | "eslint", 7 | "eslintplugin", 8 | "eslint-plugin", 9 | "fp-ts" 10 | ], 11 | "author": "buildo", 12 | "main": "lib/index.js", 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/buildo/eslint-plugin-fp-ts" 16 | }, 17 | "scripts": { 18 | "prepare": "npm run build", 19 | "preversion": "yarn lint && yarn test && yarn build", 20 | "build": "rimraf lib && yarn tsc", 21 | "pretest": "yarn --cwd tests/fixtures/fp-ts-project install", 22 | "test": "jest", 23 | "lint": "eslint src/**/*.ts" 24 | }, 25 | "files": [ 26 | "lib" 27 | ], 28 | "dependencies": { 29 | "@typescript-eslint/typescript-estree": "^8.25.0", 30 | "@typescript-eslint/utils": "^8.0.0", 31 | "fp-ts": "^2.9.3", 32 | "recast": "^0.23.11" 33 | }, 34 | "devDependencies": { 35 | "@swc/core": "^1.11.5", 36 | "@swc/jest": "^0.2.37", 37 | "@types/common-tags": "^1.8.0", 38 | "@types/node": "^17.0.31", 39 | "@types/requireindex": "^1.2.0", 40 | "@typescript-eslint/parser": "^8.25.0", 41 | "@typescript-eslint/rule-tester": "^8.25.0", 42 | "common-tags": "^1.8.0", 43 | "eslint": "^9.0.0", 44 | "eslint-plugin-fp-ts": "link:./lib", 45 | "jest": "^29.7.0", 46 | "rimraf": "^6.0.1", 47 | "typescript": "^5.0" 48 | }, 49 | "peerDependencies": { 50 | "eslint": "^9.0" 51 | }, 52 | "engines": { 53 | "node": ">=0.10.0" 54 | }, 55 | "license": "MIT" 56 | } 57 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { array, record, semigroup } from "fp-ts"; 2 | import { pipe } from "fp-ts/function"; 3 | 4 | const potentialErrors = { 5 | "no-lib-imports": require("./rules/no-lib-imports").default, 6 | "no-pipeable": require("./rules/no-pipeable").default, 7 | "no-module-imports": require("./rules/no-module-imports").default, 8 | "no-discarded-pure-expression": require("./rules/no-discarded-pure-expression") 9 | .default, 10 | }; 11 | 12 | const suggestions = { 13 | "prefer-traverse": require("./rules/prefer-traverse").default, 14 | "no-redundant-flow": require("./rules/no-redundant-flow").default, 15 | "prefer-chain": require("./rules/prefer-chain").default, 16 | "prefer-bimap": require("./rules/prefer-bimap").default, 17 | }; 18 | 19 | export const rules = { 20 | ...potentialErrors, 21 | ...suggestions, 22 | }; 23 | 24 | export const configs = { 25 | recommended: { 26 | plugins: ["fp-ts"], 27 | rules: { 28 | "fp-ts/no-lib-imports": "error", 29 | "fp-ts/no-pipeable": "error", 30 | }, 31 | }, 32 | "recommended-requiring-type-checking": { 33 | plugins: ["fp-ts"], 34 | rules: { 35 | "fp-ts/no-discarded-pure-expression": "error", 36 | }, 37 | }, 38 | all: { 39 | plugins: ["fp-ts"], 40 | rules: { 41 | ...configuredRules(potentialErrors, "error"), 42 | ...configuredRules(suggestions, "warn"), 43 | }, 44 | }, 45 | }; 46 | 47 | function configuredRules( 48 | rules: Record, 49 | level: "warn" | "error" 50 | ): Record { 51 | return pipe( 52 | rules, 53 | record.keys, 54 | array.map((k) => [`fp-ts/${k}`, level]), 55 | record.fromFoldable(semigroup.getFirstSemigroup(), array.Foldable) 56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /src/rules/no-discarded-pure-expression.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ParserServices, 3 | AST_NODE_TYPES, 4 | } from "@typescript-eslint/utils"; 5 | import { array, option, readonlyArray } from "fp-ts"; 6 | import { constVoid, pipe } from "fp-ts/function"; 7 | import { Option } from "fp-ts/Option"; 8 | import ts from "typescript"; 9 | import { contextUtils, createRule, prettyPrint } from "../utils"; 10 | 11 | export default createRule({ 12 | name: "no-discarded-pure-expression", 13 | meta: { 14 | type: "problem", 15 | hasSuggestions: true, 16 | schema: [], 17 | docs: { 18 | description: 19 | "Detects pure expressions that do nothing because they're in statement position", 20 | }, 21 | messages: { 22 | pureExpressionInStatementPosition: 23 | "'{{dataType}}' is pure, so this expression does nothing in statement position. Did you forget to return it or run it?", 24 | addReturn: "return the expression", 25 | runExpression: "run the expression", 26 | discardedDataTypeJsx: 27 | "'{{jsxAttributeName}}' expects a function returning '{{expectedReturnType}}' but the expression returns a '{{dataType}}'. Did you forget to run the expression?", 28 | discardedDataTypeArgument: 29 | "The expression returns a '{{dataType}}', but the function '{{functionName}}' expects a function returning '{{expectedReturnType}}'. Did you forget to run the expression?", 30 | }, 31 | }, 32 | defaultOptions: [], 33 | create(context) { 34 | const { isFromFpTs, typeOfNode, parserServices } = contextUtils(context); 35 | 36 | const pureDataPrefixes = ["Task", "IO"]; 37 | 38 | function isPureDataType(t: ts.Type): boolean { 39 | return ( 40 | isFromFpTs(t) && 41 | pipe( 42 | pureDataPrefixes, 43 | array.some((prefix) => 44 | t.symbol?.escapedName.toString().startsWith(prefix) 45 | ) 46 | ) 47 | ); 48 | } 49 | 50 | function pureDataReturnType(t: ts.Type): Option { 51 | if (t.isUnion()) { 52 | return pipe(t.types, readonlyArray.findFirstMap(pureDataReturnType)); 53 | } 54 | return pipe( 55 | t.getCallSignatures(), 56 | readonlyArray.map((signature) => signature.getReturnType()), 57 | readonlyArray.findFirst(isPureDataType) 58 | ); 59 | } 60 | 61 | function voidOrUknownReturnType(t: ts.Type): Option { 62 | if (t.isUnion()) { 63 | return pipe( 64 | t.types, 65 | readonlyArray.findFirstMap(voidOrUknownReturnType) 66 | ); 67 | } 68 | return pipe( 69 | t.getCallSignatures(), 70 | readonlyArray.map((signature) => signature.getReturnType()), 71 | readonlyArray.findFirst( 72 | (returnType) => 73 | !!( 74 | returnType.flags & ts.TypeFlags.Void || 75 | returnType.flags & ts.TypeFlags.Unknown 76 | ) 77 | ) 78 | ); 79 | } 80 | 81 | return { 82 | ExpressionStatement(node) { 83 | if (node.expression.type !== AST_NODE_TYPES.AssignmentExpression) { 84 | pipe( 85 | node.expression, 86 | typeOfNode, 87 | option.filter((t) => { 88 | if (t.isUnion()) { 89 | return pipe(t.types, array.every(isPureDataType)); 90 | } 91 | return isPureDataType(t); 92 | }), 93 | option.fold(constVoid, (t) => { 94 | context.report({ 95 | node: node.expression, 96 | messageId: "pureExpressionInStatementPosition", 97 | data: { 98 | dataType: t.isUnion() 99 | ? t.types[0]!.symbol.escapedName 100 | : t.symbol.escapedName, 101 | }, 102 | suggest: [ 103 | { 104 | messageId: "addReturn", 105 | fix(fixer) { 106 | return fixer.insertTextBefore(node.expression, "return "); 107 | }, 108 | }, 109 | { 110 | messageId: "runExpression", 111 | fix(fixer) { 112 | return fixer.insertTextAfter(node.expression, "()"); 113 | }, 114 | }, 115 | ], 116 | }); 117 | }) 118 | ); 119 | } 120 | }, 121 | JSXAttribute(node) { 122 | const parameterWithVoidOrUknownReturnType = ( 123 | parserServices: ParserServices, 124 | typeChecker: ts.TypeChecker 125 | ) => 126 | pipe( 127 | typeChecker.getContextualTypeForJsxAttribute( 128 | parserServices.esTreeNodeToTSNodeMap.get(node) 129 | ), 130 | option.fromNullable, 131 | option.filterMap(voidOrUknownReturnType) 132 | ); 133 | 134 | const argumentWithPureDataTypeReturnType = pipe( 135 | node, 136 | typeOfNode, 137 | option.filterMap(pureDataReturnType) 138 | ); 139 | 140 | pipe( 141 | option.Do, 142 | option.bind("parserServices", parserServices), 143 | option.bind("typeChecker", ({ parserServices }) => 144 | option.fromNullable(parserServices.program?.getTypeChecker()) 145 | ), 146 | option.bind( 147 | "parameterWithVoidOrUknownReturnType", 148 | ({ parserServices, typeChecker }) => 149 | parameterWithVoidOrUknownReturnType(parserServices, typeChecker) 150 | ), 151 | option.bind( 152 | "argumentWithPureDataTypeReturnType", 153 | () => argumentWithPureDataTypeReturnType 154 | ), 155 | option.map( 156 | ({ 157 | argumentWithPureDataTypeReturnType, 158 | parameterWithVoidOrUknownReturnType, 159 | }) => 160 | context.report({ 161 | node: node, 162 | messageId: "discardedDataTypeJsx", 163 | data: { 164 | jsxAttributeName: node.name.name, 165 | expectedReturnType: (parameterWithVoidOrUknownReturnType as any) 166 | .intrinsicName, 167 | dataType: 168 | argumentWithPureDataTypeReturnType.symbol.escapedName, 169 | }, 170 | }) 171 | ) 172 | ); 173 | }, 174 | CallExpression(node) { 175 | pipe( 176 | node.arguments, 177 | array.mapWithIndex((index, argumentNode) => 178 | pipe( 179 | option.Do, 180 | option.bind("argumentType", () => typeOfNode(argumentNode)), 181 | option.bind("parserServices", parserServices), 182 | option.bind("typeChecker", ({ parserServices }) => 183 | option.fromNullable(parserServices.program?.getTypeChecker()) 184 | ), 185 | option.bind( 186 | "parameterReturnType", 187 | ({ parserServices, typeChecker }) => { 188 | const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); 189 | return pipe( 190 | typeChecker.getContextualTypeForArgumentAtIndex( 191 | tsNode, 192 | index 193 | ), 194 | option.fromNullable, 195 | option.chain(voidOrUknownReturnType) 196 | ); 197 | } 198 | ), 199 | option.bind("argumentReturnType", ({ argumentType }) => 200 | pureDataReturnType(argumentType) 201 | ), 202 | option.map(({ argumentReturnType, parameterReturnType }) => { 203 | context.report({ 204 | node: argumentNode, 205 | messageId: "discardedDataTypeArgument", 206 | data: { 207 | functionName: prettyPrint(node.callee), 208 | dataType: argumentReturnType.symbol.escapedName, 209 | expectedReturnType: (parameterReturnType as any) 210 | .intrinsicName, 211 | }, 212 | }); 213 | }) 214 | ) 215 | ) 216 | ); 217 | }, 218 | }; 219 | }, 220 | }); 221 | -------------------------------------------------------------------------------- /src/rules/no-lib-imports.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils } from "@typescript-eslint/utils"; 2 | import { createRule, inferQuote } from "../utils"; 3 | 4 | export default createRule({ 5 | name: "no-lib-imports", 6 | meta: { 7 | type: "problem", 8 | fixable: "code", 9 | docs: { 10 | description: "Disallow imports from 'fp-ts/lib'", 11 | }, 12 | schema: [], 13 | messages: { 14 | importNotAllowed: 15 | "Importing from {{ detected }} is not allowed, import from {{ fixed }} instead", 16 | }, 17 | }, 18 | defaultOptions: [], 19 | create(context) { 20 | return { 21 | ImportDeclaration(node) { 22 | const sourceValue = ASTUtils.getStringIfConstant(node.source); 23 | const forbiddenImportPattern = /^fp-ts\/lib\//; 24 | 25 | if (sourceValue?.match(forbiddenImportPattern.source)) { 26 | const fixedImportSource = sourceValue.replace( 27 | forbiddenImportPattern, 28 | "fp-ts/" 29 | ); 30 | context.report({ 31 | node: node.source, 32 | messageId: "importNotAllowed", 33 | data: { 34 | detected: node.source.value, 35 | fixed: fixedImportSource, 36 | }, 37 | fix(fixer) { 38 | const quote = inferQuote(node.source); 39 | 40 | return fixer.replaceText( 41 | node.source, 42 | `${quote}${fixedImportSource}${quote}` 43 | ); 44 | }, 45 | }); 46 | } 47 | }, 48 | }; 49 | }, 50 | }); 51 | -------------------------------------------------------------------------------- /src/rules/no-module-imports.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ASTUtils, 3 | AST_NODE_TYPES, 4 | } from "@typescript-eslint/utils"; 5 | import { array } from "fp-ts"; 6 | import { pipe } from "fp-ts/function"; 7 | import { contextUtils, createRule, inferQuote } from "../utils"; 8 | 9 | type Options = [{ allowTypes?: boolean; allowedModules?: string[] }]; 10 | 11 | const messages = { 12 | importNotAllowed: 13 | "Importing from modules is not allowed, import from 'fp-ts' instead", 14 | importValuesNotAllowed: 15 | "Importing values from modules is not allowed, import from 'fp-ts' instead", 16 | } as const; 17 | type MessageIds = keyof typeof messages; 18 | 19 | export default createRule({ 20 | name: "no-module-imports", 21 | meta: { 22 | type: "problem", 23 | fixable: "code", 24 | docs: { 25 | description: 26 | "Disallow imports from fp-ts modules, such as `fp-ts/Option`", 27 | }, 28 | schema: [ 29 | { 30 | type: "object", 31 | properties: { 32 | allowTypes: { 33 | type: "boolean", 34 | }, 35 | allowedModules: { 36 | type: "array", 37 | items: { 38 | type: "string", 39 | }, 40 | }, 41 | }, 42 | }, 43 | ], 44 | messages: { 45 | importNotAllowed: 46 | "Importing from modules is not allowed, import from 'fp-ts' instead", 47 | importValuesNotAllowed: 48 | "Importing values from modules is not allowed, import from 'fp-ts' instead", 49 | }, 50 | }, 51 | defaultOptions: [ 52 | { allowedModules: ["function", "pipeable"], allowTypes: false }, 53 | ], 54 | create(context, [options]) { 55 | const allowTypes = options.allowTypes; 56 | const allowedModules = options.allowedModules ?? []; 57 | 58 | const { 59 | addNamedImportIfNeeded, 60 | isOnlyUsedAsType, 61 | removeImportDeclaration, 62 | } = contextUtils(context); 63 | 64 | return { 65 | ImportDeclaration(node) { 66 | const sourceValue = ASTUtils.getStringIfConstant(node.source); 67 | if (sourceValue) { 68 | const forbiddenImportPattern = /^fp-ts\/(.+)/; 69 | const matches = sourceValue.match(forbiddenImportPattern); 70 | 71 | if (matches != null) { 72 | const matchedModule = matches[1]!.replace("lib/", ""); 73 | if (allowedModules.includes(matchedModule)) { 74 | return; 75 | } 76 | 77 | const importSpecifiers = node.specifiers.filter( 78 | (importClause) => 79 | importClause.type === AST_NODE_TYPES.ImportSpecifier 80 | ); 81 | 82 | const nonTypeImports = pipe( 83 | importSpecifiers, 84 | array.filter((i) => !isOnlyUsedAsType(i)) 85 | ); 86 | 87 | if (allowTypes && nonTypeImports.length === 0) { 88 | return; 89 | } 90 | 91 | if (importSpecifiers.length > 0) { 92 | context.report({ 93 | node: node.source, 94 | messageId: allowTypes 95 | ? "importValuesNotAllowed" 96 | : "importNotAllowed", 97 | fix(fixer) { 98 | const indexExport = 99 | matchedModule.charAt(0).toLowerCase() + 100 | matchedModule.slice(1); 101 | 102 | const referencesFixes = importSpecifiers.flatMap( 103 | (importSpecifier) => { 104 | const variable = ASTUtils.findVariable( 105 | context.sourceCode.getScope(node), 106 | importSpecifier.local.name 107 | ); 108 | if (variable) { 109 | return variable.references 110 | .filter((ref) => 111 | allowTypes 112 | ? ref.identifier.parent?.type !== 113 | AST_NODE_TYPES.TSTypeReference 114 | : true 115 | ) 116 | .filter( 117 | (ref) => 118 | ref.identifier.parent?.type !== 119 | AST_NODE_TYPES.MemberExpression 120 | ) 121 | .map((ref) => 122 | fixer.insertTextBefore( 123 | ref.identifier, 124 | `${indexExport}.` 125 | ) 126 | ); 127 | } else { 128 | return []; 129 | } 130 | } 131 | ); 132 | 133 | const importFixes = 134 | !allowTypes || 135 | nonTypeImports.length === importSpecifiers.length 136 | ? [removeImportDeclaration(node, fixer)] 137 | : nonTypeImports.map((node) => { 138 | if ( 139 | context.getSourceCode().getTokenAfter(node) 140 | ?.value === "," 141 | ) { 142 | return fixer.removeRange([ 143 | node.range[0], 144 | node.range[1] + 1, 145 | ]); 146 | } else { 147 | return fixer.remove(node); 148 | } 149 | }); 150 | 151 | return [ 152 | ...importFixes, 153 | ...addNamedImportIfNeeded( 154 | indexExport, 155 | "fp-ts", 156 | inferQuote(node.source), 157 | fixer 158 | ), 159 | ...referencesFixes, 160 | ]; 161 | }, 162 | }); 163 | } 164 | } 165 | } 166 | }, 167 | }; 168 | }, 169 | }); 170 | -------------------------------------------------------------------------------- /src/rules/no-pipeable.ts: -------------------------------------------------------------------------------- 1 | import { ASTUtils, TSESTree } from "@typescript-eslint/utils"; 2 | import { createRule, inferQuote } from "../utils"; 3 | 4 | export default createRule({ 5 | name: "no-pipeable", 6 | meta: { 7 | type: "problem", 8 | fixable: "code", 9 | schema: [], 10 | docs: { 11 | description: "Disallow imports from the 'pipeable' module", 12 | }, 13 | messages: { 14 | importPipeFromFunction: 15 | "The 'pipeable' module is deprecated. Import 'pipe' from the 'function' module instead", 16 | pipeableIsDeprecated: 17 | "The 'pipeable' module is deprecated and will be removed in future versions of fp-ts", 18 | }, 19 | }, 20 | defaultOptions: [], 21 | create(context) { 22 | return { 23 | ImportDeclaration(node) { 24 | const sourceValue = ASTUtils.getStringIfConstant(node.source); 25 | const pipeableSourcePattern = /^fp-ts\/(lib\/)?pipeable/; 26 | if (sourceValue?.match(pipeableSourcePattern)) { 27 | if ( 28 | node.specifiers.find( 29 | (importClause) => 30 | ((importClause as TSESTree.ImportSpecifier).imported as TSESTree.Identifier | undefined)?.name === 31 | "pipe" 32 | ) 33 | ) { 34 | context.report({ 35 | node: node.source, 36 | messageId: "importPipeFromFunction", 37 | fix(fixer) { 38 | const quote = inferQuote(node.source); 39 | return fixer.replaceText( 40 | node.source, 41 | `${quote}fp-ts/function${quote}` 42 | ); 43 | }, 44 | }); 45 | } else { 46 | context.report({ 47 | node: node.source, 48 | messageId: "pipeableIsDeprecated", 49 | }); 50 | } 51 | } 52 | }, 53 | }; 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /src/rules/no-redundant-flow.ts: -------------------------------------------------------------------------------- 1 | import { pipe } from "fp-ts/function"; 2 | import * as O from "fp-ts/Option"; 3 | import { 4 | contextUtils, 5 | createRule, 6 | createSequenceExpressionFromCallExpressionWithExpressionArgs, 7 | getCallExpressionWithExpressionArgs, 8 | prettyPrint, 9 | } from "../utils"; 10 | 11 | export default createRule({ 12 | name: "no-redundant-flow", 13 | meta: { 14 | type: "suggestion", 15 | fixable: "code", 16 | hasSuggestions: true, 17 | schema: [], 18 | docs: { 19 | description: "Remove redundant uses of flow", 20 | }, 21 | messages: { 22 | redundantFlow: "flow can be removed because it takes only one argument", 23 | removeFlow: "remove flow", 24 | }, 25 | }, 26 | defaultOptions: [], 27 | create(context) { 28 | const { isFlowExpression } = contextUtils(context); 29 | 30 | return { 31 | CallExpression(node) { 32 | pipe( 33 | node, 34 | O.fromPredicate(isFlowExpression), 35 | /** 36 | * We ignore flow calls which contain a spread argument because these are never invalid. 37 | */ 38 | O.chain(getCallExpressionWithExpressionArgs), 39 | O.filter((flowCall) => flowCall.node.arguments.length === 1), 40 | O.map((redundantFlowCall) => { 41 | context.report({ 42 | node: redundantFlowCall.node, 43 | messageId: "redundantFlow", 44 | suggest: [ 45 | { 46 | messageId: "removeFlow", 47 | fix(fixer) { 48 | const sequenceExpression = 49 | createSequenceExpressionFromCallExpressionWithExpressionArgs( 50 | redundantFlowCall 51 | ); 52 | return [ 53 | fixer.replaceText( 54 | redundantFlowCall.node, 55 | prettyPrint(sequenceExpression) 56 | ), 57 | ]; 58 | }, 59 | }, 60 | ], 61 | }); 62 | }) 63 | ); 64 | }, 65 | }; 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /src/rules/prefer-bimap.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST_NODE_TYPES, 3 | TSESTree, 4 | } from "@typescript-eslint/utils"; 5 | import { boolean, option, apply } from "fp-ts"; 6 | import { constVoid, pipe } from "fp-ts/function"; 7 | import { 8 | calleeIdentifier, 9 | contextUtils, 10 | createRule, 11 | getAdjacentCombinators, 12 | inferIndent, 13 | prettyPrint, 14 | } from "../utils"; 15 | 16 | export default createRule({ 17 | name: "prefer-bimap", 18 | meta: { 19 | type: "suggestion", 20 | fixable: "code", 21 | hasSuggestions: true, 22 | schema: [], 23 | docs: { 24 | description: "Replace map + mapLeft with bimap", 25 | }, 26 | messages: { 27 | mapMapLeftIsBimap: 28 | "{{firstNode}} followed by {{secondNode}} can be replaced by bimap", 29 | replaceMapMapLeftBimap: 30 | "replace {{firstNode}} and {{secondNode}} with bimap", 31 | }, 32 | }, 33 | defaultOptions: [], 34 | create(context) { 35 | const { isPipeOrFlowExpression } = contextUtils(context); 36 | 37 | return { 38 | CallExpression(node) { 39 | const mapThenMapLeft = () => 40 | getAdjacentCombinators< 41 | TSESTree.CallExpression, 42 | TSESTree.CallExpression 43 | >( 44 | node, 45 | { 46 | name: "map", 47 | types: [AST_NODE_TYPES.CallExpression], 48 | }, 49 | { 50 | name: "mapLeft", 51 | types: [AST_NODE_TYPES.CallExpression], 52 | }, 53 | true 54 | ); 55 | 56 | const mapLeftThenMap = () => 57 | getAdjacentCombinators< 58 | TSESTree.CallExpression, 59 | TSESTree.CallExpression 60 | >( 61 | node, 62 | { 63 | name: "mapLeft", 64 | types: [AST_NODE_TYPES.CallExpression], 65 | }, 66 | { 67 | name: "map", 68 | types: [AST_NODE_TYPES.CallExpression], 69 | }, 70 | true 71 | ); 72 | 73 | pipe( 74 | node, 75 | isPipeOrFlowExpression, 76 | boolean.fold(constVoid, () => 77 | pipe( 78 | mapThenMapLeft(), 79 | option.alt(mapLeftThenMap), 80 | option.bindTo("combinators"), 81 | option.bind("calleeIdentifiers", ({ combinators }) => 82 | apply.sequenceT(option.option)( 83 | calleeIdentifier(combinators[0]), 84 | calleeIdentifier(combinators[1]) 85 | ) 86 | ), 87 | option.map(({ combinators, calleeIdentifiers }) => { 88 | context.report({ 89 | loc: { 90 | start: combinators[0].loc.start, 91 | end: combinators[1].loc.end, 92 | }, 93 | messageId: "mapMapLeftIsBimap", 94 | data: { 95 | firstNode: calleeIdentifiers[0].name, 96 | secondNode: calleeIdentifiers[1].name, 97 | }, 98 | suggest: [ 99 | { 100 | messageId: "replaceMapMapLeftBimap", 101 | data: { 102 | firstNode: calleeIdentifiers[0].name, 103 | secondNode: calleeIdentifiers[1].name, 104 | }, 105 | fix(fixer) { 106 | const mapFirst = calleeIdentifiers[0].name === "map"; 107 | const mapNode = mapFirst 108 | ? combinators[0] 109 | : combinators[1]; 110 | const mapLeftNode = mapFirst 111 | ? combinators[1] 112 | : combinators[0]; 113 | return [ 114 | fixer.replaceTextRange( 115 | [combinators[0].range[0], combinators[1].range[1]], 116 | `${prettyPrint(mapNode.callee).replace( 117 | /map$/, 118 | "bimap" 119 | )}(\n${inferIndent(mapNode)} ${prettyPrint( 120 | mapLeftNode.arguments[0]! 121 | )},\n${inferIndent(mapNode)} ${prettyPrint( 122 | mapNode.arguments[0]! 123 | )}\n${inferIndent(mapNode)})` 124 | ), 125 | ]; 126 | }, 127 | }, 128 | ], 129 | }); 130 | }) 131 | ) 132 | ) 133 | ); 134 | }, 135 | }; 136 | }, 137 | }); 138 | -------------------------------------------------------------------------------- /src/rules/prefer-chain.ts: -------------------------------------------------------------------------------- 1 | import { AST_NODE_TYPES } from "@typescript-eslint/utils"; 2 | import { boolean, option } from "fp-ts"; 3 | import { constVoid, pipe } from "fp-ts/function"; 4 | import { 5 | calleeIdentifier, 6 | contextUtils, 7 | createRule, 8 | getAdjacentCombinators, 9 | } from "../utils"; 10 | 11 | export default createRule({ 12 | name: "prefer-chain", 13 | meta: { 14 | type: "suggestion", 15 | fixable: "code", 16 | hasSuggestions: true, 17 | schema: [], 18 | docs: { 19 | description: "Replace map + flatten with chain", 20 | }, 21 | messages: { 22 | mapFlattenIsChain: "map followed by flatten can be replaced by chain", 23 | replaceMapFlattenWithChain: "replace map and flatten with chain", 24 | }, 25 | }, 26 | defaultOptions: [], 27 | create(context) { 28 | const { isPipeOrFlowExpression } = contextUtils(context); 29 | 30 | return { 31 | CallExpression(node) { 32 | pipe( 33 | node, 34 | isPipeOrFlowExpression, 35 | boolean.fold(constVoid, () => 36 | pipe( 37 | getAdjacentCombinators( 38 | node, 39 | { 40 | name: /map|mapWithIndex/, 41 | types: [AST_NODE_TYPES.CallExpression], 42 | }, 43 | { 44 | name: "flatten", 45 | types: [ 46 | AST_NODE_TYPES.Identifier, 47 | AST_NODE_TYPES.MemberExpression, 48 | ], 49 | }, 50 | true 51 | ), 52 | option.bindTo("combinators"), 53 | option.bind("mapCalleeIdentifier", ({ combinators: [mapNode] }) => 54 | calleeIdentifier(mapNode) 55 | ), 56 | option.map( 57 | ({ 58 | combinators: [mapNode, flattenNode], 59 | mapCalleeIdentifier, 60 | }) => { 61 | const chainIndentifier = 62 | mapCalleeIdentifier.name === "mapWithIndex" 63 | ? "chainWithIndex" 64 | : "chain"; 65 | 66 | context.report({ 67 | loc: { 68 | start: mapNode.loc.start, 69 | end: flattenNode.loc.end, 70 | }, 71 | messageId: "mapFlattenIsChain", 72 | suggest: [ 73 | { 74 | messageId: "replaceMapFlattenWithChain", 75 | fix(fixer) { 76 | return [ 77 | fixer.remove(flattenNode), 78 | fixer.removeRange([ 79 | mapNode.range[1], 80 | flattenNode.range[0], 81 | ]), 82 | fixer.replaceText( 83 | mapCalleeIdentifier, 84 | chainIndentifier 85 | ), 86 | ]; 87 | }, 88 | }, 89 | ], 90 | }); 91 | } 92 | ) 93 | ) 94 | ) 95 | ); 96 | }, 97 | }; 98 | }, 99 | }); 100 | -------------------------------------------------------------------------------- /src/rules/prefer-traverse.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST_NODE_TYPES, 3 | TSESTree, 4 | } from "@typescript-eslint/utils"; 5 | import { constVoid, pipe } from "fp-ts/function"; 6 | import { boolean, option } from "fp-ts"; 7 | import { 8 | calleeIdentifier, 9 | getAdjacentCombinators, 10 | prettyPrint, 11 | contextUtils, 12 | createRule, 13 | } from "../utils"; 14 | 15 | export default createRule({ 16 | name: "prefer-traverse", 17 | meta: { 18 | type: "suggestion", 19 | fixable: "code", 20 | hasSuggestions: true, 21 | schema: [], 22 | docs: { 23 | description: "Replace map + sequence with traverse", 24 | }, 25 | messages: { 26 | mapSequenceIsTraverse: 27 | "map followed by sequence can be replaced by traverse", 28 | replaceMapSequenceWithTraverse: "replace map and sequence with traverse", 29 | }, 30 | }, 31 | defaultOptions: [], 32 | create(context) { 33 | const { isPipeOrFlowExpression } = contextUtils(context); 34 | return { 35 | CallExpression(node) { 36 | pipe( 37 | node, 38 | isPipeOrFlowExpression, 39 | boolean.fold(constVoid, () => 40 | pipe( 41 | getAdjacentCombinators< 42 | TSESTree.CallExpression, 43 | TSESTree.CallExpression 44 | >( 45 | node, 46 | { 47 | name: /map|mapWithIndex/, 48 | types: [AST_NODE_TYPES.CallExpression], 49 | }, 50 | { 51 | name: "sequence", 52 | types: [AST_NODE_TYPES.CallExpression], 53 | }, 54 | true 55 | ), 56 | option.bindTo("combinators"), 57 | option.bind("mapCalleeIdentifier", ({ combinators: [mapNode] }) => 58 | calleeIdentifier(mapNode) 59 | ), 60 | option.map( 61 | ({ 62 | combinators: [mapNode, sequenceNode], 63 | mapCalleeIdentifier, 64 | }) => { 65 | const traverseIdentifier = 66 | mapCalleeIdentifier.name === "mapWithIndex" 67 | ? "traverseWithIndex" 68 | : "traverse"; 69 | 70 | context.report({ 71 | loc: { 72 | start: mapNode.loc.start, 73 | end: sequenceNode.loc.end, 74 | }, 75 | messageId: "mapSequenceIsTraverse", 76 | suggest: [ 77 | { 78 | messageId: "replaceMapSequenceWithTraverse", 79 | fix(fixer) { 80 | return [ 81 | fixer.remove(sequenceNode), 82 | fixer.removeRange([ 83 | mapNode.range[1], 84 | sequenceNode.range[0], 85 | ]), 86 | fixer.replaceText( 87 | mapCalleeIdentifier, 88 | traverseIdentifier 89 | ), 90 | fixer.insertTextAfter( 91 | mapNode.callee, 92 | `(${prettyPrint(sequenceNode.arguments[0]!)})` 93 | ), 94 | ]; 95 | }, 96 | }, 97 | ], 98 | }); 99 | } 100 | ) 101 | ) 102 | ) 103 | ); 104 | }, 105 | }; 106 | }, 107 | }); 108 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | TSESLint, 3 | ASTUtils, 4 | AST_NODE_TYPES, 5 | TSESTree, 6 | ESLintUtils, 7 | ParserServices, 8 | } from "@typescript-eslint/utils"; 9 | import * as recast from "recast"; 10 | import { simpleTraverse } from "@typescript-eslint/typescript-estree"; 11 | import { array, option, apply } from "fp-ts"; 12 | import { pipe } from "fp-ts/function"; 13 | import ts from "typescript"; 14 | import { Option } from "fp-ts/Option"; 15 | import * as NonEmptyArray from "fp-ts/NonEmptyArray"; 16 | import * as O from "fp-ts/Option"; 17 | 18 | declare module "typescript" { 19 | interface TypeChecker { 20 | getContextualTypeForJsxAttribute( 21 | attribute: JsxAttribute | JsxSpreadAttribute 22 | ): Type | undefined; 23 | getContextualTypeForArgumentAtIndex( 24 | nodeIn: Expression, 25 | argIndex: number 26 | ): Type | undefined; 27 | } 28 | function toFileNameLowerCase(x: string): string; 29 | } 30 | 31 | const version = require("../package.json").version; 32 | 33 | export const createRule = ESLintUtils.RuleCreator( 34 | (name) => 35 | `https://github.com/buildo/eslint-plugin-fp-ts/blob/v${version}/docs/rules/${name}.md` 36 | ); 37 | 38 | export function calleeIdentifier( 39 | node: 40 | | TSESTree.CallExpression 41 | | TSESTree.MemberExpression 42 | | TSESTree.Identifier 43 | ): option.Option { 44 | switch (node.type) { 45 | case AST_NODE_TYPES.MemberExpression: 46 | if (node.property.type === AST_NODE_TYPES.Identifier) { 47 | return option.some(node.property); 48 | } else { 49 | return option.none; 50 | } 51 | case AST_NODE_TYPES.CallExpression: 52 | switch (node.callee.type) { 53 | case AST_NODE_TYPES.Identifier: 54 | return option.some(node.callee); 55 | case AST_NODE_TYPES.MemberExpression: 56 | if (node.callee.property.type === AST_NODE_TYPES.Identifier) { 57 | return option.some(node.callee.property); 58 | } else { 59 | return option.none; 60 | } 61 | } 62 | return option.none; 63 | case AST_NODE_TYPES.Identifier: 64 | return option.some(node); 65 | } 66 | } 67 | 68 | function isWithinTypes( 69 | n: TSESTree.Node | undefined, 70 | types: N["type"][] 71 | ): n is N { 72 | return !!n && types.includes(n.type); 73 | } 74 | 75 | type CombinatorNode = 76 | | TSESTree.CallExpression 77 | | TSESTree.MemberExpression 78 | | TSESTree.Identifier; 79 | type CombinatorQuery = { 80 | name: string | RegExp; 81 | types: T[]; 82 | }; 83 | export function getAdjacentCombinators< 84 | N1 extends CombinatorNode, 85 | N2 extends CombinatorNode 86 | >( 87 | pipeOrFlowExpression: TSESTree.CallExpression, 88 | combinator1: CombinatorQuery, 89 | combinator2: CombinatorQuery, 90 | requireMatchingPrefix: boolean 91 | ): option.Option<[N1, N2]> { 92 | function matches(value: string, stringOrRegex: string | RegExp): boolean { 93 | if (typeof stringOrRegex === "string") { 94 | return value === stringOrRegex; 95 | } else { 96 | return !!value.match(stringOrRegex); 97 | } 98 | } 99 | 100 | const firstCombinatorIndex = pipeOrFlowExpression.arguments.findIndex( 101 | (a, index) => { 102 | if ( 103 | isWithinTypes(a, combinator1.types) && 104 | index < pipeOrFlowExpression.arguments.length - 1 105 | ) { 106 | const b = pipeOrFlowExpression.arguments[index + 1]; 107 | if (isWithinTypes(b, combinator2.types)) { 108 | return pipe( 109 | apply.sequenceS(option.option)({ 110 | idA: calleeIdentifier(a), 111 | idB: calleeIdentifier(b), 112 | }), 113 | option.exists( 114 | ({ idA, idB }) => 115 | matches(idA.name, combinator1.name) && 116 | matches(idB.name, combinator2.name) 117 | ) 118 | ); 119 | } 120 | } 121 | return false; 122 | } 123 | ); 124 | 125 | if (firstCombinatorIndex >= 0) { 126 | const firstCombinator = pipeOrFlowExpression.arguments[ 127 | firstCombinatorIndex 128 | ] as N1; 129 | 130 | const secondCombinator = pipeOrFlowExpression.arguments[ 131 | firstCombinatorIndex + 1 132 | ] as N2; 133 | 134 | if (requireMatchingPrefix) { 135 | // NOTE(gabro): this is a naive way of checking whether two combinators are 136 | // from the same module. 137 | // We assume the most commonly used syntax is something like: 138 | // 139 | // import { array } from 'fp-ts' 140 | // pipe( 141 | // [1, 2], 142 | // array.map(...), 143 | // array.sequence(...) 144 | // ) 145 | // 146 | // So we check that array.map and array.sequence have the same prefix ("array." in this example) 147 | // by pretty-printing it and comparing the result. 148 | // 149 | // This works well enough in practice, but if needed this can be made more exact by using 150 | // TypeScript's compiler API and comparing the types. 151 | const getPrefix = (node: CombinatorNode): string => { 152 | switch (node.type) { 153 | case AST_NODE_TYPES.CallExpression: 154 | return node.callee.type === AST_NODE_TYPES.MemberExpression 155 | ? prettyPrint(node.callee.object) 156 | : ""; 157 | case AST_NODE_TYPES.MemberExpression: 158 | return prettyPrint(node.object); 159 | case AST_NODE_TYPES.Identifier: 160 | return ""; 161 | } 162 | }; 163 | 164 | if (getPrefix(firstCombinator) === getPrefix(secondCombinator)) { 165 | return option.some([firstCombinator, secondCombinator]); 166 | } 167 | } else { 168 | return option.some([firstCombinator, secondCombinator]); 169 | } 170 | } 171 | 172 | return option.none; 173 | } 174 | 175 | export function prettyPrint(node: TSESTree.Node): string { 176 | return recast.prettyPrint(node).code; 177 | } 178 | 179 | export function inferIndent(node: TSESTree.Node): string { 180 | return new Array(node.loc.start.column + 1).join(" "); 181 | } 182 | 183 | type Quote = "'" | '"'; 184 | export function inferQuote(node: TSESTree.Literal): Quote { 185 | return node.raw[0] === "'" ? "'" : '"'; 186 | } 187 | 188 | export const contextUtils = < 189 | TMessageIds extends string, 190 | TOptions extends readonly unknown[] 191 | >( 192 | context: TSESLint.RuleContext 193 | ) => { 194 | function findModuleImport( 195 | moduleName: string 196 | ): option.Option { 197 | let importNode: option.Option = option.none; 198 | 199 | simpleTraverse(context.getSourceCode().ast as any, { 200 | enter: (node) => { 201 | if ( 202 | node.type === "ImportDeclaration" && 203 | ASTUtils.getStringIfConstant(node.source as TSESTree.Literal) === moduleName 204 | ) { 205 | importNode = option.some(node as TSESTree.ImportDeclaration); 206 | } 207 | } 208 | }) 209 | return importNode; 210 | } 211 | 212 | function findLastModuleImport(): option.Option { 213 | let importNode: option.Option = option.none; 214 | simpleTraverse(context.getSourceCode().ast as any, { 215 | enter: (node) => { 216 | if ( 217 | node.type === "ImportDeclaration" 218 | ) { 219 | importNode = option.some(node as TSESTree.ImportDeclaration); 220 | } 221 | } 222 | }) 223 | return importNode; 224 | } 225 | 226 | function addNamedImportIfNeeded( 227 | name: string, 228 | moduleName: string, 229 | quote: Quote, 230 | fixer: TSESLint.RuleFixer 231 | ): Array { 232 | return pipe( 233 | findModuleImport(moduleName), 234 | option.fold( 235 | () => 236 | // insert full named import 237 | pipe( 238 | findLastModuleImport(), 239 | option.fold( 240 | // no other imports in this file, add the import at the very beginning 241 | () => [ 242 | fixer.insertTextAfterRange( 243 | [0, 0], 244 | `import { ${name} } from ${quote}${moduleName}${quote}\n` 245 | ), 246 | ], 247 | (lastImport) => 248 | // other imports founds in this file, insert the import after the last one 249 | [ 250 | fixer.insertTextAfterRange( 251 | [lastImport.range[0], lastImport.range[1] + 1], 252 | `import { ${name} } from ${quote}${moduleName}${quote}\n` 253 | ), 254 | ] 255 | ) 256 | ), 257 | (importDeclaration) => 258 | pipe( 259 | importDeclaration.specifiers, 260 | array.findFirst( 261 | (specifier) => 262 | specifier.type === AST_NODE_TYPES.ImportSpecifier && 263 | (specifier.imported as TSESTree.Identifier | undefined)?.name === name 264 | ), 265 | option.fold( 266 | () => 267 | // insert 'name' in existing module import 268 | pipe( 269 | importDeclaration.specifiers, 270 | array.last, 271 | option.fold( 272 | // No specifiers, so this is import {} from 'fp-ts' 273 | // NOTE(gabro): It's an edge case we don't handle for now, so we just do nothing 274 | () => [fixer.insertTextAfterRange([0, 0], "")], 275 | // Insert import specifier, possibly inserting a comma if needed 276 | (lastImportSpecifier) => { 277 | if ( 278 | ASTUtils.isCommaToken( 279 | context 280 | .getSourceCode() 281 | .getTokenAfter(lastImportSpecifier)! 282 | ) 283 | ) { 284 | return [ 285 | fixer.insertTextAfter( 286 | lastImportSpecifier, 287 | ` ${name}` 288 | ), 289 | ]; 290 | } else { 291 | return [ 292 | fixer.insertTextAfter( 293 | lastImportSpecifier, 294 | `, ${name}` 295 | ), 296 | ]; 297 | } 298 | } 299 | ) 300 | ), 301 | () => 302 | // do nothing, 'name' is already imported 303 | [] 304 | ) 305 | ) 306 | ) 307 | ); 308 | } 309 | 310 | function removeImportDeclaration( 311 | node: TSESTree.ImportDeclaration, 312 | fixer: TSESLint.RuleFixer 313 | ): TSESLint.RuleFix { 314 | const nextToken = context.getSourceCode().getTokenAfter(node); 315 | 316 | if (nextToken && nextToken.loc.start.line > node.loc.start.line) { 317 | return fixer.removeRange([ 318 | node.range[0], 319 | context.getSourceCode().getIndexFromLoc({ 320 | line: node.loc.start.line + 1, 321 | column: 0, 322 | }), 323 | ]); 324 | } else { 325 | return fixer.remove(node); 326 | } 327 | } 328 | 329 | function isIdentifierImportedFrom< 330 | TMessageIds extends string, 331 | TOptions extends readonly unknown[] 332 | >( 333 | identifier: TSESTree.Identifier, 334 | targetModuleName: string | RegExp, 335 | context: TSESLint.RuleContext, 336 | node: TSESTree.Node 337 | ): boolean { 338 | const importDef = ASTUtils.findVariable( 339 | ASTUtils.getInnermostScope(context.sourceCode.getScope(node), identifier), 340 | identifier.name 341 | )?.defs.find((d) => d.type === "ImportBinding"); 342 | return !!( 343 | importDef?.parent?.type === AST_NODE_TYPES.ImportDeclaration && 344 | importDef.parent.source.value?.toString().match(targetModuleName) 345 | ); 346 | } 347 | 348 | function isFlowExpression(node: TSESTree.CallExpression): boolean { 349 | return pipe( 350 | node, 351 | calleeIdentifier, 352 | option.exists( 353 | (callee) => 354 | callee.name === "flow" && 355 | isIdentifierImportedFrom(callee, /fp-ts\//, context, node) 356 | ) 357 | ); 358 | } 359 | 360 | function isPipeOrFlowExpression(node: TSESTree.CallExpression): boolean { 361 | return pipe( 362 | node, 363 | calleeIdentifier, 364 | option.exists( 365 | (callee) => 366 | ["pipe", "flow"].includes(callee.name) && 367 | isIdentifierImportedFrom(callee, /fp-ts\//, context, node) 368 | ) 369 | ); 370 | } 371 | 372 | function isIdentifier(imported: TSESTree.Identifier | TSESTree.StringLiteral): imported is TSESTree.Identifier { 373 | return Object.prototype.hasOwnProperty.call(imported, 'name') 374 | } 375 | 376 | function getIdentifierName(imported: TSESTree.Identifier | TSESTree.StringLiteral): option.Option { 377 | return pipe( 378 | imported, 379 | option.fromPredicate(isIdentifier), 380 | option.map(({ name }) => name) 381 | ) 382 | } 383 | 384 | function isOnlyUsedAsType(node: TSESTree.ImportClause): boolean { 385 | if (node.type === AST_NODE_TYPES.ImportSpecifier) { 386 | return pipe( 387 | getIdentifierName(node.imported), 388 | option.map(name => ASTUtils.findVariable(context.sourceCode.getScope(node), name)), 389 | option.chain(option.fromNullable), 390 | option.exists((variable) => { 391 | const nonImportReferences = pipe( 392 | variable.references, 393 | array.filter( 394 | (ref) => 395 | ref.identifier.parent?.type !== AST_NODE_TYPES.ImportDeclaration 396 | ) 397 | ); 398 | return pipe( 399 | nonImportReferences, 400 | array.every( 401 | (ref) => 402 | ref.identifier.parent?.type === AST_NODE_TYPES.TSTypeReference 403 | ) 404 | ); 405 | }) 406 | ); 407 | } 408 | return false; 409 | } 410 | 411 | function parserServices(): Option }> { 412 | return pipe( 413 | option.fromNullable(context.sourceCode.parserServices), 414 | option.filter((parserServices): parserServices is ParserServices & { program: Exclude }=> parserServices.program !== null) 415 | ); 416 | } 417 | 418 | function typeOfNode(node: TSESTree.Node): Option { 419 | return pipe( 420 | option.Do, 421 | option.bind("parserServices", parserServices), 422 | option.bind("typeChecker", ({ parserServices }) => 423 | pipe(parserServices.program?.getTypeChecker(), option.fromNullable) 424 | ), 425 | option.map(({ parserServices, typeChecker }) => { 426 | const tsNode = parserServices.esTreeNodeToTSNodeMap.get(node); 427 | return typeChecker.getTypeAtLocation(tsNode); 428 | }) 429 | ); 430 | } 431 | 432 | const compilerHost = pipe( 433 | parserServices(), 434 | option.map((parserServices) => 435 | ts.createCompilerHost(parserServices.program.getCompilerOptions()) 436 | ) 437 | ); 438 | 439 | function isFromFpTs(type: ts.Type): boolean { 440 | return pipe( 441 | parserServices(), 442 | option.exists((parserServices) => { 443 | if (type.isUnion()) { 444 | const allFromFpTs = pipe(type.types, array.every(isFromFpTs)); 445 | return allFromFpTs; 446 | } 447 | 448 | const declaredFileName = type.symbol 449 | ?.getDeclarations()?.[0] 450 | ?.getSourceFile().fileName; 451 | 452 | if (declaredFileName) { 453 | return pipe( 454 | compilerHost, 455 | option.exists( 456 | (compilerHost) => 457 | !!ts.resolveModuleName( 458 | "fp-ts", 459 | declaredFileName, 460 | parserServices.program.getCompilerOptions(), 461 | compilerHost 462 | ).resolvedModule 463 | ) 464 | ); 465 | } 466 | return false; 467 | }) 468 | ); 469 | } 470 | 471 | return { 472 | addNamedImportIfNeeded, 473 | removeImportDeclaration, 474 | isFlowExpression, 475 | isPipeOrFlowExpression, 476 | isIdentifierImportedFrom, 477 | isOnlyUsedAsType, 478 | typeOfNode, 479 | isFromFpTs, 480 | parserServices, 481 | }; 482 | }; 483 | 484 | /** 485 | * Ideally we could implement this predicate in terms of an existing 486 | * `isExpression` predicate but it seems like this doesn't exist anywhere. 487 | * 488 | * There is an `isExpression` in `tsutils`. However, in the TS AST, spread is 489 | * classed as an expression (!). 490 | */ 491 | const getArgumentExpression = ( 492 | x: TSESTree.CallExpressionArgument 493 | ): O.Option => 494 | x.type !== AST_NODE_TYPES.SpreadElement ? O.some(x) : O.none; 495 | 496 | const checkIsArgumentExpression = O.getRefinement(getArgumentExpression); 497 | 498 | type CallExpressionWithExpressionArgs = { 499 | node: TSESTree.CallExpression; 500 | args: NonEmptyArray.NonEmptyArray; 501 | }; 502 | 503 | export const getCallExpressionWithExpressionArgs = ( 504 | node: TSESTree.CallExpression 505 | ): O.Option => 506 | node.arguments.every(checkIsArgumentExpression) 507 | ? pipe( 508 | node.arguments, 509 | NonEmptyArray.fromArray, 510 | O.map( 511 | (args): CallExpressionWithExpressionArgs => ({ 512 | node, 513 | args, 514 | }) 515 | ) 516 | ) 517 | : O.none; 518 | 519 | export const createSequenceExpressionFromCallExpressionWithExpressionArgs = ( 520 | call: CallExpressionWithExpressionArgs 521 | ): TSESTree.SequenceExpression => { 522 | const firstArg = pipe(call.args, NonEmptyArray.head); 523 | const lastArg = pipe(call.args, NonEmptyArray.last); 524 | return { 525 | parent: call.node, 526 | loc: call.node.loc, 527 | range: [firstArg.range[0], lastArg.range[1]], 528 | type: AST_NODE_TYPES.SequenceExpression, 529 | expressions: call.args, 530 | }; 531 | }; 532 | -------------------------------------------------------------------------------- /tests/fixtures/fp-ts-project/.gitignore: -------------------------------------------------------------------------------- 1 | *.js 2 | -------------------------------------------------------------------------------- /tests/fixtures/fp-ts-project/file.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buildo/eslint-plugin-fp-ts/c42782900ba8b7adf94d80677bf42d827490bd98/tests/fixtures/fp-ts-project/file.ts -------------------------------------------------------------------------------- /tests/fixtures/fp-ts-project/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@types/react": "^17.0.1", 4 | "typescript": "^4.1.3" 5 | }, 6 | "dependencies": { 7 | "fp-ts": "^2.9.5" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tests/fixtures/fp-ts-project/react.tsx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/buildo/eslint-plugin-fp-ts/c42782900ba8b7adf94d80677bf42d827490bd98/tests/fixtures/fp-ts-project/react.tsx -------------------------------------------------------------------------------- /tests/fixtures/fp-ts-project/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "jsx": "preserve" 5 | }, 6 | "include": ["file.ts", "react.tsx"] 7 | } 8 | -------------------------------------------------------------------------------- /tests/fixtures/fp-ts-project/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/prop-types@*": 6 | version "15.7.3" 7 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7" 8 | integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw== 9 | 10 | "@types/react@^17.0.1": 11 | version "17.0.1" 12 | resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.1.tgz#eb1f1407dea8da3bc741879c1192aa703ab9975b" 13 | integrity sha512-w8t9f53B2ei4jeOqf/gxtc2Sswnc3LBK5s0DyJcg5xd10tMHXts2N31cKjWfH9IC/JvEPa/YF1U4YeP1t4R6HQ== 14 | dependencies: 15 | "@types/prop-types" "*" 16 | csstype "^3.0.2" 17 | 18 | csstype@^3.0.2: 19 | version "3.0.6" 20 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.6.tgz#865d0b5833d7d8d40f4e5b8a6d76aea3de4725ef" 21 | integrity sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw== 22 | 23 | fp-ts@^2.9.5: 24 | version "2.9.5" 25 | resolved "https://registry.yarnpkg.com/fp-ts/-/fp-ts-2.9.5.tgz#6690cd8b76b84214a38fc77cbbbd04a38f86ea90" 26 | integrity sha512-MiHrA5teO6t8zKArE3DdMPT/Db6v2GUt5yfWnhBTrrsVfeCJUUnV6sgFvjGNBKDmEMqVwRFkEePL7wPwqrLKKA== 27 | 28 | typescript@^4.1.3: 29 | version "4.1.3" 30 | resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.1.3.tgz#519d582bd94cba0cf8934c7d8e8467e473f53bb7" 31 | integrity sha512-B3ZIOf1IKeH2ixgHhj6la6xdwR9QrLC5d1VKeCSY4tvkqhF2eqd9O7txNlS0PO3GrBAFIdr3L1ndNwteUbZLYg== 32 | -------------------------------------------------------------------------------- /tests/rules/no-discarded-pure-expression.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/no-discarded-pure-expression"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester"; 3 | import path from "path"; 4 | import { stripIndent } from "common-tags"; 5 | 6 | const fixtureProjectPath = path.join( 7 | __dirname, 8 | "..", 9 | "fixtures", 10 | "fp-ts-project" 11 | ); 12 | 13 | const ruleTester = new RuleTester({ 14 | languageOptions: { 15 | parserOptions: { 16 | sourceType: "module", 17 | tsconfigRootDir: fixtureProjectPath, 18 | project: "./tsconfig.json" 19 | }, 20 | } 21 | }); 22 | 23 | ruleTester.run("no-discarded-pure-expression", rule, { 24 | valid: [ 25 | { 26 | code: stripIndent` 27 | import { task } from "fp-ts" 28 | function ok() { 29 | return task.of(42) 30 | } 31 | `, 32 | }, 33 | { 34 | code: stripIndent` 35 | import { task } from "fp-ts" 36 | function ok() { 37 | task.of(42)() 38 | } 39 | `, 40 | }, 41 | { 42 | code: stripIndent` 43 | import { io } from "fp-ts" 44 | function ok() { 45 | io.of(42)() 46 | } 47 | `, 48 | }, 49 | { 50 | languageOptions: { 51 | parserOptions: { ecmaFeatures: { jsx: true } } 52 | }, 53 | code: stripIndent` 54 | import { task } from "fp-ts" 55 | 56 | function Foo(props: { handlerVoid: () => void; handlerUnknown: () => unknown }) { 57 | return null 58 | } 59 | 60 | const myCommand = task.of(42) 61 | 62 | const myComponent = myCommand()} handlerUnknown={() => myCommand()} /> 63 | `, 64 | }, 65 | { 66 | code: stripIndent` 67 | import { task } from "fp-ts" 68 | 69 | function foo(arg1: number, callbackVoid: () => void, callbackUnknown: () => unknown) { 70 | return null 71 | } 72 | 73 | const myCommand = task.of(42) 74 | 75 | foo(2, () => myCommand(), () => myCommand()) 76 | `, 77 | }, 78 | { 79 | // https://github.com/buildo/eslint-plugin-fp-ts/issues/62 80 | code: stripIndent` 81 | import { IO } from "fp-ts/IO"; 82 | import { IORef } from "fp-ts/IORef"; 83 | 84 | class Foo { 85 | private readonly errors: IORef; 86 | readonly getErrors: IO; 87 | 88 | constructor() { 89 | this.errors = new IORef([]); 90 | this.getErrors = this.errors.read; 91 | } 92 | } 93 | `, 94 | }, 95 | ], 96 | invalid: [ 97 | { 98 | code: stripIndent` 99 | import { task } from "fp-ts" 100 | 101 | function woops() { 102 | task.of(42) 103 | } 104 | `, 105 | errors: [ 106 | { 107 | messageId: "pureExpressionInStatementPosition", 108 | data: { 109 | dataType: "Task", 110 | }, 111 | suggestions: [ 112 | { 113 | messageId: "addReturn", 114 | output: stripIndent` 115 | import { task } from "fp-ts" 116 | 117 | function woops() { 118 | return task.of(42) 119 | } 120 | `, 121 | }, 122 | { 123 | messageId: "runExpression", 124 | output: stripIndent` 125 | import { task } from "fp-ts" 126 | 127 | function woops() { 128 | task.of(42)() 129 | } 130 | `, 131 | }, 132 | ], 133 | }, 134 | ], 135 | }, 136 | { 137 | code: stripIndent` 138 | import { task } from "fp-ts" 139 | 140 | function woops() { 141 | const x = task.of(42) 142 | x 143 | } 144 | `, 145 | errors: [ 146 | { 147 | messageId: "pureExpressionInStatementPosition", 148 | data: { 149 | dataType: "Task", 150 | }, 151 | suggestions: [ 152 | { 153 | messageId: "addReturn", 154 | output: stripIndent` 155 | import { task } from "fp-ts" 156 | 157 | function woops() { 158 | const x = task.of(42) 159 | return x 160 | } 161 | `, 162 | }, 163 | { 164 | messageId: "runExpression", 165 | output: stripIndent` 166 | import { task } from "fp-ts" 167 | 168 | function woops() { 169 | const x = task.of(42) 170 | x() 171 | } 172 | `, 173 | }, 174 | ], 175 | }, 176 | ], 177 | }, 178 | { 179 | code: stripIndent` 180 | import { taskEither } from "fp-ts" 181 | 182 | function woops() { 183 | taskEither.of(42) 184 | } 185 | `, 186 | errors: [ 187 | { 188 | messageId: "pureExpressionInStatementPosition", 189 | data: { 190 | dataType: "TaskEither", 191 | }, 192 | suggestions: [ 193 | { 194 | messageId: "addReturn", 195 | output: stripIndent` 196 | import { taskEither } from "fp-ts" 197 | 198 | function woops() { 199 | return taskEither.of(42) 200 | } 201 | `, 202 | }, 203 | { 204 | messageId: "runExpression", 205 | output: stripIndent` 206 | import { taskEither } from "fp-ts" 207 | 208 | function woops() { 209 | taskEither.of(42)() 210 | } 211 | `, 212 | }, 213 | ], 214 | }, 215 | ], 216 | }, 217 | { 218 | code: stripIndent` 219 | import { io } from "fp-ts" 220 | 221 | function woops() { 222 | io.of(42) 223 | } 224 | `, 225 | errors: [ 226 | { 227 | messageId: "pureExpressionInStatementPosition", 228 | data: { 229 | dataType: "IO", 230 | }, 231 | suggestions: [ 232 | { 233 | messageId: "addReturn", 234 | output: stripIndent` 235 | import { io } from "fp-ts" 236 | 237 | function woops() { 238 | return io.of(42) 239 | } 240 | `, 241 | }, 242 | { 243 | messageId: "runExpression", 244 | output: stripIndent` 245 | import { io } from "fp-ts" 246 | 247 | function woops() { 248 | io.of(42)() 249 | } 250 | `, 251 | }, 252 | ], 253 | }, 254 | ], 255 | }, 256 | { 257 | code: stripIndent` 258 | import { task, taskEither } from "fp-ts" 259 | 260 | function f(n: number) { 261 | if (n > 1) { 262 | return taskEither.of("foo") 263 | } 264 | return task.of(42) 265 | } 266 | 267 | function woops() { 268 | f(2) 269 | } 270 | `, 271 | errors: [ 272 | { 273 | messageId: "pureExpressionInStatementPosition", 274 | data: { 275 | dataType: "TaskEither", 276 | }, 277 | suggestions: [ 278 | { 279 | messageId: "addReturn", 280 | output: stripIndent` 281 | import { task, taskEither } from "fp-ts" 282 | 283 | function f(n: number) { 284 | if (n > 1) { 285 | return taskEither.of("foo") 286 | } 287 | return task.of(42) 288 | } 289 | 290 | function woops() { 291 | return f(2) 292 | } 293 | `, 294 | }, 295 | { 296 | messageId: "runExpression", 297 | output: stripIndent` 298 | import { task, taskEither } from "fp-ts" 299 | 300 | function f(n: number) { 301 | if (n > 1) { 302 | return taskEither.of("foo") 303 | } 304 | return task.of(42) 305 | } 306 | 307 | function woops() { 308 | f(2)() 309 | } 310 | `, 311 | }, 312 | ], 313 | }, 314 | ], 315 | }, 316 | { 317 | languageOptions: { 318 | parserOptions: { ecmaFeatures: { jsx: true } } 319 | }, 320 | code: stripIndent` 321 | import { task } from "fp-ts" 322 | 323 | function Foo(props: { handlerVoid: () => void; handlerUnknown: () => unknown }) { 324 | return null 325 | } 326 | 327 | const myCommand = task.of(42) 328 | 329 | const myComponent = myCommand} handlerUnknown={() => myCommand} /> 330 | `, 331 | errors: [ 332 | { 333 | messageId: "discardedDataTypeJsx", 334 | data: { 335 | jsxAttributeName: "handlerVoid", 336 | expectedReturnType: "void", 337 | dataType: "Task", 338 | }, 339 | }, 340 | { 341 | messageId: "discardedDataTypeJsx", 342 | data: { 343 | jsxAttributeName: "handlerUnknown", 344 | expectedReturnType: "unknown", 345 | dataType: "Task", 346 | }, 347 | }, 348 | ], 349 | }, 350 | { 351 | languageOptions: { 352 | parserOptions: { ecmaFeatures: { jsx: true } } 353 | }, 354 | code: stripIndent` 355 | import { task } from "fp-ts" 356 | 357 | const myCommand = task.of(42) 358 | 359 | const myComponent =
myCommand} /> 360 | `, 361 | errors: [ 362 | { 363 | messageId: "discardedDataTypeJsx", 364 | data: { 365 | jsxAttributeName: "onClick", 366 | expectedReturnType: "void", 367 | dataType: "Task", 368 | }, 369 | }, 370 | ], 371 | }, 372 | { 373 | code: stripIndent` 374 | import { task } from "fp-ts" 375 | 376 | function foo(arg1: number, callbackVoid: () => void, callbackUnknown: () => unknown) { 377 | return null 378 | } 379 | 380 | const myCommand = task.of(42) 381 | 382 | foo(2, () => myCommand, () => myCommand) 383 | `, 384 | errors: [ 385 | { 386 | messageId: "discardedDataTypeArgument", 387 | data: { 388 | functionName: "foo", 389 | expectedReturnType: "void", 390 | dataType: "Task", 391 | }, 392 | }, 393 | { 394 | messageId: "discardedDataTypeArgument", 395 | data: { 396 | functionName: "foo", 397 | expectedReturnType: "unknown", 398 | dataType: "Task", 399 | }, 400 | }, 401 | ], 402 | }, 403 | ], 404 | }); 405 | -------------------------------------------------------------------------------- /tests/rules/no-lib-imports.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/no-lib-imports"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester"; 3 | 4 | const ruleTester = new RuleTester({ 5 | languageOptions: { 6 | parserOptions: { 7 | sourceType: "module", 8 | }, 9 | } 10 | }); 11 | 12 | ruleTester.run("no-lib-imports", rule, { 13 | valid: [ 14 | 'import { Option } from "fp-ts/Option"', 15 | "import { Option } from 'fp-ts/Option'", 16 | 'import { option } from "fp-ts"', 17 | "import { option } from 'fp-ts'", 18 | 'import { option } from "library/fp-ts/lib"', 19 | "import { option } from 'library/fp-ts/lib'", 20 | ], 21 | invalid: [ 22 | { 23 | code: 'import { Option } from "fp-ts/lib/Option"', 24 | errors: [ 25 | { 26 | messageId: "importNotAllowed", 27 | }, 28 | ], 29 | output: 'import { Option } from "fp-ts/Option"', 30 | }, 31 | { 32 | code: "import { Option } from 'fp-ts/lib/Option'", 33 | errors: [ 34 | { 35 | messageId: "importNotAllowed", 36 | }, 37 | ], 38 | output: "import { Option } from 'fp-ts/Option'", 39 | }, 40 | ], 41 | }); 42 | -------------------------------------------------------------------------------- /tests/rules/no-module-imports.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/no-module-imports"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester"; 3 | import { stripIndent } from "common-tags"; 4 | 5 | const ruleTester = new RuleTester({ 6 | languageOptions: { 7 | parserOptions: { 8 | sourceType: "module", 9 | }, 10 | } 11 | }); 12 | 13 | ruleTester.run("no-module-imports", rule, { 14 | valid: [ 15 | 'import { option } from "fp-ts"', 16 | 'import { pipe } from "fp-ts/function"', 17 | 'import { pipe } from "fp-ts/lib/function"', 18 | { 19 | code: stripIndent` 20 | import { Option } from "fp-ts/Option" 21 | import { option } from "fp-ts" 22 | const x: Option = option.some(2) 23 | `, 24 | options: [{ allowTypes: true }], 25 | }, 26 | { 27 | code: stripIndent` 28 | import { sequenceS } from "fp-ts/Apply" 29 | `, 30 | options: [{ allowedModules: ["Apply"] }], 31 | }, 32 | ], 33 | invalid: [ 34 | { 35 | code: stripIndent` 36 | import { option } from "fp-ts" 37 | import { Option } from "fp-ts/Option" 38 | 39 | const x: Option = option.some(2) 40 | `, 41 | errors: [ 42 | { 43 | messageId: "importNotAllowed", 44 | }, 45 | ], 46 | output: stripIndent` 47 | import { option } from "fp-ts" 48 | 49 | const x: option.Option = option.some(2) 50 | `, 51 | }, 52 | { 53 | code: stripIndent` 54 | import { none, Option, some } from "fp-ts/Option" 55 | 56 | const x: Option = some(2) 57 | const y: Option = none 58 | `, 59 | options: [{ allowTypes: true }], 60 | errors: [ 61 | { 62 | messageId: "importValuesNotAllowed", 63 | }, 64 | ], 65 | output: stripIndent` 66 | import { Option, } from "fp-ts/Option" 67 | import { option } from "fp-ts" 68 | 69 | const x: Option = option.some(2) 70 | const y: Option = option.none 71 | `, 72 | }, 73 | { 74 | code: stripIndent` 75 | import { option } from "fp-ts/Option" 76 | 77 | const x = option.some(2) 78 | `, 79 | errors: [ 80 | { 81 | messageId: "importNotAllowed", 82 | }, 83 | ], 84 | output: stripIndent` 85 | import { option } from "fp-ts" 86 | 87 | const x = option.some(2) 88 | `, 89 | }, 90 | { 91 | code: stripIndent` 92 | import { some } from "fp-ts/Option" 93 | 94 | const v = some(42) 95 | `, 96 | errors: [ 97 | { 98 | messageId: "importNotAllowed", 99 | }, 100 | ], 101 | output: stripIndent` 102 | import { option } from "fp-ts" 103 | 104 | const v = option.some(42) 105 | `, 106 | }, 107 | { 108 | code: stripIndent` 109 | import { array } from "fp-ts" 110 | import { some, none, fromNullable } from "fp-ts/lib/Option" 111 | 112 | const v = some(42) 113 | const y = none 114 | const z = fromNullable(null) 115 | `, 116 | errors: [ 117 | { 118 | messageId: "importNotAllowed", 119 | }, 120 | ], 121 | output: stripIndent` 122 | import { array, option } from "fp-ts" 123 | 124 | const v = option.some(42) 125 | const y = option.none 126 | const z = option.fromNullable(null) 127 | `, 128 | }, 129 | { 130 | code: stripIndent` 131 | import { some } from 'fp-ts/Option' 132 | 133 | const v = some(42) 134 | `, 135 | errors: [ 136 | { 137 | messageId: "importNotAllowed", 138 | }, 139 | ], 140 | output: stripIndent` 141 | import { option } from 'fp-ts' 142 | 143 | const v = option.some(42) 144 | `, 145 | }, 146 | ], 147 | }); 148 | -------------------------------------------------------------------------------- /tests/rules/no-pipeable.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/no-pipeable"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester"; 3 | 4 | const ruleTester = new RuleTester({ 5 | languageOptions: { 6 | parserOptions: { 7 | sourceType: "module", 8 | }, 9 | } 10 | }); 11 | 12 | ruleTester.run("no-pipeable", rule, { 13 | valid: [ 14 | 'import { pipe } from "fp-ts/function"', 15 | 'import { pipe } from "fp-ts/lib/function"', 16 | ], 17 | invalid: [ 18 | { 19 | code: 'import { pipe } from "fp-ts/lib/pipeable"', 20 | errors: [ 21 | { 22 | messageId: "importPipeFromFunction", 23 | }, 24 | ], 25 | output: 'import { pipe } from "fp-ts/function"', 26 | }, 27 | { 28 | code: 'import { pipe } from "fp-ts/pipeable"', 29 | errors: [ 30 | { 31 | messageId: "importPipeFromFunction", 32 | }, 33 | ], 34 | output: 'import { pipe } from "fp-ts/function"', 35 | }, 36 | { 37 | code: "import { pipe } from 'fp-ts/pipeable'", 38 | errors: [ 39 | { 40 | messageId: "importPipeFromFunction", 41 | }, 42 | ], 43 | output: "import { pipe } from 'fp-ts/function'", 44 | }, 45 | { 46 | code: 'import { pipeable } from "fp-ts/pipeable"', 47 | errors: [ 48 | { 49 | messageId: "pipeableIsDeprecated", 50 | }, 51 | ], 52 | }, 53 | ], 54 | }); 55 | -------------------------------------------------------------------------------- /tests/rules/no-redundant-flow.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/no-redundant-flow"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester"; 3 | 4 | const ruleTester = new RuleTester({ 5 | languageOptions: { 6 | parserOptions: { 7 | sourceType: "module", 8 | }, 9 | } 10 | }); 11 | 12 | ruleTester.run("no-redundant-flow", rule, { 13 | valid: [ 14 | `import { flow } from "fp-ts/function" 15 | flow(foo, bar) 16 | `, 17 | `import { flow } from "fp-ts/function" 18 | flow( 19 | foo, 20 | bar 21 | ) 22 | `, 23 | `import { flow } from "fp-ts/function" 24 | flow(...fns) 25 | `, 26 | ], 27 | invalid: [ 28 | { 29 | code: ` 30 | import { flow } from "fp-ts/function" 31 | const a = flow(foo) 32 | `, 33 | errors: [ 34 | { 35 | messageId: "redundantFlow", 36 | suggestions: [ 37 | { 38 | messageId: "removeFlow", 39 | output: ` 40 | import { flow } from "fp-ts/function" 41 | const a = foo 42 | `, 43 | }, 44 | ], 45 | }, 46 | ], 47 | }, 48 | { 49 | code: ` 50 | import { flow } from "fp-ts/function" 51 | const a = flow( 52 | foo 53 | ) 54 | `, 55 | errors: [ 56 | { 57 | messageId: "redundantFlow", 58 | suggestions: [ 59 | { 60 | messageId: "removeFlow", 61 | output: ` 62 | import { flow } from "fp-ts/function" 63 | const a = foo 64 | `, 65 | }, 66 | ], 67 | }, 68 | ], 69 | }, 70 | { 71 | code: ` 72 | import { flow } from "fp-ts/function" 73 | const a = flow( 74 | foo, 75 | ); 76 | `, 77 | errors: [ 78 | { 79 | messageId: "redundantFlow", 80 | suggestions: [ 81 | { 82 | messageId: "removeFlow", 83 | output: ` 84 | import { flow } from "fp-ts/function" 85 | const a = foo; 86 | `, 87 | }, 88 | ], 89 | }, 90 | ], 91 | } 92 | ], 93 | }); 94 | -------------------------------------------------------------------------------- /tests/rules/prefer-bimap.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/prefer-bimap"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester"; 3 | import { stripIndent } from "common-tags"; 4 | 5 | const ruleTester = new RuleTester(); 6 | 7 | ruleTester.run("prefer-bimap", rule, { 8 | valid: [ 9 | { 10 | code: stripIndent` 11 | import { either } from "fp-ts" 12 | import { pipe } from "fp-ts/function" 13 | 14 | pipe( 15 | getResult(), 16 | either.bimap(e => e.toString(), a => a.toString()) 17 | ) 18 | `, 19 | }, 20 | { 21 | code: stripIndent` 22 | import { either } from "fp-ts" 23 | import { pipe } from "fp-ts/function" 24 | 25 | pipe( 26 | getResult(), 27 | either.mapLeft(e => e.toString()), 28 | either.mapLeft(e => e.toString()), 29 | ) 30 | `, 31 | }, 32 | ], 33 | invalid: [ 34 | { 35 | code: stripIndent` 36 | import { either } from "fp-ts" 37 | import { pipe } from "fp-ts/function" 38 | 39 | pipe( 40 | getResult(), 41 | either.map( 42 | a => a.toString() 43 | ), 44 | either.mapLeft( 45 | e => e.toString() 46 | ) 47 | ) 48 | `, 49 | errors: [ 50 | { 51 | messageId: "mapMapLeftIsBimap", 52 | suggestions: [ 53 | { 54 | messageId: "replaceMapMapLeftBimap", 55 | output: stripIndent` 56 | import { either } from "fp-ts" 57 | import { pipe } from "fp-ts/function" 58 | 59 | pipe( 60 | getResult(), 61 | either.bimap( 62 | e => e.toString(), 63 | a => a.toString() 64 | ) 65 | ) 66 | `, 67 | }, 68 | ], 69 | }, 70 | ], 71 | }, 72 | { 73 | code: stripIndent` 74 | import { either } from "fp-ts" 75 | import { pipe } from "fp-ts/function" 76 | 77 | pipe( 78 | getResult(), 79 | either.mapLeft( 80 | e => e.toString() 81 | ), 82 | either.map( 83 | a => a.toString() 84 | ) 85 | ) 86 | `, 87 | errors: [ 88 | { 89 | messageId: "mapMapLeftIsBimap", 90 | suggestions: [ 91 | { 92 | messageId: "replaceMapMapLeftBimap", 93 | output: stripIndent` 94 | import { either } from "fp-ts" 95 | import { pipe } from "fp-ts/function" 96 | 97 | pipe( 98 | getResult(), 99 | either.bimap( 100 | e => e.toString(), 101 | a => a.toString() 102 | ) 103 | ) 104 | `, 105 | }, 106 | ], 107 | }, 108 | ], 109 | }, 110 | { 111 | code: stripIndent` 112 | import { either } from "fp-ts" 113 | import { pipe } from "fp-ts/function" 114 | 115 | pipe( 116 | getResult(), 117 | either.mapLeft(e => e.toString()), 118 | either.map(a => a.toString()) 119 | ) 120 | `, 121 | errors: [ 122 | { 123 | messageId: "mapMapLeftIsBimap", 124 | suggestions: [ 125 | { 126 | messageId: "replaceMapMapLeftBimap", 127 | output: stripIndent` 128 | import { either } from "fp-ts" 129 | import { pipe } from "fp-ts/function" 130 | 131 | pipe( 132 | getResult(), 133 | either.bimap( 134 | e => e.toString(), 135 | a => a.toString() 136 | ) 137 | ) 138 | `, 139 | }, 140 | ], 141 | }, 142 | ], 143 | }, 144 | { 145 | code: stripIndent` 146 | import { mapLeft, map } from "fp-ts/Either" 147 | import { pipe } from "fp-ts/function" 148 | 149 | pipe( 150 | getResult(), 151 | mapLeft(e => e.toString()), 152 | map(a => a.toString()) 153 | ) 154 | `, 155 | errors: [ 156 | { 157 | messageId: "mapMapLeftIsBimap", 158 | suggestions: [ 159 | { 160 | messageId: "replaceMapMapLeftBimap", 161 | output: stripIndent` 162 | import { mapLeft, map } from "fp-ts/Either" 163 | import { pipe } from "fp-ts/function" 164 | 165 | pipe( 166 | getResult(), 167 | bimap( 168 | e => e.toString(), 169 | a => a.toString() 170 | ) 171 | ) 172 | `, 173 | }, 174 | ], 175 | }, 176 | ], 177 | }, 178 | ], 179 | }); 180 | -------------------------------------------------------------------------------- /tests/rules/prefer-chain.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/prefer-chain"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester"; 3 | 4 | const ruleTester = new RuleTester(); 5 | 6 | ruleTester.run("prefer-chain", rule, { 7 | valid: [ 8 | { 9 | code: ` 10 | import { array, option } from "fp-ts" 11 | import { pipe } from "fp-ts/function" 12 | 13 | pipe( 14 | [1, 2, 3], 15 | array.map(option.some), 16 | option.chain(x => x) 17 | ) 18 | `, 19 | }, 20 | ], 21 | invalid: [ 22 | { 23 | code: ` 24 | import { option } from "fp-ts" 25 | import { pipe } from "fp-ts/function" 26 | 27 | pipe( 28 | option.some(1), 29 | option.map(option.some), 30 | option.flatten 31 | ) 32 | `, 33 | errors: [ 34 | { 35 | messageId: "mapFlattenIsChain", 36 | suggestions: [ 37 | { 38 | messageId: "replaceMapFlattenWithChain", 39 | output: ` 40 | import { option } from "fp-ts" 41 | import { pipe } from "fp-ts/function" 42 | 43 | pipe( 44 | option.some(1), 45 | option.chain(option.some) 46 | ) 47 | `, 48 | }, 49 | ], 50 | }, 51 | ], 52 | }, 53 | { 54 | code: ` 55 | import { map, flatten } from "fp-ts/Array" 56 | import { pipe } from "fp-ts/function" 57 | 58 | pipe( 59 | [1, 2, 3], 60 | map(a => [a]), 61 | flatten 62 | ) 63 | `, 64 | errors: [ 65 | { 66 | messageId: "mapFlattenIsChain", 67 | suggestions: [ 68 | { 69 | messageId: "replaceMapFlattenWithChain", 70 | output: ` 71 | import { map, flatten } from "fp-ts/Array" 72 | import { pipe } from "fp-ts/function" 73 | 74 | pipe( 75 | [1, 2, 3], 76 | chain(a => [a]) 77 | ) 78 | `, 79 | }, 80 | ], 81 | }, 82 | ], 83 | }, 84 | { 85 | code: ` 86 | import { array } from "fp-ts" 87 | import { flow } from "fp-ts/function" 88 | 89 | flow( 90 | array.map(a => [a]), 91 | array.flatten 92 | ) 93 | `, 94 | errors: [ 95 | { 96 | messageId: "mapFlattenIsChain", 97 | suggestions: [ 98 | { 99 | messageId: "replaceMapFlattenWithChain", 100 | output: ` 101 | import { array } from "fp-ts" 102 | import { flow } from "fp-ts/function" 103 | 104 | flow( 105 | array.chain(a => [a]) 106 | ) 107 | `, 108 | }, 109 | ], 110 | }, 111 | ], 112 | }, 113 | { 114 | code: ` 115 | import { mapWithIndex } from "fp-ts/Array" 116 | import { pipe } from "fp-ts/function" 117 | 118 | pipe( 119 | [1, 2, 3], 120 | mapWithIndex(a => [a]), 121 | flatten 122 | ) 123 | `, 124 | errors: [ 125 | { 126 | messageId: "mapFlattenIsChain", 127 | suggestions: [ 128 | { 129 | messageId: "replaceMapFlattenWithChain", 130 | output: ` 131 | import { mapWithIndex } from "fp-ts/Array" 132 | import { pipe } from "fp-ts/function" 133 | 134 | pipe( 135 | [1, 2, 3], 136 | chainWithIndex(a => [a]) 137 | ) 138 | `, 139 | }, 140 | ], 141 | }, 142 | ], 143 | }, 144 | ], 145 | }); 146 | -------------------------------------------------------------------------------- /tests/rules/prefer-traverse.test.ts: -------------------------------------------------------------------------------- 1 | import rule from "../../src/rules/prefer-traverse"; 2 | import { RuleTester } from "@typescript-eslint/rule-tester" 3 | 4 | const ruleTester = new RuleTester(); 5 | 6 | ruleTester.run("prefer-traverse", rule, { 7 | valid: [ 8 | { 9 | code: ` 10 | import { array, option } from "fp-ts" 11 | import { pipe } from "fp-ts/function" 12 | 13 | pipe( 14 | [1, 2, 3], 15 | array.map(option.some), 16 | option.sequence(option.option) 17 | ) 18 | `, 19 | }, 20 | ], 21 | invalid: [ 22 | { 23 | code: ` 24 | import { array, option } from "fp-ts" 25 | import { pipe } from "fp-ts/function" 26 | 27 | pipe( 28 | [1, 2, 3], 29 | array.map(option.some), 30 | array.sequence(option.option) 31 | ) 32 | `, 33 | errors: [ 34 | { 35 | messageId: "mapSequenceIsTraverse", 36 | suggestions: [ 37 | { 38 | messageId: "replaceMapSequenceWithTraverse", 39 | output: ` 40 | import { array, option } from "fp-ts" 41 | import { pipe } from "fp-ts/function" 42 | 43 | pipe( 44 | [1, 2, 3], 45 | array.traverse(option.option)(option.some) 46 | ) 47 | `, 48 | }, 49 | ], 50 | }, 51 | ], 52 | }, 53 | { 54 | code: ` 55 | import { map, sequence } from "fp-ts/Array" 56 | import { pipe } from "fp-ts/function" 57 | 58 | pipe( 59 | [1, 2, 3], 60 | map(option.some), 61 | sequence(option.option) 62 | ) 63 | `, 64 | errors: [ 65 | { 66 | messageId: "mapSequenceIsTraverse", 67 | suggestions: [ 68 | { 69 | messageId: "replaceMapSequenceWithTraverse", 70 | output: ` 71 | import { map, sequence } from "fp-ts/Array" 72 | import { pipe } from "fp-ts/function" 73 | 74 | pipe( 75 | [1, 2, 3], 76 | traverse(option.option)(option.some) 77 | ) 78 | `, 79 | }, 80 | ], 81 | }, 82 | ], 83 | }, 84 | { 85 | code: ` 86 | import { array, option } from "fp-ts" 87 | import { flow } from "fp-ts/function" 88 | 89 | flow( 90 | array.map(option.some), 91 | array.sequence(option.option) 92 | ) 93 | `, 94 | errors: [ 95 | { 96 | messageId: "mapSequenceIsTraverse", 97 | suggestions: [ 98 | { 99 | messageId: "replaceMapSequenceWithTraverse", 100 | output: ` 101 | import { array, option } from "fp-ts" 102 | import { flow } from "fp-ts/function" 103 | 104 | flow( 105 | array.traverse(option.option)(option.some) 106 | ) 107 | `, 108 | }, 109 | ], 110 | }, 111 | ], 112 | }, 113 | { 114 | code: ` 115 | import { mapWithIndex, sequence } from "fp-ts/Array" 116 | import { pipe } from "fp-ts/function" 117 | 118 | pipe( 119 | [1, 2, 3], 120 | mapWithIndex(option.some), 121 | sequence(option.option) 122 | ) 123 | `, 124 | errors: [ 125 | { 126 | messageId: "mapSequenceIsTraverse", 127 | suggestions: [ 128 | { 129 | messageId: "replaceMapSequenceWithTraverse", 130 | output: ` 131 | import { mapWithIndex, sequence } from "fp-ts/Array" 132 | import { pipe } from "fp-ts/function" 133 | 134 | pipe( 135 | [1, 2, 3], 136 | traverseWithIndex(option.option)(option.some) 137 | ) 138 | `, 139 | }, 140 | ], 141 | }, 142 | ], 143 | }, 144 | ], 145 | }); 146 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 4 | "module": "CommonJS", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 5 | "outDir": "lib", /* Redirect output structure to the directory. */ 6 | "strict": true, /* Enable all strict type-checking options. */ 7 | "noUnusedLocals": true, /* Report errors on unused locals. */ 8 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 9 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 10 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 11 | "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 12 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 13 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 14 | "forceConsistentCasingInFileNames": true, 15 | "lib": ["ES2019"] 16 | }, 17 | "include": ["src"] 18 | } 19 | --------------------------------------------------------------------------------