Ruby 3.3リリース! 新機能解説

Prism⁠エラートレラントな⁠まったく新しいRubyパーサ

Prismは、Ruby 3.3.0にバンドルされた新しいライブラリで、プログラミング言語Rubyの新しいパーサであるPrismパーサのバインディングです。Prismはエラートレラント、移植性、メンテナンス性、高速性、効率性を考慮して設計されています。この記事では、Prismの歴史、設計、API、そして今後の課題について取り上げます。

使用方法

Rubyバインディングを通してPrismパーサを使うにはrequire "prism"をして、Prismモジュールのparseメソッド、または他のparse_*系のメソッドを呼んでください。次に例を示します。

require "prism"
Prism.parse("1 + 2")

parseメソッドは、パース結果のオブジェクトを返します。このオブジェクトは、ソースコードに対応した構文木に加え、エラー、警告、コメントのリスト、そしてパース操作に関連するさまざまなメタデータを持ちます。重要なのは、このメソッドは構文エラーが見つかっても例外を発生させず、常にパース結果を返すことです。この特徴は、構文エラーを含む可能性のあるソースコードを扱うのに適しています。

Prismの歴史

Prismは2021年に設計されました。Shopifyにおいて、高速かつ効率的でエラートレラントなパーサが必要になり、開発が始まりました。2021年時点でShopifyはすでに、CRuby、TruffleRuby、Sorbet、およびさまざまなRubyのツールに多大な投資をしていました。Shopifyの開発者は合計4つのRubyパーサのメンテナンスを手伝っていました。これはかなりの作業であり、すべてのプロジェクトで共通して使えるパーサがあれば、コミュニティ全体の役に立つことは明らかでした。

私たちは各プロジェクトのメンテナなどと相談しつつ、さまざまなプロトタイピングと設計のフェーズを経て、現在のPrismの設計にたどり着きました。ここに来るまでに1年半がかかっています。その間にPrismはオープンソースになり、Rubyエコシステムのさまざまなプロジェクトと統合されてきました。

Prismの設計

前述のとおり、Prismはエラートレラント、移植性、メンテナンス性、高速性、効率性を考慮して設計されています。パーサとその構文木のノードは、Ruby実装やツールがなるべく扱いやすいように設計されています。これらの設計目標を順番に説明します。

エラートレラント

MicrosoftがVisual Studio CodeやLanguage Server Protocol(LSP)を作って以来、プログラミング言語においてエラートレラントが注目されるようになりました。構文エラーを含んでいるコードをパースできることは開発者体験を良くするため、エディタで使われるパーサに必須の機能となりました。というのは、編集中のコードはたいてい不完全な状態だからです。このため、Prismはエラートレラントを意識して設計され、パーサ生成器を使わずに手書きで実装されています。Prismは、どんなに構文エラーを含むファイルに対しても、最低限、トップレベルのステートメントのリストは常に返すようになっています。

Prismの開発チームは開発中、RubyのLSPサーバであるRuby LSPの開発チームと緊密に協力してきました。これにより、Ruby LSPから渡されるコードをPrismがパースできること、そしてPrismが返すエラーがエンドユーザにとって有用であることを確かめることができました。私たちはこの作業をRuby 3.4.xでも続け、Prismのエラートレラントを改善していきます。

移植性

Prismは、これまでに開発されてきた多数のRubyパーサの置き換え候補として設計されました。CRubyのパーサだけでなく、他のRuby実装やサードパーティのツールなどで使われているパーサも置き換えの対象です。このために、Prismの開発者は当初からJRubyTruffleRubyIRB、その他実装やツールのメンテナと相談してきました。

結果的に、CRuby、JRuby、TruffleRuby、Natalieが、既存のパーサの置き換えとしてPrismを統合しました。CRuby(デフォルトのRuby実装)では、選択可能なパーサのひとつとして提供されます。JRubyとTruffleRubyは、次のバージョンでPrismをデフォルトにしようとしています。NatalieはすでにPrismをデフォルトとしています。

プログラミング言語Rubyでは、他にもさまざまなサードパーティのパーサが開発されてきました。その中にはwhitequark/parserseattlerb/ruby_parserがあります。この2つのパーサは長年にわたり、かのrubocopをはじめ、Rubyエコシステムのさまざまなツールやライブラリで使われてきました。私たちはこれらのツールの開発者とともに、Prismをバックエンドとして使うオプションを提供しようとしています。これにより、エコシステム全体をひとつの総合的な取り組みにまとめようとしています。

Prismは依存をもたないスタンドアロンのライブラリなので、他の言語のバインディングを提供するのもかんたんです。執筆時点でPrismはすでに、Ruby、C、C++、Rust、Java、JavaScriptで書かれたツールで使われています。これらの言語で書かれたライブラリにとってPrismが実用可能な選択肢となれるよう、それらのメンテナと積極的に協力しています。

メンテナンス性

Prismは、コミュニティがデフォルトのパーサとして長く使い続けられるように、なるべくメンテナンス可能な設計にしました。そのために、構文木の定義では、すべてのノードとフィールドにドキュメントとテストが書かれています。さらに補足的な文脈を伝えるために、Prismの設計と実装について一連のブログを書いています。Prismのメンテナンス性に投資し続けることで、今後何年にもわたって、Prismをすぐれた開発ツールの基盤としてコミュニティに提供できることを願っています。

パーサの設計

Prismは手書きの再帰下降パーサです。C99で書かれていて、Rubyがサポートするプラットフォームすべてに移植できるように設計されています。大きなPrattパーサとして構成されていて、Rubyの文法が優先順位や結合規則を変えるときはそれに合わせて変更を追加しています。

一般に、Prismは有効なRubyコードに収まらないコードもパースします。たとえばクラス名が期待される位置には、クラス名の定数(定数パス)だけでなく、任意の式が来ることを想定してパースします。たとえば次のようなコードです。

class foo.bar
end

このようにするのは、エラー回復をうまく行うためです。本来許されない位置にある式をパースできるようにすることで、エンドユーザにとってより便利なエラー回復ができるようになります。

これは差分パースにとっても便利です。差分パースとは、ファイルの一部分だけをパースする機能のことです。任意の位置にある任意の種類の式をパースすることで、たとえ無効な形であっても、ツールが構文木を表現できるようにします。このことはlintツールや型検査にとって特に重要です。なぜなら、これによってファイルが変わるたびに多くの情報を再利用できるようになるからです。

上記の例で言うとfoo.barは構文木上で無効な位置にありますが、型検査器やlintツールはこれを有効なメソッド呼び出しとして解析できます。その後、ユーザが文字を書き足してコード全体を有効にしたとき、ツールはそのメソッド呼び出しを再処理しなくてすみます。

ノードの設計

Prismの構文木のノードは、なるべくコンパイルしやすいように設計されています。それでいて、いつでもソースコードを復元できるだけの情報を保持しています。これを念頭に置き、Prismでは、ほかの一般的な構文木ではひとつにまとめられるようなノードでも、意図がなるべく明らかになるように別種のノードにしています。たとえば次のコードを考えましょう。

@foo = 1
for @foo in 1..10 do end

どちらの行も、インスタンス変数@fooに書き込みを行っています。1行目では、1という値を直接書き込んでいます。2行目では、ループのイテレーションの現在の値を間接的に書き込んでいます。Prism以外の構文木では通常、この2種類の書き込みは両方とも「インスタンス変数への書き込み」ノードで表現されます。1行目のような直接的な書き込みの場合は、書き込まれる値をノードに持たせます。2行目のような間接的な書き込みの場合は、ノードは書き込まれる値を持ちません。コンパイラなどのツールがそのようなノードを処理する場合、値が存在するかどうかを検査する必要があります。一方Prismでは、この2種類の書き込みをそれぞれInstanceVariableWriteNodeInstanceVariableTargetNodeという別のノードにします。1つめのノードは直接的な書き込みに、2つめのノードは間接的な書き込みに使用されます。

このように分割することで、CRubyのコンパイラで分岐のネストが減り、より「フラット」なコンパイラになります。Prismのノードを設計するときの基本理念に、親ノードをコンパイルする際に子ノードを見なくて良いようにする、というものがあるので、これは意図通りです。この設計により、将来のコンパイラの保守や拡張が容易になると考えています。また、値を持たないノードにnull値のようなものを持たせる必要がないので省メモリでもあります。

速度⁠効率

まだ改善の余地はたくさんありますが、Prismがなるべく速く、かつ省メモリであることを確認するため、ベンチマークを数多く行っています。大規模なRubyコードをパースし、パース自体にかかる時間と、その構文木をRubyで扱える形式に変換する時間の両方を測定しています。これは今年も続けていく予定です。

テスト

しっかりしたテストスイートを作り上げることは、私たちPrism開発者にとって非常に重要です。プログラミング言語Rubyにはたくさんのテストスイートが作られてきましたが、パーサのために作られたテストスイートはほぼありません。Prismでは、開発時に自分たちで作ったテスト用コードに加え、whitequark/parserseattlerb/ruby_parserのパーサ用テストスイートを組み込んでいます。また、rubygems.orgでリリースされているすべてのgemの最新版コードに対してもテストしています。これは、バグやエッジケースを洗い出すのに役立っています。

私たちは複数のテスト方式を使っています。1つはリグレッションテストです。テスト用コードをパースして得られた構文木のスナップショットをとり、以後のテスト実行でパース結果が変わらないか比較しています。これで、正しくパースできていた構文木の扱いにリグレッションのバグが入らないようにしています。2つめは、特定の機能やエラートレラントに対応する手動のユニットテストです。これらはエッジケースをテストしたり、一貫性のある方法でエラー回復できていることを確認したりするために有用です。3つめは、正規表現、エンコーディング、エスケープシーケンスなどの特定の機能に対する小さなテストスイートです。こちらでは、ブルートフォーステスト(とりうる可能性のある値を網羅的にテスト)しています。たとえばエンコーディングについては、すべてのエンコーディングのすべてのコードポイントをテストしています。これらによって、各機能が正しく動いていることを確かめています。

最後に、ファジングテストによってさまざまな入力をPrismパーサに与えることも非常に重要です。Cプロジェクトでは、メモリ破壊のバグを入れてしまいがちです。私たちはAFL++というファジングツールを使って、パーサとレクサ(字句解析器)をテストし、クラッシュしたり、入力より後の範囲外メモリを読んだりしないことを確かめています。ASANや様々なメモリサニタイザを組み合わせて、Prismができるだけ安定していることを確かめています。

苦労した点

Rubyのソースコードを扱うにはむずかしいことがたくさんあります。文法は非常に複雑で、何年もかけて何度も拡張されてきました。これ以外にも、Prism開発中に直面した課題がいくつかあります。

ローカル変数の読み出しとメソッド呼び出しは、識別子ひとつだけで表現されている場合、区別ができません。識別子がローカル変数になると構文木の形が変わってしまうので、これは非常に大きな違いになります。そのため、ローカル変数のスコープはパース時にすべて解決しなければなりません。通常、これはそれほどむずかしいことではありません。しかしRubyには、ローカル変数への書き込み以外にもローカル変数を定義する構文があります。たとえば、名前付きキャプチャグループを持つ正規表現が、ローカル変数を定義したり変更したりします。つまりPrismがRubyコードを適切にパースするためには、CRubyと同じように正規表現をパースする正規表現パーサを持たなくてはなりません。次のコードを見てください。

/(?<foo>bar)/ =~ "bar"
foo / bar#/

このコードでは、1行目でローカル変数fooが定義され、それが2行目で使用されています。2行目は、barを引数とする/メソッドの呼び出しです。しかし、もしfooが定義されていなかったら、これは正規表現を引数とするメソッドfooの呼び出しとしてパースされます。これは微妙な違いですが、ローカル変数をパース時に解決することの重要性がわかるでしょう。

Rubyのソースコードは、CRubyがサポートする90種類のASCII互換エンコーディングで表現できます。よってPrismがRubyコードを適切にパースするためには、CRubyがサポートするすべてのエンコーディングをきちんとサポートする必要があります。ただ、一部の機能だけで十分だったのは幸運でした。バイト列がアルファベットかどうか、英数字かどうか、大文字かどうかを判断できることが必要でした。Rubyコードのエンコーディングは次のように指定されます。

# encoding: Shift_JIS

エンコーディング名は、別名を含めて154個のASCII互換エンコーディングのいずれかになります。文字列や識別子を正しくパースするために、このエンコーディングのコメントは現れたら即座に解決する必要があります。

最後に、Rubyには文字列や正規表現で使用できるエスケープシーケンスがたくさんあります。エスケープシーケンスを使うと、任意のUnicodeのコードポイントや、その他のさまざまな特殊文字を表現できます。PrismがRubyコードを適切にパースするためには、各種エスケープシーケンスに対応し、それが表すバイト列を返す必要があります。これにより、個々の実装でエスケープシーケンスをパースする必要がなくなりますが、Prismのメンテナンスはよりむずかしくなります。

API

Prismには、単にパースするAPIだけでなく、Rubyの構文木を扱うツールを作る開発者にとって便利なAPIがたくさんあります。追加の情報を得るための新しいAPIもありますし、これまで標準APIがなかった既存のワークフローを置き換えるAPIもあります。

既存のワークフローの例として、ソースファイル中のすべてのコメントを取り出すことが挙げられます。通常、これはRipperで行われていました。しかしPrismでは、同じことがもっと簡単にできます。

Prism.parse_comments(<<~RUBY)
# foo
# bar
RUBY

これは次のようなコメントの配列を返します。

# =>
# [#<Prism::InlineComment @location=#<Prism::Location @start_offset=0 @length=5 start_line=1>>,
#  #<Prism::InlineComment @location=#<Prism::Location @start_offset=6 @length=5 start_line=2>>]

他のワークフローとしては、ソースファイルが有効かどうかを判定することがあります。これはRipperRubyVM::InstructionSequenceで行われていました。Prismは、より簡単なAPIを提供しています。

Prism.parse_success?("1 + 2") # => true
Prism.parse_success?("1 +") # => false

Prismがこのような補助的なAPIを提供することで、ユーザはあまりコードを書くことなく、Rubyのバージョンによらず一貫した体験を得られます。

構文木のノードもすべて、共通のAPIを持ちます。各ノードは、専用のクラスを持ちます(他のRubyパーサのノードは、単一のクラスになっていて、typeフィールドで区別する傾向があるのと対象的です⁠⁠。各ノードのクラスには、子ノードや属性情報を取り出すための名前付きフィールドのメソッドがあります。さらに、そのノードが持つ子ノードをまとめて取り出すための#child_nodesメソッドnilを含む)#compact_child_nodesnilを含まない)もあります。このAPIを使うことで、構文木のすべてのノードをたどることができます。

def walk(node, indent = 0)
  puts "#{" " * indent}#{node.type}"
  node.compact_child_nodes.each { |child| walk(child, indent + 2) }
end

walk(Prism.parse("foo.bar(1); baz(2)").value)

このコードは次のような木構造を出力します。

program_node
  statements_node
    call_node
      call_node
      arguments_node
        integer_node
    call_node
      arguments_node
        integer_node

各ノードには#copyメソッドもあります。これは、ノードを不変なものとして扱ったり、一部のフィールドを置き換えた新しいノードを生成したりするのに使えます。#deconstruct#deconstruct_keysもあり、パターンマッチができます。#locationメソッドがあり、ユーザはそのノードが元のソースコード中のどこにあったかを正確に知ることができます。

一部のノードだけを扱いたいときは、Visitorオブジェクトを受け取る#acceptメソッドが便利です。これはダブルディスパッチのVisitorパターンを実装したもので、構文木をかんたんに走査できます。Prismはよくあるユースケースに対して、Prism::VisitorPrism::Compilerを提供しています。Prism::Visitorクラスは、ノードを検索して取り出すようなケースで便利です。Prism::Compilerクラスは、構文木をバイトコードなどの別の形式に変換するケースで便利です。Prism::Visitorの例として、構文木中のすべてのメソッド呼び出しを列挙したければ、次のようにできます。

class MethodCallFinder < Prism::Visitor
  attr_reader :calls

  def initialize(calls)
    @calls = calls
  end

  def visit_call_node(node)
    super
    calls << node.name
  end
end

calls = []
Prism.parse("foo.bar.baz").value.accept(MethodCallFinder.new(calls))

calls
# => [:foo, :bar, :baz]

Prismは組み込みのVisitorCompilerもいくつか提供しています。これは単体でも便利ですし、構文木を操る例にもなっています。たとえば、構文木をGraphvizの有向グラフ形式に変換する機能があります。Prism::DesugarCompilerというものもあり、これは少ない種類のノードで同等の構文に変換する「脱糖」をしてくれます。Prism::MutationCompilerは、自動リファクタリングのような構文書き換えを可能にします。

今後の課題

現在、Ruby 3.3.0が公開されています。私たちはRubyコミュニティと協調し、Rubyのツール群の進化を支える最良の基盤を提供すべく、Prismの開発を続けていきます。この目標のため、私たちはPrismの今後の方向性をいくつか考えています。

Prismの1つめの大目標は、CRubyの現在のパーサと完全に同等にすることです。現時点でPrismは、有効なRubyコードを正しくパースすることはできていますが、無効なRubyコードを正しく拒絶することについてはまだエッジケースがあります。現在この差分をできるだけ早くなくすように努力していて、Ruby 3.4.0がリリースされるまでには解決したいと思っています。さらに、CRubyがPrismをデフォルトのパーサとして使うようになったとしても、⁠エラー回復や警告などで)一切の機能を失うことがないよう、いくつかの警告、エラーメッセージの人間工学的な改良、エラー回復の調整を行っています。

今年の2つめの大目標は、コミュニティにPrismをもっと使ってもらうことです。Prismはすでに多くの主要ツールやRuby実装で使われていますが、Prismの恩恵を受けられるところはRubyエコシステム内にまだたくさんあります。これは、mrubyのようなRuby実装や、Sorbetのようなツールです。私たちは今年、これらのプロジェクトのメンテナとともに、Prismがそれらで使える選択肢になるようにしたいと考えています。

3つめは、ドキュメントを改善し、Prismを開発する際の開発者体験をよくすることです。私たちは最初から体験を良くするために努力してきましたが、改善の余地は常にあります。理想的には、⁠経験レベルに関係なく)誰でもPrismに貢献できる程度にハードルを下げたいと思います。

最後に、パフォーマンスの向上に時間を費やしたいと考えています。Prismはすでにかなり高速ですが、改善できる部分もまだあります。特定のターゲットプラットフォームに最適化すべく、SIMD命令やその他の低レベル最適化を検討しています。また、Prismのメモリ使用量を減らすべく、メモリレイアウトや割当の最適化も検討する予定です。

総じて、Prismとそれが生み出すRubyツールの未来について、私たちは大いに期待しています。Prismを使って開発された新しいツールやライブラリがすでにいくつも登場しています。この傾向がRuby 3.3.0のリリースで続くことを願っています。

おすすめ記事

記事・ニュース一覧