簡易 CUI プロンプトを組んでみる

7 min read読了の目安(約6800字

本家ブログでも書いたけど,新年早々

https://zenn.dev/zetamatta/articles/d7b76ff6535d7d

という記事を見て手元の自作ツールをアップデートしたのだが,今回お世話になった zetamatta/go-readline-ny パッケージについてもう少し掘り下げて紹介してみる。

go-readline-ny は CUI 上で1行編集・入力ができるパッケージで以下の特徴がある。

  1. Windows 用の拡張 shell NYAGOS 用に開発されたパッケージ
  2. Emacs 風のキー・バインディング(C-wC-y などが使える)
    • ファンクションキーなどに機能を割り当てられる(らしい?)
  3. Ctrl+C および Ctrl+D を正しく拾ってエラー(readline.CtrlC および io.EOF)として返してくれる(上位レイヤでの SIGNAL 操作は不要)
  4. mattn/go-colorable と組み合わせて使える
  5. 簡易ヒストリ機能を付けられる
  6. context 標準パッケージをサポートしている
  7. マルチプラットフォーム対応(多分)。少なくとも 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]。こんな感じのコードでどうだろう。

sample1.go
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 
> あいうえお
おえういあ

という結果になった。うんうん。ちゃんと反転してるね。

次にこのプロンプトをくり返し実行するようコードを変更してみる。更に簡易ヒストリ機能も有効にする。

sample2.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 型は

history.go
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 型は

history.go
type IHistory interface {
    Len() int
    At(int) string
}

と interface 型になっている。つまり,この readline.IHistory 型の構成を満たすのであれば,自作のヒストリ型を使うこともできるわけだ。

そこで

sample3.go
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]。これを使って

sample3.go
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 の基本機能は押さえられたかな。もう少しこれで遊んでみよう。

参考図書

https://www.amazon.co.jp/dp/4621300253
脚注
  1. 厳密には,並び替えは rune (Unicode コードポイント) 単位であって「文字」単位ではない。異体字や絵文字などは複数のコードの合成になっていることもあるので,今回のようなコードで反転させると,たぶん大変なことになる(笑) ↩︎

  2. 本当はリングバッファとかにすべきなんだろうけど,今回はサボっている。ごめんペコン。 ↩︎