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

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

パーサー選択の影響

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

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

Ripper

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がコードベースで作業するにはつらい

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

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, "!")))

私の頭がどうかしてしまったのか、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を使う場合の本当の問題は「プロジェクトの最初の作者が活動を停止してしまった」ために「若干停滞している」ことだけです(最近は新しいメンテナーがかなり頑張っていますが)。私の記事がきっかけで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ビルドでお役に立ちますように!


おたより発掘

関連記事

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

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の監修および半分程度を翻訳、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れて更新翻訳中。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ

BPSアドベントカレンダー