Opalとモンキーパッチでrubyの各種gemをJavaScriptから利用する


[Opal](http://opalrb.com/) によってrubyの各種gemを利用してJavaScriptで実装したブラウザ上で動作するアプリケーションを作ることができます。

私は [スモウルビー3](https://smalruby.jp/smalruby3-gui) [(GitHub)](https://github.com/smalruby/smalruby3-gui) という[Scratch 3.0](https://beta.scratch.mit.edu) ベースのビジュアルプログラミングツールを開発しているのですが、ユーザーが入力したRubyスクリプトを解析して命令ブロックに変換する機能を実現するために [Opal](http://opalrb.com/) を使って [parser gem 0.11.4](https://github.com/whitequark/parser) をJavaScriptから利用しています。

```html
<script src="static/javascripts/opal.min.js" onload="Opal.load('opal')" />
<script src="static/javascripts/opal-parser.min.js"
        onload="Opal.load('opal-parser');
                Opal.load('parser');
                Opal.load('parser/ruby23');
                Opal.Parser.CurrentRuby = Opal.Parser.Ruby23;" />
```

```javascript
const root = Opal.Parser.CurrentRuby.$parse('move(10)');
console.log(root.$to_s());
// =>
// (send nil "move"
//   (int 10))
```

## Syntax Error が拾えない！？

しかしながら、実際に使ってみると Syntax Error が発生したときに [parser gem](https://github.com/whitequark/parser) が挙げる例外が期待したものとは異なることがわかりました。

```javascript
try {
    Opal.Parser.CurrentRuby.$parse('move(10'); // 閉じカッコが抜けている
} catch (e) {
    console.log(e);
}
// =>
// 期待しているのは
// $SyntaxError {
//     name: "SyntaxError",
//     message: "unexpected token $end",
//     ...
// なのですが、実際には以下の例外が挙がる。
// $NotImplementedError {
//    name: "NotImplementedError",
//    message: "String#chomp! not supported. Mutable String methods are not supported in Opal.",
//    ...
```

Syntax Error が発生したソースコード上の場所と、ソースコード自身を取得したかったので、これでは困ります。

例外のメッセージ
`String#chomp! not supported. Mutable String methods are not supported in Opal.`
を良く見てみると、エラーの原因は明確で [parser gem](https://github.com/whitequark/parser) の中で、 `String#chomp!` によって文字列の破壊的な操作をしている箇所があり、そこで例外が発生していることがわかります。

[Opal](http://opalrb.com/) では Rubyの文字列を JavaScript の文字列として表現しているため、破壊的な操作は一切できません。Rubyのソースコードでそのような箇所があれば、非破壊的な操作で実装しなおす必要があります。

調べた結果、 `Parser::Source::Buffer#source_lines` でソースコードの各行の末尾の改行を取り除いて、配列に格納している箇所で `String#chomp!` を使っていることがわかりました。

```ruby
module Parser
  module Source
    class Buffer
      def source_lines
        @lines ||= begin
          lines = @source.lines.to_a
          lines << ''.dup if @source.end_with?("\n".freeze)

          lines = lines.map do |line|
            line.chomp!("\n".freeze) # <= String#chomp!は使えない
            line.freeze
            line
          end

          lines.freeze
        end
      end
    end
  end
end
```

## JavaScriptでparser gemにモンキーパッチ

さて、どうしたものかと思っていたのですが、[Opal](http://opalrb.com/) を使えばブラウザ上で Ruby スクリプトを実行できるため、いつものようにモンキーパッチで回避させればいいことに気が付きました。作成したモンキーパッチとそれを適用するJavaScriptのコードが以下です。

```javascript
Opal.eval(`
module Parser
  module Source
    class Buffer
      def source_lines
        @lines ||= begin
          lines = @source.lines.to_a
          lines << ''.dup if @source.end_with?("\n".freeze)

          lines = lines.map do |line|
            line = line.chomp("\n".freeze) # <= String#chompで置き換え
            line.freeze
            line
          end

          lines.freeze
        end
      end
    end
  end
end
`);
```

さらにもう一箇所、同じように文字列の破壊的な操作をしている箇所がありました。 `String#[]=(range, 文字列)` です。こちらは少し置き換えるのが難しかったのですが、なんとか以下のようにして回避できました。

```javascript
Opal.eval(`
module Parser
  class Diagnostic
    def render_line(range, ellipsis=false, range_end=false)
      source_line    = range.source_line
      highlight_line = ' ' * source_line.length

      @highlights.each do |highlight|
       line_range = range.source_buffer.line_range(range.line)
        if highlight = highlight.intersect(line_range)
          highlight_line[highlight.column_range] = '~' * highlight.size
        end
      end

      if range.is?("\n")
        highlight_line += "^"
      else
        # 元のコード: 「highlight_line[range.column_range] =」がNG
        # if !range_end && range.size >= 1
        #   highlight_line[range.column_range] = '^' + '~' * (range.size - 1)
        # else
        #   highlight_line[range.column_range] = '~' * range.size
        # end
        #      
        if !range_end && range.size >= 1
          s = '^' + '~' * (range.size - 1)
        else
          s = '~' * range.size
        end
        end_pos = range.column_range.end
        if !range.column_range.exclude_end?
          end_pos += 1
        end
        highlight_line = highlight_line[0...range.column_range.begin] + s + highlight_line[end_pos..-1]
      end

      highlight_line += '...' if ellipsis

      [source_line, highlight_line].
        map { |line| "#{range.source_buffer.name}:#{range.line}: #{line}" }
    end
  end
end
`);
```

最終的には
https://github.com/smalruby/smalruby3-gui/blob/develop/src/lib/ruby-parser.js
のようにしてモンキーパッチを適用しています。

これで `Opal.Parser.CurrentRuby.$parse()` で Syntax Error を拾うことができるようになりました。

```javascript
try {
    Opal.Parser.CurrentRuby.$parse('move(10'); // 閉じカッコが抜けている
} catch (e) {
    console.log(e);
}
// =>
// $SyntaxError {
//     name: "SyntaxError",
//     message: "unexpected token $end",
//     ...
```

## まとめ: Opal とモンキーパッチでrubyの各種gemがJavaScriptから利用できる

[Opal](http://opalrb.com/) と *少しのモンキーパッチによって* rubyの各種gemを利用してJavaScriptで実装したブラウザ上で動作するアプリケーションを作ることができます。

みなさんも Ruby だったらあんな風に実現できるけど、JavaScriptだと難しいな...ということがあれば、 [Opal](http://opalrb.com/) を試してみてください。

## 余談: node.js でも Opal を使う

スモウルビー3の単体テストは node.js で実行していますが、ブラウザ用の opal.min.js を使うことができませんでした。そのため、単体テストでは node.js でも動作する [opal-runtime](https://www.npmjs.com/package/opal-runtime) と [opal-compiler](https://www.npmjs.com/package/opal-compiler) を使って、次のようにして単体テストの実行前に  `Opal.Parser.CurrentRuby` を設定して回避しています。

```javascript
let globalObject = console;
if (typeof (global) !== 'undefined') {
    globalObject = global;
}
if (typeof (window) !== 'undefined') {
    globalObject = window;
}
globalObject.Opal = require('opal-runtime').Opal;
require('opal-compiler');
globalObject.Opal.load('parser');
globalObject.Opal.load('parser/ruby25');
globalObject.Opal.Parser.CurrentRuby = globalObject.Opal.Parser.Ruby25;
```

どなたかブラウザでもnode.jsでもどちらでも使える `opal.js` と `opal-parser.js` に対応した npm を作ってもらえないですかね :sweat_smile: