REPLのつくりかた (Node篇)

言語を自作していると、特殊用途でない限り CLI は概ね実装することになりますが、 [REPL(Read-Eval-Print Loop; 対話的にコードを実行できる仕組み)](https://ja.wikipedia.org/wiki/REPL) は必ずしも要らないため、後回しにしてしまいがちです。

なぜ、後回しにしてしまうかを説明したいと思います。

## 1. だるい
便利に使えるREPLというのは、メジャーな言語のREPL(Python, JavaScript, Ruby, その他いろいろ)を見ればわかりますが、複数行にわたるコンソールの編集機能を提供していたり、入力のヒストリー機能を持っていたりします。
これらは、単純に標準入出力を読み書きするだけでは実現できません。
端末の行・列のサイズを取ったり、ユーザーのキー入力(矢印キーやバックスペース等)で入力カーソルを動かしたり、文字を消したりする必要があります。

## 2. だるい
端末の画面描画を行うためには、端末にエスケープシーケンスによるコントロールコードを送信します。
[ANSIエスケープシーケンス チートシート](https://qiita.com/PruneMazui/items/8a023347772620025ad6)

## 3. だるい
ただ、いくら何でも毎度エスケープシーケンスを送るコードを書いていては、訳が分からなくなってくるので、一般的に端末の画面制御は「端末制御ライブラリ」と呼ばれるものを使用します。
Cのライブラリとしては、ncurses 等があります。

## 4. だるい
画面描画ができたとしても、カーソル位置や入力の制御も必要です！
面倒だ！！ CLIから読ませればいいじゃない！！！

## REPLをつくろう
しかし、先人たちもこのような面倒なことを放置するわけもなく、シェルやREPLのためのテキスト入出力ライブラリが作られました。
もっとも有名なものが [GNU Readline](https://tiswww.case.edu/php/chet/readline/rltop.html) です。GNU Readline は bash にも使われています。

Nodeにも以前より(GNU Readlineとは少し異なるものを含め) Readline 的機能を実現するライブラリがありましたが、`Node.js 10.0` から標準モジュールに含まれるようになりました。
わずか 100行でかなりしっかりした REPL が実装できました。

`readline` 😍👍 、 `console.dir` 😍👍 ！

```js
// 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処理系 [Liyad](https://www.npmjs.com/package/liyad) の [CLI+REPL](https://www.npmjs.com/package/liyad-cli) をリリースしました。