AWKっぽいコードをLISPで書く

__________
    <○√
     ‖ 
     くく
しまった! これは車輪の再発明だ!
ココは俺に任せて先に http://www.cliki.net/CLAWK に行け!

こんな感じでテキストファイルを処理するLISPユーティリティが欲しい。

(defawk parsing
    ;; letと同じ変数束縛。全てに先んじて束縛される。
    ;; 続くすべての節で、ここで定義した変数を使える。
    (sum)

  ;; レコードに対する繰り返しの前に実行される begin節
  (begin (setf sum 0))

  ;; レコードに対して毎回繰り返し実行される body節
  (body
   ;; body節の中はどんなLISPコードでもよい
   ;; ただし、matchではじまるリスト(match節)は、カレントレコードに対する
   ;; 正規表現のマッチを試し、マッチすれば実行されるようなコードとなる。
   (match "(\\w+)"
     ;; マッチ結果は $ 関数で参照できる
     (format t "MATCH: ~A in ~A~%" ($ 1) ($ 0))))
  (body
   ;; body節の処理中は、フィールドセパレータによって区切られた
   ;; フィールドを、 $f 関数で参照できる
   (format t "RECORD: ~A~%" ($f 0))
   (print *current-fields*)
   (print *number-of-fields*)
   (incf sum (read-from-string ($f 3))))

  ;; すべてのレコードに対する処理が終わった後に実行される end節
  (end (format t "TOTAL: ~A~%" sum)))

(何をしてるコードかはあんまり重要じゃない)

ということで書いてみた。

ちなみに、CL-PPCREに依存している。

;;; 設定できるオプション
(defvar *field-separator* "\\s+"
  "フィールドセパレーター。デフォルトでは、1つ以上の空白文字")


;;; *** マクロの中で使われる関数とスペシャル変数 ***

;;; それぞれの行を処理するときに、カレントのレコード、
;;; それが何番目のレコード化、分割されたフィールドのリスト
;;; そしてフィールドの数を記録しておくスペシャル変数。
(defvar *current-record* "")

(defvar *current-fields* nil)

(defvar *number-of-records* 0)

(defvar *number-of-fields* 0)

(defun update-record (record)
  "レコードを引数にして、上記のスペシャル変数を更新する関数"
  (setf *current-record*
	record
	*number-of-records* (1+ *number-of-records*)
	*current-fields*
	(cl-ppcre:split *field-separator* record))
  (setf *number-of-fields* (length *current-fields*)))


;;; レコードに対して正規表現でマッチをしようとしたときに、
;;; その結果、各レジスタを保持するリスト、レジスタの長さの
;;; 3つを記録しておくためのスペシャル変数
(defvar *matched* "")

(defvar *match-list* NIL)

(defvar *number-of-matches* 0)

(defun match (regexp)
  "引数として与えられた正規表現のマッチを試し、
その結果を上記のスペシャル変数にセットする"
  (multiple-value-bind (matched match-list)
      (cl-ppcre:scan-to-strings regexp *current-record*)
      (let ((len (length *match-list*)))
	(setf *matched* matched
	      *match-list* match-list
	      *number-of-matches* len))))


;;; *** ここからマクロを展開するための関数 ***

(defun check-clauses (clause-list)
  "各節が健全な切かどうかを試す関数
具体的には、それがbeginかbodyかendではじまるリストなら健全"
  (mapc (lambda (clause)
	  (unless (find (car clause) '(begin body end))
	    (error
	     "各節は begin, body, end のいずれかではじまるべき: ~S"
	     clause)))
	clause-list)
      t)


(defun pickup-clauses (sym clause-list)
  "節のリストの中から、ある種類の節だけを抜き出す

節はリストなので、第一引数で表されたシンボルではじまる節をすべて集め、
そのシンボルだけを取り除いた残り(つまり、cdr)を、まとめて返す。
別々の節に書かれたコードも、書かれた順に1つにまとめられる"
  (apply #'append
	 (mapcar #'rest
		 (remove-if-not
		  (lambda (clause) (eql sym (car clause)))
		  clause-list))))

(defun parse-body-clause (clause)
  "body節をパースする。
もしそれがmatchではじまるリストならば、下の関数で書き換える"
  (case (car clause)
    (match (match-clause-to-sexp clause))
    (t clause)))


(defun match-clause-to-sexp (clause)
  "matchではじまるリストを、match関数の呼び出しに書き換える"
  (destructuring-bind (match-sym regexp . body) clause
    (declare (ignore match-sym))
    `(progn (match ,regexp)
	    (when *matched*
	      ,@body))))


;;; fn-nameという名前の関数を定義するマクロ。
;;; この関数は、関数を1つ受け取り、節で定めた処理を実行していく。
;;; 渡される関数は、引数なしの関数であり、呼び出すとレコードとして
;;; 文字列を返すか、もうレコードが無いことを表すnilを返すべきである。
(defmacro defawk (fn-name binding-clause &rest clause-list)
  ;; まず、適切な節だけを含むか確認する
  (check-clauses clause-list)
  (let ((record-supplier (gensym "RECORD-SUPPLIER"))
	(record (gensym "RECORD"))
	(begin-clause-list (pickup-clauses 'begin clause-list))
	(body-clause-list (pickup-clauses 'body clause-list))
	(end-clause-list (pickup-clauses 'end clause-list)))
    `(defun ,fn-name (,record-supplier)
       (let ,binding-clause
	 (labels (;;; フィールドを参照するための関数。
		  ;;; ($f n) でn番目のフィールドを参照する。
		  ;;; フィールドのカウントは1からはじまる。
		  ;;; ($f 0) はカレントレコードと等しい
		  ($f (n)
		    (cond ((or (not (integerp n))
			       (minusp n)
			       (< *number-of-fields* n))
			   NIL)
			  ((zerop n) *current-record*)
			  (t (nth (- n 1) *current-fields*))))
		  ;;; match節によるマッチの結果を参照するための変数
		  ;;; ($ 0) はマッチした全体を表す文字列を返す
		  ;;; ($ 1) 以降は部分マッチのレジスタを参照する
		  ($ (n)
		    (cond ((or (not (integerp n))
			       (minusp n)
			       (< *number-of-matches* n))
			   NIL)
			  ((zerop n) *matched*)
			  (t (svref *match-list* (- n 1))))))
	   ;; begin節はここに展開される
	   ,@begin-clause-list
	   ;; レコード供給関数がnilを返すまで、body節を繰り返し実行
	   (do ((,record (funcall ,record-supplier)
			 (funcall ,record-supplier)))
	       ((null ,record) nil)
	     (update-record ,record)
	     ,@(mapcar #'parse-body-clause body-clause-list))
	   ;; end節は繰り返しの後に展開される
	   ,@end-clause-list)))))

このように使う

;;; レコードを供給する関数……を生成する関数を定義
;;; この場合は単にファイルから一行ずつ読むだけ。すべて読むと行の代わりにNILを返す。
(defun make-record-supplier (in)
  (lambda ()
    (read-line in NIL)))

;;; 処理を定義。これによってparsing関数が作られる。
(defawk parsing (sum) ; 合計を記録していくための変数 sum を宣言
  (begin (setf sum 0)) ; sum を 0 にしておく。別に宣言時に ((sum 0)) としても同じ事

  ;; \w+ を含むレコードがあればこの節が実行される
  (body
   (match "(\\w+)"
     (format t "MATCH: ~A in ~A~%" ($ 1) ($ 0))))

  ;; すべてのレコードについて、分割されたフィールドとかを表示してみる
  (body
   (format t "RECORD: ~A~%" ($f 0))
   (print *current-fields*)
   (print *number-of-fields*)
   (incf sum (read-from-string ($f 3)))) ; 3番目のフィールドの数字を sum に累積していく

  ;; すべてのレコードを読み終わったら sum の値を表示
  (end (format t "TOTAL: ~A~%" sum)))

;;; レコード供給関数生成関数を parsing 関数に渡してやると処理が始まる
(with-open-file (in "result.txt")
  (parsing (make-record-supplier in)))