GoでCommonMarkのパーサを実装しました。

分かりやすいASTに変換+拡張が容易、そこそこ速い実装になっています。

めちゃくちゃしんどかったです。

経緯

Go言語のMarkdownパーサといえばblackfridayですが、 拡張するための機構がないのでさくっと自前でMarkdownパーサを書くか、と思い立ちました。

そこで「そういえばCommonMarkなんてもんがあったな」と思い出しせっかくならCommonMark準拠にするかと おもってCommonMarkの仕様を読み始めました。

え、なにこれは…

Markdownで出来ることなんてrestructuredTextなどほかのマークアップ言語に比べれば わずかなものです。しかし、たかがそれだけを実装するために凄まじく複雑な仕様 が定義されているのでした。

以下、CommonMarkに寄せられた声です。

しまいには、CommonMarkの中心人物自体が「えらいもん作ってしまった・・・」となっています。

There are 17 principles governing emphasis , for example, and these rules still leave cases undecided. The rules for list items and HTML blocks are also very complex. All of these rules lead to unexpected results sometimes, and they make writing a parser for CommonMark a complex affair. I despair, at times, of getting to a spec that is worth calling 1.0.

私も仕様を読んだ段階で「これはヤバい」と感じたのですがここで引くのも悔しかったので作り切りました。

CommonMarkについて思うところ

正直なところ、これが世に広まるってどうなの、と思います。

そもそもなんでMarkdownが軽量マークアップ言語の中でこれだけ広く使われるようになったのかというと

  • 書き手としての書きやすさ
  • 実装者としての実装しやすさ

つまり、「書き手の適当さ」と「実装側の適当さ」がいい具合に噛み合ったからだと思っています。 書き手はそれなりに適当に書けるし、実装するにしても仕様が適当なので、適当に実装してもMarkdown 対応であると言うことができました。

それによってあらゆる言語、さまざまなタイプの実装が生まれそれが至るところで使われることになり Markdownは広まっていったのです。

それに対して、CommonMarkはあまりに実装するのがしんどすぎます。さらにはそれだけしんどい思いを しても出来ること自体は少ないのです。テーブルさえ使えないのです。

その実装の難しさからCommonMark対応のパーサはいわゆるMarkdownパーサと比べるとわずかしかありません。 CommonMarkにかかわっている人の中には「それが何の問題ですか?」という人さえいます。 「CとJSで参照実装提供してるじゃん。ブラウザはC動かないからJSも用意してある。それ以外の言語は C実装のバインディング作ればいいだけでしょ。実装なんて1個あればいいんだよ」なんて感じです。

CommonMarkはオリジナルのMarkdown作者から「Markdownの名前は使うな」と言われてCommonMarkという名前 になったという経緯があります。

Markdownを冠していないのであれば、perlの神正規表現で実装されたオリジナルのMarkdown.plの動きにでき るだけ寄せようとするのではなく、もっと仕様をシンプルにする方向に動いて欲しかったです。

CommonMarkパーサを実装したい人に向けて

とにかく、CommonMarkに準拠するのは難しい。ということでいるかいないかわからない、CommonMarkパーサを これから書こうと言う人に気づきを共有しておきます。

  • いわゆるプログラミング言語やXML, JSONなどのパースとは全く別物です。そういうものはもともと 「パースしやすいように」という視点で文法が作られてますが、CommonMarkはそんなことはありません。 pegベースのパーサもあるので無理じゃないですがいわゆるLL,LRやLALRのような方法では厳しいです。
  • Markdownはいうなれば「行志向」なので業単位を基本としましょう。
  • 「lazy continuation」はかなり曲者です。
  • 仕様策定者自身が Beyond Markdown で述べている 部分は間違いなくしんどいです。特に強調は自分で考えるのは諦めましょう。素直に参照実装と同じ アルゴリズムで実装するより他にspec testを通すのは難しいです。
  • タブが本来のタブの意味でつかわれる点に注意。つまりタブ文字の位置によりタブは 1文字分、2文字分、3文字分、4文字分、いずれの文字幅にもなりえます。
  • とにかく折れない心が大事です。CommonMarkとMarkdownは別物です。覚悟してかかりましょう。

相変わらずメインのマシンはWindowsなのですが、batファイルのもろもろがいつまでも覚えられず、bashスクリプトで書きたいなあ、ということでbatファイルにbashスクリプトを埋め込むことにしました。 Cでつくった自作用ツールがあるため、Windowsにはかならずmsys2をいれているのでbashは絶対あるんですよね。

その他の言語を埋め込む方法は

が詳しいです。

シェルスクリプトをバッチファイルに埋め込む

以下のようにすると、バッチファイルにシェルスクリプト(bashスクリプト)を埋め込むことにできます。拡張子 .bat で保存すると cmd.exe からもmsys2の bash.exe からも起動できます。

:rem () { <<'#__CO__'
@bash  "%~f0" %* & exit /b
#__CO__
}

echo 以下普通にシェルスクリプト

仕組みはバッチファイルのコメントやら、bashのヒアドキュメントやら、: コマンドやらをうまく利用して埋め込んでいます。


Go Advent Calendar 2016 16日目です。去年に引き続き今年も3つカレンダーがあり相変わらずの人気ですね。

さて、Go1.8では待望?のShared Libraryのロードが可能になります。 pluginパッケージ を使います。

Go1.8beta1ではLinuxとMacOSがサポートされていたのですが、MacOSで問題が見つかりbeta2ではLinuxのみで利用可能な機能となります。

Advent Calendar 2の qt-luigiさんのネタ と被ってしまったのですが実戦的に使ってみました、ということで許してください。

プラグインの作成とコンパイル

マニュアルページにあるとおり、以下のようになります。

package main

// // No C code needed.
import "C"

import "fmt"

var V int

func F() { fmt.Printf("Hello, number %d\n", V) }

ポイントは

  • Cのコードはないが、 import "C" が必要
  • packagemain

という2点です。コンパイルは

$ go build -buildmode=plugin -o plugin.so plugin.go

でOK。簡単ですね。これで plugin.so が生成されます。

プラグインのロード

これまたマニュアルページどおりですが

p, err := plugin.Open("plugin.so")
if err != nil {
    panic(err)
}
v, err := p.Lookup("V")
if err != nil {
    panic(err)
}
f, err := p.Lookup("F")
if err != nil {
    panic(err)
}
*v.(*int) = 7
f.(func())() // prints "Hello, number 7"

のように非常に直感的に使えます。

GopherLuaで使ってみた

拙作のPure GoによるLua実装 GopherLua ですが(何気にstarいっぱいでうれしいですね)、当然ながらC言語実装のように共有ライブラリをロードできませんでした。

そのため、必要なライブラリはすべて事前に組み込んでおく必要がありました。そこでGo1.8で共有ライブラリロードを実装できるのか、実装できるだろうけどちゃんと動くのか、と思い試してみました。

こちらは feature-exp-go1.8pluginsブランチ で実際に動かせます。プラグイン部分のコミットは 571b031 です。

まずプラグイン側から。Luaのお作法通りです。

package main

import (
    "C"
    "github.com/yuin/gopher-lua"
)

func Add(L *lua.LState) int {
    v1 := L.CheckInt(1)
    v2 := L.CheckInt(2)
    L.Push(lua.LNumber(v1 + v2))
    return 1
}

func LuaOpenPlugin(L *lua.LState) int {
    L.Push(
        L.SetFuncs(L.NewTable(), map[string]lua.LGFunction{
            "add": Add,
        }))
    return 1
}

C実装のLuaでは luaopen_共有ライブラリファイル名 が実行されるのですがそこはGoの命名規則に合わせました。違いはそれくらいですね。

こいつをコンパイルして・・・

$ cd /home/yuin/tmp/plugin
$ go build -buildmode=plugin -o plugin.so plugin.go

こうじゃ

$ glua
> package.cpath = package.cpath .. ";" .. "/home/yuin/tmp/plugin/?.so"
> adder = require("plugin")
> print(adder.add(1, 2))
3

おおおおおおおおおおおおお

普通に動きますね。素晴らしい。ちなみに、「ロードする側」と「ロードされる側(すなわちプラグイン)」のバージョンが違うと以下のようにエラーになります。この判定が結構厳しいので(プラグインが参照していない部分の更新でもダメっぽい)、事前にプラグインをコンパイルしておいて配布、は難しいのではないでしょうか。

<string>:1: plugin.Open: plugin was built with a different version of package github.com/yuin/gopher-lua

package.loadlib も実装しました。

$ glua
> print(package.loadlib("/home/yuin/tmp/plugin/notfound", "foo"))
nil plugin.Open(/home/yuin/tmp/plugin/notfound): realpath failed    open
> print(package.loadlib("/home/yuin/tmp/plugin/plugin.so", "foo"))
nil plugin: symbol foo not found in plugin plugin/unnamed-16c3f13f46f4b66b64ad316d78cd61078d12ac64  init
> print(package.loadlib("/home/yuin/tmp/plugin/plugin.so", "LuaOpenPlugin"))
function: 0xc4200c9840
> 

完璧ですね。

pluginパッケージ、使えそうですが・・・

少なくとも、Linuxでは plugin パッケージは使えそうです。ただし、本体と共有ライブラリのコンパイル時、完全にバージョンを合わせる必要があるところが難しそう。

Goの大きなメリットである単一バイナリ配布や、クロスコンパイルと相性は悪いですがうまく使っていければいいなと思います。