Skip to content

Oxlint JS プラグインのプレビュー


今年初めに、コミュニティからの意見を募集しました。これにより、カスタムのJSプラグインに対応するOxlintの設計を決定するための情報を得ました。今日、長年の研究・プロトタイピングを経てようやく実装が完了した成果をお知らせします:

Oxlint は、JSで書かれたプラグインをサポートします!

主な特徴

  • ESLint互換のプラグインAPI。多くの既存のESLintプラグインを変更せずに実行可能。
  • より高いパフォーマンスを実現する、わずかに異なる代替API。

これは何で、何ではないか

このプレビュー版は始まりにすぎません。以下のように重要な点を押さえておきましょう:

  • この初期リリースでは、ESLintのプラグインAPIのすべてを実装していません。
  • パフォーマンスは良好ですが、これから ずっと 向上する予定です。多数の最適化が計画されています。

コードチェックルールで最もよく使われるAPI は実装済み なので、多くの既存のESLintルールはすでに動作します。ただしトークン関連のAPIは未対応のため、スタイル(フォーマット)関連のルールは動作しません。

ユーザーの皆様にお試しいただき、フィードバックをいただけると幸いです。これにより次の開発フェーズでの優先順位を決めることができます。

このブログ記事の内容

  1. 使い方
  2. 今後予定されていること
  3. 「両方を満たす」アプローチ(= ESLint互換性 かつ 超高速性能)を実現する技術的な詳細

すぐに始めたい方へ

プロジェクト内にOxlintを導入します:

sh
pnpm add -D oxlint

カスタムのJSプラグインを作成します:

js
// plugin.js

// 最もシンプルなルール - デバッガー禁止
const rule = {
  create(context) {
    return {
      DebuggerStatement(node) {
        context.report({
          message: "デバッガーは使用禁止!",
          node,
        });
      },
    };
  },
};

const plugin = {
  meta: {
    name: "最高のプラグイン",
  },
  rules: {
    "no-debugger": rule,
  },
};

export default plugin;

プラグインを有効にする設定ファイルを作成します:

json
// .oxlintrc.json
{
  "jsPlugins": ["./plugin.js"],
  "rules": {
    "best-plugin-ever/no-debugger": "error"
  }
}

Lint対象のファイルを作成します:

js
// foo.js
debugger;

Oxlintを実行します:

sh
pnpm oxlint

次のような出力が期待されます:

 x best-plugin-ever(no-debugger): デバッガーは使用禁止!
  ,-[foo.js:1:1]
1 | debugger;
  : ^^^^^^^^^
  `----

プラグインの作成についてさらに詳しい情報は、ドキュメント を参照してください。

代替API

Oxlintは、より高いパフォーマンスを実現するため、わずかに異なる代替APIも提供しています。

この代替APIによるプラグインは、ESLintおよびOxlintの両方と互換性があります。

クラス宣言が5つ以上あるファイルを検出するルールの例:

ESLint版

js
const rule = {
  create(context) {
    let classCount = 0;

    return {
      ClassDeclaration(node) {
        classCount++;
        if (classCount === 6) {
          context.report({ message: "クラスが多すぎます", node });
        }
      },
    };
  },
};

代替API版

js
import { defineRule } from "oxlint";

const rule = defineRule({
  createOnce(context) {
    // カウンタ変数を定義
    let classCount;

    return {
      before() {
        // 各ファイルのAST走査前にカウンタをリセット
        classCount = 0;
      },
      // 以前と同様
      ClassDeclaration(node) {
        classCount++;
        if (classCount === 6) {
          context.report({ message: "クラスが多すぎます", node });
        }
      },
    };
  },
});

差異点

  1. ルールオブジェクトを defineRule(...) でラップする。
diff
- const rule = {
+ const rule = defineRule({
  1. create の代わりに createOnce を使う。
diff
-   create(context) {
+   createOnce(context) {
  1. create の本体にあるファイルごとの初期化処理を before フックに移動する。
diff
-     let classCount = 0;
+     let classCount;

      return {
+       before() {
+         classCount = 0; // カウンタをリセット
+       },
        ClassDeclaration(node) {
          classCount++;
          if (classCount === 6) {
            context.report({ message: "クラスが多すぎます", node });
          }
        },
      };
    },
  });

この差異が唯一の大きな違いです。create(ESLintの方法)はファイルごとに何度も呼び出されますが、createOnce は一度だけ呼び出されます。

他のすべてのAPIは、完全にESLintと同じように動作します。

この代替APIが大幅にパフォーマンス向上を実現する理由については、ドキュメント を参照してください。

パフォーマンス

前述したように、この初期プレビュー版ではパフォーマンスは主眼としていませんでした。主な目標は、実際のプロジェクトで使えるほどの十分なAPIを整備し、早期採用者からのフィードバックを得ることでした。

現在のパフォーマンスはまずまずですが、決して目立つものではありません。

しかし重要な点として、我々が開発した 次世代 版のプロトタイプでは、採用したアーキテクチャ設計が、さまざまな最適化を加えることで 非常に優れた パフォーマンスを達成できることが示されています(内部構造 参照)。

今後数か月間でこれらの最適化を適用し、現在のバージョンと比べて複数倍のスピードアップが実現される予定です。

とはいえ、こうした最適化がなくても、すでにOxlintのパフォーマンスは競争力を持っています。

中規模のTypeScriptプロジェクト vuejs/core を対象としたOxlintとESLintの比較:

ライター時間
ESLint4,116 ms
ESLint(マルチスレッド)3,710 ms
Oxlint48 ms
Oxlint(カスタムJSプラグイン付き)236 ms
詳細

INFO

sh
hyperfine -i --warmup 3 \
  './node_modules/.bin/oxlint --silent' \
  './node_modules/.bin/oxlint -c .oxlintrc-with-custom-plugin.json --silent' \
  'USE_CUSTOM_PLUGIN=true ./node_modules/.bin/eslint .' \
  'USE_CUSTOM_PLUGIN=true ./node_modules/.bin/eslint . --concurrency=auto'

注:執筆時点のNPM上のOxlintバージョン(1.23.0)には、このベンチマークに影響を与えるバグがあり、カスタムプラグインのコストを大きく低く見積もっている。上記の結果は、バグ修正後の最新 main ブランチを使用し、このコミット にて取得されたものです。 詳しくは 以下 を参照してください。

この例では、シンプルなJSプラグインを追加してもコストは顕著ですが、それでもOxlintは、ESLintの新しいマルチスレッド実行よりも15倍速く動作しています。

もちろん、より複雑なプラグインや多数のプラグインを使用すると、パフォーマンスコストはさらに高くなります。

機能一覧

Oxlintは、基本的に「ASTの検査」に依存するプラグイン/ルールでよく使われる、大部分のESLint APIをサポートしています。これには、「コード修正型」のルールが含まれます。

まだトークンベースのAPIは対応していないため、スタイル(フォーマット)関連のルールは動作しません。

対応済み

  • ASTのトラバーサル
  • ASTの探索(node.parentcontext.sourceCode.getAncestors
  • 修正機能(Fixes)
  • セレクタ (ESLintドキュメント)
  • SourceCode API(例:context.sourceCode.getText(node)

まだ対応していない

  • ランタイム(IDE)サポート
  • ルールオプション
  • 建議(Suggestions)
  • スコープ分析 (実装済み v1.25.0以降)
  • トークンやコメントに関連する SourceCode API(例:context.sourceCode.getTokens(node)
  • コントロールフロー解析

今後の予定

今後数か月間、以下の作業を進めていきます:

1. プラグインAPIの拡充

目標は、100%のESLintプラグインAPIをサポートすることです。これにより、最終的には、変更なしで あらゆる ESLintプラグインを実行できるようになります。

2. パフォーマンスの向上

すでにパフォーマンスは良好ですが、プロトタイピング段階でさらなる大幅な改善が確認されています。これらを適用し、可能な限りRustの速度に近づけるように、OxlintのJSプラグインを最適化していきます。

内部構造

この記事の残りの部分は、OxlintでJSプラグインを使うために必須ではありません。ただし、実装の技術的な細かい仕組みに興味がある方は、以下をご覧ください…

大きな問い:ESLint互換を目指すべきか?

今年初めに、コミュニティに対して問いかけました。Oxlintは、エラーの表示形式など、既存のESLintプラグイン互換性を追求すべきかどうか。

当然、熟練感と移行の容易さという意味では、完全に互換性のあるインターフェイスが理想です。

しかし、Oxlintはその優れたパフォーマンスで知られており、これを大きく犠牲にすることは望ましくありません。

過去数か月間のプロトタイピングの主な目的は、パフォーマンスとエラー表示形式互換性のトレードオフを明確にし、両方を満たす「ケーキを食べても、残してもいい」解決策(= 現行の互換性と「十分速い」というパフォーマンス)を見つけることです。

私たちは、複数のアプローチの組み合わせによって、両者の要件を満たす方法を見つけたと考えています。

代替API

なぜこのAPIがパフォーマンスの向上を実現するのか、詳しくはドキュメント を参照してください。

ロウ転送

Oxcのようなツールは、JavaScript/TypeScriptファイルのコードを「抽象構文木」(AST)として表現します。 (抽象構文木

ASTは非常に巨大であり、それ自体のサイズは元のソースコードよりも遥かに大きいです。

通常、JSとネイティブ言語(例:Rust)の間に高速な相互運用性を実現する最大の障壁は、このような巨大なデータ構造を「二つの世界」の間でシリアライズ/デシリアライズするコストです。

最も単純かつ一般的な方法は、ASTをJSONにシリアライズして文字列として送信し、JSON.parse で再構築することですが、これは極めて遅いです。この変換コストが、そもそもネイティブコードの利点を上回ってしまうこともしばしばあります。他にもより効率的なシリアライズ形式はありますが、依然として大きなオーバーヘッドがあります。

我々は「ロウ転送」と呼ばれる手法を開発し、ネイティブメモリレイアウトをシリアライズ形式として利用することで、シリアライズそのものを排除しました(詳細は こちら)。

「ロウ転送」は、現在のJSプラグイン実装の基盤となっています。

レイジィデシリアライズ

良いパフォーマンスのもう一つの最大の敵は、特にワーカースレッドで複数のCPUコアにわたって実行される場合のガベージコレクターです。作成したすべてのオブジェクトは、メモリ回収のために破棄する必要があります。JSではこれがガベージコレクターの仕事です。V8などのJSエンジンは高度に最適化されていますが、ガベージコレクション自体は高コストであり、実際のワークロードに必要なコンピューティングリソースを奪ってしまいます。

我々は、非同期にデシリアライズを行い、実際に必要となる部分のみをデシリアライズするような、仮想的(遅延)な構文木走査器をプロトタイプ化しました。

例えば、クラス宣言に関連するルールの場合、この走査器はほとんどの構文木を無視して高速通過し、ClassDeclaration ノードのみを実際にオブジェクト化してルール処理に渡します。その他の構文木(変数宣言、if文、関数など)は、ノードオブジェクトを作成する必要すらありません。

これにより、2つの効果が得られます:

  1. ロウ転送により、シリアライズのコストがゼロになります。レイジィデシリアライズにより、もう一方の側(デシリアライズ)のコストも劇的に削減されます。
  2. ガベージコレクターへの負荷が大幅に減少します。

Denoも類似のアプローチを取っており、Marvin Hagemeisterのブログ記事 で非常に明快に説明されています。Deno lintは非常に効率的な実装を備えています。

しかしながら、我々が発見したのは、レイジィデシリアライズロウ転送 の組み合わせが、本当に優れたパフォーマンスを実現する鍵であるということです。両方のオーバーヘッドを除去することで、テストでは、カスタムプラグインの実行速度が大幅に向上することが確認されました。

この最適化は、現在のバージョンのJSプラグインにはまだ組み込まれていません。将来のバージョンで実装予定です。

使ってみよう!

ぜひカスタムプラグインを使ってみて、ご体験をお聞かせください。好意的なフィードバックも否定的なフィードバックも、心から歓迎します。

特に、自分のプラグインに必要ないくつかのAPIが不足していると感じたら、ぜひ教えてください。今後数か月間で、ニーズの高いものを優先的に補完していきます。

よいコードチェックス!