言語を自作していると、特殊用途でない限り CLI は概ね実装することになりますが、 REPL(Read-Eval-Print Loop; 対話的にコードを実行できる仕組み) は必ずしも要らないため、後回しにしてしまいがちです。
なぜ、後回しにしてしまうかを説明したいと思います。
1. だるい
便利に使えるREPLというのは、メジャーな言語のREPL(Python, JavaScript, Ruby, その他いろいろ)を見ればわかりますが、複数行にわたるコンソールの編集機能を提供していたり、入力のヒストリー機能を持っていたりします。
これらは、単純に標準入出力を読み書きするだけでは実現できません。
端末の行・列のサイズを取ったり、ユーザーのキー入力(矢印キーやバックスペース等)で入力カーソルを動かしたり、文字を消したりする必要があります。
2. だるい
端末の画面描画を行うためには、端末にエスケープシーケンスによるコントロールコードを送信します。
ANSIエスケープシーケンス チートシート
3. だるい
ただ、いくら何でも毎度エスケープシーケンスを送るコードを書いていては、訳が分からなくなってくるので、一般的に端末の画面制御は「端末制御ライブラリ」と呼ばれるものを使用します。
Cのライブラリとしては、ncurses 等があります。
4. だるい
画面描画ができたとしても、カーソル位置や入力の制御も必要です!
面倒だ!! CLIから読ませればいいじゃない!!!
REPLをつくろう
しかし、先人たちもこのような面倒なことを放置するわけもなく、シェルやREPLのためのテキスト入出力ライブラリが作られました。
もっとも有名なものが GNU Readline です。GNU Readline は bash にも使われています。
Nodeにも以前より(GNU Readlineとは少し異なるものを含め) Readline 的機能を実現するライブラリがありましたが、Node.js 10.0
から標準モジュールに含まれるようになりました。
わずか 100行でかなりしっかりした REPL が実装できました。
readline
😍👍 、 console.dir
😍👍 !
// Node.js>=10.0
// 旧バージョンでの代替手段として linebyline (https://www.npmjs.com/package/readline) があります。
// ※互換はありません。
const readline = require('readline');
// 構文解析の結果、ステートメントが完了していないことを示すエラー。
class ScriptUnexpectedTerminationError extends Error {
}
// オレオレ言語の 字句解析器(lexer) + 構文解析器(parser)。
function parse(s) {
s = (s || '').trim();
if (s.length === 0) {
throw new ScriptUnexpectedTerminationError('Unexpected termination of script');
}
let m;
if (! s.startsWith('(')) {
throw new Error('Syntax error');
}
if (m = s.match(/^\(([^]*)\)$/)) {
return m[1].split(/\s/);
}
throw new ScriptUnexpectedTerminationError('Unexpected termination of script');
}
// オレオレ言語のインタープリタを作成する関数。
// 状態を内包している必要があります。
function createEvaluater() {
const ctx = { /* your interpreter's internal status */ };
return ((s) => {
return parse(s).map(x => x + '!');
});
}
// REPLを開始します。
function startRepl(startup) {
const prompt = '>>> ';
let pkgName = 'OreOreGengo';
let pkgVer = '0.0.0';
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt,
});
const repl = createEvaluater();
// ユーザー入力の前に実行したいコードがあれば受け付ける
if (startup && startup.trim().length > 0) {
console.dir(repl(startup));
}
console.log(`${pkgName} ${pkgVer} CLI (REPL)\nType ^C to exit.\n`);
rl.prompt();
let buf = '';
// 行の入力を受け付ける
rl.on('line', (input) => {
buf += input;
const trimmed = buf.trim();
if (trimmed.length) {
try {
parse(buf);
} catch (e0) {
if (e0 instanceof ScriptUnexpectedTerminationError) {
// 複数行の入力
buf += '\n';
rl.setPrompt('... ');
rl.prompt();
return;
}
}
try {
console.dir(repl(buf));
} catch (e) {
console.error(e);
} finally {
buf = '';
}
} else {
buf = '';
}
rl.setPrompt(prompt);
rl.prompt();
})
.on('close', () => {
console.log('Bye!');
process.exit(0);
});
}
startRepl('(1 2 3 5 7 11 13)');
実行結果
[ '1!', '2!', '3!', '5!', '7!', '11!', '13!' ]
OreOreGengo 0.0.0 CLI (REPL)
Type ^C to exit.
>>> (
... 1 2 3
... )
[ '!', '1!', '2!', '3!', '!' ]
>>> Bye!