Edited at

REPLのつくりかた (Node篇)

言語を自作していると、特殊用途でない限り 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!


さいごに

拙作のLisp処理系 LiyadCLI+REPL をリリースしました。