Go言語の strconv.ParseFloat のとある挙動変更を見つけた話

弊社のとあるGoプロダクトでGo 1.14から1.16へアップデートしたところ、プログラムの挙動が変わる問題が発生しました *1。 ドキュメントに書かれていない strconv.ParseFloat の挙動の変更を踏んでしまったのです。

package main

import (
    "fmt"
    "strconv"
)

func main() {
    fmt.Println(strconv.ParseFloat("1e100x", 64))
    fmt.Println(strconv.ParseFloat("1e1000x", 64))
}

このコードをGo 1.14で実行すると

0 strconv.ParseFloat: parsing "1e100x": invalid syntax
0 strconv.ParseFloat: parsing "1e1000x": invalid syntax

となりますが、Go 1.15や1.16.4では

0 strconv.ParseFloat: parsing "1e100x": invalid syntax
+Inf strconv.ParseFloat: parsing "1e1000x": value out of range

のようになります。 strconv.ParseFloat は引数の文字列全体が浮動小数点数として解釈できなければエラーを返してくれる関数です。 しかし、prefixが大きすぎる浮動小数点数である場合は、別のエラーになっています。

これはバグでしょうか、意図した変更でしょうか。 この挙動の変更を追いかけたときの私の脳内をトレースしてみます。

まずはリリースノートを確認しましょう。 当初、私は1.14と1.16の間ということしか分かっておらず、1.151.16の両方のリリースノートを開きました。 しかし strconv.ParseFloat のエラーの内容を変更するという記述はどこにもありません。 1.16のParseFloatが Eisel-Lemire アルゴリズムを採用したという変更がいかにも怪しくて最初に疑いましたが、正解は1.15の ParseComplex の追加の方でした。

誰かissue報告はしていないでしょうか。意図した変更なので閉じられているか、gotipでは直っている可能性があります。 strconv ParseFloat value out of rangeのようなキーワードで探してみましたが、ヒットしませんでした。

それでは該当ファイルのHistoryやBlameを確認しましょう。 src/strconv/atof.goの変更を追っていると、複素数 (例: 3+4i) をパースする ParseComplex を実装する直前のコミットである 1d31f9b が目に留まると思います。複素数の実部虚部は浮動小数点数も許されており (例: 1e20+3e40i)、 ParseFloat の処理をうまく使うために parseFloatPrefix を導入したようです。

このコミットよく見ていると、パースが最後まで到達していないエラー処理を遅延させたために、 1e1000x のように全体で浮動小数点数として解釈できないものにはprefixをパースしたときのエラーをそのまま返してしまっていることがわかります。コミットの意図を汲み取ると、複素数のパーサーを書くためにリファクタリングしたコミットであり、エラー内容を変更するという意図はなさそうです。この段階で、意図しない挙動変更だったという確信を持ちます。

最後にドキュメントとの矛盾を探します。ParseFloat のエラーについて次のように記述されています。

The errors that ParseFloat returns have concrete type *NumError and include err.Num = s.

If s is not syntactically well-formed, ParseFloat returns err.Err = ErrSyntax.

If s is syntactically well-formed but is more than 1/2 ULP away from the largest floating point number of the given size, ParseFloat returns f = ±Inf, err.Err = ErrRange. https://golang.org/pkg/strconv/#ParseFloat

1e1000x1e100x と同様に syntactically well-formed ではありませんから、ドキュメントからも err.Err = ErrSyntax を返すのが正しいことがわかります。 もしエラーの変更が正しいならば、ドキュメントを修正する必要がありそうです。

該当するコミットにエラー内容までを変更する意図はなさそうなことや、ドキュメントと挙動が異なることから自信を持ったので、issueをたてました。

github.com

数時間後にはCLが作られて、Mergeされていました。こういう素早さはさすがですね。 やはり意図しない変更ということで合っていたようです。次のリリースでは直っていることでしょう。

strconv.ParseFloat の挙動の変更を見つけて報告したお話でした。おしまい。

Vim の doautoall コマンドの問題と 8.2.2596 の挙動変更について

Vimdoautoallコマンドはいろいろな問題のあるコマンドで、私にとって悩みの多い機能でした。 しかし 8.2.2596 の変更によって問題のすべてが解決しました。 本記事ではこのコマンドの問題点とこのパッチの意味について解説します。 なおコミットメッセージは:doautocmdとなっていますが、:doautoallが正解です。

この記事は、バッファとウィンドウ、タブ(ページ)、イベントに関して理解していることを前提としています。 これらについてのヘルプは以下を参照してください。 vim-jp.org vim-jp.org


doautoallコマンドは、すべてのバッファに対して指定したイベントを発火するコマンドです。 例えばdoautoall FileTypeはすべてのバッファにFileTypeコマンドを発火します。

このコマンドの困る挙動の例を挙げましょう。 例えばWinEnterイベントで、ウィンドウ一覧を取得してそのバッファ名を (デバッグの場所として) statuslineに表示してみましょう。

autocmd WinEnter * let &statusline = join(
      \ map(
      \   range(1, winnr('$')),
      \   'v:val . ": " . bufname(winbufnr(v:val))'
      \ ), ' | ')

タブで他のファイルを開いたときの挙動を見てみましょう。

edit foo.txt | vnew bar.txt | tabe baz.txt | tabp
doautoall WinEnter

8.2.2596よりも古いVimでは、以下のようになるはずです。

1: baz.txt | 2: bar.txt | 3: foo.txt

現在のタブには2つしかウィンドウがありませんから、baz.txtが表示されるのは奇妙です。

もう一つ困る挙動の例を挙げましょう。 先程のコマンドを改善して、アクティブウィンドウがわかるようにしてみます。 現在のウィンドウ番号はwinnr()で取得できます。

autocmd WinEnter * let &statusline = '%t / ' . join(
      \ map(
      \   range(1, winnr('$')),
      \   'v:val . ": " . (v:val == winnr() ? "[" : "") .' .
      \   'bufname(winbufnr(v:val)) . (v:val == winnr() ? "]" : "")'
      \ ), ' | ')

今度は現在のタブで3つのウィンドウを開いてみます。

edit foo.txt | vnew bar.txt | vnew baz.txt | wincmd p
doautoall WinEnter

古いVimでは以下のようになるでしょう。

bar.txt / 1: [baz.txt] | 2: bar.txt | 3: foo.txt

今のバッファはbar.txtですから、baz.txtがアクティブに見えているのは奇妙です。

以上の挙動は、statuslineWinEnterに限った話ではありません。 doautoallコマンドは、発火できるイベントに制限がありませんから (例えばdoautoall WinEnterの意味を考えているとおかしなコマンドに思われますが)、実際にユーザーはどんなイベントでも発火することができます。 なので何らかのイベントのトリガーでwinnr()で現在のウィンドウを記録している、bufname()で現在のバッファ名を取得している、winrestcmd()winsaveview()を記録している場合など広いユースケースで問題になります。 意味のわからないコマンドだとしても、Vimが実行を許している以上はVimプラグインで考慮する必要があります。 echomsgではなくstatuslineを使ったのは、最終的なウィンドウの状態がユーザーから見たときの挙動を左右するからです。


doautoallの問題点を大きく分けるとautocommand window (用語は:h win_gettype()より) とバッファの発火順があります。 このコマンドがイベントを発火するときに、現在のバッファやウィンドウを変更し、イベントハンドラの中でどのバッファかわかるようになっています。 あるバッファが現在のタブのどのウィンドウにも表示されていない場合は、autocommand windowという一時的なウィンドウ (コード上はaucmd_win) にバッファを紐付けて、それをアクティブウィンドウとした上でイベントを発火しています。 多くのイベントハンドラはこの特殊なウィンドウで実行されることを意図していないでしょう。 Vim 8.2.0996 (正確には8.2.0991からですが返り値に統一感がないということで変更された) より win_gettype() という関数でautocommand windowかどうかを判別できるようになっています。 この特殊なウィンドウが、1つ目の困る挙動で裏のタブで開いているバッファ名が表示された理由です。

2つ目の問題は発火順です。 doautoallは素朴にバッファ番号順にイベントを発火していました。 イベントハンドラは、イベントがdoautoallで発火されたことを検知できません。 autocommand windowは特殊なウィンドウなので区別する手段が作られましたが、最後のバッファが現在のタブの非アクティブウィンドウに表示されている場合は、やはりアクティブウィンドウを勘違いしてしまいます。

この問題を解決するのが、8.2.2596で取り込まれた以下の修正です。 解決方法は簡単で、順番を入れ替えて、現在のウィンドウに表示されているバッファのイベントを最後に実行するというものです。 これによってdoautoallが引き起こすあらゆる問題が解決しました。 イベント発火の順序の入れ替えという少し不安になる変更でしたが、Bramはあっさりと受け入れてくれました。 パッチがmergeされてから一週間が経ち、特に問題になっていないようですので大丈夫でしょう。 github.com


私は lightline.vim というプラグインを作ってメンテナをしており、このdoautoallコマンドによってアクティブウィンドウのstatuslineが正しく表示されないという問題には随分悩まされてきました (#435, #444, #447, #448, #556)。 特にセッションファイルにdoautoall SessionLoadPostというコマンドが含まれていることから、セッションの文脈でバグ報告が上がることが多い現象です。 しかし問題の本質はdoautoallコマンドの挙動だったので、トリッキーなworkaroundを入れる (例: カーソル移動で更新とかタイマーで再チェックとか) ことなく根本から修正できてよかったです。

autocommand windowwin_gettype()によって検知できるようになりました (8.2.0996, 8.2.0991)。 現在のウィンドウに表示されているバッファのイベント発火は最後に行われるようになりました (8.2.2596)。 この2つの修正によってすべての悩みが解決しました。 以上がこのdoautoallコマンドの問題点とそれに対する修正に関するすべてになります。 この修正によってlightline.vimのみならず、他の多くのプラグインが助かっているかと思います。

実際に手でdoautoallを打つことはめったにないですが、Vimプラグインのメンテナをしているとそういう機能とも真面目に向き合う必要があります。 ソフトウェアの問題への修正の多くはincrementalであり、dirty workaroundを行うのが容易いことが多いでしょう。 しかし、起きている現象の本質を考えることで、ソフトウェアを本当に良いものにしていけるのだと思います。 本質を捉えるのは難しいこともありますが、これができるプログラマになりたいものです。

Pythonのstrptime %zに関するバグ報告をした

timefmt-goというGoライブラリを公開してメンテしているのですが、最近タイムゾーン周りの対応が弱いことに気がついて実装していました。タイムゾーンオフセットの表記は +0900 のようにコロンを入れないほうが一般的だと思いますが、RFC3339では +09:00 のようにコロンありの形もvalidです。strftime では %:z によりこの形を出力し、strptime%z はコロンありなし両方の形をparseする必要があります。GNU拡張のstrftimeでは %::z を使うと秒まで表記し、%:::z ではオフセットの精度によっていい感じに表記するようです (分精度なら分までなど)。

 $ date +%z
+0900
 $ date +%:z
+09:00
 $ date +%::z
+09:00:00
 $ date +%:::z
+09

strptimeの他の言語の挙動を確認しているときに、Python (3.9.2) で思わぬエラーが発生することに気が付きました。

>>> from datetime import datetime
>>> datetime.strptime('Z', '%z')
datetime.datetime(1900, 1, 1, 0, 0, tzinfo=datetime.timezone.utc)
>>> datetime.strptime('z', '%z')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 453, in _strptime
    if z[3] == ':':
IndexError: string index out of range

%z が大文字の Z を解釈するのは正しいのですが、それ以外のおかしな入力のときは ValueError となるべきです。

>>> datetime.strptime('x', '%z')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/local/lib/python3.9/_strptime.py", line 568, in _strptime_datetime
    tt, fraction, gmtoff_fraction = _strptime(data_string, format)
  File "/usr/local/lib/python3.9/_strptime.py", line 349, in _strptime
    raise ValueError("time data %r does not match format %r" %
ValueError: time data 'x' does not match format '%z'

例外の型が違うということは except ValueError のようなキャッチをすり抜けてしまうということですね。ざんねんでした。

早速ソースコードを読んでいると、フォーマットを正規表現に変換するときに大文字小文字を無視するオプションを使っていて (%aMonでもMONでもマッチするように)、UTCを表す Z が意図せず z にもマッチしているようでした。

            'z': r"(?P<z>[+-]\d\d:?[0-5]\d(:?[0-5]\d(\.\d{1,6})?)?|Z)",
# 略
    def compile(self, format):
        """Return a compiled re object for the format string."""
        return re_compile(self.pattern(format), IGNORECASE)
# 略
        elif group_key == 'z':
            z = found_dict['z']
            if z == 'Z':
                gmtoff = 0
            else:
                if z[3] == ':':
                    z = z[:3] + z[4:]

考慮漏れだとわかったので、既存のissueがないことを確かめて新たにチケットを切りました。 bugs.python.org GitHubではないので面倒かと思ったのですが、OpenIDですぐにログインできて、本名も要求されずとても簡単でした (Linux Foundationとかと比べると遥かに)。 次の日には興味を持った人がパッチを投げてくれました。 月や曜日のマッチングにはignore caseフラグが必要なのでどう修正するのかわからなかったのですが、 (?-i:PATTERN) で一時的に無視することができるのですね。知らなかった。 github.com 3.9.3では直るんじゃないでしょうか。 世界から一つバグが消えてよかったですね。

Pythonstrptime正規表現で実装されていて結構遅いと思いますが、あまり気にしない文化なのでしょうか。 日時なんて大した長さの文字列ではないからこれで良いのかもしれません (しかも strptime('999', '%H%M%S') みたいなケースも対応している)。 timefmtは極力アロケーションを減らし、strconv.Atoiすら避けてカリカリにチューニングしているので、Pythonのライブラリはだいぶ富豪的だなと思いました。 終わり。

itchyny.hatenablog.com

Go言語のorderedmapパッケージを改善した

Go言語で書かれたorderedmapというサードパーティパッケージがあります。 github.com

Goのmapには順序がなく、JSONをデコードすると順序が失われ、それをエンコードするとオブジェクトのキーの順序にソートされます。 これに困る人はそこそこいるようで、順序を保持するmapはいくつか実装されてきました。 その中の一つが、orderedmapというパッケージです。 シンプルなインターフェイスが気に入っています。

orderedmapパッケージの利用例

package main

import (
    "encoding/json"
    "fmt"
    "log"

    "github.com/iancoleman/orderedmap"
)

func main() {
    src := `{ "z": 1, "x": 2, "y": 3 }`

    fmt.Println("# map[string]interface{}")
    var v map[string]interface{}
    if err := json.Unmarshal([]byte(src), &v); err != nil {
        log.Fatal(err)
    }
    bs, err := json.MarshalIndent(v, "", "  ")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s\n\n", bs)

    fmt.Println("# OrderedMap")
    o := orderedmap.New()
    if err := json.Unmarshal([]byte(src), &o); err != nil {
        log.Fatal(err)
    }
    bs, err = json.MarshalIndent(o, "", "  ")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("%s\n\n", bs)
}
# map[string]interface{}
{
  "x": 2,
  "y": 3,
  "z": 1
}

# OrderedMap
{
  "z": 1,
  "x": 2,
  "y": 3
}

しかし、このパッケージのUnmarshal (JSONOrderedMap の変換) の実装は、正直あまりきれいではありませんでした。 v0.1.0までの実装は以下のようになっていました。

  • map[string]interface{}Unmarshal する
  • オブジェクトの各キーに対して、JSON文字列の中の出現位置を探す
    • キーの "エスケープして、 strings.LastIndex で探す
    • JSON文字列を探したインデックスまでを切り取って } を付け加えたときにvalidなJSONかチェックする
      • validなJSONでなければ、ネストされたJSONのキーにヒットしまっているので、更に前にたどってキーのポジションを探す
  • オブジェクトの各値に対して
    • オブジェクトであれば、OrderedMapへの変換を再帰的に行う
    • 配列であれば、各要素の変換を再帰的に行う
  • オブジェクトのキー一覧を、出現位置でソートする

オブジェクトのキーの順序を保持したい → キーの文字列の出現位置を探索してソートすれば良い、という素朴な発想でこういう実装になっているのだと思いますが、この処理には様々な問題があります。

  • オブジェクトのキーを文字列から探すときに " しかエスケープしておらず、{ "\n": 1 } のようなものはうまくUnmarshalできない
    • { "A\u0041A\u0041A": 0 } のようなキーもありうるので、strings.LastIndex でポジションを探す方針には限界がある (指数関数的な個数のエスケープを試す必要がある)
  • パフォーマンスが良くない (#2)
    • validなJSONかかどうかをチェックするという処理でいちいち json.Unmarshal している (orderedmap.go#L178-L192)。
    • 探索範囲を絞ったりスペースをスキップしたりするために、文字列のアロケーションが多い
  • ネストしたJSONに対処するために実装が複雑で、一見してバグっていない自信がない (主観)
    • 重複キーに対しては結構怪しい気がする

個人的には好きなパッケージですが、Unmarshalの実装が複雑であまり使いたくないという気持ちがありました。 しかし作者も課題感を持っているようでしたし、解決策も思いついたので、実装し直すことにしました。

標準パッケージのencoding/json*DecoderにはToken() (Token, error)という関数があります。 JSONトークンを一つずつデコードする関数です。 *DecoderJSONのステートを持っているので、単なるlexerではなくてvalidなJSONトークン列を返してくれます。 これを使います。

  • map[string]interface{}Unmarshal する
  • JSONから *Decoder を作り、dec.Token() を使ってトークンをたどっていく
    • キーはスライスに追加する
      • 重複キーは後ろに移動する
    • 値の最初のトークンによって、オブジェクトか配列かその他の場合に分かれる
      • 値がオブジェクトの場合
        • map[string]interface{}またはOrderedMap (重複キー) の場合はOrderedMapに変換する処理を行う
        • それ以外の場合 (型が違う) は重複キーなので、OrderedMapへのデコードを行うが得られた値は捨てる
      • 値が配列の場合
        • 配列の各要素に対してオブジェクトか配列の場合は再帰的にデコードを行う
        • それ以外の場合 (型が違う、配列のインデックスを超えているなど) は重複キーなので、デコードを行うが得られた値は捨てる

この実装によって様々な点が改善されました。

  • オブジェクトのキーがエスケープされていても問題なく動く
    • { "A\u0041A\u0041A": 0 } のようなものでもOK
  • 以前の実装では何度も文字列上を走査する必要があったが、この実装では二回スキャンすればよい
    • 実装を突き詰めてJSONパースを自前で持てば一回で済むが、そこはencoding/jsonに任せて小さく実装している
  • オブジェクトのキー一覧をソートする必要がない
    • ただし重複キーが大量にある場合のパフォーマンスは良くない
  • 文字列のアロケーションが大幅に減少し、パフォーマンスも改善される
  • 実装が比較的読みやすい

ベンチマークは以下のように改善されています。 アロケーションは半分以下に抑えられ、パフォーマンスも三倍近く改善しています。

benchmark                     old ns/op     new ns/op     delta
BenchmarkUnmarshalJSON-16     125485        37536         -70.09%

benchmark                     old allocs    new allocs    delta
BenchmarkUnmarshalJSON-16     964           389           -59.65%

benchmark                     old bytes     new bytes     delta
BenchmarkUnmarshalJSON-16     49885         13174         -73.59%

一方、Marshal (OrderedMapJSONへの変換) にも改善を入れました。 Unmarshal ほどの問題はありませんでしたが、オブジェクトのキーのエスケープが足りていない ({ "\n": 1 } 相当を出力すると改行が入りvalidなJSONにならない) とか、文字列のアロケーションが多いといった問題を解決しています。 文字列の結合をbytes.Bufferに置き換える単純なお仕事です。

ベンチマークは以下のようになっています。 アロケーションはかなり減りましたが、パフォーマンスとしては7%程度の改善にとどまっています。

benchmark                   old ns/op     new ns/op     delta
BenchmarkMarshalJSON-16     15687         14535         -7.34%

benchmark                   old allocs    new allocs    delta
BenchmarkMarshalJSON-16     94            33            -64.89%

benchmark                   old bytes     new bytes     delta
BenchmarkMarshalJSON-16     9309          2195          -76.42%

以上の改善は、v0.2.0としてリリースされています。 個人的には実装がかなりクリアになり (まあ自分で書いたし)、パフォーマンスも良くなったので、安心してこのパッケージを使えるようになりました。 また一つ世界が良くなりましたね。 おしまい!

gojq が homebrew/core に入りました、他近況

gojqはGoで実装されたjqコマンドおよびライブラリです。2019年の4月から開発を始めて、現在も様々な改善を行っています。

これまでgojqのHomebrew formulaはitchyny/tapより配信していましたが、このたび公式のhomebrew/coreに入りました。 これにより、次のコマンドでインストールできるようになりました。

brew install gojq

tapの考え方についてはTapsを参照してください。

実は、私への連絡もなしにいきなりhomebrew/coreに入れられていて、formulaの品質が悪かったのですが、色々と指摘したらすぐに直してくれました。 自分のコントロールできない部分に置くのが正直嫌だったのですが、すでに入ってしまったのを怒ってもしょうがないし、bottleによるメリットも大きいので良いかなと思います。 ただ、連絡はしてほしかったですね… (というかメイン開発者の意向を無視して勝手にformula追加できるのどうなの…)

formulaの配布元tapを移動するときは、tap_migrations.json を使います。 tapのリポジトリのトップにこの名前のJSONを作っておけば、既存のtapを使っているユーザーもスムーズにtapの移行が行われます。 今回はhomebrew/coreに移動したので、以下のようなJSONファイルを配置しました

{
  "gojq": "homebrew/core"
}

gojqは様々なフィードバックを受けて完成度があがっています。 最近あったものとしては、$HOMEが空のときにうまく動かないというものです (#58)。 デフォルトのモジュールパスの~/.jqを展開するためにos.UserHomeDirを使っていたのですが、値を取れないときにエラーとして終了していました。 $HOMEが取れないケースというのは想定したことなかったのです。 AWS Lambdaで動かすとこれが空になるそうですね。

Fuzzingによるコーナーケースの修正も行っています。 例えば ""[:{}] とか infinite * "x" のようなクエリのクラッシュが発見されています。 また先日は全探索によるバグの探索も行いました。 コマンド引数に渡すクエリ文字列を生成して、コマンドの実行結果を比較するという感じです。

jqとgojqの終了ステータスの差異をチェックするコード

package main

import (
    "fmt"
    "os/exec"
    "syscall"
)

func main() {
    source := []byte(" ")
    for {
        check(string(source))
        for i := len(source) - 1; i >= 0; i-- {
            if source[i] != '~' {
                source[i]++
                if i == 0 && (source[i] == '-' || source[i] == '+') ||
                    source[i] == '#' {
                    source[i]++
                }
                if source[i] == '1' {
                    source[i] = ':'
                }
                break
            }
            source[i] = ' '
            if i == 0 {
                source[0] = '!'
                source = append(source, '!')
            } else if i == len(source)-1 {
                source[i]++
            }
        }
    }
}

var sem = make(chan struct{}, 10)

func check(src string) {
    sem <- struct{}{}
    go func() {
        defer func() { <-sem }()
        if x, y := run("jq", "-n", src), run("gojq", "-n", src); x != y {
            fmt.Printf("ng: %q %d %d\n", src, x, y)
        } else if x == 0 {
            fmt.Printf("ok: %q %d %d\n", src, x, y)
        }
    }()
}

func run(command string, args ...string) int {
    cmd := exec.Command(command, args...)
    if err := cmd.Start(); err != nil {
        return -1
    }
    if err := cmd.Wait(); err != nil {
        if exiterr, ok := err.(*exec.ExitError); ok {
            if status, ok := exiterr.Sys().(syscall.WaitStatus); ok {
                return status.ExitStatus()
            }
        }
    }
    return 0
}

この調査では 1?/1 (lexerのバグで、?//という演算子をチェックして失敗した後のoffsetのバグ)、0.._ (これもlexerのバグで、クエリのエラーとすべきものを受け付けてしまっていた) や @a? (compilerのバグで、定義されていないformatをcompile errorとしてはいけない) といったクエリの挙動が異なることが発見されました。 実装に相当自信があっても、Fuzzingや探索を行ってみると、何かしらのバグは見つかるものですね。

gojq v0.12.0からはJSONに色を付けるライブラリを自前で持ち、パフォーマンスが三倍以上改善しました。 以前はgo-prettyprintにお世話になっていましたが、オブジェクトのキーのエスケープがバグっていたのと (#15にて修正済)、NaNや無限大の正規化が (これらはnullと出力するというjqの仕様を実装する必要がある) オーバーヘッドになっていたこと、またこのライブラリのパフォーマンスがあまり良くないことが問題でした。 色付けを行う部分を自前にすることで、jq仕様の正規化も組み込むことができ、またメモリーアロケーションも大幅に減少することができました。 go-prettyjsonのパフォーマンスの改善に関しては#17にてフィードバックしています (二倍近くは速くなりそうです)。

gojqは言語処理系です。 作り始めて二年近くになり、処理系もパーサーもそれぞれ完全なリライトをしています。 コミット数も1000を超え、様々なことを学ぶことができました。 Goのツールにライブラリとしてgojqを埋め込む利用例も増えています。 ソフトウェアは完成することはありません。 これからもgojqを楽しんでいきたいと思います。