Skip to content

Instantly share code, notes, and snippets.

@wanabe
Last active December 2, 2024 15:51
Show Gist options
  • Save wanabe/97ff3399e386b2dbf7b4bcadc579f673 to your computer and use it in GitHub Desktop.
Save wanabe/97ff3399e386b2dbf7b4bcadc579f673 to your computer and use it in GitHub Desktop.
ちょい足し AltRb のススメ、あるいは Prism とエラートレラントの悪用

はじめに

これは Ruby Advent Calendar 2024 の記事です。

日々 Ruby を使う中で、ちょっとだけ新しい文法が欲しくなったり、逆に今の文法に気に入らないところがあったりといった経験はないでしょうか。 気に入らない文法は使わなければいいだけですし、複数人でそれを統一したいなら Rubocop があります。ですが、存在しない文法を使うことはできません。 そんなとき AltRb という選択肢はどうでしょうか、という提案をこの文ではお届けします。

AltRb とは

AltRb という言葉が一般的に使われているわけではなく、勝手に私がそう呼んでいるだけです。簡単に言うと AltJs の Ruby 版のことを指しています。

AltJs はトランスパイル結果として JavaScript スクリプトを取得する想定で設計実装された言語、あるいはその実装のことです。TypeScript や JSX などが有名かと思います。

その Ruby 版、トランスパイルして Ruby スクリプトを取り出せる言語やその実装を AltRb と呼ぶことにしています。 有名なものとしては Ruby Next https://github.com/ruby-next/ruby-next があります。Ruby Next は古い ruby で新しい文法を使えるようにする、いわゆる polyfill を提供するトランスパイラです。

AltRb の利点

自分の好きな文法を使える

AltRb であれば、既存の Ruby 文法に縛られる必要はありません。Ruby 本体への文法の提案はよほどの説得力と多くの賛同がなければ難しいですが、AltRb ならそんなことを気にせずに自分色に染められます。自分で使うだけなら互換性すら気にする必要がありません。

Ruby の既存エコシステムに乗り続けられる

結果として Ruby スクリプトが得られて ruby コマンドで実行できるので、既存の rubygems 等がそのまま使えます。 そのため、既存の Ruby のプロジェクトの一部から使い始めることもできます。

Ruby の改善に乗り続けられる

YJIT をはじめとして、Ruby の改善は衰えることなく日々進められています。 最終的に実行するのが ruby の実行器なのであれば、これらの改善の恩恵を受け続けることができます。

ちょい足し AltRb のススメ

AltRb のいいところを書きましたが、現実的にコストとなる部分ももちろんあります。 まず設計。言語をゼロから設計するのはとても骨が折れる作業です。 次に実装。実行器としては ruby コマンドがあるから楽ですが、それでもトランスパイラを作るということはパーサが必要になってきます。

ですがもし、既存の Ruby 文法をちょっとだけ拡張するだけならどうでしょう? 既存文法を使うのであれば、大部分の設計はせずに済みます。 実装についても、ちょっと加工するだけで Ruby の既存のパーサツールが使えるなら、なんとかなりそうな気がしてきます。 ということで、Ruby の文法をちょっと変えるだけの AltRb、「ちょい足し AltRb」ならいろいろと楽できそうです。

ちょっとの文法違反を乗り越える、エラートレラント

先ほど、「ちょっと加工するだけで Ruby の既存のパーサツールが使えるなら」と書きました。 ということはもちろん、ちょっとであっても加工はしないといけません。文法を拡張するということは、既存の Ruby 文法には収まっていないのですから。 (余談ですが、ここで無理にでも Ruby 文法に収める方向に行くのも賢い選択肢だと思います。)

さて、そのためにパーサを自作するべきでしょうか?せっかくちょい足しにしたのに、あんまり楽になった気がしませんね。 しかし最近、この目的に合致する道具が出現しました。エラートレラントを強い特色として持つ、Prism https://github.com/ruby/prism です。

Prism とエラートレラントの悪用

Prism 自体の説明はいろいろなところでいろいろな人がされているのでここでは簡単にだけ紹介します。 簡単に言うと Prism は Ruby スクリプトをパースして結果を返してくれるライブラリです。 さまざまな特徴を持っていますが、ここでは文法的に正しくない箇所があってもパース処理をできるだけ継続する点に注目していきます。

エラーを含むスクリプトのパース

実際にエラーを含むスクリプトを Prism でパースしてみます。ここでは試しに、インスタンス変数を引数として渡してみます。

require "prism"
result = Prism.parse(<<~INVALID)
  def foo(@bar)
  end
INVALID
p result.value, result.errors

このスクリプトを実行すると、構文木とエラーの両方を手に入れることができます。

$ ruby a.rb
@ ProgramNode (location: (1,0)-(2,3))
├── flags: ∅
├── locals: []
└── statements:
    @ StatementsNode (location: (1,0)-(2,3))
    ├── flags: ∅
    └── body: (length: 1)
        └── @ DefNode (location: (1,0)-(2,3))
            ├── flags: newline
            ├── name: :foo
            ├── name_loc: (1,4)-(1,7) = "foo"
            ├── receiver: ∅
            ├── parameters:
            │   @ ParametersNode (location: (1,8)-(1,12))
            │   ├── flags: ∅
            │   ├── requireds: (length: 1)
            │   │   └── @ RequiredParameterNode (location: (1,8)-(1,12))
            │   │       ├── flags: ∅
            │   │       └── name: :@bar
            │   ├── optionals: (length: 0)
            │   ├── rest: ∅
            │   ├── posts: (length: 0)
            │   ├── keywords: (length: 0)
            │   ├── keyword_rest: ∅
            │   └── block: ∅
            ├── body: ∅
            ├── locals: [:@bar]
            ├── def_keyword_loc: (1,0)-(1,3) = "def"
            ├── operator_loc: ∅
            ├── lparen_loc: (1,7)-(1,8) = "("
            ├── rparen_loc: (1,12)-(1,13) = ")"
            ├── equal_loc: ∅
            └── end_keyword_loc: (2,0)-(2,3) = "end"

[#<Prism::ParseError @type=:argument_formal_ivar @message="invalid formal argument; formal argument cannot be an instance variable" @location=#<Prism::Location @start_offset=8 @length=4 start_line=1> @level=:syntax>]

エラー情報を用いたスクリプトの加工(1) エラーの修正

構文木とエラーの情報があれば、元のスクリプトを加工することも楽にできます。Prism ではノードだけでなくエラーにも位置情報を記録してくれているので、これを使うことができます。

引数の中のインスタンス変数を通常のローカル変数に置き換えてみます。 (余談ですが、細かい工夫として reverse_each を使い末尾に近い位置から加工することで offset がずれるのを防いでいます)

require "prism"

script = <<~INVALID
  def foo(@bar)
  end

  def bar(@buz)
  end
INVALID
result = Prism.parse(script)
result.errors.reverse_each do |error|
  next unless error.type == :argument_formal_ivar

  script[error.location.start_offset, 1] = ""
end
puts script

これで、Ruby 文法として正しいスクリプトを作り出せました。

$ ruby a.rb
def foo(bar)
end

def bar(buz)
end

エラー情報を用いたスクリプトの加工(2) エラー情報を使ったさらなる加工

エラーを直すだけでなく、その後の加工ももちろんできます。

渡したインスタンス変数に引数の値を代入してやることで、インスタンス変数を引数に取ったのと同じ動作にしてみます。

require "prism"

def find_def_node(node, offset)
  return unless ((node.location.start_offset)..(node.location.end_offset)).include?(offset)

  node.compact_child_nodes.each do |child|
    def_node = find_def_node(child, offset)
    return def_node if def_node
  end
  node.type == :def_node ? node : nil
end

script = <<~INVALID
  def foo(@bar)
  end

  def bar(@buz)
  end
INVALID
result = Prism.parse(script)
result.errors.reverse_each do |error|
  next unless error.type == :argument_formal_ivar

  script[error.location.start_offset, 1] = ""
  name = script[error.location.start_offset, error.location.length - 1]
  def_node = find_def_node(result.value, error.location.start_offset)
  script[def_node.rparen_loc.end_offset, 0] = "@#{name} = #{name}\n"
end
puts script

これで、インスタンス変数引数のトランスパイルが完成です。

$ ruby a.rb
def foo(bar)
@bar = bar
end

def bar(buz)
@buz = buz
end

実用的にはここから改行の有無やインデントなど諸々修正していくべきですが、長くなってしまうのでここでは割愛します。

おわりに

Prism のエラートレラント機能を悪用することで、簡単な Ruby 向けトランスパイラを実装する方法を紹介しました。 皆さんも、思い思いにカスタマイズした自分流の文法で AltRb を実装してみてはいかがでしょうか。

おまけ:出来上がったものがこちら

実際にトランスパイラツールとして形にしてみた例として、拙作 ruby_mod_kit へのリンクを載せておきます。 https://github.com/wanabe/ruby_mod_kit

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment