1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

1
Posted at

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 の紹介でした。
標準入力の扱いがシンプルで導入しやすいので、色々なツールに気軽に組み込めそうです。

  1. Linuxのターミナルの話です。WindowsのPowerShell上ではカーソルが効きます(PowerShell自体の機能?)。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?