スクリプト言語としてのEmacs Lisp

--松山朋洋 (2010/03/18)

この文書はまだ作りかけです。



概要

PerlやRuby、Pythonといった汎用スクリプト言語が台頭する昨今、テキスト処理という分野では他に劣らぬポテンシャルを持つEmacs Lispが日の目を見ないのは実に悲しいことです。Emacs Lispには影ながらもEmacsを支えてきたという確かな実績がありますし、多くのEmacsユーザーはEmacs Lispの恩恵のもとで彼らの生産性を大幅に向上させてきました。それなのになぜEmacs Lisp自体は注目されないのでしょうか。本文書ではそんなEmacs Lispの憂き目を晴らすべく、テキスト処理の基本から応用まで例をまじえながらスクリプト言語としてのEmacs Lispについて解説します。

対象読者

見方を変えれば、この文書の最終的な目標はEmacsとEmacs Lispの概念的な分離であると言えます。すなわち対象読者の条件には「Emacsユーザーであること」が含まれません。EclipseユーザーでもVimユーザーでも何かのツールを使って複雑なテキスト処理をこなしたいのであれば彼らは対象読者になります。また本文書は実際の開発現場で参考にされることも想定しています。

はじめに

Emacsとは

GNU Emacs(以下、Emacs)はRichard M. Stallmanによって開発された高機能なテキストエディタです。根幹部分をEmacs Lispとして公開することによりユーザーに高い透明性・拡張性を提供しています。Emacsの機能の多くがEmacs Lispで実装されており、また標準ではない優れた拡張が多く存在します。

本文書では重要な前提になりますが、Emacsに対する操作は全てEmacs Lispに還元可能です。つまりEmacsで出来ることは全てEmacs Lispでも出来るということです。余談ですが、この特性のおかげでキーボードマクロの実装は大変シンプルなものになっています。

EmacsとEmacs Lisp

EmacsとEmacs Lispの関係性が不鮮明なので補足します。

汎用スクリプト言語の常識から言うと、ある言語には必ずそれ専用のインタプリタが存在します。ではEmacs LispにはEmacs Lispインタプリタが存在するかというと、少なくとも実装の見地から言えば、答えはノーです。emacsなるプログラムは存在しますが、elispなるプログラムは存在しません。elispプログラムが存在し、その特殊化がemacsプログラムであれば分かりやすいですが、現実は逆で、emacsプログラムが存在し、その機能を一部制限することでelispプログラムたり得るのです。

つまり用語の定義としては以下のようになります。

Emacs
Emacs Lispを実行する機能+編集機能などの本体機能
Emacs Lispインタプリタ
本体機能を省いたEmacs本体
Emacs Lisp
EmacsあるいはEmacs Lispインタプリタが解釈するスクリプト言語

Emacs Lispインタプリタの起動方法については後述します。

Emacs Lispの特徴

Emacs LispはLisp方言の一種で、世界中に多くのユーザーが存在する反面、Lispとしてはかなり特異な存在です。Emacs Lispの特徴を以下に簡単に示します。

バッファ
バッファはEmacs Lispのテキスト処理を担う最も重要なデータ構造です。
スコープ
Emacs Lispのスコープはダイナミックスコープです。
非同期処理
Emacs Lispはマルチスレッドに対応していません。ある特別な方法で非同期処理を実現します。
末尾再帰最適化
Emacs Lispは末尾再帰最適化に対応していません。おそらくスコープなどが絡んだ複雑なフレーム構造が原因だと思われます。
ダンプ
Emacs Lispは現在実行中の状態を実行可能ファイルとしてダンプすることができます。起動速度のためのテクニックです。

Emacs Lisp入門

TODO

スクリプトの基本

実行方法

前述したようにEmacs LispインタプリタはEmacs本体の機能制限版であると言えます。Emacs Lispインタプリタとして起動するには--scriptオプションを指定します。

$ emacs --script test.el

Emacs 23以前では--scriptオプションを利用できないので、代わりに以下のようにします。

$ emacs22 -batch -l test.el

-batchオプションはEmacsをバッチモードで起動することを意味します。-lオプションは指定されたファイルをロードすることを意味します。

重要なオプションとして-qオプションと-Qオプションがあります。-qオプションは.emacsなどの初期化ファイルのロードをスキップします。-Qオプションは-qオプションに加えサイトファイルのロードをスキップします。サイトファイルとはシステム特有の設定を記述したファイルです。-batchオプションも--scriptオプションも暗黙で-qオプションを付与しますが、サイトファイルが巨大だとEmacs Lispインタプリタの起動が遅くなります。そのような場合は追加で-Qオプションを付与するとよいでしょう。なお-batchオプションや--scriptオプションで起動されたEmacsはバッチモードというモードで動作します。バッチモードの反対はインタラクティブモードです。

Hello World

簡単な例としてHello Worldを書いてみましょう。次のファイルを作成します。

hello.el:

(princ "Hello, World!\n")

続いて先ほど説明した方法で実行します。

$ emacs --script hello.el
Hello, World!

標準入出力

前節のHello Worldは何も変哲もない例でしたが、スクリプト言語にとって重要な標準出力を伴っていることに気付いたと思います。通常、IOポートはファイルディスクリプタなどで抽象化されますが、Emacs Lispではそれがバッファに該当しますread系関数でバッファから入力し、print系関数でバッファへ出力します。

バッファにはミニバッファという特別なバッファが存在し、read系関数あるいはprint系関数で対象バッファが指定されない場合は、このミニバッファが対象バッファとして選択されます。ミニバッファはインタラクティブモードではユーザーからの入力およびエコーエリアとして動作し、バッチモードでは標準入力(stdin)および標準出力(stdout)のプロキシとして動作します。

read系関数には様々ありますが基本的にreadread-charおよびread-eventを使用します。print系関数にも様々ありますが基本的にprintprincおよびprinc-listを使用します。標準入出力の詳細はその都度説明します。

なお標準エラー(stderr)はmessageerrorに紐付けられています。

コマンドライン引数

コマンドライン引数はargvで参照できます。Emacs23以前はcommand-line-args-leftを使用してください。

argv.el:

(print argv)

実行例:

$ emacs --script argv.el foo bar baz
("foo" "bar" "baz")

printは与えられたLispオブジェクトをreadできる形式で出力します。

また-lオプションなどをスクリプトが使用する場合、Emacsが後から解釈しないようにスクリプトの最後でargvnilにしてください。

argv2.el:

(print argv)
(setq argv nil)

実行結果:

$ emacs --script argv.el -l argv.el
("-l" "argv.el")

nil
$ emacs --script argv2.el -l argv2.el
("-l" "argv.el")

簡単な例としてコマンドライン引数の総和を出力するスクリプトを示します。

sum.el:

(let ((sum 0))
  (dolist (n argv)
    (setq sum (+ sum (string-to-number n))))
  (print sum))

letで総和用の束縛を作り、コマンドライン引数を一つずつ整数に変換しながら足しあわせて、最後にprintで出力します。

$ emacs --script sum.el 1 2 3
6

ちなみに関数型言語風に書けば次のようになります。

sum.el:

(print (apply '+ (mapcar 'string-to-number argv)))

バッファ

バッファはEmacs Lispで最も重要なデータ構造(概念)です。汎用スクリプト言語が提供する文字列とは比べものにならないほど複雑で多様な操作が可能です。また、人間特有の抽象的な表現をかなりの精度でそのまま表現することが可能です。Emacsの編集画面に表示されているそれがバッファなのですから、当然と言えば当然です。

処理の流れ

汎用スクリプト言語では入力データを逐次読み込んで処理を行うのが一般的ですが、Emacs Lispでは次の手順で処理を行うのが常套手段です。

  1. バッファを作成する
  2. 処理対象のデータをバッファに入力する
  3. 処理を行う
  4. バッファの内容を標準出力やファイルに出力する

メモリを大量の使用しますが、より直感的な処理が可能になります。

作成

テキスト処理を始めるにはまずバッファを作成しなくてはなりません。バッファの作成には大きくわけて三つあります。

一つは既存のファイルを開いてバッファを作成する方法です。その場合バッファは実際のファイルと関連付けられます。ファイルを開くにはfind-file系関数を使います。やっかいなのはfind-file系関数の使いわけです。

find-file
既存のファイルを開き文字コードの変換、メジャーモードの判定、その他を行いカレントバッファに設定する
find-file-literally
既存のファイルを開き、何の処理もせずにカレントバッファに設定する
find-file-noselect
既存のファイルを開き、何の処理もおこなわない。開いたバッファを返す

find-fileは様々な処理を行うため不確定要素が多くなります。文字コードの変換などを想定しているのであれば、find-fileを使っても問題ありませんが、可能ならばfind-file-literallyfind-file-noselectを使ってください。ただこれらの関数も確実にファイルを開くというわけではない(すでに開いていたらそのままバッファを返すだけ)ので、確実にファイルにアクセスするには、次に述べるinsert-file-contents系関数を使います。

バッファを作るもう一つの方法としては、with-temp-bufferがあります。with-temp-bufferはその名の通り、一時的なバッファを作成し、そのバッファをカレントバッファに設定して処理を行うマクロです。様々な場面で利用されますが、insert-file-contentsとの組合せが、ファイルにアクセスするためのデファクトスタンダードになっています。insert-file-contents系関数は指定されたファイルの内容を読み出し、現在のバッファのポイントに挿入する関数です。find-file系関数と同様、文字コード変換などを行うinsert-file-contentsinsert-file-contents-literallyがありますが、同様の理由でinsert-file-contents-literallyを使うべきです。

(with-temp-buffer
  (insert-file-contents-literally "data")
  ;; ここに処理を書く
  )

例として指定されたファイルの内容とそのバッファ名を表示するスクリプトを示します。

cat-find-file.el:

(find-file (car argv))
(princ-list (buffer-name))
(princ (buffer-string))

princ-listは引数の全てのオブジェクトをそのままの形式(クオートなし、readできない)で出力し、最後に改行する関数です。princは引数のオブジェクトをそのままの形式で出力する関数です。buffer-nameはカレントバッファの名前を返す関数です。buffer-stringはカレントバッファの内容を文字列で返す関数です。

cat-insert-file-contents.el:

(with-temp-buffer
  (insert-file-contents (car argv))
  (princ-list (buffer-name))
  (princ (buffer-string)))

実行例:

$ cat data
Hello, Emacs Lisp!
$ emacs --script cat-find-file.el data
data
Hello, Emacs Lisp!
$ emacs --script cat-insert-file-contents.el data
 *temp*
Hello, Emacs Lisp!

バッファ名が異なっているのが分かると思います。なおfind-file-literallyを使うと再読み込みするかというプロンプトが出て正しく動作しない可能性があります。これはEmacsがすでにそのファイルを開いているのが原因です。その後の引数を無視する--オプションがあれば問題にならないのですが、現状では対応のしようがないようです。

バッファを作成する最後の方法は、get-buffer-createです。この関数は指定された名前のバッファを探し、見つからなければ新しく作成して返します。

(get-buffer-create "mybuf") ; => #<buffer mybuf>

カレントバッファ

現在編集中のバッファをカレントバッファと呼びます。Emacs Lispの多くの関数がデフォルトでカレントバッファを対象としているため、現在どのバッファがカレントバッファであるかを意識したプログラミングが必要です。前節の例でfind-fileの直後にbuffer-namebuffer-stringを呼び出して想定通りの結果を得られたのは、find-fileでカレントバッファが切り替わっていたからです。通常、バッファはファイルを開くと同時に切り替わります(find-file-noselectは例外)。

(find-file "foo")
;; fooバッファ
(find-file "bar")
;; barバッファ

バッファを明示的に切り替えるにはset-bufferを使います。

(set-buffer (get-buffer-create "mybuf"))
;; mybufバッファ

一時的にカレントバッファを切り替えるにはwith-current-bufferを使います。これは非常によく使うので是非覚えておいてください。

(set-buffer (get-buffer-create "hoge"))
;; hogeバッファ
(with-temp-buffer (get-buffer-create "mybuf")
  ;; mybufバッファ
  )
;; hogeバッファ

ポイント

ポイントはバッファの先頭からの論理的なオフセットです。文字列のインデックスのようなものですが、ゼロベースではなくワンベースです。Emacsが認識する文字と単位とします。

現在のカーソルのポイントはpointで取得します。カーソルを指定したポイントに移動するにはgoto-charを使います。多くの関数がカーソルのポイントを内部的に変更するため、通常何らかの処理の前にpointの結果を保存しておき、後からその値を利用します。例えば、現在のポイントから特定の文字列を検索し、その部分文字列を出力するには次のようにします。

(let ((point (point)))
  (search-forward "neadle")
  (princ (buffer-substring point (point))))

ポイントの変更から現在のフレームを守るにはsave-excursionを使います。save-excursionの前後ではカレントバッファ・ポイント・マークが一致することが保証されます。

;; ここの状態と
(save-excursion
  ;; バッファの変更
  (set-buffer (get-buffer-create "mybuf"))
  ;; ポイントの変更
  (goto-char 123))
;; ここの状態は一致する

save-excursionも非常によく利用するマクロですので覚えておいてください。

バッファの先頭ポイントはpoint-min、末尾ポイントはpoint-maxで取得できます。

ここで例として本物のcatを作ってみましょう。

cat.el:

(with-temp-buffer
  (dolist (file argv)
    (insert-file-contents-literally file)
    (goto-char (point-max)))
  (princ (buffer-string)))

実行例:

$ cat data1
GNU Emacs
$ cat data2
Emacs Lisp
$ emacs --script cat.el data1 data2
GNU Emacs
Emacs Lisp

RubyのARGF.readと比べればまだまだ複雑ですが、意外と簡単に実装できると思いませんか?

移動

移動を使いこなしてはじめてバッファの威力が発揮されます。従来の文字列ではインデックスやオフセットと呼ばれる一次元的な値を増減することで移動とみなします。しかしこれは人間がエディタで見ている二次元的なデータ構造とは全く異なります。Cで次の行頭に移動(ポインタの代入=移動)するには次のように書きます。

const char *p = str;
p = strchr(p, '\n') + 1;

なんだかそれっぽく読めませんし、バグもありそうです。一方Emacs Lispでは次のように書きます。

(forward-line)

なんて分かりやすいんでしょう。たとえ我々のIQが50に低下してもEmacs Lispのコードならメンテンスできそうな気さえします。

Emacs Lispにはこのような移動系の操作が多数定義されています。個人的にはEmacsあるいはEmacs Lispを習得するには移動系をマスターするのが最も早道なのではないかと考えています。

それでは移動系について説明します。

文字の前後の移動にはforward-charおよびbackward-charを使います。

;; 前方に移動
(forward-char)
;; 後方に移動
(backward-char)

行の前後の移動にはforward-lineを使います。

;; 次の行に移動
(forward-line)
;; 前の行に移動
(forward-line -1)

backward-lineという関数は存在しません。通常我々が使っているprevious-lineというコマンドを利用することも可能ですが、ゴールカラムなどの問題があるので負数をforward-lineに与えるほうが無難です。

特定の行への移動はgoto-lineを使います。

;; 3行目に移動
(goto-line 3)

行頭・行末の移動にはbeginning-of-lineおよびend-of-lineを使います。line-beginning-positionおよびline-end-positiongoto-charと組み合せることも可能です。

;; 行頭に移動
(beginning-of-line)
;; 行頭に移動 (goto-char)
(goto-char (line-beginning-position))
;; 行末に移動
(end-of-line)
;; 行末に移動 (goto-char)
(goto-char (line-end-position))

バッファの先頭・末尾の移動にはpoint-minおよびpoint-maxgoto-charを組み合わせます。このイディオムはよく使います。

;; バッファの先頭に移動
(goto-char (point-min))
;; バッファの末尾に移動
(goto-char (point-max))

ここで簡単な例として与えられたファイルの3行目から6行目を出力するスクリプトを示します。

(with-temp-buffer
  (insert-file-contents-literally (car argv))
  (goto-line 3)
  (let ((point (point)))
    (goto-line 6)
    (princ (buffer-substring point (point)))))

少しどんくさい感じがしますが、大体このような感じになります。これまで通りinsert-file-contents-literallyでファイルの内容を一時バッファに挿入し、goto-lineで3行目に移動してたら、そのポイントを一時的に保存します。続いてgoto-lineで6行目に移動し、最後に先程のポイントと現在のポイントのバッファ部分文字列をbuffer-substringで取得して出力しています。

続いて単語単位移動について説明します。単語単位で前後に移動するにはforward-wordおよびbackward-wordを使います。

;; 前方に1単語分移動
(forward-word)
;; 後方に1単語分移動
(backward-word)

そして非常に重要なのがS式単位の移動です。これを使わなければEmacs Lispの魅力が半減と言っても過言ではありません。S式とはLisp処理系が解釈しえる式のことで、数値リテラル、文字列リテラル、シンボル、括弧などがS式とみなされます。元々はLisp編集用のコマンドですが、括弧の対応をうまく解釈してくれるので、正規表現では解決できない問題も簡単に解決できます。S式単位で前後に移動するにはforward-sexpおよびbackward-sexpを使います。

;; 前方に1S式分移動
(forward-sexp)
;; 後方に1S式分移動
(forward-sexp)

forward-sexp実行例(_がカーソル):

移動前 移動後
_123 123_
_"Hello" "Hello"_
this-isa:symbol this-isa:symbol
_(123) (123)_
_(123 (345)) (123 (345))_

シンボルとして使用できる文字や括弧として使用できる文字はシンタックスで制御されるため一概には言えませんが、大体の場合それぞれのモードで適当な解釈がなされます。

Emacs Lispにはこれらの移動単位を抽象的に扱ったthingという概念があります。thingはユーザーが自由に定義できます。他にも以下のようなthingがあります。

検索

検索は移動系の一種ですが、長くなりすぎるので別の節として説明します。他の移動系と同様、検索も重要な操作の一つです。検索は常に現在のポイントを始点とします

現在のポイントから前方あるいは後方に文字列を検索するにはsearch-forwardおよびsearch-backwardを使います。文字列を発見したポイントに移動し、そのポイントを返します。

;; 前方にneadleを検索
(search-forward "neadle")
;; 後方にneadleを検索
(search-backward "neadle")

例として最初のダブルクオートから次のダブルクオートまでを出力するスクリプトを示します。

dquote.el:

(with-temp-buffer
  (insert-file-contents-literally (car argv))
  (search-forward "\"")
  (let ((point (point)))
    (search-forward "\"")
    (princ (buffer-substring point (1- (point))))))

実行例:

$ cat data
GNU Emacs is "the most customizable editor"
$ emacs --script dquote.el data
the most customizable editor

前節のforward-sexpを使えば次のようにも書けます。

dquote.el:

(with-temp-buffer
  (insert-file-contents-literally (car argv))
  (search-forward "\"")
  (let ((point (point)))
    (backward-char)
    (forward-sexp)
    (princ (buffer-substring point (1- (point))))))

search-forwardおよびsearch-backwardは対象文字列が存在しない場合はエラーを発行します。エラーを発行せずnilを返すようにするには第三引数にtを渡します。

;; エラーを発行する
(search-forward "naver seen1")
;; nilを返す
(search-forward "naver seen2" nil t)

これは特にwhileと一緒に利用されます。

whileイディオム:

(while (search-forward "neadle" nil t)
  (princ-list "Found at: " (point)))

このイディオムと前節のforward-sexpを組み合せて、少し複雑な問題に挑戦してみましょう。

問題: 与えられたファイルに存在する全ての括弧の対応を列挙するスクリプトを書きなさい

答え:

(with-temp-buffer
  (insert-file-contents-literally (car argv))
  (while (search-forward "(" nil t)
    (save-excursion
      (backward-char)
      (let ((point (point)))
        (forward-sexp)
        (princ-list (buffer-substring point (point))))))))

実行例:

$ cat data
((name . "Tomohiro Matsuyama")
 (editor . "Emacs")
 (os . ("Debian GNU/Linux" "Gentoo Linux" "Ubuntu")))
$ emacs --script paren.el data
((name . "Tomohiro Matsuyama")
 (editor . "Emacs")
 (os . ("Debian GNU/Linux" "Gentoo Linux" "Ubuntu")))
(name . "Tomohiro Matsuyama")
(editor . "Emacs")
(os . ("Debian GNU/Linux" "Gentoo Linux" "Ubuntu"))
("Debian GNU/Linux" "Gentoo Linux" "Ubuntu")

save-excursionforward-sexpがミソです。save-excursionがなければforward-sexp後のポイントで次の検索を行なうので、結果的にトップレベルの括弧しか表示されなくなります。forward-sexpで対応する括弧に移動し、移動前と移動後のバッファ部分文字列を取得します。

whileイディオムは非常によく使うので覚えておいてください。

正規表現で検索するにはre-search-forwardおよびre-search-backwardを使います。正規表現については付録: 正規表現を参照してください。

;; 正規表現で前方に検索
(re-search-forward "\\(\\w+\\)")
;; 正規表現で後方に検索
(re-search-backward "\\(\\w+\\)")

エラーを抑制するには第三引数にtを渡します。

挿入と削除

現在のポイントに文字列を挿入するにはinsertを使います。挿入後は挿入した文字列の直後にポイントが移動します。

hello2.el:

(with-temp-buffer
  (insert "Hello")
  (insert " World")
  (princ (buffer-string)))

実行例:

$ emacs --script hello2.el
Hello World

insertには複数の引数を渡すことができます。特定のポイントに文字列を挿入するには、その場所にgoto-charしてからinsertします。

バッファをクリアするにはerase-bufferを使います。バッファを使いまわす場合などに便利です。

;; バッファのクリア
(erase-buffer)

現在の行を削除するにはkill-whole-lineを使います。

;; 現在の行を削除
(kill-whole-line)

現在のポイントから行末まで削除するにはkill-lineを使います。

単語やS式を削除するには、まずbackward-*で開始位置まで移動して、kill-*で削除します。

;; 現在指している単語を削除
(progn
  (backward-word)
  (kill-word 1))
;; 現在指しているS式を削除
(progn
  (backward-sexp)
  (kill-sexp 1))

prognは一連の式を順に評価して最後の評価結果を返すスペシャルフォーム[1]です。

バッファの特定の範囲を削除するにはdelete-regionを使います。

;; 100から200までを削除
(delete-region 100 200)

保存

処理結果をファイルに保存するには大きくわけて二つの方法があります。

一つはfind-fileで開かれたバッファに限りますが、save-bufferを使う方法です。

例として与えられたファイルにライセンスヘッダを挿入して保存するスクリプトを示します。

license.el:

(find-file (car argv))
(insert ";; This file is distribued under the term of GPLv3 or later\n")
(save-buffer)

実行例:

$ cat data
Some code here
$ emacs --script license.el data
Saving file /home/tomo/tmp/data...
Wrote /home/tomo/tmp/data
$ cat data
;; This file is distribued under the term of GPLv3 or later
Some code here

ただし、この方法はfind-fileの問題に加え、save-buffer自体にも問題(find-fileと同様に、様々な余分な処理が実行される)があるので、推奨されません。代わりに次に述べるもう一つの方法を使ってください。

ファイルに保存するもう一つの方法はwrite-regionを使う方法です。with-temp-bufferinsert-file-contentsのイディオムと同時に使います。

license2.el:

(let ((file (car argv)))
  (with-temp-buffer
    (insert-file-contents-literally file)
    (insert ";; This file is distribued under the term of GPLv3 or later\n")
    (write-region (point-min) (point-max) file)))

実行例:

$ cat data
Some code here
$ emacs --script license.el data
Wrote /home/tomo/tmp/data
$ cat data
;; This file is distribued under the term of GPLv3 or later
Some code here

write-regionの第一引数と第二引数に保存する内容の開始位置と終了位置を指定し、第三引数にファイル名を指定します。"Wrote ..."というメッセージを表示させたくない場合はwrite-regionの第五引数に0を渡します。

問題: 与えられたファイルの"Linux"という文字列を"GNU/Linux"に変換して保存するスクリプトを書きなさい

答え:

gnulize.el:

(let ((file (car argv)))
  (with-temp-buffer
    (insert-file-contents-literally file)
    (while (search-forward "Linux" nil t)
      (replace-match "GNU/Linux"))
    (write-region (point-min) (point-max) file)))

実行例:

$ cat data
Linuxにはたくさんの素敵なアプリケーションがあります。開発者の多くもLinuxを使用しています。
$ emacs --script gnulize.el data
$ cat data
GNU/Linuxにはたくさんの素敵なアプリケーションがあります。開発者の多くもGNU/Linuxを使用しています。

sedを使った別解:

$ cat data
Linuxにはたくさんの素敵なアプリケーションがあります。開発者の多くもLinuxを使用しています。
$ sed -ine 's|Linux|GNU/Linux|g' data
$ cat data
GNU/Linuxにはたくさんの素敵なアプリケーションがあります。開発者の多くもGNU/Linuxを使用しています。

今回のスクリプトのミソはsearch-forwardreplace-matchのイディオムです。直感的に"Linux"を探して"GNU/Linux"で置き換えると読めますね。この方法は正攻法ですが、直感的には、Emacsには文字列置換機能があるのだから、それに関連した関数があるはずだと考えます。そしてreplace-stringなる便利な関数を見つけるのです。

gnulize2.el:

(let ((file (car argv)))
  (with-temp-buffer
    (insert-file-contents-literally file)
    ;; replace-stringで置換
    (replace-string "Linux" "GNU/Linux")
    (write-region (point-min) (point-max) file)))

恐ろしく直感的です。このようにあるはずの関数が実際にあって簡単に再利用できるのがEmacs Lispの魅力の一つです。

複数バッファ

あるバッファから文字列を抜き出して、その結果にさらに処理を加えて出力するといった場合は、複数のバッファを使うのが有効です。

例としてdefunで定義される関数の一覧をソートして出力するスクリプトを示します。

defuns.el:

(let ((buf (get-buffer-create "mybuf")))
  (with-temp-buffer
    (insert-file-contents-literally (car argv))
    (while (re-search-forward "(defun +\\([^ ]+\\)" nil t)
      (let ((func (match-string 1)))
        (with-current-buffer buf
          (insert func "\n")))))
  (with-current-buffer buf
    (sort-lines nil (point-min) (point-max))
    (princ (buffer-string)))))

実行例:

$ emacs --script defuns.el popup.el
popup-cascade-menu
popup-create-line-string
popup-current-physical-column
popup-delete
popup-draw
popup-fill-string
popup-hidden-p
popup-hide
popup-hide-line
popup-isearch-filter-list
popup-isearch-read-event
popup-isearch-update
popup-item-propertize
popup-item-property
popup-last-line-of-buffer-p
popup-line-hidden-p
popup-line-overlay
popup-live-p
popup-lookup-key-by-event
popup-menu-document
popup-menu-fallback
popup-menu-show-help
popup-menu-show-quick-help
popup-next
popup-preferred-width
popup-previous
popup-scroll-down
popup-scroll-up
popup-select
popup-selected-item
popup-selected-line
popup-selected-line-overlay
popup-set-filtered-list
popup-set-line-item
popup-set-list
popup-substring-by-width
popup-truncated-partial-width-window-p
popup-x-to-string

実践例

インデントを修正する

インデント修正プログラム(広義では整形プログラム)は多数存在しますが、実際のコーディングで使われているEmacsほど信頼性の高いものはないでしょう。でも一々ファイルを開いてインデントを修正するのは面倒くさいですよね[2]。それならEmacs Lispでスクリプト化してしまいましょう。これまでの知識に加えてindent-regionという関数を知っていれば簡単に実装できます。

(dolist (file argv)
  (find-file file)
  (indent-region (point-min) (point-max))
  (save-buffer)
  (kill-buffer))

今まで散々insert-file-contentsを使えと言っておきながらfind-fileを使っています。これには理由があって、indent-regionで使用されるインデント関数はメジャーモードごとに個別に定義されるので、適切なメジャーモードを選択しておく必要があるのです。with-temp-bufferinsert-file-contentsではメジャーモードの選択まではやりません。このスクリプトの素晴しいのは、拡張子などからメジャーモードが推定されるため、このスクリプト一つでほとんどのプログラミング言語のインデントをこなせます。

$ cat test.c
int main()
{
printf("Hello, World!\n");
return 0;
}
$ cat test.css
body {
font-size: large;
background-color: red;
}
$ cat test.html
<html>
<body>
<h1>Hello</h1>
</body>
</html>
$ emacs --script test.c test.css test.html
Indenting region...
Indenting region... done
Saving file /home/tomo/tmp/test.c...
Wrote /home/tomo/tmp/test.c
Saving file /home/tomo/tmp/test.css...
Wrote /home/tomo/tmp/test.css
Saving file /home/tomo/tmp/test.html...
Wrote /home/tomo/tmp/test.html
$ cat test.c
int main()
{
  printf("Hello, World!\n");
  return 0;
}
$ cat test.css
body {
    font-size: large;
    background-color: red;
}
$ cat test.html
<html>
  <body>
    <h1>Hello</h1>
  </body>
</html>

HTMLにヘッダ・フッタを挿入する

ファイルの置換にはsed+-iオプションが有力な候補になりますが、置換文字列に改行などが入っているとsコマンドのエスケープが大変になります。そのような場合にEmacs Lispは最適です。

hdrftr.el:

(dolist (file argv)
  (with-temp-buffer
    (insert-file-contents-literally file)
    (search-forward "</h1>")
    (insert-file-contents-literally "header.html")
    (search-forward "</body>")
    (goto-char (match-beginning 0))
    (insert-file-contents-literally "footer.html")
    (write-region (point-min) (point-max) file)))

このコードは30秒で実装しました(そして一発で動きました)。PerlやRubyだとどれぐらいかかるでしょう?Emacs Lispの素晴しさが身に染みて分かります。

test.html:

<html>
  <body>
    <h1>Hello</h1>
    This is contents
  </body>
</html>

header.html:

<a href="/">Go to top</a>

footer.html:

Copyleft all rights reversed

実行結果:

$ emacs --script test.html
Wrote /home/tomo/tmp/test.html
$ cat test.html # 整形しています
<html>
  <body>
    <h1>Hello</h1>
    <a href="/">Go to top</a>
    This is contents
    Copyleft all rights reversed
  </body>
</html>

Webスクレイピング

emacs-w3mを利用してWebスクレイピングする方法を説明します。通常のスクレイピングはダウンロードしてきたHTMLからXPathでデータを抽出しますが、Emacs Lispではemacs-w3mでHTMLをレンダリングし、そのレンダリング結果をそのままデータをコピーして抽出します。抽出スクリプトが簡単に作れ、テーブルなどのレイアウトがそのまま保たれるなど、多くのメリットがあります。

今回は例として気象庁のページから東京の今日の天気予報を出力するスクリプトを考えます。おおまかな手順は次のようになります。

  1. M-x emacs-w3mでスクレイピングしたいページにアクセスする
  2. 抽出の目印となる文字列を決める
  3. 一連の手順をEmacs Lispで記述する

東京の今日の天気予報はこのページから取得できるようです。実際にM-x emacs-w3mして確認してみると、「Chiho」という文字列が抽出の目印になりそうです。そして日ごとの天気予報はちょうどパラグラフで分かれるようなので、「Chiho」から次のパラグラフまでを抽出すれば今日の天気予報を抽出できるはずです。

なお、バッチモードで日本語ページを表示すると文字化けする問題があったのであえて英語ページにしています。時間がある時に調査します。

まず、雛形を示します。

forecast.el:

(require 'w3m)
(setq w3m-async-exec nil)

はじめに(require 'w3m)でemacs-w3mをロードし、w3m-async-execnilにしておきます。こうしておかないとページの読み込み中に処理が実行される可能性があるからです。次に東京の天気予報を表示するコードを追加しましょう。

(w3m-browse-url "http://www.jma.go.jp/en/yoho/319.html")

このコードを実行すると、天気予報のページが表示されたバッファが選択されます。あとはこれまでと同じ要領です。

(let ((point (line-beginning-position -1)))
  (forward-paragraph)
  (princ (buffer-string point (point))))

最終的なコードは次のようになります。

forecast.el:

(require 'w3m)
(setq w3m-async-exec nil)
(w3m-browse-url "http://www.jma.go.jp/en/yoho/319.html")
(search-forward "Chiho")
(let ((point (line-beginning-position -1)))
  (forward-paragraph)
  (princ (buffer-substring point (point))))

実行例:

$ emacs -L /usr/share/emacs/site-lisp/w3m -batch -l forecast.el

         Tokyo                Probability               Temperature        
         Chiho                     of                    Forecast          
Three-hourly Forecasts       Precipitation                                 
                         00-06  --%                    Daytime             
   Today    RAIN AT      06-12  10%                     High               
 18 March   TIMES        12-18  30%             Tokyo  13℃
 RAIN AT                 18-24  50%

日本語まわりや表示領域に関して色々問題ありますが、なんとか動いているようです。

関数定義をfoldする

forward-sexpの重要な例として関数定義をfoldして表示するスクリプトを考えます。

まずfind-fileでファイルを開きます。これはbeginning-of-defunを使うためです。

(find-file (car argv))

次にバッファの末尾に移動して、whileでバッファの先頭まで関数定義を探すようにします。

(goto-char (point-max))
(while (beginning-of-defun)

関数定義の先頭から最初に見つかるブレースを関数の本体であるとみなして、その中身を"..."で置き換えます。

(save-excursion
  (search-forward "{")
  (let ((point (point)))
    (backward-char)
    (forward-sexp)
    (delete-region point (1- (point)))
    (backward-char)
    (insert "...")))

完成形は次のようになります。

fold.el:

(find-file (car argv))
(goto-char (point-max))
(while (beginning-of-defun)
  (save-excursion
    (search-forward "{")
    (let ((point (point)))
      (backward-char)
      (forward-sexp)
      (delete-region point (1- (point)))
      (backward-char)
      (insert "..."))))
(princ-list (buffer-string))

大量のファイルをインタラクティブに置換

最後の例として大量のファイルの置換を目的としたインタラクティブなコマンドを定義してみます。コマンドラインの第一引数に置換パターン、第二引数に置換文字列、それ以降に置換するファイルを列挙します。

まず最初に置換パターンと置換文字列を取得してlet*で束縛します。

(let* ((pat (pop argv))
       (str (pop argv)))

popは引数のリストの先頭を取り除いて返すマクロです。let*letと違って中の束縛の順序が保証されます。

次におきまりのdolistwith-temp-bufferinsert-file-contents-literallyでファイルを処理していきます。

  (dolist (file argv)
    (with-temp-buffer
      (princ-list (format "Processing %s...\n" file))
      (insert-file-literally file)

princ-listで処理中のファイルを出力しています。

続いてwhilere-search-forwardイディオムが登場します。

(let (substitued)
  (while (re-search-forward pat nil t)

次にマッチした行を表示します。エスケープシーケンスを利用してマッチ部分を赤色のボールドで表示するようにしています。

(princ-list (buffer-substring (line-beginning-position)
                              (match-beginning 0))
            "\33[31;1m"
            (match-string 0)
            "\33[0m"
            (buffer-substring (match-end 0)
                              (line-end-position)))

さらに置換するかどうかをユーザーに尋ね、YESなら置換を行いその結果を表示します。

(when (equal (downcase (read-string "Substitute? (Y/n): " nil nil "y")) "y")
  (replace-match str)
 (setq substituted t)
  (princ-list (buffer-substring (line-beginning-position)
                                (match-beginning 0))
              "\33[32;1m"
              str
              "\33[0m"
              (buffer-substring (+ (match-beginning 0)
                                   (length str))
                                (line-end-position))))

最後にsubstitutedtの場合のみwrite-regionで元のファイルに出力します。

(if substituted
    (write-region (point-min) (point-max) file))

完成形は次のようになります。

subst.el:

(let* ((pat (pop argv))
       (str (pop argv)))
  (dolist (file argv)
    (with-temp-buffer
      (insert-file-literally file)
      (princ-list (format "Processing %s..." file))
      (let (substitued)
        (while (re-search-forward pat nil t)
          (princ-list (buffer-substring (line-beginning-position)
                                        (match-beginning 0))
                      "\33[31;1m"
                      (match-string 0)
                      "\33[0m"
                      (buffer-substring (match-end 0)
                                        (line-end-position)))
          (when (equal (downcase (read-string "Substitute? (Y/n): " nil nil "y")) "y")
            (replace-match str)
            (setq substituted t)
            (princ-list (buffer-substring (line-beginning-position)
                                          (match-beginning 0))
                        "\33[32;1m"
                        str
                        "\33[0m"
                        (buffer-substring (+ (match-beginning 0)
                                             (length str))
                                          (line-end-position)))))
        (if substituted
            (write-region (point-min) (point-max) file))))))

スクリプトの応用

テキストプロパティ

TODO

非同期処理

TODO

シェル

TODO

高速化

TODO

付録: 正規表現

TODO

付録: その他のネタ

2画面diff

ターミナルから視覚的な2画面diffを見たいと思ったことはありませんか。Emacs Lispなら簡単に2画面diffを実現できます。

diff.el:

(menu-bar-mode nil)
(tool-bar-mode nil)
(setq ediff-split-window-function 'split-window-horizontally)
(add-hook 'ediff-quit-hook 'kill-emacs)
(ediff-files (nth 0 argv) (nth 1 argv))
(select-window (get-buffer-window ediff-control-buffer))

  1. スペシャルフォームとは特殊な処理が施される式

  2. C-x h M-x indent-regionで済みますが