RAKSUL TechBlog

RAKSULグループのエンジニアが技術トピックを発信するブログです

【RubyKaigi 2025】「RuboCop: Modularity and AST Insights」の3つのポイント紹介

こんにちは。ラクスル事業部 Webエンジニアの西元・森田です。

私達は先日開催されたRubyKaigi 2025 に参加してきました。
多くの興味深い発表がありましたが、その中でも特にRuboCop: Modularity and AST Insightsに注目しました。
このブログでは発表の3つのポイント「プラグイン・アドオン・AST」についてまとめて紹介します。

1. RuboCop プラグイン(RuboCop 1.72以降)

RuboCopにはデフォルトでさまざまなルールが用意されていますが、標準機能だけでは特定のライブラリに特化したチェックには対応しきれないため、カスタムCopが広く使われています。ここでのカスタムCopには、公式のrubocop-railsrubocop-rspecに加え、サードパーティ製のものも含まれます。

RuboCop 1.72以降では、こうしたカスタムCopを扱うためにPluginという仕組みが導入されました。これによって、「rubocop.ymlrequire:の代わりに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を返すメソッドです。
  • 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の拡張性や利便性においてさまざまな課題があり、この1年だけでも着実に解決されていることがわかって興味深かったです。

今後も情報をキャッチアップし、RuboCopの進化に追従していきたいです。