SCSH(Scheme Shell)スクリプト入門
SCSHは、Schemeプログラミング言語をベースとしたスクリプト言語だ。shやbashの長いスクリプトに置き換わることと、Scheme言語をシェルスクリプト作成により向いたものに拡張することを目的として、Olin Shivers氏によって作成された。
SCSHはUnixシステム(Linux、BSD、Cygwin)にLisp的なインターフェースを持たせるもので、SCSHでは正規表現のDSL(ドメイン固有言語)とawkのDSLを利用することができる。UnixとLispがそれぞれ元にしている考え方に違いがあるため、スクリプトの作成にPerlやシェルを使用しているUnixユーザには一見するとSCSHが奇妙に感じられるかもしれない。
Unixの考え方とLispの考え方
Unixでは、「専門化」と「文字列」というキーワードで表わされる考え方をする。Unixにおける「専門化」の意味は、複数の仕事を行う一つの巨大なプログラムを作成するのではなく、それぞれは一つの仕事をうまく行う小さなプログラムを複数作成するということだ。そうすることにより、組み合わせて大きなプログラムを作成することのできる小さくて単純なコンポーネントが作成されるので、モジュール性が高まる。
またUnixでは「文字列」を引数としてプログラムを実行することが、一つのプログラムから別のプログラムにデータを渡すための唯一の方法となっている。言い換えれば、渡されるデータのデータ型が失われるため、データを受け取るプログラムがデータをパースして文字列を適切なデータ型に変換する必要があるということだ。例を挙げると、「kill 223
」を実行したときには、「223」という文字列がパースされて数値に変換される。このように、入力を受け取るUnixのプログラムはすべて、受け取った文字列を何らかの必要なデータ型のオブジェクトに変換するパースを自ら行わなければならない。そのためオブジェクトとして受け渡しを行うことが面倒になっている。
(Scheme系の言語でもCommon Lisp系の言語でも)Lispのプログラムでは、モジュール性が同様に好まれるものの、整数/シンボル/リストなどのオブジェクトという形でプログラム間でデータを受け渡しするということも好まれる。このことは例えばEmacsにも見ることができて、Emacsでは文字列だけではなく様々な型のオブジェクトを扱う複数の小さなEmacs-Lispプログラムが使用されている。
SCSH(Scheme Shell)の特徴
SCSHでは、他のたいていの言語とは異なり正規表現エンジンが文字列ベースでは“なく”、Lisp風の文法を持った組み込みDSLになっている。またSCSHでは「/」(スラッシュ)をシンボル名の中で使用することができるので、文字列型を使用せずにファイル名を作成することができる。さらにSCSHではサーバやクライアントの作成を自動化する高レベル関数も備えたネットワークソケットインターフェースが提供されている。
正規表現を作成するための文法は「SRE」と呼ばれるもので、Schemeに似ている(つまりリストの集まり)。文字列である通常の正規表現の表記法と比べると、正規表現を説明するためのコメントを書き加えることができたり、正規表現を組み合わせて作成することができたりするなどの利点がいくつかある。SREコードにコメントを書き加えることは、Schemeのコメントを書き加えることで行われる。SREはリストなので、Perlのようにコメントを正規表現の表記中に直接的に書き入れる必要はない(そのような書き方は正規表現のPOSIX標準に反していて、おそらく他の正規表現エンジンとの互換性はない)。このことは例えばPerlの正規表現で書いた数独の解答プログラムや、有名な電子メール確認用の正規表現など、長々しい正規表現が必要な場合に役立つ。正規表現には実行時に具体的な内容の決まる動的正規表現を使用することもできる。これはPerlなどの言語で行うことのできる変数展開に似ているが、SCSHでは変数展開がPOSIXの正規表現の標準とかち合うことがないという点が異なる。
Lispのマクロを使えば、コンパイル時に文法を操作することができる。つまり自分で自由に新しい文法を定義することができるということだ。Schemeの仕様ではマクロは構文と呼ばれていて、Schemeには新たな構文を定義するのに役立つパターンマッチングツールがある。SCSHでは、正規表現エンジン、プロセスの表記法、awkの表記法はすべてマクロ(構文)として定義されている。新たな構文を定義することは、行いたいことの乱雑な詳細を隠蔽するのに役立つ。例えばawkの構文では、使用するべきレコードリーダーをユーザが指定できるようにすることで、レコードを検索するためにファイルを一行ずつループで処理するコードを隠蔽している。
複数のHTMLファイルに対し一部のテキストを置換する
それではSCSHが実際に役立つ例を紹介しよう。以前、latex2htmlプログラムを使用してLaTexファイルからHTMLファイルを生成しなければならないことがあった。ところが困ったことに、引数を指定せずにlatex2htmlを実行するとナビゲーションバー用の画像のパス名が絶対パス名として生成された。生成したHTMLを表示するウェブブラウザは/usr/lib/latex2html/icons/の中で画像を探そうとするのだが、このウェブページをインターネット経由で閲覧する場合や、latex2htmlをインストールしていない場合には画像を見つけることができない。
解決方法は、(HTMLファイルと一緒に配布するように)ナビゲーションバー用の画像をパッケージにまとめて、HTMLファイル内で絶対パス名になっている箇所を見つけ出して置換することだった。そのため、まずlatex2htmlに一つの引数を指定して「latex2html testdoc.latex
」として実行した。これによりtestdocという名前のディレクトリが作成されて、そのディレクトリの中にHTMLファイルが生成された。
<!--ここまでHTMLファイルの内容--> <!--ナビゲーションバー--> <A NAME="tex2html2" HREF="node1.html" <IMG WIDTH="37" HEIGHT="24" ALIGN="BOTTOM" BORDER="0" ALT="next" SRC="file:/usr/lib/latex2html/icons/next.png"></A> <IMG WIDTH="26" HEIGHT="24" ALIGN="BOTTOM" BORDER="0" ALT="up" SRC="file:/usr/lib/latex2html/icons/up_g.png"> <!— この後もHTMLが続く —>
ここで「file:/usr/lib/latex2html/icons/
」となっている箇所を見つけ出して、別のパスか空の文字列に置換する必要がある。私の場合は空の文字列に置き換えて、使いたいアイコン画像をHTMLファイルと一緒に配布することにした。この単純作業を行うためのコードを以下に示す。
#!/usr/bin/scsh -s !# (define replace (rx "file:/usr/lib/latex2html/icons/")) (define (read-lines) (port->string-list (current-input-port))) (define (replace-line line) (regexp-substitute/global (current-output-port) replace line 'pre 'post) (newline)) (for-each (lambda (fname) (let ((lines (with-input-from-file fname read-lines))) (rename-file fname (string-append fname ".bak")) (with-output-to-file fname (lambda () (for-each replace-line lines))))) (glob "*.html"))
このコードは、SCSHの基本的な使い方を示す良い例だ。最初の行は、使用するインタプリタをシェルに伝えるためのシェバング行だ。次の3行では、置換したい正規表現、現在の入力ポートを文字列のリストに変換する関数、正規表現にマッチした文字列を空の文字列で置き換える関数をそれぞれ定義している。この例の正規表現には特別な点は特になくて、単にマッチさせる文字列を指定しているに過ぎない。
続く行ではfor-each
関数を呼び出して、「*.html」に該当するすべてのファイルに対してlambda
で定義する関数を適用している。lambda
で定義した関数の中では、let
フォームを使用して、ファイルfname
から読み取った行のリストを変数lines
に代入している。この関数の後半部分では、バックアップのためにファイルをファイル拡張子「.bak」を付け足したファイル名に改名している。その後いよいよ、文字列の置換を行っている。
(with-output-to-file fname (lambda () (for-each replace-line lines)))))
この部分では、まずファイルfname
を開いて、次に変数lines
内の各文字列について関数replace-line
を適用することで、すでに指定した正規表現に対するマッチを見つけて置換し、開いているファイルに行を出力している。
ファイルを日時に基づいてソートする
次に紹介する実用的な例は、最近更新されたファイルをソートして表示するためのスクリプトだ。
#!/usr/bin/scsh \ -o sort -s !# (define (new-date day month year) (make-date 0 0 0 day (- month 1) (- year 1900))) (define older-than? <=) (define newer-than? >=) (define (date-is comparison-proc day month year) (lambda (f) (comparison-proc (file-last-mod f) (time (new-date day month year))))) (define (sort-by-date filter-proc filenames) (sort-list (filter filter-proc filenames) (lambda (a b) (older-than? (file-last-mod a) (file-last-mod b))))) (define (display-filename/date filename) (format #t "~a - ~a~%" (format-date "~d ~B ~Y" (date (file-last-mod filename))) filename)) (for-each display-filename/date (sort-by-date (date-is newer-than? 21 4 2008) (directory-files)))
最初の行はやはりシェバング行だ。二行目は、scsh
の実行ファイルに渡されるコマンドライン引数だ。
続く行では、日付/時刻オブジェクトを作成するための関数を定義しているのだが、SCSHがそれらを作成する通常のやり方に変わった特徴があることを考慮している(月のパラメータは0から11までの範囲でなければならず、年のパラメータはその年から1900年を引いた値)。その次の2行では、以上/以下を比較する関数の別名としてolder-than?
とnewer-than?
を定義している。関数date-is
は、ファイル名fのファイルの変更時刻をdate-is
に与えられた日/月/年と比較する比較関数を使用した匿名関数を返す。このようにしておくと、sort-by-date
の呼び出しが読みやすくなるという利点がある。例えば「date-is newer-than 2 1 2008
」はファイルの更新時刻が2008年1月2日よりも後の場合に真を返す関数を返すことになる。その次の行では、ファイル名の文字列のフィルタ/ソート済みのリストを返すsort-by-date
関数を定義している。
次に、関数display-filename/date
を定義し、その後その関数を使ってソートされたファイル名を表示する。なお関数display-filename/date
では、ファイル名と日付の表示の仕方を決めている。上記の場合は「日 月 年」の後にファイル名が表示される。
CSVファイルからデータを取り出してHTMLに変換する
SCSHのもう一つの組み込みDSLはawkだ。awkとはもちろんテキストストリーム内のレコードやフィールドをパースするのに役立つUnixツールのことで、SCSHのDSLのawkはマクロ「awk
」を使用して呼び出し、レコードとフィールドの切り分け方やレコードの取捨選択のための基準を指定することができる。awk構文ではファイル内のレコードやフィールドをループで処理する作業が隠蔽されていて、レコードがどのようなときにレコードをどのようにするのかを定義することができる。
awk構文には、レコード処理関数、処理関数が返す値の名前、条件文のリストを与える必要がある。レコード処理関数は、入力ストリームからレコードを読み取って、そのレコードからパースしたレコードとフィールドを返す。フィールドリーダーは、SCSHの関数であるfield-reader
を使用して作成することができる。レコード処理関数は通常、読み取ったレコードと、フィールドのリストを返す。そのような各値は、構文に与えておいた変数名のおかげで簡単に参照することができる。
グラフや円グラフやスプレッドシート用のデータを保存するためによく使われる形式の一つに、CSV(Comma Separated Value)がある。CSVでは一行ごとにレコードが記述されていて、各フィールドは「,」で区切られている。
a,3,apple b,23,banana c,1,camel
上に示した例では、一行ごとのレコードが3つあって、各レコードには3つのフィールドがある。awk構文はこれらをすべて文字列に変換して、処理を行うためのリストの中に保存する。
今後実際に対処する必要が出てくる可能性もあるより現実的なCSVファイルの例として、連絡先を保存したファイルを扱ってみよう。CSV形式のファイルに保存しておくとバックアップの観点から都合が良い場合があったり、別プログラムにインポートする作業を自動化するのに便利だったりすることがある。
Name,E-mail Address,Notes,E-mail 2 Address,E-mail 3 Address,Mobile Phone,Pager,Company,Job Title,Home Phone,Home Phone 2,Home Fax,Home Address,Business Phone,Business Phone 2,Business Fax,Business Address,Other Phone,Other Fax,Other Address Hiro Protagonist,,"Last of the freelance hackers",,,,,,,,,,,,,,,,, Mr. Lee,lee@greaterhongkong.com,,,,,,Mr. Lee's Greater Hong Kong,President,,,,,,,,,,, Casimir Radon,cradon@megaversity.edu,"Physics club head, friend of Sarah",,,,,,,555.555.1234,,,,,,,,,
このCSVファイルでは、一行目が各フィールド名のリストになっている。そのため一行目は読み飛ばす必要があるのだが、SCSHのawk構文ではこのことを簡単に行うことができる。
以下のコードは、メールアドレスが含まれているレコードについてのみ、名前とメールアドレスをHTML形式で出力する。
#!/usr/bin/scsh -s !# (define read-csv (field-reader (infix-splitter "," 20))) (define (empty-field? x) (string= x "")) (define (start-html page-title) (format #t #<<END <html> <head> <title>~a</title> </head> <body> <h1>~a</h1> <p> END page-title page-title)) (define (end-html) (display " </p> </body> </html> ")) (define (display-email-address email name) (format #t #<@ <a href="mailto:~a">~a</a><br/>~%@ email name name)) (with-input-from-file "contacts.csv" (lambda () (define $ list-ref) (start-html "Contact List") (awk (read-csv) (record fields) n-records () (range: 1 #f (if (not (empty-field? ($ fields 1))) (display-email-address ($ fields 1) ($ fields 0))))) (end-html)))
このCSVファイルでは、フィールドの中にコンマ「,」が含まれているものもある。このことはパースの際に大きな問題となり得るが、SCSHでは簡単に対処することができる。ダブルクォートで囲まれたフィールドにはコンマが含まれていても良いことになってるので、CSVのレコードとフィールドを読み取るための関数を定義する際には特に気にせずにコンマをフィールドのデリミタとして指定するのにinfix-splitter
を使用すれば良い。
次の行は、与えられた文字列が空かどうかを確認する関数empty-field?
を定義している。その次のstart-html
はHTMLページの先頭を出力するための関数で、ページのタイトルを指定することができる。この関数ではHTMLの内容にヒア文字列を使用しているので、エスケープしなくてもダブルクォートを使用することができる。end-html
関数は、単にHTMLページの最後の部分を出力している。display-email-address
関数は、ヒア文字列を使用して、メールアドレスへのHTMLリンクを作成して出力している。ここではヒア文字列のデミリタとして「@
」記号を使用している。
そして最後にcontacts.csvファイルを開いてlambda関数を実行している。なおファイルはlambda関数の最後で自動的に閉じられる。lambda関数の中では、まずstart-html
関数が呼ばれ、次にread-csv
レコードリーダーを用いたawk構文が使用されている。そしてレコードを読み取る度に、条件に該当するかどうかの確認をする。この例では、レコード番号が1より大きければ次の文を実行して、現在のレコードの2つめのフィールド(メールアドレス)が空かどうかを確認している。空でなければメールアドレスを出力する。
なおawkでは、行番号や、読み取ったレコードが正規表現にマッチするかどうかや、単純なif条件など、他のタイプの条件も指定することができる。
ライブラリ
SCSHには、Perl、Python、Rubyにも引けを取らないほどSCSHを便利にすることができるライブラリやモジュールがある。特に便利なライブラリとしては、XMLをパースするためのSSAXや、インターネット関連のスクリプトを作成するためのSUNetなどがある。SUNetにはFTP、SMTP、POP3、Daytime、Time、DNSプロトコル用のクライアントに加えて、FTPサーバやHTTP/Webサーバも含まれている。その他にも、PostgreSQLやMySQLといったデータベースとのやり取りを行うためのライブラリや、画像についての情報を抽出するためのライブラリなどがある。
まとめ
SCSHは、特定領域(シェルスクリプトの作成)の問題解決を行うように変更することのできる基礎となる小さな言語(Scheme)の存在が、いかにパワフルかを体現している。また正規表現を作成するための革新的な手法を提供しているため、これまでよりも少し楽にシェルスクリプトを作成することができるようになっている。一部のPerlコードと比べると長々しいように感じられるかもしれないが、文字数やワード数でコードを判断していると、最終的にはAPLのような言語に行き着く悪循環に陥ってしまうだろう。シェルスクリプトの作成は重要な作業なので、長い関数名を書くために数分の時間が余分にかかったとしても、それを重荷だというようにとらえるべきではないだろう。SCSHを使えば、ウェブサーバも、GUIアプリケーションも、典型的なNcursesベースのインストールスクリプトも作成することができる。