Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

RuboCop作者がRubyコードフォーマッタを比較してみた: 後編(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。記事が長いので3分割いたしました。参考までに、元記事の後にtestdouble社のstandard(standardrb)というgemも登場しています↓。

testdouble/standard - GitHub

  • 2019/06/05: 初版公開
  • 2023/04/12: 更新

RuboCop作者がRubyコードフォーマッタを比較してみた: 後編(翻訳)

パーサー選択の影響

ここからは、うんとテクニカルな方面に舵を切りますので、ご自由に読み飛ばしていただいて構いません。ツールを支えるパーサーライブラリをどう選択するかについて議論したいと思います。パーサーにはさまざまな選択肢がありますが、実用上はRuby組み込みのRipperか、サードパーティのparserの2つに1つになるのがほとんどです。

パーサーの選択は、一般にパフォーマンス、移植性、そしてUXに影響します。パーサーの選択次第で、ツール作者がコードベースを進化させる能力が大きく影響を受けます。

Ripper

参考: ruby/ext/ripper at master · ruby/ruby · GitHub

RipperはRuby 1.9からMRIの一部として提供されており、多くの人が(明確な理由に基づいて)Ripperを正式のRubyパーサーとみなしています。ツールでRipperを選ぶ場合、ランタイムの依存関係を潜在的にゼロにできます。これぞライブラリやツールの開発における聖杯です。その他にも以下のメリットがあります。

  • (非常に)高速
  • Rubyコアチームがメンテナンスしており、見捨てられることはほぼ考えられない
  • MRI自身のパーサーを用いているので、正しく動くと考えられる

しかし少なからぬデメリットもあります。RuboCopはバージョン0.8までRuboCopを用いていて、長い間充実した期間を過ごしました。しかしながら最終的にRipperの以下のような点がどうにも我慢ならなくなりました。

  • ドキュメントがほぼ存在しない(存在したことがあったでしょうか?)
  • ASTが単純なネステッド配列で表現されていて非常に扱いづらい(何らかのASTラッパーを使えば別)。Ruby 2.6にはRipperベースのASTライブラリが多少追加されたので、この点については改善されたかもしれません。
  • 以前は内部がMRIに特化していた。最終的にはJRubyでも使えるようになったと考えていますが、Rubyの実装が絡むと明らかに移植性が落ちます。
  • Ripperのバグを踏んだ場合、Rubyコアチームが修正(または修正をマージ)してくれるかどうかは彼ら次第。運が悪いと長い間待たされるかもしれません。
  • ASTノードにメタデータがほとんどアタッチされない(問題の場所を特定するのが難しくなる)
  • RipperのASTが必要以上に複雑。例: よく似た種類のノードが異なる種類のノードで表現されている(prefixやpostfix条件など)、言語のキーワードの扱い方が残念。これについては皆さまにもお試しのうえ実感いただければと思います。
  • RipperはほぼC言語で書かれているため、ほとんどのRubyistがコードベースで作業するにはつらい

訳注(2023/04/12)

現在はRipperのドキュメントがあります(ウォッチ20211026)。

参考: Ripper | ripper-docs
参考: class Ripper (Ruby 3.2 リファレンスマニュアル)

RipperのAST表現は以下のような感じになります。

Ripper.sexp "puts 'Hello, world!'"

# AST
[:program,
 [[:command,
   [:@ident, "puts", [1, 0]],
   [:args_add_block, [[:string_literal, [:string_content, [:@tstring_content, "Hello, world!", [1, 6]]]]], false]]]]

あらゆる情報がネステッド配列に単純に織り込まれているのがおわかりでしょう。位置メタデータ([1, 0]など)は、識別子やリテラルなどの開始位置しか指定していません。

パースするコードが少し複雑になっただけで、かなり威圧感のあるASTが出力されます。

Ripper.sexp('def hello(world) "Hello, #{world}!"; end')

[:program,
 [[:def,
   [:@ident, "hello", [1, 4]],
   [:paren,
    [:params, [[:@ident, "world", [1, 10]]], nil, nil, nil, nil, nil, nil]],
   [:bodystmt,
    [[:string_literal,
      [:string_content,
       [:@tstring_content, "Hello, ", [1, 18]],
       [:string_embexpr, [[:var_ref, [:@ident, "world", [1, 27]]]]],
       [:@tstring_content, "!", [1, 33]]]]],
    nil,
    nil,
    nil]]]]

こ、怖い🙀。しかもnilだらけ!このAPIを使うことの恐ろしさをじっくり理解するために、ごく初期バージョンのRuboCopのコードを一切れ引用します。

each(:method_add_arg, sexp) do |s|
  next if s[1][0] != :call

  receiver = s[1][1][1]
  method_name = s[1][3][1]

  if receiver && receiver[1] == 'Array' &&
     method_name == 'new' && s[2] == [:arg_paren, nil]
    offences.delete(Offence.new(:convention,
                                receiver[2].lineno,
                                ERROR_MESSAGE))
    add_offence(:convention,
                receiver[2].lineno,
                ERROR_MESSAGE)
  end
end

最初の数行が既にどうかしていますよね?ネステッド配列はASTからのデータ形式としてはまっとうなのでしょうが、それにしてもこのAPIはあまりに貧弱です。

おそらく2.6で追加された実験的なASTライブラリではRipperとのやりとりが改善されていますが、私はまだそのあたりをチェックしていません。しかも、私は新しいASTライブラリの導入方法にかなり失望しています。こうした機能を頻繁に使うツールメンテナーたちとRubyコアチームとのやりとりは設計上の貴重なフィードバックになったかもしれないのに、私の知る限りではRubyコアチームはこうしたやりとりにあまり乗り気ではありませんでした。問題を解決するには、その問題解決に関わる当事者を引き込むべきであると私は固く信じています。

幸いというべきか、私はここしばらくRipperを使っていないので、この方面について関わる予定はありません。ではparserの話に進みましょう。

parser

whitequark/parser - GitHub

parserはいかにも素っ気ない名前ですが、いろいろな意味でかなりイケているプロジェクトです。

  • 高速(Ripperほどではないが)
  • ドキュメントがとても充実している
  • 移植性が高い(主要なRuby実装ならどれでも使える)
  • ASTフォーマットがシンプルでとても扱いやすい
  • ASTにメタデータが多数アタッチされている
  • parserにバンドルされているモジュールを使ってソースコードを簡単に書き換えられる(RuboCopの有名なオートコレクト機能はこれを基礎にしています)
  • ほとんどがRubyで書かれている(Ragelも利用している)ので、Rubyistにとってかなり敷居が低い

parserのASTは次のような感じになります。

# 基本的な例
Parser::CurrentRuby.parse("puts 'Hello, world!'")

s(:send, nil, :puts,
  s(:str, "Hello, world!"))
# 「複雑な」例
Parser::CurrentRuby.parse('def hello(world) "Hello, #{world}!"; end')

s(:def, :hello,
  s(:args,
    s(:arg, :world)),
  s(:dstr,
    s(:str, "Hello, "),
    s(:begin,
      s(:lvar, :world)),
    s(:str, "!")))

私の頭がどうかしてしまったのか、parserの出力はRipperの出力よりはるかに理解しやすいように思えます。ネストしたsexpがいくつかあるだけで、情報がASTの構造とは直接関連しない形で抽象化されています。ASTの要素に関する位置メタデータもずっと簡単に取得できます(コードのどの位置にあるか、その中かっこがどこにあるかなど)。

Parser::CurrentRuby.parse("puts 'Hello, world!'").loc

=> #<Parser::Source::Map::Send:0x00007fa59fc50d68
 @begin=nil,
 @dot=nil,
 @end=nil,
 @expression=#<Parser::Source::Range (string) 0...20>,
 @node=s(:send, nil, :puts,
  s(:str, "Hello, world!")),
 @selector=#<Parser::Source::Range (string) 0...4>>

出力結果は本記事の対象範囲外なので、ここでくどくど説明するつもりはありません。ここで言っておきたいのは、必要になりそうなあらゆる情報がラップされているおかげでAPIでの利用が簡単だという点です。しかも、ここはすべてみっちりとドキュメントに記載されています。

parserのメリットは、(少なくとも私にとっては)まだまだあります。parserはRuby自身に組み込まれてないので、バグフィックスのペースも速く、リリースも頻繁です。これはツールの作者にとって非常に重要な点です。さらに、Rubyよりparserの方が(運営プロセス上もコードベースでも)はるかにコントリビューションしやすいのです。

さらにありがたいのは、Parserで「マルチ解析モード」が使えることです(Ruby 2.0、2.1、2.2など)。このモードを用いれば、現在実行中のRubyバージョンから独立してパーサーを選択できるので、たとえばRuby 2.6を実行中にRuby 2.3向けパーサーでRuby構文をチェックできます。これは特にライブラリメンテナーにとって実にありがたい機能であり、現在のRubyランタイムと癒着しているRipperではできないことです。具体的には、RuboCopのTargetRubyVersionはこれを基礎にしています。

parserを使う場合の本当の問題は「プロジェクトの最初の作者が活動を停止してしまった」ために「若干停滞している」ことだけです(最近は新しいメンテナーがかなり頑張っていますが1)。私の記事がきっかけでparserにもっと人が集まってくれればと思います。このプロジェクトで必要なのは、Rubyの今後の変更を常にトラッキングすることです。この作業には終わりというものがありませんが、進行中のメンテナンスで常にメリットを得られます。

Ripperと比べてparserが他に見劣りしているのは、スピード(大した違いはないのですが)と、自分のプロジェクトにparserへのサードパーティ依存を追加する必要がある点です。

本セクションの結論として、フォーマッタツールをメンテナンスしている仲間たちに、ぜひ一度parserを触って検討してみて欲しいと思います。私も当初はparserについて疑いの眼差しで見ていましたが、今やすっかりparser信者です!parserを使ってみて驚くメンテナーもいることでしょう。そして彼らメンテナーの人生も楽になり、ユーザーのエクスペリエンスも向上することでしょう。

さてフォーマッタの勝者は...

これはもう「場合によりけり」でしょう。どのフォーマッタにもトレードオフがありますし、最終的に自分にとって最も価値があるフォーマッタは自分で決めるしかありません。

rubyfmtはきわめてシンプルです。なにしろgemですらなく、Rubyスクリプトファイルがひとつあるきりですから。インストールをシンプルにするためにRipperで頑張るという方針は納得です。もうひとつ印象的だったのは、ほとんどのソースファイルで50ms以下で実行できたことで、これは本当に凄いことです!反面、現時点ではプロジェクトが若い分まだ成熟の余地があり、ドキュメントの量も少なすぎます。このツールでの正しいフォーマットルールがどうなっているのかは、私もコードを読むまではわかりませんでした。

RuFoは、rubyfmtをさらに成熟・洗練させて柔軟性を高めた感じです。このプロジェクトは古典的なgemとして配布され、設定の余地もある程度サポートしつつ、デフォルトでは控えめな修正を目指しています(コードレイアウトの変更を最小限に留める)。Ripperを使っていることがRuFoにとってよいかはわかりませんが、RuFoメンテナーがparserのパフォーマンスが彼らの要求に見合うかどうかを検討してくれればと思っています。

prettier-rubyはPrettierに依存しており、Node.jsの扱いを避けたい人々にとっては押し付けがましいかもしれません(今どきのWeb開発でNodeを避けるとむしろつらくなりますが)。既にPrettierを使っているのであればprettier-rubyは十分納得の行く選択です。

そして最後は我らがRuboCopですが、今回の出場選手の中でフォーマッタ専用でないツールはRuboCopだけです。RuboCopのこの主要な強みについては既に皆さんもRuboCopをお使いいただいていると思いますので、その点に満足いただいているのであれば、別のツールで余計なトラブルに合わずにすみます。

反面、RucoCopは他のフォーマッタのようなことはあまりやりませんし、他のフォーマッタほど高速でもありません。RuboCopがエディタと深く統合されている点は、比較的大きなコミュニティが今後も長年に渡ってメンテナンスすると思われるプロジェクトにおいては間違いなく大きなメリットとなります。RuboCopはレイアウトについての設定を大量に提供しており、これは機能であると同時に状況によっては問題にもなりえます。

RuboCopは、レイアウトを修正せずにレイアウトに関する問題を通知する唯一のツールです。これまでお知らせしたことはありませんでしたが、RuboCopをフォーマッティング専用で使いたい場合は以下の2つのコマンドを知っておくとよいでしょう。

$ rubocop --only Layout
$ rubocop --fix-layout

私が見る限りでは、今回は明らかな勝者はいませんが、私個人の好みで言うならRuboCopかRuFoになるでしょう。RuboCopに何かフォーマット専用ツールを組み合わせることに決めるのであれば、両者が互いの足を踏みつけることのないように、RuboCopのレイアウト部門を完全に無効にすることをおすすめします。

終わりに

私自身がささいなことだと思っているトピックで小説をまるまる一本書き上げてしまいました!

では本記事全体の要点はどこにあるのでしょうか?私にとって最も重要なのは、Rubyプログラマーは既にフォーマットツールについてさまざまな選択肢を楽しめる状況にあるということです。このことは、おそらくほとんどのプロジェクトで認識されているでしょう。私には、多くのプロジェクトの目標がある程度重複しているように見えるので、各プロジェクトがさまざまなレベルでうまく力を合わせれば車輪の再発明を避けられるのではないでしょうか。

私自身は、今存在するあらゆる別ツールのことを考慮に入れて、RuboCopでのフォーマットに私たちがどの程度投資を続けるのがよいのかについて考え続けています。おそらくはRuboCopをその方面に進めて、1つのツールだけを扱う必要があるようにすべきなのでしょう。あるいは、おそらくレイアウト部門のスコープを絞るか、どこかのタイミングで削除するべきなのでしょう。そこは時とともに明らかになるでしょう。

本記事ではフォーマットツールそのものについてはあまり言及しませんでしたが、その代りフォーマットのスタイルを統一することの価値や、車輪を再発明することの危険性について書きました。また、Ripperを使って静的コード分析ツールを構築したつらい日々から学んだいくばくかの教訓についても皆さんと共有しました。

本記事を書くきっかけは、rubyfmtに関する最近のお知らせです。当初、私は以前の(同じ名前の)rubyfmtを思い出して「またか」と思ったものです。Rubyコードフォーマッタはいったいいくつあればいいのでしょうか?コードフォーマットの問題が「解決済み」になるのはいったいいつになるのでしょうか?考え方は人それぞれですが、Rubyに足りない足りないと言われていた捉えどころのないコードフォーマッタは、今なら「ある」と私は強く思います。

私たちツールメンテナーは、互いに競い合うことも、共通の目標に向かって力を合わせて戦うこともできます。私の経験では、協力しながら作業する方が(少なくともオープンソースソフトウェアのプロジェクトに関しては)競争よりもよい結果をもたらします。そして共同作業が必要なのは何もコードレベルに限った話ではありません。納得のゆく共通のフォーマットスタイルを確立するために頑張るだけでも、多くの人にとって大きな一歩となります。最後はポジティブな言葉で締めくくりたいと思います。

皆さん、ハックを続けましょう!皆さんのRubyコードがいつも隅々までフォーマットされ、RuboCopがCIビルドでお役に立ちますように!

おたより発掘

関連記事

RuboCop作者がRubyコードフォーマッタを比較してみた: 前編(翻訳)

RuboCop作者がRubyコードフォーマッタを比較してみた: 中編(翻訳)

Ruby: RuboCopで== trueや== falseが怒られない理由


  1. 訳注: 2023/04/12現在のparser gemリポジトリは活発に活動しています。 

CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。