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

# 3行

* https://github.com/pocke/rouge-opal
* 色々hackを書いた
* まだ動いていない



# Opalとは？

ブラウザで動くRuby

# Rougeとは？

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



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




# hack


## rougeをrequireするようにする

rougeでは、requireの代わりに`load_relative`や`load_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`しています。

```ruby
# in lib/rouge.rb

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

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


```ruby
# 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した後に入れます。

```ruby
# 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ライブラリを作成して、それを使用するようにしています。


```ruby
# 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`メソッドを受け付ける場合にはそのまま使い、受け付けない場合にはそれをフォーマッタ名とみなしてフォーマッタを探索するようになっています。

```ruby
# lib/rouge.rb

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

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

```ruby
$ 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されている必要があります。