TL; DR
- 標準入力の取得をlinerに置き換えるだけで...
- 左右キーでカーソル移動
- 上キーで1つ前の行を入力
- Tabで補完
はじめに
GoではCLIがサクッと実装できて重宝しています。一方、対話モードを作るとカーソルが使えず1操作性がいまいちです。
拙作のツール(text/templateのインタプリタ)でもREPLでカーソルが使えず、タイポするとバックスペースで全消ししないといけませんでした...
$ tmplscript -i
tmpl:1> {{print "Hello, war^[[D^[[D
(ツールについて詳細は以下の記事で紹介しています)
そこで、本記事では手軽にカーソルを使えるようにするモジュール「liner」を紹介します。
使い方
liner オブジェクトから標準入力を受け取るだけです!カーソル処理については liner 側が良きに計らってくれます。
- 左右キーでカーソル移動
- 上キーで1つ前の行を再度入力
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 での終了時処理を挟むことができます。
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
}
$ ./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: "!!!"
- 引数:
上記の通り、補完のために単語の境界を区切る実装が必要なため想像以上にややこしかったです。Off-By-OneとOut of Rangeの嵐
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 の紹介でした。
標準入力の扱いがシンプルで導入しやすいので、色々なツールに気軽に組み込めそうです。
-
Linuxのターミナルの話です。WindowsのPowerShell上ではカーソルが効きます(PowerShell自体の機能?)。 ↩