ripper 週末クッキング その0

rubyの文法は汚ない。解析する気すら起きない」という友人Tの言葉に思わず同意してしまったので、そういう問題を解決するために作られている(はずの)Rubyソース解析ライブラリripperを使って、RubyソースをHTMLにしてハイライト(色付け)するスクリプトを書いてみることにした。以後、リアルタイムぎみに御届けします。

例によってアバウトな仕様

  • Rubyソースを入力として、HTMLを出力する
  • HTML出力では、Rubyソースに色付けを行う
  • class,def,begin,end等 予約語を明るく
  • コメント部分は暗めに
  • ヒアドキュメントの部分は、、取れるの??

さーまず、ripperの解説を読もう。

ripper 週末クッキング その1 ripperはどこに?

ripperってどこに転がってるんだっけ?いきなり見つかりません。
http://rubyforge.org/projects/ripper/
CVSから拾ってくればいいのかな。なんか古そうだけど。最近、1.9に統合されたという話を見るのだが、1.8系に対応したripperってもう開発ストップ?
さすがに見切り発車すぎて クッキングシリーズ三段目にして途絶の危機か?

CVSからtarボールをひろってきて、ruby extconf.rb;make;するもエラー。gprefがなかったらしい。apt-get install gprefして続行。
今度は、ripper.cのコンパイルでこけた。

… なんか駄目ぽ。明日にしよう。
やりなおしたら、すっと通りました。今度は、make testがこける。test/unitがないらしい。再びapt-get install libtest-unit-ruby。おーし。テストとおった。おめでとう自分。
というわけでインストールは完了。なんのことはない、必要なものが全てそろっていたら一発でいくはずでしたね。

ripper 週末クッキング その2 ripperのお勉強

まずサンプルをたぐってみる。
count-if.rbがやることがわかりやすくてよいかも。短いし、ということで読む。
わかったこと

  • Ripperを継承したクラスが基本
  • Ripperには、解析する文字列を与えてnew
  • メソッドを定義して、parseで解析開始。あとはメソッドまかせ?確かSAX的に使うという話だったので、解析結果の要素にしたがって順に対応するメソッドが呼ばれるのだろう。
  • on__kw(word)というメソッドでキーワードが拾えるらしい。wordはキーワードの種類だろう。中身は文字列かな?キーワードが来た!というイベントに対応するメソッドですね。
  • method_missingも定義されているが、こちらは何をしているかいまいちわからず。

というわけで、一番謎なmethod_missingを、引数の中身を表示させるように書きかえてみる。
実行。ぽち。
:on__spというシンボルと、空白の文字列(個数はいろいろ)がかえってきている。要は、spというイベントを発生させたいのだがそれに対応するメソッドがない、ということかな。その後のはソース中の文字列とみた。
というわけで、def on__sp(word)して、wordを表示させてみた。予想どおり空白文字がどどっと表示された。ふむ。要は空白は無視されるはずだから対応するメソッドは記述されてない、ってことかな。

てことは、Ripperが発行するイベントを全て把握すれば使えるようになるはずだな。ついでに、on__kwで補足されるwordも表示してみた。def,do,end,if,elsif等々、キーワードが文字列で拾えてますね。よし。

ripper 週末クッキング その3 ripperが吐くイベント

on__spは定義されてなかったけど、method_missingで拾えたのがon__spくらいだったので、他のメソッド(イベント)はあらかじめ定義されていると当りをつけて、lib/ripper.rbを読んでみた。予想どおり、on__xxなるメソッドが羅列されている。Parser EventsとLexer Eventに分かれているらしい。わかりやすいコメントですね。

イベントの種類としては、Parser Eventsとして定義されているやつを押さえれば十分だろう。Lexer Eventsはon__scanを除いてひとまず無視。on__scan(event,token)という中身のないメソッドがあるが、これは恐らくイベント生成時のフック用だと予想して、またもeventとtokenを表示させるものを書いてみた。結果はビンゴ。event名のシンボルとその時のtokenが羅列されました。

というわけで、「ここはどういうイベントになるんだー?」と思ったときは、上記の内容表示on__scanを書いたものでソースを解析させれば、そのトークンが何のイベントを発生させるかわかるわけですね。たぶんParser Eventsのメソッド名みたら予想つくと思うけど。よし。

ripper 週末クッキング その4 できました

まず、いろいろ勘違いしてたところを訂正。
on__scanが返すイベントは、Parser Eventsじゃなくて、Lexer Eventsのほうだった。Lexerのほうが字句解析なんだから、スキャナーのon_scanでフックできるイベントはLexerのほうだよね。
そして、ハイライトするためには構文解析なんて必要なくて、字句解析のレベルでそのトークンが何かわかればよかった。なので、Parser Eventsじゃなくて、Lexer Eventsのほうを使った。
でも、字句解析レベルだとヒアドキュメント、文字列等の「その中に違う字句を持つけど、ひとくくりにして扱いたい部分」を自分で管理する必要があった。このへん、もしかしたらParser Eventsと組みあわせてやれば楽にできたのかもしれないけど。
というわけで、on__kw等の色付けに必要な部分のLexer Eventに対応するメソッドを書いて、状態管理の必要なところはon__scanの中で管理。状態遷移の管理がはいったので、on__scanがちょっと複雑になっちゃいました。

できたもの

それぞれ以下のような色付けをします。TAB幅は8に決めうちです。色はCSSを埋めこんで制御してるので適当にいじれば適当に変わります。

キーワード,演算子
定数
maroon
文字列,シンボル,正規表現リテラル,数字
グローバル/クラス/インスタンス変数
ヒアドキュメント
#ff00ff

今度は、Lexer EventだけじゃなくてParser Eventの処理が必要なのを作ってみないと駄目だな。


せっかくなのでソース自体をHTML化したやつをはりつけておこう。テスト用に変なのもまじってますが。
と思ったら、はてなではHTMLタグが無効化されるのかな?よくわからない。すこし格闘してみたが、最終的には
がちゃんと機能してくれないのであきらめ。
brを出力するのをやめて、pとCSSで制御するようにした。ふー。
はてな用にCSSを微調整してはりつけ。えらい苦労した。
そうか、よく考えたら一段階目の出力は抽象的な構造にしといて、はてなやらWikiやら普通のHTMLやら出力先によってシリアライザーを書きわけるべきだったか。

ripper 週末クッキング その5 シリアライザーの分離

というわけで、解析部分とシリアライズ部分を分けてみた。
いまのとこ、素のHTMLを一枚生成するのと、はてな用の文字列を生成するのと二種類。
ソースはこんなかんじ。
とおもったら、

|と< >

が干渉して途中でおかしくなりやがりますね。やれやれ。強引に置換することにする。

# highlight.rb by sshi  

  

$global = 1 

test = /regexp/ 

require 'ripper' 

require 'cgi' 

 

hoge = :test;:test 

 

class HighLight < Ripper 

 

  def self.highlight(src) 

    obj = new(src) 

    obj.parse 

    obj.ret 

  end 

  attr_reader :ret 

   

  def initialize(src) 

    super src 

    @ret = [] 

  end 

   

  def serialize(serializer) 

    serializer.new(@ret).process 

  end 

   

  def add(word,klass = nil

    if klass == nil 

      @ret << word.gsub(/\n$/,''

    else 

      @ret << [klass,word.gsub(/\n$/,' ')] 

    end 

    @ret << ['newline',nilif word =~ /\n$/ 

  end 

   

  @@event_list = %w(kw gvar int ivar cvar const op embdoc embdoc_beg embdoc_end comment) 

  @@sym_list = @@event_list.map do  | event |  

    "on__#{event}".to_sym 

  end 

  @@event_list.each do  | event |  

    self.class_eval<<EOS  

    def on__#{event}(word)  

      add(word,'#{event}')  

    end  

EOS 

  end 

 

  def on__scan(event,token) 

    case @state 

    when :heredoc 

      if event == :on__heredoc_end 

        @state = :none  

        add(token.gsub(/\n$/,''),"heredoc"

      else 

        add(token,"heredoc"

      end 

    when :sym 

      add(token,'sym'

      @state = :none 

    when :tstring 

      add(token,'tstring'

      @string_stack += 1 if event == :on__tstring_beg 

      if event == :on__tstring_end 

        @string_stack -= 1 

        @state = :none if @string_stack == 0 

      end 

    when :regexp 

      add(token,'regexp'

      @state = :none if event == :on__regexp_end 

    else 

      case event 

      when :on__heredoc_beg 

        add(token+"\n",'heredoc'

        @state = :heredoc 

      when :on__tstring_beg 

        add(token,'tstring'

        @state = :tstring 

        @string_stack = 1 

      when :on__symbeg 

        add(token,'sym'

        @state = :sym 

      when :on__regexp_beg 

        add(token,'regexp'

        @state = :regexp 

      else 

        add(token) unless @@sym_list.include?(event) 

      end 

    end 

  end 

 

  def method_missing( mid, *args );end 

end 

 

require 'cgi' 

 

class HTMLSerializer 

 

  def initialize(code_ar) 

    @ar = code_ar 

  end 

   

  def e(str) 

    return CGI.escapeHTML(str).gsub(/ /,'&nbsp;').gsub(/\t/,'&nbsp;'*8

  end 

 

  def contents 

    @ret = "" 

    @ar.each do  | item |  

      if item.class == String 

        @ret += e(item) 

      else 

        klass = item[0

        word = item[1

        if klass == 'newline' 

          @ret += "&nbsp;</p>\n<p class='br'>" 

        else 

          @ret +="<span class='#{klass}'>#{e(word)}</span>" 

        end 

      end 

    end 

    @ret 

  end 

   

  def process 

    return<<EOS_h  

<html>  

<head>  

<style>  

body {  

  font-family:monospace;  

 

  

.heredoc {  

  color:#ff00ff;  

 

  

.comment ,.embdoc,.embdoc_beg,.embdoc_end{  

  color:gray;  

 

  

.kw ,.op{  

  color:red;  

 

  

.const {  

  color:maroon;  

 

  

.tstring ,.sym,.regexp {  

  color:blue;  

 

.gvar , .cvar , .ivar {  

  color:green;  

 

  

.int {  

  color:blue;  

 

  

p.br {  

  margin:0pt;  

  padding:0pt;  

 

</style></head>  

<body>  

<p class='br'>  

#{contents}  

</p>  

</body>  

</html>  

EOS_h 

  end 

end 

 

class HATENA < HTMLSerializer 

  def process 

    return<<EOS_ha  

><div class="highlight" style="overflow:scroll;">  

<p class='br'>  

#{contents.gsub(/\ | /,'  |  ')}  

</p>  

</div><  

EOS_ha 

  end 

end 

 

if __FILE__ == $0 

  p = HighLight.new(ARGF

  p.parse 

  #puts p.serialize(HTMLSerializer)  

  puts p.serialize(HATENA

end 

はてな用の文字列を生成して、はてなダイアリーに貼り付ける時には、あらかじめCSSを追加しておかないといけない。例えばこんなの。

.heredoc {
  color:#ff00ff;
}

.comment ,.embdoc,.embdoc_beg,.embdoc_end{
  color:gray;
}

.kw ,.op{
  color:red;
}

.const {
  color:maroon;
}

.tstring ,.sym,.regexp {
  color:blue;
}
.gvar , .cvar , .ivar {
  color:green;
}

.int {
  color:blue;
}

div.highlight {
  font-family:monospace;
  background:white;
  padding:0pt;
  margin:0pt;
  overflow:scroll;
  font-size: x-small;
}

div.highlight p.br {
  margin:0;
  padding:0;
  text-indent: 0em;
  line-height:1;
}

HTML用に生成した時は、これらのスタイルは自動的に埋めこまれる。