ESLint plugins

Run ESLint plugins in bun lint, and write your own rules

bun lint runs ESLint plugins, both from npm and your own. Rules get the same context, sourceCode, scopes, and fixers as in ESLint.

This plugin has one rule, which reports TODO comments:

lint/plugin.js
const noTodo = {
  meta: {
    type: "suggestion",
    messages: { todo: "Open an issue instead of leaving a TODO." },
    schema: [],
  },
  create(context) {
    const { sourceCode } = context;
    return {
      Program() {
        for (const comment of sourceCode.getAllComments()) {
          if (comment.value.includes("TODO")) {
            context.report({ loc: comment.loc, messageId: "todo" });
          }
        }
      },
    };
  },
};

export default {
  meta: { name: "local" },
  rules: { "no-todo": noTodo },
};

Load it in bunfig.toml. Bun's default rules stay on, and the plugin's rules are added to them.

bunfig.toml
[lint]
plugins = ["./lint/plugin.js"]

[lint.rules]
"local/no-todo" = "warn"
terminal
bun lint
1 | // TODO: handle errors
    ^
warn: Open an issue instead of leaving a TODO.  local/no-todo
   at src/index.js:1:1

1 problem (0 errors, 1 warning)
Linted 3 files [15.40ms]

You don't need the eslint package. To learn the API, read ESLint's guide to plugins.

If you have an ESLint config or an oxlint config, load it there as usual:

import local from "./lint/plugin.js";

export default [
  {
    plugins: { local },
    rules: { "local/no-todo": "warn" },
  },
];

plugins in bunfig.toml works next to those files too, and next to an .eslintrc.*. A plugin can only be loaded once under a name, so load it in one place.

Plugins from npm#

Install the package and name it. ESLint's short names work, so "perfectionist" is eslint-plugin-perfectionist.

terminal
bun add -d eslint-plugin-perfectionist
bunfig.toml
[lint]
plugins = ["perfectionist"]

[lint.rules]
"perfectionist/sort-imports" = "error"

A path is relative to bunfig.toml. A rule's prefix is the plugin's own name, meta.name, without eslint-plugin-. To use another prefix:

bunfig.toml
[lint]
plugins = [{ name = "sort", specifier = "eslint-plugin-perfectionist" }]

[lint.rules]
"sort/sort-imports" = "error"

You don't load the plugins that are built in, such as react, import, or unicorn. Their rules work without it. Next to an ESLint or oxlint config, naming one turns it on, as plugins in that file does.

A config file can't use the processors or languages of a plugin that is loaded this way. For those, import the plugin in the config file.

For one run, use --plugin. Next to an ESLint config its prefix is the name you give the flag, as in ESLint:

terminal
bun lint --plugin perfectionist --rule 'perfectionist/sort-imports: error'

With an ESLint config, eight plugins run natively: @typescript-eslint, react, react-hooks, import, n, prettier, json, and markdown. Everything else runs from node_modules, including unicorn, jsx-a11y, promise, jest, and vitest. Bun's built-in versions of those follow oxlint, so Bun only uses them with bunfig.toml or an oxlint config.

bun lint --doctor shows which of your rules run natively and which run in JavaScript.

Performance#

JavaScript rules are slower than native ones. bun lint --timing shows how much time they take.

  • Only turn on a plugin's rules for the files that need them. Files without JavaScript rules pay nothing.
  • Put a plugin in its own module. If you define it inside eslint.config.js, every thread has to run the whole config file.

Run the installed package instead#

To run a built-in plugin from node_modules, for example because you pin an older version:

terminal
bun lint --no-native-plugin-rules
bun lint --native-plugin-rules=@typescript-eslint,import # only these two stay native
bunfig.toml
[lint]
nativePluginRules = ["@typescript-eslint", "import"]

ESLint's core rules are always native. Keep @typescript-eslint native if you use type-aware rules, because JavaScript rules don't get types.

nativePluginRules picks which version of a plugin runs. It doesn't turn rules on or off. To do that, see lint.rules.

prettier/prettier#

prettier/prettier from eslint-plugin-prettier is native. It compares each file with the output of bun fmt and never starts Prettier.

Bun falls back to the installed package, and says why, when it can't guarantee the same output as your Prettier:

  • You have a version other than Prettier 3.9.
  • You use a Prettier plugin that bun fmt doesn't have.
  • The file isn't JavaScript or TypeScript.

Without Prettier, use bun/format. It checks files against bun fmt.

Other languages#

In an ESLint config, a file can have a language from a plugin. Bun supports the languages of two plugins natively:

  • @eslint/json: json/json, json/jsonc, and json/json5, with its rules.
  • @eslint/markdown: markdown/commonmark and markdown/gfm, with its rules.

Bun parses these files with its own parsers and runs its own versions of the rules, so eslint doesn't have to be installed.

eslint.config.js
import json from "@eslint/json";

export default [
  {
    files: ["**/*.json"],
    plugins: { json },
    language: "json/json",
    rules: { "json/no-duplicate-keys": "error" },
  },
];

Comments such as // eslint-disable-next-line work in JSONC and JSON5, as they do in ESLint. A file with a syntax error gets a single error and no rules run. ESLint does the same, but the wording of the error is Bun's.

eslint.config.js
import markdown from "@eslint/markdown";

export default [
  {
    files: ["**/*.md"],
    plugins: { markdown },
    language: "markdown/gfm",
    languageOptions: { frontmatter: "yaml" },
    rules: { "markdown/no-missing-label-refs": "error" },
  },
];

markdown.configs.recommended works too, and so do languageOptions.frontmatter, languageOptions.math, and comments such as <!-- eslint-disable-next-line markdown/no-html -->.

The plugin parses Markdown with micromark. Bun uses the parser behind Bun.markdown. The two agree on almost every document. In the few places where micromark deviates from the CommonMark and GFM specs, Bun follows the specs. For example, <a href=b/c> is an HTML tag to Bun and plain text to the plugin.

Bun hands every other file to the eslint package in your project, so keep eslint 8.57 or later installed if you use:

  • A language from another plugin, such as css/css.
  • A JSON or Markdown rule that isn't built in, such as one of your own. The whole file then goes to eslint.
  • A parser for another file type, such as vue-eslint-parser, svelte-eslint-parser, or yaml-eslint-parser.
  • eslint-plugin-html.

You still run bun lint, and these files show up in the same report. bun lint --doctor shows how many files go to eslint.

Processors#

Processors work. This config lints the code blocks in Markdown files:

eslint.config.js
import markdown from "@eslint/markdown";

export default [
  { files: ["**/*.md"], plugins: { markdown }, processor: "markdown/markdown" },
  { files: ["**/*.md/*.js"], rules: { "no-var": "error" } },
];

Notes#

  • JavaScript rules don't get types. parserServices.program is null, even with --type-aware. A rule that calls typescript-eslint's getParserServices() doesn't run, and bun lint exits with code 2.
  • Plugins run on several threads. Each thread imports the plugin, so module-level state is per thread, not per run.
  • A renamed built-in plugin runs in JavaScript. For example, @antfu/eslint-config registers @typescript-eslint as ts.
  • A rule that throws ends the run with exit code 2. With an oxlint config, Bun reports the error for that file and continues.
  • A report at loc: { line: 0, column: 0 } is at 1:1. /* eslint-disable */ at the start of the file disables it, except with an ESLint or oxlint config, where neither tool lets a comment disable it. With an ESLint config, the stylish, json, and unix formats print 0:1, like ESLint. With an oxlint config, a loc outside the file that oxlint rejects makes the rule throw, as it does in oxlint.
  • Type-aware rules skip files that have a processor.