Oxlint JS Plugins Preview
Earlier this year we asked for input from the community to inform design for Oxlint support for custom JS plugins. Today, we are pleased to announce the result of many months of research, prototyping, and finally building:
Oxlint supports plugins written in JS!
Key features
- ESLint-compatible plugin API. Oxlint will be able to run many existing ESLint plugins without modification.
- An alternative API which is slightly different, and unlocks better performance.
What this is and isn't
This preview release is just the beginning. It is important to note that:
- This initial release does not implement all of ESLint's plugin API.
- Performance is good, but it's going to get way better - we have many optimizations in the pipeline.
The most commonly-used APIs for code-checking rules are implemented, so many existing ESLint rules will already work. But token-related APIs are absent, so stylistic (formatting) rules will not.
We invite users to take it for a spin, give feedback, and inform our priorities for the next phase of development.
This blog post covers
- How to use it.
- What's coming next.
- Some of the technical details that enable our "have cake and eat it" approach of providing both ESLint compatibility and excellent performance.
Quick Start
Install Oxlint in your project:
pnpm add -D oxlint
Write a custom JS plugin:
// plugin.js
// The simplest rule of all - no debugger
const rule = {
create(context) {
return {
DebuggerStatement(node) {
context.report({
message: "No debugger!",
node,
});
},
};
},
};
const plugin = {
meta: {
name: "best-plugin-ever",
},
rules: {
"no-debugger": rule,
},
};
export default plugin;
Create config file enabling the plugin:
// .oxlintrc.json
{
"jsPlugins": ["./plugin.js"],
"rules": {
"best-plugin-ever/no-debugger": "error"
}
}
Add a file to be linted:
// foo.js
debugger;
Run Oxlint:
pnpm oxlint
Expect to see:
x best-plugin-ever(no-debugger): No debugger!
,-[foo.js:1:1]
1 | debugger;
: ^^^^^^^^^
`----
For further details on authoring plugins, see the docs.
Alternative API
Oxlint also offers a slightly different API which unlocks better performance.
This alternative API produces plugins which are compatible with ESLint, as well as Oxlint.
Example rule that flags files containing more than 5 class declarations:
ESLint version
const rule = {
create(context) {
let classCount = 0;
return {
ClassDeclaration(node) {
classCount++;
if (classCount === 6) {
context.report({ message: "Too many classes", node });
}
},
};
},
};
Alternative API version
import { defineRule } from "oxlint";
const rule = defineRule({
createOnce(context) {
// Define counter variable
let classCount;
return {
before() {
// Reset counter before traversing AST of each file
classCount = 0;
},
// Same as before
ClassDeclaration(node) {
classCount++;
if (classCount === 6) {
context.report({ message: "Too many classes", node });
}
},
};
},
});
The differences
- Wrap the rule object in
defineRule(...)
.
- const rule = {
+ const rule = defineRule({
- Use
createOnce
instead ofcreate
.
- create(context) {
+ createOnce(context) {
- Move any per-file setup from body of
create
intobefore
hook.
- let classCount = 0;
+ let classCount;
return {
+ before() {
+ classCount = 0; // Reset counter
+ },
ClassDeclaration(node) {
classCount++;
if (classCount === 6) {
context.report({ message: "Too many classes", node });
}
},
};
},
});
This is the only significant difference - create
(ESLint's method) is called repeatedly for each file, whereas createOnce
is called once only.
All other APIs behave exactly the same as in ESLint.
The reasons why this alternative API has potential to greatly improve performance are explained in the docs.
Performance
As mentioned above, performance is not yet as good as it can be. Our prototyping work has demonstrated significant speed-gains from various optimizations, which we'll apply in future releases.
However, even without all those optimizations, the cost of adding JS plugins into the mix is already surprisingly low.
Oxlint vs ESLint linting a medium-sized TypeScript project vuejs/core:
Linter | Time |
---|---|
ESLint | 7,030 ms |
ESLint multi-threaded | 4,541 ms |
Oxlint | 582 ms |
Oxlint with custom JS plugin | 584 ms |
Details
- Benchmark repo: https://github.com/camc314/core/tree/c/bench-custom-plugins
- Benchmarked on 2023 MacBook Pro M2 Max
- Bench command:
hyperfine \
--warmup 1 \
--runs 5 \
'pnpm run oxlint' \
'pnpm run oxlint-with-custom-plugin' \
'pnpm run eslint-with-custom-plugin' \
'pnpm run eslint-with-custom-plugin-parallel' -i
In this example, the cost of adding a simple JS plugin to Oxlint is less than 1%, and Oxlint is 8x faster than ESLint, even using ESLint's new multi-threaded runner.
Obviously, more complicated JS plugins, or many of them, will have a higher performance cost - which is why we will be focused in future releases on driving down that cost further.
Features
Oxlint supports most of ESLint's APIs which are typically used in plugins/rules which rely only on AST inspection. That includes most "fix code"-type rules.
It does not yet support token-based APIs, so stylistic (formatting) rules will not work yet.
Supported
- AST traversal
- AST exploration (
node.parent
,context.sourceCode.getAncestors
) - Fixes
- Selectors (ESLint docs)
SourceCode
APIs (e.g.context.sourceCode.getText(node)
)
Not supported yet
- Language server (IDE) support
- Rule options
- Suggestions
- Scope analysis
SourceCode
APIs related to tokens and comments (e.g.context.sourceCode.getTokens(node)
)- Control flow analysis
What's next
Over the next few months, we will be:
1. Filling out the plugin API surface
Aim is to support 100% of ESLint's plugin API surface, so that Oxlint will eventually be able to run any ESLint plugin without modification.
2. Improving performance
Performance is already good, but we have proven during our prototyping many significant performance gains from further optimizations. We will apply them, and make JS plugins in Oxlint run at as close to Rust speed as we can get.
Under the hood
The rest of this post is not necessary to use JS plugins with Oxlint. But if you're interested in the geeky details of how our implementation works, read on...
The big question: To ESLint compat or not to ESLint compat?
The question which we posed to the community earlier this year was whether Oxlint should aim for an ESLint-compatible plugin API or not.
Obviously, an ESLint-compatible interface is ideal in terms of familiarity and ease of migration from ESLint.
However, Oxlint is known for its excellent performance, and compromising that too much would not be desirable.
The main aim of our prototyping work over past few months has been to quantify what is the trade-off between performance and ESLint compatibility, and to investigate if there's a "have cake and eat it" solution which satisfies both - providing an ESLint-compatible API and acceptable performance ("acceptable" here means pretty damn fast!)
We believe that through a combination of different approaches, we've found a way to satisfy both demands.
Alternative API
See explanation in docs of why this API unlocks potential for higher performance.
Raw transfer
Tools like Oxc represent the code of a JS/TS file as an "AST" (abstract syntax tree). ASTs are really big - much much larger than the source code they represent.
Typically, the biggest barrier to performant interop between JS and native languages like Rust is the serialization and deserialization involved in transferring such large data structures between the "two worlds".
The simplest and most common way to move an AST between JS and Rust is: Serialize the AST to JSON, send it across to JS as a string and then "rehydrate" it again with JSON.parse
. But this is extremely slow. Often the cost of these conversions is so high that they massively outweigh the performance gain of using native code in the first place. Other serialization formats are more efficient than JSON, but they still have a sizeable overhead.
We have developed a scheme "raw transfer" which cuts out serialization altogether, by using Rust's native memory layout as the serialization format (more details on how it works here).
"Raw transfer" is the base of the current implementation of JS plugins.
Lazy deserialization
The 2nd biggest enemy of good perf, particularly when running JS across multiple CPU cores in worker threads, is the garbage collector. Every object you create also needs to be destroyed to recover its memory. In JS, this is the job of the garbage collector. JS engines like V8 are highly optimized, but still garbage collection is an expensive process, and GC "steals" CPU resources from the actual workload.
We have prototyped an AST visitor which deserializes the AST lazily, and only deserializes the parts of the AST which actually need to be.
For example, if your lint rule relates to class declarations, this visitor will fly through most of the AST without doing much, and will only create JS objects for ClassDeclaration
AST nodes, which are then passed to the rule's code to process. For the rest of the AST (variable declarations, if
statements, functions, etc) there is no need to create node objects at all.
This has 2 effects:
- Raw transfer cuts the cost of serialization to zero. Laziness dramatically reduces the other side (deserialization) too.
- Greatly reduced garbage collector pressure.
Deno has taken a similar approach, which is explained brilliantly in Marvin Hagemeister's blog post, and Deno lint has a superbly efficient implementation.
However, we've found that it's the combination of lazy deserialization with "raw transfer" which delivers really good performance. Our tests have found that, with both these overheads removed, JS plugins can run at much greater speed.
This optimization is not yet included in current version of JS plugins. We will implement it in a future version.
Try it out!
Please try out JS plugins and report your experience. All feedback - either positive or negative - is gratefully received.
In particular, if you find that Oxlint is lacking some of the APIs you need for your plugins to work, please let us know. We'll be filling in the gaps in the API over the next few months, and will prioritise those for which there's greatest demand.
Happy linting!