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