簡易 CUI プロンプトを組んでみる
本家ブログでも書いたけど,新年早々
という記事を見て手元の自作ツールをアップデートしたのだが,今回お世話になった zetamatta/go-readline-ny パッケージについてもう少し掘り下げて紹介してみる。
go-readline-ny は CUI 上で1行編集・入力ができるパッケージで以下の特徴がある。
- Windows 用の拡張 shell NYAGOS 用に開発されたパッケージ
- Emacs 風のキー・バインディング(
C-w
やC-y
などが使える)- ファンクションキーなどに機能を割り当てられる(らしい?)
- Ctrl+C および Ctrl+D を正しく拾ってエラー(readline.CtrlC および io.EOF)として返してくれる(上位レイヤでの SIGNAL 操作は不要)
- mattn/go-colorable と組み合わせて使える
- 簡易ヒストリ機能を付けられる
- context 標準パッケージをサポートしている
- マルチプラットフォーム対応(多分)。少なくとも Windows と Ubuntu では問題なく動作している
いたれりつくせりだよ(笑)
簡易プロンプトとしては bufio 標準パッケージを使った bufio.Scanner が有名である。入力ストリームを選ばないのはメリットだが,1行編集・入力に関しては,バックスペースなど,ごく基本的な機能しか提供していない。
また,上述の「コマンドラインシェル??? 誰でも作れますよ」では mattn/go-tty を紹介していて,これを使うと RAW モードでかなりプリミティブな操作ができるようだが「お手軽に簡易 CUI プロンプトを組みたい」という場合にはちょっとヘヴィな感じがする。 go-readline-ny なら
text, err := (&readline.Editor{}).ReadLine(context.Background())
でもちゃんと動く。素晴らしい!
というわけで go-readline-ny を使って簡易 CUI プロンプトを組んでみる。
まずは以下の関数を考える。
func Reverse(r []rune) []rune {
if len(r) > 1 {
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
}
return r
}
Rune 配列を逆順に並べ替えるだけの簡単なお仕事である。簡易 CUI プロンプトで入力した文字列をこの関数に通して文字列を反転させる[1]。こんな感じのコードでどうだろう。
func main() {
//input
text, err := (&readline.Editor{
Prompt: func() (int, error) { return fmt.Print("> ") },
}).ReadLine(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, errPrint(err))
return
}
//output
fmt.Println(string(Reverse([]rune(text))))
}
ちなみに errPrint() 関数は
func errPrint(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, readline.CtrlC):
return "処理を中断します"
case errors.Is(err, io.EOF):
return "処理を終了します"
default:
return err.Error()
}
}
という感じに Ctrl+C
および Ctrl+D
をチェックする関数である。
これを実行すると
$ go run sample1.go
> あいうえお
おえういあ
という結果になった。うんうん。ちゃんと反転してるね。
次にこのプロンプトをくり返し実行するようコードを変更してみる。更に簡易ヒストリ機能も有効にする。
func main() {
history := simplehistory.New()
editor := readline.Editor{
Prompt: func() (int, error) { return fmt.Print("> ") },
History: history,
}
fmt.Println("Input Ctrl+D to stop.")
for {
//input
text, err := editor.ReadLine(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, errPrint(err))
return
}
//output
fmt.Println(string(Reverse([]rune(text))))
//add history
history.Add(text)
}
return
}
なお,ヒストリ制御用のオブジェクトは zetamatta/go-readline-ny/simplehistory パッケージを使っている。これを実行すると
$ go run sample2.go
Input Ctrl+D to stop.
> あいうえお
おえういあ
> しんぶんし
しんぶんし
>
処理を終了します
てな感じになった(最後は Ctrl+D
で終了している)。ちなみに上下のカーソルでヒストリを表示してくれる。ブラボー!
実は readline.Editor 型は
type Editor struct {
KeyMap
History IHistory
Writer io.Writer
Out *bufio.Writer
Prompt func() (int, error)
Default string
Cursor int
LineFeed func(Result)
OpenKeyGetter func() (KeyGetter, error)
}
と定義されていて,さらにこの中の IHistory 型は
type IHistory interface {
Len() int
At(int) string
}
と interface 型になっている。つまり,この readline.IHistory 型の構成を満たすのであれば,自作のヒストリ型を使うこともできるわけだ。
そこで
const (
max = 50
logfile = "history.log"
)
type History struct {
buffer []string
}
var _ readline.IHistory = (*History)(nil) //compiler hint
func New() (*History, error) {
history := &History{buffer: []string{}}
file, err := os.Open(logfile)
if err != nil {
return history, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
history.Add(scanner.Text())
}
return history, scanner.Err()
}
func (h *History) Len() int {
if h == nil {
return 0
}
return len(h.buffer)
}
func (h *History) At(n int) string {
if h == nil || h.Len() <= n {
return ""
}
return h.buffer[n]
}
func (h *History) Add(s string) {
if h == nil || len(s) == 0 {
return
}
if n := h.Len(); n < 1 {
h.buffer = append(h.buffer, s)
} else if h.buffer[n-1] != s {
h.buffer = append(h.buffer, s)
}
if n := h.Len(); n > max {
h.buffer = h.buffer[n-max:]
}
}
func (h *History) Save() error {
if h == nil {
return nil
}
file, err := os.Create(logfile)
if err != nil {
return err
}
defer file.Close()
for _, s := range h.buffer {
fmt.Fprintln(file, s)
}
return nil
}
という型とメソッドを考えてみた[2]。これを使って
func main() {
history, err := New()
if err != nil {
fmt.Fprintln(os.Stderr, err)
//continue
}
editor := readline.Editor{
Prompt: func() (int, error) { return fmt.Print("> ") },
History: history,
}
fmt.Println("Input Ctrl+D to stop.")
for {
//input
text, err := editor.ReadLine(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, errPrint(err))
break
}
//output
fmt.Println(string(Reverse([]rune(text))))
//add history
history.Add(text)
}
if err := history.Save(); err != nil {
fmt.Fprintln(os.Stderr, err)
}
return
}
とすれば,ヒストリを history.log
に最大50個保存される。ひとつ前の sample2.go
のコードと見比べて欲しい。
これで go-readline-ny の基本機能は押さえられたかな。もう少しこれで遊んでみよう。
参考図書
Discussion