Ruby
SATySFi

SATySFi で Ruby のソースコードをシンタックス ハイライトする

結論

前処理でRubyのコードをパースしてSATySFiのインラインコマンドに変換し、SATySFiのインラインコマンドを定義して色付けをする感じで実装した。

結果

Screenshot from 2018-10-09 12-44-24.png

https://github.com/hanachin/satysfi-syntax-highlight-sample/releases/tag/v0.0.1

ソースコードはここにあります。
https://github.com/hanachin/satysfi-syntax-highlight-sample

実装方針

以下のような方法が考えられます。

  • SATySFi(言語の方)で字句解析する
  • SATySFi(言語実装の方)で字句解析する
  • SATySFiでは字句解析をせず前処理として外部で字句解析を行い結果を.satyに変換する

ここでは一番最後の方針で実装します。

理由

SATySFi(言語の方)で字句解析を実装するにはまだまだSATySFiでのプログラミング経験が足りません。
SATySFi(言語実装の方)に手を加えるにもOCamlを書いた経験がなく学習に時間がかかりそうです。
一番最後の前処理を挟む方針だと書き慣れたRubyで実装することが出来ます。

前処理でやること

  • Rubyのプログラムのソースコードを受けとり字句解析する
  • 字句解析した結果をSATySFiのインラインコマンドに変換しsatyに埋め込む

字句解析にはiroを使います。
埋め込みにはerbを使います。

実装

こんな感じでRubyのコードをパースしてSATySFiのコマンドに変換して書き出す

highlight.rb
require "iro"

using Module.new {
  refine(Array) do
    def type
      self[0]
    end

    def lineno
      self[1] - 1
    end

    def column
      self[2] - 1
    end

    def length
      self[3]
    end
  end
}

INLINE_CMD_MAP =
  {
    "String" => "code-string",
    "Charactor" => "code-character",
    "Number" => "code-number",
    "Float" => "code-float",
    "Comment" => "code-comment",
    "Type" => "code-type",
    "Delimiter" => "code-delimiter",
    "rubyDefine" => "code-define",
    "keyword" => "code-keyword",
    "rubyFunction" => "code-function",
    "rubyLocalVariable" => "code-lvar"
  }

def highlight_positions(code)
  tokens = Iro::Ruby::Parser.tokens(code)
  tokens.flat_map {|group, poss|
    poss.map {|pos|
      [INLINE_CMD_MAP.fetch(group), *pos]
    }
  }.sort_by {|pos|
    [pos.lineno, pos.column]
  }
end

def highlight_code(cmd, code)
  code.each_char.chunk {|c| c == " " }.map do |space, cs|
    if space
      "\\code-space;" * cs.size
    elsif cs.empty?
        ""
    else
        "\\#{cmd}(`#{cs.join}`);"
    end
  end.join
end

def highlight(code)
  hs = highlight_positions(code)

  saty = ''
  code.each_line.with_index do |line, lineno|
    line_hs = hs.select {|h| h.lineno == lineno }

    saty << highlight_code("code-other", line) and next if line_hs.empty?

    last_column = 0
    line_hs.each do |h|
      saty << highlight_code("code-other", line[last_column...h.column])
      last_column = h.column + h.length
      saty << highlight_code(h.type, line[h.column...last_column])
    end
    saty << highlight_code("code-other", line[last_column..-1])
    saty << "\\code-new-line;"
  end
  saty
end

こういう感じでerbからhighlightメソッドを呼び出します。
Rubyのソースコードをヒアドキュメントで書いてhighlightメソッドに渡しています。

sample.saty.erb
    +code-highlight {
      <%=
      highlight(<<~'RUBY')
        # Greet to you!
        def hello(name)
          puts "Hello, #{name}"
        end

        hello('your name')
      RUBY
      %>
    }

こういう感じに変換されます。

sample.saty
    +code-highlight {
      \code-comment(`#`);\code-space;\code-comment(`Greet`);\code-space;\code-comment(`to`);\code-space;\code-comment(`you!`);\code-other(`
`);\code-new-line;\code-define(`def`);\code-space;\code-function(`hello`);\code-other(`(name)
`);\code-new-line;\code-space;\code-space;\code-other(`puts`);\code-space;\code-delimiter(`"`);\code-string(`Hello,`);\code-space;\code-delimiter(`#{`);\code-lvar(`name`);\code-delimiter(`}`);\code-delimiter(`"`);\code-other(`
`);\code-new-line;\code-define(`end`);\code-other(`
`);\code-new-line;\code-other(`
`);\code-other(`hello(`);\code-delimiter(`'`);\code-string(`your`);\code-space;\code-string(`name`);\code-delimiter(`'`);\code-other(`)
`);\code-new-line;
    }

あとは対応するインラインコマンドをSATySFiで実装して、色とかつければ完成。

local.satyh
@require: color
@require: gr

module Highlight : sig
  direct \code-string    : [string] inline-cmd
  direct \code-character : [string] inline-cmd
  direct \code-number    : [string] inline-cmd
  direct \code-float     : [string] inline-cmd
  direct \code-comment   : [string] inline-cmd
  direct \code-type      : [string] inline-cmd
  direct \code-delimiter : [string] inline-cmd
  direct \code-define    : [string] inline-cmd
  direct \code-keyword   : [string] inline-cmd
  direct \code-function  : [string] inline-cmd
  direct \code-lvar      : [string] inline-cmd
  direct \code-other     : [string] inline-cmd

  direct \code-space : [] inline-cmd
  direct \code-new-line : [] inline-cmd
  direct +code-highlight : [inline-text] block-cmd
end = struct
  let highlight ctx color code =
    read-inline (ctx |> set-text-color color) (embed-string code)

  let-inline ctx \code-string    code = highlight ctx (RGB (1.0,  0.43, 0.43)) code
  let-inline ctx \code-character code = highlight ctx (RGB (1.0,  0.43, 0.43)) code
  let-inline ctx \code-number    code = highlight ctx (RGB (1.0,  0.43, 0.43)) code
  let-inline ctx \code-float     code = highlight ctx (RGB (1.0,  0.43, 0.43)) code
  let-inline ctx \code-comment   code = highlight ctx (RGB (0.6,  0.60, 0.60)) code
  let-inline ctx \code-type      code = highlight ctx (RGB (1.0,  0.86, 0.42)) code
  let-inline ctx \code-delimiter code = highlight ctx (RGB (0.78, 0.29, 1.00)) code
  let-inline ctx \code-define    code = highlight ctx (RGB (0.28, 1.00, 0.65)) code
  let-inline ctx \code-keyword   code = highlight ctx (RGB (0.24, 0.75, 1.00)) code
  let-inline ctx \code-function  code = highlight ctx (RGB (0.24, 0.75, 1.00)) code
  let-inline ctx \code-lvar      code = highlight ctx (RGB (0.24, 0.75, 1.00)) code
  let-inline ctx \code-other     code = highlight ctx (RGB (0.87, 0.87, 0.87)) code

  let-inline ctx \code-space =
    inline-skip (get-natural-width (read-inline ctx {0}))

  let-inline ctx \code-new-line =
    inline-fil ++ discretionary 0 (inline-skip((get-text-width ctx) *' 2.)) inline-nil inline-nil

  let code-decoset =
    let deco (x, y) w h d =
      [ fill (RGB (0.2, 0.2, 0.2)) (Gr.rectangle (x, y -' d) (x +' w, y +' h)) ]
    in
      (deco, deco, deco, deco)
  let code-fill-color = (RGB(0.975, 0.975, 1.))
  let code-stroke-color = (RGB(0., 0., 0.25))
  let code-pads = (5pt, 5pt, 5pt, 5pt)
  let code-ctx ctx =
    let mono-ctx = ctx |> set-font Latin (`lmmono`, 1., 0.) in
    let fontsize = get-font-size mono-ctx in
    let charwid = get-natural-width (read-inline mono-ctx {0}) in
    mono-ctx |> set-space-ratio (charwid /' fontsize) 0. 0.
  let-block ctx +code-highlight inner =
    block-frame-breakable ctx code-pads code-decoset (fun ctx -> (
      line-break true true (code-ctx ctx) (read-inline (code-ctx ctx) inner)
    ))
end

今後

Rubyで実装した部分を徐々にSATySFi実装に置き換えていけば前処理としてerbを実行する必要がなくなる、はず。

c10d39a462b1931ddf8dc6566e51818c.jpg