目的
Prolog ネタでなにかを書きたくなることがあるかもしれないので、そのときのために 「文芸的プログラミング」的なものを考えました。
概要
すこぶる単純かつ乱暴な話で、 Qiita で本文になっている部分をコメントで書いて、Prolog コードは普通に実行可能な形で書く、というだけのことです。
Prolog は記述順序が比較的自由なので、文章の文脈に沿ってコードを記述したものに適当なコメントを付加しただけのものでもそれらしく読めるだろうと考えました。
記述順序に強く依存する言語、例えば C++ とかだとそんなに単純にはいかないと思います。
変換プログラムは sed や awk で書ける程度の内容なのですが、練習として敢えて Prolog でテキストファイルを変換するプログラムを書いてみます。
実装
全体の流れは
- 与えられたファイルをストリームとしてオープンする
- ストリームからの UTF-8 テキストの読み出し
- ルールに従って char リストを変換する
- 変換結果を出力する
というものです。
与えられたファイルをストリームとしてオープンする
わざわざ述語にするほどのことでもないのですが、一応上に書いた全体の流れに沿って述語にしてみました。与えられたファイル名を UTF-8 テキストとしてオープンしてそのストリームを返します。
open_text_stream(InputFile, Stream) :-
open(InputFile, read, Stream, [encoding(utf8)]).
ストリームからの UTF-8 テキストの読み出し
ストリームから終端まで読み込んで、読み込んだ内容を char のリストとして返す述語は以下のように書けます:
stream_chars(Stream, Data) :-
( at_end_of_stream(Stream) -> Data = []
; get_char(Stream, C), Data = [C|Rest], stream_chars(Stream, Rest)
).
一文字づつ読み出しは効率が悪そう、とか、どんなに巨大なファイルでもメモリ上にリストとして読み込むというのはいかがなものか、という至極もっともなつっこみどころはありますが、Qiita に書く程度の文章なら問題ないでしょう。
ルールに従って char リストを変換する
テキスト(を char のリストにしたもの)をルールに従って変換します。
最低限必要なルールは、
- コメント開始行の "/*" をコード終了部分の "```" に、
- コメント終了行の "*/" をコード開始部分の "```prolog" に
変換することです(面倒を避けるために、コメント開始行・終了行には空白も含めてなにも他のことは書いてはならないことにします。改行コードすらも限定してしまいます)。
変換ルールを定義してみます:
rule("\n/*\n", "\n```\n").
rule("\n*/\n", "\n\n```prolog\n").
char のリストから rule/2 の第一引数に該当する部分を探して、あればその部分を第二引数の値に変換する DCG は以下のように書けます:
% リストが空になったら終了
translate(S-S) --> [].
% ルールにマッチする部分を書き換える
translate(Start-End) -->
{
rule(From, To),
string_chars(From, FromChars),
string_chars(To, ToChars)
},
FromChars,
{
insert(Start-Middle, ToChars), !
},
translate(Middle-End).
% それ以外は元のまま
translate(Start-End) -->
[C], { insert(Start-Middle, [C]) },
translate(Middle-End).
Start-End という奇妙なものが引数にあることが気になった方もおられるでしょう。
これは '-'/2 を使って差分リストを表現したものです。
変換結果は Start-End の間に char リストとして埋め込むことにしています。
差分リストに char リストを埋め込む insert/2 は以下のように定義します:
insert(S-S, []).
insert([C|S]-E, [C|R]) :- insert(S-E, R).
これでコード開始部分・終了部分に ``` の行を付加することができます。
しかし、最初に出現するコメント開始行、最後に出現するコメント終了行をそのままにすると余計な ``` が出てしまうので、これも削っておかなければなりません。
また、プログラムファイルにはヘッダ部分、トレイラー部分もあります。
例えば、こんな感じのものです。
#! /usr/bin/swipl -f -q
% -*- coding:utf-8-unix; mode:prolog -*-
Qiita に載せる文章にはこの部分は不要です。
この部分を含めて、ファイル先頭から最初に出現するコメント開始行までまとめて消すことにします。こうすれば、ファイル先頭に文章化の対象でない情報を書いておくこともできます。
ファイル終端についても同様にコメント終了行を含めて消してしまうことにします。
最初のコメント開始行までを char リストから削る処理はこんな感じで書けます:
% 与えられたリストが空なら空を返す。ここが呼ばれることはないはず。
trim_beginning([], []).
% '/', '*', '\n' の並びが最初に出現したら、それ以降が欲しい部分。
trim_beginning(['/', '*' | Rest], Rest) :- !.
% '/', '*', '\n' の並びが出現するまでは捨てる
trim_beginning([_| Rest], TrimmedChars) :-
trim_beginning(Rest, TrimmedChars).
最後のコメント終了行以降を削る処理は若干面倒です。
戦略として、元のリストを2つの差分リスト Start-Middle / Middle-End(=[]) に分割し、
- Middle-End が先頭に "*/" の並び持ち、かつ
- Middle-End 先頭以外に "*/" の並びがない、
という状態をゴールとして探索すれば、そのときの Start-Middle が欲しい結果となります。
some_chars/1 で char リストを適当に分割しては trailing/1 でチェックする、ということをバックトラックで繰り返して結果を得ます。
多分、もっと効率の良くて簡単に書ける出来合いの述語があるだろうとは思います。
% 最後のコメント終了行を探して、その位置を返す
trim_trailing([], []).
trim_trailing(Chars, M) :-
insert(S-[], Chars),
some_chars(S-M), % 与えられたリスト S に含まれる適当な位置を M として返す
trailing(M-[]).
% 与えられたリスト先頭から終端までの適当な位置を差分リストの終端にする
some_chars(S-S).
some_chars([_|S]-E) :- some_chars(S-E).
% 終端部分は '*', '/' の並びから開始し、かつ '*', '/' の並びを開始位置以外に持たない。
trailing(['*', '/'|M]-End) :-
not_contains_trailing(M-End).
% '*', '/' の並びをもたないことを判定する述語
not_contains_trailing(E-E).
not_contains_trailing(M-E) :-
\+ M = ['*', '/'|_], M = [_|M2], not_contains_trailing(M2-E).
trim_beginning, trim_trailing の両方を組み合わせるとヘッダ部分、トレイラー部分を削除する述語を書くことが出来ます:
trim(Chars, TrimmedChars) :-
trim_beginning(Chars, Start),
trim_trailing(Start, End),
insert(Start-End, TrimmedChars).
変換結果を出力する
ここまでの手順で変換した結果は char のリストです。これを標準出力に表示する述語を以下のように準備します。
write_chars([]).
write_chars([C|Cs]) :- write(C), write_chars(Cs).
メイン関数
ここまでの部品を組み合わせると、引数に与えられたファイルを変換して表示するプログラムにすることができます。
SWI-Prolog の場合には以下のようにすると、Prolog ソースコードをスクリプトとして実行可能です:
% メイン関数
:- initialization (main, main).
main([]) :-
write('Usage: literate.pl <input_file>'), nl, halt.
main(Argv) :-
Argv = [Input|_],
open_text_stream(Input, Stream),
stream_chars(Stream, Chars),
trim(Chars, TrimmedChars),
call_dcg(translate(S-[]), TrimmedChars, []),
write_chars(S), nl,
halt.
おわりに
本物の文芸的プログラミングと比べる気も起きないほど安直な方法ですが、簡単なだけにあまり書き方について悩まずに済むとも言えそうです。
この記事自身も、ここに書いた方法で書きました。