2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Opal (Ruby-to-JavaScript compiler)Advent Calendar 2018

Day 3

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

Last updated at Posted at 2018-12-03

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

私は スモウルビー3 (GitHub) というScratch 3.0 ベースのビジュアルプログラミングツールを開発しているのですが、ユーザーが入力したRubyスクリプトを解析して命令ブロックに変換する機能を実現するために Opal を使って parser gem 0.11.4 をJavaScriptから利用しています。

<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;" />
const root = Opal.Parser.CurrentRuby.$parse('move(10)');
console.log(root.$to_s());
// =>
// (send nil "move"
//   (int 10))

Syntax Error が拾えない!?

しかしながら、実際に使ってみると Syntax Error が発生したときに parser gem が挙げる例外が期待したものとは異なることがわかりました。

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 の中で、 String#chomp! によって文字列の破壊的な操作をしている箇所があり、そこで例外が発生していることがわかります。

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

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

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 を使えばブラウザ上で Ruby スクリプトを実行できるため、いつものようにモンキーパッチで回避させればいいことに気が付きました。作成したモンキーパッチとそれを適用する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, 文字列) です。こちらは少し置き換えるのが難しかったのですが、なんとか以下のようにして回避できました。

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 を拾うことができるようになりました。

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

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

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

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

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

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

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.jsopal-parser.js に対応した npm を作ってもらえないですかね :sweat_smile:

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?