自作CLIの対話モードでカーソルが効かない！となった時に使えるライブラリ

# TL; DR

- 標準入力の取得をlinerに置き換えるだけで...
    - 左右キーでカーソル移動
    - 上キーで1つ前の行を入力
    - Tabで補完

https://github.com/peterh/liner

# はじめに

GoではCLIがサクッと実装できて重宝しています。一方、対話モードを作るとカーソルが使えず[^cursor]操作性がいまいちです。

[拙作のツール](https://github.com/Syuparn/tmplscript)（text/templateのインタプリタ）でもREPLでカーソルが使えず、タイポするとバックスペースで全消ししないといけませんでした...

```bash:左に戻って修正したかったのに...
$ tmplscript -i
tmpl:1> {{print "Hello, war^[[D^[[D
```

（ツールについて詳細は以下の記事で紹介しています）

https://qiita.com/Syuparn/items/4157d13c39b95185acfd

そこで、本記事では手軽にカーソルを使えるようにするモジュール「**liner**」を紹介します。

https://github.com/peterh/liner

# 使い方

`liner` オブジェクトから標準入力を受け取るだけです！カーソル処理については `liner` 側が良きに計らってくれます。

- 左右キーでカーソル移動
- 上キーで1つ前の行を再度入力

```go:実装例
line := liner.NewLiner()
defer line.Close()

line.SetCtrlCAborts(true) // 有効にしないと Ctrl+Cが吸われてしまい終了できない

lineNum := 1
// 入力ループ
for {
	// 次の行を取得（`\n` はつかない）
	inputStr, err := line.Prompt(fmt.Sprintf("tmpl:%d> ", lineNum))
	if err != nil {
		// ...
	}
	// 入力をパース、評価して結果を出力...
}
```

`line.Prompt` の引数は、入力行に表示されるプロンプトの文字列（冒頭の例でいう `tmpl:1> `）です。

また、返されたエラーが `liner.ErrPromptAborted` かどうかを判定することで `Ctrl+C` での終了時処理を挟むことができます。

```go
inputStr, err := line.Prompt(fmt.Sprintf("tmpl:%d> ", lineNum))
if err != nil {
	if err == liner.ErrPromptAborted {
		// REPL終了前にメッセージ出力
		fmt.Println("\nBye.")
		return nil
	}
	// それ以外の入力エラー
	fmt.Fprintf(os.Stderr, "input error:\n%v\n", err)
	continue
}
```

```bash
$ ./tmplscript -i
tmpl:1> ^C

Bye.
```

# キーワードの補完を実装

さらに、`liner` ではTab補完の実装も可能です。以下の仕様(`liner.WordCompleter`) を満たす補完関数を実装し、linerへ設定します。~~仕様の癖が強いです~~

- 型: `func(line string, pos int) (head string, completions []string, tail string)`

|引数、戻り値|役割|
|-|-|
|`line`| 行の全文|
|`pos`| カーソルの位置|
|`head`| 補完の候補よりも前に現れる文字列|
|`completions`| 補完の候補一覧（Tabを押すたびに次の候補が出る）|
|`tail`| 補完の候補よりも後に現れる文字列|

- 例: 補完 `wo -> world` の場合
    - 引数: `line: "Hello, wo!!!", pos: 9`
    - 戻り値: `head: "Hello, ", completions: {"world", "word"}, tail: "!!!"`

https://pkg.go.dev/github.com/peterh/liner#WordCompleter

上記の通り、補完のために単語の境界を区切る実装が必要なため想像以上にややこしかったです。~~Off-By-OneとOut of Rangeの嵐~~

```go
func newWordCompleter() liner.WordCompleter {
	return func(line string, pos int) (head string, completions []string, tail string) {
		// まずは行をhead, (不完全なパーツ), tailに分割
		head, partial, tail := splitLine(line, pos)

		if partial == "" {
			// マッチしえない
			return
		}

		// 関数名の補完
		for name := range funcMap() {
			if strings.HasPrefix(name, partial) {
				completions = append(completions, name)
			}
		}
		return
	}
}

// text/templateのデリミタによって単語を切り出す
func splitLine(line string, pos int) (head, partial, tail string) {
	// posを含む単語の開始、終了位置
	startPos := partialStartPos(line, pos, "{}()<>=| ")
	endPos := partialEndPos(line, pos, "{}()<>=| ")

	// 行を3分割
	head = subStr(line, 0, startPos)
	tail = subStr(line, endPos, len(line))
	partial = subStr(line, startPos, endPos)
	return
}

func partialStartPos(line string, pos int, delims string) int {
	delimPos := strings.LastIndexAny(subStr(line, 0, pos), delims)
	if delimPos < 0 {
		return 0
	}
	return delimPos + 1
}

func partialEndPos(line string, pos int, delims string) int {
	relativeDelimPos := strings.IndexAny(subStr(line, pos, len(line)), delims)
	if relativeDelimPos < 0 {
		return len(line)
	}
	return pos + relativeDelimPos
}

// str[start:end] のラッパー。無くてもよいがOut Of Range回避が面倒...
func subStr(str string, start, end int) string {
	if start >= len(str) {
		return ""
	}
	if end <= 0 {
		return ""
	}

	if end > len(str) {
		end = len(str)
	}
	if start < 0 {
		start = 0
	}

	return str[start:end]
}
```

# おわりに

以上、対話モードのCLIでカーソルが使えるようになる `liner` の紹介でした。
標準入力の扱いがシンプルで導入しやすいので、色々なツールに気軽に組み込めそうです。

[^cursor]: Linuxのターミナルの話です。WindowsのPowerShell上ではカーソルが効きます（PowerShell自体の機能？）。