Tefil

Ippei Kishida

Last-modified:2018/09/13 23:47:44.

1 概要

RubyGems.org に公開してあります。

テキストフィルターコマンドのフレームワークと、 それを使った幾つかのフィルターコマンドを提供します。

2 コマンド

2.1 calc

bc コマンドは小数点の前のゼロが省略されたりして不便なので作りました。

% echo '1+2' | calc
3

% echo '1.0+2.0' | calc
3.0

% echo 'sqrt(2)' | calc
1.41421356237309504880

% echo 'l(2)' | calc
0.69314718055994530941

% echo 'e(1)' | calc
2.71828182845904523536

-r, –ruby オプションを付けると Ruby 式の表現での演算ができます。

% echo 'Math::PI' | calc -r
3.141592653589793

-p, –preserve オプションを付けると、元の式を出力に含めて「=」で結んだものを出力します。

% echo 'e(1)' | calc -p
e(1) = 2.71828182845904523536

-s, –split オプションを付けると、任意の文字を区切り文字とし、 各要素を別個に演算します。

% echo "1+2 , 3 * 4" | calc --split=","
3,12

-S, –supply オプションを付けると、 ストリームで与えられた演算に対して、最後に式を追加します。

% echo "1.2 3.4" | calc --split=" " --supply="/2"
0.60000000000000000000 1.70000000000000000000

ただし、単純に追加するだけなので 演算の優先順序が作用します。 以下の例では、1+2 に /2 を補足するので、“1+2/2” となり、結果が 2 となります。

% echo "1+2"         | calc --supply="/2"
2.00000000000000000000

以下のような表形式のデータを加工するのに向いています。

0.0 0.1 0.2
0.3 0.4 0.5
0.6 0.7 0.8

2.2 indentconv

インデントの字下げ幅を変更します。 以下の例では字下げ単位を2から 4 に変更しています。

% cat sample0.txt
a
  b
    c
  b
    c

% indentconv 2 4 sample0.txt
a
    b
        c
    b
        c

0 は特殊な数字で、タブ文字1個を意味します。 以下の例では字下げをスペース2個からタブに変更しています。

% cat sample1.txt
  a
    b
      c
    b
      c

% indentconv 2 0 sample1.txt
    a
        b
            c
        b
            c

以下の例では字下げをタブからスペース2個に変更しています。

% cat sample2.txt
a
    b
        c
    b
        c

% ~/git/tefil/bin/indentconv 0 2 sample2.txt
a
  b
    c
  b
    c

2.3 indentstat

ファイル内のインデント幅の集計を取ります。

% cat sample0.txt
a
  b
    c
      d
        e

    c
      d
        e

        e

% indentstat sample0.txt
 0|***
 2|*
 4|**
 6|**
 8|***

引数のファイルが1つならば上記のようにファイル名を表示しませんが、 複数ならばファイルごとにファイル名を表示します。

% cat sample1.txt
  a
    b
      c
        d
          e
  
      c
        d
          e
  
          e


% indentstat sample0.txt sample1.txt
sample0.txt:
 0|***
 2|*
 4|**
 6|**
 8|***

sample1.txt:
 2|***
 4|*
 6|**
 8|**
10|***

-m, –minimum オプションは 非ゼロの最小のインデント幅を出力します。 ただし、全ての行のインデント幅が 0 ならば 0 を出力します。

% indentstat sample0.txt -m
2

2.4 md2fswiki

Markdown 形式のフォーマットを FreeStyleWiki 形式に変更します。 ただし完璧に動作するわけではなく、複雑な構文は処理できません。

% cat sample.md
# head1

## head2

### head3

abc *italic* def
abc **bold** def
* item
    * item
        * item
            * item
1. enum
    1. enum
        1. enum
            1. enum
[Google](http://www.google.co.jp/)" ,
    formatted text"

<!-- comment -->

% md2fswiki sample.md
!!! head1

!! head2

! head3

abc ''italic'' def
abc '''bold''' def
* item
*** item
     '' item
         '' item
+ enum
+++ enum
     1. enum
         1. enum
[Google|http://www.google.co.jp/]" ,
 formatted text"

<!-- comment -->

2.5 fswiki2md

FreeStyleWiki 形式のフォーマットを Markdown 形式に変更します。 ただし完璧に動作するわけではなく、複雑な構文は処理できません。

% cat sample.fswiki
!!! head1
!! head2
! head3
abc ''italic'' def
abc '''bold''' def
* item
** item
*** item
**** item
+ enum
++ enum
+++ enum
++++ enum
[Google|http://www.google.co.jp/]" ,
 formatted text"
----"
// comment"

% fswiki2md sample.fswiki
# head1

## head2

### head3

abc *italic* def
abc **bold** def
* item
    * item
        * item
            * item
1. enum
    1. enum
        1. enum
            1. enum
[Google](http://www.google.co.jp/)" ,
    formatted text"
---"
<!-- comment"-->

2.6 percentpack

URL などに用いられる % を伴う 16 進数表記を バイナリにパッキングします。

% cat sample.txt
%E3%83%86%E3%82%B9%E3%83%88

% percentpack sample.txt
テスト

2.7 zshescape

文字列のうち、 zsh で特別な意味を持つものをバックスラッシュエスケープします。 ファイル名の変更スクリプト作成などに使えるでしょう。

% cat sample.txt
abcdABCD * * *

% zshescape sample.txt
abcdABCD\ \*\ \*\ \*

2.8 linesub

行ごとに文字を置換します。 以下の例は「beggar」という文字列を「BEGGAR」に置換します。 デフォルトでは最初にヒットした文字列のみ置換します。

% cat sample1.txt
A bad workman always blames his tools. Once a beggar, always a beggar.

% linesub beggar BEGGAR sample1.txt
A bad workman always blames his tools. Once a BEGGAR, always a beggar.

ヒットする箇所全てを置換するには -g, –global オプションを付加します。

% linesub beggar BEGGAR sample1.txt -g
A bad workman always blames his tools. Once a BEGGAR, always a BEGGAR.

置換元を正規表現として扱うには -r, –reg-exp オプションを付加します。

% linesub "\S" x sample1.txt -r -g
x xxx xxxxxxx xxxxxx xxxxxx xxx xxxxxx xxxx x xxxxxxx xxxxxx x xxxxxxx

-o, –overwrite で入力ファイルを上書きします。 -O, –overwrite-backup=suffix は入力ファイルを上書きしますが、 元ファイルのバックアップを取ります。 バックアップファイル名は suffix を末尾に付けたものになります。

2.9 linesplit

行を分割して複数の行にします。 句点の後ろに改行を挿入するようなことができます。

% cat sample1.txt
A bad workman always blames his tools. Once a beggar, always a beggar.

% linesplit sample1.txt
A bad workman always blames his tools.
 Once a beggar, always a beggar.

デフォルトでは半角ピリオドのみに作用します。 上の例では切り分けられた後の行の先頭に空白が入っています。 これは元の文で 「. 」のようにピリオドのあとの空白がありますが、 ピリオドのあとに改行文字が入ったためです。 行頭や行末の空白を削除するために –strip オプションを用意しています。

% linesplit --strip sample1.txt
A bad workman always blames his tools.
Once a beggar, always a beggar.

区切り文字のデフォルトは半角ピリオドですが、 –separator オプションで文字列を複数指定できます。 指定は半角スペース区切りでまとめた文字列として与えます。

% linesplit --separator=", ." sample1.txt
A bad workman always blames his tools.
 Once a beggar,
 always a beggar.

–separator オプションで 文字列 を指定できるという例を示します。

% linesplit --separator=beg sample1.txt
A bad workman always blames his tools. Once a beg
gar, always a beg
gar.

半角スペースを区切り文字に追加する場合には –space オプションを使います。

% linesplit --space sample1.txt
A 
bad 
workman 
always 
blames 
his 
tools.

Once 
a 
beggar, 
always 
a 
beggar.

「Fig. 」などの略称のピリオドを除外したいことがあるかもしれません。 このために、 –except オプションで例外となる文字列を指定できます。 -e, –except オプションは例外となる文字列を追加します。 これまでのサンプルで、 tools. を除外することにしましょう。

% linesplit --except="tools." sample1.txt
A bad workman always blames his tools. Once a beggar, always a beggar.

この例外文字列も複数指定できます。 実行結果は示しませんが、以下のようにできます。

linesplit --except="FIG. Fig."

2.10 table コマンド

columnform, columnanalyze, table コマンドは Tefil から削除されました。 tabmani ジェムをご利用ください。 [2018-09-13]

3 ライブラリの使用方法

3.1 テキストフィルタおさらい

テキストフィルタ、使ってますか? 文字列からなるストリームを適当に加工するものですが、 unix は様々なフィルタが用意されています。 (see フィルタ (ソフトウェア) - Wikipedia) grep, cat, head, tail, wc, sort などが該当します。 Tefil ライブラリはこのようなフィルタを自作するためのフレームワークを提供します。 本節では 幾つかのフィルタプログラムを見ながら、 フレームワークにどのような機能が必要かを確認していきましょう。

3.1.1 grep

grep は入力を行ごとに処理し、文字列を含む行を抽出します。

% grep foo file.txt
% grep foo *.txt
% dmesg | grep eth

上記の 1 行目では、ファイル file.txt から foo という文字列を含む行のみを表示し、 それ以外の行を無視します。 2 行目のように複数のファイルを指定できます。 フィルタは 1, 2 行目のように、可変個数のファイルを扱えると便利です。

3 行目のように、ファイルの中身だけでなく、他のプログラムの出力に フィルタをかけることもできます。 3 行目は地味なようですがパイプ処理によって他のコマンドと連携させて使用することが可能になり、 応用がぐっと広がります。 grep では引数で与えるファイルの個数が 0 個のときに、入力をファイル入力から標準入力に切り替えます。 ファイル入力と標準入力は一見大きく異なるものに見えるかもしれませんが、 プログラム上はいずれもストリームという上位概念で包括されます。 プログラムに順にデータが流し込まれ、流れ出ていくと考えましょう。 フィルタは標準入力を扱えるべきです。

3.1.2 bc

grep は行ごとに処理していますが、 キーボード入力に対してフィルタリングして便利になるケースは grep ではほぼありません。 このため入力を一括して取り込み、文字列として処理すれば良いような気がしてきます。 しかしテキストフィルタには、これでは不便な場面が出てきます。 その例に bc コマンドを挙げましょう。 bc コマンドは簡単な計算機です。 以下のように使います。

% echo "1+2" | bc
3

% bc
bc 1.06.95
Copyright 1991-1994, 1997, 1998, 2000, 2004, 2006 Free Software Foundation, Inc.
This is free software with ABSOLUTELY NO WARRANTY.
For details type `warranty'. 
1+2
3
2+3
5

前者は標準入力に “1+2” という数式を与えたもので、 プログラムは 3 を出力して終了します。 後者は 引数を与えずに実行したもので、 「bc 1.06.95」から「For details type `warranty’. 」を表示したあとキーボードからの入力を待ちます。 そこで 1+2[Enter] と打つと プログラムが 3 を返します。 終了するには Ctrl-d を打ちます。

もし入力を一括して受け取ってから文字列として処理するような処理だったら、 後者で 「1+2」と打っても 「3」が出力されず、 「2+3」と Ctrl-d を打ったあとに答えが「3」「5」と出力されることになります。 このようなプログラムは対話的に反応が返る方が好ましいです。 一般にフィルタは文字列として入力を受けるのではなく、 IO ストリーム として情報を処理すべきだということです。

3.1.3 sort

grep, bc は行ごとに完結した処理で出力を行っていました。 しかしフィルタには行ごとでは処理を行わず、 ストリーム全体の情報を使って出力を行うものもあります。 この例として sort を挙げましょう。

% dmesg | sort

出力は割愛しますが、 dmesg の出力が行単位でソートされて出力されます。 ソートするということは最初に出力すべきものが 最後に入力される可能性があるわけで、 必然的に入力が完了するまで出力が行われないことになります。

このことから、フィルタは行単位の処理で完結できるとは限らないことが分かります。

3.1.4 まとめ

ここまでをまとめると、以下のようになります。

3.2 Tefil の役割

Tefil はフィルタを自作するためのフレームワークを提供します。 Tefil::TextFilterBase がフィルタプログラムの骨格となるクラスです。 このクラスは process_stream メソッドで例外を生じる抽象クラスになっています。 プログラマはこのクラスを継承したサブクラスで process_stream メソッドを定義してください。 基本的には、bin/ に入っているプログラムおよび ここから require されているバックエンドのライブラリのソースを見れば分かると思います。

本節のサンプルファイルは以下に置いてあります。

3.3 フィルタのプログラミング

3.3.1 インストール

% gem install tefil

3.3.2 簡単な使い方

Tefil を使った簡単なフィルタ作成を実演してみましょう。 入力を行単位に分割し、 行の内容を10回繰り返すフィルタを作ってみます。 ファイル名を list010.rb とします。

#! /usr/bin/env ruby
# coding: utf-8

require "tefil"

class Filter10Times < Tefil::TextFilterBase
  def process_stream(in_io, out_io)
    in_io.each_line do |line|
      out_io.puts line.chomp * 10
    end
  end
end

f10 = Filter10Times.new
f10.filter(paths: ARGV)

説明は不要だと思いますが、一応。

  1. tefil を require する。
  2. Tefil::TextFilterBase を継承した Filter10Times クラスを作成。
  3. Filter10Times クラスで process_stream を作成。
  4. Filter10Times クラスインスタンス f10を作成。
  5. f10 のメソッド filter を作用させ、コマンドライン引数の配列を与える。

process_stream は 2 つの IO を引数に取ります。 これが文字列ではないということに注意してください。

filter メソッドに与える配列の要素をパスと見做して、 各個処理します。 この配列が空ならば、STDIN を扱います。

テスト環境を作って実行してみましょう。 以下の内容ファイルを用意して、

% cat ora.txt
オラ

% cat muda.txt
無駄

引数ファイル1個の場合。

% ruby list010.rb ora.txt
オラオラオラオラオラオラオラオラオラオラ

引数ファイル2個の場合。

% ruby list010.rb ora.txt muda.txt
オラオラオラオラオラオラオラオラオラオラ
無駄無駄無駄無駄無駄無駄無駄無駄無駄無駄

ファイル指定なしの場合、標準入力から。

% echo "オラ" | ruby list010.rb
オラオラオラオラオラオラオラオラオラオラ

キーボード入力してみましょう。 以下は「オラ[Enter]無駄[Enter]」と入力しています。

% ruby list010.rb
オラ
オラオラオラオラオラオラオラオラオラオラ
無駄
無駄無駄無駄無駄無駄無駄無駄無駄無駄無駄

filter メソッドを使う方法は簡単なので、まずは これを使うと良いでしょう。

3.3.2.1 上書き機能

filter メソッドの output に :overwrite を付加すると 上書きモードになります。

f10.filter(paths: ARGV, output: :overwrite)

3.3.2.2 バックアップ付き上書き機能

f10.filter(paths: ARGV, output: :overwrite_backup, suffix: ".bak")

filter メソッドの output に :overwrite_backup を付加すると バックアップ付きの suffix として文字列を与えるとバックアップファイルの末尾に 付加されます。 デフォルトは “.bak” です。

3.3.3 複数フィルタの連携(ストリーム全体を使った処理)

複数のフィルタを連携させましょう。 ここでは 別に行末に「!」を追加する FilterExclamation を作成し、 先に作った Filter10Times と連携してみましょう。 以下の list020.rb を作ります。

#! /usr/bin/env ruby
# coding: utf-8

require "tefil"

class Filter10Times < Tefil::TextFilterBase
  def process_stream(in_io, out_io)
    in_io.each_line do |line|
      out_io.puts line.chomp * 10
    end
  end
end

class FilterExclamation < Tefil::TextFilterBase
  def process_stream(in_io, out_io)
    in_io.each_line do |line|
      out_io.puts line.chomp + "!"
    end
  end
end

f10 = Filter10Times.new
fex = FilterExclamation.new
Tefil::TextFilterBase.open_stream(paths: ARGV, show_filenames: true) do |io|
  result = io.tefil_filter(f10).tefil_filter(fex)
  print result.read
end

実行してみましょう。

% ruby list020.rb ora.txt
オラオラオラオラオラオラオラオラオラオラ!

最後に「!」が付きました。 list020.rb を以下のように書き換えてみましょう。 ここでは list030.rb としています。

  #result = io.tefil_filter(fex).tefil_filter(f10)
  result = io.tefil_filter(f10).tefil_filter(fex)

実行すると以下のようになります。 作用する順番が変わってますね。

% ruby list030.rb ora.txt
オラ!オラ!オラ!オラ!オラ!オラ!オラ!オラ!オラ!オラ!

標準入力、特にキーボード入力はどうでしょうか? ruby list020.rb で実行し、「オラ」と入力しても下のように反応しないように見えます。 ここで Ctrl-d を入力するとストリームが完結し、処理されます。

% ruby list020.rb
オラ

list020.rbopen_streamshow_filenames: true を渡しています。 これで複数ファイルを引数で渡したときに、 ファイルの先頭でファイル名を表示するようになります。

% ruby list020.rb ora.txt muda.txt
ora.txt:
オラオラオラオラオラオラオラオラオラオラ!
muda.txt:
無駄無駄無駄無駄無駄無駄無駄無駄無駄無駄!

IO#tefil_filter を使った処理は、 ストリーム全体を使うため、ストリームが完結するまで処理が行われません。 このため、sort や wc のような処理に向いています。 しかし bc でキーボード入力と対話的に使用するようなことはできません。

3.3.4 複数フィルタの連携(行単位の処理)

行単位での処理を行うようにしてみましょう。 list020.rb を以下のように修正したものを list040.rb とします。

result = io.tefil_filter(f10).tefil_filter(fex)
print result.read

io.each_line do |str|
  print str.tefil_filter(f10).tefil_filter(fex)
end

実行してキーボード入力してみましょう。 以下のように、Enter 入力直後に反応します。

% ruby list040.rb
オラ
オラオラオラオラオラオラオラオラオラオラ!

IO#each_line を使うことで 1行ごとに文字列を取得し、 その単位で処理を行います。 このため、 bc のような逐次処理する可能性のある処理もでき、最も汎用性の高い方法です。

3.3.5 既存クラスへの影響

require “tefil” すると、 IO クラス と StringIO クラスに tefil_filter メソッドが追加されます。 このメソッドは フィルタオブジェクトを引数として取り、 自身(self) の ストリームを process_stream で処理した StringIO を rewind して返します。 この機構により、メソッドチェインでフィルタをかけることが出来ます。

String クラス に tefil_filter メソッドを追加します。 Tefil::TextFilterBase#process_stream で定義されたのと 同様の処理を自身(self) に施した String を返します。 当然ですが、これもメソッドチェインでフィルタをかけることが出来ます。

3.4 メモ

3.4.1 Tefil::TextFilterBase クラス

Tefil::TextFilterBase はインスタンス変数を持っていないため、 クラスではなくモジュールでも良さそうに見えます。 しかし、これを継承したサブクラスを作る際に、 initialize でインスタンス変数を設定できた方が便利になることもあると判断しました。