├── .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 |
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 |
--------------------------------------------------------------------------------