こんにちは。ラクスル事業部 Webエンジニアの西元・森田です。
私達は先日開催されたRubyKaigi 2025 に参加してきました。
多くの興味深い発表がありましたが、その中でも特にRuboCop: Modularity and AST Insightsに注目しました。
このブログでは発表の3つのポイント「プラグイン・アドオン・AST」についてまとめて紹介します。
1. RuboCop プラグイン(RuboCop 1.72以降)
RuboCopにはデフォルトでさまざまなルールが用意されていますが、標準機能だけでは特定のライブラリに特化したチェックには対応しきれないため、カスタムCopが広く使われています。ここでのカスタムCopには、公式のrubocop-rails
やrubocop-rspec
に加え、サードパーティ製のものも含まれます。
RuboCop 1.72以降では、こうしたカスタムCopを扱うためにPluginという仕組みが導入されました。これによって、「rubocop.yml
でrequire:
の代わりにplugin:
を使うことが推奨されるようになった」というユーザ影響があったものの、変更の背景はあまり知られていませんでした。
RubyKaigi 2025では、このPluginが導入された背景について発表がありました。
RuboCopの設定統合に関する課題
RuboCopはこれまで、カスタムCopを定義するためのインターフェイスRuboCop::Base
こそ用意していたものの、カスタムcopをRuboCop本体の設定とマージする仕組みは不十分でした。
そのため、多くのカスタムCopはRuboCop::ConfigLoader
という、RuboCopの内部設定に関わるクラスのメソッドを直接呼び出して、設定を直接書き換えてしまっていました。
このアプローチには2つの問題がありました。
まず、カスタムcopのコードがRuboCop本体のConfigLoader
の実装詳細に強く依存する、密結合状態になっていたことです。
また、複数のカスタムCopを併用した際、読み込み順序によって設定結果が変化するという問題も発生していました。
LintRoller導入とその効果
この課題を解決するため、RuboCop 1.72以降では設定を一元管理するLintRoller
という仕組みが導入されました。カスタムCopはLintRoller::Plugin
を継承して設定を登録し、RuboCop本体はそこから設定を受け取る形となりました。
# rubocop-rspec/lib/rubocop/rspec/plugin.rb class Plugin < LintRoller::Plugin
これによって、カスタムCopがRuboCop本体の設定を直接干渉する必要がなくなり、設定の競合に関する問題も解消されました。
RuboCopプラグイン導入に伴う変更点
ユーザ向け
冒頭でも書いた通り、今後はrubocop.yml
で従来のrequire:
の代わりに、plugin:
を記述する必要があります。開発者向け
新たにLintRoller::Plugin
を継承したクラスを定義する必要があります。
また、ゼロからPluginを作成するのが面倒な場合は、rubocop-extension-generator
というgemを使うことで、雛形を生成できるようです。
2. アドオン
Ruby LSPは、Shopifyが提供している拡張機能で、IDE上でのコード補完や定義ジャンプといった豊富な機能を持っています。
しかし、この拡張機能はパース処理に関する課題を抱えていました。
Ruby LSPのパース処理に関する課題
2024年時点では、Ruby LSP内の各機能がそれぞれ同じコードを個別にパースしていました。さらにRuboCopを併用する場合、RuboCop側でも別途パース処理が走るため、パフォーマンスの観点で非効率となっていました。
そこで、Ruby LSPの開発者は、Ruby LSPで生成したパース結果をRuboCopで再利用すれば無駄を避けられると考え、RuboCopとの連携を検討し始めました。
しかし、Ruby LSPではすでにPrismによるパースが行われていたもの、RuboCop(rubocop-ast
)はこの時点でPrismに非対応で、ASTの形式が一致しないため、そのままでは利用できませんでした。
一時的にPrismのASTをrubocop-ast
互換に変換するモンキーパッチが導入されたものの、長期的に利用するのは難しいとされていました。
Ruby LSPとRuboCopの協調
2024年3月にリリースされたRuboCop v1.62では、AST解析エンジンとしてPrismをオプショナルで利用できるようになりました。
また、Ruby LSPがすでに生成したASTをRuboCopに直接渡すための構成も整備されました。(対象プルリクエスト:rubocop-ast PR #359 Enable reusable Prism parse result)
対応前は、初期化時に内部でPrismが再実行されていました。
module RuboCop::AST class ProcessedSource def initialize(source, ruby_version, path = nil, parser_engine: :parser_whitequark) parse(source, ruby_version, parser_engine) end end end
対応後は、もしパース済みの結果があれば、そのまま受け取れるようになりました。
module RuboCop::AST class PrismParsed def initialize(prism_result) @prism_result = prism_result end end end
発表によると、実際にこの方法を適用した場合、RuboCopによるソースコードの解析時間は、再パースを行う場合に比べて約1.3倍高速化されたそうです。
今後の展望
構文解析の重複問題こそ解消されましたが、Ruby LSP内のRuboCopアドオンにはまだ改善の余地があります。たとえば、rubocop:disable
コメントの自動挿入など、RuboCop拡張機能に備わっている機能が不十分なようです。
こうした機能がRuby LSP側にも対応し、正式なアドオンとして提供されれば、開発体験は確実に高まるため、引き続き注目していきたいです。
3. AST Insights
発表の中ではParser gemからPrismへパーサーを変更したことの背景やRuboCopのパーサーに関する今後についての話がされていました。
ここでは前回のTechBlog【RubyKaigi 2025参加者向け】RuboCopに関する2024年発表内容振り返り&今年の見どころを紹介 の内容をかいつまみつつ、Prismを使ってParser gemのASTを出力している部分について少し調査したことを書いていきます。
Parser gemの課題とPrismへの移行の背景
これまで、RuboCopはRubyコードの解析にParser gemを利用してきました。Parser gemは強力なパーサーライブラリですが、メンテナンスがコミュニティに依存しており、Rubyの進化に追随するための継続的なメンテナンスが課題でした。
一方PrismはRuby公式が開発を進めている新しいパーサーであり、以下の特長を持ちます。
- エラートレランス
- C言語実装による高速性
- Ruby 3.4以降、推奨パーサーとなったことによるメンテナンスの安定性
これらの特長や後述するParser gem ASTへの変換機能があることから、RuboCopのデフォルトパーサーがPrismへ変更されました。
今後はPrismに直接依存するため、もしバグの要因がPrismにあればPrism自体が修正されることでRuboCopでもそのままバグが改善される可能性も高まります。これにより、Parser gemのメンテナンスに割いていたコストを、本来力を入れたいRuboCop開発に集中させることができます。
RuboCopのパーサー変更の決め手の1つとなったAST変換機能
Prismへのパーサー変更には多くの利点がありますが、RuboCopはこれまでParser gemのASTを前提として構築されてきました。そのため、Prismに変更することでASTが変わってしまうと、その影響範囲は甚大です。
RuboCop自体はもちろん、カスタムCopを利用している場合の検証ロジックがParser gemの出力するASTに基づいていました。したがって、ASTが変更されると、各カスタムCopの利用者側でも修正が必要となり、全面的な移行を実現できる状況にありませんでした。
しかしPrismでは、Parser gem ASTへ変換するAdapterであるPrism::Translation::Parser
を用意しており、これを使うことでRuboCopのパーサー変更が現実的なものとなりました。
講演の中でも触れられていましたが、現状のRuboCopではASTの形式を変えなければいけないような課題はなく、AST自体は引き続きParser gemの形式を利用することに問題はないとの見解でした。
そこで、パース処理にはPrismを採用しつつ、RuboCop内で参照するASTはParser gem ASTのままとするという方針で改善が進められました。
つまり、パーサー自体がParser gemであれPrismであれ、そこに依存することはなく、最終的にParser gem ASTが取得できれば問題ないという設計です。
ASTのインターフェイスのみに依存する設計となったことで、将来的にPrismがLramaや他のパーサーに置き換わったとしても、最終的にParser gem ASTに変換可能であれば、RuboCop側の修正を最小限に抑えつつパーサーの進化による恩恵を受け続けられるということです。
Prism::Translation::Parser
Prism::Translation::Parserには主に下記の機能が実装されています。
parse
- 広く使われているParser gemの
parse
メソッドと同様のインターフェイスで、Parser gem ASTを返すメソッドです。
- 広く使われているParser gemの
parse_with_comments
- ソースコードの解析と同時にコメントも抽出し、抽出されたコメントは
Parser::Source::Comment
オブジェクトへと変換します。 - これにより、コードの解析結果に加え、コメントの位置情報などもParser gemの形式で利用することが可能です。
- ソースコードの解析と同時にコメントも抽出し、抽出されたコメントは
tokenize
- Prismを用いてソースコードの字句解析(トークン化)を行い、その結果得られたトークンをParser gemのトークン形式へ変換します。
Prism::Translation::ParserでParser gem ASTを取得してみる
たとえば puts 'こんにちは'
というコードをパースした場合、PrismとParser gemではそれぞれ次のようなASTを取得できます。
Prism AST
@ CallNode (location: (1,0)-(1,22)) ├── flags: newline, ignore_visibility ├── receiver: ∅ ├── call_operator_loc: ∅ ├── name: :puts ├── message_loc: (1,0)-(1,4) = "puts" ├── opening_loc: ∅ ├── arguments: │ @ ArgumentsNode (location: (1,5)-(1,22)) │ ├── flags: ∅ │ └── arguments: (length: 1) │ └── @ StringNode (location: (1,5)-(1,22)) │ ├── flags: ∅ │ ├── opening_loc: (1,5)-(1,6) = "'" │ ├── content_loc: (1,6)-(1,21) = "こんにちは" │ ├── closing_loc: (1,21)-(1,22) = "'" │ └── unescaped: "こんにちは" ├── closing_loc: ∅ └── block: ∅
Parser gem AST
s(:send, nil, :puts, s(:str, "こんにちは"))
これらのAST出力を試したときの詳細については 【RubyKaigi 2025参加者向け】RuboCopに関する2024年発表内容振り返り&今年の見どころを紹介 に乗せていますので、ご興味があればぜひ見てください。
講演で紹介があったように、Prism::Translation::Parser
にはParser gemのASTを取得するメソッドが実装されています。
その主要な機能であるparse
メソッドを使ってParser gemのASTを出力してみましょう。
require "prism" require "prism/translation/parser" source = "puts 'こんにちは'" ast = Prism::Translation::Parser.parse(source) puts ast.inspect # => s(:send, nil, :puts, # s(:str, "こんにちは"))
RuboCopでは現在使われていませんが、もしコードに含まれるコメントもASTに反映させたい場合には、parse_with_comments
メソッドを利用できます。
require "prism" require "prism/translation/parser" source = <<-RUBY # Hello! puts 'こんにちは' RUBY ast = Prism::Translation::Parser.parse_with_comments(source) puts ast.inspect # => [s(:send, nil, :puts, # s(:str, "こんにちは")), [#<Parser::Source::Comment (string):1:3 "# Hello!">]]
Prism::Translation::Parser の処理を少し追ってみる
Prism::Translation::Parser#parse
メソッドの中身を見てみると下記のようになっています。
def parse(source_buffer) @source_buffer = source_buffer source = source_buffer.source # Prismはバイト単位でオフセットを持つが、Parser gem は文字単位でオフセットを持つのでその差分を計算 offset_cache = build_offset_cache(source) # @parserはPrismを指し、Prismのparseメソッドを呼んでPrism ASTを生成 result = unwrap(@parser.parse(source, **prism_options), offset_cache) # Prism AST から Parser gem AST に変換 build_ast(result.value, offset_cache) ensure @source_buffer = nil end
Prism ASTからParser gem ASTへの変換を行うbuild_ast
メソッドはprivateメソッドとして定義されています。そのため、Prismを利用するアプリケーション側ではこの変換処理を意識する必要はありません。単純なケースであれば、パーサーのクラス名をParser::CurrentRuby
などからPrism::Translation::Parser
に変更するだけで、Prismを使ってParser gem ASTを取得できるようになります。
参考
- 「RuboCop: Modularity and AST Insights」プレゼンテーション資料 https://speakerdeck.com/koic/rubocop-modularity-and-ast-insights
まとめ
普段ユーザとして利用している際は気づかなかったものの、RuboCopの拡張性や利便性においてさまざまな課題があり、この1年だけでも着実に解決されていることがわかって興味深かったです。
今後も情報をキャッチアップし、RuboCopの進化に追従していきたいです。