Rubyのエンコーディングとコマンドライン引数

Ruby 1.9.3以降、ファイル名の扱いでひっかかりポイントがあったんだよな。なんだったっけ?

と、ここしばらく、頭の片すみにモヤモヤが居座っていた。ついさっき、どういうことだったかを思い出せた。

最初にひっかかったのはファイルを操作するスクリプトだった。ファイル名にまつわるところに何かある、と勘違いしてしまっていたのはそのせいだろう。しかしきちんと思い出してみると、ファイル名ではなくコマンドライン引数のエンコーディングにより生じる問題だった。ファイル名のほうを適当に直すという場当たり対処をついついしてしまったのも勘違いを深める要因だったかもしれない。

ともあれ、問題のコードを以下に示す。

#!/usr/bin/env ruby
# encoding: utf-8
require 'optparse'
OptionParser.new do |opt|
  opt.on('-h') do
    puts opt
    exit
  end
end.parse!(ARGV)

必要な部分だけを抜き出したのでこのコード自体は何もしない。引数に-hを含むときに使い方を表示するだけ。ところが、コマンドライン引数にこわれたマルチバイト文字列を渡すと例外が発生してしまう。(上のスクリプトのファイル名をt.rbとする。)

% ./t.rb "$(nkf -s <<<あいうえ)"
/usr/local/lib/ruby/2.0.0/optparse.rb:1355:in `===': invalid byte sequence in UTF-8 (ArgumentError)
#...(略)...

例外が起きている部分はこんな内容。(引用した最後の行が1355行目。) コマンドライン引数を順番に検査しているところのようだ。

    argv.unshift(arg) if arg = catch(:terminate) {
      while arg = argv.shift
        case arg
        # long option
        when /\A--([^=]*)(?:=(.*))?/m

考えてみればもっともなことで、Rubyが言語環境に合わせたエンコーディングで動作しようとしているところに、あやしげなバイト列をつっこんだのだからしかたがない。それをなんとかするのはスクリプトを書いたもののつとめ。ではどうしようかというところで少し戸惑いがあった。

上の状況はEncoding.default_externalがutf-8であるから起きている。ということは、これを変えればよいだろうと思いつく。

#!/usr/bin/env ruby
# encoding: utf-8
require 'optparse'
Encoding.default_external = 'binary'
#...(略)...

でもそうはいかない。なぜなら、コマンドライン引数が処理されるのは、Rubyが起動してもろもろの初期化を終え、そして与えられたスクリプトの実行が始まる前だから。スクリプトに足しても引いてもこの点では変化がない。

それならばRubyが起動するときに問題が起きないようにすればよい。

% ruby -Ebinary ./t.rb "$(nkf -s <<<あいうえ)"

とはいえ、これでは実用上困る。こんなふうに書きたくなるのだが……

#!/usr/bin/env ruby -Ebinary
#...(略)...

実際にやってみるとうまくいかない。

% ./t.rb
/usr/bin/env: ruby -Ebinary: そのようなファイルやディレクトリはありません

このあたりの挙動はOS依存なので、たとえば#!/usr/bin/ruby -Ebinaryならうまく動くとか、それでもダメとか、いろいろでてくる。そして忘れたころにひっかかる。(LANG=Cにして回避というのも同じようなところに落ち着く。)

まあ、そもそもEncoding.default_exteralを変えるというのは、問題の所在に対して影響の範囲が大きすぎる。ARGVの扱い方に起因しているわけだから、これをなんとかするのがスジだろう。たとえばこういうの。

#!/usr/bin/env ruby
# encoding: utf-8
require 'optparse'
OptionParser.new do |opt|
  opt.on('-h') do
    puts opt
    exit
  end
end.parse!(ARGV.map {|arg| arg.valid_encoding? ? arg : arg.dup.force_encoding('binary') })

ARGV自体は変更できないので、その中身を必要に応じてコピーしながら大丈夫そうなものはそのまま、まずそうなのはエンコーディングをbinaryにしておく。その上でoptparseに渡してやる。

なんだかまわり道をしてしまった。一つには最初にぶつかったときになんとなく対処してしまったこと。もう一つはoptparseの中で例外起きてるなってところで軽く思考停止してしまったこと。それでついなるべく外側で対処しようと考えるようになった。

それと、最初にファイル名ではなくコマンドライン引数に起因すると書いたが、これは今回はそういうケースだったということ。実際のところファイル名を読み込むケースでも同じような問題は起きる。

% touch "$(nkf -s <<<あいう)"
% ruby -e 'Dir.glob("*") {|x| /^\./ =~ x }'
-e:1:in `block in <main>': invalid byte sequence in UTF-8 (ArgumentError)
...(略)... 

標準入出力やファイルの内容についてはエンコードを意識するのだが、ファイル名はちょっとだけ盲点のような気がする。おかしなファイル名のファイルがごろごろしてるのが普通かっていうと、そうではないだろうけども。

あ、ちなみにoptparseの中で例外が起きないようにするだけなら、コマンドライン引数の与え方を変えるだけでもなんとかなる。もちろんコマンドライン引数に対し何かするスクリプトであるなら、おそらくはスクリプト本体でそれらのエンコーディングをうまく扱ってやらなければならないのには変わりない。

% ./t.rb -- "$(nkf -s <<<あいうえ)"