Help us understand the problem. What is going on with this article?

Opalでrougeを動かそうとしている話

3行

Opalとは?

ブラウザで動くRuby

Rougeとは?

RougeはRubyで実装されたSyntax Highlightライブラリです。
つまり、これがOpalで動けばブラウザのみでSyntax Highlightが行えるようになります。面白そうですね。1

とはいえOpalのことがなにも考えられていないライブラリは、一筋縄では動きません。
というわけでどういうhackをしていったのかを紹介していきます。

hack

rougeをrequireするようにする

rougeでは、requireの代わりにload_relativeload_lexerというメソッドが定義されています。
これらはOpalから使う上で都合が悪いので、代わりにrequireで置き換えます。

具体的には、rougeのリポジトリのlib/rouge.rbをコピーしてきて、load_relativeの変わりに単にrequireを使うようにしました。
https://github.com/pocke/rouge-opal/blob/0c82e8ca67f9622a62d3a38a0e323f975606bc2e/lib/rouge.rb

なんでrougeがload_relativeをわざわざ定義して使っているのかはよく分かっていません。これはいらないんじゃなかろうか。

lexerの依存関係を注意してrequireするようにする

rougeではlexerが各言語のSyntax定義を持っています。つまりサポートしている言語の数だけlexerが定義されています。
これらをrougeは次のコードでrequireしています。

# in lib/rouge.rb

Dir.glob(lexer_dir('*rb')).each { |f| Rouge::Lexers.load_lexer(f.sub(lexer_dir, '')) }

当然これもOpalではうまく動かないため、requireを書き下す必要があります。
ただし、単にファイル一覧を適当な順番でrequireしたのではうまく動きません。なぜならばこれらには依存関係があるためです。
たとえばTypeScriptのLexerを見てみましょう。

# lib/rouge/lexers/typescript.rb

module Rouge
  module Lexers
    load_lexer 'javascript.rb'
    load_lexer 'typescript/common.rb'

    class Typescript < Javascript
      # ...
    end
  end
end

TypeScriptはだいたいJavaScriptなので、JavaScriptのLexerを継承しています。
つまりTypeScriptのLexerを読み込む前にJavaScriptのLexerを読み込む必要があるのです。

というわけで、まずは邪魔なRouge::Lexers.load_lexerメソッドを無効化しましょう。
次のモンキーパッチをrouge/lexerをrequireした後に入れます。

# https://github.com/pocke/rouge-opal/blob/2fd9b6257d96f7a004de71ef15913477e7f18b78/lib/rouge.rb#L45-L51
module Rouge
  module Lexers
    def self.load_lexer(_)
      # ignore
    end
  end
end

次に、lexerを依存関係を考慮してrequireします。これにはトポロジカルソートを使います。
Rubyではtsortライブラリでトポロジカルソートを行えます。
https://docs.ruby-lang.org/ja/latest/class/TSort.html

今回はソートされたrequireを列挙するファイルを生成するRake taskを作成しました。2
https://github.com/pocke/rouge-opal/blob/2fd9b6257d96f7a004de71ef15913477e7f18b78/Rakefile#L8-L47

Opalに存在しないライブラリのスタブを作る

OpalにはRubyの標準ライブラリがいくらか含まれていますが、とはいえすべての標準ライブラリが含まれているわけではありません。
そのため今回は使用する範囲で標準ライブラリのスタブを作成しました。

今回作成したのはcgiとthreadです。どちらもOpalは提供していないライブラリです。

cgiは今回必要とする部分では使われていないようだったので、単にrequireが通るよう空のファイルをLOAD_PATHが通ったところに置きました。
https://github.com/pocke/rouge-opal/blob/2fd9b6257d96f7a004de71ef15913477e7f18b78/lib/cgi.rb

threadは、スレッドローカルな値を保存するkey-value storeとして使われていました。
そのため、それだけが動くようなthreadライブラリを作成して、それを使用するようにしています。

# https://github.com/pocke/rouge-opal/blob/2fd9b6257d96f7a004de71ef15913477e7f18b78/lib/thread.rb
class Thread
  def self.current
    @current ||= self.new
  end

  def initialize
    @values = {}
  end

  def [](k)
    @values[k]
  end

  def []=(k, v)
    @values[k] = v
  end
end

Encoding

ブラウザでのJavaScriptのencodingはUTF-16です。
ところがRougeはUTF-8を要求していて、UTF-16の文字列を渡すとエラーになってしまいます。

https://github.com/rouge-ruby/rouge/blob/7e04388a1222bf26ddb660778744eab82a6ea847/lib/rouge/lexer.rb#L284-L292

そのため、String#force_encodingを使って文字列のencodingを変換します。
https://github.com/pocke/rouge-opal/blob/2fd9b6257d96f7a004de71ef15913477e7f18b78/lib/main.rb#L8

respond_to? :format

Rouge.highlightメソッドは、フォーマッタとして渡された引数がformatメソッドを受け付ける場合にはそのまま使い、受け付けない場合にはそれをフォーマッタ名とみなしてフォーマッタを探索するようになっています。

# lib/rouge.rb

formatter = Formatter.find(formatter) unless formatter.respond_to? :format

ところが、Opalではこれがうまく動きませんでした。
具体的には、フォーマッタとして"html"のような文字列を指定した時に、formatter.respond_to? :formattrueを返してしまいます。
一方MRIではこれはfalseを返します。

$ opal-repl
>> "".respond_to? :format
=> true

$ irb
irb(main):001:0> "".respond_to? :format
=> false

これはOpalのバグなんじゃないかなあと思っています。Kernel.#formatが存在するからかな?

今回はRouge.highlightメソッドにモンキーパッチを当てることで対応しました。

まだ動いていない

さて、これだけのhackを当ててもまだOpalでRougeは動きません。
次のようなエラーを出して死んでしまいました。

"empty?: undefined method `empty?' for nil
    at constructor.$method_missing (http://127.0.0.1:43881/index.js:3907:56)
    at constructor.method_missing_stub (http://127.0.0.1:43881/index.js:1310:35)
    at $$49 (http://127.0.0.1:43881/index.js:26064:37)
    at Object.Opal.yieldX (http://127.0.0.1:43881/index.js:1475:18)
    at Function.$call (http://127.0.0.1:43881/index.js:17636:25)
    at constructor.$$16 (http://127.0.0.1:43881/index.js:26351:39)
    at constructor.$instance_exec (http://127.0.0.1:43881/index.js:3815:24)
    at Opal.send (http://127.0.0.1:43881/index.js:1671:19)
    at $$35 (http://127.0.0.1:43881/index.js:26561:15)
    at Object.Opal.yield1 (http://127.0.0.1:43881/index.js:1452:14)"

見ていくと、どうやらRouge::RegexLexer#stream_tokensメソッドの呼び出しで意図せずnilが発生していそうです。
https://github.com/rouge-ruby/rouge/blob/7e04388a1222bf26ddb660778744eab82a6ea847/lib/rouge/regex_lexer.rb#L256-L279

見たところOpalが実装したStringScannerを使っています。
このStringScannerの実装がバグっているのじゃないかなあと予想しています。

そのうち気が向いたらこのエラーの原因を追ってみようと思います。


  1. 実用的には、最初からJavaScriptで書かれたライブラリを使うと便利だと思います。 

  2. 自分の手元で動かすことしか考えていないので、~/ghq/下にrougeがcloneされている必要があります。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away