【Go言語】pecoのコードリーディング

pecoのコードリーディングをしてみる

目的は、Go言語の勉強と peco, fzf といったコマンドラインの探索機能の実装を知りたいためである。
peco コードは規模が小さいであろう初版近くを利用することにした。

なお、ブログ表示のために行頭のタブを半角2スペースで表示している。

 

環境

OS Ubuntu 18.04
Go version 1.11
fish shell version 2.7.1
GOPATH $HOME/go

 

準備

コード取得

GitHub からコードを取得して、2番目のリリース物を取得する

% git clone https://github.com/peco/peco
% git checkout a1649fc

 

ビルドに必要なパッケージを取得する

% go get github.com/jessevdk/go-flags
% go get github.com/nsf/termbox-go

 

ビルドする

GDB を使うことがあるかも知れないので、デバッグシンボルあり・最適化なしでビルドする。

% go build -gcflags '-N -l' -o peco percol.go  tty_linux.go 

なお、通常の最適化ありでのビルドは「go build percol.go tty_linux.go」で良い。

 

gtags を導入する

Go言語に対応してくださった方に感謝。

% go get github.com/juntaki/gogtags
[ GNU GLOBALでgolangの関数呼び出し行にタグジャンプする ]

 

テスト実行する

動作しているか確認する。

% ls | ./peco

 
こちらを参考にして、GDB でも動作するかを確認する。
gdb ./peco を実行したところ、次のようなログが出た。
一応、★を付与した箇所にて「Reading symbols from ./peco...done.」とあるのでシンボルの読み込みはできた模様。

% gdb ./peco
    GNU gdb (Ubuntu 8.1-0ubuntu3) 8.1.0.20180409-git
    Copyright (C) 2018 Free Software Foundation, Inc.
    License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
    This is free software: you are free to change and redistribute it.
    There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
    and "show warranty" for details.
    This GDB was configured as "x86_64-linux-gnu".
    Type "show configuration" for configuration details.
    For bug reporting instructions, please see:
    <http://www.gnu.org/software/gdb/bugs/>.
    Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.
    For help, type "help".
    Type "apropos word" to search for commands related to "word"...
★  Reading symbols from ./peco...done.
    warning: File "/snap/go/2635/src/runtime/runtime-gdb.py" auto-loading has been declined by your `auto-load safe-path' set to "$debugdir:$datadir/auto-load".
    To enable execution of this file add
    	add-auto-load-safe-path /snap/go/2635/src/runtime/runtime-gdb.py
    line to your configuration file "/home/neko/.gdbinit".
    To completely disable this security protection add
    	set auto-load safe-path /
    line to your configuration file "/home/neko/.gdbinit".
    For more information about this security protection see the
    "Auto-loading safe path" section in the GDB manual.  E.g., run from the shell:
    	info "(gdb)Auto-loading safe path"

 
main パッケージの main 関数にブレイクポイントを設定する

(gdb) b main.main
(gdb) r
main 関数で停止するので、Ctrl-o を入力して表示を下図のように変えてデバッグしていく。

f:id:dnkrnka:20180902225058p:plain:w800
 

リーディング

上述の通りビルドが通ったので正しいコードということが分かったのでコードを見ていく。
 

percol.go

package main

import (
  "bufio"
  "fmt"
  "os"
  "regexp"
  "sync"
  "time"
  "unicode/utf8"

  "github.com/jessevdk/go-flags"
  "github.com/mattn/go-runewidth"
  "github.com/nsf/termbox-go"
)

type Ctx struct {
  result       string
  loop         bool
  mutex        sync.Mutex
  query        []rune
  dirty        bool // true if filtering must be redone
  cursorX      int
  selectedLine int
  lines        []Match
  current      []Match
}

type Match struct {
  line    string
  matches [][]int
}

var ctx = Ctx{
  "",
  true,
  sync.Mutex{},
  []rune{},
  false,
  0,
  1,
  []Match{},
  nil,
}

var timer *time.Timer

func showHelp() {
  const v = ` 
Usage: percol [options] [FILE]

Options:
  -h, --help            show this help message and exit
  --tty=TTY             path to the TTY (usually, the value of $TTY)
  --rcfile=RCFILE       path to the settings file
  --output-encoding=OUTPUT_ENCODING
                        encoding for output
  --input-encoding=INPUT_ENCODING
                        encoding for input and output (default 'utf8')
  --query=QUERY         pre-input query
  --eager               suppress lazy matching (slower, but display correct
                        candidates count)
  --eval=STRING_TO_EVAL
                        eval given string after loading the rc file
  --prompt=PROMPT       specify prompt (percol.view.PROMPT)
  --right-prompt=RIGHT_PROMPT
                        specify right prompt (percol.view.RPROMPT)
  --match-method=MATCH_METHOD
                        specify matching method for query. ` + "`string`" + ` (default)
                        and ` + "`regex`" + ` are currently supported
  --caret-position=CARET
                        position of the caret (default length of the ` + "`query`" + `)
  --initial-index=INDEX
                        position of the initial index of the selection
                        (numeric, "first" or "last")
  --case-sensitive      whether distinguish the case of query or not
  --reverse             whether reverse the order of candidates or not
  --auto-fail           auto fail if no candidates
  --auto-match          auto matching if only one candidate
  --prompt-top          display prompt top of the screen (default)
  --prompt-bottom       display prompt bottom of the screen
  --result-top-down     display results top down (default)
  --result-bottom-up    display results bottom up instead of top down
  --quote               whether quote the output line
  --peep                exit immediately with doing nothing to cache module
                        files and speed up start-up time
`
  os.Stderr.Write([]byte(v))
}

type CmdOptions struct {
  Help bool   `short:"h" long:"help" description:"show this help message and exit"`
  TTY  string `long:"tty" description:"path to the TTY (usually, the value of $TTY)"`
}

func main() {
  var err error

  // defer は LIFO で動作する。従って本無名関数は一番最初に defer 定義したので一番最後に処理される。
  defer func() {
    // ctx は Ctx構造体のグローバル変数
    if ctx.result != "" {
      // 正常 main 終了時には string 型 ctx.result に文字列が格納されている。
      os.Stdout.WriteString(ctx.result)
    }
  }()

  // CmdOptions 構造体データへのポインタを取得する
  opts := &CmdOptions{}
  // flag パッケージのインスタンスを獲得する。(NewXXXX は C++ のコンストラクタに相当する)
  p := flags.NewParser(opts, flags.PrintErrors)
  args, err := p.Parse() // &opts, os.Args)
  if err != nil {
    panic(err)
    os.Exit(1)
  }

  // -h, --help, を指定した場合にフラグが立つ
  if opts.Help {
    showHelp() // 本ファイルの L48 にてオプション一覧を表示する処理をしている
    os.Exit(1)
  }

  var input *os.File // os.File 型へのポインタ input を用意する

  // receive input from either a file or Stdin
  if len(args) > 0 {
    // C言語でいう FILE ポインタを取得する
    input, err = os.Open(args[0])
    if err != nil {
      os.Exit(1)
    }
  } else if !isTty() {
    input = os.Stdin
  }
  // readline 相当
  rdr := bufio.NewReader(input)
  for {
    // 改行コードを区切りにして文字列を抽出する。
    // (gdb) p line.str とすることで現在バッファに取り込んだ文字列が分かる
    line, err := rdr.ReadString('\n')
    if err != nil {
      break
    }

    // ctx.lines は Match型の配列であり、(gdb の内容より) 次のように格納される。
    // 例: README.md を引数に指定した場合
    // (gdb) p ctx.lines.array[0]
    // $27 = {line = 0xc000076080 "percol\n", matches = {array = 0x0, len = 0, cap = 0}}
    // (gdb) p ctx.lines.array[1]
    // $28 = {line = 0xc000076090 "======\n", matches = {array = 0x0, len = 0, cap = 0}}
    ctx.lines = append(ctx.lines, Match{line, nil})
  }

    // termbox の初期化処理。以下が termbox を使う上での基本的な流れである。
    // 初期化     = termbox.Init
    // ポーリング = termbox.PollEvent
    // 終了       = termbox.Close
    // (ref) 使用例「http://www.nekochango.com/entry/golang/samples#termbox1go」
  err = termbox.Init()
  if err != nil {
    fmt.Fprintln(os.Stderr, err)
    os.Exit(1)
  }
  defer termbox.Close()

  // Esc入力を受け付ける
  termbox.SetInputMode(termbox.InputEsc)
  // 画面表示を更新する
  refreshScreen(0)
  // 絞り込みのためのキー入力を受け付ける。内部で refreshScreen() を呼び出している
  mainLoop()
}

func printTB(x, y int, fg, bg termbox.Attribute, msg string) {
  for len(msg) > 0 {
    c, w := utf8.DecodeRuneInString(msg)
    msg = msg[w:]
    termbox.SetCell(x, y, c, fg, bg)
    x += w
  }
}

func filterLines() {
  ctx.current = []Match{}

  re := regexp.MustCompile(regexp.QuoteMeta(string(ctx.query)))
  for _, line := range ctx.lines {
    ms := re.FindAllStringSubmatchIndex(line.line, 1)
    if ms == nil {
      continue
    }
    ctx.current = append(ctx.current, Match{line.line, ms})
  }
  if len(ctx.current) == 0 {
    ctx.current = nil
  }
}

func refreshScreen(delay time.Duration) {
  if timer == nil {
    timer = time.AfterFunc(delay, func() {
      if ctx.dirty {
        filterLines()
      }
      drawScreen()
      ctx.dirty = false
    })
  } else {
    timer.Reset(delay)
  }
}

func drawScreen() {
  ctx.mutex.Lock()
  defer ctx.mutex.Unlock()

  width, height := termbox.Size()
  _ = width
  termbox.Clear(termbox.ColorDefault, termbox.ColorDefault)

  var targets []Match
  if ctx.current == nil {
    targets = ctx.lines
  } else {
    targets = ctx.current
  }

  printTB(0, 0, termbox.ColorDefault, termbox.ColorDefault, "QUERY>")
  printTB(8, 0, termbox.ColorDefault, termbox.ColorDefault, string(ctx.query))
  for n := 1; n+2 < height; n++ {
    if n-1 >= len(targets) {
      break
    }

    fgAttr := termbox.ColorDefault
    bgAttr := termbox.ColorDefault
    if n == ctx.selectedLine {
      fgAttr = termbox.AttrUnderline
      bgAttr = termbox.ColorMagenta
    }

    target := targets[n-1]
    line := target.line
    if target.matches == nil {
      printTB(0, n, fgAttr, bgAttr, line)
    } else {
      prev := 0
      for _, m := range target.matches {
        if m[0] > prev {
          printTB(prev, n, fgAttr, bgAttr, line[prev:m[0]])
          prev += runewidth.StringWidth(line[prev:m[0]])
        }
        printTB(prev, n, fgAttr|termbox.ColorCyan, bgAttr, line[m[0]:m[1]])
        prev += runewidth.StringWidth(line[m[0]:m[1]])
      }

      m := target.matches[len(target.matches)-1]
      if m[0] > prev {
        printTB(prev, n, fgAttr|termbox.ColorCyan, bgAttr, line[m[0]:m[1]])
      } else if len(line) > m[1] {
        printTB(prev, n, fgAttr, bgAttr, line[m[1]:len(line)])
      }
    }
  }
  termbox.Flush()
}

func mainLoop() {
    // ポーリングをする
  for ctx.loop {
    ev := termbox.PollEvent()
    if ev.Type == termbox.EventError {
      //update = false
    } else if ev.Type == termbox.EventKey {
            // 入力されたキーに応じて処理を振り分ける
      handleKeyEvent(ev)
    }
  }
}

func handleKeyEvent(ev termbox.Event) {
  update := true
  switch ev.Key {
    // Esc 入力時。終了する。
  case termbox.KeyEsc:
    termbox.Close()
    os.Exit(1)
    /*
      case termbox.KeyHome, termbox.KeyCtrlA:
        cursor_x = 0
      case termbox.KeyEnd, termbox.KeyCtrlE:
        cursor_x = len(input)
    */
    // Enter 押下時。現在選択中のバッファを選択する。
  case termbox.KeyEnter:
    if len(ctx.current) == 1 {
      ctx.result = ctx.current[0].line
    }
    ctx.loop = false
    /*
      case termbox.KeyArrowLeft:
        if cursor_x > 0 {
          cursor_x--
        }
      case termbox.KeyArrowRight:
        if cursor_x < len([]rune(input)) {
          cursor_x++
        }
    */
    // カーソルの上キーが入力されたら、絞り込み候補配列のインデックスを一つ前に戻す
  case termbox.KeyArrowUp, termbox.KeyCtrlK:
    ctx.selectedLine--
    /*
      if cursor_y < len(current)-1 {
        if cursor_y < height-4 {
          cursor_y++
        }
      }
    */
    // カーソルの上キーが入力されたら、絞り込み候補配列のインデックスを一つ先に戻す
  case termbox.KeyArrowDown, termbox.KeyCtrlJ:
    ctx.selectedLine++
    /*
        if cursor_y > 0 {
          cursor_y--
        }
      case termbox.KeyCtrlO:
        if cursor_y >= 0 && cursor_y < len(current) {
          *edit = true
          break loop
        }
      case termbox.KeyCtrlI:
        heading = !heading
      case termbox.KeyCtrlL:
        update = true
      case termbox.KeyCtrlU:
        cursor_x = 0
        input = []rune{}
        update = true
    */
    // Backspace キーであれば、 ctx.query 配列のポイント番地を一つ前にする
  case termbox.KeyBackspace, termbox.KeyBackspace2:
    if len(ctx.query) == 0 {
      update = false
    } else {
      ctx.query = ctx.query[:len(ctx.query)-1]
      ctx.dirty = true
    }
  default:
    if ev.Key == termbox.KeySpace {
      ev.Ch = ' '
    }

        // 入力された文字列を結合し、ctx.query に格納する
    if ev.Ch > 0 {
      ctx.query = append(ctx.query, ev.Ch)
      ctx.dirty = true
    }
  }

  if update {
    refreshScreen(10 * time.Millisecond)
  }
}

tty_linux.go

// +build linux

package main

import (
  "os"
  "syscall"
  "unsafe"
)

func isTty() bool {
  var termios syscall.Termios
  _, _, err := syscall.Syscall6(syscall.SYS_IOCTL, os.Stdin.Fd(), uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(&termios)), 0, 0, 0)
  return err == 0
}

func ttyReady() error {
  return nil
}

func ttyTerm() {
}