├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── ci.yml ├── .npmrc ├── CONTRIBUTING.md ├── pnpm-workspace.yaml ├── .gitignore ├── vitest.config.ts ├── src ├── rules │ ├── import-dedupe.md │ ├── if-newline.md │ ├── _test.ts │ ├── curly.md │ ├── if-newline.test.ts │ ├── no-import-dist.test.ts │ ├── import-dedupe.test.ts │ ├── no-ts-export-equal.test.ts │ ├── consistent-chaining.md │ ├── no-import-node-modules-by-path.test.ts │ ├── no-top-level-await.test.ts │ ├── no-ts-export-equal.ts │ ├── consistent-list-newline.md │ ├── no-import-dist.ts │ ├── top-level-function.md │ ├── no-top-level-await.ts │ ├── if-newline.ts │ ├── no-import-node-modules-by-path.ts │ ├── indent-unindent.test.ts │ ├── import-dedupe.ts │ ├── indent-unindent.md │ ├── indent-unindent.ts │ ├── top-level-function.test.ts │ ├── top-level-function.ts │ ├── curly.ts │ ├── curly.test.ts │ ├── __snapshots__ │ │ └── consistent-list-newline.test.ts.snap │ ├── consistent-chaining.ts │ ├── consistent-chaining.test.ts │ ├── consistent-list-newline.ts │ └── consistent-list-newline.test.ts ├── index.ts └── utils.ts ├── tsconfig.json ├── eslint.config.js ├── README.md ├── LICENSE ├── .vscode └── settings.json └── package.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [antfu] 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true 2 | shamefully-hoist=true 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Please refer to https://github.com/antfu/contribute 2 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - playground 3 | - examples/* 4 | onlyBuiltDependencies: 5 | - esbuild 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .idea 4 | *.log 5 | *.tgz 6 | coverage 7 | dist 8 | lib-cov 9 | logs 10 | node_modules 11 | temp 12 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | reporters: 'dot', 7 | }, 8 | }) 9 | -------------------------------------------------------------------------------- /src/rules/import-dedupe.md: -------------------------------------------------------------------------------- 1 | # import-dedupe 2 | 3 | Auto-fix import deduplication. 4 | 5 | ## Rule Details 6 | 7 | 8 | ```js 9 | // 👎 bad 10 | import { Foo, Bar, Foo } from 'foo' 11 | ``` 12 | 13 | Will be fixed to: 14 | 15 | 16 | ```js 17 | // 👍 good 18 | import { Foo, Bar } from 'foo' 19 | ``` 20 | -------------------------------------------------------------------------------- /src/rules/if-newline.md: -------------------------------------------------------------------------------- 1 | # if-newline 2 | 3 | Enforce line breaks between `if` statements and their consequent / alternate expressions. Only applicable for inline `if` statements. 4 | 5 | ## Rule Details 6 | 7 | 8 | ```js 9 | // 👎 bad 10 | if (foo) bar() 11 | ``` 12 | 13 | 14 | ```js 15 | // 👍 good 16 | if (foo) 17 | bar() 18 | ``` 19 | -------------------------------------------------------------------------------- /src/rules/_test.ts: -------------------------------------------------------------------------------- 1 | import type { RuleTesterInitOptions, TestCasesOptions } from 'eslint-vitest-rule-tester' 2 | import tsParser from '@typescript-eslint/parser' 3 | import { run as _run } from 'eslint-vitest-rule-tester' 4 | 5 | export function run(options: TestCasesOptions & RuleTesterInitOptions): void { 6 | _run({ 7 | parser: tsParser as any, 8 | ...options, 9 | }) 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2020", 4 | "lib": ["esnext"], 5 | "module": "esnext", 6 | "moduleResolution": "Bundler", 7 | "resolveJsonModule": true, 8 | "types": [ 9 | "vitest/globals" 10 | ], 11 | "strict": true, 12 | "strictNullChecks": true, 13 | "esModuleInterop": true, 14 | "skipDefaultLibCheck": true, 15 | "skipLibCheck": true 16 | }, 17 | "include": [ 18 | "src" 19 | ], 20 | "exclude": [ 21 | "vendor" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/rules/curly.md: -------------------------------------------------------------------------------- 1 | # curly 2 | 3 | Anthony's opinionated taste with curly. Simliar to eslint's builtin curly: [`['error', 'multi-or-nest', 'consistent']`](https://eslint.org/docs/latest/rules/curly#consistent) but allows both curly and non-curly on one-liner. This rule is not configurable. 4 | 5 | ## Rule Details 6 | 7 | 8 | ```js 9 | // 👍 ok 10 | if (foo) { 11 | bar() 12 | } 13 | 14 | // 👍 ok 15 | if (foo) 16 | bar() 17 | ``` 18 | 19 | 20 | ```js 21 | // 👎 bad 22 | if (foo) 23 | const obj = { 24 | bar: 'bar' 25 | } 26 | ``` 27 | -------------------------------------------------------------------------------- /src/rules/if-newline.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from './_test' 2 | import rule, { RULE_NAME } from './if-newline' 3 | 4 | const valids = [ 5 | `if (true) 6 | console.log('hello') 7 | `, 8 | `if (true) { 9 | console.log('hello') 10 | }`, 11 | ] 12 | const invalids = [ 13 | ['if (true) console.log(\'hello\')', 'if (true) \nconsole.log(\'hello\')'], 14 | ] 15 | 16 | run({ 17 | name: RULE_NAME, 18 | rule, 19 | valid: valids, 20 | invalid: invalids.map(i => ({ 21 | code: i[0], 22 | output: i[1], 23 | errors: [{ messageId: 'missingIfNewline' }], 24 | })), 25 | }) 26 | -------------------------------------------------------------------------------- /src/rules/no-import-dist.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from './_test' 2 | import rule, { RULE_NAME } from './no-import-dist' 3 | 4 | const valids = [ 5 | 'import xxx from "a"', 6 | 'import "b"', 7 | 'import "floating-vue/dist/foo.css"', 8 | ] 9 | 10 | const invalids = [ 11 | 'import a from "../dist/a"', 12 | 'import "../dist/b"', 13 | 'import b from \'dist\'', 14 | 'import c from \'./dist\'', 15 | ] 16 | 17 | run({ 18 | name: RULE_NAME, 19 | rule, 20 | valid: valids, 21 | invalid: invalids.map(i => ({ 22 | code: i, 23 | errors: [{ messageId: 'noImportDist' }], 24 | })), 25 | }) 26 | -------------------------------------------------------------------------------- /src/rules/import-dedupe.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from './_test' 2 | import rule, { RULE_NAME } from './import-dedupe' 3 | 4 | const valids = [ 5 | 'import { a } from \'foo\'', 6 | ] 7 | const invalids = [ 8 | [ 9 | 'import { a, b, a, a, c, a } from \'foo\'', 10 | 'import { a, b, c, } from \'foo\'', 11 | ], 12 | ] 13 | 14 | run({ 15 | name: RULE_NAME, 16 | rule, 17 | valid: valids, 18 | invalid: invalids.map(i => ({ 19 | code: i[0], 20 | output: i[1], 21 | errors: [{ messageId: 'importDedupe' }, { messageId: 'importDedupe' }, { messageId: 'importDedupe' }], 22 | })), 23 | }) 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | permissions: 4 | contents: write 5 | 6 | on: 7 | push: 8 | tags: 9 | - 'v*' 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 0 18 | 19 | - name: Install pnpm 20 | uses: pnpm/action-setup@v3 21 | 22 | - name: Set node 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: lts/* 26 | 27 | - run: npx changelogithub 28 | env: 29 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 30 | -------------------------------------------------------------------------------- /src/rules/no-ts-export-equal.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from './_test' 2 | import rule, { RULE_NAME } from './no-ts-export-equal' 3 | 4 | run({ 5 | name: RULE_NAME, 6 | rule, 7 | valid: [ 8 | { code: 'export default {}', filename: 'test.ts' }, 9 | { code: 'export = {}', filename: 'test.js' }, 10 | ], 11 | invalid: [ 12 | { 13 | code: 'export = {}', 14 | filename: 'test.ts', 15 | errors(errors) { 16 | expect(errors.map(i => i.message)) 17 | .toMatchInlineSnapshot(` 18 | [ 19 | "Use ESM \`export default\` instead", 20 | ] 21 | `) 22 | }, 23 | }, 24 | ], 25 | }) 26 | -------------------------------------------------------------------------------- /src/rules/consistent-chaining.md: -------------------------------------------------------------------------------- 1 | # consistent-chaining 2 | 3 | Enforce consistent line breaks for chaining member access. 4 | 5 | ## Rule Details 6 | 7 | 8 | ```js 9 | // 👎 bad 10 | const foo1 = [].map(x => x + 'bar') 11 | .filter(Boolean) 12 | 13 | const foo2 = [] 14 | .map(x => x + 'bar').filter(Boolean) 15 | ``` 16 | 17 | 18 | ```js 19 | // 👍 good 20 | const foo1 = [].map(x => x + 'bar').filter(Boolean) 21 | 22 | const foo2 = [] 23 | .map(x => x + 'bar') 24 | .filter(Boolean) 25 | ``` 26 | 27 | It will check the newline style of the **first** property access and apply the same style to the rest of the chaining access. 28 | -------------------------------------------------------------------------------- /src/rules/no-import-node-modules-by-path.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from './_test' 2 | import rule, { RULE_NAME } from './no-import-node-modules-by-path' 3 | 4 | const valids = [ 5 | 'import xxx from "a"', 6 | 'import "b"', 7 | 'const c = require("c")', 8 | 'require("d")', 9 | ] 10 | 11 | const invalids = [ 12 | 'import a from "../node_modules/a"', 13 | 'import "../node_modules/b"', 14 | 'const c = require("../node_modules/c")', 15 | 'require("../node_modules/d")', 16 | ] 17 | 18 | run({ 19 | name: RULE_NAME, 20 | rule, 21 | valid: valids, 22 | invalid: invalids.map(i => ({ 23 | code: i, 24 | errors: [{ messageId: 'noImportNodeModulesByPath' }], 25 | })), 26 | }) 27 | -------------------------------------------------------------------------------- /src/rules/no-top-level-await.test.ts: -------------------------------------------------------------------------------- 1 | import { unindent as $ } from 'eslint-vitest-rule-tester' 2 | import { run } from './_test' 3 | import rule, { RULE_NAME } from './no-top-level-await' 4 | 5 | const valids = [ 6 | 'async function foo() { await bar() }', 7 | $` 8 | const a = async () => { 9 | await bar() 10 | } 11 | `, 12 | ] 13 | 14 | const invalids = [ 15 | 'await foo()', 16 | 17 | $` 18 | function foo() { 19 | 20 | } 21 | 22 | await foo() 23 | `, 24 | $` 25 | const a = { 26 | foo: await bar() 27 | } 28 | `, 29 | ] 30 | 31 | run({ 32 | name: RULE_NAME, 33 | rule, 34 | valid: valids, 35 | invalid: invalids.map(i => ({ 36 | code: i, 37 | errors: [{ messageId: 'NoTopLevelAwait' }], 38 | })), 39 | }) 40 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | import { tsImport } from 'tsx/esm/api' 3 | 4 | const local = await tsImport('./src/index.ts', import.meta.url).then(r => r.default) 5 | 6 | export default antfu( 7 | { 8 | type: 'lib', 9 | }, 10 | { 11 | ignores: ['vendor'], 12 | }, 13 | { 14 | name: 'tests', 15 | files: ['**/*.test.ts'], 16 | rules: { 17 | 'antfu/indent-unindent': 'error', 18 | }, 19 | }, 20 | { 21 | rules: { 22 | 'unicorn/consistent-function-scoping': 'off', 23 | 'antfu/consistent-chaining': 'error', 24 | }, 25 | }, 26 | ) 27 | // replace local config 28 | .onResolved((configs) => { 29 | configs.forEach((config) => { 30 | if (config?.plugins?.antfu) 31 | config.plugins.antfu = local 32 | }) 33 | }) 34 | -------------------------------------------------------------------------------- /src/rules/no-ts-export-equal.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | 3 | export const RULE_NAME = 'no-ts-export-equal' 4 | export type MessageIds = 'noTsExportEqual' 5 | export type Options = [] 6 | 7 | export default createEslintRule({ 8 | name: RULE_NAME, 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Do not use `exports =`', 13 | }, 14 | schema: [], 15 | messages: { 16 | noTsExportEqual: 'Use ESM `export default` instead', 17 | }, 18 | }, 19 | defaultOptions: [], 20 | create: (context) => { 21 | const extension = context.getFilename().split('.').pop() 22 | if (!extension) 23 | return {} 24 | if (!['ts', 'tsx', 'mts', 'cts'].includes(extension)) 25 | return {} 26 | 27 | return { 28 | TSExportAssignment(node) { 29 | context.report({ 30 | node, 31 | messageId: 'noTsExportEqual', 32 | }) 33 | }, 34 | } 35 | }, 36 | }) 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-antfu 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | 6 | Anthony extended ESLint rules. For [antfu/eslint-config](https://github.com/antfu/eslint-config). 7 | 8 | [Rules List](./src/rules) 9 | 10 | ## Sponsors 11 | 12 |

13 | 14 | 15 | 16 |

17 | 18 | ## License 19 | 20 | [MIT](./LICENSE) License © 2023-PRESENT [Anthony Fu](https://github.com/antfu) 21 | 22 | 23 | 24 | [npm-version-src]: https://img.shields.io/npm/v/eslint-plugin-antfu?style=flat&colorA=080f12&colorB=1fa669 25 | [npm-version-href]: https://npmjs.com/package/eslint-plugin-antfu 26 | [npm-downloads-src]: https://img.shields.io/npm/dm/eslint-plugin-antfu?style=flat&colorA=080f12&colorB=1fa669 27 | [npm-downloads-href]: https://npmjs.com/package/eslint-plugin-antfu 28 | -------------------------------------------------------------------------------- /src/rules/consistent-list-newline.md: -------------------------------------------------------------------------------- 1 | # consistent-list-newline 2 | 3 | Enforce consistent line breaks inside braces of object/array/named imports/exports and function parameters. 4 | 5 | ## Rule Details 6 | 7 | 8 | ```js 9 | // 👎 bad 10 | const foo = { 11 | bar: 'baz', qux: 'quux', 12 | fez: 'fum' 13 | } 14 | ``` 15 | 16 | 17 | ```js 18 | // 👍 good 19 | const foo = { 20 | bar: 'baz', 21 | qux: 'quux', 22 | fez: 'fum' 23 | } 24 | 25 | // 👍 good 26 | const foo = { bar: 'baz', qux: 'quux', fez: 'fum' } 27 | ``` 28 | 29 | It will check the newline style of the **first** property or item and apply to the rest of the properties or items. So you can also use this rule to quite wrap / unwrap your code. 30 | 31 | ## Rule Conflicts 32 | 33 | This rule might conflicts with the [object-curly-newline](https://eslint.org/docs/rules/object-curly-newline). You can turn if off. 34 | 35 | ```ts 36 | export default { 37 | rules: { 38 | 'object-curly-newline': 'off', 39 | } 40 | } 41 | ``` 42 | -------------------------------------------------------------------------------- /src/rules/no-import-dist.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | 3 | export const RULE_NAME = 'no-import-dist' 4 | export type MessageIds = 'noImportDist' 5 | export type Options = [] 6 | 7 | export default createEslintRule({ 8 | name: RULE_NAME, 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Prevent importing modules in `dist` folder', 13 | }, 14 | schema: [], 15 | messages: { 16 | noImportDist: 'Do not import modules in `dist` folder, got {{path}}', 17 | }, 18 | }, 19 | defaultOptions: [], 20 | create: (context) => { 21 | function isDist(path: string): boolean { 22 | return Boolean((path.startsWith('.') && path.match(/\/dist(\/|$)/))) 23 | || path === 'dist' 24 | } 25 | 26 | return { 27 | ImportDeclaration: (node) => { 28 | if (isDist(node.source.value)) { 29 | context.report({ 30 | node, 31 | messageId: 'noImportDist', 32 | data: { 33 | path: node.source.value, 34 | }, 35 | }) 36 | } 37 | }, 38 | } 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-PRESENT Anthony Fu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/rules/top-level-function.md: -------------------------------------------------------------------------------- 1 | # top-level-function 2 | 3 | Enforce top-level function to be declared using `function` instead of arrow function or function expression. With auto-fix. 4 | 5 | ## Rule Details 6 | 7 | 8 | ```ts 9 | // 👎 bad 10 | export const square = (a: number, b: number): number => { 11 | const a2 = a * a 12 | const b2 = b * b 13 | return a2 + b2 + 2 * a * b 14 | } 15 | ``` 16 | 17 | 18 | ```ts 19 | // 👎 bad 20 | export const square = function (a: number, b: number): number { 21 | const a2 = a * a 22 | const b2 = b * b 23 | return a2 + b2 + 2 * a * b 24 | } 25 | ``` 26 | 27 | 28 | ```js 29 | // 👍 good 30 | export function square(a: number, b: number): number { 31 | const a2 = a * a 32 | const b2 = b * b 33 | return a2 + b2 + 2 * a * b 34 | } 35 | ``` 36 | 37 | ### Exceptions 38 | 39 | When the variable is assigned with types, it rule will ignore it. 40 | 41 | 42 | ```ts 43 | // 👍 ok 44 | export const square: MyFunction = (a: number, b: number): number => { 45 | const a2 = a * a 46 | const b2 = b * b 47 | return a2 + b2 + 2 * a * b 48 | } 49 | ``` 50 | -------------------------------------------------------------------------------- /src/rules/no-top-level-await.ts: -------------------------------------------------------------------------------- 1 | import type { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | 4 | export const RULE_NAME = 'no-top-level-await' 5 | export type MessageIds = 'NoTopLevelAwait' 6 | export type Options = [] 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | type: 'problem', 12 | docs: { 13 | description: 'Prevent using top-level await', 14 | }, 15 | schema: [], 16 | messages: { 17 | NoTopLevelAwait: 'Do not use top-level await', 18 | }, 19 | }, 20 | defaultOptions: [], 21 | create: (context) => { 22 | return { 23 | AwaitExpression: (node) => { 24 | let parent: TSESTree.Node | undefined = node.parent 25 | while (parent) { 26 | if (parent.type === 'FunctionDeclaration' || parent.type === 'FunctionExpression' || parent.type === 'ArrowFunctionExpression') { 27 | return 28 | } 29 | parent = parent.parent 30 | } 31 | context.report({ 32 | node, 33 | messageId: 'NoTopLevelAwait', 34 | }) 35 | }, 36 | } 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable the flat config support 3 | "eslint.experimental.useFlatConfig": true, 4 | 5 | // Disable the default formatter 6 | "prettier.enable": false, 7 | "editor.formatOnSave": false, 8 | 9 | // Auto fix 10 | "editor.codeActionsOnSave": { 11 | "source.fixAll.eslint": "explicit", 12 | "source.organizeImports": "never" 13 | }, 14 | 15 | "eslint.runtime": "node", 16 | 17 | // Silent the stylistic rules in you IDE, but still auto fix them 18 | "eslint.rules.customizations": [ 19 | { "rule": "@stylistic/*", "severity": "warn" }, 20 | { "rule": "style*", "severity": "warn" }, 21 | { "rule": "*-indent", "severity": "warn" }, 22 | { "rule": "*-spacing", "severity": "warn" }, 23 | { "rule": "*-spaces", "severity": "warn" }, 24 | { "rule": "*-order", "severity": "warn" }, 25 | { "rule": "*-dangle", "severity": "warn" }, 26 | { "rule": "*-newline", "severity": "warn" }, 27 | { "rule": "*quotes", "severity": "warn" }, 28 | { "rule": "*semi", "severity": "warn" } 29 | ], 30 | 31 | "eslint.validate": [ 32 | "javascript", 33 | "javascriptreact", 34 | "typescript", 35 | "typescriptreact", 36 | "vue", 37 | "html", 38 | "markdown", 39 | "json", 40 | "jsonc", 41 | "yaml" 42 | ] 43 | } 44 | -------------------------------------------------------------------------------- /src/rules/if-newline.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | 3 | export const RULE_NAME = 'if-newline' 4 | export type MessageIds = 'missingIfNewline' 5 | export type Options = [] 6 | 7 | export default createEslintRule({ 8 | name: RULE_NAME, 9 | meta: { 10 | type: 'layout', 11 | docs: { 12 | description: 'Newline after if', 13 | }, 14 | fixable: 'whitespace', 15 | schema: [], 16 | messages: { 17 | missingIfNewline: 'Expect newline after if', 18 | }, 19 | }, 20 | defaultOptions: [], 21 | create: (context) => { 22 | return { 23 | IfStatement(node) { 24 | if (!node.consequent) 25 | return 26 | if (node.consequent.type === 'BlockStatement') 27 | return 28 | if (node.test.loc.end.line === node.consequent.loc.start.line) { 29 | context.report({ 30 | node, 31 | loc: { 32 | start: node.test.loc.end, 33 | end: node.consequent.loc.start, 34 | }, 35 | messageId: 'missingIfNewline', 36 | fix(fixer) { 37 | return fixer.replaceTextRange([node.consequent.range[0], node.consequent.range[0]], '\n') 38 | }, 39 | }) 40 | } 41 | }, 42 | } 43 | }, 44 | }) 45 | -------------------------------------------------------------------------------- /src/rules/no-import-node-modules-by-path.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | 3 | export const RULE_NAME = 'no-import-node-modules-by-path' 4 | export type MessageIds = 'noImportNodeModulesByPath' 5 | export type Options = [] 6 | 7 | export default createEslintRule({ 8 | name: RULE_NAME, 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Prevent importing modules in `node_modules` folder by relative or absolute path', 13 | }, 14 | schema: [], 15 | messages: { 16 | noImportNodeModulesByPath: 'Do not import modules in `node_modules` folder by path', 17 | }, 18 | }, 19 | defaultOptions: [], 20 | create: (context) => { 21 | return { 22 | 'ImportDeclaration': (node) => { 23 | if (node.source.value.includes('/node_modules/')) { 24 | context.report({ 25 | node, 26 | messageId: 'noImportNodeModulesByPath', 27 | }) 28 | } 29 | }, 30 | 'CallExpression[callee.name="require"]': (node: any) => { 31 | const value = node.arguments[0]?.value 32 | if (typeof value === 'string' && value.includes('/node_modules/')) { 33 | context.report({ 34 | node, 35 | messageId: 'noImportNodeModulesByPath', 36 | }) 37 | } 38 | }, 39 | } 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /src/rules/indent-unindent.test.ts: -------------------------------------------------------------------------------- 1 | import { unindent as $ } from 'eslint-vitest-rule-tester' 2 | import { run } from './_test' 3 | import rule from './indent-unindent' 4 | 5 | run({ 6 | name: 'indent-unindent', 7 | rule, 8 | 9 | valid: [ 10 | $` 11 | const a = $\` 12 | b 13 | \` 14 | `, 15 | $` 16 | const a = foo\`b\` 17 | `, 18 | ], 19 | invalid: [ 20 | { 21 | code: $` 22 | const a = { 23 | foo: $\` 24 | if (true) 25 | return 1 26 | \` 27 | } 28 | `, 29 | output: $` 30 | const a = { 31 | foo: $\` 32 | if (true) 33 | return 1 34 | \` 35 | } 36 | `, 37 | }, 38 | { 39 | code: $` 40 | const a = $\` 41 | if (true) 42 | return 1\` 43 | `, 44 | output: $` 45 | const a = $\` 46 | if (true) 47 | return 1 48 | \` 49 | `, 50 | }, 51 | { 52 | description: 'should work with escapes', 53 | code: $` 54 | const a = $\` 55 | \\t\\t\\\`foo\\\` 56 | \\tbar 57 | \` 58 | `, 59 | output: $` 60 | const a = $\` 61 | \\t\\t\\\`foo\\\` 62 | \\tbar 63 | \` 64 | `, 65 | }, 66 | ], 67 | }) 68 | -------------------------------------------------------------------------------- /src/rules/import-dedupe.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | 3 | export const RULE_NAME = 'import-dedupe' 4 | export type MessageIds = 'importDedupe' 5 | export type Options = [] 6 | 7 | export default createEslintRule({ 8 | name: RULE_NAME, 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Fix duplication in imports', 13 | }, 14 | fixable: 'code', 15 | schema: [], 16 | messages: { 17 | importDedupe: 'Expect no duplication in imports', 18 | }, 19 | }, 20 | defaultOptions: [], 21 | create: (context) => { 22 | return { 23 | ImportDeclaration(node) { 24 | if (node.specifiers.length <= 1) 25 | return 26 | 27 | const names = new Set() 28 | node.specifiers.forEach((n) => { 29 | const id = n.local.name 30 | if (names.has(id)) { 31 | context.report({ 32 | node, 33 | loc: { 34 | start: n.loc.end, 35 | end: n.loc.start, 36 | }, 37 | messageId: 'importDedupe', 38 | fix(fixer) { 39 | const s = n.range[0] 40 | let e = n.range[1] 41 | if (context.getSourceCode().text[e] === ',') 42 | e += 1 43 | return fixer.removeRange([s, e]) 44 | }, 45 | }) 46 | } 47 | names.add(id) 48 | }) 49 | 50 | // console.log(node) 51 | }, 52 | } 53 | }, 54 | }) 55 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import type { ESLint, Linter } from 'eslint' 2 | import { version } from '../package.json' 3 | import consistentChaining from './rules/consistent-chaining' 4 | import consistentListNewline from './rules/consistent-list-newline' 5 | import curly from './rules/curly' 6 | import ifNewline from './rules/if-newline' 7 | import importDedupe from './rules/import-dedupe' 8 | import indentUnindent from './rules/indent-unindent' 9 | import noImportDist from './rules/no-import-dist' 10 | import noImportNodeModulesByPath from './rules/no-import-node-modules-by-path' 11 | import noTopLevelAwait from './rules/no-top-level-await' 12 | import noTsExportEqual from './rules/no-ts-export-equal' 13 | import topLevelFunction from './rules/top-level-function' 14 | 15 | const plugin = { 16 | meta: { 17 | name: 'antfu', 18 | version, 19 | }, 20 | // @keep-sorted 21 | rules: { 22 | 'consistent-chaining': consistentChaining, 23 | 'consistent-list-newline': consistentListNewline, 24 | 'curly': curly, 25 | 'if-newline': ifNewline, 26 | 'import-dedupe': importDedupe, 27 | 'indent-unindent': indentUnindent, 28 | 'no-import-dist': noImportDist, 29 | 'no-import-node-modules-by-path': noImportNodeModulesByPath, 30 | 'no-top-level-await': noTopLevelAwait, 31 | 'no-ts-export-equal': noTsExportEqual, 32 | 'top-level-function': topLevelFunction, 33 | }, 34 | } satisfies ESLint.Plugin 35 | 36 | export default plugin 37 | 38 | type RuleDefinitions = typeof plugin['rules'] 39 | 40 | export type RuleOptions = { 41 | [K in keyof RuleDefinitions]: RuleDefinitions[K]['defaultOptions'] 42 | } 43 | 44 | export type Rules = { 45 | [K in keyof RuleOptions]: Linter.RuleEntry 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v3 20 | 21 | - name: Set node 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: lts/* 25 | 26 | - name: Setup 27 | run: npm i -g @antfu/ni 28 | 29 | - name: Install 30 | run: nci 31 | 32 | - name: Lint 33 | run: nr lint 34 | 35 | typecheck: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - uses: actions/checkout@v4 39 | 40 | - name: Install pnpm 41 | uses: pnpm/action-setup@v3 42 | 43 | - name: Set node 44 | uses: actions/setup-node@v4 45 | with: 46 | node-version: lts/* 47 | 48 | - name: Setup 49 | run: npm i -g @antfu/ni 50 | 51 | - name: Install 52 | run: nci 53 | 54 | - name: Typecheck 55 | run: nr typecheck 56 | 57 | test: 58 | runs-on: ${{ matrix.os }} 59 | 60 | strategy: 61 | matrix: 62 | node: [lts/*] 63 | os: [ubuntu-latest, windows-latest, macos-latest] 64 | fail-fast: false 65 | 66 | steps: 67 | - uses: actions/checkout@v4 68 | 69 | - name: Install pnpm 70 | uses: pnpm/action-setup@v3 71 | 72 | - name: Set node ${{ matrix.node }} 73 | uses: actions/setup-node@v4 74 | with: 75 | node-version: ${{ matrix.node }} 76 | 77 | - name: Setup 78 | run: npm i -g @antfu/ni 79 | 80 | - name: Install 81 | run: nci 82 | 83 | - name: Build 84 | run: nr build 85 | 86 | - name: Test 87 | run: nr test 88 | -------------------------------------------------------------------------------- /src/rules/indent-unindent.md: -------------------------------------------------------------------------------- 1 | # indent-unindent 2 | 3 | Enforce consistent indentation style for content inside template string with [`unindent`](https://github.com/antfu/utils/blob/6cc9a99faaca1767969a375fdb2f222130d196c8/src/string.ts#L124) tag. 4 | 5 | ## Rule Details 6 | 7 | 8 | ```js 9 | // 👎 bad 10 | import { unindent } from '@antfu/utils' 11 | 12 | const cases = [ 13 | unindent` 14 | const foo = { 15 | bar: 'baz', qux: 'quux', 16 | fez: 'fum' 17 | }`, 18 | unindent` 19 | if (true) { 20 | console.log('hello') 21 | }`, 22 | ] 23 | ``` 24 | 25 | 26 | ```js 27 | // 👍 good 28 | import { unindent } from '@antfu/utils' 29 | 30 | const cases = [ 31 | unindent` 32 | const foo = { 33 | bar: 'baz', qux: 'quux', 34 | fez: 'fum' 35 | } 36 | `, 37 | unindent` 38 | if (true) { 39 | console.log('hello') 40 | } 41 | `, 42 | ] 43 | ``` 44 | 45 | By default it affects the template tag named `unindent`, `unIndent` or `$`. This rule works specifically for the `unindent` utility function from [`@antfu/utils`](https://github.com/antfu), where the leading and trailing empty lines are removed, and the common indentation is removed from each line. This rule fixes the content inside the template string but shall not affect the runtime result. 46 | 47 | ## Known issues 48 | 49 | This rule will report errors when files use `CRLF`. 50 | 51 | We strongly recommend standardizing line endings to `LF` to avoid these warnings and ensure cross-platform compatibility. 52 | 53 | You can configure your environment by setting `"files.eol": "\n"` in VSCode or use the ESLint rule: . 54 | 55 | --- 56 | 57 | **Why LF?** 58 | - Avoids mixed line endings in collaborative environments 59 | - Aligns with Unix/Linux/macOS standards and modern toolchains 60 | - Eliminates Git diff noise caused by CRLF/LF conflicts 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-antfu", 3 | "type": "module", 4 | "version": "3.1.1", 5 | "packageManager": "pnpm@10.6.2", 6 | "description": "Anthony's opinionated ESLint rules", 7 | "author": "Anthony Fu ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/antfu", 10 | "homepage": "https://github.com/antfu/eslint-plugin-antfu#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/antfu/eslint-plugin-antfu.git" 14 | }, 15 | "bugs": "https://github.com/antfu/eslint-plugin-antfu/issues", 16 | "keywords": [ 17 | "eslint-plugin" 18 | ], 19 | "sideEffects": false, 20 | "exports": { 21 | ".": "./dist/index.mjs" 22 | }, 23 | "main": "./dist/index.mjs", 24 | "module": "./dist/index.mjs", 25 | "types": "./dist/index.d.mts", 26 | "files": [ 27 | "dist" 28 | ], 29 | "scripts": { 30 | "build": "unbuild", 31 | "dev": "unbuild --stub", 32 | "lint": "pnpm run dev && eslint .", 33 | "prepublishOnly": "nr build", 34 | "release": "bumpp && pnpm publish", 35 | "start": "tsx src/index.ts", 36 | "test": "vitest", 37 | "typecheck": "tsc --noEmit", 38 | "prepare": "simple-git-hooks" 39 | }, 40 | "peerDependencies": { 41 | "eslint": "*" 42 | }, 43 | "devDependencies": { 44 | "@antfu/eslint-config": "^4.8.1", 45 | "@antfu/ni": "^24.1.0", 46 | "@antfu/utils": "^9.1.0", 47 | "@types/eslint": "^9.6.1", 48 | "@types/node": "^22.13.10", 49 | "@typescript-eslint/typescript-estree": "^8.26.1", 50 | "@typescript-eslint/utils": "^8.26.1", 51 | "bumpp": "^10.1.0", 52 | "eslint": "^9.22.0", 53 | "eslint-vitest-rule-tester": "^2.0.0", 54 | "jsonc-eslint-parser": "^2.4.0", 55 | "lint-staged": "^15.4.3", 56 | "simple-git-hooks": "^2.11.1", 57 | "tsup": "^8.4.0", 58 | "tsx": "^4.19.3", 59 | "typescript": "^5.8.2", 60 | "unbuild": "^3.5.0", 61 | "vite": "^6.2.1", 62 | "vitest": "^3.0.8" 63 | }, 64 | "resolutions": { 65 | "eslint-plugin-antfu": "workspace:*" 66 | }, 67 | "simple-git-hooks": { 68 | "pre-commit": "npx lint-staged" 69 | }, 70 | "lint-staged": { 71 | "*": "eslint --fix" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/rules/indent-unindent.ts: -------------------------------------------------------------------------------- 1 | import { unindent } from '@antfu/utils' 2 | import { createEslintRule } from '../utils' 3 | 4 | export type MessageIds = 'indent-unindent' 5 | export type Options = [{ 6 | indent?: number 7 | tags?: string[] 8 | }] 9 | 10 | export default createEslintRule({ 11 | name: 'indent-unindent', 12 | meta: { 13 | type: 'layout', 14 | docs: { 15 | description: 'Enforce consistent indentation in `unindent` template tag', 16 | }, 17 | fixable: 'code', 18 | schema: [ 19 | { 20 | type: 'object', 21 | properties: { 22 | indent: { 23 | type: 'number', 24 | minimum: 0, 25 | default: 2, 26 | }, 27 | tags: { 28 | type: 'array', 29 | items: { 30 | type: 'string', 31 | }, 32 | }, 33 | }, 34 | additionalProperties: false, 35 | }, 36 | ], 37 | messages: { 38 | 'indent-unindent': 'Consistent indentation in unindent tag', 39 | }, 40 | }, 41 | defaultOptions: [{}], 42 | create(context) { 43 | const { 44 | tags = ['$', 'unindent', 'unIndent'], 45 | indent = 2, 46 | } = context.options?.[0] ?? {} 47 | 48 | return { 49 | TaggedTemplateExpression(node) { 50 | const id = node.tag 51 | if (!id || id.type !== 'Identifier') 52 | return 53 | if (!tags.includes(id.name)) 54 | return 55 | if (node.quasi.quasis.length !== 1) 56 | return 57 | const quasi = node.quasi.quasis[0] 58 | const value = quasi.value.raw 59 | const lineStartIndex = context.sourceCode.getIndexFromLoc({ 60 | line: node.loc.start.line, 61 | column: 0, 62 | }) 63 | const baseIndent = context.sourceCode.text.slice(lineStartIndex).match(/^\s*/)?.[0] ?? '' 64 | const targetIndent = baseIndent + ' '.repeat(indent) 65 | const pure = unindent([value] as any) 66 | let final = pure 67 | .split('\n') 68 | .map(line => targetIndent + line) 69 | .join('\n') 70 | 71 | final = `\n${final}\n${baseIndent}` 72 | 73 | if (final !== value) { 74 | context.report({ 75 | node: quasi, 76 | messageId: 'indent-unindent', 77 | fix: fixer => fixer.replaceText(quasi, `\`${final}\``), 78 | }) 79 | } 80 | }, 81 | } 82 | }, 83 | }) 84 | -------------------------------------------------------------------------------- /src/rules/top-level-function.test.ts: -------------------------------------------------------------------------------- 1 | import { run } from './_test' 2 | import rule, { RULE_NAME } from './top-level-function' 3 | 4 | const valids = [ 5 | 'function foo() {}', 6 | // allow arrow function inside function 7 | 'function foo() { const bar = () => {} }', 8 | // allow arrow function when type is specified 9 | 'const Foo: Bar = () => {}', 10 | // allow let/var 11 | 'let foo = () => {}', 12 | // allow arrow function in as 13 | 'const foo = (() => {}) as any', 14 | // allow iife 15 | ';(() => {})()', 16 | // allow export default 17 | 'export default () => {}', 18 | 'export default defineConfig(() => {})', 19 | // allow one-line arrow function 20 | 'const foo = (x, y) => x + y', 21 | 'const foo = async (x, y) => x + y', 22 | 'const foo = () => String(123)', 23 | 'const foo = () => ({})', 24 | ] 25 | 26 | const invalids = [ 27 | [ 28 | 'const foo = (x, y) => \nx + y', 29 | 'function foo (x, y) {\n return x + y\n}', 30 | ], 31 | [ 32 | 'const foo = (as: string, bar: number) => { return as + bar }', 33 | 'function foo (as: string, bar: number) { return as + bar }', 34 | ], 35 | [ 36 | 'const foo = (as: string, bar: number): Omit => \nas + bar', 37 | 'function foo (as: string, bar: number): Omit {\n return as + bar\n}', 38 | ], 39 | [ 40 | 'export const foo = () => {}', 41 | 'export function foo () {}', 42 | ], 43 | [ 44 | 'export const foo = () => \n({})', 45 | 'export function foo () {\n return {}\n}', 46 | ], 47 | [ 48 | 'export const foo = async () => \n({})', 49 | 'export async function foo () {\n return {}\n}', 50 | ], 51 | [ 52 | 'const foo = function () {}', 53 | 'function foo () {}', 54 | ], 55 | [ 56 | 'const foo = function (as: string, bar: number) { return as + bar }', 57 | 'function foo (as: string, bar: number) { return as + bar }', 58 | ], 59 | [ 60 | 'const foo = function (as: string, bar: number): Omit { return as + bar }', 61 | 'function foo (as: string, bar: number): Omit { return as + bar }', 62 | ], 63 | [ 64 | 'export const foo = function () {}', 65 | 'export function foo () {}', 66 | ], 67 | [ 68 | 'export const foo = function () { \nreturn {} }', 69 | 'export function foo () { \nreturn {} }', 70 | ], 71 | [ 72 | 'export const foo = async function () { \nreturn {}\n }', 73 | 'export async function foo () { \nreturn {}\n }', 74 | ], 75 | ] 76 | 77 | run({ 78 | name: RULE_NAME, 79 | rule, 80 | valid: valids, 81 | invalid: invalids.map(i => ({ 82 | code: i[0], 83 | output: i[1], 84 | errors: [{ messageId: 'topLevelFunctionDeclaration' }], 85 | })), 86 | }) 87 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { RuleListener, RuleWithMeta, RuleWithMetaAndName } from '@typescript-eslint/utils/eslint-utils' 2 | import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' 3 | import type { Rule } from 'eslint' 4 | 5 | // @keep-sorted 6 | const hasDocs = [ 7 | 'consistent-chaining', 8 | 'consistent-list-newline', 9 | 'curly', 10 | 'if-newline', 11 | 'import-dedupe', 12 | 'indent-unindent', 13 | 'top-level-function', 14 | ] 15 | 16 | const blobUrl = 'https://github.com/antfu/eslint-plugin-antfu/blob/main/src/rules/' 17 | 18 | export type RuleModule< 19 | T extends readonly unknown[], 20 | > = Rule.RuleModule & { 21 | defaultOptions: T 22 | } 23 | 24 | /** 25 | * Creates reusable function to create rules with default options and docs URLs. 26 | * 27 | * @param urlCreator Creates a documentation URL for a given rule name. 28 | * @returns Function to create a rule with the docs URL format. 29 | */ 30 | function RuleCreator(urlCreator: (name: string) => string) { 31 | // This function will get much easier to call when this is merged https://github.com/Microsoft/TypeScript/pull/26349 32 | // TODO - when the above PR lands; add type checking for the context.report `data` property 33 | return function createNamedRule< 34 | TOptions extends readonly unknown[], 35 | TMessageIds extends string, 36 | >({ 37 | name, 38 | meta, 39 | ...rule 40 | }: Readonly>): RuleModule { 41 | return createRule({ 42 | meta: { 43 | ...meta, 44 | docs: { 45 | ...meta.docs, 46 | url: urlCreator(name), 47 | }, 48 | }, 49 | ...rule, 50 | }) 51 | } 52 | } 53 | 54 | /** 55 | * Creates a well-typed TSESLint custom ESLint rule without a docs URL. 56 | * 57 | * @returns Well-typed TSESLint custom ESLint rule. 58 | * @remarks It is generally better to provide a docs URL function to RuleCreator. 59 | */ 60 | function createRule< 61 | TOptions extends readonly unknown[], 62 | TMessageIds extends string, 63 | >({ 64 | create, 65 | defaultOptions, 66 | meta, 67 | }: Readonly>): RuleModule { 68 | return { 69 | create: (( 70 | context: Readonly>, 71 | ): RuleListener => { 72 | const optionsWithDefault = context.options.map((options, index) => { 73 | return { 74 | ...defaultOptions[index] || {}, 75 | ...options || {}, 76 | } 77 | }) as unknown as TOptions 78 | return create(context, optionsWithDefault) 79 | }) as any, 80 | defaultOptions, 81 | meta: meta as any, 82 | } 83 | } 84 | 85 | export const createEslintRule = RuleCreator( 86 | ruleName => hasDocs.includes(ruleName) 87 | ? `${blobUrl}${ruleName}.md` 88 | : `${blobUrl}${ruleName}.test.ts`, 89 | ) as any as ({ name, meta, ...rule }: Readonly>) => RuleModule 90 | 91 | const warned = new Set() 92 | 93 | export function warnOnce(message: string): void { 94 | if (warned.has(message)) 95 | return 96 | warned.add(message) 97 | console.warn(message) 98 | } 99 | -------------------------------------------------------------------------------- /src/rules/top-level-function.ts: -------------------------------------------------------------------------------- 1 | import { createEslintRule } from '../utils' 2 | 3 | export const RULE_NAME = 'top-level-function' 4 | export type MessageIds = 'topLevelFunctionDeclaration' 5 | export type Options = [] 6 | 7 | export default createEslintRule({ 8 | name: RULE_NAME, 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Enforce top-level functions to be declared with function keyword', 13 | }, 14 | fixable: 'code', 15 | schema: [], 16 | messages: { 17 | topLevelFunctionDeclaration: 'Top-level functions should be declared with function keyword', 18 | }, 19 | }, 20 | defaultOptions: [], 21 | create: (context) => { 22 | return { 23 | VariableDeclaration(node) { 24 | if (node.parent.type !== 'Program' && node.parent.type !== 'ExportNamedDeclaration') 25 | return 26 | 27 | if (node.declarations.length !== 1) 28 | return 29 | if (node.kind !== 'const') 30 | return 31 | if (node.declare) 32 | return 33 | 34 | const declaration = node.declarations[0] 35 | 36 | if ( 37 | declaration.init?.type !== 'ArrowFunctionExpression' 38 | && declaration.init?.type !== 'FunctionExpression' 39 | ) { 40 | return 41 | } 42 | if (declaration.id?.type !== 'Identifier') 43 | return 44 | if (declaration.id.typeAnnotation) 45 | return 46 | if ( 47 | declaration.init.body.type !== 'BlockStatement' 48 | && declaration.id?.loc.start.line === declaration.init?.body.loc.end.line 49 | ) { 50 | return 51 | } 52 | 53 | const fnExpression = declaration.init 54 | const body = declaration.init.body 55 | const id = declaration.id 56 | 57 | context.report({ 58 | node, 59 | loc: { 60 | start: id.loc.start, 61 | end: body.loc.start, 62 | }, 63 | messageId: 'topLevelFunctionDeclaration', 64 | fix(fixer) { 65 | const code = context.getSourceCode().text 66 | const textName = code.slice(id.range[0], id.range[1]) 67 | const textArgs = fnExpression.params.length 68 | ? code.slice(fnExpression.params[0].range[0], fnExpression.params[fnExpression.params.length - 1].range[1]) 69 | : '' 70 | const textBody = body.type === 'BlockStatement' 71 | ? code.slice(body.range[0], body.range[1]) 72 | : `{\n return ${code.slice(body.range[0], body.range[1])}\n}` 73 | const textGeneric = fnExpression.typeParameters 74 | ? code.slice(fnExpression.typeParameters.range[0], fnExpression.typeParameters.range[1]) 75 | : '' 76 | const textTypeReturn = fnExpression.returnType 77 | ? code.slice(fnExpression.returnType.range[0], fnExpression.returnType.range[1]) 78 | : '' 79 | const textAsync = fnExpression.async ? 'async ' : '' 80 | 81 | const final = `${textAsync}function ${textName} ${textGeneric}(${textArgs})${textTypeReturn} ${textBody}` 82 | // console.log({ 83 | // input: code.slice(node.range[0], node.range[1]), 84 | // output: final, 85 | // }) 86 | return fixer.replaceTextRange([node.range[0], node.range[1]], final) 87 | }, 88 | }) 89 | }, 90 | } 91 | }, 92 | }) 93 | -------------------------------------------------------------------------------- /src/rules/curly.ts: -------------------------------------------------------------------------------- 1 | import type { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | 4 | export const RULE_NAME = 'curly' 5 | export type MessageIds = 'missingCurlyBrackets' 6 | export type Options = [] 7 | 8 | export default createEslintRule({ 9 | name: RULE_NAME, 10 | meta: { 11 | type: 'layout', 12 | docs: { 13 | description: 'Enforce Anthony\'s style of curly bracket', 14 | }, 15 | fixable: 'whitespace', 16 | schema: [], 17 | messages: { 18 | missingCurlyBrackets: 'Expect curly brackets', 19 | }, 20 | }, 21 | defaultOptions: [], 22 | create: (context) => { 23 | function requireCurly(body: TSESTree.Statement | TSESTree.Expression): boolean { 24 | if (!body) 25 | return false 26 | // already has curly brackets 27 | if (body.type === 'BlockStatement') 28 | return true 29 | // nested statements 30 | if (['IfStatement', 'WhileStatement', 'DoWhileStatement', 'ForStatement', 'ForInStatement', 'ForOfStatement'].includes(body.type)) 31 | return true 32 | const statement = body.type === 'ExpressionStatement' 33 | ? body.expression 34 | : body 35 | // multiline 36 | if (statement.loc.start.line !== statement.loc.end.line) 37 | return true 38 | return false 39 | } 40 | 41 | function wrapCurlyIfNeeded(body: TSESTree.Statement): void { 42 | if (body.type === 'BlockStatement') 43 | return 44 | context.report({ 45 | node: body, 46 | messageId: 'missingCurlyBrackets', 47 | * fix(fixer) { 48 | yield fixer.insertTextAfter(body, '\n}') 49 | const token = context.sourceCode.getTokenBefore(body) 50 | yield fixer.insertTextAfterRange(token!.range, ' {') 51 | }, 52 | }) 53 | } 54 | 55 | function check(bodies: TSESTree.Statement[], additionalChecks: TSESTree.Expression[] = []): void { 56 | const requires = [...bodies, ...additionalChecks].map(body => requireCurly(body)) 57 | 58 | // If any of the bodies requires curly brackets, wrap all of them to be consistent 59 | if (requires.some(i => i)) 60 | bodies.map(body => wrapCurlyIfNeeded(body)) 61 | } 62 | 63 | return { 64 | IfStatement(node) { 65 | const parent = node.parent 66 | // Already handled by the upper level if statement 67 | if (parent.type === 'IfStatement' && parent.alternate === node) 68 | return 69 | 70 | const statements: TSESTree.Statement[] = [] 71 | const tests: TSESTree.Expression[] = [] 72 | 73 | function addIf(node: TSESTree.IfStatement): void { 74 | statements.push(node.consequent) 75 | if (node.test) 76 | tests.push(node.test) 77 | if (node.alternate) { 78 | if (node.alternate.type === 'IfStatement') 79 | addIf(node.alternate) 80 | else 81 | statements.push(node.alternate) 82 | } 83 | } 84 | 85 | addIf(node) 86 | check(statements, tests) 87 | }, 88 | WhileStatement(node) { 89 | check([node.body], [node.test]) 90 | }, 91 | DoWhileStatement(node) { 92 | check([node.body], [node.test]) 93 | }, 94 | ForStatement(node) { 95 | check([node.body]) 96 | }, 97 | ForInStatement(node) { 98 | check([node.body]) 99 | }, 100 | ForOfStatement(node) { 101 | check([node.body]) 102 | }, 103 | } 104 | }, 105 | }) 106 | -------------------------------------------------------------------------------- /src/rules/curly.test.ts: -------------------------------------------------------------------------------- 1 | import { unindent as $ } from 'eslint-vitest-rule-tester' 2 | import { run } from './_test' 3 | import rule, { RULE_NAME } from './curly' 4 | 5 | run({ 6 | name: RULE_NAME, 7 | rule, 8 | valid: [ 9 | $` 10 | if (true) 11 | console.log('hello') 12 | `, 13 | $` 14 | if (true) { 15 | console.log('hello') 16 | } 17 | `, 18 | $` 19 | while (true) 20 | console.log('bar') 21 | `, 22 | $` 23 | if (true) 24 | console.log('foo') 25 | else if (false) 26 | console.log('bar') 27 | `, 28 | $` 29 | if (true) { 30 | console.log('foo') 31 | } else if (false) { 32 | console.log('bar') 33 | } else if (true) { 34 | console.log('baz') 35 | } 36 | `, 37 | $` 38 | function identity(x) { 39 | if (foo) 40 | console.log('bar') 41 | } 42 | `, 43 | $` 44 | function identity(x) { 45 | if (foo) 46 | console.log('bar') 47 | ;console.log('baz') 48 | } 49 | `, 50 | $` 51 | function identity(x) { 52 | if (foo) 53 | return x; 54 | } 55 | `, 56 | ], 57 | invalid: [ 58 | { 59 | description: 'multi', 60 | code: $` 61 | if (true) 62 | console.log({ 63 | foo 64 | }) 65 | `, 66 | output: $` 67 | if (true) { 68 | console.log({ 69 | foo 70 | }) 71 | } 72 | `, 73 | }, 74 | { 75 | description: 'nested', 76 | code: $` 77 | if (true) 78 | if (false) console.log('bar') 79 | `, 80 | output: $` 81 | if (true) { 82 | if (false) console.log('bar') 83 | } 84 | `, 85 | }, 86 | { 87 | description: 'consistent', 88 | code: $` 89 | if (true) 90 | console.log('bar') 91 | else 92 | console.log({ 93 | foo 94 | }) 95 | `, 96 | output: $` 97 | if (true) { 98 | console.log('bar') 99 | } 100 | else { 101 | console.log({ 102 | foo 103 | }) 104 | } 105 | `, 106 | }, 107 | { 108 | description: 'while', 109 | code: $` 110 | while (true) 111 | console.log({ 112 | foo 113 | }) 114 | `, 115 | output: $` 116 | while (true) { 117 | console.log({ 118 | foo 119 | }) 120 | } 121 | `, 122 | }, 123 | { 124 | description: 'if-else-if', 125 | code: $` 126 | if (true) 127 | console.log('foo') 128 | else if (false) 129 | console.log('bar') 130 | else if (true) 131 | console.log('baz') 132 | else { 133 | console.log('qux') 134 | } 135 | `, 136 | output: $` 137 | if (true) { 138 | console.log('foo') 139 | } 140 | else if (false) { 141 | console.log('bar') 142 | } 143 | else if (true) { 144 | console.log('baz') 145 | } 146 | else { 147 | console.log('qux') 148 | } 149 | `, 150 | }, 151 | { 152 | description: 'multiline-test', 153 | code: $` 154 | if ( 155 | foo 156 | || bar 157 | ) 158 | return true 159 | `, 160 | output: $` 161 | if ( 162 | foo 163 | || bar 164 | ) { 165 | return true 166 | } 167 | `, 168 | }, 169 | ], 170 | }) 171 | -------------------------------------------------------------------------------- /src/rules/__snapshots__/consistent-list-newline.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`consistent-list-newline > invalid > Invalid #0: const a = { 4 | foo: "bar", bar: 2 } 1`] = ` 5 | "const a = { 6 | foo: "bar", 7 | bar: 2 8 | }" 9 | `; 10 | 11 | exports[`consistent-list-newline > invalid > Invalid #1: const a = {foo: "bar", 12 | bar: 2 13 | } 1`] = `"const a = {foo: "bar", bar: 2}"`; 14 | 15 | exports[`consistent-list-newline > invalid > Invalid #2: const a = [ 16 | 1, 2, 3] 1`] = ` 17 | "const a = [ 18 | 1, 19 | 2, 20 | 3 21 | ]" 22 | `; 23 | 24 | exports[`consistent-list-newline > invalid > Invalid #3: const a = [1, 25 | 2, 3 26 | ] 1`] = `"const a = [1, 2, 3]"`; 27 | 28 | exports[`consistent-list-newline > invalid > Invalid #4: import { 29 | foo, bar } from "foo" 1`] = ` 30 | "import { 31 | foo, 32 | bar 33 | } from "foo"" 34 | `; 35 | 36 | exports[`consistent-list-newline > invalid > Invalid #5: import { foo, 37 | bar } from "foo" 1`] = `"import { foo, bar } from "foo""`; 38 | 39 | exports[`consistent-list-newline > invalid > Invalid #6: log( 40 | a, b) 1`] = ` 41 | "log( 42 | a, 43 | b 44 | )" 45 | `; 46 | 47 | exports[`consistent-list-newline > invalid > Invalid #7: function foo( 48 | a, b) {} 1`] = ` 49 | "function foo( 50 | a, 51 | b 52 | ) {}" 53 | `; 54 | 55 | exports[`consistent-list-newline > invalid > Invalid #8: const foo = ( 56 | a, b) => {} 1`] = ` 57 | "const foo = ( 58 | a, 59 | b 60 | ) => {}" 61 | `; 62 | 63 | exports[`consistent-list-newline > invalid > Invalid #9: const foo = ( 64 | a, b): { 65 | a:b} => {} 1`] = ` 66 | "const foo = ( 67 | a, 68 | b 69 | ): { 70 | a:b 71 | } => {}" 72 | `; 73 | 74 | exports[`consistent-list-newline > invalid > Invalid #10: const foo = ( 75 | a, b): {a:b} => {} 1`] = ` 76 | "const foo = ( 77 | a, 78 | b 79 | ): {a:b} => {}" 80 | `; 81 | 82 | exports[`consistent-list-newline > invalid > Invalid #11: interface Foo { 83 | a: 1,b: 2 84 | } 1`] = ` 85 | "interface Foo { 86 | a: 1, 87 | b: 2 88 | }" 89 | `; 90 | 91 | exports[`consistent-list-newline > invalid > Invalid #15: type Foo = { 92 | a: 1,b: 2 93 | } 1`] = ` 94 | "type Foo = { 95 | a: 1, 96 | b: 2 97 | }" 98 | `; 99 | 100 | exports[`consistent-list-newline > invalid > Invalid #17: type Foo = [1,2, 101 | 3] 1`] = `"type Foo = [1,2,3]"`; 102 | 103 | exports[`consistent-list-newline > invalid > Invalid #18: new Foo(1,2, 104 | 3) 1`] = `"new Foo(1,2,3)"`; 105 | 106 | exports[`consistent-list-newline > invalid > Invalid #19: new Foo( 107 | 1,2, 108 | 3) 1`] = ` 109 | "new Foo( 110 | 1, 111 | 2, 112 | 3 113 | )" 114 | `; 115 | 116 | exports[`consistent-list-newline > invalid > Invalid #20: foo( 117 | ()=>bar(), 118 | ()=> 119 | baz()) 1`] = ` 120 | "foo( 121 | ()=>bar(), 122 | ()=> 123 | baz() 124 | )" 125 | `; 126 | 127 | exports[`consistent-list-newline > invalid > Invalid #21: foo(()=>bar(), 128 | ()=> 129 | baz()) 1`] = ` 130 | "foo(()=>bar(),()=> 131 | baz())" 132 | `; 133 | 134 | exports[`consistent-list-newline > invalid > Invalid #22: foo(1, 2) 1`] = `"foo(1, 2)"`; 136 | 137 | exports[`consistent-list-newline > invalid > Invalid #23: foo< 138 | X,Y>( 139 | 1, 2) 1`] = ` 140 | "foo< 141 | X, 142 | Y 143 | >( 144 | 1, 145 | 2 146 | )" 147 | `; 148 | 149 | exports[`consistent-list-newline > invalid > Invalid #24: function foo< 150 | X,Y>() {} 1`] = ` 151 | "function foo< 152 | X, 153 | Y 154 | >() {}" 155 | `; 156 | 157 | exports[`consistent-list-newline > invalid > Invalid #25: const {a, 158 | b 159 | } = c 1`] = `"const {a,b} = c"`; 160 | 161 | exports[`consistent-list-newline > invalid > Invalid #26: const [ 162 | a,b] = c 1`] = ` 163 | "const [ 164 | a, 165 | b 166 | ] = c" 167 | `; 168 | 169 | exports[`consistent-list-newline > invalid > Invalid #27: foo(([ 170 | a,b]) => {}) 1`] = ` 171 | "foo(([ 172 | a, 173 | b 174 | ]) => {})" 175 | `; 176 | -------------------------------------------------------------------------------- /src/rules/consistent-chaining.ts: -------------------------------------------------------------------------------- 1 | import type { TSESTree } from '@typescript-eslint/utils' 2 | import { createEslintRule } from '../utils' 3 | 4 | export const RULE_NAME = 'consistent-chaining' 5 | export type MessageIds = 'shouldWrap' | 'shouldNotWrap' 6 | export type Options = [ 7 | { 8 | allowLeadingPropertyAccess?: boolean 9 | }, 10 | ] 11 | 12 | export default createEslintRule({ 13 | name: RULE_NAME, 14 | meta: { 15 | type: 'layout', 16 | docs: { 17 | description: 'Having line breaks styles to object, array and named imports', 18 | }, 19 | fixable: 'whitespace', 20 | schema: [ 21 | { 22 | type: 'object', 23 | properties: { 24 | allowLeadingPropertyAccess: { 25 | type: 'boolean', 26 | description: 'Allow leading property access to be on the same line', 27 | default: true, 28 | }, 29 | }, 30 | additionalProperties: false, 31 | }, 32 | ], 33 | messages: { 34 | shouldWrap: 'Should have line breaks between items, in node {{name}}', 35 | shouldNotWrap: 'Should not have line breaks between items, in node {{name}}', 36 | }, 37 | }, 38 | defaultOptions: [ 39 | { 40 | allowLeadingPropertyAccess: true, 41 | }, 42 | ], 43 | create: (context) => { 44 | const knownRoot = new WeakSet() 45 | 46 | const { 47 | allowLeadingPropertyAccess = true, 48 | } = context.options[0] || {} 49 | 50 | return { 51 | MemberExpression(node) { 52 | let root: TSESTree.Node = node 53 | while (root.parent && (root.parent.type === 'MemberExpression' || root.parent.type === 'CallExpression')) 54 | root = root.parent 55 | if (knownRoot.has(root)) 56 | return 57 | knownRoot.add(root) 58 | 59 | const members: TSESTree.MemberExpression[] = [] 60 | let current: TSESTree.Node | undefined = root 61 | while (current) { 62 | switch (current.type) { 63 | case 'MemberExpression': { 64 | if (!current.computed) 65 | members.unshift(current) 66 | current = current.object 67 | break 68 | } 69 | case 'CallExpression': { 70 | current = current.callee 71 | break 72 | } 73 | case 'TSNonNullExpression': { 74 | current = current.expression 75 | break 76 | } 77 | default: { 78 | // Other type of note, that means we are probably reaching out the head 79 | current = undefined 80 | break 81 | } 82 | } 83 | } 84 | 85 | let leadingPropertyAcccess = allowLeadingPropertyAccess 86 | let mode: 'single' | 'multi' | null = null 87 | 88 | members.forEach((m) => { 89 | const token = context.sourceCode.getTokenBefore(m.property)! 90 | const tokenBefore = context.sourceCode.getTokenBefore(token)! 91 | const currentMode: 'single' | 'multi' = token.loc.start.line === tokenBefore.loc.end.line ? 'single' : 'multi' 92 | const object = m.object.type === 'TSNonNullExpression' ? m.object.expression : m.object 93 | if ( 94 | leadingPropertyAcccess 95 | && (object.type === 'ThisExpression' || object.type === 'Identifier' || object.type === 'MemberExpression' || object.type === 'Literal') 96 | && currentMode === 'single' 97 | ) { 98 | return 99 | } 100 | 101 | leadingPropertyAcccess = false 102 | if (mode == null) { 103 | mode = currentMode 104 | return 105 | } 106 | 107 | if (mode !== currentMode) { 108 | context.report({ 109 | messageId: mode === 'single' ? 'shouldNotWrap' : 'shouldWrap', 110 | loc: token.loc, 111 | data: { 112 | name: root.type, 113 | }, 114 | fix(fixer) { 115 | if (mode === 'multi') 116 | return fixer.insertTextAfter(tokenBefore, '\n') 117 | else 118 | return fixer.removeRange([tokenBefore.range[1], token.range[0]]) 119 | }, 120 | }) 121 | } 122 | }) 123 | }, 124 | } 125 | }, 126 | }) 127 | -------------------------------------------------------------------------------- /src/rules/consistent-chaining.test.ts: -------------------------------------------------------------------------------- 1 | import type { InvalidTestCase, ValidTestCase } from 'eslint-vitest-rule-tester' 2 | import { unindent as $ } from 'eslint-vitest-rule-tester' 3 | import { expect } from 'vitest' 4 | import { run } from './_test' 5 | import rule, { RULE_NAME } from './consistent-chaining' 6 | 7 | const valids: ValidTestCase[] = [ 8 | 'foo().bar().baz()[1].dar', 9 | 'foo().bar.baz().boo()', 10 | $` 11 | foo() 12 | .bar 13 | .baz() 14 | .boo() 15 | `, 16 | $` 17 | Math.random() 18 | .toString() 19 | .split('') 20 | .map(Number) 21 | `, 22 | $` 23 | foo.bar.baz() 24 | .toString() 25 | .split('') 26 | .map(Number) 27 | `, 28 | $` 29 | foo.bar.baz 30 | .toString() 31 | .split('') 32 | .map(Number) 33 | `, 34 | $` 35 | foo.bar!.baz 36 | .toString() 37 | .split('') 38 | .map(Number) 39 | `, 40 | $` 41 | foo.bar.baz 42 | .toString()! 43 | .split('') 44 | .map(Number) 45 | `, 46 | $` 47 | foo.bar.baz! 48 | .toString() 49 | .split('') 50 | .map(Number) 51 | `, 52 | ] 53 | 54 | // Check snapshot for fixed code 55 | const invalid: InvalidTestCase[] = [ 56 | { 57 | code: $` 58 | foo().bar 59 | .baz() 60 | .boo() 61 | `, 62 | output: o => expect(o) 63 | .toMatchInlineSnapshot(`"foo().bar.baz().boo()"`), 64 | }, 65 | { 66 | code: $` 67 | foo({ 68 | bar: true 69 | }).bar 70 | .baz() 71 | .boo() 72 | `, 73 | output: o => expect(o) 74 | .toMatchInlineSnapshot(` 75 | "foo({ 76 | bar: true 77 | }).bar.baz().boo()" 78 | `), 79 | }, 80 | { 81 | code: $` 82 | foo({ 83 | bar: true 84 | }) 85 | .bar 86 | .baz().boo() 87 | `, 88 | output: o => expect(o) 89 | .toMatchInlineSnapshot(` 90 | "foo({ 91 | bar: true 92 | }) 93 | .bar 94 | .baz() 95 | .boo()" 96 | `), 97 | }, 98 | { 99 | code: $` 100 | foo({ 101 | bar: true 102 | })[1] 103 | .bar 104 | .baz[1]().boo.bar() 105 | `, 106 | output: o => expect(o) 107 | .toMatchInlineSnapshot(` 108 | "foo({ 109 | bar: true 110 | })[1] 111 | .bar 112 | .baz[1]() 113 | .boo 114 | .bar()" 115 | `), 116 | }, 117 | { 118 | code: $` 119 | Math.random() 120 | .toString() 121 | .split('').map(Number) 122 | `, 123 | output: o => expect(o) 124 | .toMatchInlineSnapshot(` 125 | "Math.random() 126 | .toString() 127 | .split('') 128 | .map(Number)" 129 | `), 130 | }, 131 | { 132 | code: $` 133 | this.foo 134 | .toString() 135 | .split('').map(Number) 136 | `, 137 | output: o => expect(o) 138 | .toMatchInlineSnapshot(` 139 | "this.foo 140 | .toString() 141 | .split('') 142 | .map(Number)" 143 | `), 144 | }, 145 | { 146 | code: $` 147 | Math 148 | .random() .toString() .split('').map(Number) 149 | `, 150 | output: o => expect(o) 151 | .toMatchInlineSnapshot(` 152 | "Math 153 | .random() 154 | .toString() 155 | .split('') 156 | .map(Number)" 157 | `), 158 | }, 159 | { 160 | code: $` 161 | [foo].map(x => x) 162 | .filter(x => x) 163 | `, 164 | output: o => expect(o) 165 | .toMatchInlineSnapshot(`"[foo].map(x => x).filter(x => x)"`), 166 | }, 167 | { 168 | code: $` 169 | [foo] 170 | .map(x => x).filter(x => x) 171 | `, 172 | output: o => expect(o) 173 | .toMatchInlineSnapshot(` 174 | "[foo] 175 | .map(x => x) 176 | .filter(x => x)" 177 | `), 178 | }, 179 | { 180 | code: $` 181 | foo.bar.bar 182 | .filter().map() 183 | `, 184 | output: o => expect(o) 185 | .toMatchInlineSnapshot(` 186 | "foo.bar.bar 187 | .filter() 188 | .map()" 189 | `), 190 | }, 191 | { 192 | code: $` 193 | foo.bar.bar 194 | .filter()!.map() 195 | `, 196 | output: o => expect(o) 197 | .toMatchInlineSnapshot(` 198 | "foo.bar.bar 199 | .filter()! 200 | .map()" 201 | `), 202 | }, 203 | { 204 | code: $` 205 | Math 206 | .random()! .toString() .split('')!.map(Number) 207 | `, 208 | output: o => expect(o) 209 | .toMatchInlineSnapshot(` 210 | "Math 211 | .random()! 212 | .toString() 213 | .split('')! 214 | .map(Number)" 215 | `), 216 | }, 217 | { 218 | code: $` 219 | foo({ 220 | bar: true 221 | })! 222 | .bar! 223 | .baz().boo() 224 | `, 225 | output: o => expect(o) 226 | .toMatchInlineSnapshot(` 227 | "foo({ 228 | bar: true 229 | })! 230 | .bar! 231 | .baz() 232 | .boo()" 233 | `), 234 | }, 235 | { 236 | code: $` 237 | this.foo! 238 | .toString() 239 | .split('').map(Number) 240 | `, 241 | output: o => expect(o) 242 | .toMatchInlineSnapshot(` 243 | "this.foo! 244 | .toString() 245 | .split('') 246 | .map(Number)" 247 | `), 248 | }, 249 | ] 250 | 251 | run({ 252 | name: RULE_NAME, 253 | rule, 254 | 255 | valid: valids, 256 | invalid: invalid.map((i): InvalidTestCase => 257 | typeof i === 'string' 258 | ? { 259 | code: i, 260 | output: o => expect(o).toMatchSnapshot(), 261 | } 262 | : i, 263 | ), 264 | }) 265 | -------------------------------------------------------------------------------- /src/rules/consistent-list-newline.ts: -------------------------------------------------------------------------------- 1 | import type { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/utils' 2 | import type { RuleFix, RuleFixer, RuleListener } from '@typescript-eslint/utils/ts-eslint' 3 | import { createEslintRule } from '../utils' 4 | 5 | export const RULE_NAME = 'consistent-list-newline' 6 | export type MessageIds = 'shouldWrap' | 'shouldNotWrap' 7 | export type Options = [{ 8 | ArrayExpression?: boolean 9 | ArrayPattern?: boolean 10 | ArrowFunctionExpression?: boolean 11 | CallExpression?: boolean 12 | ExportNamedDeclaration?: boolean 13 | FunctionDeclaration?: boolean 14 | FunctionExpression?: boolean 15 | ImportDeclaration?: boolean 16 | JSONArrayExpression?: boolean 17 | JSONObjectExpression?: boolean 18 | JSXOpeningElement?: boolean 19 | NewExpression?: boolean 20 | ObjectExpression?: boolean 21 | ObjectPattern?: boolean 22 | TSFunctionType?: boolean 23 | TSInterfaceDeclaration?: boolean 24 | TSTupleType?: boolean 25 | TSTypeLiteral?: boolean 26 | TSTypeParameterDeclaration?: boolean 27 | TSTypeParameterInstantiation?: boolean 28 | }] 29 | 30 | export default createEslintRule({ 31 | name: RULE_NAME, 32 | meta: { 33 | type: 'layout', 34 | docs: { 35 | description: 'Having line breaks styles to object, array and named imports', 36 | }, 37 | fixable: 'whitespace', 38 | schema: [{ 39 | type: 'object', 40 | properties: { 41 | ArrayExpression: { type: 'boolean' }, 42 | ArrayPattern: { type: 'boolean' }, 43 | ArrowFunctionExpression: { type: 'boolean' }, 44 | CallExpression: { type: 'boolean' }, 45 | ExportNamedDeclaration: { type: 'boolean' }, 46 | FunctionDeclaration: { type: 'boolean' }, 47 | FunctionExpression: { type: 'boolean' }, 48 | ImportDeclaration: { type: 'boolean' }, 49 | JSONArrayExpression: { type: 'boolean' }, 50 | JSONObjectExpression: { type: 'boolean' }, 51 | JSXOpeningElement: { type: 'boolean' }, 52 | NewExpression: { type: 'boolean' }, 53 | ObjectExpression: { type: 'boolean' }, 54 | ObjectPattern: { type: 'boolean' }, 55 | TSFunctionType: { type: 'boolean' }, 56 | TSInterfaceDeclaration: { type: 'boolean' }, 57 | TSTupleType: { type: 'boolean' }, 58 | TSTypeLiteral: { type: 'boolean' }, 59 | TSTypeParameterDeclaration: { type: 'boolean' }, 60 | TSTypeParameterInstantiation: { type: 'boolean' }, 61 | } satisfies Record, 62 | additionalProperties: false, 63 | }], 64 | messages: { 65 | shouldWrap: 'Should have line breaks between items, in node {{name}}', 66 | shouldNotWrap: 'Should not have line breaks between items, in node {{name}}', 67 | }, 68 | }, 69 | defaultOptions: [{}], 70 | create: (context, [options = {}] = [{}]) => { 71 | const multilineNodes = new Set([ 72 | 'ArrayExpression', 73 | 'FunctionDeclaration', 74 | 'ObjectExpression', 75 | 'ObjectPattern', 76 | 'TSTypeLiteral', 77 | 'TSTupleType', 78 | 'TSInterfaceDeclaration', 79 | ]) 80 | function removeLines(fixer: RuleFixer, start: number, end: number, delimiter?: string): RuleFix { 81 | const range = [start, end] as const 82 | const code = context.sourceCode.text.slice(...range) 83 | return fixer.replaceTextRange(range, code.replace(/(\r\n|\n)/g, delimiter ?? '')) 84 | } 85 | 86 | function getDelimiter(root: TSESTree.Node, current: TSESTree.Node): string | undefined { 87 | if (root.type !== 'TSInterfaceDeclaration' && root.type !== 'TSTypeLiteral') 88 | return 89 | const currentContent = context.sourceCode.text.slice(current.range[0], current.range[1]) 90 | return currentContent.match(/(?:,|;)$/) ? undefined : ',' 91 | } 92 | 93 | function hasComments(current: TSESTree.Node): boolean { 94 | let program: TSESTree.Node = current 95 | while (program.type !== 'Program') 96 | program = program.parent 97 | const currentRange = current.range 98 | 99 | return !!program.comments?.some((comment) => { 100 | const commentRange = comment.range 101 | return ( 102 | commentRange[0] > currentRange[0] 103 | && commentRange[1] < currentRange[1] 104 | ) 105 | }) 106 | } 107 | 108 | function check( 109 | node: TSESTree.Node, 110 | children: (TSESTree.Node | null)[], 111 | nextNode?: TSESTree.Node, 112 | ): void { 113 | const items = children.filter(Boolean) as TSESTree.Node[] 114 | if (items.length === 0) 115 | return 116 | 117 | // Look for the opening bracket, we first try to get the first token of the parent node 118 | // and fallback to the token before the first item 119 | let startToken = ['CallExpression', 'NewExpression'].includes(node.type) 120 | ? undefined 121 | : context.sourceCode.getFirstToken(node) 122 | if (node.type === 'CallExpression') { 123 | startToken = context.sourceCode.getTokenAfter( 124 | node.typeArguments 125 | ? node.typeArguments 126 | : node.callee.type === 'MemberExpression' 127 | ? node.callee.property 128 | : node.callee, 129 | ) 130 | } 131 | if (startToken?.type !== 'Punctuator') 132 | startToken = context.sourceCode.getTokenBefore(items[0]) 133 | 134 | const endToken = context.sourceCode.getTokenAfter(items[items.length - 1]) 135 | const startLine = startToken!.loc.start.line 136 | 137 | if (startToken!.loc.start.line === endToken!.loc.end.line) 138 | return 139 | 140 | let mode: 'inline' | 'newline' | null = null 141 | let lastLine = startLine 142 | 143 | items.forEach((item, idx) => { 144 | if (mode == null) { 145 | mode = item.loc.start.line === lastLine ? 'inline' : 'newline' 146 | lastLine = item.loc.end.line 147 | return 148 | } 149 | 150 | const currentStart = item.loc.start.line 151 | 152 | if (mode === 'newline' && currentStart === lastLine) { 153 | context.report({ 154 | node: item, 155 | messageId: 'shouldWrap', 156 | data: { 157 | name: node.type, 158 | }, 159 | * fix(fixer) { 160 | yield fixer.insertTextBefore(item, '\n') 161 | }, 162 | }) 163 | } 164 | else if (mode === 'inline' && currentStart !== lastLine) { 165 | const lastItem = items[idx - 1] 166 | if (context.sourceCode.getCommentsBefore(item).length > 0) 167 | return 168 | const content = context.sourceCode.text.slice(lastItem!.range[1], item.range[0]) 169 | if (content.includes('\n')) { 170 | context.report({ 171 | node: item, 172 | messageId: 'shouldNotWrap', 173 | data: { 174 | name: node.type, 175 | }, 176 | * fix(fixer) { 177 | yield removeLines(fixer, lastItem!.range[1], item.range[0], getDelimiter(node, lastItem)) 178 | }, 179 | }) 180 | } 181 | } 182 | 183 | lastLine = item.loc.end.line 184 | }) 185 | 186 | const endRange = nextNode 187 | ? Math.min( 188 | context.sourceCode.getTokenBefore(nextNode)!.range[0], 189 | node.range[1], 190 | ) 191 | : node.range[1] 192 | const endLoc = context.sourceCode.getLocFromIndex(endRange) 193 | 194 | const lastItem = items[items.length - 1]! 195 | if (mode === 'newline' && endLoc.line === lastLine) { 196 | context.report({ 197 | node: lastItem, 198 | messageId: 'shouldWrap', 199 | data: { 200 | name: node.type, 201 | }, 202 | * fix(fixer) { 203 | yield fixer.insertTextAfter(lastItem, '\n') 204 | }, 205 | }) 206 | } 207 | else if (mode === 'inline' && endLoc.line !== lastLine) { 208 | // If there is only one multiline item, we allow the closing bracket to be on the a different line 209 | if (items.length === 1 && !(multilineNodes as Set).has(node.type)) 210 | return 211 | if (context.sourceCode.getCommentsAfter(lastItem).length > 0) 212 | return 213 | 214 | const content = context.sourceCode.text.slice(lastItem.range[1], endRange) 215 | if (content.includes('\n')) { 216 | context.report({ 217 | node: lastItem, 218 | messageId: 'shouldNotWrap', 219 | data: { 220 | name: node.type, 221 | }, 222 | * fix(fixer) { 223 | const delimiter = items.length === 1 ? '' : getDelimiter(node, lastItem) 224 | yield removeLines(fixer, lastItem.range[1], endRange, delimiter) 225 | }, 226 | }) 227 | } 228 | } 229 | } 230 | 231 | const listenser = { 232 | ObjectExpression: (node) => { 233 | check(node, node.properties) 234 | }, 235 | ArrayExpression: (node) => { 236 | check(node, node.elements) 237 | }, 238 | ImportDeclaration: (node) => { 239 | check( 240 | node, 241 | node.specifiers[0]?.type === 'ImportDefaultSpecifier' 242 | ? node.specifiers.slice(1) 243 | : node.specifiers, 244 | ) 245 | }, 246 | ExportNamedDeclaration: (node) => { 247 | check(node, node.specifiers) 248 | }, 249 | FunctionDeclaration: (node) => { 250 | check( 251 | node, 252 | node.params, 253 | node.returnType || node.body, 254 | ) 255 | }, 256 | FunctionExpression: (node) => { 257 | check( 258 | node, 259 | node.params, 260 | node.returnType || node.body, 261 | ) 262 | }, 263 | ArrowFunctionExpression: (node) => { 264 | if (node.params.length <= 1) 265 | return 266 | check( 267 | node, 268 | node.params, 269 | node.returnType || node.body, 270 | ) 271 | }, 272 | CallExpression: (node) => { 273 | check(node, node.arguments) 274 | }, 275 | TSInterfaceDeclaration: (node) => { 276 | check(node, node.body.body) 277 | }, 278 | TSTypeLiteral: (node) => { 279 | check(node, node.members) 280 | }, 281 | TSTupleType: (node) => { 282 | check(node, node.elementTypes) 283 | }, 284 | TSFunctionType: (node) => { 285 | check(node, node.params) 286 | }, 287 | NewExpression: (node) => { 288 | check(node, node.arguments) 289 | }, 290 | TSTypeParameterDeclaration(node) { 291 | check(node, node.params) 292 | }, 293 | TSTypeParameterInstantiation(node) { 294 | check(node, node.params) 295 | }, 296 | ObjectPattern(node) { 297 | check(node, node.properties, node.typeAnnotation) 298 | }, 299 | ArrayPattern(node) { 300 | check(node, node.elements) 301 | }, 302 | JSXOpeningElement(node) { 303 | if (node.attributes.some(attr => attr.loc.start.line !== attr.loc.end.line)) 304 | return 305 | 306 | check(node, node.attributes) 307 | }, 308 | JSONArrayExpression(node: TSESTree.ArrayExpression) { 309 | if (hasComments(node)) 310 | return 311 | check(node, node.elements) 312 | }, 313 | JSONObjectExpression(node: TSESTree.ObjectExpression) { 314 | if (hasComments(node)) 315 | return 316 | 317 | check(node, node.properties) 318 | }, 319 | } satisfies RuleListener 320 | 321 | type KeysListener = keyof typeof listenser 322 | type KeysOptions = keyof Options[0] 323 | 324 | // Type assertion to check if all keys are exported 325 | exportType() 326 | exportType() 327 | 328 | ;(Object.keys(options) as KeysOptions[]) 329 | .forEach((key) => { 330 | if (options[key] === false) 331 | delete listenser[key] 332 | }) 333 | 334 | return listenser 335 | }, 336 | }) 337 | 338 | // eslint-disable-next-line unused-imports/no-unused-vars, ts/explicit-function-return-type 339 | function exportType() {} 340 | -------------------------------------------------------------------------------- /src/rules/consistent-list-newline.test.ts: -------------------------------------------------------------------------------- 1 | import type { InvalidTestCase, ValidTestCase } from 'eslint-vitest-rule-tester' 2 | import { unindent as $ } from 'eslint-vitest-rule-tester' 3 | import jsoncParser from 'jsonc-eslint-parser' 4 | import { expect } from 'vitest' 5 | import { run } from './_test' 6 | import rule, { RULE_NAME } from './consistent-list-newline' 7 | 8 | const valids: ValidTestCase[] = [ 9 | 'const a = { foo: "bar", bar: 2 }', 10 | 'const a = {\nfoo: "bar",\nbar: 2\n}', 11 | 'const a = [1, 2, 3]', 12 | 'const a = [\n1,\n2,\n3\n]', 13 | 'import { foo, bar } from "foo"', 14 | 'import {\nfoo,\nbar\n} from "foo"', 15 | 'const a = [`\n\n`, `\n\n`]', 16 | 'log(a, b)', 17 | 'log(\na,\nb\n)', 18 | 'function foo(a, b) {}', 19 | 'function foo(\na,\nb\n) {}', 20 | 'const foo = (a, b) => {\n\n}', 21 | 'const foo = (a, b): {a:b} => {\n\n}', 22 | 'interface Foo { a: 1, b: 2 }', 23 | 'interface Foo {\na: 1\nb: 2\n}', 24 | 'a\n.filter(items => {\n\n})', 25 | 'new Foo(a, b)', 26 | 'new Foo(\na,\nb\n)', 27 | 'function foo(a, b) {}', 28 | 'foo(() =>\nbar())', 29 | 'foo(() =>\nbar()\n)', 30 | `call<{\nfoo: 'bar'\n}>('')`, 31 | $` 32 | (Object.keys(options) as KeysOptions[]) 33 | .forEach((key) => { 34 | if (options[key] === false) 35 | delete listenser[key] 36 | }) 37 | `, 38 | // https://github.com/antfu/eslint-plugin-antfu/issues/11 39 | `function fn({ foo, bar }: {\nfoo: 'foo'\nbar: 'bar'\n}) {}`, 40 | { 41 | code: 'foo(\na, b\n)', 42 | options: [{ CallExpression: false }], 43 | }, 44 | // https://github.com/antfu/eslint-plugin-antfu/issues/14 45 | { 46 | code: $` 47 | const a = ( 48 |
49 | {text.map((item, index) => ( 50 |

51 |

52 | ))} 53 |
54 | ) 55 | `, 56 | parserOptions: { 57 | ecmaFeatures: { 58 | jsx: true, 59 | }, 60 | }, 61 | }, 62 | // https://github.com/antfu/eslint-plugin-antfu/issues/15 63 | $` 64 | export const getTodoList = request.post< 65 | Params, 66 | ResponseData, 67 | >('/api/todo-list') 68 | `, 69 | // https://github.com/antfu/eslint-plugin-antfu/issues/16 70 | { 71 | code: $` 72 | function TodoList() { 73 | const { data, isLoading } = useSwrInfinite( 74 | (page) => ['/api/todo/list', { page }], 75 | ([, params]) => getToDoList(params), 76 | ) 77 | return
78 | } 79 | `, 80 | parserOptions: { 81 | ecmaFeatures: { 82 | jsx: true, 83 | }, 84 | }, 85 | }, 86 | $` 87 | bar( 88 | foo => foo 89 | ? '' 90 | : '' 91 | ) 92 | `, 93 | $` 94 | bar( 95 | (ruleName, foo) => foo 96 | ? '' 97 | : '' 98 | ) 99 | `, 100 | // https://github.com/antfu/eslint-plugin-antfu/issues/19 101 | $` 102 | const a = [ 103 | (1), 104 | (2) 105 | ]; 106 | `, 107 | `const a = [(1), (2)];`, 108 | // https://github.com/antfu/eslint-plugin-antfu/issues/27 109 | $` 110 | this.foobar( 111 | (x), 112 | y, 113 | z 114 | ) 115 | `, 116 | $` 117 | foobar( 118 | (x), 119 | y, 120 | z 121 | ) 122 | `, 123 | $` 124 | foobar( 125 | (x), 126 | y, 127 | z 128 | ) 129 | `, 130 | // https://github.com/antfu/eslint-plugin-antfu/issues/22 131 | $` 132 | import Icon, { 133 | MailOutlined, 134 | NumberOutlined, 135 | QuestionCircleOutlined, 136 | QuestionOutlined, 137 | UserOutlined, 138 | } from '@ant-design/icons'; 139 | `, 140 | { 141 | code: $` 142 | function Foo() { 143 | return ( 144 |
150 | hi 151 |
152 | ); 153 | } 154 | `, 155 | parserOptions: { 156 | ecmaFeatures: { 157 | jsx: true, 158 | }, 159 | }, 160 | }, 161 | { 162 | code: $` 163 | { 164 | "foo": ["bar", "baz"] 165 | } 166 | `, 167 | languageOptions: { 168 | parser: jsoncParser, 169 | }, 170 | }, 171 | { 172 | code: $` 173 | { 174 | "foo": [ 175 | "bar", 176 | "baz" 177 | ] 178 | } 179 | `, 180 | languageOptions: { 181 | parser: jsoncParser, 182 | }, 183 | }, 184 | { 185 | code: $` 186 | { 187 | "foo": {"a": "1", "b": "2"} 188 | } 189 | `, 190 | languageOptions: { 191 | parser: jsoncParser, 192 | }, 193 | }, 194 | { 195 | code: $` 196 | { 197 | "foo": { 198 | "a": "1", 199 | "b": "2" 200 | } 201 | } 202 | `, 203 | languageOptions: { 204 | parser: jsoncParser, 205 | }, 206 | }, 207 | { 208 | description: 'Ignore when there is a comment', 209 | code: $` 210 | { 211 | "foo": { "a": "1", 212 | // comment 213 | "b": "2" 214 | }, 215 | } 216 | `, 217 | languageOptions: { 218 | parser: jsoncParser, 219 | }, 220 | }, 221 | ] 222 | 223 | // Check snapshot for fixed code 224 | const invalid: InvalidTestCase[] = [ 225 | 'const a = {\nfoo: "bar", bar: 2 }', 226 | 'const a = {foo: "bar", \nbar: 2\n}', 227 | 'const a = [\n1, 2, 3]', 228 | 'const a = [1, \n2, 3\n]', 229 | 'import {\nfoo, bar } from "foo"', 230 | 'import { foo, \nbar } from "foo"', 231 | 'log(\na, b)', 232 | 'function foo(\na, b) {}', 233 | 'const foo = (\na, b) => {}', 234 | 'const foo = (\na, b): {\na:b} => {}', 235 | 'const foo = (\na, b): {a:b} => {}', 236 | 'interface Foo {\na: 1,b: 2\n}', 237 | { 238 | description: 'Add delimiter to avoid syntax error, (interface)', 239 | code: 'interface Foo {a: 1\nb: 2\n}', 240 | output: o => expect(o) 241 | .toMatchInlineSnapshot(`"interface Foo {a: 1,b: 2,}"`), 242 | }, 243 | { 244 | description: 'Delimiter already exists', 245 | code: 'interface Foo {a: 1;\nb: 2,\nc: 3}', 246 | output: o => expect(o) 247 | .toMatchInlineSnapshot(`"interface Foo {a: 1;b: 2,c: 3}"`), 248 | }, 249 | { 250 | description: 'Delimiter in the middle', 251 | code: $` 252 | export interface Foo { a: 1 253 | b: Pick 254 | c: 3 255 | } 256 | `, 257 | output: o => expect(o) 258 | .toMatchInlineSnapshot(`"export interface Foo { a: 1, b: Pick, c: 3,}"`), 259 | }, 260 | 'type Foo = {\na: 1,b: 2\n}', 261 | { 262 | description: 'Add delimiter to avoid syntax error, (type)', 263 | code: 'type Foo = {a: 1\nb: 2\n}', 264 | output: o => expect(o) 265 | .toMatchInlineSnapshot(`"type Foo = {a: 1,b: 2,}"`), 266 | }, 267 | 'type Foo = [1,2,\n3]', 268 | 'new Foo(1,2,\n3)', 269 | 'new Foo(\n1,2,\n3)', 270 | 'foo(\n()=>bar(),\n()=>\nbaz())', 271 | 'foo(()=>bar(),\n()=>\nbaz())', 272 | 'foo(1, 2)', 273 | 'foo<\nX,Y>(\n1, 2)', 274 | 'function foo<\nX,Y>() {}', 275 | 'const {a,\nb\n} = c', 276 | 'const [\na,b] = c', 277 | 'foo(([\na,b]) => {})', 278 | 279 | { 280 | description: 'CRLF', 281 | code: 'const a = {foo: "bar", \r\nbar: 2\r\n}', 282 | output: o => expect(o.replace(/\r/g, '\\r')) 283 | .toMatchInlineSnapshot(`"const a = {foo: "bar", bar: 2}"`), 284 | }, 285 | // https://github.com/antfu/eslint-plugin-antfu/issues/14 286 | { 287 | code: $` 288 | const a = ( 289 |
290 | {text.map(( 291 | item, index) => ( 292 |

293 |

294 | ))} 295 |
296 | ) 297 | `, 298 | output: o => expect(o).toMatchInlineSnapshot(` 299 | "const a = ( 300 |
301 | {text.map(( 302 | item, 303 | index 304 | ) => ( 305 |

306 |

307 | ))} 308 |
309 | )" 310 | `), 311 | parserOptions: { 312 | ecmaFeatures: { 313 | jsx: true, 314 | }, 315 | }, 316 | }, 317 | // https://github.com/antfu/eslint-plugin-antfu/issues/18 318 | { 319 | code: $` 320 | export default antfu({ 321 | }, 322 | { 323 | foo: 'bar' 324 | } 325 | // some comment 326 | // hello 327 | ) 328 | `, 329 | output: o => expect(o).toMatchInlineSnapshot(` 330 | "export default antfu({ 331 | },{ 332 | foo: 'bar' 333 | } 334 | // some comment 335 | // hello 336 | )" 337 | `), 338 | }, 339 | { 340 | code: $` 341 | function Foo() { 342 | return ( 343 |
347 | hi 348 |
349 | ); 350 | } 351 | `, 352 | output: o => expect(o).toMatchInlineSnapshot(` 353 | "function Foo() { 354 | return ( 355 |
356 | hi 357 |
358 | ); 359 | }" 360 | `), 361 | parserOptions: { 362 | ecmaFeatures: { 363 | jsx: true, 364 | }, 365 | }, 366 | }, 367 | { 368 | code: $` 369 | function Foo() { 370 | return ( 371 |
375 | hi 376 |
377 | ); 378 | } 379 | `, 380 | output: o => expect(o).toMatchInlineSnapshot(` 381 | "function Foo() { 382 | return ( 383 |
388 | hi 389 |
390 | ); 391 | }" 392 | `), 393 | parserOptions: { 394 | ecmaFeatures: { 395 | jsx: true, 396 | }, 397 | }, 398 | }, 399 | // https://github.com/antfu/eslint-plugin-antfu/issues/18 400 | { 401 | code: $` 402 | export default antfu({ 403 | }, 404 | // some comment 405 | { 406 | foo: 'bar' 407 | }, 408 | { 409 | } 410 | // hello 411 | ) 412 | `, 413 | output: o => expect(o).toMatchInlineSnapshot(` 414 | "export default antfu({ 415 | }, 416 | // some comment 417 | { 418 | foo: 'bar' 419 | },{ 420 | } 421 | // hello 422 | )" 423 | `), 424 | }, 425 | { 426 | code: $` 427 | { 428 | "foo": ["bar", 429 | "baz"], 430 | } 431 | `, 432 | languageOptions: { 433 | parser: jsoncParser, 434 | }, 435 | output: o => expect(o).toMatchInlineSnapshot(` 436 | "{ 437 | "foo": ["bar", "baz"], 438 | }" 439 | `), 440 | }, 441 | { 442 | code: $` 443 | { 444 | "foo": [ 445 | "bar","baz" 446 | ], 447 | } 448 | `, 449 | languageOptions: { 450 | parser: jsoncParser, 451 | }, 452 | output: o => expect(o).toMatchInlineSnapshot(` 453 | "{ 454 | "foo": [ 455 | "bar", 456 | "baz" 457 | ], 458 | }" 459 | `), 460 | }, 461 | { 462 | code: $` 463 | { 464 | "foo": {"a": "1", 465 | "b": "2"} 466 | } 467 | `, 468 | languageOptions: { 469 | parser: jsoncParser, 470 | }, 471 | output: o => expect(o).toMatchInlineSnapshot(` 472 | "{ 473 | "foo": {"a": "1", "b": "2"} 474 | }" 475 | `), 476 | }, 477 | { 478 | code: $` 479 | { 480 | "foo": { 481 | "a": "1", "b": "2" 482 | } 483 | } 484 | `, 485 | languageOptions: { 486 | parser: jsoncParser, 487 | }, 488 | output: o => expect(o).toMatchInlineSnapshot(` 489 | "{ 490 | "foo": { 491 | "a": "1", 492 | "b": "2" 493 | } 494 | }" 495 | `), 496 | }, 497 | { 498 | description: 'Only ignore when there is a comment', 499 | code: $` 500 | { 501 | "foo": { "a": "1", 502 | // comment 503 | "b": "2" 504 | }, 505 | "bar": ["1", 506 | "2"] 507 | } 508 | `, 509 | languageOptions: { 510 | parser: jsoncParser, 511 | }, 512 | output: o => expect(o).toMatchInlineSnapshot(` 513 | "{ 514 | "foo": { "a": "1", 515 | // comment 516 | "b": "2" 517 | }, 518 | "bar": ["1", "2"] 519 | }" 520 | `), 521 | }, 522 | { 523 | description: 'Check for function arguments in type', 524 | code: $` 525 | interface Foo { 526 | bar: ( 527 | foo: string, bar: { 528 | bar: string, baz: string }) => void 529 | } 530 | `, 531 | output: o => expect(o).toMatchInlineSnapshot(` 532 | "interface Foo { 533 | bar: ( 534 | foo: string, 535 | bar: { 536 | bar: string, 537 | baz: string 538 | } 539 | ) => void 540 | }" 541 | `), 542 | }, 543 | { 544 | code: $` 545 | interface foo { 546 | a:1} 547 | `, 548 | output: o => expect(o).toMatchInlineSnapshot(` 549 | "interface foo { 550 | a:1 551 | }" 552 | `), 553 | }, 554 | { 555 | code: $` 556 | interface foo {a:1 557 | } 558 | `, 559 | output: o => expect(o).toMatchInlineSnapshot(` 560 | "interface foo {a:1}" 561 | `), 562 | }, 563 | { 564 | code: $` 565 | type foo = { 566 | a:1} 567 | `, 568 | output: o => expect(o).toMatchInlineSnapshot(` 569 | "type foo = { 570 | a:1 571 | }" 572 | `), 573 | }, 574 | { 575 | code: $` 576 | type foo = {a:1 577 | } 578 | `, 579 | output: o => expect(o).toMatchInlineSnapshot(` 580 | "type foo = {a:1}" 581 | `), 582 | }, 583 | { 584 | code: $` 585 | type foo = [ 586 | 1] 587 | `, 588 | output: o => expect(o).toMatchInlineSnapshot(` 589 | "type foo = [ 590 | 1 591 | ]" 592 | `), 593 | }, 594 | { 595 | code: $` 596 | type foo = [1 597 | ] 598 | `, 599 | output: o => expect(o).toMatchInlineSnapshot(` 600 | "type foo = [1]" 601 | `), 602 | }, 603 | { 604 | code: $` 605 | const foo = [ 606 | 1] 607 | `, 608 | output: o => expect(o).toMatchInlineSnapshot(` 609 | "const foo = [ 610 | 1 611 | ]" 612 | `), 613 | }, 614 | { 615 | code: $` 616 | const foo = { 617 | a:1} 618 | `, 619 | output: o => expect(o).toMatchInlineSnapshot(` 620 | "const foo = { 621 | a:1 622 | }" 623 | `), 624 | }, 625 | { 626 | code: $` 627 | const foo = {a:1 628 | } 629 | `, 630 | output: o => expect(o).toMatchInlineSnapshot(` 631 | "const foo = {a:1}" 632 | `), 633 | }, 634 | { 635 | code: $` 636 | function foo(a 637 | ){} 638 | `, 639 | output: o => expect(o).toMatchInlineSnapshot(` 640 | "function foo(a){}" 641 | `), 642 | }, 643 | { 644 | code: $` 645 | function foo( 646 | a){} 647 | `, 648 | output: o => expect(o).toMatchInlineSnapshot(` 649 | "function foo( 650 | a 651 | ){}" 652 | `), 653 | }, 654 | ] 655 | 656 | run({ 657 | name: RULE_NAME, 658 | rule, 659 | 660 | valid: valids, 661 | invalid: invalid.map((i): InvalidTestCase => 662 | typeof i === 'string' 663 | ? { 664 | code: i, 665 | output: o => expect(o).toMatchSnapshot(), 666 | } 667 | : i, 668 | ), 669 | }) 670 | --------------------------------------------------------------------------------