├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── ci.yml ├── .npmrc ├── CONTRIBUTING.md ├── src ├── index.ts ├── commands.ts ├── commands │ ├── no-type.md │ ├── to-one-line.md │ ├── to-dynamic-import.md │ ├── inline-arrow.md │ ├── no-shorthand.md │ ├── keep-unique.md │ ├── reverse-if-else.md │ ├── to-arrow.md │ ├── to-for-of.md │ ├── to-function.md │ ├── to-promise-all.md │ ├── no-shorthand.test.ts │ ├── to-destructuring.md │ ├── to-ternary.md │ ├── no-shorthand.ts │ ├── to-for-each.md │ ├── hoist-regexp.md │ ├── to-string-literal.md │ ├── to-template-literal.md │ ├── _test-utils.ts │ ├── no-x-above.md │ ├── no-x-above.test.ts │ ├── keep-unique.test.ts │ ├── no-type.ts │ ├── reverse-if-else.test.ts │ ├── reverse-if-else.ts │ ├── _utils.ts │ ├── to-function.test.ts │ ├── to-string-literal.test.ts │ ├── inline-arrow.ts │ ├── to-for-of.test.ts │ ├── inline-arrow.test.ts │ ├── regex101.md │ ├── to-dynamic-import.test.ts │ ├── to-destructuring.ts │ ├── keep-aligned.md │ ├── hoist-regexp.ts │ ├── no-x-above.ts │ ├── keep-sorted.md │ ├── to-ternary.test.ts │ ├── index.ts │ ├── no-type.test.ts │ ├── keep-unique.ts │ ├── to-dynamic-import.ts │ ├── regex101.test.ts │ ├── hoist-regexp.test.ts │ ├── keep-aligned.ts │ ├── to-for-of.ts │ ├── to-string-literal.ts │ ├── to-function.ts │ ├── to-ternary.ts │ ├── to-arrow.test.ts │ ├── keep-aligned.test.ts │ ├── regex101.ts │ ├── to-arrow.ts │ ├── to-destructuring.test.ts │ ├── to-template-literal.ts │ ├── to-template-literal.test.ts │ ├── to-for-each.test.ts │ ├── to-one-line.test.ts │ ├── to-for-each.ts │ ├── to-one-line.ts │ ├── to-promise-all.ts │ ├── to-promise-all.test.ts │ ├── keep-sorted.ts │ └── keep-sorted.test.ts ├── plugin.ts ├── config.ts ├── rule.ts ├── traverse.ts ├── utils.ts ├── types.ts └── context.ts ├── netlify.toml ├── .gitignore ├── vitest.config.ts ├── tsdown.config.ts ├── docs ├── .vitepress │ ├── theme │ │ ├── index.ts │ │ └── style.css │ ├── vite.config.ts │ ├── uno.config.ts │ └── config.ts ├── integrations │ └── vscode.md ├── index.md ├── package.json ├── guide │ ├── install.md │ └── index.md └── public │ └── logo.svg ├── tsconfig.json ├── example.ts ├── eslint.config.js ├── LICENSE ├── .vscode └── settings.json ├── pnpm-workspace.yaml ├── README.md └── 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 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createPluginWithCommands } from './plugin' 2 | 3 | export default createPluginWithCommands() 4 | -------------------------------------------------------------------------------- /src/commands.ts: -------------------------------------------------------------------------------- 1 | export * from './commands/index' 2 | 3 | export type { Command } from './types' 4 | export { defineCommand } from './types' 5 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | publish = "docs/.vitepress/dist" 3 | command = "pnpm run build && pnpm run docs:build" 4 | 5 | [build.environment] 6 | NODE_VERSION = "20" 7 | -------------------------------------------------------------------------------- /.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 | docs/.vitepress/cache 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | release: 10 | uses: sxzz/workflows/.github/workflows/release.yml@v1 11 | with: 12 | publish: true 13 | permissions: 14 | contents: write 15 | id-token: write 16 | -------------------------------------------------------------------------------- /src/commands/no-type.md: -------------------------------------------------------------------------------- 1 | # `no-type` 2 | 3 | Removes TypeScript type annotations. 4 | 5 | ## Triggers 6 | 7 | - `/// no-type` 8 | - `/// nt` 9 | 10 | ## Examples 11 | 12 | ```ts 13 | /// no-type 14 | const foo: string = 'foo' 15 | ``` 16 | 17 | Will be converted to: 18 | 19 | ```ts 20 | const foo = 'foo' 21 | ``` 22 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsdown' 2 | 3 | export default defineConfig({ 4 | entry: [ 5 | 'src/index', 6 | 'src/config', 7 | 'src/types', 8 | 'src/commands.ts', 9 | ], 10 | exports: true, 11 | clean: true, 12 | external: [ 13 | '@typescript-eslint/utils', 14 | ], 15 | }) 16 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | // https://vitepress.dev/guide/custom-theme 2 | import type { EnhanceAppContext } from 'vitepress' 3 | import Theme from 'vitepress/theme' 4 | 5 | import 'floating-vue/dist/style.css' 6 | import 'uno.css' 7 | import './style.css' 8 | 9 | // @unocss-include 10 | export default { 11 | extends: Theme, 12 | enhanceApp({ app: _ }: EnhanceAppContext) { 13 | }, 14 | } 15 | -------------------------------------------------------------------------------- /src/commands/to-one-line.md: -------------------------------------------------------------------------------- 1 | # `to-one-line` 2 | 3 | Convert multiple lines object to one line object. 4 | 5 | ## Triggers 6 | 7 | - `/// to-one-line` 8 | - `/// tol` 9 | - `/// 21l` 10 | 11 | ## Examples 12 | 13 | ```js 14 | /// to-one-line 15 | const foo = { 16 | bar: 1, 17 | baz: 2 18 | } 19 | ``` 20 | 21 | Will be converted to: 22 | 23 | ```js 24 | const foo = { bar: 1, baz: 2 } 25 | ``` 26 | -------------------------------------------------------------------------------- /src/commands/to-dynamic-import.md: -------------------------------------------------------------------------------- 1 | # `to-dynamic-import` 2 | 3 | Convert import statement to dynamic import. 4 | 5 | ## Triggers 6 | 7 | - `/// to-dynamic-import` 8 | - `/// to-dynamic` 9 | 10 | ## Examples 11 | 12 | ```js 13 | /// to-dynamic-import 14 | import bar, { foo } from './foo' 15 | ``` 16 | 17 | Will be converted to: 18 | 19 | ```js 20 | const { default: bar, foo } = await import('./foo') 21 | ``` 22 | -------------------------------------------------------------------------------- /src/commands/inline-arrow.md: -------------------------------------------------------------------------------- 1 | # `inline-arrow` 2 | 3 | Inline return statement of arrow function. 4 | 5 | ## Triggers 6 | 7 | - `/// inline-arrow` 8 | - `/// ia` 9 | 10 | ## Examples 11 | 12 | ```ts 13 | /// inline-arrow 14 | const foo = async (msg: string): void => { 15 | return fn(msg) 16 | } 17 | ``` 18 | 19 | Will be converted to: 20 | 21 | ```ts 22 | const foo = async (msg: string): void => fn(msg) 23 | ``` 24 | -------------------------------------------------------------------------------- /src/commands/no-shorthand.md: -------------------------------------------------------------------------------- 1 | # `no-shorthand` 2 | 3 | Expand object shorthand properties to their full form. 4 | 5 | ## Triggers 6 | 7 | - `/// no-shorthand` 8 | - `/// nsh` 9 | 10 | ## Examples 11 | 12 | ```js 13 | /// no-shorthand 14 | const obj = { a, b, c: 0 } 15 | ``` 16 | 17 | Will be converted to: 18 | 19 | ```js 20 | // eslint-disable-next-line object-shorthand 21 | const obj = { a: a, b: b, c: 0 } 22 | ``` 23 | -------------------------------------------------------------------------------- /src/commands/keep-unique.md: -------------------------------------------------------------------------------- 1 | # `keep-unique` 2 | 3 | Keep array items unique, removing duplicates. 4 | 5 | ## Triggers 6 | 7 | - `/// keep-unique` 8 | - `/// uniq` 9 | - `// @keep-unique` 10 | 11 | ## Examples 12 | 13 | ```js 14 | /// keep-unique 15 | const array = [ 16 | 1, 17 | 2, 18 | 3, 19 | 2, 20 | '3', 21 | ] 22 | ``` 23 | 24 | Will be converted to: 25 | 26 | ```js 27 | /// keep-unique 28 | const array = [ 29 | 1, 30 | 2, 31 | 3, 32 | '3', 33 | ] 34 | ``` 35 | -------------------------------------------------------------------------------- /src/commands/reverse-if-else.md: -------------------------------------------------------------------------------- 1 | # `reverse-if-else` 2 | 3 | Reverse the order of if-else statements and negate the condition. 4 | 5 | ## Triggers 6 | 7 | - `/// reverse-if-else` 8 | - `/// rife` 9 | - `/// rif` 10 | 11 | ## Examples 12 | 13 | ```ts 14 | /// reverse-if-else 15 | if (a === 1) { 16 | a = 2 17 | } 18 | else { 19 | a = 3 20 | } 21 | ``` 22 | 23 | Will be converted to: 24 | 25 | ```ts 26 | if (!(a === 1)) { 27 | a = 3 28 | } 29 | else { 30 | a = 2 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /src/commands/to-arrow.md: -------------------------------------------------------------------------------- 1 | # `to-arrow` 2 | 3 | Convert a standard function declaration to an arrow function. 4 | 5 | Revese command: [`to-function`](./to-function) 6 | 7 | ## Triggers 8 | 9 | - `/// to-arrow` 10 | - `/// 2a` 11 | 12 | ## Examples 13 | 14 | ```ts 15 | /// to-arrow 16 | function foo(msg: string): void { 17 | console.log(msg) 18 | } 19 | ``` 20 | 21 | Will be converted to: 22 | 23 | ```ts 24 | const foo = (msg: string): void => { 25 | console.log(msg) 26 | } 27 | ``` 28 | -------------------------------------------------------------------------------- /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/commands/to-for-of.md: -------------------------------------------------------------------------------- 1 | # `to-for-of` 2 | 3 | Convert `.forEach()` to for-of loop. 4 | 5 | Revese command: [`to-for-each`](./to-for-each) 6 | 7 | ## Triggers 8 | 9 | - `/// to-for-of` 10 | - `/// forof` 11 | 12 | ## Examples 13 | 14 | ```js 15 | /// to-for-of 16 | items.forEach((item) => { 17 | if (!item) 18 | return 19 | console.log(item) 20 | }) 21 | ``` 22 | 23 | Will be converted to: 24 | 25 | ```js 26 | for (const item of items) { 27 | if (!item) 28 | continue 29 | console.log(item) 30 | } 31 | ``` 32 | -------------------------------------------------------------------------------- /src/commands/to-function.md: -------------------------------------------------------------------------------- 1 | # `to-function` 2 | 3 | Convert an arrow function to a standard function declaration. 4 | 5 | Revese command: [`to-arrow`](./to-arrow) 6 | 7 | ## Triggers 8 | 9 | - `/// to-function` 10 | - `/// to-fn` 11 | - `/// 2f` 12 | 13 | ## Examples 14 | 15 | ```ts 16 | /// to-function 17 | const foo = async (msg: string): void => { 18 | console.log(msg) 19 | } 20 | ``` 21 | 22 | Will be converted to (the command comment will be removed along the way): 23 | 24 | ```ts 25 | async function foo(msg: string): void { 26 | console.log(msg) 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/integrations/vscode.md: -------------------------------------------------------------------------------- 1 | # VS Code Extension 2 | 3 | A community plugin that provides support in VS Code. 4 | 5 | > [!INFO] 6 | > This is a community-maintained plugin. Please do your own research before using. If you have any issues with this plugin, please report it to [kvoon3/vscode-eslint-codemod](https://github.com/kvoon3/vscode-eslint-codemod). 7 | 8 | ## Installation 9 | 10 | [Install in Marketplace](https://marketplace.visualstudio.com/items?itemName=kvoon.vscode-eslint-codemod) 11 | 12 | ## Features 13 | 14 | - Autocomplete 15 | - Auto fix 16 | - Preview code changes 17 | - In-editor documentation 18 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: home 3 | 4 | hero: 5 | name: ESLint Plugin 6 | text: Command 7 | tagline: Comment-as-command for one-off codemod 8 | image: 9 | src: /logo.svg 10 | alt: ESLint Plugin Command Logo 11 | actions: 12 | - theme: brand 13 | text: Get Started 14 | link: /guide/ 15 | 16 | features: 17 | - title: On-demand 18 | icon: 📦 19 | details: Effective when you need it 20 | - title: Codemods 21 | icon: 🛠️ 22 | details: Reliable codemods with ease 23 | - title: Customizable 24 | icon: 🧩 25 | details: Support custom commands 26 | --- 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/commands/to-promise-all.md: -------------------------------------------------------------------------------- 1 | # `to-promise-all` 2 | 3 | Convert multiple `await` statements to `await Promise.all()`. 4 | 5 | ## Triggers 6 | 7 | - `/// to-promise-all` 8 | - `/// 2pa` 9 | 10 | ## Examples 11 | 12 | ```js 13 | /// to-promise-all 14 | const foo = await getFoo() 15 | const { bar, baz } = await getBar() 16 | ``` 17 | 18 | Will be converted to: 19 | 20 | ```js 21 | const [ 22 | foo, 23 | { bar, baz }, 24 | ] = await Promise.all([ 25 | getFoo(), 26 | getBar(), 27 | ]) 28 | ``` 29 | 30 | This command will try to search all continuous declarations with `await` and convert them to a single `await Promise.all()` call. 31 | -------------------------------------------------------------------------------- /src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { ESLint } from 'eslint' 2 | import type { ESLintPluginCommandOptions } from './types' 3 | import { version } from '../package.json' 4 | import BuiltinRules, { createRuleWithCommands } from './rule' 5 | 6 | export function createPluginWithCommands(options: ESLintPluginCommandOptions = {}) { 7 | const { 8 | name = 'command', 9 | } = options 10 | const plugin = options.commands 11 | ? createRuleWithCommands(options.commands) 12 | : BuiltinRules 13 | return { 14 | meta: { 15 | name, 16 | version, 17 | }, 18 | rules: { 19 | command: plugin, 20 | }, 21 | } satisfies ESLint.Plugin 22 | } 23 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "module", 3 | "private": true, 4 | "scripts": { 5 | "docs:dev": "vitepress dev", 6 | "docs:build": "vitepress build", 7 | "docs:preview": "vitepress preview" 8 | }, 9 | "devDependencies": { 10 | "@iconify-json/ph": "catalog:docs", 11 | "@iconify-json/svg-spinners": "catalog:docs", 12 | "@unocss/reset": "catalog:docs", 13 | "@vueuse/core": "catalog:docs", 14 | "floating-vue": "catalog:docs", 15 | "fuse.js": "catalog:docs", 16 | "unocss": "catalog:docs", 17 | "unplugin-vue-components": "catalog:docs", 18 | "vitepress": "catalog:docs", 19 | "vue": "catalog:docs" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/commands/no-shorthand.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { noShorthand as command } from './no-shorthand' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | /// no-shorthand 9 | const obj = fn({ a, b, c: d }) 10 | `, 11 | output: $` 12 | const obj = fn({ a: a, b: b, c: d }) 13 | `, 14 | errors: ['command-fix'], 15 | }, 16 | { 17 | code: $` 18 | /// nsh 19 | const obj = 10 20 | `, 21 | errors: ['command-error'], 22 | }, 23 | { 24 | code: $` 25 | /// nsh 26 | const obj = { key: value, key2: value2 } 27 | `, 28 | errors: ['command-error'], 29 | }, 30 | ) 31 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import type { Linter } from 'eslint' 2 | import type { ESLintPluginCommandOptions } from './types' 3 | import defaultPlugin from './index' 4 | import { createPluginWithCommands } from './plugin' 5 | 6 | export default function config(options: ESLintPluginCommandOptions = {}): Linter.FlatConfig { 7 | const plugin = options.commands 8 | ? createPluginWithCommands(options) 9 | : defaultPlugin 10 | const { 11 | name = 'command', 12 | } = options 13 | 14 | // @keep-sorted 15 | return { 16 | name, 17 | plugins: { 18 | [name]: plugin, 19 | }, 20 | rules: { 21 | [`${name}/command`]: 'error', 22 | }, 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /docs/.vitepress/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url' 2 | import UnoCSS from 'unocss/vite' 3 | import Components from 'unplugin-vue-components/vite' 4 | import { defineConfig } from 'vite' 5 | 6 | export default defineConfig({ 7 | plugins: [ 8 | Components({ 9 | dirs: [ 10 | fileURLToPath(new URL('./components', import.meta.url)), 11 | ], 12 | dts: fileURLToPath(new URL('../components.d.ts', import.meta.url)), 13 | include: [/\.vue$/, /\.vue\?vue/, /\.md$/], 14 | extensions: ['vue', 'md'], 15 | }), 16 | UnoCSS( 17 | fileURLToPath(new URL('./uno.config.ts', import.meta.url)), 18 | ), 19 | ], 20 | publicDir: fileURLToPath(new URL('../public', import.meta.url)), 21 | }) 22 | -------------------------------------------------------------------------------- /example.ts: -------------------------------------------------------------------------------- 1 | import { consola } from 'consola' 2 | 3 | const statusMap = { 4 | '200': 'OK', 5 | '404': 'Not Found', 6 | '500': 'Internal Server Error', 7 | '403': 'Forbidden', 8 | '400': 'Bad Request', 9 | } 10 | 11 | const errorCodes = ['404', '500', '403', '400', '400', '404'] 12 | 13 | export const add = (a: number, b: number): number => a + b 14 | 15 | export const log = async (input: T): Promise => { 16 | const status = input.match(/\d{3}/)?.[0] || '200' 17 | 18 | let message: string 19 | if (statusMap[status]) 20 | message = statusMap[status] 21 | else 22 | message = 'Unknown Status Code' 23 | 24 | consola[errorCodes.includes(status) ? 'error' : 'info']( 25 | `Status Code: ${status} - Message: ${message}`, 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/to-destructuring.md: -------------------------------------------------------------------------------- 1 | # `to-destructuring` 2 | 3 | Convert an assignment expression to destructuring assignment. 4 | 5 | ## Triggers 6 | 7 | - `/// to-destructuring` 8 | - `/// to-dest` 9 | - `/// 2destructuring` 10 | - `/// 2dest` 11 | 12 | ## Examples 13 | 14 | ```ts 15 | /// to-destructuring 16 | const foo = bar.foo 17 | 18 | /// to-dest 19 | const baz = bar?.foo 20 | 21 | /// 2destructuring 22 | const foo = bar[0] 23 | 24 | /// 2dest 25 | const foo = bar?.[1] 26 | 27 | let foo 28 | /// to-destructuring 29 | foo = bar().foo 30 | ``` 31 | 32 | Will be converted to: 33 | 34 | ```ts 35 | const { foo } = bar 36 | 37 | const { foo: baz } = bar ?? {} 38 | 39 | const [foo] = bar 40 | 41 | const [,foo] = bar ?? [] 42 | 43 | let foo 44 | ;({ foo } = bar()) 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/.vitepress/uno.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | presetAttributify, 4 | presetIcons, 5 | presetUno, 6 | } from 'unocss' 7 | 8 | export default defineConfig({ 9 | shortcuts: { 10 | 'button-action': 'flex flex-inline gap-2 items-center justify-center px-3 py-0.5 rounded hover:color-$vp-c-brand-2 hover:bg-$vp-c-default-soft', 11 | 'border-base': 'border-color-$vp-c-divider', 12 | 'text-brand': 'color-$vp-c-brand-1', 13 | 'text-brand-yellow': 'color-$vp-c-yellow-1', 14 | 'text-brand-red': 'color-$vp-c-red-1', 15 | }, 16 | blocklist: [ 17 | 'container', 18 | ], 19 | presets: [ 20 | presetUno(), 21 | presetAttributify(), 22 | presetIcons(), 23 | ], 24 | safelist: [ 25 | 'font-mono', 26 | 'mb0!', 27 | 'no-underline!', 28 | ], 29 | }) 30 | -------------------------------------------------------------------------------- /src/commands/to-ternary.md: -------------------------------------------------------------------------------- 1 | # `to-ternary` 2 | 3 | Convert an `if-else` statement to a [`ternary expression`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Conditional_operator). 4 | 5 | ## Triggers 6 | 7 | - `/// to-ternary` 8 | - `/// to-3` 9 | - `/// 2ternary` 10 | - `/// 23` 11 | 12 | ## Examples 13 | 14 | ```js 15 | /// to-ternary 16 | if (condition) 17 | foo() 18 | else 19 | bar = 1 20 | 21 | // For conditional assignments to the same variable 22 | /// to-ternary 23 | if (condition1) 24 | foo = 1 25 | else if (condition2) 26 | foo = bar 27 | else 28 | foo = baz() 29 | ``` 30 | 31 | Will be converted to (the command comment will be removed along the way): 32 | 33 | ```js 34 | condition ? foo() : bar = 1 35 | 36 | foo = condition1 ? 1 : condition2 ? bar : baz() 37 | ``` 38 | -------------------------------------------------------------------------------- /src/commands/no-shorthand.ts: -------------------------------------------------------------------------------- 1 | import type { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import type { Command } from '../types' 3 | 4 | export const noShorthand: Command = { 5 | name: 'no-shorthand', 6 | match: /^\s*[/:@]\s*(no-shorthand|nsh)$/, 7 | action(ctx) { 8 | const nodes = ctx.findNodeBelow({ 9 | filter: node => node.type === 'Property' && node.shorthand, 10 | findAll: true, 11 | }) 12 | if (!nodes || nodes.length === 0) 13 | return ctx.reportError('Unable to find shorthand object property to convert') 14 | 15 | ctx.report({ 16 | nodes, 17 | message: 'Expand shorthand', 18 | * fix(fixer) { 19 | for (const node of nodes) 20 | yield fixer.insertTextAfter(node.key, `: ${ctx.getTextOf(node.key)}`) 21 | }, 22 | }) 23 | }, 24 | } 25 | -------------------------------------------------------------------------------- /src/commands/to-for-each.md: -------------------------------------------------------------------------------- 1 | # `to-for-each` 2 | 3 | Convert for-of/for-in loop to `.forEach()`. 4 | 5 | Revese command: [`to-for-of`](./to-for-of) 6 | 7 | ## Triggers 8 | 9 | - `/// to-for-each` 10 | - `/// foreach` 11 | 12 | ## Examples 13 | 14 | ```js 15 | /// to-for-each 16 | for (const item of items) { 17 | if (!item) 18 | continue 19 | console.log(item) 20 | } 21 | ``` 22 | 23 | Will be converted to: 24 | 25 | ```js 26 | items.forEach((item) => { 27 | if (!item) 28 | return 29 | console.log(item) 30 | }) 31 | ``` 32 | 33 | For for-in loop: 34 | 35 | ```js 36 | /// to-for-each 37 | for (const key in obj) { 38 | if (!obj[key]) 39 | continue 40 | console.log(obj[key]) 41 | } 42 | ``` 43 | 44 | Will be converted to: 45 | 46 | ```js 47 | Object.keys(obj).forEach((key) => { 48 | if (!obj[key]) 49 | return 50 | console.log(obj[key]) 51 | }) 52 | ``` 53 | -------------------------------------------------------------------------------- /src/commands/hoist-regexp.md: -------------------------------------------------------------------------------- 1 | # `hoist-regexp` 2 | 3 | Hoist regular expressions to the top-level. 4 | 5 | ## Triggers 6 | 7 | - `/// hoist-regexp` 8 | - `/// hoist-regex` 9 | - `/// hreg` 10 | 11 | ## Examples 12 | 13 | ```ts 14 | function foo(msg: string): void { 15 | /// hoist-regexp 16 | console.log(/foo/.test(msg)) 17 | } 18 | ``` 19 | 20 | Will be converted to: 21 | 22 | ```ts 23 | const re$0 = /foo/ 24 | 25 | function foo(msg: string): void { 26 | console.log(re$0.test(msg)) 27 | } 28 | ``` 29 | 30 | You can also provide a name for the hoisted regular expression: 31 | 32 | ```ts 33 | function foo(msg: string): void { 34 | /// hoist-regexp myRegex 35 | console.log(/foo/.test(msg)) 36 | } 37 | ``` 38 | 39 | Will be converted to: 40 | 41 | ```ts 42 | const myRegex = /foo/ 43 | 44 | function foo(msg: string): void { 45 | console.log(myRegex.test(msg)) 46 | } 47 | ``` 48 | -------------------------------------------------------------------------------- /src/commands/to-string-literal.md: -------------------------------------------------------------------------------- 1 | # `to-string-literal` 2 | 3 | Convert template literals to string literals. 4 | 5 | Revese command: [`to-template-literal`](./to-template-literal) 6 | 7 | ## Triggers 8 | 9 | - `/// to-string-literal` 10 | - `/// to-sl` 11 | - `/// 2string-literal` 12 | - `/// 2sl` 13 | 14 | or if you fancy `@`: 15 | 16 | - `// @to-string-literal` 17 | - `// @to-sl` 18 | - `// @2string-literal` 19 | - `// @2sl` 20 | 21 | ## Examples 22 | 23 | ```js 24 | /// @2sl 25 | const foo = `bar` 26 | 27 | // @2sl 28 | const quxx = `${foo}quxx` 29 | 30 | // Also supports using numbers to specify which items need to be converted (starts from 1) 31 | // @2sl 1 3 32 | const bar = `bar`; const baz = `baz`; const qux = `qux` 33 | ``` 34 | 35 | Will be converted to: 36 | 37 | ```js 38 | const foo = 'bar' 39 | 40 | // eslint-disable-next-line prefer-template 41 | const quxx = foo + 'quxx' 42 | 43 | const bar = 'bar'; const baz = `baz`; const qux = 'qux' 44 | ``` 45 | -------------------------------------------------------------------------------- /src/commands/to-template-literal.md: -------------------------------------------------------------------------------- 1 | # `to-template-literal` 2 | 3 | Convert string literals to template literals. 4 | 5 | Revese command: [`to-string-literal`](./to-string-literal) 6 | 7 | ## Triggers 8 | 9 | - `/// to-template-literal` 10 | - `/// to-tl` 11 | - `/// 2template-literal` 12 | - `/// 2tl` 13 | 14 | or if you fancy `@`: 15 | 16 | - `// @to-template-literal` 17 | - `// @to-tl` 18 | - `// @2template-literal` 19 | - `// @2tl` 20 | 21 | ## Examples 22 | 23 | ```js 24 | /// @2tl 25 | const bar = 'bar' 26 | 27 | // @2tl 28 | // eslint-disable-next-line prefer-template 29 | const quxx = bar + 'quxx' 30 | 31 | // Also supports using numbers to specify which items need to be converted (starts from 1) 32 | // @2tl 1 3 33 | const foo = 'foo'; const baz = 'baz'; const qux = 'qux' 34 | ``` 35 | 36 | Will be converted to: 37 | 38 | ```js 39 | const bar = `bar` 40 | 41 | const quxx = `${bar}quxx` 42 | 43 | const foo = `foo`; const baz = 'baz'; const qux = `qux` 44 | ``` 45 | -------------------------------------------------------------------------------- /src/commands/_test-utils.ts: -------------------------------------------------------------------------------- 1 | import type { TestCase } from 'eslint-vitest-rule-tester' 2 | import type { Command } from '../types' 3 | import * as tsParser from '@typescript-eslint/parser' 4 | import { run as _run } from 'eslint-vitest-rule-tester' 5 | import { createRuleWithCommands } from '../rule' 6 | 7 | export { unindent as $ } from 'eslint-vitest-rule-tester' 8 | 9 | export function run(command: Command | Command[], ...cases: (TestCase | string)[]) { 10 | const commands = Array.isArray(command) ? command : [command] 11 | 12 | const validCases: (TestCase | string)[] = [] 13 | const invalidCases: TestCase[] = [] 14 | 15 | for (const c of cases) { 16 | if (typeof c === 'string') 17 | validCases.push(c) 18 | else 19 | invalidCases.push(c) 20 | } 21 | 22 | return _run({ 23 | name: commands[0].name, 24 | rule: createRuleWithCommands(commands) as any, 25 | languageOptions: { 26 | parser: tsParser, 27 | }, 28 | valid: validCases, 29 | invalid: invalidCases, 30 | }) 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/no-x-above.md: -------------------------------------------------------------------------------- 1 | # `no-x-above` 2 | 3 | Disallow certain syntaxes above or below the comment, with in the current block. 4 | 5 | ## Triggers 6 | 7 | - `// @no-await-above` - Disallow `await` above the comment. 8 | - `// @no-await-below` - Disallow `await` below the comment. 9 | 10 | ## Supported Checker Type 11 | 12 | - `await` - Disallow `await` above or below the comment. 13 | 14 | // TODO: support more types 15 | 16 | ## Examples 17 | 18 | ```js 19 | const foo = syncOp() 20 | const bar = await asyncOp() // <-- this is not allowed 21 | // @no-await-above 22 | const baz = await asyncOp() // <-- this is ok 23 | ``` 24 | 25 | The effect will only affect the current scope, for example: 26 | 27 | ```js 28 | console.log(await foo()) // <-- this is not checked, as it's not in the function scope where the comment is 29 | 30 | async function foo() { 31 | const bar = syncOp() 32 | const baz = await asyncOp() // <-- this is not allowed 33 | // @no-await-above 34 | const qux = await asyncOp() // <-- this is ok 35 | } 36 | ``` 37 | -------------------------------------------------------------------------------- /src/commands/no-x-above.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { noXAbove as command } from './no-x-above' 3 | 4 | run( 5 | command, 6 | // { 7 | // code: $` 8 | // /// no-await-below 9 | // const obj = await foo() 10 | // `, 11 | // errors: ['command-error'], 12 | // }, 13 | // $` 14 | // const obj = await foo() 15 | // /// no-await-below 16 | // const obj = foo() 17 | // `, 18 | // { 19 | // code: $` 20 | // const obj = await foo() 21 | // /// no-await-above 22 | // const obj = foo() 23 | // `, 24 | // errors: ['command-fix'], 25 | // }, 26 | // // Don't count outside of scope 27 | // $` 28 | // await foo() 29 | // async function foo() { 30 | // /// no-await-above 31 | // const obj = await Promise.all([]) 32 | // } 33 | // `, 34 | // Don't count inside 35 | $` 36 | async function foo() { 37 | /// no-await-below 38 | console.log('foo') 39 | const bar = async () => { 40 | const obj = await Promise.all([]) 41 | } 42 | } 43 | `, 44 | ) 45 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import antfu from '@antfu/eslint-config' 2 | // eslint-disable-next-line antfu/no-import-dist 3 | import command from './dist/config.mjs' 4 | 5 | export default antfu( 6 | { 7 | ignores: ['vendor'], 8 | }, 9 | ) 10 | .replace( 11 | 'antfu/command/rules', 12 | command(), 13 | ) 14 | .append( 15 | { 16 | files: ['**/*.md/**/*'], 17 | rules: { 18 | 'command/command': 'off', 19 | 'antfu/top-level-function': 'off', 20 | 'style/max-statements-per-line': 'off', 21 | }, 22 | }, 23 | { 24 | rules: { 25 | 'antfu/top-level-function': 'off', 26 | }, 27 | }, 28 | { 29 | files: ['**/*.test.ts'], 30 | rules: { 31 | 'antfu/indent-unindent': 'error', 32 | }, 33 | }, 34 | { 35 | files: ['example.ts'], 36 | rules: { 37 | 'no-console': 'off', 38 | 'prefer-template': 'off', 39 | 'prefer-const': 'off', 40 | 'style/quote-props': 'off', 41 | 'style/no-multiple-empty-lines': 'off', 42 | 'style/type-generic-spacing': 'off', 43 | }, 44 | }, 45 | ) 46 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | // Silent the stylistic rules in you IDE, but still auto fix them 16 | "eslint.rules.customizations": [ 17 | { "rule": "@stylistic/*", "severity": "warn" }, 18 | { "rule": "style*", "severity": "warn" }, 19 | { "rule": "*-indent", "severity": "warn" }, 20 | { "rule": "*-spacing", "severity": "warn" }, 21 | { "rule": "*-spaces", "severity": "warn" }, 22 | { "rule": "*-order", "severity": "warn" }, 23 | { "rule": "*-dangle", "severity": "warn" }, 24 | { "rule": "*-newline", "severity": "warn" }, 25 | { "rule": "*quotes", "severity": "warn" }, 26 | { "rule": "*semi", "severity": "warn" } 27 | ], 28 | 29 | "eslint.validate": [ 30 | "javascript", 31 | "javascriptreact", 32 | "typescript", 33 | "typescriptreact", 34 | "vue", 35 | "html", 36 | "markdown", 37 | "json", 38 | "jsonc", 39 | "yaml" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/keep-unique.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { keepSorted } from './keep-sorted' 3 | import { keepUnique as command } from './keep-unique' 4 | 5 | run( 6 | [ 7 | command, 8 | keepSorted, 9 | ], 10 | // Already unique 11 | $` 12 | // @keep-unique 13 | export const arr = [ 14 | 'apple', 15 | 'bar', 16 | 'foo', 17 | ] 18 | `, 19 | // Unique 20 | { 21 | code: $` 22 | // @keep-unique 23 | export const arr = [ 24 | 1, 2, 3, 2, 1, '3', 25 | 3, 26 | 4, '3', 27 | false, true, false, 28 | ] 29 | `, 30 | output: $` 31 | // @keep-unique 32 | export const arr = [ 33 | 1, 2, 3, '3', 34 | 4, false, true, 35 | ] 36 | `, 37 | errors: ['command-fix'], 38 | }, 39 | { 40 | description: 'Unique combine with sort', 41 | code: $` 42 | /** 43 | * @keep-unique @keep-sorted 44 | */ 45 | export const arr = [ 3, 2, 1, 2, 1, 'foo', 'bar' ] 46 | `, 47 | output(output) { 48 | expect(output).toMatchInlineSnapshot(` 49 | "/** 50 | * @keep-unique @keep-sorted 51 | */ 52 | export const arr = [ 'bar', 'foo', 1, 2, 3, ]" 53 | `) 54 | }, 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /src/commands/no-type.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../types' 2 | 3 | export const noType: Command = { 4 | name: 'no-type', 5 | match: /^\s*[/:@]\s*(no-type|nt)$/, 6 | action(ctx) { 7 | const nodes = ctx.findNodeBelow({ 8 | filter: node => node.type.startsWith('TS'), 9 | findAll: true, 10 | shallow: true, 11 | }) 12 | 13 | if (!nodes || nodes.length === 0) 14 | return ctx.reportError('Unable to find type to remove') 15 | 16 | ctx.report({ 17 | nodes, 18 | message: 'Remove type', 19 | * fix(fixer) { 20 | for (const node of nodes.reverse()) { 21 | if (node.type === 'TSAsExpression' // foo as number 22 | || node.type === 'TSSatisfiesExpression' // foo satisfies T 23 | || node.type === 'TSNonNullExpression' // foo! 24 | || node.type === 'TSInstantiationExpression' // foo 25 | ) { 26 | yield fixer.removeRange([node.expression.range[1], node.range[1]]) 27 | } 28 | else if (node.type === 'TSTypeAssertion') { // foo 29 | yield fixer.removeRange([node.range[0], node.expression.range[0]]) 30 | } 31 | else { 32 | yield fixer.remove(node) 33 | } 34 | } 35 | }, 36 | }) 37 | }, 38 | } 39 | -------------------------------------------------------------------------------- /src/commands/reverse-if-else.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { reverseIfElse as command } from './reverse-if-else' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | /// reverse-if-else 9 | const foo = 'bar' 10 | `, 11 | errors: ['command-error'], 12 | }, 13 | // with else if 14 | { 15 | code: $` 16 | /// reverse-if-else 17 | if (a === 1) { 18 | a = 2 19 | } 20 | else if (a === 2) { 21 | a = 3 22 | } 23 | `, 24 | errors: ['command-error'], 25 | }, 26 | { 27 | code: $` 28 | /// reverse-if-else 29 | if (a === 1) { 30 | a = 2 31 | } 32 | else { 33 | a = 3 34 | } 35 | `, 36 | output: $` 37 | if (!(a === 1)) { 38 | a = 3 39 | } 40 | else { 41 | a = 2 42 | } 43 | `, 44 | errors: ['command-fix'], 45 | }, 46 | // without if 47 | { 48 | code: $` 49 | /// reverse-if-else 50 | if (a === 1 || b === 2) { 51 | a = 2 52 | } 53 | `, 54 | output: (i) => { 55 | expect(i).toMatchInlineSnapshot(` 56 | "if (!(a === 1 || b === 2)) { 57 | } 58 | else { 59 | a = 2 60 | }" 61 | `) 62 | }, 63 | errors: ['command-fix'], 64 | }, 65 | ) 66 | -------------------------------------------------------------------------------- /src/commands/reverse-if-else.ts: -------------------------------------------------------------------------------- 1 | import type { AST_NODE_TYPES } from '@typescript-eslint/utils' 2 | import type { Command } from '../types' 3 | 4 | export const reverseIfElse: Command = { 5 | name: 'reverse-if-else', 6 | match: /^\s*[/:@]\s*(reverse-if-else|rife|rif)$/, 7 | action(ctx) { 8 | const node = ctx.findNodeBelow('IfStatement') 9 | 10 | if (!node) 11 | return ctx.reportError('Cannot find if statement') 12 | 13 | const elseNode = node.alternate 14 | 15 | const isElseif = elseNode?.type === ('IfStatement' as AST_NODE_TYPES.IfStatement) 16 | if (isElseif) 17 | return ctx.reportError('Unable reverse when `else if` statement exist') 18 | 19 | const ifNode = node.consequent 20 | 21 | ctx.report({ 22 | loc: node.loc, 23 | message: 'Reverse if-else', 24 | fix(fixer) { 25 | const lineIndent = ctx.getIndentOfLine(node.loc.start.line) 26 | 27 | const conditionText = ctx.getTextOf(node.test) 28 | const ifText = ctx.getTextOf(ifNode) 29 | const elseText = elseNode ? ctx.getTextOf(elseNode) : '{\n}' 30 | 31 | const str = [ 32 | `if (!(${conditionText})) ${elseText}`, 33 | `else ${ifText}`, 34 | ] 35 | .map((line, idx) => idx ? lineIndent + line : line) 36 | .join('\n') 37 | 38 | return fixer.replaceText(node, str) 39 | }, 40 | }) 41 | }, 42 | } 43 | -------------------------------------------------------------------------------- /src/commands/_utils.ts: -------------------------------------------------------------------------------- 1 | import type { Tree } from '../types' 2 | 3 | export function getNodesByIndexes(nodes: T[], indexes: number[]) { 4 | return indexes.length 5 | ? indexes.map(n => nodes[n]).filter(Boolean) 6 | : nodes 7 | } 8 | 9 | /** 10 | * 11 | * @param value Accepts a string of numbers separated by spaces 12 | * @param integer If true, only positive integers are returned 13 | */ 14 | export function parseToNumberArray(value: string | undefined, integer = false) { 15 | return value?.split(' ') 16 | .map(Number) 17 | .filter(n => 18 | !Number.isNaN(n) 19 | && integer 20 | ? (Number.isInteger(n) && n > 0) 21 | : true, 22 | ) ?? [] 23 | } 24 | 25 | export function insideRange(node: Tree.Node, range: [number, number], includeStart = true, includeEnd = true) { 26 | return ( 27 | (includeStart ? node.range[0] >= range[0] : node.range[0] > range[0]) 28 | && (includeEnd ? node.range[1] <= range[1] : node.range[1] < range[1]) 29 | ) 30 | } 31 | 32 | export function unwrapType(node: Tree.Node) { 33 | if (node.type === 'TSAsExpression' // foo as number 34 | || node.type === 'TSSatisfiesExpression' // foo satisfies T 35 | || node.type === 'TSNonNullExpression' // foo! 36 | || node.type === 'TSInstantiationExpression' // foo 37 | || node.type === 'TSTypeAssertion') { // foo 38 | return node.expression 39 | } 40 | return node 41 | } 42 | -------------------------------------------------------------------------------- /src/commands/to-function.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toFunction as command } from './to-function' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | ///to-fn 9 | const a = 1 10 | `, 11 | errors: ['command-error'], 12 | }, 13 | { 14 | code: $` 15 | /// to-function 16 | export const foo = (arg: Z): Bar => { 17 | const bar = () => {} 18 | } 19 | `, 20 | output: $` 21 | export function foo (arg: Z): Bar { 22 | const bar = () => {} 23 | } 24 | `, 25 | errors: ['command-fix'], 26 | }, 27 | // Arrow function without name 28 | { 29 | code: $` 30 | // /2f 31 | export default (arg: Z): Bar => { 32 | const bar = () => {} 33 | } 34 | `, 35 | output: $` 36 | export default function (arg: Z): Bar { 37 | const bar = () => {} 38 | } 39 | `, 40 | errors: ['command-fix'], 41 | }, 42 | // Object method 43 | { 44 | code: $` 45 | const bar = { 46 | /// to-fn 47 | bar: (a: number, b: number): number => a + b, 48 | foo: () => { return 1 } 49 | } 50 | `, 51 | output: $` 52 | const bar = { 53 | bar (a: number, b: number): number { 54 | return a + b 55 | }, 56 | foo: () => { return 1 } 57 | } 58 | `, 59 | errors: ['command-fix'], 60 | }, 61 | ) 62 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | catalogMode: prefer 2 | shellEmulator: true 3 | trustPolicy: no-downgrade 4 | 5 | packages: 6 | - playground 7 | - docs 8 | - examples/* 9 | catalogs: 10 | dev: 11 | '@antfu/eslint-config': ^6.6.0 12 | '@antfu/ni': ^28.0.0 13 | '@antfu/utils': ^9.3.0 14 | '@eslint/config-inspector': ^1.4.2 15 | '@types/lodash.merge': ^4.6.9 16 | '@types/node': ^24.10.2 17 | '@types/semver': ^7.7.1 18 | '@typescript-eslint/rule-tester': ^8.49.0 19 | '@typescript-eslint/typescript-estree': ^8.49.0 20 | '@typescript-eslint/utils': ^8.49.0 21 | '@vitest/ui': ^4.0.15 22 | bumpp: ^10.3.2 23 | chokidar: ^5.0.0 24 | eslint: ^9.39.1 25 | eslint-vitest-rule-tester: ^3.0.0 26 | fast-glob: ^3.3.3 27 | lint-staged: ^16.2.7 28 | lodash.merge: 4.6.2 29 | pnpm: ^10.25.0 30 | rimraf: ^6.1.2 31 | semver: ^7.7.3 32 | simple-git-hooks: ^2.13.1 33 | tsdown: ^0.17.2 34 | typescript: ^5.9.3 35 | vite: ^7.2.7 36 | vitest: ^4.0.15 37 | docs: 38 | '@iconify-json/ph': ^1.2.2 39 | '@iconify-json/svg-spinners': ^1.2.4 40 | '@unocss/reset': ^66.5.10 41 | '@vueuse/core': ^14.1.0 42 | floating-vue: ^5.2.2 43 | fuse.js: ^7.1.0 44 | unocss: ^66.5.10 45 | unplugin-vue-components: ^30.0.0 46 | vitepress: ^1.6.4 47 | vue: ^3.5.25 48 | prod: 49 | '@es-joy/jsdoccomment': ^0.78.0 50 | onlyBuiltDependencies: 51 | - esbuild 52 | - simple-git-hooks 53 | - unrs-resolver 54 | -------------------------------------------------------------------------------- /src/commands/to-string-literal.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toStringLiteral as command } from './to-string-literal' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | // @2sl 9 | const a = \`a\`; const b = \`b\`; const c = 'c'; 10 | `, 11 | output: $` 12 | const a = "a"; const b = "b"; const c = 'c'; 13 | `, 14 | errors: ['command-fix'], 15 | }, 16 | // You can specify which one to convert 17 | { 18 | code: $` 19 | // @2sl 2 3 20 | const a = \`a\`, b = \`b\`, c = \`c\`, d = \`d\`; 21 | `, 22 | output: $` 23 | const a = \`a\`, b = "b", c = "c", d = \`d\`; 24 | `, 25 | errors: ['command-fix'], 26 | }, 27 | // mixed 28 | { 29 | code: $` 30 | // @2sl 1 3 31 | const a = 'a', b = 'b', c = \`c\`, d = 'd', e = \`e\`, f = \`f\`; 32 | `, 33 | output: $` 34 | const a = 'a', b = 'b', c = "c", d = 'd', e = \`e\`, f = "f"; 35 | `, 36 | errors: ['command-fix'], 37 | }, 38 | // `a${b}d` -> `'a' + b + 'd'` 39 | { 40 | code: $` 41 | // @2sl 42 | const a = \`\${g}a\${a}a\${b}c\${d}e\${a}\`; 43 | `, 44 | output: $` 45 | const a = g + "a" + a + "a" + b + "c" + d + "e" + a; 46 | `, 47 | errors: ['command-fix'], 48 | }, 49 | // escape 50 | { 51 | code: $` 52 | // @2sl 53 | const a = \`"\\"\\\\"\` 54 | `, 55 | output: $` 56 | const a = "\\"\\\\\\"\\\\\\\\\\"" 57 | `, 58 | errors: ['command-fix'], 59 | }, 60 | ) 61 | -------------------------------------------------------------------------------- /src/commands/inline-arrow.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | import { unwrapType } from './_utils' 3 | 4 | export const inlineArrow: Command = { 5 | name: 'inline-arrow', 6 | match: /^\s*[/:@]\s*(inline-arrow|ia)$/, 7 | action(ctx) { 8 | const arrowFn = ctx.findNodeBelow('ArrowFunctionExpression') 9 | if (!arrowFn) 10 | return ctx.reportError('Unable to find arrow function to convert') 11 | const body = arrowFn.body 12 | if (body.type !== 'BlockStatement') 13 | return ctx.reportError('Arrow function body must have a block statement') 14 | 15 | const statements = body.body 16 | if ( 17 | (statements.length !== 1 || statements[0].type !== 'ReturnStatement') 18 | && (statements.length !== 0) 19 | ) { 20 | return ctx.reportError('Arrow function body must have a single statement') 21 | } 22 | const statement = statements[0] as Tree.ReturnStatement | undefined 23 | const argument: Tree.Node | null = statement?.argument ? unwrapType(statement.argument) : null 24 | const isObject = (argument?.type === 'ObjectExpression') 25 | 26 | ctx.report({ 27 | node: arrowFn, 28 | loc: body.loc, 29 | message: 'Inline arrow function', 30 | fix(fixer) { 31 | let raw = statement && statement.argument 32 | ? ctx.getTextOf(statement.argument) 33 | : 'undefined' 34 | if (isObject) 35 | raw = `(${raw})` 36 | return fixer.replaceTextRange(body.range, raw) 37 | }, 38 | }) 39 | }, 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/to-for-of.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toForOf as command } from './to-for-of' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | /// to-for-of 9 | bar.forEach(b => { 10 | if (!b) 11 | return 12 | console.log(b) 13 | }) 14 | `, 15 | output: $` 16 | for (const b of bar) { 17 | if (!b) 18 | continue 19 | console.log(b) 20 | } 21 | `, 22 | errors: ['command-fix'], 23 | }, 24 | // Chaining 25 | { 26 | code: $` 27 | /// to-for-of 28 | a.sort().filter(b => !!b).forEach(b => { 29 | console.log(b) 30 | }) 31 | `, 32 | output: $` 33 | for (const b of a.sort().filter(b => !!b)) { 34 | console.log(b) 35 | } 36 | `, 37 | errors: ['command-fix'], 38 | }, 39 | // Chaining multi-line 40 | { 41 | code: $` 42 | /// to-for-of 43 | a 44 | .sort() 45 | .filter(b => !!b) 46 | .forEach(b => { 47 | console.log(b) 48 | }) 49 | `, 50 | output: $` 51 | for (const b of a 52 | .sort() 53 | .filter(b => !!b)) { 54 | console.log(b) 55 | } 56 | `, 57 | errors: ['command-fix'], 58 | }, 59 | // forEach with index (TODO: support this) 60 | { 61 | code: $` 62 | /// to-for-of 63 | a.forEach((b, i) => { 64 | console.log(i, b) 65 | }) 66 | `, 67 | errors: ['command-error', 'command-error-cause'], 68 | }, 69 | ) 70 | -------------------------------------------------------------------------------- /docs/guide/install.md: -------------------------------------------------------------------------------- 1 | # Installation 2 | 3 | Install the `eslint-plugin-command` package: 4 | 5 | ```bash 6 | npm i eslint-plugin-command -D 7 | ``` 8 | 9 | In your flat config `eslint.config.mjs`: 10 | 11 | ```js 12 | // eslint.config.mjs 13 | import command from 'eslint-plugin-command/config' 14 | 15 | export default [ 16 | // ... your other flat config 17 | command(), 18 | ] 19 | ``` 20 | 21 |
22 | Legacy Config 23 | 24 | While no longer supported, you may still use the legacy `.eslintrc.js` file: 25 | 26 | ```js 27 | // .eslintrc.js 28 | module.exports = { 29 | plugins: [ 30 | 'command' 31 | ], 32 | rules: { 33 | 'command/command': 'error', 34 | }, 35 | } 36 | ``` 37 | 38 |
39 | 40 | ## Custom Commands 41 | 42 | It's also possible to define your custom commands. 43 | 44 | ```js 45 | // eslint.config.mjs 46 | import { builtinCommands, defineCommand } from 'eslint-plugin-command/commands' 47 | import command from 'eslint-plugin-command/config' 48 | 49 | const myCommand = defineCommand({ 50 | name: 'my-command', 51 | // RegExp to match the command comment (without leading `//`) 52 | match: /^@my-command$/, 53 | action(context) { 54 | // Do something with the context 55 | }, 56 | }) 57 | 58 | export default [ 59 | // ... your other flat config 60 | command({ 61 | commands: [ 62 | ...builtinCommands, 63 | myCommand, 64 | ] 65 | }), 66 | ] 67 | ``` 68 | 69 | You can refer to [the built-in commands as examples](https://github.com/antfu/eslint-plugin-command/tree/main/src/commands). 70 | -------------------------------------------------------------------------------- /src/commands/inline-arrow.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { inlineArrow as command } from './inline-arrow' 3 | 4 | run( 5 | command, 6 | // no arrow function 7 | { 8 | code: $` 9 | ///inline-arrow 10 | const a = 1 11 | `, 12 | errors: ['command-error'], 13 | }, 14 | // multi statement 15 | { 16 | code: $` 17 | /// inline-arrow 18 | export const foo = arg => { 19 | const a = 1 20 | return a 21 | } 22 | `, 23 | errors: ['command-error'], 24 | }, 25 | { 26 | code: $` 27 | /// inline-arrow 28 | export const foo = (arg: Z): Bar => { 29 | return arg 30 | } 31 | `, 32 | output: $` 33 | export const foo = (arg: Z): Bar => arg 34 | `, 35 | errors: ['command-fix'], 36 | }, 37 | // no return statement 38 | { 39 | code: $` 40 | ///inline-arrow 41 | const foo = () => {} 42 | `, 43 | output: $` 44 | const foo = () => undefined 45 | `, 46 | errors: ['command-fix'], 47 | }, 48 | // without return argument 49 | { 50 | code: $` 51 | // /ia 52 | export default (arg: Z): Bar => { return } 53 | `, 54 | output: $` 55 | export default (arg: Z): Bar => undefined 56 | `, 57 | errors: ['command-fix'], 58 | }, 59 | { 60 | code: $` 61 | /// inline-arrow 62 | export const foo = () => { 63 | return { a: 'b' } as any 64 | } 65 | `, 66 | output: $` 67 | export const foo = () => ({ a: 'b' } as any) 68 | `, 69 | errors: ['command-fix'], 70 | }, 71 | ) 72 | -------------------------------------------------------------------------------- /src/commands/regex101.md: -------------------------------------------------------------------------------- 1 | # `regex101` 2 | 3 | Generate up-to-date [regex101](https://regex101.com/) links for your RegExp patterns in jsdoc comments. Helps you test and inspect the RegExp easily. 4 | 5 | ## Triggers 6 | 7 | - `// @regex101` 8 | - `/* @regex101 */` 9 | 10 | ## Examples 11 | 12 | ```js 13 | /** 14 | * RegExp to match foo or bar, optionally wrapped in quotes. 15 | * 16 | * @regex101 17 | */ 18 | const foo = /(['"])?(foo|bar)\\1?/gi 19 | ``` 20 | 21 | Will be updated to: 22 | 23 | ```js 24 | /** 25 | * RegExp to match foo or bar, optionally wrapped in quotes. 26 | * 27 | * @regex101 https://regex101.com/?regex=%28%5B%27%22%5D%29%3F%28foo%7Cbar%29%5C1%3F&flags=gi&flavor=javascript 28 | */ 29 | const foo = /(['"])?(foo|bar)\\1?/gi 30 | ``` 31 | 32 | An whenever you update the RegExp pattern, the link will be updated as well. 33 | 34 | ### Optional Test Strings 35 | 36 | Test string can also be provided via an optional `@example` tag: 37 | 38 | ```js 39 | /** 40 | * Some jsdoc 41 | * 42 | * @example str 43 | * \`\`\`js 44 | * if ('foo'.match(foo)) { 45 | * const foo = bar 46 | * } 47 | * \`\`\` 48 | * 49 | * @regex101 50 | */ 51 | const foo = /(['"])?(foo|bar)\\1?/gi 52 | ``` 53 | 54 | Will be updated to: 55 | 56 | ```js 57 | /** 58 | * Some jsdoc 59 | * 60 | * @example str 61 | * \`\`\`js 62 | * if ('foo'.match(foo)) { 63 | * const foo = bar 64 | * } 65 | * \`\`\` 66 | * 67 | * @regex101 https://regex101.com/?regex=%28%5B%27%22%5D%29%3F%28foo%7Cbar%29%5C1%3F&flags=gi&flavor=javascript&testString=if+%28%27foo%27.match%28foo%29%29+%7B%0A++const+foo+%3D+bar%0A%7D 68 | */ 69 | const foo = /(['"])?(foo|bar)\\1?/gi 70 | ``` 71 | -------------------------------------------------------------------------------- /src/commands/to-dynamic-import.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toDynamicImport as command } from './to-dynamic-import' 3 | 4 | run( 5 | command, 6 | // Named import 7 | { 8 | code: $` 9 | /// to-dynamic-import 10 | import { foo } from 'bar' 11 | `, 12 | output: $` 13 | const { foo } = await import('bar') 14 | `, 15 | errors: ['command-fix'], 16 | }, 17 | // Default import 18 | { 19 | code: $` 20 | /// to-dynamic-import 21 | import foo from 'bar' 22 | `, 23 | output: $` 24 | const { default: foo } = await import('bar') 25 | `, 26 | errors: ['command-fix'], 27 | }, 28 | // Namespace 29 | { 30 | code: $` 31 | /// to-dynamic-import 32 | import * as foo from 'bar' 33 | `, 34 | output: $` 35 | const foo = await import('bar') 36 | `, 37 | errors: ['command-fix'], 38 | }, 39 | // Mixed 40 | { 41 | code: $` 42 | /// to-dynamic-import 43 | import foo, { bar, baz as tex } from 'bar' 44 | `, 45 | output: $` 46 | const { default: foo, bar, baz: tex } = await import('bar') 47 | `, 48 | errors: ['command-fix'], 49 | }, 50 | // Type import (error) 51 | { 52 | code: $` 53 | /// to-dynamic-import 54 | import type { Type } from 'baz' 55 | `, 56 | errors: ['command-error'], 57 | }, 58 | // Mixed with type import 59 | { 60 | code: $` 61 | /// to-dynamic-import 62 | import foo, { bar, type Type } from 'bar' 63 | `, 64 | output: $` 65 | import { type Type } from 'bar' 66 | const { default: foo, bar } = await import('bar') 67 | `, 68 | errors: ['command-fix'], 69 | }, 70 | ) 71 | -------------------------------------------------------------------------------- /src/commands/to-destructuring.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../types' 2 | 3 | export const toDestructuring: Command = { 4 | name: 'to-destructuring', 5 | match: /^\s*[/:@]\s*(?:to-|2)(?:destructuring|dest)$/i, 6 | action(ctx) { 7 | const node = ctx.findNodeBelow( 8 | 'VariableDeclaration', 9 | 'AssignmentExpression', 10 | ) 11 | if (!node) 12 | return ctx.reportError('Unable to find object/array to convert') 13 | 14 | const isDeclaration = node.type === 'VariableDeclaration' 15 | 16 | const rightExpression = isDeclaration ? node.declarations[0].init : node.right 17 | const member = rightExpression?.type === 'ChainExpression' ? rightExpression.expression : rightExpression 18 | 19 | if (member?.type !== 'MemberExpression') 20 | return ctx.reportError('Unable to convert to destructuring') 21 | 22 | const id = isDeclaration ? ctx.getTextOf(node.declarations[0].id) : ctx.getTextOf(node.left) 23 | const property = ctx.getTextOf(member.property) 24 | 25 | // TODO maybe there is no good way to know if this is an array or object 26 | const isArray = !Number.isNaN(Number(property)) 27 | 28 | const left = isArray ? `${','.repeat(Number(property))}${id}` : `${id === property ? id : `${property}: ${id}`}` 29 | 30 | let right = `${ctx.getTextOf(member.object)}` 31 | if (member.optional) 32 | right += ` ?? ${isArray ? '[]' : '{}'}` 33 | 34 | let str = isArray ? `[${left}] = ${right}` : `{ ${left} } = ${right}` 35 | str = isDeclaration ? `${node.kind} ${str}` : `;(${str})` 36 | 37 | ctx.report({ 38 | node, 39 | message: 'Convert to destructuring', 40 | fix: fixer => fixer.replaceTextRange(node.range, str), 41 | }) 42 | }, 43 | } 44 | -------------------------------------------------------------------------------- /src/commands/keep-aligned.md: -------------------------------------------------------------------------------- 1 | # `keep-aligned` 2 | 3 | Keep specific symbols within a block of code are aligned vertically. 4 | 5 | ## Triggers 6 | 7 | - `/// keep-aligned ` 8 | - `// @keep-aligned ` 9 | 10 | ## Examples 11 | 12 | 13 | 14 | ```typescript 15 | // @keep-aligned , , , 16 | export const matrix = [ 17 | 1, 0, 0, 18 | 0.866, -0.5, 0, 19 | 0.5, 0.866, 42, 20 | ] 21 | ``` 22 | 23 | Will be converted to: 24 | 25 | 26 | 27 | ```typescript 28 | // @keep-aligned , , , 29 | export const matrix = [ 30 | 1 , 0 , 0 , 31 | 0.866, -0.5 , 0 , 32 | 0.5 , 0.866, 42, 33 | ] 34 | ``` 35 | 36 | ### Repeat Mode 37 | 38 | For the example above where `,` is the only repeating symbol for alignment, `keep-aligned*` could be used instead to indicate a repeating pattern: 39 | 40 | 41 | 42 | ```typescript 43 | // @keep-aligned* , 44 | export const matrix = [ 45 | 1, 0, 0, 46 | 0.866, -0.5, 0, 47 | 0.5, 0.866, 42, 48 | ] 49 | ``` 50 | 51 | Will produce the same result. 52 | 53 | > [!TIP] 54 | > This rule does not work well with other spacing rules, namely `style/no-multi-spaces, style/comma-spacing, antfu/consistent-list-newline` were disabled for the example above to work. Consider adding `/* eslint-disable */` to specific ESLint rules for lines affected by this command. 55 | > 56 | > ```typescript 57 | > /* eslint-disable style/no-multi-spaces, style/comma-spacing, antfu/consistent-list-newline */ 58 | > // @keep-aligned , , , 59 | > export const matrix = [ 60 | > 1 , 0 , 0 , 61 | > 0.866, -0.5 , 0 , 62 | > 0.5 , 0.866, 42, 63 | > ] 64 | > /* eslint-enable style/no-multi-spaces, style/comma-spacing, antfu/consistent-list-newline */ 65 | > ``` 66 | -------------------------------------------------------------------------------- /src/commands/hoist-regexp.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | 3 | export const hoistRegExp: Command = { 4 | name: 'hoist-regexp', 5 | match: /^\s*[/:@]\s*(?:hoist-|h)reg(?:exp?)?(?:\s+(\S+)\s*)?$/, 6 | action(ctx) { 7 | const regexNode = ctx.findNodeBelow((node): node is Tree.RegExpLiteral => node.type === 'Literal' && 'regex' in node) as Tree.RegExpLiteral 8 | if (!regexNode) 9 | return ctx.reportError('No regular expression literal found') 10 | 11 | const topNodes = ctx.source.ast.body as Tree.Node[] 12 | const scope = ctx.source.getScope(regexNode) 13 | 14 | let parent = regexNode.parent as Tree.Node | undefined 15 | while (parent && !topNodes.includes(parent)) 16 | parent = parent.parent 17 | if (!parent) 18 | return ctx.reportError('Failed to find top-level node') 19 | 20 | let name = ctx.matches[1] 21 | if (name) { 22 | if (scope.references.find(ref => ref.identifier.name === name)) 23 | return ctx.reportError(`Variable '${name}' is already in scope`) 24 | } 25 | else { 26 | let baseName = regexNode.regex.pattern.replace(/\W/g, '_').replace(/_{2,}/g, '_').replace(/^_+|_+$/, '').toLowerCase() 27 | 28 | if (baseName.length > 0) 29 | baseName = baseName[0].toUpperCase() + baseName.slice(1) 30 | 31 | let i = 0 32 | name = `re${baseName}` 33 | while (scope.references.find(ref => ref.identifier.name === name)) { 34 | i++ 35 | name = `${baseName}${i}` 36 | } 37 | } 38 | 39 | ctx.report({ 40 | node: regexNode, 41 | message: `Hoist regular expression to ${name}`, 42 | * fix(fixer) { 43 | yield fixer.insertTextBefore(parent, `const ${name} = ${ctx.source.getText(regexNode)}\n`) 44 | yield fixer.replaceText(regexNode, name) 45 | }, 46 | }) 47 | }, 48 | } 49 | -------------------------------------------------------------------------------- /.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/commands/no-x-above.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../types' 2 | 3 | const types = [ 4 | 'await', 5 | 6 | // TODO: implement 7 | // 'statements', 8 | // 'functions', 9 | ] as const 10 | 11 | export const noXAbove: Command = { 12 | name: 'no-x-above', 13 | match: new RegExp(`^\\s*[/:@]\\s*no-(${types.join('|')})-(above|below)$`), 14 | action(ctx) { 15 | const type = ctx.matches[1] as (typeof types)[number] 16 | const direction = ctx.matches[2] as 'above' | 'below' 17 | 18 | const node = ctx.findNodeBelow(() => true) 19 | const parent = node?.parent 20 | if (!parent) 21 | return ctx.reportError('No parent node found') 22 | 23 | if (parent.type !== 'Program' && parent.type !== 'BlockStatement') 24 | return ctx.reportError('Parent node is not a block') 25 | 26 | const children = parent.body 27 | 28 | const targetNodes = direction === 'above' 29 | ? children.filter(c => c.range[1] <= ctx.comment.range[0]) 30 | : children.filter(c => c.range[0] >= ctx.comment.range[1]) 31 | 32 | if (!targetNodes.length) 33 | return 34 | 35 | switch (type) { 36 | case 'await': 37 | for (const target of targetNodes) { 38 | ctx.traverse(target, (path, { SKIP }) => { 39 | if (path.node.type === 'FunctionDeclaration' || path.node.type === 'FunctionExpression' || path.node.type === 'ArrowFunctionExpression') { 40 | return SKIP 41 | } 42 | if (path.node.type === 'AwaitExpression') { 43 | ctx.report({ 44 | node: path.node, 45 | message: 'Disallowed await expression', 46 | }) 47 | } 48 | }) 49 | } 50 | return 51 | default: 52 | return ctx.reportError(`Unknown type: ${type}`) 53 | } 54 | // console.log({ targetRange: targetNodes }) 55 | }, 56 | } 57 | -------------------------------------------------------------------------------- /src/commands/keep-sorted.md: -------------------------------------------------------------------------------- 1 | # `keep-sorted` 2 | 3 | Keep the object keys or array items sorted. 4 | 5 | ## Triggers 6 | 7 | - `/// keep-sorted` 8 | - `// @keep-sorted` 9 | 10 | ## Examples 11 | 12 | ```js 13 | /// keep-sorted 14 | const obj = { 15 | b: 2, 16 | a: 1, 17 | c: 3, 18 | } 19 | ``` 20 | 21 | Will be converted to: 22 | 23 | ```js 24 | /// keep-sorted 25 | const obj = { 26 | a: 1, 27 | b: 2, 28 | c: 3, 29 | } 30 | ``` 31 | 32 | Different from the other commands, the comment will not be removed after transformation to keep the sorting. 33 | 34 | #### Sort Array of Objects 35 | 36 | This command takes an optional inline JSON configuration to specify the keys to sort. 37 | 38 | ```js 39 | /// keep-sorted { "keys": ["index", "name"] } 40 | const arr = [ 41 | { index: 4, name: 'foo' }, 42 | { index: 2, name: 'bar' }, 43 | { index: 2, name: 'apple' }, 44 | { index: 0, name: 'zip' }, 45 | ] 46 | ``` 47 | 48 | Will be converted to: 49 | 50 | ```js 51 | /// keep-sorted { "keys": ["index", "name"] } 52 | const arr = [ 53 | { index: 0, name: 'zip' }, 54 | { index: 2, name: 'apple' }, 55 | { index: 2, name: 'bar' }, 56 | { index: 4, name: 'foo' }, 57 | ] 58 | ``` 59 | 60 | #### Sort Object of Objects 61 | 62 | Uses optional inline JSON configuration (like [Sort Array of Objects](#sort-array-of-objects)) to define sort keys. 63 | 64 | ```js 65 | /// keep-sorted { "keys": ["index","label"] } 66 | const obj = { 67 | a: { index: 3, label: 'banana' }, 68 | b: { index: 2, label: 'cherry' }, 69 | c: { index: 2, label: 'apple' }, 70 | d: { index: 1, label: 'berry' } 71 | } 72 | ``` 73 | 74 | Will be converted to: 75 | 76 | ```js 77 | /// keep-sorted { "keys": ["index","label"] } 78 | const obj = { 79 | d: { index: 1, label: 'berry' }, 80 | c: { index: 2, label: 'apple' }, 81 | b: { index: 2, label: 'cherry' }, 82 | a: { index: 3, label: 'banana' }, 83 | } 84 | ``` 85 | -------------------------------------------------------------------------------- /src/commands/to-ternary.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toTernary as command } from './to-ternary' 3 | 4 | run( 5 | command, 6 | // no `else` 7 | { 8 | code: $` 9 | /// to-ternary 10 | if (c1) 11 | foo() 12 | else if (c2) 13 | bar = 1 14 | `, 15 | errors: ['command-error'], 16 | }, 17 | // too many lines in a `if` 18 | { 19 | code: $` 20 | /// 2ternary 21 | if (c1) { 22 | foo() 23 | bar = 1 24 | } 25 | else { 26 | bar = 2 27 | } 28 | `, 29 | errors: ['command-error'], 30 | }, 31 | // normal 32 | { 33 | code: $` 34 | /// to-3 35 | if (c1) 36 | foo() 37 | else 38 | bar = 1 39 | `, 40 | output: $` 41 | c1 ? foo() : bar = 1 42 | `, 43 | errors: ['command-fix'], 44 | }, 45 | // more `else-if` and block 46 | { 47 | code: $` 48 | /// 23 49 | if (a > b) { 50 | foo() 51 | } 52 | else if (c2) { 53 | bar = 1 54 | } 55 | else { 56 | baz() 57 | } 58 | `, 59 | output: $` 60 | a > b ? foo() : c2 ? bar = 1 : baz() 61 | `, 62 | errors: ['command-fix'], 63 | }, 64 | // same name assignment 65 | { 66 | code: $` 67 | /// to-ternary 68 | if (c1) 69 | foo = 1 70 | else if (c2) 71 | foo = bar 72 | else 73 | foo = baz() 74 | `, 75 | output: $` 76 | foo = c1 ? 1 : c2 ? bar : baz() 77 | `, 78 | errors: ['command-fix'], 79 | }, 80 | // different names assignment 81 | { 82 | code: $` 83 | /// to-ternary 84 | if (c1) 85 | foo = 1 86 | else if (c2) 87 | bar = 2 88 | else 89 | baz() 90 | `, 91 | output: $` 92 | c1 ? foo = 1 : c2 ? bar = 2 : baz() 93 | `, 94 | errors: ['command-fix'], 95 | }, 96 | ) 97 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { hoistRegExp } from './hoist-regexp' 2 | import { inlineArrow } from './inline-arrow' 3 | import { keepAligned } from './keep-aligned' 4 | import { keepSorted } from './keep-sorted' 5 | import { keepUnique } from './keep-unique' 6 | import { noShorthand } from './no-shorthand' 7 | import { noType } from './no-type' 8 | import { noXAbove } from './no-x-above' 9 | import { regex101 } from './regex101' 10 | import { reverseIfElse } from './reverse-if-else' 11 | import { toArrow } from './to-arrow' 12 | import { toDestructuring } from './to-destructuring' 13 | import { toDynamicImport } from './to-dynamic-import' 14 | import { toForEach } from './to-for-each' 15 | import { toForOf } from './to-for-of' 16 | import { toFunction } from './to-function' 17 | import { toOneLine } from './to-one-line' 18 | import { toPromiseAll } from './to-promise-all' 19 | import { toStringLiteral } from './to-string-literal' 20 | import { toTemplateLiteral } from './to-template-literal' 21 | import { toTernary } from './to-ternary' 22 | 23 | // @keep-sorted 24 | export { 25 | hoistRegExp, 26 | inlineArrow, 27 | keepAligned, 28 | keepSorted, 29 | keepUnique, 30 | noShorthand, 31 | noType, 32 | noXAbove, 33 | regex101, 34 | reverseIfElse, 35 | toArrow, 36 | toDestructuring, 37 | toDynamicImport, 38 | toForEach, 39 | toForOf, 40 | toFunction, 41 | toOneLine, 42 | toPromiseAll, 43 | toStringLiteral, 44 | toTemplateLiteral, 45 | toTernary, 46 | } 47 | 48 | // @keep-sorted 49 | export const builtinCommands = [ 50 | hoistRegExp, 51 | inlineArrow, 52 | keepAligned, 53 | keepSorted, 54 | keepUnique, 55 | noShorthand, 56 | noType, 57 | noXAbove, 58 | regex101, 59 | reverseIfElse, 60 | toArrow, 61 | toDestructuring, 62 | toDynamicImport, 63 | toForEach, 64 | toForOf, 65 | toFunction, 66 | toOneLine, 67 | toPromiseAll, 68 | toStringLiteral, 69 | toTemplateLiteral, 70 | toTernary, 71 | ] 72 | -------------------------------------------------------------------------------- /src/commands/no-type.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { noType as command } from './no-type' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | /// no-type 9 | let a: string 10 | `, 11 | output: $` 12 | let a 13 | `, 14 | errors: ['command-fix'], 15 | }, 16 | { 17 | code: $` 18 | /// no-type 19 | function a(arg: A): R {} 20 | `, 21 | output: $` 22 | function a(arg) {} 23 | `, 24 | errors: ['command-fix'], 25 | }, 26 | { 27 | code: $` 28 | /// no-type 29 | declare const a: string 30 | `, 31 | output: $` 32 | declare const a 33 | `, 34 | errors: ['command-fix'], 35 | }, 36 | { 37 | code: $` 38 | /// no-type 39 | fn(arg as any) 40 | `, 41 | output: $` 42 | fn(arg) 43 | `, 44 | errors: ['command-fix'], 45 | }, 46 | { 47 | code: $` 48 | /// no-type 49 | fn(arg satisfies any) 50 | `, 51 | output: $` 52 | fn(arg) 53 | `, 54 | errors: ['command-fix'], 55 | }, 56 | { 57 | code: $` 58 | /// no-type 59 | fn(arg!) 60 | `, 61 | output: $` 62 | fn(arg) 63 | `, 64 | errors: ['command-fix'], 65 | }, 66 | { 67 | code: $` 68 | /// no-type 69 | fn(arg) 70 | `, 71 | output: $` 72 | fn(arg) 73 | `, 74 | errors: ['command-fix'], 75 | }, 76 | { 77 | code: $` 78 | /// no-type 79 | const fn = foo 80 | `, 81 | output: $` 82 | const fn = foo 83 | `, 84 | errors: ['command-fix'], 85 | }, 86 | { 87 | code: $` 88 | /// no-type 89 | type A = string 90 | `, 91 | output: '', 92 | errors: ['command-fix'], 93 | }, 94 | { 95 | code: $` 96 | /// nt 97 | const a = 1 98 | `, 99 | errors: ['command-error'], 100 | }, 101 | ) 102 | -------------------------------------------------------------------------------- /src/rule.ts: -------------------------------------------------------------------------------- 1 | import type { Command, MessageIds, RuleOptions } from './types' 2 | import { builtinCommands } from './commands' 3 | import { CommandContext } from './context' 4 | import { createEslintRule } from './utils' 5 | 6 | export function createRuleWithCommands(commands: Command[]) { 7 | return createEslintRule({ 8 | name: 'command', 9 | meta: { 10 | type: 'problem', 11 | docs: { 12 | description: 'Comment-as-command for one-off codemod with ESLint', 13 | }, 14 | fixable: 'code', 15 | schema: [], 16 | messages: { 17 | 'command-error': '[{{command}}] error: {{message}}', 18 | 'command-error-cause': '[{{command}}] error cause: {{message}}', 19 | 'command-fix': '[{{command}}] fix: {{message}}', 20 | }, 21 | }, 22 | defaultOptions: [], 23 | create: (context) => { 24 | const sc = context.sourceCode 25 | const comments = sc.getAllComments() 26 | 27 | for (const comment of comments) { 28 | const commandRaw = comment.value 29 | for (const command of commands) { 30 | const type = command.commentType ?? 'line' 31 | if (type === 'line' && comment.type !== 'Line') 32 | continue 33 | if (type === 'block' && comment.type !== 'Block') 34 | continue 35 | 36 | let matches = typeof command.match === 'function' 37 | ? command.match(comment) 38 | : commandRaw.match(command.match) 39 | 40 | if (!matches) 41 | continue 42 | 43 | // create a dummy match for user provided function that returns `true` 44 | if (matches === true) 45 | matches = '__dummy__'.match('__dummy__')! 46 | 47 | const result = command.action(new CommandContext(context, comment, command, matches)) 48 | if (result !== false) 49 | break 50 | } 51 | } 52 | return {} 53 | }, 54 | }) 55 | } 56 | 57 | export default /* @__PURE__ */ createRuleWithCommands(builtinCommands) 58 | -------------------------------------------------------------------------------- /src/commands/keep-unique.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../types' 2 | 3 | export interface KeepSortedInlineOptions { 4 | key?: string 5 | keys?: string[] 6 | } 7 | 8 | const reLine = /^[/@:]\s*(?:keep-)?uni(?:que)?$/ 9 | const reBlock = /(?:\b|\s)@keep-uni(?:que)?(?:\b|\s|$)/ 10 | 11 | export const keepUnique: Command = { 12 | name: 'keep-unique', 13 | commentType: 'both', 14 | match: comment => comment.value.trim().match(comment.type === 'Line' ? reLine : reBlock), 15 | action(ctx) { 16 | const node = ctx.findNodeBelow('ArrayExpression') 17 | if (!node) 18 | return ctx.reportError('Unable to find array to keep unique') 19 | 20 | const set = new Set() 21 | const removalIndex = new Set() 22 | 23 | node.elements.forEach((item, idx) => { 24 | if (!item) 25 | return 26 | if (item.type !== 'Literal') 27 | return 28 | if (set.has(String(item.raw))) 29 | removalIndex.add(idx) 30 | else 31 | set.add(String(item.raw)) 32 | }) 33 | 34 | if (removalIndex.size === 0) 35 | return false 36 | 37 | ctx.report({ 38 | node, 39 | message: 'Keep unique', 40 | removeComment: false, 41 | fix(fixer) { 42 | const removalRanges = Array.from(removalIndex) 43 | .map((idx) => { 44 | const item = node.elements[idx]! 45 | const nextItem = node.elements[idx + 1] 46 | if (nextItem) 47 | return [item.range[0], nextItem.range[0]] 48 | const nextToken = ctx.source.getTokenAfter(item) 49 | if (nextToken && nextToken.value === ',') 50 | return [item.range[0], nextToken.range[1]] 51 | return item.range 52 | }) 53 | .sort((a, b) => b[0] - a[0]) 54 | let text = ctx.getTextOf(node) 55 | for (const [start, end] of removalRanges) 56 | text = text.slice(0, start - node.range[0]) + text.slice(end - node.range[0]) 57 | return fixer.replaceText(node, text) 58 | }, 59 | }) 60 | }, 61 | } 62 | -------------------------------------------------------------------------------- /src/traverse.ts: -------------------------------------------------------------------------------- 1 | // Vendored from https://github.com/discord/eslint-traverse 2 | 3 | import type { RuleContext } from '@typescript-eslint/utils/ts-eslint' 4 | import type { Tree } from './types' 5 | 6 | // @keep-sorted 7 | export interface TraversePath { 8 | node: Tree.Node 9 | parent: Tree.Node | null 10 | parentKey: string | null 11 | parentPath: TraversePath | null 12 | } 13 | 14 | export const SKIP = Symbol('skip') 15 | export const STOP = Symbol('stop') 16 | 17 | export type TraverseVisitor = ( 18 | path: TraversePath, 19 | symbols: { SKIP: symbol, STOP: symbol }, 20 | ) => symbol | void 21 | 22 | export function traverse( 23 | context: RuleContext, 24 | node: Tree.Node, 25 | visitor: TraverseVisitor, 26 | ): boolean { 27 | const allVisitorKeys = context.sourceCode.visitorKeys 28 | const queue: TraversePath[] = [] 29 | 30 | // @keep-sorted 31 | queue.push({ 32 | node, 33 | parent: null, 34 | parentKey: null, 35 | parentPath: null, 36 | }) 37 | 38 | while (queue.length) { 39 | const currentPath = queue.shift()! 40 | 41 | const result = visitor(currentPath, { SKIP, STOP }) 42 | if (result === STOP) 43 | return false 44 | if (result === SKIP) 45 | continue 46 | 47 | const visitorKeys = allVisitorKeys[currentPath.node.type] 48 | if (!visitorKeys) 49 | continue 50 | 51 | for (const visitorKey of visitorKeys) { 52 | const child = (currentPath.node as any)[visitorKey] 53 | 54 | if (!child) { 55 | continue 56 | } 57 | else if (Array.isArray(child)) { 58 | for (const item of child) { 59 | queue.push({ 60 | node: item, 61 | parent: currentPath.node, 62 | parentKey: visitorKey, 63 | parentPath: currentPath, 64 | }) 65 | } 66 | } 67 | else { 68 | queue.push({ 69 | node: child, 70 | parent: currentPath.node, 71 | parentKey: visitorKey, 72 | parentPath: currentPath, 73 | }) 74 | } 75 | } 76 | } 77 | 78 | return true 79 | } 80 | -------------------------------------------------------------------------------- /src/commands/to-dynamic-import.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | 3 | export const toDynamicImport: Command = { 4 | name: 'to-dynamic-import', 5 | match: /^\s*[/:@]\s*(?:to-|2)?(?:dynamic|d)(?:-?import)?$/i, 6 | action(ctx) { 7 | const node = ctx.findNodeBelow('ImportDeclaration') 8 | if (!node) 9 | return ctx.reportError('Unable to find import statement to convert') 10 | 11 | let namespace: string | undefined 12 | 13 | if (node.importKind === 'type') 14 | return ctx.reportError('Unable to convert type import to dynamic import') 15 | 16 | const typeSpecifiers: Tree.ImportSpecifier[] = [] 17 | 18 | const destructure = node.specifiers 19 | .map((specifier) => { 20 | if (specifier.type === 'ImportSpecifier') { 21 | if (specifier.importKind === 'type') { 22 | typeSpecifiers.push(specifier) 23 | return null 24 | } 25 | if (specifier.imported.type === 'Identifier' && specifier.local.name === specifier.imported.name) 26 | return ctx.getTextOf(specifier.imported) 27 | else 28 | return `${ctx.getTextOf(specifier.imported)}: ${ctx.getTextOf(specifier.local)}` 29 | } 30 | else if (specifier.type === 'ImportDefaultSpecifier') { 31 | return `default: ${ctx.getTextOf(specifier.local)}` 32 | } 33 | else if (specifier.type === 'ImportNamespaceSpecifier') { 34 | namespace = ctx.getTextOf(specifier.local) 35 | return null 36 | } 37 | return null 38 | }) 39 | .filter(Boolean) 40 | .join(', ') 41 | 42 | let str = namespace 43 | ? `const ${namespace} = await import(${ctx.getTextOf(node.source)})` 44 | : `const { ${destructure} } = await import(${ctx.getTextOf(node.source)})` 45 | 46 | if (typeSpecifiers.length) 47 | str = `import { ${typeSpecifiers.map(s => ctx.getTextOf(s)).join(', ')} } from ${ctx.getTextOf(node.source)}\n${str}` 48 | 49 | ctx.report({ 50 | node, 51 | message: 'Convert to dynamic import', 52 | fix: fixer => fixer.replaceText(node, str), 53 | }) 54 | }, 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/regex101.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { regex101 as command } from './regex101' 3 | 4 | run( 5 | command, 6 | // basic 7 | { 8 | code: $` 9 | // @regex101 10 | const foo = /(?:\\b|\\s)@regex101(\\s[^\\s]+)?(?:\\s|\\b|$)/g 11 | `, 12 | output: output => expect(output).toMatchInlineSnapshot(` 13 | "// @regex101 https://regex101.com/?regex=%28%3F%3A%5Cb%7C%5Cs%29%40regex101%28%5Cs%5B%5E%5Cs%5D%2B%29%3F%28%3F%3A%5Cs%7C%5Cb%7C%24%29&flags=g&flavor=javascript 14 | const foo = /(?:\\b|\\s)@regex101(\\s[^\\s]+)?(?:\\s|\\b|$)/g" 15 | `), 16 | errors: ['command-fix'], 17 | }, 18 | // block comment 19 | { 20 | code: $` 21 | /** 22 | * Some jsdoc 23 | * 24 | * @regex101 25 | * @deprecated 26 | */ 27 | const foo = /(['"])?(foo|bar)\\1?/gi 28 | `, 29 | output: output => expect(output).toMatchInlineSnapshot(` 30 | "/** 31 | * Some jsdoc 32 | * 33 | * @regex101 https://regex101.com/?regex=%28%5B%27%22%5D%29%3F%28foo%7Cbar%29%5C1%3F&flags=gi&flavor=javascript 34 | * @deprecated 35 | */ 36 | const foo = /(['"])?(foo|bar)\\1?/gi" 37 | `), 38 | errors: ['command-fix'], 39 | }, 40 | // example block 41 | { 42 | code: $` 43 | /** 44 | * Some jsdoc 45 | * 46 | * @example str 47 | * \`\`\`js 48 | * if ('foo'.match(foo)) { 49 | * const foo = bar 50 | * } 51 | * \`\`\` 52 | * 53 | * @regex101 54 | */ 55 | const foo = /(['"])?(foo|bar)\\1?/gi 56 | `, 57 | output: output => expect(output).toMatchInlineSnapshot(` 58 | "/** 59 | * Some jsdoc 60 | * 61 | * @example str 62 | * \`\`\`js 63 | * if ('foo'.match(foo)) { 64 | * const foo = bar 65 | * } 66 | * \`\`\` 67 | * 68 | * @regex101 https://regex101.com/?regex=%28%5B%27%22%5D%29%3F%28foo%7Cbar%29%5C1%3F&flags=gi&flavor=javascript&testString=if+%28%27foo%27.match%28foo%29%29+%7B%0A++const+foo+%3D+bar%0A%7D 69 | */ 70 | const foo = /(['"])?(foo|bar)\\1?/gi" 71 | `), 72 | errors: ['command-fix'], 73 | }, 74 | ) 75 | -------------------------------------------------------------------------------- /docs/guide/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | outline: deep 3 | --- 4 | 5 | # Introduction 6 | 7 | **ESLint Plugin Command** is a special kind of ESLint plugin, that by default, **does nothing**. Instead of checking for code quality, it serves as a micro-codemod tool triggers by special comments on-demand, resuse the infrastructure of ESLint. 8 | 9 | For example, one of the built-in commands, `/// to-function` allows you to convert a single arrow function expression to a function declaration. 10 | 11 | 12 | 13 | ```ts 14 | /// to-function 15 | const foo = async (msg: T): void => { 16 | console.log(msg) 17 | } 18 | ``` 19 | 20 | Will be transformed to this when you hit save with your editor or run `eslint . --fix`. After executing the command, the comment will also be removed along with the transformation: 21 | 22 | ```ts 23 | async function foo(msg: T): void { 24 | console.log(msg) 25 | } 26 | ``` 27 | 28 | One more example that `/// to-promise-all` converts a sequence of `await` expressions to `await Promise.all()`: 29 | 30 | 31 | 32 | ```ts 33 | /// to-promise-all 34 | const foo = await bar().then 35 | const { get } = await import('lodash-es') 36 | ``` 37 | 38 | Will be transformed to: 39 | 40 | ```ts 41 | const [ 42 | foo, 43 | { get }, 44 | ] = await Promise.all([ 45 | bar(), 46 | import('lodash-es'), 47 | ] as const) 48 | ``` 49 | 50 | ## Built-in Commands 51 | 52 | There is a few list of built-in commands for quick references 53 | 54 | - [`/// keep-sorted`](/commands/keep-sorted) - keep an object/array/interface always sorted 55 | - [`/// to-function`](/commands/to-function) - converts an arrow function to a normal function 56 | - [`/// to-arrow`](/commands/to-arrow) - converts a normal function to an arrow function 57 | - [`/// to-for-each`](/commands/to-for-each) - converts a for-in/for-of loop to `.forEach()` 58 | - [`/// to-for-of`](/commands/to-for-of) - converts a `.forEach()` to a for-of loop 59 | - [`/// to-promise-all`](/commands/to-promise-all) - converts a sequence of `await` exps to `await Promise.all()` 60 | - [`/// to-string-literal`](/commands/to-string-literal) - converts a template literal to a string concatenation 61 | - [`/// to-template-literal`](/commands/to-template-literal) - converts a string concatenation to a template literal 62 | - ... and more, check the sidebar for the full list 63 | -------------------------------------------------------------------------------- /src/commands/hoist-regexp.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { hoistRegExp as command } from './hoist-regexp' 3 | 4 | run( 5 | command, 6 | // basic 7 | { 8 | code: $` 9 | function foo(msg: string): void { 10 | /// hoist-regexp 11 | console.log(/foo/.test(msg)) 12 | } 13 | `, 14 | output: $` 15 | const reFoo = /foo/ 16 | function foo(msg: string): void { 17 | console.log(reFoo.test(msg)) 18 | } 19 | `, 20 | errors: ['command-fix'], 21 | }, 22 | // custom name 23 | { 24 | code: $` 25 | function foo(msg: string): void { 26 | /// hoist-regex customName 27 | console.log(/foo/.test(msg)) 28 | } 29 | `, 30 | output: $` 31 | const customName = /foo/ 32 | function foo(msg: string): void { 33 | console.log(customName.test(msg)) 34 | } 35 | `, 36 | errors: ['command-fix'], 37 | }, 38 | // nested functions 39 | { 40 | code: $` 41 | const bar = 1 42 | function bar(msg: string): void { 43 | } 44 | 45 | function foo(msg: string): void { 46 | const bar = () => { 47 | for (let i = 0; i < 10; i++) { 48 | /// hreg 49 | console.log(/foo|bar*([^a])/.test(msg)) 50 | } 51 | } 52 | } 53 | `, 54 | output: $` 55 | const bar = 1 56 | function bar(msg: string): void { 57 | } 58 | 59 | const reFoo_bar_a = /foo|bar*([^a])/ 60 | function foo(msg: string): void { 61 | const bar = () => { 62 | for (let i = 0; i < 10; i++) { 63 | console.log(reFoo_bar_a.test(msg)) 64 | } 65 | } 66 | } 67 | `, 68 | errors: ['command-fix'], 69 | }, 70 | // throw error if variable already exists 71 | { 72 | code: $` 73 | function foo(msg: string): void { 74 | const customName = 42 75 | /// hoist-regex customName 76 | console.log(/foo/.test(msg)) 77 | } 78 | `, 79 | errors: ['command-error'], 80 | }, 81 | // throw error if it's already top-level 82 | { 83 | code: $` 84 | /// hoist-regex 85 | const customName = /foo/ 86 | function foo(msg: string): void { 87 | console.log(/foo/.test(msg)) 88 | } 89 | `, 90 | errors: ['command-error'], 91 | }, 92 | ) 93 | -------------------------------------------------------------------------------- /src/commands/keep-aligned.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../types' 2 | 3 | const reLine = /^[/@:]\s*keep-aligned(?\*?)(?(\s+\S+)+)$/ 4 | 5 | export const keepAligned: Command = { 6 | name: 'keep-aligned', 7 | commentType: 'line', 8 | match: comment => comment.value.trim().match(reLine), 9 | action(ctx) { 10 | // this command applies to any node below 11 | const node = ctx.findNodeBelow(() => true) 12 | if (!node) 13 | return 14 | 15 | const alignmentSymbols = ctx.matches.groups?.symbols?.trim().split(/\s+/) 16 | if (!alignmentSymbols) 17 | return ctx.reportError('No alignment symbols provided') 18 | const repeat = ctx.matches.groups?.repeat 19 | 20 | const nLeadingSpaces = node.range[0] - ctx.comment.range[1] - 1 21 | const text = ctx.context.sourceCode.getText(node, nLeadingSpaces) 22 | const lines = text.split('\n') 23 | const symbolIndices: number[] = [] 24 | 25 | const nSymbols = alignmentSymbols.length 26 | if (nSymbols === 0) 27 | return ctx.reportError('No alignment symbols provided') 28 | 29 | const n = repeat ? Number.MAX_SAFE_INTEGER : nSymbols 30 | let lastPos = 0 31 | for (let i = 0; i < n && i < 20; i++) { 32 | const symbol = alignmentSymbols[i % nSymbols] 33 | const maxIndex = lines.reduce((maxIndex, line) => 34 | Math.max(line.indexOf(symbol, lastPos), maxIndex), -1) 35 | symbolIndices.push(maxIndex) 36 | 37 | if (maxIndex < 0) { 38 | if (!repeat) 39 | return ctx.reportError(`Alignment symbol "${symbol}" not found`) 40 | else 41 | break 42 | } 43 | 44 | for (let j = 0; j < lines.length; j++) { 45 | const line = lines[j] 46 | const index = line.indexOf(symbol, lastPos) 47 | if (index < 0) 48 | continue 49 | if (index !== maxIndex) { 50 | const padding = maxIndex - index 51 | lines[j] = line.slice(0, index) + ' '.repeat(padding) + line.slice(index) 52 | } 53 | } 54 | lastPos = maxIndex + symbol.length 55 | } 56 | 57 | const modifiedText = lines.join('\n') 58 | if (text === modifiedText) 59 | return 60 | 61 | ctx.report({ 62 | node, 63 | message: 'Keep aligned', 64 | removeComment: false, 65 | fix: fixer => fixer.replaceText(node, modifiedText.slice(nLeadingSpaces)), 66 | }) 67 | }, 68 | } 69 | -------------------------------------------------------------------------------- /src/commands/to-for-of.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | import { FOR_TRAVERSE_IGNORE } from './to-for-each' 3 | 4 | export const toForOf: Command = { 5 | name: 'to-for-of', 6 | match: /^\s*[/:@]\s*(?:to-|2)?for-?of$/i, 7 | action(ctx) { 8 | const target = ctx.findNodeBelow((node) => { 9 | if (node.type === 'CallExpression' && node.callee.type === 'MemberExpression' && node.callee.property.type === 'Identifier' && node.callee.property.name === 'forEach') 10 | return true 11 | return false 12 | }) as Tree.CallExpression | undefined 13 | 14 | if (!target) 15 | return ctx.reportError('Unable to find .forEach() to convert') 16 | 17 | const member = target.callee as Tree.MemberExpression 18 | const iterator = member.object 19 | const fn = target.arguments[0] 20 | if (fn.type !== 'ArrowFunctionExpression' && fn.type !== 'FunctionExpression') 21 | return ctx.reportError('Unable to find .forEach() to convert') 22 | 23 | // TODO: support this in some way 24 | if (fn.params.length !== 1) { 25 | return ctx.reportError( 26 | 'Unable to convert forEach', 27 | { 28 | node: fn.params[0], 29 | message: 'Index argument in forEach is not yet supported for conversion', 30 | }, 31 | ) 32 | } 33 | 34 | // Find all return statements 35 | const returnNodes: Tree.ReturnStatement[] = [] 36 | ctx.traverse(fn.body, (path, { SKIP }) => { 37 | if (FOR_TRAVERSE_IGNORE.includes(path.node.type)) 38 | return SKIP 39 | if (path.node.type === 'ReturnStatement') 40 | returnNodes.push(path.node) 41 | }) 42 | // Convert `continue` to `return` 43 | let textBody = ctx.getTextOf(fn.body) 44 | returnNodes 45 | .sort((a, b) => b.loc.start.line - a.loc.start.line) 46 | .forEach((c) => { 47 | textBody 48 | // eslint-disable-next-line prefer-template 49 | = textBody.slice(0, c.range[0] - fn.body.range[0]) 50 | + 'continue' 51 | + textBody.slice(c.range[1] - fn.body.range[0]) 52 | }) 53 | 54 | const local = fn.params[0] 55 | const str = `for (const ${ctx.getTextOf(local)} of ${ctx.getTextOf(iterator)}) ${textBody}` 56 | 57 | ctx.report({ 58 | node: target, 59 | message: 'Convert to for-of loop', 60 | fix(fixer) { 61 | return fixer.replaceText(target, str) 62 | }, 63 | }) 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /src/commands/to-string-literal.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | import { getNodesByIndexes, parseToNumberArray } from './_utils' 3 | 4 | export const toStringLiteral: Command = { 5 | name: 'to-string-literal', 6 | match: /^\s*[/:@]\s*(?:to-|2)?(?:string-literal|sl)\s*(\S.*)?$/, 7 | action(ctx) { 8 | const numbers = ctx.matches[1] 9 | // From integers 1-based to 0-based to match array indexes 10 | const indexes = parseToNumberArray(numbers, true).map(n => n - 1) 11 | const nodes = ctx.findNodeBelow({ 12 | types: ['TemplateLiteral'], 13 | shallow: true, 14 | findAll: true, 15 | }) 16 | if (!nodes?.length) 17 | return ctx.reportError('No template literals found') 18 | 19 | ctx.report({ 20 | nodes, 21 | message: 'Convert to string literal', 22 | * fix(fixer) { 23 | for (const node of getNodesByIndexes(nodes, indexes)) { 24 | const ids = extractIdentifiers(node) 25 | let raw = JSON.stringify(ctx.source.getText(node).slice(1, -1)).slice(1, -1) 26 | 27 | if (ids.length) 28 | raw = toStringWithIds(raw, node, ids) 29 | else 30 | raw = `"${raw}"` 31 | 32 | yield fixer.replaceTextRange(node.range, raw) 33 | } 34 | }, 35 | }) 36 | }, 37 | } 38 | 39 | interface Identifier { 40 | name: string 41 | range: [number, number] 42 | } 43 | 44 | function extractIdentifiers(node: Tree.TemplateLiteral) { 45 | const ids: Identifier[] = [] 46 | for (const child of node.expressions) { 47 | if (child.type === 'Identifier') 48 | ids.push({ name: child.name, range: child.range }) 49 | // TODO: sub expressions, e.g. `${a + b}` -> '' + a + b + '' 50 | } 51 | return ids 52 | } 53 | 54 | function toStringWithIds(raw: string, node: Tree.TemplateLiteral, ids: Identifier[]) { 55 | let hasStart = false 56 | let hasEnd = false 57 | ids.forEach(({ name, range }, index) => { 58 | let startStr = `" + ` 59 | let endStr = ` + "` 60 | 61 | if (index === 0) { 62 | hasStart = range[0] - /* `${ */3 === node.range[0] 63 | if (hasStart) 64 | startStr = '' 65 | } 66 | if (index === ids.length - 1) { 67 | hasEnd = range[1] + /* }` */2 === node.range[1] 68 | if (hasEnd) 69 | endStr = '' 70 | } 71 | 72 | raw = raw.replace(`\${${name}}`, `${startStr}${name}${endStr}`) 73 | }) 74 | return `${hasStart ? '' : `"`}${raw}${hasEnd ? '' : `"`}` 75 | } 76 | -------------------------------------------------------------------------------- /src/commands/to-function.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | 3 | export const toFunction: Command = { 4 | name: 'to-function', 5 | match: /^\s*[/:@]\s*(to-(?:fn|function)|2f|tf)$/, 6 | action(ctx) { 7 | const arrowFn = ctx.findNodeBelow('ArrowFunctionExpression') 8 | if (!arrowFn) 9 | return ctx.reportError('Unable to find arrow function to convert') 10 | 11 | let start: Tree.Node = arrowFn 12 | let id: Tree.Identifier | undefined 13 | const body = arrowFn.body 14 | 15 | if (arrowFn.parent.type === 'VariableDeclarator' && arrowFn.parent.id.type === 'Identifier') { 16 | id = arrowFn.parent.id 17 | if (arrowFn.parent.parent.type === 'VariableDeclaration' && arrowFn.parent.parent.kind === 'const' && arrowFn.parent.parent.declarations.length === 1) 18 | start = arrowFn.parent.parent 19 | } 20 | else if (arrowFn.parent.type === 'Property' && arrowFn.parent.key.type === 'Identifier') { 21 | id = arrowFn.parent.key 22 | start = arrowFn.parent.key 23 | } 24 | 25 | ctx.report({ 26 | node: arrowFn, 27 | loc: { 28 | start: start.loc.start, 29 | end: body.loc.start, 30 | }, 31 | message: 'Convert to function', 32 | fix(fixer) { 33 | const textName = ctx.getTextOf(id) 34 | const textArgs = arrowFn.params.length 35 | ? ctx.getTextOf([arrowFn.params[0].range[0], arrowFn.params[arrowFn.params.length - 1].range[1]]) 36 | : '' 37 | const textBody = body.type === 'BlockStatement' 38 | ? ctx.getTextOf(body) 39 | : `{\n return ${ctx.getTextOf(body)}\n}` 40 | const textGeneric = ctx.getTextOf(arrowFn.typeParameters) 41 | const textTypeReturn = ctx.getTextOf(arrowFn.returnType) 42 | const textAsync = arrowFn.async ? 'async' : '' 43 | 44 | const fnBody = [`${textGeneric}(${textArgs})${textTypeReturn}`, textBody].filter(Boolean).join(' ') 45 | 46 | let final = [textAsync, `function`, textName, fnBody].filter(Boolean).join(' ') 47 | 48 | if (arrowFn.parent.type === 'Property') 49 | final = [textAsync, textName, fnBody].filter(Boolean).join(' ') 50 | 51 | // console.log({ 52 | // final, 53 | // original: code.slice(start.range[0], arrowFn.range[1]), 54 | // p: arrowFn.parent.type, 55 | // }) 56 | return fixer.replaceTextRange([start.range[0], arrowFn.range[1]], final) 57 | }, 58 | }) 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /src/commands/to-ternary.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | 3 | export const toTernary: Command = { 4 | name: 'to-ternary', 5 | match: /^\s*[/:@]\s*(?:to-|2)(?:ternary|3)$/, 6 | action(ctx) { 7 | const node = ctx.findNodeBelow('IfStatement') 8 | 9 | if (!node) 10 | return ctx.reportError('Unable to find an `if` statement to convert') 11 | 12 | let result = '' 13 | let isAssignment = true 14 | 15 | const normalizeStatement = (n: Tree.Statement | null) => { 16 | if (!n) 17 | return ctx.reportError('Unable to convert `if` statement without an `else` clause') 18 | if (n.type === 'BlockStatement') { 19 | if (n.body.length !== 1) 20 | return ctx.reportError('Unable to convert statement contains more than one expression') 21 | else return n.body[0] 22 | } 23 | else { 24 | return n 25 | } 26 | } 27 | 28 | const getAssignmentId = (n: Tree.Statement) => { 29 | if (n.type === 'IfStatement') 30 | n = n.consequent 31 | if (n.type !== 'ExpressionStatement' || n.expression.type !== 'AssignmentExpression' || n.expression.left.type !== 'Identifier') 32 | return 33 | return ctx.getTextOf(n.expression.left) 34 | } 35 | 36 | let ifNode: Tree.IfStatement = node 37 | while (ifNode) { 38 | const consequent = normalizeStatement(ifNode.consequent) 39 | const alternate = normalizeStatement(ifNode.alternate) 40 | 41 | if (!consequent || !alternate) 42 | return 43 | 44 | if (isAssignment) { 45 | const ifId = getAssignmentId(consequent) 46 | const elseId = getAssignmentId(alternate) 47 | 48 | if (!ifId || ifId !== elseId) 49 | isAssignment = false 50 | } 51 | 52 | result += `${ctx.getTextOf(ifNode.test)} ? ${ctx.getTextOf(consequent)} : ` 53 | 54 | if (alternate.type !== 'IfStatement') { 55 | result += ctx.getTextOf(alternate) 56 | break 57 | } 58 | else { 59 | ifNode = alternate 60 | } 61 | } 62 | 63 | if (isAssignment) { 64 | const id = getAssignmentId(normalizeStatement(node.consequent)!) 65 | result = `${id} = ${result.replaceAll(`${id} = `, '')}` 66 | } 67 | 68 | ctx.report({ 69 | node, 70 | message: 'Convert to ternary', 71 | fix: fix => fix.replaceTextRange(node.range, result), 72 | }) 73 | }, 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eslint-plugin-command 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | 6 | [**Documentations**](https://eslint-plugin-command.antfu.me/) 7 | 8 | Comment-as-command for one-off codemod with ESLint. 9 | 10 | https://github.com/antfu/eslint-plugin-command/assets/11247099/ec401a21-4081-42d0-8748-9d0376b7d501 11 | 12 | ## Introduction 13 | 14 | **ESLint Plugin Command** is a special kind of ESLint plugin, that by default, **does nothing**. Instead of checking for code quality, it serves as a micro-codemod tool triggers by special comments on-demand, resuse the infrastructure of ESLint. 15 | 16 | For example, one of the built-in commands, `/// to-function` allows you to convert a single arrow function expression to a function declaration. 17 | 18 | 19 | 20 | ```ts 21 | /// to-function 22 | const foo = async (msg: T): void => { 23 | console.log(msg) 24 | } 25 | ``` 26 | 27 | Will be transformed to this when you hit save with your editor or run `eslint . --fix`. After executing the command, the comment will also be removed along with the transformation: 28 | 29 | ```ts 30 | async function foo(msg: T): void { 31 | console.log(msg) 32 | } 33 | ``` 34 | 35 | One more example that `/// to-promise-all` converts a sequence of `await` expressions to `await Promise.all()`: 36 | 37 | 38 | 39 | ```ts 40 | /// to-promise-all 41 | const foo = await bar().then 42 | const { get } = await import('lodash-es') 43 | ``` 44 | 45 | Will be transformed to: 46 | 47 | ```ts 48 | const [ 49 | foo, 50 | { get }, 51 | ] = await Promise.all([ 52 | bar(), 53 | import('lodash-es'), 54 | ] as const) 55 | ``` 56 | 57 | Refer to the [documentation](https://eslint-plugin-command.antfu.me/) for more details. 58 | 59 | ## Sponsors 60 | 61 |

62 | 63 | 64 | 65 |

66 | 67 | ## License 68 | 69 | MIT License © 2024-PRESENT [Anthony Fu](https://github.com/antfu) 70 | 71 | 72 | 73 | [npm-version-src]: https://img.shields.io/npm/v/eslint-plugin-command?style=flat&colorA=080f12&colorB=1fa669 74 | [npm-version-href]: https://npmjs.com/package/eslint-plugin-command 75 | [npm-downloads-src]: https://img.shields.io/npm/dm/eslint-plugin-command?style=flat&colorA=080f12&colorB=1fa669 76 | [npm-downloads-href]: https://npmjs.com/package/eslint-plugin-command 77 | -------------------------------------------------------------------------------- /src/commands/to-arrow.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toArrow as command } from './to-arrow' 3 | 4 | run( 5 | command, 6 | 'const foo = function () {}', 7 | `export const foo = (arg: Z): Bar => { 8 | const bar = () => {} 9 | }`, 10 | { 11 | code: $` 12 | /// 2a 13 | const a = 1 14 | `, 15 | errors: ['command-error'], 16 | }, 17 | // Function declaration 18 | { 19 | code: $` 20 | /// to-arrow 21 | export async function foo (arg: T): Bar { 22 | const bar = () => {} 23 | } 24 | `, 25 | output: $` 26 | export const foo = async (arg: T): Bar => { 27 | const bar = () => {} 28 | } 29 | `, 30 | errors: ['command-fix'], 31 | }, 32 | // Function expression 33 | { 34 | code: $` 35 | ///to-arrow 36 | const bar = async function foo (arg: T): Bar { 37 | function baz() {} 38 | } 39 | `, 40 | output: $` 41 | const bar = async (arg: T): Bar => { 42 | function baz() {} 43 | } 44 | `, 45 | errors: ['command-fix'], 46 | }, 47 | // Object method 48 | { 49 | code: $` 50 | const bar = { 51 | /// to-arrow 52 | async [bar]?(a: number, b: number): number { 53 | return a + b 54 | }, 55 | foo() { 56 | return 1 57 | }, 58 | } 59 | `, 60 | output: $` 61 | const bar = { 62 | [bar]: async (a: number, b: number): number => { 63 | return a + b 64 | }, 65 | foo() { 66 | return 1 67 | }, 68 | } 69 | `, 70 | errors: ['command-fix'], 71 | }, 72 | // Getter/setter 73 | { 74 | code: $` 75 | const bar = { 76 | /// to-arrow 77 | get id() {} 78 | } 79 | `, 80 | errors: ['command-error'], 81 | }, 82 | // Class method 83 | { 84 | code: $` 85 | class Bar { 86 | /// to-arrow 87 | private static override async [bar]?(a: number, b: number): number { 88 | return a + b 89 | } 90 | foo() { 91 | return 1 92 | } 93 | } 94 | `, 95 | output: $` 96 | class Bar { 97 | private static override [bar] ? = async (a: number, b: number): number => { 98 | return a + b 99 | } 100 | foo() { 101 | return 1 102 | } 103 | } 104 | `, 105 | errors: ['command-fix'], 106 | }, 107 | ) 108 | -------------------------------------------------------------------------------- /src/commands/keep-aligned.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { keepAligned } from './keep-aligned' 3 | 4 | run( 5 | keepAligned, 6 | { 7 | code: $` 8 | // @keep-aligned , , , 9 | export const matrix = [ 10 | 1, 0, 0, 11 | 0.866, -0.5, 0, 12 | 0.5, 0.866, 42, 13 | ] 14 | `, 15 | output(output) { 16 | expect(output).toMatchInlineSnapshot(` 17 | "// @keep-aligned , , , 18 | export const matrix = [ 19 | 1 , 0 , 0 , 20 | 0.866, -0.5 , 0 , 21 | 0.5 , 0.866, 42, 22 | ]" 23 | `) 24 | }, 25 | }, 26 | { 27 | code: $` 28 | // @keep-aligned , , ] 29 | export const matrix = [ 30 | [1, 0, 0], 31 | [0.866, -0.5, 0], 32 | [0.5, 0.866, 42], 33 | ] 34 | `, 35 | output(output) { 36 | expect(output).toMatchInlineSnapshot(` 37 | "// @keep-aligned , , ] 38 | export const matrix = [ 39 | [1 , 0 , 0 ], 40 | [0.866, -0.5 , 0 ], 41 | [0.5 , 0.866, 42], 42 | ] " 43 | `) 44 | }, 45 | }, 46 | { 47 | code: $` 48 | /// keep-aligned* , 49 | export const matrix = [ 50 | 1, 0, 0, 0.866, 0.5, 0.866, 42, 51 | 0.866, -0.5, 0, 0.5, 0.121212, 52 | 0.5, 0.866, 118, 1, 0, 0, 0.866, -0.5, 12, 53 | ] 54 | `, 55 | output(output) { 56 | expect(output).toMatchInlineSnapshot(` 57 | "/// keep-aligned* , 58 | export const matrix = [ 59 | 1 , 0 , 0 , 0.866, 0.5 , 0.866, 42 , 60 | 0.866, -0.5 , 0 , 0.5 , 0.121212, 61 | 0.5 , 0.866, 118, 1 , 0 , 0 , 0.866, -0.5, 12, 62 | ] " 63 | `) 64 | }, 65 | }, 66 | { 67 | code: $` 68 | function foo(arr: number[][], i: number, j: number) { 69 | // @keep-aligned arr[ ] arr[ ] ][j 70 | return arr[i - 1][j - 1] + arr[i - 1][j] + arr[i - 1][j + 1] 71 | + arr[i][j - 1] + arr[i][j] + arr[i][j + 1] 72 | + arr[i + 1][j - 1] + arr[i + 1][j] + arr[i][j + 1] 73 | } 74 | `, 75 | output(output) { 76 | expect(output).toMatchInlineSnapshot(` 77 | "function foo(arr: number[][], i: number, j: number) { 78 | // @keep-aligned arr[ ] arr[ ] ][j 79 | return arr[i - 1][j - 1] + arr[i - 1][j] + arr[i - 1][j + 1] 80 | + arr[i ][j - 1] + arr[i ][j] + arr[i ][j + 1] 81 | + arr[i + 1][j - 1] + arr[i + 1][j] + arr[i ][j + 1] 82 | }" 83 | `) 84 | }, 85 | }, 86 | ) 87 | -------------------------------------------------------------------------------- /src/commands/regex101.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | import { parseComment } from '@es-joy/jsdoccomment' 3 | 4 | // @regex101 https://regex101.com/?regex=%60%60%60%28.*%29%5Cn%28%5B%5Cs%5CS%5D*%29%5Cn%60%60%60&flavor=javascript 5 | const reCodeBlock = /```(.*)\n([\s\S]*)\n```/ 6 | 7 | export const regex101: Command = { 8 | name: 'regex101', 9 | /** 10 | * @regex101 https://regex101.com/?regex=%28%5Cb%7C%5Cs%7C%5E%29%28%40regex101%29%28%5Cs%5CS%2B%29%3F%28%5Cb%7C%5Cs%7C%24%29&flavor=javascript 11 | */ 12 | match: /(\b|\s|^)(@regex101)(\s\S+)?(\b|\s|$)/, 13 | commentType: 'both', 14 | action(ctx) { 15 | const literal = ctx.findNodeBelow((n) => { 16 | return n.type === 'Literal' && 'regex' in n 17 | }) as Tree.RegExpLiteral | undefined 18 | if (!literal) 19 | return ctx.reportError('Unable to find a regexp literal to generate') 20 | 21 | const [ 22 | _fullStr = '', 23 | spaceBefore = '', 24 | commandStr = '', 25 | existingUrl = '', 26 | _spaceAfter = '', 27 | ] = ctx.matches as string[] 28 | 29 | let example: string | undefined 30 | 31 | if (ctx.comment.value.includes('```') && ctx.comment.value.includes('@example')) { 32 | try { 33 | const parsed = parseComment(ctx.comment, '') 34 | const tag = parsed.tags.find(t => t.tag === 'example') 35 | const description = tag?.description 36 | const code = description?.match(reCodeBlock)?.[2].trim() 37 | if (code) 38 | example = code 39 | } 40 | catch {} 41 | } 42 | 43 | // docs: https://github.com/firasdib/Regex101/wiki/FAQ#how-to-prefill-the-fields-on-the-interface-via-url 44 | const query = new URLSearchParams() 45 | query.set('regex', literal.regex.pattern) 46 | if (literal.regex.flags) 47 | query.set('flags', literal.regex.flags) 48 | query.set('flavor', 'javascript') 49 | if (example) 50 | query.set('testString', example) 51 | const url = `https://regex101.com/?${query}` 52 | 53 | if (existingUrl.trim() === url.trim()) 54 | return 55 | 56 | const indexStart = ctx.comment.range[0] + ctx.matches.index! + spaceBefore.length + 2 /** comment prefix */ 57 | const indexEnd = indexStart + commandStr.length + existingUrl.length 58 | 59 | ctx.report({ 60 | loc: { 61 | start: ctx.source.getLocFromIndex(indexStart), 62 | end: ctx.source.getLocFromIndex(indexEnd), 63 | }, 64 | removeComment: false, 65 | message: `Update the regex101 link`, 66 | fix(fixer) { 67 | return fixer.replaceTextRange([indexStart, indexEnd], `@regex101 ${url}`) 68 | }, 69 | }) 70 | }, 71 | } 72 | -------------------------------------------------------------------------------- /src/commands/to-arrow.ts: -------------------------------------------------------------------------------- 1 | import type { Command } from '../types' 2 | 3 | export const toArrow: Command = { 4 | name: 'to-arrow', 5 | match: /^\s*[/:@]\s*(to-arrow|2a|ta)$/, 6 | action(ctx) { 7 | const fn = ctx.findNodeBelow('FunctionDeclaration', 'FunctionExpression') 8 | if (!fn) 9 | return ctx.reportError('Unable to find function declaration to convert') 10 | 11 | const id = fn.id 12 | const body = fn.body 13 | 14 | let rangeStart = fn.range[0] 15 | const rangeEnd = fn.range[1] 16 | 17 | const parent = fn.parent 18 | 19 | if (parent.type === 'Property' && parent.kind !== 'init') 20 | return ctx.reportError(`Cannot convert ${parent.kind}ter property to arrow function`) 21 | 22 | ctx.report({ 23 | node: fn, 24 | loc: { 25 | start: fn.loc.start, 26 | end: body.loc.start, 27 | }, 28 | message: 'Convert to arrow function', 29 | fix(fixer) { 30 | let textName = ctx.getTextOf(id) 31 | const textArgs = fn.params.length 32 | ? ctx.getTextOf([fn.params[0].range[0], fn.params[fn.params.length - 1].range[1]]) 33 | : '' 34 | const textBody = body.type === 'BlockStatement' 35 | ? ctx.getTextOf(body) 36 | : `{\n return ${ctx.getTextOf(body)}\n}` 37 | const textGeneric = ctx.getTextOf(fn.typeParameters) 38 | const textTypeReturn = ctx.getTextOf(fn.returnType) 39 | const textAsync = fn.async ? 'async' : '' 40 | 41 | let final = [textAsync, `${textGeneric}(${textArgs})${textTypeReturn} =>`, textBody].filter(Boolean).join(' ') 42 | 43 | // For function declaration 44 | if (fn.type === 'FunctionDeclaration' && textName) { 45 | final = `const ${textName} = ${final}` 46 | } 47 | 48 | // For object methods 49 | else if (parent.type === 'Property') { 50 | rangeStart = parent.range[0] 51 | textName = ctx.getTextOf(parent.key) 52 | final = `${parent.computed ? `[${textName}]` : textName}: ${final}` 53 | } 54 | 55 | // For class methods 56 | else if (parent.type === 'MethodDefinition') { 57 | rangeStart = parent.range[0] 58 | textName = ctx.getTextOf(parent.key) 59 | final = `${[ 60 | parent.accessibility, 61 | parent.static && 'static', 62 | parent.override && 'override', 63 | parent.computed ? `[${textName}]` : textName, 64 | parent.optional && '?', 65 | ].filter(Boolean).join(' ')} = ${final}` 66 | } 67 | 68 | return fixer.replaceTextRange([rangeStart, rangeEnd], final) 69 | }, 70 | }) 71 | }, 72 | } 73 | -------------------------------------------------------------------------------- /src/commands/to-destructuring.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toDestructuring as command } from './to-destructuring' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | /// to-destructuring 9 | const foo = bar.foo 10 | `, 11 | output: $` 12 | const { foo } = bar 13 | `, 14 | errors: ['command-fix'], 15 | }, 16 | { 17 | code: $` 18 | /// to-destructuring 19 | const baz = bar.foo 20 | `, 21 | output: $` 22 | const { foo: baz } = bar 23 | `, 24 | errors: ['command-fix'], 25 | }, 26 | { 27 | code: $` 28 | /// to-destructuring 29 | const foo = bar?.foo 30 | `, 31 | output: $` 32 | const { foo } = bar ?? {} 33 | `, 34 | errors: ['command-fix'], 35 | }, 36 | { 37 | code: $` 38 | /// to-destructuring 39 | const foo = bar[1] 40 | `, 41 | output: $` 42 | const [,foo] = bar 43 | `, 44 | errors: ['command-fix'], 45 | }, 46 | { 47 | code: $` 48 | /// to-destructuring 49 | const foo = bar?.[0] 50 | `, 51 | output: $` 52 | const [foo] = bar ?? [] 53 | `, 54 | errors: ['command-fix'], 55 | }, 56 | { 57 | code: $` 58 | /// to-destructuring 59 | const foo = bar().foo 60 | `, 61 | output: $` 62 | const { foo } = bar() 63 | `, 64 | errors: ['command-fix'], 65 | }, 66 | { 67 | code: $` 68 | /// to-destructuring 69 | const foo = bar()?.foo 70 | `, 71 | output: $` 72 | const { foo } = bar() ?? {} 73 | `, 74 | errors: ['command-fix'], 75 | }, 76 | { 77 | code: $` 78 | /// to-destructuring 79 | foo = bar.foo 80 | `, 81 | output: $` 82 | ;({ foo } = bar) 83 | `, 84 | errors: ['command-fix'], 85 | }, 86 | { 87 | code: $` 88 | /// to-destructuring 89 | baz = bar.foo 90 | `, 91 | output: $` 92 | ;({ foo: baz } = bar) 93 | `, 94 | errors: ['command-fix'], 95 | }, 96 | { 97 | code: $` 98 | /// to-destructuring 99 | foo = bar[0] 100 | `, 101 | output: $` 102 | ;([foo] = bar) 103 | `, 104 | errors: ['command-fix'], 105 | }, 106 | { 107 | code: $` 108 | /// to-destructuring 109 | foo = bar().foo 110 | `, 111 | output: $` 112 | ;({ foo } = bar()) 113 | `, 114 | errors: ['command-fix'], 115 | }, 116 | { 117 | code: $` 118 | /// to-destructuring 119 | baz = bar().foo 120 | `, 121 | output: $` 122 | ;({ foo: baz } = bar()) 123 | `, 124 | errors: ['command-fix'], 125 | }, 126 | ) 127 | -------------------------------------------------------------------------------- /src/commands/to-template-literal.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | import { getNodesByIndexes, parseToNumberArray } from './_utils' 3 | 4 | type NodeTypes = Tree.StringLiteral | Tree.BinaryExpression 5 | 6 | export const toTemplateLiteral: Command = { 7 | name: 'to-template-literal', 8 | match: /^\s*[/:@]\s*(?:to-|2)?(?:template-literal|tl)\s*(\S.*)?$/, 9 | action(ctx) { 10 | const numbers = ctx.matches[1] 11 | // From integers 1-based to 0-based to match array indexes 12 | const indexes = parseToNumberArray(numbers, true).map(n => n - 1) 13 | let nodes: NodeTypes[] | undefined 14 | nodes = ctx 15 | .findNodeBelow({ 16 | types: ['Literal', 'BinaryExpression'], 17 | shallow: true, 18 | findAll: true, 19 | }) 20 | ?.filter(node => 21 | node.type === 'Literal' 22 | ? typeof node.value === 'string' 23 | : node.type === 'BinaryExpression' 24 | ? node.operator === '+' 25 | : false, 26 | ) as NodeTypes[] | undefined 27 | 28 | if (!nodes || !nodes.length) 29 | return ctx.reportError('No string literals or binary expressions found') 30 | 31 | // Since we can specify numbers, the order is sensitive 32 | nodes = getNodesByIndexes(nodes, indexes) 33 | 34 | ctx.report({ 35 | nodes, 36 | message: 'Convert to template literal', 37 | * fix(fixer) { 38 | for (const node of nodes.reverse()) { 39 | if (node.type === 'BinaryExpression') 40 | yield fixer.replaceText(node, `\`${traverseBinaryExpression(node)}\``) 41 | else 42 | yield fixer.replaceText(node, `\`${escape(node.value)}\``) 43 | } 44 | }, 45 | }) 46 | }, 47 | } 48 | 49 | function getExpressionValue(node: Tree.Expression | Tree.PrivateIdentifier) { 50 | if (node.type === 'Identifier') 51 | return `\${${node.name}}` 52 | if (node.type === 'Literal' && typeof node.value === 'string') 53 | return escape(node.value) 54 | return '' 55 | } 56 | 57 | function traverseBinaryExpression(node: Tree.BinaryExpression): string { 58 | let deepestExpr = node 59 | let str = '' 60 | 61 | while (deepestExpr.left.type === 'BinaryExpression') 62 | deepestExpr = deepestExpr.left 63 | 64 | let currentExpr: Tree.BinaryExpression | null = deepestExpr 65 | 66 | while (currentExpr) { 67 | str += getExpressionValue(currentExpr.left) + getExpressionValue(currentExpr.right) 68 | if (currentExpr === node) 69 | break 70 | currentExpr = currentExpr.parent as Tree.BinaryExpression | null 71 | } 72 | 73 | return str 74 | } 75 | 76 | function escape(raw: string) { 77 | // TODO handle multi escape characters '\\${str}' 78 | return raw.replace(/`/g, '\\`').replace(/\$\{/g, '\\${') 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-plugin-command", 3 | "type": "module", 4 | "version": "3.4.0", 5 | "packageManager": "pnpm@10.25.0", 6 | "description": "Comment-as-command for one-off codemod with ESLint", 7 | "author": "Anthony Fu ", 8 | "license": "MIT", 9 | "funding": "https://github.com/sponsors/antfu", 10 | "homepage": "https://github.com/antfu/eslint-plugin-command#readme", 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/antfu/eslint-plugin-command.git" 14 | }, 15 | "bugs": "https://github.com/antfu/eslint-plugin-command/issues", 16 | "keywords": [ 17 | "eslint-plugin", 18 | "codemod" 19 | ], 20 | "sideEffects": false, 21 | "exports": { 22 | ".": "./dist/index.mjs", 23 | "./commands": "./dist/commands.mjs", 24 | "./config": "./dist/config.mjs", 25 | "./types": "./dist/types.mjs", 26 | "./package.json": "./package.json" 27 | }, 28 | "main": "./dist/index.mjs", 29 | "module": "./dist/index.mjs", 30 | "types": "./dist/index.d.mts", 31 | "files": [ 32 | "dist" 33 | ], 34 | "scripts": { 35 | "build": "tsdown", 36 | "lint": "tsdown && eslint .", 37 | "prepublishOnly": "nr build", 38 | "release": "bumpp", 39 | "test": "vitest", 40 | "docs": "nr -C docs docs:dev", 41 | "docs:build": "nr -C docs docs:build", 42 | "typecheck": "tsc --noEmit", 43 | "prepare": "simple-git-hooks" 44 | }, 45 | "peerDependencies": { 46 | "eslint": "*" 47 | }, 48 | "dependencies": { 49 | "@es-joy/jsdoccomment": "catalog:prod" 50 | }, 51 | "devDependencies": { 52 | "@antfu/eslint-config": "catalog:dev", 53 | "@antfu/ni": "catalog:dev", 54 | "@antfu/utils": "catalog:dev", 55 | "@eslint/config-inspector": "catalog:dev", 56 | "@types/lodash.merge": "catalog:dev", 57 | "@types/node": "catalog:dev", 58 | "@types/semver": "catalog:dev", 59 | "@typescript-eslint/rule-tester": "catalog:dev", 60 | "@typescript-eslint/typescript-estree": "catalog:dev", 61 | "@typescript-eslint/utils": "catalog:dev", 62 | "@vitest/ui": "catalog:dev", 63 | "bumpp": "catalog:dev", 64 | "eslint": "catalog:dev", 65 | "eslint-vitest-rule-tester": "catalog:dev", 66 | "fast-glob": "catalog:dev", 67 | "lint-staged": "catalog:dev", 68 | "lodash.merge": "catalog:dev", 69 | "pnpm": "catalog:dev", 70 | "rimraf": "catalog:dev", 71 | "semver": "catalog:dev", 72 | "simple-git-hooks": "catalog:dev", 73 | "tsdown": "catalog:dev", 74 | "typescript": "catalog:dev", 75 | "vite": "catalog:dev", 76 | "vitest": "catalog:dev" 77 | }, 78 | "resolutions": { 79 | "chokidar": "catalog:dev", 80 | "eslint-plugin-command": "workspace:*" 81 | }, 82 | "simple-git-hooks": { 83 | "pre-commit": "npx lint-staged" 84 | }, 85 | "lint-staged": { 86 | "*": "eslint --fix" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /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 | export interface RuleModule< 6 | T extends readonly unknown[], 7 | > extends Rule.RuleModule { 8 | defaultOptions: T 9 | } 10 | 11 | /** 12 | * Creates reusable function to create rules with default options and docs URLs. 13 | * 14 | * @param urlCreator Creates a documentation URL for a given rule name. 15 | * @returns Function to create a rule with the docs URL format. 16 | */ 17 | function RuleCreator(urlCreator: (ruleName: string) => string) { 18 | // This function will get much easier to call when this is merged https://github.com/Microsoft/TypeScript/pull/26349 19 | // TODO - when the above PR lands; add type checking for the context.report `data` property 20 | return function createNamedRule< 21 | TOptions extends readonly unknown[], 22 | TMessageIds extends string, 23 | >({ 24 | name, 25 | meta, 26 | ...rule 27 | }: Readonly>): RuleModule { 28 | return createRule({ 29 | meta: { 30 | ...meta, 31 | docs: { 32 | ...meta.docs, 33 | url: urlCreator(name), 34 | }, 35 | }, 36 | ...rule, 37 | }) 38 | } 39 | } 40 | 41 | /** 42 | * Creates a well-typed TSESLint custom ESLint rule without a docs URL. 43 | * 44 | * @returns Well-typed TSESLint custom ESLint rule. 45 | * @remarks It is generally better to provide a docs URL function to RuleCreator. 46 | */ 47 | function createRule< 48 | TOptions extends readonly unknown[], 49 | TMessageIds extends string, 50 | >({ 51 | create, 52 | defaultOptions, 53 | meta, 54 | }: Readonly>): RuleModule { 55 | return { 56 | create: (( 57 | context: Readonly>, 58 | ): RuleListener => { 59 | const optionsWithDefault = context.options.map((options, index) => { 60 | return { 61 | ...defaultOptions[index] || {}, 62 | ...options || {}, 63 | } 64 | }) as unknown as TOptions 65 | return create(context, optionsWithDefault) 66 | }) as any, 67 | defaultOptions, 68 | meta: meta as any, 69 | } 70 | } 71 | 72 | export const createEslintRule = RuleCreator( 73 | () => 'https://github.com/antfu/eslint-plugin-command', 74 | ) as any as ({ name, meta, ...rule }: Readonly>) => RuleModule 75 | 76 | const warned = new Set() 77 | 78 | export function warnOnce(message: string) { 79 | if (warned.has(message)) 80 | return 81 | warned.add(message) 82 | console.warn(message) 83 | } 84 | -------------------------------------------------------------------------------- /src/commands/to-template-literal.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toTemplateLiteral as command } from './to-template-literal' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | // @2tl 9 | const a = \`a\${a}\`, b = \`b\`, c = "c", d = 2; 10 | `, 11 | output: $` 12 | const a = \`a\${a}\`, b = \`b\`, c = \`c\`, d = 2; 13 | `, 14 | errors: ['command-fix'], 15 | }, 16 | // You can specify which one to convert 17 | { 18 | code: $` 19 | // @2tl 1 4 20 | const a = 'a', b = 'b', c = 'c', d = 'd'; 21 | `, 22 | output: $` 23 | const a = \`a\`, b = 'b', c = 'c', d = \`d\`; 24 | `, 25 | errors: ['command-fix'], 26 | }, 27 | // mixed 28 | { 29 | code: $` 30 | // @2tl 1 3 31 | const a = \`a\`; const b = \`b\`; const c = 'c'; const d = \`d\`; const e = 'e'; const f = 'f'; 32 | `, 33 | output: $` 34 | const a = \`a\`; const b = \`b\`; const c = \`c\`; const d = \`d\`; const e = 'e'; const f = \`f\`; 35 | `, 36 | errors: ['command-fix'], 37 | }, 38 | // 'a' + b + 'c' -> `a${b}c` 39 | { 40 | code: $` 41 | // @2tl 42 | const a = 'a' + b + 'c'; 43 | `, 44 | output: $` 45 | const a = \`a\${b}c\`; 46 | `, 47 | errors: ['command-fix'], 48 | }, 49 | { 50 | code: $` 51 | // @2tl 52 | const b = b + 'c' + d + 'e' + f + z + 'g' + h + 'i' + j; 53 | `, 54 | output: $` 55 | const b = \`\${b}c\${d}e\${f}\${z}g\${h}i\${j}\`; 56 | `, 57 | errors: ['command-fix'], 58 | }, 59 | { 60 | code: $` 61 | // @2tl 62 | const a = a + b + c; 63 | `, 64 | output: $` 65 | const a = \`\${a}\${b}\${c}\`; 66 | `, 67 | errors: ['command-fix'], 68 | }, 69 | { 70 | code: $` 71 | // @2tl 2 4 72 | const a = a + b; const d = d + 'e'; const c = '3'; const d = '4'; 73 | `, 74 | output: $` 75 | const a = a + b; const d = \`\${d}e\`; const c = '3'; const d = \`4\`; 76 | `, 77 | errors: ['command-fix'], 78 | }, 79 | { 80 | code: $` 81 | // @2tl 1 2 82 | const a = '4' + b; const d = d + 'e'; const c = '3'; const d = '4'; 83 | `, 84 | output: $` 85 | const a = \`4\${b}\`; const d = \`\${d}e\`; const c = '3'; const d = '4'; 86 | `, 87 | errors: ['command-fix'], 88 | }, 89 | // escape 90 | { 91 | code: $` 92 | // @2tl 93 | const a = "\`" 94 | `, 95 | output: $` 96 | const a = \`\\\`\` 97 | `, 98 | errors: ['command-fix'], 99 | }, 100 | { 101 | code: $` 102 | // @2tl 103 | const a = str + "\`" 104 | `, 105 | output: $` 106 | const a = \`\${str}\\\`\` 107 | `, 108 | errors: ['command-fix'], 109 | }, 110 | { 111 | code: $` 112 | // @2tl 113 | const a = "\${str}" 114 | `, 115 | output: $` 116 | const a = \`\\\${str}\` 117 | `, 118 | errors: ['command-fix'], 119 | }, 120 | ) 121 | -------------------------------------------------------------------------------- /src/commands/to-for-each.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toForEach as command } from './to-for-each' 3 | 4 | run( 5 | command, 6 | // Basic for-of 7 | { 8 | code: $` 9 | /// to-for-each 10 | for (const foo of bar) { 11 | if (foo) { 12 | continue 13 | } 14 | else if (1 + 1 === 2) { 15 | continue 16 | } 17 | } 18 | `, 19 | output: $` 20 | bar.forEach((foo) => { 21 | if (foo) { 22 | return 23 | } 24 | else if (1 + 1 === 2) { 25 | return 26 | } 27 | }) 28 | `, 29 | errors: ['command-fix'], 30 | }, 31 | // One-line for-of 32 | { 33 | code: $` 34 | /// to-for-each 35 | for (const foo of bar) 36 | count += 1 37 | `, 38 | output: $` 39 | bar.forEach((foo) => { 40 | count += 1 41 | }) 42 | `, 43 | errors: ['command-fix'], 44 | }, 45 | // Nested for 46 | { 47 | code: $` 48 | /// to-for-each 49 | for (const foo of bar) { 50 | for (const baz of foo) { 51 | if (foo) { 52 | continue 53 | } 54 | } 55 | const fn1 = () => { 56 | continue 57 | } 58 | function fn2() { 59 | continue 60 | } 61 | } 62 | `, 63 | output: $` 64 | bar.forEach((foo) => { 65 | for (const baz of foo) { 66 | if (foo) { 67 | continue 68 | } 69 | } 70 | const fn1 = () => { 71 | continue 72 | } 73 | function fn2() { 74 | continue 75 | } 76 | }) 77 | `, 78 | errors: ['command-fix'], 79 | }, 80 | // Throw on return statement 81 | { 82 | code: $` 83 | /// to-for-each 84 | for (const foo of bar) { 85 | return foo 86 | } 87 | `, 88 | errors: ['command-error', 'command-error-cause'], 89 | }, 90 | // Destructure 91 | { 92 | code: $` 93 | /// to-for-each 94 | for (const [key, value] of Object.entries(baz)) { 95 | console.log(foo, bar) 96 | } 97 | `, 98 | output: $` 99 | Object.entries(baz).forEach(([key, value]) => { 100 | console.log(foo, bar) 101 | }) 102 | `, 103 | errors: ['command-fix'], 104 | }, 105 | // Iterate over expressions 106 | { 107 | code: $` 108 | /// to-for-each 109 | for (const i of 'a' + 'b') 110 | console.log(i) 111 | `, 112 | output: $` 113 | ;('a' + 'b').forEach((i) => { 114 | console.log(i) 115 | }) 116 | `, 117 | errors: ['command-fix'], 118 | }, 119 | // Iterate over object 120 | { 121 | code: $` 122 | /// to-for-each 123 | for (const key of { a: 1, b: 2 }) 124 | console.log(key) 125 | `, 126 | output: $` 127 | ;({ a: 1, b: 2 }).forEach((key) => { 128 | console.log(key) 129 | }) 130 | `, 131 | errors: ['command-fix'], 132 | }, 133 | ) 134 | -------------------------------------------------------------------------------- /src/commands/to-one-line.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toOneLine as command } from './to-one-line' 3 | 4 | run( 5 | command, 6 | { 7 | code: $` 8 | /// to-one-line 9 | const foo = { 10 | bar: 1, 11 | baz: 2, 12 | } 13 | `, 14 | output: $` 15 | const foo = { bar: 1, baz: 2 } 16 | `, 17 | errors: ['command-fix'], 18 | }, 19 | { 20 | code: $` 21 | /// to-one-line 22 | const arr = [ 23 | 1, 24 | 2, 25 | 3, 26 | 4, 27 | ] 28 | `, 29 | output: $` 30 | const arr = [1, 2, 3, 4] 31 | `, 32 | errors: ['command-fix'], 33 | }, 34 | { 35 | code: $` 36 | /// tol 37 | obj = { 38 | x: 100, 39 | y: 200, 40 | } 41 | `, 42 | output: $` 43 | obj = { x: 100, y: 200 } 44 | `, 45 | errors: ['command-fix'], 46 | }, 47 | { 48 | code: $` 49 | /// to-one-line 50 | const data = { 51 | user: { 52 | name: 'Alice', 53 | age: 30, 54 | }, 55 | scores: [ 56 | 10, 57 | 20, 58 | 30, 59 | ], 60 | } 61 | `, 62 | output: $` 63 | const data = { user: { name: 'Alice', age: 30 }, scores: [10, 20, 30] } 64 | `, 65 | errors: ['command-fix'], 66 | }, 67 | { 68 | code: $` 69 | /// 21l 70 | const alreadyOneLine = { a: 1, b: 2 } 71 | `, 72 | output: $` 73 | const alreadyOneLine = { a: 1, b: 2 } 74 | `, 75 | errors: ['command-fix'], 76 | }, 77 | { 78 | code: $` 79 | /// to-one-line 80 | const fruits = [ 81 | "apple", 82 | "banana", 83 | "cherry", 84 | ] 85 | `, 86 | output: $` 87 | const fruits = ["apple", "banana", "cherry"] 88 | `, 89 | errors: ['command-fix'], 90 | }, 91 | { 92 | code: $` 93 | /// to-one-line 94 | whichFruitIsTheBest([ 95 | "apple", 96 | "banana", 97 | "cherry", 98 | ]) 99 | `, 100 | output: $` 101 | whichFruitIsTheBest(["apple", "banana", "cherry"]) 102 | `, 103 | errors: ['command-fix'], 104 | }, 105 | { 106 | code: $` 107 | /// to-one-line 108 | function whichFruitIsTheBest({ 109 | apple, 110 | banana, 111 | cherry, 112 | }) {} 113 | `, 114 | output: $` 115 | function whichFruitIsTheBest({ apple, banana, cherry }) {} 116 | `, 117 | errors: ['command-fix'], 118 | }, 119 | { 120 | code: $` 121 | /// to-one-line 122 | function f([ 123 | a, 124 | b, 125 | c, 126 | ]) {} 127 | `, 128 | output: $` 129 | function f([a, b, c]) {} 130 | `, 131 | errors: ['command-fix'], 132 | }, 133 | { 134 | code: $` 135 | /// to-one-line 136 | return { 137 | foo: 1, 138 | bar: 2, 139 | } 140 | `, 141 | output: $` 142 | return { foo: 1, bar: 2 } 143 | `, 144 | errors: ['command-fix'], 145 | }, 146 | ) 147 | -------------------------------------------------------------------------------- /src/commands/to-for-each.ts: -------------------------------------------------------------------------------- 1 | import type { Command, NodeType, Tree } from '../types' 2 | 3 | export const FOR_TRAVERSE_IGNORE: NodeType[] = [ 4 | 'FunctionDeclaration', 5 | 'FunctionExpression', 6 | 'ArrowFunctionExpression', 7 | 'WhileStatement', 8 | 'DoWhileStatement', 9 | 'ForInStatement', 10 | 'ForOfStatement', 11 | 'ForStatement', 12 | 'ArrowFunctionExpression', 13 | ] 14 | 15 | export const toForEach: Command = { 16 | name: 'to-for-each', 17 | match: /^\s*[/:@]\s*(?:to-|2)?for-?each$/i, 18 | action(ctx) { 19 | const node = ctx.findNodeBelow('ForInStatement', 'ForOfStatement') 20 | if (!node) 21 | return ctx.reportError('Unable to find for statement to convert') 22 | 23 | const continueNodes: Tree.ContinueStatement[] = [] 24 | const result = ctx.traverse(node.body, (path, { STOP, SKIP }) => { 25 | if (FOR_TRAVERSE_IGNORE.includes(path.node.type)) 26 | return SKIP 27 | 28 | if (path.node.type === 'ContinueStatement') { 29 | continueNodes.push(path.node) 30 | } 31 | else if (path.node.type === 'BreakStatement') { 32 | ctx.reportError( 33 | 'Unable to convert for statement with break statement', 34 | { 35 | node: path.node, 36 | message: 'Break statement has no equivalent in forEach', 37 | }, 38 | ) 39 | return STOP 40 | } 41 | else if (path.node.type === 'ReturnStatement') { 42 | ctx.reportError( 43 | 'Unable to convert for statement with return statement', 44 | { 45 | node: path.node, 46 | message: 'Return statement has no equivalent in forEach', 47 | }, 48 | ) 49 | return STOP 50 | } 51 | }) 52 | if (!result) 53 | return 54 | 55 | // Convert `continue` to `return` 56 | let textBody = ctx.getTextOf(node.body) 57 | continueNodes 58 | .sort((a, b) => b.loc.start.line - a.loc.start.line) 59 | .forEach((c) => { 60 | textBody 61 | // eslint-disable-next-line prefer-template 62 | = textBody.slice(0, c.range[0] - node.body.range[0]) 63 | + 'return' 64 | + textBody.slice(c.range[1] - node.body.range[0]) 65 | }) 66 | // Add braces if missing 67 | if (!textBody.trim().startsWith('{')) 68 | textBody = `{\n${textBody}\n}` 69 | 70 | const localId = node.left.type === 'VariableDeclaration' 71 | ? node.left.declarations[0].id 72 | : node.left 73 | const textLocal = ctx.getTextOf(localId) 74 | let textIterator = ctx.getTextOf(node.right) 75 | 76 | if (!['Identifier', 'MemberExpression', 'CallExpression'].includes(node.right.type)) 77 | textIterator = `(${textIterator})` 78 | 79 | let str = node.type === 'ForOfStatement' 80 | ? `${textIterator}.forEach((${textLocal}) => ${textBody})` 81 | : `Object.keys(${textIterator}).forEach((${textLocal}) => ${textBody})` 82 | 83 | // If it starts with `(`, add a semicolon to prevent AST confusion 84 | if (str[0] === '(') 85 | str = `;${str}` 86 | 87 | ctx.report({ 88 | node, 89 | message: 'Convert to forEach', 90 | fix(fixer) { 91 | return fixer.replaceText(node, str) 92 | }, 93 | }) 94 | }, 95 | } 96 | -------------------------------------------------------------------------------- /src/commands/to-one-line.ts: -------------------------------------------------------------------------------- 1 | import type { Command, NodeType, Tree } from '../types' 2 | 3 | export const toOneLine: Command = { 4 | name: 'to-one-line', 5 | match: /^[/@:]\s*(?:to-one-line|21l|tol)$/, 6 | action(ctx) { 7 | const node = ctx.findNodeBelow( 8 | 'VariableDeclaration', 9 | 'AssignmentExpression', 10 | 'CallExpression', 11 | 'FunctionDeclaration', 12 | 'FunctionExpression', 13 | 'ReturnStatement', 14 | ) 15 | if (!node) 16 | return ctx.reportError('Unable to find node to convert') 17 | 18 | let target: Tree.Node | null = null 19 | 20 | // For a variable declaration we use the initializer. 21 | if (node.type === 'VariableDeclaration') { 22 | const decl = node.declarations[0] 23 | if (decl && decl.init && isAllowedType(decl.init.type)) 24 | target = decl.init 25 | } 26 | // For an assignment we use the right side. 27 | else if (node.type === 'AssignmentExpression') { 28 | if (node.right && isAllowedType(node.right.type)) 29 | target = node.right 30 | } 31 | // In a call we search the arguments. 32 | else if (node.type === 'CallExpression') { 33 | target = node.arguments.find(arg => isAllowedType(arg.type)) || null 34 | } 35 | // In a function we search the parameters. 36 | else if ( 37 | node.type === 'FunctionDeclaration' 38 | || node.type === 'FunctionExpression' 39 | ) { 40 | target = node.params.find(param => isAllowedType(param.type)) || null 41 | } 42 | // For a return statement we use its argument. 43 | else if (node.type === 'ReturnStatement') { 44 | if (node.argument && isAllowedType(node.argument.type)) 45 | target = node.argument 46 | } 47 | 48 | if (!target) 49 | return ctx.reportError('Unable to find object/array literal or pattern to convert') 50 | 51 | // Get the text of the node to reformat it. 52 | const original = ctx.getTextOf(target) 53 | // Replace line breaks with spaces and remove extra spaces. 54 | let oneLine = original.replace(/\n/g, ' ').replace(/\s{2,}/g, ' ').trim() 55 | // Remove a comma that comes before a closing bracket or brace. 56 | oneLine = oneLine.replace(/,\s*([}\]])/g, '$1') 57 | 58 | if (target.type === 'ArrayExpression' || target.type === 'ArrayPattern') { 59 | // For arrays, add a missing space before a closing bracket. 60 | oneLine = oneLine.replace(/\[\s+/g, '[').replace(/\s+\]/g, ']') 61 | } 62 | else { 63 | // For objects, add a missing space before a closing bracket or brace. 64 | oneLine = oneLine.replace(/([^ \t])([}\]])/g, '$1 $2') 65 | // Add a space between a ']' and a '}' if they touch. 66 | oneLine = oneLine.replace(/\](\})/g, '] $1') 67 | } 68 | 69 | // Fix any nested array formatting. 70 | oneLine = oneLine.replace(/\[\s+/g, '[').replace(/\s+\]/g, ']') 71 | 72 | ctx.report({ 73 | node: target, 74 | message: 'Convert object/array to one line', 75 | fix: fixer => fixer.replaceTextRange(target.range, oneLine), 76 | }) 77 | 78 | function isAllowedType(type: NodeType): boolean { 79 | return ( 80 | type === 'ObjectExpression' 81 | || type === 'ArrayExpression' 82 | || type === 'ObjectPattern' 83 | || type === 'ArrayPattern' 84 | ) 85 | } 86 | }, 87 | } 88 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { TSESLint as Linter, TSESTree as Tree } from '@typescript-eslint/utils' 2 | import type { CommandContext } from './context' 3 | 4 | export type { CommandContext, Linter, Tree } 5 | 6 | export type NodeType = `${Tree.Node['type']}` 7 | 8 | export type RuleOptions = [] 9 | export type MessageIds = 'command-error' | 'command-error-cause' | 'command-fix' 10 | 11 | export interface Command { 12 | /** 13 | * The name of the command 14 | * Used to identify the command in reported errors 15 | */ 16 | name: string 17 | /** 18 | * RegExp to match the comment, without the leading `//` or `/*` 19 | */ 20 | match: RegExp | ((comment: Tree.Comment) => RegExpMatchArray | boolean | undefined | null) 21 | /** 22 | * The type of the comment. By default commands are only matched with line comments. 23 | * 24 | * - `line` - `//` 25 | * - `block` - `/*` 26 | * 27 | * @default 'line' 28 | */ 29 | commentType?: 'line' | 'block' | 'both' 30 | /** 31 | * Main action of the command. 32 | * 33 | * Return `false` for "no-change", and forward to the next commands. 34 | * 35 | * @param ctx The context of the command (per-file, per matched comment) 36 | */ 37 | action: (ctx: CommandContext) => false | void 38 | } 39 | 40 | export interface ESLintPluginCommandOptions { 41 | /** 42 | * Name of the plugin 43 | * @default 'command' 44 | */ 45 | name?: string 46 | /** 47 | * Custom commands to use 48 | * If not provided, all the built-in commands will be used 49 | */ 50 | commands?: Command[] 51 | } 52 | 53 | export type CommandReportDescriptor = Partial> & { 54 | nodes?: Tree.Node[] 55 | /** 56 | * Remove the command comment on fix 57 | * 58 | * @default true 59 | */ 60 | removeComment?: boolean 61 | /** 62 | * Message of the report 63 | */ 64 | message: string 65 | } 66 | 67 | export type CommandReportErrorCauseDescriptor = { 68 | /** 69 | * An override of the location of the report 70 | */ 71 | loc: Readonly | Readonly 72 | /** 73 | * Reason of the cause 74 | */ 75 | message: string 76 | } | { 77 | /** 78 | * The Node or AST Token which the report is being attached to 79 | */ 80 | node: Tree.Node | Tree.Token 81 | /** 82 | * An override of the location of the report 83 | */ 84 | loc?: Readonly | Readonly 85 | /** 86 | * Reason of the cause 87 | */ 88 | message: string 89 | } 90 | 91 | export function defineCommand(command: Command) { 92 | return command 93 | } 94 | 95 | export interface FindNodeOptions { 96 | /** 97 | * The type of the node to search for 98 | */ 99 | types?: (Keys | `${Keys}`)[] 100 | /** 101 | * Whether to search only the direct children of the node 102 | */ 103 | shallow?: boolean 104 | /** 105 | * Return the first node found, or an array of all matches 106 | */ 107 | findAll?: All 108 | /** 109 | * Custom filter function to further filter the nodes 110 | * 111 | * `types` is ignored when `filter` is provided 112 | */ 113 | filter?: (node: Tree.Node) => boolean 114 | } 115 | -------------------------------------------------------------------------------- /src/commands/to-promise-all.ts: -------------------------------------------------------------------------------- 1 | import type { Command, Tree } from '../types' 2 | 3 | type TargetNode = Tree.VariableDeclaration | Tree.ExpressionStatement 4 | type TargetDeclarator = Tree.VariableDeclarator | Tree.AwaitExpression 5 | 6 | export const toPromiseAll: Command = { 7 | name: 'to-promise-all', 8 | match: /^[/@:]\s*(?:to-|2)(?:promise-all|pa)$/, 9 | action(ctx) { 10 | const parent = ctx.getParentBlock() 11 | const nodeStart = ctx.findNodeBelow(isTarget) as TargetNode 12 | let nodeEnd: Tree.Node = nodeStart 13 | if (!nodeStart) 14 | return ctx.reportError('Unable to find variable declaration') 15 | if (!parent.body.includes(nodeStart)) 16 | return ctx.reportError('Variable declaration is not in the same block') 17 | 18 | function isTarget(node: Tree.Node): node is TargetNode { 19 | if (node.type === 'VariableDeclaration') 20 | return node.declarations.some(declarator => declarator.init?.type === 'AwaitExpression') 21 | else if (node.type === 'ExpressionStatement') 22 | return node.expression.type === 'AwaitExpression' 23 | return false 24 | } 25 | 26 | function getDeclarators(node: TargetNode): TargetDeclarator[] { 27 | if (node.type === 'VariableDeclaration') 28 | return node.declarations 29 | if (node.expression.type === 'AwaitExpression') 30 | return [node.expression] 31 | return [] 32 | } 33 | 34 | let declarationType = 'const' 35 | const declarators: TargetDeclarator[] = [] 36 | for (let i = parent.body.indexOf(nodeStart); i < parent.body.length; i++) { 37 | const node = parent.body[i] 38 | if (isTarget(node)) { 39 | declarators.push(...getDeclarators(node)) 40 | nodeEnd = node 41 | if (node.type === 'VariableDeclaration' && node.kind !== 'const') 42 | declarationType = 'let' 43 | } 44 | else { 45 | break 46 | } 47 | } 48 | 49 | function unwrapAwait(node: Tree.Node | null) { 50 | if (node?.type === 'AwaitExpression') 51 | return node.argument 52 | return node 53 | } 54 | 55 | ctx.report({ 56 | loc: { 57 | start: nodeStart.loc.start, 58 | end: nodeEnd.loc.end, 59 | }, 60 | message: 'Convert to `await Promise.all`', 61 | fix(fixer) { 62 | const lineIndent = ctx.getIndentOfLine(nodeStart.loc.start.line) 63 | const isTs = ctx.context.filename.match(/\.[mc]?tsx?$/) 64 | 65 | function getId(declarator: TargetDeclarator) { 66 | if (declarator.type === 'AwaitExpression') 67 | return '/* discarded */' 68 | return ctx.getTextOf(declarator.id) 69 | } 70 | 71 | function getInit(declarator: TargetDeclarator) { 72 | if (declarator.type === 'AwaitExpression') 73 | return ctx.getTextOf(declarator.argument) 74 | return ctx.getTextOf(unwrapAwait(declarator.init)) 75 | } 76 | 77 | const str = [ 78 | `${declarationType} [`, 79 | ...declarators 80 | .map(declarator => `${getId(declarator)},`), 81 | '] = await Promise.all([', 82 | ...declarators 83 | .map(declarator => `${getInit(declarator)},`), 84 | isTs ? '] as const)' : '])', 85 | ] 86 | .map((line, idx) => idx ? lineIndent + line : line) 87 | .join('\n') 88 | 89 | return fixer.replaceTextRange([ 90 | nodeStart.range[0], 91 | nodeEnd.range[1], 92 | ], str) 93 | }, 94 | }) 95 | }, 96 | } 97 | -------------------------------------------------------------------------------- /src/commands/to-promise-all.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { toPromiseAll as command } from './to-promise-all' 3 | 4 | run( 5 | command, 6 | { 7 | filename: 'index.js', 8 | description: 'Program level', 9 | code: $` 10 | /// to-promise-all 11 | const a = await foo() 12 | const b = await bar() 13 | `, 14 | output: $` 15 | const [ 16 | a, 17 | b, 18 | ] = await Promise.all([ 19 | foo(), 20 | bar(), 21 | ]) 22 | `, 23 | errors: ['command-fix'], 24 | }, 25 | // Function declaration 26 | { 27 | filename: 'index.ts', 28 | code: $` 29 | async function fn() { 30 | /// to-promise-all 31 | const a = await foo() 32 | const b = await bar() 33 | } 34 | `, 35 | output: $` 36 | async function fn() { 37 | const [ 38 | a, 39 | b, 40 | ] = await Promise.all([ 41 | foo(), 42 | bar(), 43 | ] as const) 44 | } 45 | `, 46 | errors: ['command-fix'], 47 | }, 48 | // If Statement 49 | { 50 | code: $` 51 | if (true) { 52 | /// to-promise-all 53 | const a = await foo() 54 | .then(() => {}) 55 | const b = await import('bar').then(m => m.default) 56 | } 57 | `, 58 | output: $` 59 | if (true) { 60 | const [ 61 | a, 62 | b, 63 | ] = await Promise.all([ 64 | foo() 65 | .then(() => {}), 66 | import('bar').then(m => m.default), 67 | ] as const) 68 | } 69 | `, 70 | errors: ['command-fix'], 71 | }, 72 | // Mixed declarations 73 | { 74 | code: $` 75 | on('event', async () => { 76 | /// to-promise-all 77 | let a = await foo() 78 | .then(() => {}) 79 | const { foo, bar } = await import('bar').then(m => m.default) 80 | const b = await baz(), c = await qux(), d = foo() 81 | }) 82 | `, 83 | output: $` 84 | on('event', async () => { 85 | let [ 86 | a, 87 | { foo, bar }, 88 | b, 89 | c, 90 | d, 91 | ] = await Promise.all([ 92 | foo() 93 | .then(() => {}), 94 | import('bar').then(m => m.default), 95 | baz(), 96 | qux(), 97 | foo(), 98 | ] as const) 99 | }) 100 | `, 101 | errors: ['command-fix'], 102 | }, 103 | // Await expressions 104 | { 105 | code: $` 106 | /// to-promise-all 107 | const a = await bar() 108 | await foo() 109 | const b = await baz() 110 | doSomething() 111 | const nonTarget = await qux() 112 | `, 113 | output: $` 114 | const [ 115 | a, 116 | /* discarded */, 117 | b, 118 | ] = await Promise.all([ 119 | bar(), 120 | foo(), 121 | baz(), 122 | ] as const) 123 | doSomething() 124 | const nonTarget = await qux() 125 | `, 126 | errors: ['command-fix'], 127 | }, 128 | // Should stop on first non-await expression 129 | { 130 | code: $` 131 | /// to-promise-all 132 | const a = await bar() 133 | let b = await foo() 134 | let c = baz() 135 | const d = await qux() 136 | `, 137 | output: $` 138 | let [ 139 | a, 140 | b, 141 | ] = await Promise.all([ 142 | bar(), 143 | foo(), 144 | ] as const) 145 | let c = baz() 146 | const d = await qux() 147 | `, 148 | errors: ['command-fix'], 149 | }, 150 | ) 151 | -------------------------------------------------------------------------------- /docs/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import type { DefaultTheme } from 'vitepress' 2 | import { fileURLToPath } from 'node:url' 3 | import fg from 'fast-glob' 4 | import { defineConfig } from 'vitepress' 5 | import { version } from '../../package.json' 6 | import vite from './vite.config' 7 | 8 | const root = fileURLToPath(new URL('../../', import.meta.url)) 9 | 10 | const commands = fg.sync('src/commands/*.md', { 11 | cwd: root, 12 | }) 13 | 14 | const GUIDES: DefaultTheme.NavItemWithLink[] = [ 15 | { text: 'Getting Started', link: '/guide/' }, 16 | { text: 'Installation', link: '/guide/install' }, 17 | ] 18 | 19 | const COMMANDS: DefaultTheme.NavItemWithLink[] = commands.map(file => ({ 20 | text: file.split('/').pop()!.replace('.md', ''), 21 | link: `/commands/${file.split('/').pop()!.replace('.md', '')}`, 22 | })) 23 | 24 | const INTEGRATIONS: DefaultTheme.NavItemWithLink[] = [ 25 | { text: 'VS Code Extension', link: '/integrations/vscode' }, 26 | ] 27 | 28 | const VERSIONS: (DefaultTheme.NavItemWithLink | DefaultTheme.NavItemChildren)[] = [ 29 | { text: `v${version} (current)`, link: '/' }, 30 | { text: `Release Notes`, link: 'https://github.com/antfu/eslint-plugin-command/releases' }, 31 | { text: `Contributing`, link: 'https://github.com/antfu/eslint-plugin-command/blob/main/CONTRIBUTING.md' }, 32 | ] 33 | 34 | // https://vitepress.dev/reference/site-config 35 | export default defineConfig({ 36 | title: 'ESLint Plugin Command', 37 | description: 'Comment-as-command for one-off codemod with ESLint.', 38 | markdown: { 39 | theme: { 40 | light: 'vitesse-light', 41 | dark: 'vitesse-dark', 42 | }, 43 | 44 | }, 45 | 46 | rewrites: { 47 | ...Object.fromEntries(commands.map(file => [ 48 | file, 49 | `commands/${file.split('/').pop()}`, 50 | ])), 51 | // rewrite docs markdown because we set the `srcDir` to the root of the monorepo 52 | 'docs/:name(.+).md': ':name.md', 53 | }, 54 | 55 | cleanUrls: true, 56 | srcDir: root, 57 | vite, 58 | themeConfig: { 59 | logo: '/logo.svg', 60 | nav: [ 61 | { 62 | text: 'Guide', 63 | items: [ 64 | { 65 | items: GUIDES, 66 | }, 67 | ], 68 | }, 69 | { 70 | text: 'Commands', 71 | items: COMMANDS, 72 | }, 73 | { 74 | text: 'Integrations', 75 | items: INTEGRATIONS, 76 | }, 77 | { 78 | text: `v${version}`, 79 | items: VERSIONS, 80 | }, 81 | ], 82 | 83 | sidebar: Object.assign( 84 | {}, 85 | { 86 | '/': [ 87 | { 88 | text: 'Guide', 89 | items: GUIDES, 90 | }, 91 | { 92 | text: 'Commands', 93 | items: COMMANDS, 94 | }, 95 | { 96 | text: 'Integrations', 97 | items: INTEGRATIONS, 98 | }, 99 | ], 100 | }, 101 | ), 102 | 103 | editLink: { 104 | pattern: 'https://github.com/antfu/eslint-plugin-command/edit/main/:path', 105 | text: 'Suggest changes to this page', 106 | }, 107 | 108 | search: { 109 | provider: 'local', 110 | }, 111 | 112 | socialLinks: [ 113 | { icon: 'github', link: 'https://github.com/antfu/eslint-plugin-command' }, 114 | ], 115 | 116 | footer: { 117 | message: 'Released under the MIT License.', 118 | copyright: 'Copyright © 2024-PRESENT Anthony Fu.', 119 | }, 120 | }, 121 | 122 | head: [ 123 | ['meta', { name: 'theme-color', content: '#ffffff' }], 124 | ['link', { rel: 'icon', href: '/logo.svg', type: 'image/svg+xml' }], 125 | ['meta', { name: 'author', content: 'Anthony Fu' }], 126 | ['meta', { property: 'og:title', content: 'ESLint Plugin Command' }], 127 | ['meta', { property: 'og:image', content: 'https://eslint-plugin-command.antfu.me/og.png' }], 128 | ['meta', { property: 'og:description', content: 'Comment-as-command for one-off codemod with ESLint.' }], 129 | ['meta', { name: 'twitter:card', content: 'summary_large_image' }], 130 | ['meta', { name: 'twitter:image', content: 'https://eslint-plugin-command.antfu.me/og.png' }], 131 | ['meta', { name: 'viewport', content: 'width=device-width, initial-scale=1.0, viewport-fit=cover' }], 132 | ], 133 | }) 134 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/style.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Customize default theme styling by overriding CSS variables: 3 | * https://github.com/vuejs/vitepress/blob/main/src/client/theme-default/styles/vars.css 4 | */ 5 | 6 | /** 7 | * Colors 8 | * 9 | * Each colors have exact same color scale system with 3 levels of solid 10 | * colors with different brightness, and 1 soft color. 11 | * 12 | * - `XXX-1`: The most solid color used mainly for colored text. It must 13 | * satisfy the contrast ratio against when used on top of `XXX-soft`. 14 | * 15 | * - `XXX-2`: The color used mainly for hover state of the button. 16 | * 17 | * - `XXX-3`: The color for solid background, such as bg color of the button. 18 | * It must satisfy the contrast ratio with pure white (#ffffff) text on 19 | * top of it. 20 | * 21 | * - `XXX-soft`: The color used for subtle background such as custom container 22 | * or badges. It must satisfy the contrast ratio when putting `XXX-1` colors 23 | * on top of it. 24 | * 25 | * The soft color must be semi transparent alpha channel. This is crucial 26 | * because it allows adding multiple "soft" colors on top of each other 27 | * to create a accent, such as when having inline code block inside 28 | * custom containers. 29 | * 30 | * - `default`: The color used purely for subtle indication without any 31 | * special meanings attched to it such as bg color for menu hover state. 32 | * 33 | * - `brand`: Used for primary brand colors, such as link text, button with 34 | * brand theme, etc. 35 | * 36 | * - `tip`: Used to indicate useful information. The default theme uses the 37 | * brand color for this by default. 38 | * 39 | * - `warning`: Used to indicate warning to the users. Used in custom 40 | * container, badges, etc. 41 | * 42 | * - `danger`: Used to show error, or dangerous message to the users. Used 43 | * in custom container, badges, etc. 44 | * -------------------------------------------------------------------------- */ 45 | 46 | :root { 47 | --vp-c-brand-1: #4B32C3; 48 | --vp-c-brand-2: #8080F2; 49 | --vp-c-brand-3: #9595ed; 50 | --vp-c-brand-soft: #8080F250; 51 | --vp-c-bg-alt: #f9f9f9; 52 | 53 | --vp-c-yellow-1: #edb253; 54 | --vp-c-yellow-2: #daac61; 55 | --vp-c-yellow-3: #e6cc78; 56 | 57 | --vp-c-red-1: #b34e52; 58 | --vp-c-red-2: #bc6063; 59 | --vp-c-red-3: #cb7676; 60 | } 61 | 62 | .dark { 63 | --vp-c-brand-1: #9595ed; 64 | --vp-c-brand-2: #8080F2; 65 | --vp-c-brand-3: #4B32C3; 66 | --vp-c-brand-soft: #9595ed50; 67 | --vp-c-bg-alt: #18181b; 68 | 69 | --vp-c-yellow-1: #e6cc78; 70 | --vp-c-yellow-2: #daac61; 71 | --vp-c-yellow-3: #edb253; 72 | 73 | --vp-c-red-1: #cb7676; 74 | --vp-c-red-2: #bc6063; 75 | --vp-c-red-3: #b34e52; 76 | } 77 | 78 | :root { 79 | --vp-c-default-1: var(--vp-c-gray-1); 80 | --vp-c-default-2: var(--vp-c-gray-2); 81 | --vp-c-default-3: var(--vp-c-gray-3); 82 | --vp-c-default-soft: var(--vp-c-gray-soft); 83 | 84 | --vp-c-tip-1: var(--vp-c-brand-1); 85 | --vp-c-tip-2: var(--vp-c-brand-2); 86 | --vp-c-tip-3: var(--vp-c-brand-3); 87 | --vp-c-tip-soft: var(--vp-c-brand-soft); 88 | 89 | --vp-c-warning-1: var(--vp-c-yellow-1); 90 | --vp-c-warning-2: var(--vp-c-yellow-2); 91 | --vp-c-warning-3: var(--vp-c-yellow-3); 92 | --vp-c-warning-soft: var(--vp-c-yellow-soft); 93 | 94 | --vp-c-danger-1: var(--vp-c-red-1); 95 | --vp-c-danger-2: var(--vp-c-red-2); 96 | --vp-c-danger-3: var(--vp-c-red-3); 97 | --vp-c-danger-soft: var(--vp-c-red-soft); 98 | } 99 | 100 | :root { 101 | --vp-c-text-1: rgba(42, 40, 47); 102 | --vp-c-text-2: rgba(42, 40, 47, 0.78); 103 | --vp-c-text-3: rgba(42, 40, 47, 0.56); 104 | --black-text-1: rgba(42, 40, 47); 105 | } 106 | 107 | .dark { 108 | --vp-c-text-1: rgba(255, 255, 245, 0.86); 109 | --vp-c-text-2: rgba(235, 235, 245, 0.6); 110 | --vp-c-text-3: rgba(235, 235, 245, 0.38); 111 | } 112 | 113 | /** 114 | * Component: Button 115 | * -------------------------------------------------------------------------- */ 116 | 117 | :root { 118 | --vp-button-brand-border: transparent; 119 | --vp-button-brand-text: var(--vp-c-white); 120 | --vp-button-brand-bg: var(--vp-c-brand-1); 121 | --vp-button-brand-hover-border: transparent; 122 | --vp-button-brand-hover-text: var(--vp-c-white); 123 | --vp-button-brand-hover-bg: var(--vp-c-brand-2); 124 | --vp-button-brand-active-border: transparent; 125 | --vp-button-brand-active-text: var(--vp-c-white); 126 | --vp-button-brand-active-bg: var(--vp-c-brand-1); 127 | } 128 | 129 | .dark { 130 | --vp-button-brand-text: var(--black-text-1); 131 | --vp-button-brand-bg: var(--vp-c-brand-2); 132 | --vp-button-brand-hover-text: var(--black-text-1); 133 | --vp-button-brand-hover-bg: var(--vp-c-brand-1); 134 | --vp-button-brand-active-text: var(--black-text-1); 135 | --vp-button-brand-active-bg: var(--vp-c-brand-3); 136 | } 137 | 138 | /** 139 | * Component: Home 140 | * -------------------------------------------------------------------------- */ 141 | 142 | :root { 143 | --vp-home-hero-name-color: var(--vp-c-brand-1); 144 | } 145 | 146 | @media (min-width: 640px) { 147 | :root { 148 | --vp-home-hero-image-filter: blur(56px); 149 | } 150 | } 151 | 152 | @media (min-width: 960px) { 153 | :root { 154 | --vp-home-hero-image-filter: blur(72px); 155 | } 156 | } 157 | 158 | /** 159 | * Component: Custom Block 160 | * -------------------------------------------------------------------------- */ 161 | 162 | :root { 163 | --vp-custom-block-tip-border: transparent; 164 | --vp-custom-block-tip-text: var(--vp-c-text-1); 165 | --vp-custom-block-tip-bg: var(--vp-c-brand-soft); 166 | --vp-custom-block-tip-code-bg: var(--vp-c-brand-soft); 167 | } 168 | 169 | /** 170 | * Component: Algolia 171 | * -------------------------------------------------------------------------- */ 172 | 173 | .DocSearch { 174 | --docsearch-primary-color: var(--vp-c-brand-1) !important; 175 | } 176 | -------------------------------------------------------------------------------- /src/context.ts: -------------------------------------------------------------------------------- 1 | import type { TraverseVisitor } from './traverse' 2 | import type { Command, CommandReportDescriptor, CommandReportErrorCauseDescriptor, FindNodeOptions, Linter, MessageIds, RuleOptions, Tree } from './types' 3 | import { SKIP, STOP, traverse } from './traverse' 4 | 5 | export class CommandContext { 6 | /** 7 | * The ESLint RuleContext 8 | */ 9 | readonly context: Linter.RuleContext 10 | /** 11 | * The comment node that triggered the command 12 | */ 13 | readonly comment: Tree.Comment 14 | /** 15 | * Command that triggered the context 16 | */ 17 | readonly command: Command 18 | /** 19 | * Alias for `this.context.sourceCode` 20 | */ 21 | readonly source: Linter.SourceCode 22 | /** 23 | * Regexp matches 24 | */ 25 | readonly matches: RegExpMatchArray 26 | 27 | constructor( 28 | context: Linter.RuleContext, 29 | comment: Tree.Comment, 30 | command: Command, 31 | matches: RegExpMatchArray, 32 | ) { 33 | this.context = context 34 | this.comment = comment 35 | this.command = command 36 | this.source = context.sourceCode 37 | this.matches = matches 38 | } 39 | 40 | /** 41 | * A shorthand of `this.context.sourceCode.getText(node)` 42 | * 43 | * When `node` is `null` or `undefined`, it returns an empty string 44 | */ 45 | getTextOf(node?: Tree.Node | Tree.Token | Tree.Range | null) { 46 | if (!node) 47 | return '' 48 | if (Array.isArray(node)) 49 | return this.context.sourceCode.text.slice(node[0], node[1]) 50 | return this.context.sourceCode.getText(node) 51 | } 52 | 53 | /** 54 | * Report an ESLint error on the triggering comment, without fix 55 | */ 56 | reportError( 57 | message: string, 58 | ...causes: CommandReportErrorCauseDescriptor[] 59 | ) { 60 | this.context.report({ 61 | loc: this.comment.loc, 62 | messageId: 'command-error', 63 | data: { 64 | command: this.command.name, 65 | message, 66 | }, 67 | }) 68 | for (const cause of causes) { 69 | const { message, ...pos } = cause 70 | this.context.report({ 71 | ...pos, 72 | messageId: 'command-error-cause', 73 | data: { 74 | command: this.command.name, 75 | message, 76 | }, 77 | }, 78 | ) 79 | } 80 | } 81 | 82 | /** 83 | * Report an ESLint error. 84 | * Different from normal `context.report` as that it requires `message` instead of `messageId`. 85 | */ 86 | report(descriptor: CommandReportDescriptor): void { 87 | const { message, ...report } = descriptor 88 | const { comment, source } = this 89 | 90 | if (report.nodes) { 91 | report.loc ||= { 92 | start: report.nodes[0].loc.start, 93 | end: report.nodes[report.nodes.length - 1].loc.end, 94 | } 95 | } 96 | 97 | this.context.report({ 98 | ...report as any, 99 | messageId: 'command-fix', 100 | data: { 101 | command: this.command.name, 102 | message, 103 | ...report.data, 104 | }, 105 | * fix(fixer) { 106 | if (report.fix) { 107 | const result = report.fix(fixer) 108 | // if is generator 109 | if (result && 'next' in result) { 110 | for (const fix of result) 111 | yield fix 112 | } 113 | else if (result) { 114 | yield result 115 | } 116 | } 117 | 118 | if (report.removeComment !== false) { 119 | const lastToken = source.getTokenBefore( 120 | comment, 121 | { includeComments: true }, 122 | )?.range[1] 123 | let rangeStart = source.getIndexFromLoc({ 124 | line: comment.loc.start.line, 125 | column: 0, 126 | }) - 1 127 | if (lastToken != null) 128 | rangeStart = Math.max(0, lastToken, rangeStart) 129 | let rangeEnd = comment.range[1] 130 | // The first line 131 | if (comment.loc.start.line === 1) { 132 | if (source.text[rangeEnd] === '\n') 133 | rangeEnd += 1 134 | } 135 | yield fixer.removeRange([rangeStart, rangeEnd]) 136 | } 137 | }, 138 | }) 139 | } 140 | 141 | /** 142 | * Utility to traverse the AST starting from a node 143 | */ 144 | traverse(node: Tree.Node, cb: TraverseVisitor): boolean { 145 | return traverse(this.context, node, cb) 146 | } 147 | 148 | /** 149 | * Find specific node within the line below the comment 150 | * 151 | * Override 1: Find the fist node of a specific type with rest parameters 152 | */ 153 | findNodeBelow(...keys: (T | `${T}`)[]): Extract | undefined 154 | /** 155 | * Find specific node within the line below the comment 156 | * 157 | * Override 2: Find the first matched node with a custom filter function 158 | */ 159 | findNodeBelow(filter: ((node: Tree.Node) => boolean)): Tree.Node | undefined 160 | /** 161 | * Find specific node within the line below the comment 162 | * 163 | * Override 3: Find all match with full options (returns an array) 164 | */ 165 | findNodeBelow(options: FindNodeOptions): Extract[] | undefined 166 | /** 167 | * Find specific node within the line below the comment 168 | * 169 | * Override 4: Find one match with full options 170 | */ 171 | findNodeBelow(options: FindNodeOptions): Extract | undefined 172 | // Implementation 173 | findNodeBelow(...args: any): any { 174 | let options: FindNodeOptions 175 | 176 | if (typeof args[0] === 'string') 177 | options = { types: args as Tree.Node['type'][] } 178 | else if (typeof args[0] === 'function') 179 | options = { filter: args[0] } 180 | else 181 | options = args[0] 182 | 183 | const { 184 | shallow = false, 185 | findAll = false, 186 | } = options 187 | 188 | const tokenBelow = this.context.sourceCode.getTokenAfter(this.comment) 189 | if (!tokenBelow) 190 | return 191 | const nodeBelow = this.context.sourceCode.getNodeByRangeIndex(tokenBelow.range[1]) 192 | if (!nodeBelow) 193 | return 194 | 195 | const result: any[] = [] 196 | let target = nodeBelow 197 | while (target.parent && target.parent.loc.start.line === nodeBelow.loc.start.line) 198 | target = target.parent 199 | 200 | const filter = options.filter 201 | ? options.filter 202 | : (node: Tree.Node) => options.types!.includes(node.type) 203 | 204 | this.traverse(target, (path) => { 205 | if (path.node.loc.start.line !== nodeBelow.loc.start.line) 206 | return STOP 207 | if (filter(path.node)) { 208 | result.push(path.node) 209 | if (!findAll) 210 | return STOP 211 | if (shallow) 212 | return SKIP 213 | } 214 | }) 215 | 216 | return findAll 217 | ? result 218 | : result[0] 219 | } 220 | 221 | /** 222 | * Get the parent block of the triggering comment 223 | */ 224 | getParentBlock(): Tree.BlockStatement | Tree.Program { 225 | const node = this.source.getNodeByRangeIndex(this.comment.range[0]) 226 | if (node?.type === 'BlockStatement') { 227 | if (this.source.getCommentsInside(node).includes(this.comment)) 228 | return node 229 | } 230 | if (node) 231 | console.warn(`Expected BlockStatement, got ${node.type}. This is probably an internal bug.`) 232 | return this.source.ast 233 | } 234 | 235 | /** 236 | * Get indent string of a specific line 237 | */ 238 | getIndentOfLine(line: number): string { 239 | const lineStr = this.source.getLines()[line - 1] || '' 240 | const match = lineStr.match(/^\s*/) 241 | return match ? match[0] : '' 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/commands/keep-sorted.ts: -------------------------------------------------------------------------------- 1 | import type { Command, CommandContext, Tree } from '../types' 2 | 3 | export interface KeepSortedInlineOptions { 4 | key?: string 5 | keys?: string[] 6 | } 7 | 8 | const reLine = /^[/@:]\s*(?:keep-sorted|sorted)\s*(\{.*\})?$/ 9 | const reBlock = /(?:\b|\s)@keep-sorted\s*(\{.*\})?(?:\b|\s|$)/ 10 | 11 | export const keepSorted: Command = { 12 | name: 'keep-sorted', 13 | commentType: 'both', 14 | match: comment => comment.value.trim().match(comment.type === 'Line' ? reLine : reBlock), 15 | action(ctx) { 16 | const optionsRaw = ctx.matches[1] || '{}' 17 | let options: KeepSortedInlineOptions | null = null 18 | try { 19 | options = JSON.parse(optionsRaw) 20 | } 21 | catch { 22 | return ctx.reportError(`Failed to parse options: ${optionsRaw}`) 23 | } 24 | 25 | let node = ctx.findNodeBelow( 26 | 'ObjectExpression', 27 | 'ObjectPattern', 28 | 'ArrayExpression', 29 | 'TSInterfaceBody', 30 | 'TSTypeLiteral', 31 | 'TSSatisfiesExpression', 32 | ) || ctx.findNodeBelow( 33 | 'ExportNamedDeclaration', 34 | 'TSInterfaceDeclaration', 35 | 'VariableDeclaration', 36 | ) 37 | 38 | if (node?.type === 'TSInterfaceDeclaration') { 39 | node = node.body 40 | } 41 | 42 | if (node?.type === 'VariableDeclaration') { 43 | const dec = node.declarations[0] 44 | if (!dec) { 45 | node = undefined 46 | } 47 | else if (dec.id.type === 'ObjectPattern') { 48 | node = dec.id 49 | } 50 | else { 51 | node = Array.isArray(dec.init) ? dec.init[0] : dec.init 52 | if (node && node.type !== 'ObjectExpression' && node.type !== 'ArrayExpression' && node.type !== 'TSSatisfiesExpression') 53 | node = undefined 54 | } 55 | } 56 | 57 | // Unwrap TSSatisfiesExpression 58 | if (node?.type === 'TSSatisfiesExpression') { 59 | if (node.expression.type !== 'ArrayExpression' && node.expression.type !== 'ObjectExpression') { 60 | node = undefined 61 | } 62 | else { 63 | node = node.expression 64 | } 65 | } 66 | 67 | if (!node) 68 | return ctx.reportError('Unable to find object/array/interface to sort') 69 | 70 | const objectKeys = [ 71 | options?.key, 72 | ...(options?.keys || []), 73 | ].filter(x => x != null) as string[] 74 | 75 | if (objectKeys.length > 0 && node.type !== 'ArrayExpression' && node.type !== 'ObjectExpression') 76 | return ctx.reportError(`Only arrays and objects can be sorted by keys, but got ${node.type}`) 77 | 78 | if (node.type === 'ObjectExpression') { 79 | return sort( 80 | ctx, 81 | node, 82 | node.properties.filter(Boolean) as (Tree.ObjectExpression | Tree.Property)[], 83 | (prop) => { 84 | if (objectKeys.length) { 85 | if (prop.type === 'Property' && prop.value.type === 'ObjectExpression') { 86 | const objectProp = prop.value 87 | return objectKeys.map((key) => { 88 | for (const innerProp of objectProp.properties) { 89 | if (innerProp.type === 'Property' && getString(innerProp.key) === key) { 90 | return getString(innerProp.value) 91 | } 92 | } 93 | return null 94 | }) 95 | } 96 | } 97 | else if (prop.type === 'Property') { 98 | return getString(prop.key) 99 | } 100 | return null 101 | }, 102 | ) 103 | } 104 | else if (node.type === 'ObjectPattern') { 105 | sort( 106 | ctx, 107 | node, 108 | node.properties, 109 | (prop) => { 110 | if (prop.type === 'Property') 111 | return getString(prop.key) 112 | return null 113 | }, 114 | ) 115 | } 116 | else if (node.type === 'ArrayExpression') { 117 | return sort( 118 | ctx, 119 | node, 120 | node.elements.filter(Boolean) as (Tree.Expression | Tree.SpreadElement)[], 121 | (element) => { 122 | if (objectKeys.length) { 123 | if (element.type === 'ObjectExpression') { 124 | return objectKeys 125 | .map((key) => { 126 | for (const prop of element.properties) { 127 | if (prop.type === 'Property' && getString(prop.key) === key) 128 | return getString(prop.value) 129 | } 130 | return null 131 | }) 132 | } 133 | else { 134 | return null 135 | } 136 | } 137 | return getString(element) 138 | }, 139 | ) 140 | } 141 | else if (node.type === 'TSInterfaceBody') { 142 | return sort( 143 | ctx, 144 | node, 145 | node.body, 146 | (prop) => { 147 | if (prop.type === 'TSPropertySignature') 148 | return getString(prop.key) 149 | return null 150 | }, 151 | false, 152 | ) 153 | } 154 | else if (node.type === 'TSTypeLiteral') { 155 | return sort( 156 | ctx, 157 | node, 158 | node.members, 159 | (prop) => { 160 | if (prop.type === 'TSPropertySignature') 161 | return getString(prop.key) 162 | return null 163 | }, 164 | false, 165 | ) 166 | } 167 | else if (node.type === 'ExportNamedDeclaration') { 168 | return sort( 169 | ctx, 170 | node, 171 | node.specifiers, 172 | (prop) => { 173 | if (prop.type === 'ExportSpecifier') 174 | return getString(prop.exported) 175 | return null 176 | }, 177 | ) 178 | } 179 | else { 180 | return false 181 | } 182 | }, 183 | } 184 | 185 | function sort( 186 | ctx: CommandContext, 187 | node: Tree.Node, 188 | list: T[], 189 | getName: (node: T) => string | (string | null)[] | null, 190 | insertComma = true, 191 | ): false | void { 192 | const firstToken = ctx.context.sourceCode.getFirstToken(node)! 193 | const lastToken = ctx.context.sourceCode.getLastToken(node)! 194 | if (!firstToken || !lastToken) 195 | return ctx.reportError('Unable to find object/array/interface to sort') 196 | 197 | if (list.length < 2) 198 | return false 199 | 200 | const reordered = list.slice() 201 | const ranges = new Map() 202 | const names = new Map() 203 | 204 | const rangeStart = Math.max( 205 | firstToken.range[1], 206 | ctx.context.sourceCode.getIndexFromLoc({ 207 | line: list[0].loc.start.line, 208 | column: 0, 209 | }), 210 | ) 211 | 212 | let rangeEnd = rangeStart 213 | for (let i = 0; i < list.length; i++) { 214 | const item = list[i] 215 | let name = getName(item) 216 | if (typeof name === 'string') 217 | name = [name] 218 | names.set(item, name) 219 | 220 | let lastRange = item.range[1] 221 | const nextToken = ctx.context.sourceCode.getTokenAfter(item) 222 | if (nextToken?.type === 'Punctuator' && nextToken.value === ',') 223 | lastRange = nextToken.range[1] 224 | const nextChar = ctx.context.sourceCode.getText()[lastRange] 225 | 226 | // Insert comma if it's the last item without a comma 227 | let text = ctx.getTextOf([rangeEnd, lastRange]) 228 | if (nextToken === lastToken && insertComma) 229 | text += ',' 230 | 231 | // Include subsequent newlines 232 | if (nextChar === '\n') { 233 | lastRange++ 234 | text += '\n' 235 | } 236 | 237 | ranges.set(item, [rangeEnd, lastRange, text]) 238 | rangeEnd = lastRange 239 | } 240 | 241 | const segments: [number, number][] = [] 242 | let segmentStart: number = -1 243 | for (let i = 0; i < list.length; i++) { 244 | if (names.get(list[i]) == null) { 245 | if (segmentStart > -1) 246 | segments.push([segmentStart, i]) 247 | segmentStart = -1 248 | } 249 | else { 250 | if (segmentStart === -1) 251 | segmentStart = i 252 | } 253 | } 254 | if (segmentStart > -1 && segmentStart !== list.length - 1) 255 | segments.push([segmentStart, list.length]) 256 | 257 | for (const [start, end] of segments) { 258 | reordered.splice( 259 | start, 260 | end - start, 261 | ...reordered 262 | .slice(start, end) 263 | .sort((a, b) => { 264 | const nameA: (string | null)[] = names.get(a)! 265 | const nameB: (string | null)[] = names.get(b)! 266 | 267 | const length = Math.max(nameA.length, nameB.length) 268 | for (let i = 0; i < length; i++) { 269 | const a = nameA[i] 270 | const b = nameB[i] 271 | if (a == null || b == null || a === b) 272 | continue 273 | return a.localeCompare(b, 'en', { numeric: true }) 274 | } 275 | return 0 276 | }), 277 | ) 278 | } 279 | 280 | const changed = reordered.some((prop, i) => prop !== list[i]) 281 | if (!changed) 282 | return false 283 | 284 | const newContent = reordered 285 | .map(i => ranges.get(i)![2]) 286 | .join('') 287 | 288 | // console.log({ 289 | // reordered, 290 | // newContent, 291 | // oldContent: ctx.context.sourceCode.text.slice(rangeStart, rangeEnd), 292 | // }) 293 | 294 | ctx.report({ 295 | node, 296 | message: 'Keep sorted', 297 | removeComment: false, 298 | fix(fixer) { 299 | return fixer.replaceTextRange([rangeStart, rangeEnd], newContent) 300 | }, 301 | }) 302 | } 303 | 304 | function getString(node: Tree.Node): string | null { 305 | if (node.type === 'Identifier') 306 | return node.name 307 | if (node.type === 'Literal') 308 | return String(node.raw) 309 | return null 310 | } 311 | -------------------------------------------------------------------------------- /src/commands/keep-sorted.test.ts: -------------------------------------------------------------------------------- 1 | import { $, run } from './_test-utils' 2 | import { keepSorted as command } from './keep-sorted' 3 | 4 | run( 5 | command, 6 | // Already sorted 7 | $` 8 | // @keep-sorted 9 | export const arr = [ 10 | 'apple', 11 | 'bar', 12 | 'foo', 13 | ] 14 | `, 15 | // multi interfaces without export 16 | $` 17 | // @keep-sorted 18 | interface A { 19 | foo: number 20 | } 21 | // @keep-sorted 22 | interface B { 23 | foo: number 24 | } 25 | `, 26 | // multi declares without export 27 | $` 28 | // @keep-sorted 29 | const arr1 = [ 30 | { index: 0, name: 'foo' }, 31 | ] 32 | // @keep-sorted 33 | const arr2 = [ 34 | { index: 0, name: 'foo' }, 35 | ] 36 | `, 37 | // Object property 38 | { 39 | code: $` 40 | // @keep-sorted 41 | export const obj = { 42 | foo, 43 | bar: () => {}, 44 | apple: 1, 45 | } 46 | `, 47 | output: $` 48 | // @keep-sorted 49 | export const obj = { 50 | apple: 1, 51 | bar: () => {}, 52 | foo, 53 | } 54 | `, 55 | errors: ['command-fix'], 56 | }, 57 | // Some object property keys are string 58 | { 59 | code: $` 60 | // @keep-sorted 61 | export const obj = { 62 | foo, 63 | 'bar': () => {}, 64 | apple: 1, 65 | } 66 | `, 67 | output: $` 68 | // @keep-sorted 69 | export const obj = { 70 | 'bar': () => {}, 71 | apple: 1, 72 | foo, 73 | } 74 | `, 75 | errors: ['command-fix'], 76 | }, 77 | // All object property keys are string 78 | { 79 | code: $` 80 | // @keep-sorted 81 | export const rules = { 82 | 'block-scoped-var': 'error', 83 | 'array-callback-return': 'error', 84 | 'constructor-super': 'error', 85 | 'default-case-last': 'error', 86 | } 87 | `, 88 | output: $` 89 | // @keep-sorted 90 | export const rules = { 91 | 'array-callback-return': 'error', 92 | 'block-scoped-var': 'error', 93 | 'constructor-super': 'error', 94 | 'default-case-last': 'error', 95 | } 96 | `, 97 | errors: ['command-fix'], 98 | }, 99 | // Array elements 100 | { 101 | code: $` 102 | // @keep-sorted 103 | export const arr = [ 104 | 'foo', 105 | 'bar', 106 | 'apple', 107 | ] 108 | `, 109 | output: $` 110 | // @keep-sorted 111 | export const arr = [ 112 | 'apple', 113 | 'bar', 114 | 'foo', 115 | ] 116 | `, 117 | errors: ['command-fix'], 118 | }, 119 | // Type interface members 120 | { 121 | code: $` 122 | // @keep-sorted 123 | export interface Path { 124 | parent: TSESTree.Node | null 125 | parentPath: Path | null 126 | parentKey: string | null 127 | node: TSESTree.Node 128 | } 129 | `, 130 | output: $` 131 | // @keep-sorted 132 | export interface Path { 133 | node: TSESTree.Node 134 | parent: TSESTree.Node | null 135 | parentKey: string | null 136 | parentPath: Path | null 137 | } 138 | `, 139 | errors: ['command-fix'], 140 | }, 141 | { 142 | description: 'Type members', 143 | code: $` 144 | // @keep-sorted 145 | export type Path = { 146 | parent: TSESTree.Node | null 147 | parentPath: Path | null 148 | parentKey: string | null 149 | node: TSESTree.Node 150 | } 151 | `, 152 | output: $` 153 | // @keep-sorted 154 | export type Path = { 155 | node: TSESTree.Node 156 | parent: TSESTree.Node | null 157 | parentKey: string | null 158 | parentPath: Path | null 159 | } 160 | `, 161 | errors: ['command-fix'], 162 | }, 163 | { 164 | description: 'Function arguments', 165 | code: $` 166 | function foo() { 167 | // @keep-sorted 168 | queue.push({ 169 | parent: null, 170 | node, 171 | parentPath: null, 172 | parentKey: null, 173 | }) 174 | } 175 | `, 176 | output: $` 177 | function foo() { 178 | // @keep-sorted 179 | queue.push({ 180 | node, 181 | parent: null, 182 | parentKey: null, 183 | parentPath: null, 184 | }) 185 | } 186 | `, 187 | errors: ['command-fix'], 188 | }, 189 | { 190 | description: 'Export statement', 191 | code: $` 192 | // @keep-sorted 193 | export { 194 | foo, 195 | bar, 196 | apple, 197 | } 198 | `, 199 | output: $` 200 | // @keep-sorted 201 | export { 202 | apple, 203 | bar, 204 | foo, 205 | } 206 | `, 207 | errors: ['command-fix'], 208 | }, 209 | { 210 | description: 'Export statement without trailing comma', 211 | code: $` 212 | // @keep-sorted 213 | export { 214 | foo, 215 | bar, 216 | apple 217 | } 218 | `, 219 | output: $` 220 | // @keep-sorted 221 | export { 222 | apple, 223 | bar, 224 | foo, 225 | } 226 | `, 227 | errors: ['command-fix'], 228 | }, 229 | { 230 | description: 'Sort array of objects', 231 | code: $` 232 | // @keep-sorted { "keys": ["index", "name"] } 233 | export default [ 234 | { index: 4, name: 'foo' }, 235 | { index: 2, name: 'bar' }, 236 | { index: 2, name: 'apple' }, 237 | { index: 0, name: 'zip' }, 238 | 'foo', 239 | { index: 6, name: 'bar' }, 240 | { index: 3, name: 'foo' }, 241 | ] 242 | `, 243 | output: $` 244 | // @keep-sorted { "keys": ["index", "name"] } 245 | export default [ 246 | { index: 0, name: 'zip' }, 247 | { index: 2, name: 'apple' }, 248 | { index: 2, name: 'bar' }, 249 | { index: 4, name: 'foo' }, 250 | 'foo', 251 | { index: 3, name: 'foo' }, 252 | { index: 6, name: 'bar' }, 253 | ] 254 | `, 255 | errors: ['command-fix'], 256 | }, 257 | { 258 | description: 'Error on invalid JSON', 259 | code: $` 260 | // @keep-sorted { keys: [1, 2, 3] } 261 | export default [ 262 | { index: 4, name: 'foo' }, 263 | { index: 2, name: 'bar' }, 264 | ] 265 | `, 266 | errors: ['command-error'], 267 | }, 268 | { 269 | description: 'Destructuring assignment', 270 | code: $` 271 | // @keep-sorted 272 | const { foo, bar, apple } = obj 273 | `, 274 | output: $` 275 | // @keep-sorted 276 | const { apple, bar, foo, } = obj 277 | `, 278 | errors: ['command-fix'], 279 | }, 280 | { 281 | description: 'Destructuring assignment multiple lines', 282 | code: $` 283 | // @keep-sorted 284 | const { 285 | foo, 286 | bar, 287 | apple, 288 | } = obj 289 | `, 290 | output: $` 291 | // @keep-sorted 292 | const { 293 | apple, 294 | bar, 295 | foo, 296 | } = obj 297 | `, 298 | errors: ['command-fix'], 299 | }, 300 | { 301 | description: 'Destructuring assignment multiple lines without trailing comma', 302 | code: $` 303 | // @keep-sorted 304 | const { 305 | foo, 306 | bar, 307 | apple 308 | } = obj 309 | `, 310 | output: $` 311 | // @keep-sorted 312 | const { 313 | apple, 314 | bar, 315 | foo, 316 | } = obj 317 | `, 318 | errors: ['command-fix'], 319 | }, 320 | { 321 | description: 'Block comment', 322 | code: $` 323 | /** 324 | * Some JSdocs 325 | * 326 | * @keep-sorted 327 | * @description 328 | */ 329 | export const arr = [ 330 | 'foo', 331 | 'bar', 332 | 'apple', 333 | ] 334 | `, 335 | output: $` 336 | /** 337 | * Some JSdocs 338 | * 339 | * @keep-sorted 340 | * @description 341 | */ 342 | export const arr = [ 343 | 'apple', 344 | 'bar', 345 | 'foo', 346 | ] 347 | `, 348 | errors: ['command-fix'], 349 | }, 350 | { 351 | description: 'Inlined array', 352 | code: $` 353 | // @keep-sorted 354 | export const arr = [ 'foo', 'bar', 'apple' ] 355 | `, 356 | output: $` 357 | // @keep-sorted 358 | export const arr = [ 'apple', 'bar', 'foo', ] 359 | `, 360 | }, 361 | { 362 | description: 'Array without trailing comma', 363 | code: $` 364 | // @keep-sorted 365 | export const arr = [ 366 | 'foo', 367 | 'bar', 368 | 'apple' 369 | ] 370 | `, 371 | output: $` 372 | // @keep-sorted 373 | export const arr = [ 374 | 'apple', 375 | 'bar', 376 | 'foo', 377 | ] 378 | `, 379 | }, 380 | { 381 | description: 'With satisfies', 382 | code: $` 383 | // @keep-sorted 384 | const a = { 385 | foo, 386 | bar, 387 | apple 388 | } satisfies Record 389 | `, 390 | output: $` 391 | // @keep-sorted 392 | const a = { 393 | apple, 394 | bar, 395 | foo, 396 | } satisfies Record 397 | `, 398 | errors: ['command-fix'], 399 | }, 400 | { 401 | description: 'With satisfies', 402 | code: $` 403 | // @keep-sorted 404 | const a = bar satisfies Record 405 | `, 406 | errors: ['command-error'], 407 | }, 408 | { 409 | description: 'Sort object of objects', 410 | code: $` 411 | /// keep-sorted { "keys": ["index","label"] } 412 | const obj = { 413 | a: { index: 3, label: 'banana' }, 414 | b: { index: 2, label: 'cherry' }, 415 | c: { index: 2, label: 'apple' }, 416 | d: { index: 1, label: 'berry' } 417 | } 418 | `, 419 | output: $` 420 | /// keep-sorted { "keys": ["index","label"] } 421 | const obj = { 422 | d: { index: 1, label: 'berry' }, 423 | c: { index: 2, label: 'apple' }, 424 | b: { index: 2, label: 'cherry' }, 425 | a: { index: 3, label: 'banana' }, 426 | } 427 | `, 428 | errors: ['command-fix'], 429 | }, 430 | ) 431 | --------------------------------------------------------------------------------