Ruby 2.5 系で CSV.generate のバグ

Ruby 2.5 系で CSV.generate を使用しようとしたら意図しない動作をして、調べてみたらバグだったのでそのまとめ。
しかし、これ、結構クリティカルなバグだと思うんですけど、全然話題になってないのが不思議(当時は話題になっていたのかもしれないけど。

CSV.generate とは

以下のような感じで CSV 形式で文字列を構築する事が出来ます。

# Ruby 2.4 で実行
require "csv"
require "pp"

result = CSV.generate do |csv|
    csv << [1, 2, 3]
    csv << ["homu", "mami", "mado"]
end
pp result
# => "1,2,3\n" + "homu,mami,mado\n"

https://wandbox.org/permlink/jL6prEzNrYZ0g9m0

また CSV.generate の第一引数で『その CSV の先頭文字列』を指定する事が出来ます。

require "csv"
require "pp"

csv = CSV.generate("prefix") do |csv|
    csv << [1, 2, 3]
end
pp csv
# => "prefix1,2,3\n"

# 既存の csv に対して追記したり
csv = CSV.generate(csv) do |csv|
    csv << ["homu", "mami", "mado"]
end
pp csv
# => "1,2,3\n" + "homu,mami,mado\n"

https://wandbox.org/permlink/afzhyZti9dAAyukB

CSV.generate のバグ

で、件のバグなんですが、先ほど説明した『CSV.generate の第一引数で『その CSV の先頭文字列』を指定する』が Ruby 2.5 では動作しません。

# Ruby 2.5 で実行した場合
require "csv"

csv = CSV.generate("prefix") do |csv|
    csv << ["homu", "mami", "mado"]
end
pp csv
# => "homu,mami,mado\n"

https://wandbox.org/permlink/jWYqvmnnWIRdH2X7

上記のようなコードではすぐに『何かおかしい』とわかるんですが、例えば次のように『CSV データに BOM を追加する』みたいな事をしたい場合はほぼ気づきません。

require "csv"

# BOM 付き CSV データを生成したいが追加されない…
bom = "\uFEFF"
csv = CSV.generate(bom) do |csv|
    csv << ["homu", "mami", "mado"]
end

# 出力先によっては BOM がついているかどうかが視覚的にわからないのでバグを見つけるのがむずかしい…
pp csv
# => "homu,mami,mado\n"

わたしも上記のような事をやりたかったんですが、うまく動作しなくて調べてみたら既知のバグでした。
Ruby BOM CSV』でググるCSV.generate を使ったやり方を書いているブログとかが結構ヒットするんですが、このバグに気づいてない人もいるんじゃないかなあ…。

Ruby 2.6 では修正済み

このバグは Ruby 2.6 では修正済みとなっています。

Ruby 2.5 系での対処方法

幸いにも CSV.generateRuby で実装されているので次のようなモンキーパッチで対処する事が出来ます。

require "csv"

# CSV.generate の実装を上書き
class CSV
    def self.generate(str=nil, **options)
        # add a default empty String, if none was given
        if str
            str = StringIO.new(str)
            str.seek(0, IO::SEEK_END)
        else
            encoding = options[:encoding]
            str      = String.new
            str.force_encoding(encoding) if encoding
        end
        csv = new(str, options) # wrap
        yield csv               # yield for appending
        csv.string              # return final String
    end
end

# これで BOM が追加される
bom = "\uFEFF"
csv = CSV.generate(bom) do |csv|
    csv << ["homu", "mami", "mado"]
end
pp csv
# => "<feff>homu,mami,mado\n"

まとめ

Ruby 2.6 はよ〜