■ rg コマンド ruby grep

UNIX 系のシステムを使っているなら、grep 使いますよね。 Ruby で書いた便利 grep です。

Twitter で Rak: カラフルで見やすいRuby版grep が紹介されていました。 漢字の扱いは? というのはありますが、よさそうですね。 私がここで紹介している rg.rb に強調表示を付け加えることも難しくありません。 ヒントは Ruby でカレンダー で扱っている文字の強調表示を行なっている方法です。 また less コマンド使用することを前提とした場合は、強調表示の方法として

a^Ha
という「文字の重ね打ち」になるコードを出力することで可能になります。

Ruby は、いろいろ便利なことが手軽にできるので、自分用の grep を作ってみるといいかも?

姉妹編として、より単機能なのですが、スクリプトも読みやすいですし改造しやすい Ruby で grep 再び も参照してみてください。 わずかな行数で、いろいろできる grep を実現しています。 スクリプトも読みやすいサイズになっています。

また、Ruby 1.9.x での漢字の扱いについて試行錯誤している Ruby で grep 三度 1.9 専用 もあります。 1.9 で漢字コードが複数あっても処理できないかあれこれしています。

「便利さ」ってなんでしょう? 普段どんな検索しますか? Ruby の正規表現がそのまま使えるといいですよね。 私は、ASCII テキストとそれと同じくらいいろいろな漢字コードが含まれるテキストも扱います。 また、圧縮されているファイルも扱います。 こういうとき、使えるコマンド持っていますか?

■ 使い方

基本的には、

$ rg.rb [オプション] 検索パターン ファイル名
になります。

検索パターンには、Ruby の正規表現がそのまま使えます。

ファイル名は、対象となるファイルがしぼられていない場合は、シェル上で「*」で OK です。 バイナリファイルやディレクトリは対象からはずしますし、圧縮されている場合も適当に扱ってくれます。 zsh を使っているような場合は、「**/*」でディレクトリ以下のすべてのファイルを扱えます。 また find コマンドと組み合わせることで、zsh と同等のことができます。

$ find * -name "*.c" | xargs rg.rb [オプション] 検索パターン

オプションとしては、次のものがあります。

オプション説明
-bバイナリファイルも検索対象とします
-eE-mail の引用に使われる行頭の「>」や「|」を無視します
-fファイル名を表示します
-iアルファベットの大文字小文字を区別しません
-k漢字を検索します
-lファイル名だけを表示します
-m行をまたいだパターンを検索します
-n行番号を表示します
-s連続したスペースを一つとして扱います
-vパターンが一致しないと表示します
-Ffgrep コマンドと同じくメタ文字もそのまま検索します
-Vversion を表示します

検索結果によって終了コードが変わっています。 シェルスクリプトと組み合わせる場合などに使えます。

exit コード説明
0パターンがみつかった場合
1なんらかのエラーか、パターンがみつからない場合

● 引数のファイルについて

引数としてファイルを指定しますが、このときシェル上で「*」を指定してもほとんど問題はありません。 バイナリは対象外にしますし、ディレクトリもパスします。 圧縮されたファイルは、前処理プログラムを呼びだして検索の対象にしてしまいます。 便利でしょ? ということで、目的のものを見つけるために対象ファイルは「*」を指定可能です。

引数として圧縮されたファイルを指定した場合でも検索可能です。 このとき、ファイルの拡張子が圧縮フォーマットと一致していることが必要になります。 拡張子「.bz2」なら自動的に bzcat が展開してそれを検索しています。 このような作業を自動的に行うのでとても便利です。

拡張子実行されるコマンド
.lzhlha l
.zipunzip -l
.zoozoo -list
.tar.gz/tgztar tzvf
.tar.bz2/tbztar tIvf
.gz/.z/.Zzcat
.bz2bzcat

● オプションについて

漢字検索オプション -k を加えると

赤い コランダム の 宝石
でも「赤いコランダムの宝石」で検索可能です。

multi line オプション -m を加えると

赤いコラン
ダムの宝石
でも「赤いコランダムの宝石」で検索可能です。
Welcome to
Ruby world
でも「to Ruby」で検索可能です。

E-mail オプション -e を加えると

> 赤いコラン
> ダムの宝石
でも「赤いコランダムの宝石」で検索可能です。

space one オプション -s を加えると

Welcome to    Ruby world
でも「to Ruby」で検索可能です。 まあ、「\s+」使えばいいけどね。

漢字コードについて。 このスクリプトおよび処理の内部的には、EUC Japan を使用しています。 検索用パターンは、nkf モジュールで必ず EUC に変換されます。 検索対象となる入力文字列は、-k オプションが指定されている場合のみ nkf モジュールで変換されます。

-m オプションについて。 入力を空行区切りのブロックで扱います。 検索するキーワードは、ブロック内で改行で区切られていても、検索可能です。 その際は、改行は一つのスペースとして扱われます。 -k が指定されている場合は、スペースは入りません。

-k オプションについて。 「漢字」が「スペース」で区切られていた場合、「スペース」をとりのぞきます。 テキストの整形ツールなどで、「スペース」が入った場合でも、検索が可能にするためです。 日本語オンラインマニュアルなどに利用できると思います。

-e オプションについて。 E-mail で引用に使用される「|」や「>」が行頭にある場合、それをとりのぞき検索します。 引用中の改行で区切られた部分でもキーワードと一致しているか? 確認可能になっています。

■ ソースコード

最初に Ruby 1.9 系用。 次に Ruby 1.8 系用です。 Web ブラウザでの表示上は Ruby のスクリプトですが、HTML の特殊文字をエスケープしています。 テキストとしてセーブしてご利用ください。

スクリプト中に、漢字コードを直接指定している部分があります。 このスクリプトは、セーブ後 EUC-Japan に変換してください。

#! /usr/local/bin/ruby -Ke
# /home/tetsu/src/ruby/toolbox/rg.rb
# Created: July 09,2000 Sunday 18:26:03
# Author: tetsu(WATANABE Tetsuya)
RCS_ID = '$Id: rg.rb,v 1.29 2008/08/26 06:02:34 tetsu Exp $'
# usage:

class File
  def File.zopen(filename)
    suff2exe = {
      '\.lzh' => 'lha l ',      # lha pq
      '\.zip' => 'unzip -l ',   # unzip -p
      '\.zoo' => 'zoo -list ',  # zoo xpq
      '\.tar.gz' => 'tar tzvf ',
      '\.tgz' =>    'tar tzvf ',
      '\.tar.bz2' => 'tar tjvf ',
      '\.tbz' =>     'tar tjvf ',
      '\.gz' => 'zcat ',
      '\.z' =>  'zcat ',
      '\.Z' =>  'zcat ',
      '\.bz2' => 'bzcat '
    }

    suffix = ''
    suff2exe.keys.sort do |a, b|
      b.size <=> a.size
    end.each do |s|
      if filename =~ /#{s}$/
        suffix = s
        break
      end
    end

    if suff2exe.key?(suffix)
      f = File.popen(suff2exe[suffix] + filename)
      eval "def f.path; '#{filename}' end"
      f
    else
      File.open(filename)
    end
  end
end

require 'nkf'

class String
  def toeuc
    NKF.nkf('--oc=euc-jp -Z1m', self)
  end
end

def rg(re, f, opt)
  opt_multi = opt['multi']
  opt_lineno = opt['lineno']
  opt_kanji = opt['kanji']
  opt_binary = opt['binary']
  opt_email = opt['email']
  opt_nkf = opt['nkf']
  opt_spaceone = opt['spaceone']
  opt_revertmatch = opt['revertmatch']
  opt_filenameonly = opt['filenameonly']
  opt_filename = opt['filename']

  match = false
  sep = "\n"
  
  if opt_multi
    linecount = 0 if opt_lineno
    sep.concat("\n")
    lf2 = if opt_kanji then '' else ' ' end # 改行の扱い
  end

  while l = f.gets(sep)
    l.force_encoding('ascii-8bit')
    break if ! opt_binary && l =~ /\000/n
    l.force_encoding('euc-jp')
    str = l.dup
    str.gsub!(/^[|>\s]+/, '') if opt_email
    str = str.gsub(/^\s+/, '').gsub(/\s+$/, '').gsub(/\n/, lf2) if opt_multi
    str = str.toeuc.gsub(/([ -瑤])\s+([ -瑤])/, '\1\2') if opt_kanji
    str.gsub!(/[ \t]+/, ' ') if opt_spaceone

    m_flag = str =~ re

    if (m_flag && opt_revertmatch == false) || (m_flag.nil? && opt_revertmatch)
      match = true
      if opt_filenameonly
        puts f.path
        break
      else
        if opt_multi
          l.gsub!(/^/) do (linecount += 1).to_s + ':' end if opt_lineno
          l.gsub!(/^/) do f.path + ':' end if opt_filename
        else            
          print f.path, ':' if opt_filename
          print f.lineno, ':' if opt_lineno
        end
        print l.toeuc
      end
    else
      linecount += l.count("\n") if opt_multi and opt_lineno
    end
  end
  match
end

def usage
  STDERR.print <<EOF
usage: #{$0} [-befiklmnsvF|-V] pattern [files...]
  b: binary file
  e: E-mail format
  f: filename
  i: ignore_case
  k: kanji
  l: only filename
  m: multi line
  n: line number
  s: space one
  v: revert match
  F: fgrep
  V: version and exit
EOF
  exit 1
end
  
opt = {
  'binary' => false,            # バイナリファイルも
  'email' => false,             # E-mail 形式の引用を扱う
  'filename' => false,          # ファイル名表示
  'ignorecase' => false,        # アルファベットの大文字小文字を区別しない
  'kanji' => false,             # 漢字を検索する場合
  'filenameonly' => false,      # ファイル名だけ表示
  'multi' => false,             # 行をまたいだパターンを指定したい場合
  'lineno' => false,            # lineno 表示
  'revertmatch' => false,       # マッチしなかったら
  'spaceone' => false,          # 連続スペースを一つとして扱う
  'fgrep' => false,             # fgrep と同じ
  'nkf' => '-eZ1m'              # 文字種統一のため
}

while ARGV[0] and ARGV[0][0] == '-'
  l = ARGV.shift
  opt['binary'] = true       if l =~ /b/
  opt['email'] = true        if l =~ /e/
  opt['filename'] = true     if l =~ /f/
  opt['ignorecase'] = true   if l =~ /i/
  opt['kanji'] = true        if l =~ /k/
  opt['filenameonly'] = true if l =~ /l/
  opt['multi'] = true        if l =~ /m/
  opt['lineno'] = true       if l =~ /n/
  opt['spaceone'] = true     if l =~ /s/
  opt['revertmatch'] = true  if l =~ /v/
  opt['fgrep'] = true        if l =~ /F/
  if l =~ /^-V/
    puts RCS_ID
    exit
  end
  usage if l =~ /[^\-befiklmnsvVF]/
end

usage if ARGV.size < 1

pattern = ARGV.shift
pattern = pattern.toeuc if opt['kanji']
pattern = Regexp.quote(pattern)        if opt['fgrep']

re = Regexp.new(pattern, opt['ignorecase'])

match = 0

opt['filename'] = true if ARGV.size > 1

if ARGV.size == 0
  class IO; def path; '-' end end
  match |= if rg(re, STDIN, opt) then 1 else 0 end
else
  while filename = ARGV.shift
    next unless (st = File.stat(filename)) && st.file? && st.readable?
    f = File.zopen(filename)
    match |= if rg(re, f, opt) then 1 else 0 end
    f.close
  end
end

if match == 0
  exit 1
end
#! /usr/local/bin/ruby -Ke
# /home/tetsu/src/ruby/toolbox/rg.rb
# Created: July 09,2000 Sunday 18:26:03
# Author: tetsu(WATANABE Tetsuya)
RCS_ID = '$Id: rg.rb,v 1.26 2007/05/29 17:42:10 tetsu Exp $'
# usage:

class File
  def File.zopen(filename)
    suff2exe = {
      '\.lzh' => 'lha l ',      # lha pq
      '\.zip' => 'unzip -l ',   # unzip -p
      '\.zoo' => 'zoo -list ',  # zoo xpq
      '\.tar.gz' => 'tar tzvf ',
      '\.tgz' =>    'tar tzvf ',
      '\.tar.bz2' => 'tar tIvf ',
      '\.tbz' =>     'tar tIvf ',
      '\.gz' => 'zcat ',
      '\.z' =>  'zcat ',
      '\.Z' =>  'zcat ',
      '\.bz2' => 'bzcat '
    }

    suffix = ''
    suff2exe.keys.sort do |a, b|
      b.size <=> a.size
    end.each do |s|
      if filename =~ /#{s}$/
        suffix = s
        break
      end
    end

    if suff2exe.key?(suffix)
      f = File.popen(suff2exe[suffix] + filename)
      eval "def f.path; '#{filename}' end"
      f
    else
      File.open(filename)
    end
  end
end

def rg(re, f, opt)
  opt_multi = opt['multi']
  opt_lineno = opt['lineno']
  opt_kanji = opt['kanji']
  opt_binary = opt['binary']
  opt_email = opt['email']
  opt_nkf = opt['nkf']
  opt_spaceone = opt['spaceone']
  opt_revertmatch = opt['revertmatch']
  opt_filenameonly = opt['filenameonly']
  opt_filename = opt['filename']

  match = false
  sep = "\n"
  
  if opt_multi
    linecount = 0 if opt_lineno
    sep.concat("\n")
    lf2 = if opt_kanji then '' else ' ' end # 改行の扱い
  end

  while l = f.gets(sep)
    break if ! opt_binary && l.count("\000") > 1
    str = l.dup
    str.gsub!(/^[|>\s]+/, '') if opt_email
    str = str.gsub(/^\s+/, '').gsub(/\s+$/, '').gsub(/\n/, lf2) if opt_multi
    str = NKF.nkf(opt_nkf, str).gsub(/([ -瑤])\s+([ -瑤])/, '\1\2') if opt_kanji
    str.gsub!(/[ \t]+/, ' ') if opt_spaceone

    m_flag = str =~ re

    if (m_flag && opt_revertmatch == false) || (m_flag.nil? && opt_revertmatch)
      match = true
      if opt_filenameonly
        puts f.path
        break
      else
        if opt_multi
          l.gsub!(/^/) do (linecount += 1).to_s + ':' end if opt_lineno
          l.gsub!(/^/) do f.path + ':' end if opt_filename
        else            
          print f.path, ':' if opt_filename
          print f.lineno, ':' if opt_lineno
        end
        print NKF.nkf(opt_nkf, l)
      end
    else
      linecount += l.count("\n") if opt_multi and opt_lineno
    end
  end
  match
end

def usage
  STDERR.print <<EOF
usage: #{$0} [-befiklmnsv] [files...]
  b: binary file
  e: E-mail format
  f: filename
  i: ignore_case
  k: kanji
  l: only filename
  m: multi line
  n: line number
  s: space one
  v: revert match
  V: version
EOF
  exit 1
end
  
opt = {
  'binary' => false,            # バイナリファイルも
  'email' => false,             # E-mail 形式の引用を扱う
  'filename' => false,          # ファイル名表示
  'ignorecase' => false,        # アルファベットの大文字小文字を区別しない
  'kanji' => false,             # 漢字を検索する場合
  'filenameonly' => false,      # ファイル名だけ表示
  'multi' => false,             # 行をまたいだパターンを指定したい場合
  'lineno' => false,            # lineno 表示
  'revertmatch' => false,       # マッチしなかったら
  'spaceone' => false,          # 連続スペースを一つとして扱う
  'fgrep' => false,             # fgrep と同じ
  'nkf' => '-eZ1m'              # 文字種統一のため
}

while ARGV[0] =~ /^-/
  $_ = ARGV.shift
  opt['binary'] = true if ~/b/
  opt['email'] = true if ~/e/
  opt['filename'] = true if ~/f/
  opt['ignorecase'] = true if ~/i/
  opt['kanji'] = true if ~/k/
  opt['filenameonly'] = true if ~/l/
  opt['multi'] = true if ~/m/
  opt['lineno'] = true if ~/n/
  opt['spaceone'] = true if ~/s/
  opt['revertmatch'] = true if ~/v/
  opt['fgrep'] = true if ~/F/
  if ~/^-V/
    print RCS_ID, "\n"
    exit
  end
  usage if ~/[^\-befiklmnsvVF]/
end

usage if ARGV.size < 1

require 'nkf'

pattern = NKF.nkf(opt['nkf'], ARGV.shift)
pattern = Regexp.quote(pattern) if opt['fgrep']
re = Regexp.new(pattern, opt['ignorecase'])

match = 0

opt['filename'] = true if ARGV.size > 1

if ARGV.size == 0
  class IO; def path; '-' end end
  match |= if rg(re, STDIN, opt) then 1 else 0 end
else
  while filename = ARGV.shift
    next unless (st = File.stat(filename)) && st.file? && st.readable?
    f = File.zopen(filename)
    match |= if rg(re, f, opt) then 1 else 0 end
    f.close
  end
end

if match == 0
  exit 1
end

■ 解説

grep コマンドなのですが、できるだけ手軽に使いたいということがあって次のような特徴を持ちます。 特徴というか、私の希望ですね。

これらの希望を実現したのが rg.rb (ruby で grep) です。

「結果を表示する」ためと、「検索する」ことをわけて扱っています。 検索するためには、キーワードのマッチを考えて前処理をいくつか行っています。 特に漢字のドキュメントでは、普通はスペースや改行を無視して扱いたいので、前処理が必要になっています。 これは、オプションを指定した場合の動作です。 結果は、前処理後のものを表示するのではなく、オリジナルを表示する必要があるので「保存」しています。

デフォルトの動作として、引数で渡されてしまったテキスト以外のファイルを無視するようにしています。 これは、手軽にシェルのワイルドカード「*」を使えるようにするためです。 ですから、いきなり

$ cd /bin
$ rg.rb sh *
igawk:#! /bin/sh
igawk:    shift
igawk:    --)     shift; break;;
igawk:    -W)     shift
igawk:            shift;;
igawk:            shift;;
igawk:            shift;;
igawk:            shift;;
igawk:    shift
igawk:        shift
remadmin:#!/bin/sh
remadmin:       shift
とか、できてしまいます。

漢字のための前処理もデフォルトにしたいかなと思ったのですが、オーバーへッドが気になったので、オプション指定時だけにしています。

ちょい、扱いがめんどうだったのが、入力をブロックで扱ったときの行番号です。 最初どうしようか悩みました。 もっとスマートな方法があればいいのですが、目的は達成できているのでとりあえず?

rg.rb を Emacs 上で使うための Emacs-Lisp です。 もとは、mg.pl 用だったりしますけど。

;; rg.rb 2000/7/10
(defun rg (command)
  "Run rg instead of grep."
  (interactive "sRun rg (with args): ")
  (require 'compile)
  (compile-internal (concat "rg.rb -n " command " /dev/null")
            "No more rg hits" "rg"))

漢字を使用する場合には、整形プログラム(roff 系)や w3m などでスペースが入ったりします。 漢字オプションを加えると、漢字と漢字の間のスペースを無視するようになっています。 文字種統一も考えていています。 漢字オプションを指定すると、「検索するパターン」と「検索対象」を同じ nkf モジュールのオプションで処理します。 このため、文字種の違いによる検索がはずれるということが起きにくくなります。 検索対象が改行によって二行に分断されている場合も対応しています。 E-mail の引用時のように、行頭に引用の「|」や「>」が含まれる場合も。 使っていくうちに調整が必要になると思いますが、なかなか便利です。

■ 余談

grep(1) コマンドの grep って「g/re/p」なのはご存じですよね? って、ed(1) はしりません?

「global」に「/regular expression(正規表現)/」を検索して「print」する
という感じです。

こんな感じで使ったりするんです。

$ ed ChangeLog 
303885
g/Ruby/p
        * eval.c (rb_eval): the value from RTEST() is not valid Ruby
        * regex.c (re_match): now understands interrupts under Ruby.
n
3927            * regex.c (re_match): now understands interrupts under Ruby.
ファイル名を指定すると、そのサイズが最初に表示されます(303885)。 コマンドとして「g/Ruby/p」を入力します。 「g」全体を「/Ruby/」Ruby で検索して、結果を「p」表示する指定です。 「p」がなければ、表示はありません。 「n」コマンドを使っていますが、これは行番号付で行を表示するということです。 終了は「q」です。 書き込むときは「w」をお忘れなく。 オンラインマニュアルにコマンドの説明などがありますので、ちょっとだけ試してみるのもおもしろいと思います。 vi(1) が使えないときに役立つかもしれませんよ?

ラインエディタ(ed)って、普通使わないですよね。 これでプログラム書いたんですよ。 すごいですね。 また、スクリプトとしてファイルの編集に ed(1) も使っていました。 いまは Ruby などの便利なスクリプト言語があるので、ほとんどみませんけど。

昔々は、ターミナルがテレタイプ(キーボードとプリンタ用紙)だったんですよ。 そういう環境では、スクリーンエディタなんて使えませんよね。 ed(1) なら、キーボードから入力して、プリンタに出力されるだけでも使えます。 ちなみにいまのプリンタと違って、これも一行一行文字を出力するくらいしかできないものですけど。 こういう環境だったので、ls(1) みたいなコマンド名使っていたりしたんですよ。 しばらくして、ターミナルがキャラクタベースのものになってきて、ようやく vi(1) のようなスクリーンエディタがでてきました。 BSD で Bill Joy さんが作ったものです。 このとき、遅いモデム(300bps とか)でも動くように表示領域を小さくするとかいろいろ工夫していたりするんです。

私が UNIX を使いはじめたときは vi(1) はありましたので、ed(1) だけを使うということはありませんでした。 でも、learn(1) という自己学習コマンド(CAI)があって、それで勉強しました。 ファイルを編集するようなスクリプトを作成する場合にも使えたことと、vi(1) が動かなくなったときのために勉強したものです。 vi(1) がいつでも動くとは限らないですから。

追加したいオプションとしては

というのがあります。 件数表示のほうは、そんなにややこしくなさそうですね。 前後の行については、保存しておいて必要なタイミングで表示すればできるかな。 「前」はいいですけど「後」はややこしい? 現状では、オプション -m (行をまたいだ検索) を使うことで、「空行ブロック」単位に表示します。 これである程度代用できちゃいます。 みなさんは、grep のオプションでほしいなと思うのはなんでしょう? 私はほとんど困っていないのですが...

fgrep オプション -F サポート記念の追加です。 grep の仲間として egrep や fgrep があります。 egrep は、awk と同様に拡張正規表現がサポートされています。 fgrep は fixed grep ということで、正規表現に使用されるメタ文字をそのまま検索します。 けして fast grep ではありません(よく間違う?)。 開発された当時は、検索の処理速度が違っていて、通は?「普段から egrep を使っていた」なんて話もありました。 また、egrep の拡張正規表現がキーワードの区切り(Ruby での「\b」)などを指定できてかなり便利なものでした。 速度の話は、むかしむかしの話ですので、現在は違っているかな。

■ 履歴

1.26 2007/5/29

リファクタリングかな。 $_ 依存の書きかたを変更。 オプションの渡しかたの変更。

1.25 2003/3/24

文字種指定の []- を使うときに、たとえ先頭の - でも \ が必要と警告が表示されるための対応。

1.24 2002/2/13

fix 拡張子の定義の「.」をエスケープ

1.23 2001/10/23

fgrep オプション -F のサポート

1.22 2001/8/14

ruby-1.7.1 2001-08-06 対応です

1.21 2001/3/21

一度 File::stat して、その後この情報を再利用します

1.20 2001/3/1

オプション -l で、ファイル名だけの表示ができなかったので bug fix しました


渡辺哲也(WATANABE Tetsuya): Tetsuya.WATANABE atmark nifty.com