Goで便利なTUIファイラーを作ったのでその実装の話

こんにちわ。ゴリラです。
最近GoでシンプルなTUIファイラーを作ったので、軽く紹介して実装の話をしていきます。

<a href="https://github.com/skanehira/ff"><img src="https://github-link-card.s3.ap-northeast-1.amazonaws.com/skanehira/ff.png" width="460px"></a>

以前書いた[こちらの記事](https://qiita.com/gorilla0513/items/2bb416e371c43d6d88fc)でも軽く紹介しています。

# 機能
ざっくり以下の機能を持っています。

- ディレクトリ、ファイルの作成、削除、コピー、リネーム、プレビュー
- ディレクトリのブックマーク

詳しくは[README](https://github.com/skanehira/ff/blob/master/README.md)を参照してください。

# 実装について
筆者はTUIツールを作るときに、いつも[tview](https://github.com/rivo/tview)というライブラリを使っています。
`tview`の基本的な使い方については別途記事を書く予定なので、本記事ではそれ以外でいくつかの機能の実装について解説していきます。

## ファイル一覧
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/dc83c0fd-5df9-bcfd-46e4-6623e74437b1.png)

`ioutil.ReadDir(dirname string) ([]os.FileInfo, error)`を使ってパス配下のディレクトリ・ファイル情報を取得しています。
戻り値の`os.FileInfo`は以下のようにインターフェイスになっていて、ディレクトリ及びファイルの情報を取得できます。

```go
// A FileInfo describes a file and is returned by Stat and Lstat.
type FileInfo interface {
	Name() string       // base name of the file
	Size() int64        // length in bytes for regular files; system-dependent for others
	Mode() FileMode     // file mode bits
	ModTime() time.Time // modification time
	IsDir() bool        // abbreviation for Mode().IsDir()
	Sys() interface{}   // underlying data source (can return nil)
}
```

`FileInfo`のメソッドについて解説していきます。

- `Size()`
`Size()`で取得できるのはバイト数になっていますが、そのままだと分かりづらいです。
そこで、サイズを人間が読みやすい形に変換する[dustin/go-humanize](https://github.com/dustin/go-humanize)といライブラリを使って読みやすい形式に変換しています。
こちらのライブラリはサイズ以外にもいろんな情報を人間が読みやすい形に変換したりパースできます。詳細はREADMEを参照してください。

- `Mode()`
`Mode()`はファイルモードの情報`os.FileMode`を返しますが、そのまま出力すると上位1bitしか表示されません。
ディレクトリなら`d--------`が表示されます。`os.FileMode`には`String()`が生えているので、それを使うと`ls`コマンドでみるような形式`drwxr-xr-x`が表示されます。

- `Owner`と`Group`
`Sys()`を`syscall.Stat_t`に型アサーションして`Owner`と`Group`を取ります。`Sys()`の実態はOSによって異なります。Windowsの場合は`syscall.Win32FileAttributeData`型にアサーションします。
`Stat_t`からは`Uid`と`Gid`を取得できるので、それをもとに`os/user`パッケージの`LookupId`と`LookupGroupId`を使用してユーザIDとグループIDから名前を取得します。

```go
// get file owner, group
if stat, ok := file.Sys().(*syscall.Stat_t); ok {
	uid := strconv.Itoa(int(stat.Uid))
	u, err := user.LookupId(uid)
	if err != nil {
		owner = uid
	} else {
		owner = u.Username
	}
	gid := strconv.Itoa(int(stat.Gid))
	g, err := user.LookupGroupId(gid)
	if err != nil {
		group = gid
	} else {
		group = g.Name
	}
}
```

もう少し詳しく知りたい方は[Goならわかるシステムプログラミング](https://ascii.jp/elem/000/001/423/1423022/)を読んでみてください。

## ファイル、ディレクトリの操作
ファイル、ディレクトリのリネーム、新規作成などは`os`パッケージの関数を使用すれば簡単です。

| 操作               | 関数                              |
|--------------------|-----------------------------------|
| ファイル作成       | `os.Create(name string) (*os.File, error)`|
| ディレクトリ作成   | `os.Mkdir(name string, perm os.FileMode) error` |
| ファイルの削除     | `os.Remove(name string) error`    | 
| ディレクトリの削除 | `os.RemoveAll(path string) error` |
| リネーム           | `os.Rename(oldpath, newpath string) error`   |

1つ問題なのはファイル、ディレクトリのコピー関数が用意されていないことです。そもそもコピーのシステムコールがないからだと思います。
で、`io.Copy`を使えば中身のコピーはできますが、属性のコピーまではできないです。面倒だったので属性のコピーも含めてやってくれる[otiai10/copy](https://github.com/otiai10/copy)というライブラリをを使いました。

## ブックマーク
`ff`は`b`でディレクトリをブックマークしておくことができます。ブックマークはデフォルトでは無効になっているので、
以下のように`config.yaml`に設定を追記する必要があります。

```yaml
bookmark:
  enable: true
  file: $XDG_CONFIG_HOME/ff/bookmark.db
```

このブックマークは`sqlite3`のDBを使っています。`ff`を起動するときに`file`に設定したファイルをDBファイルとして使います。このファイルはなくても自動で作成するようになっています。以下の実装はブックマークの初期化処理になっています。

```go
file = os.ExpandEnv(file)
// if db file is not exist, create new db file
if !system.IsExist(file) {
	if _, err := os.Create(file); err != nil {
		log.Println(err)
		// if can't create new file, use in memory db
		file = ":memory:"
	}
}

db, err := gorm.Open("sqlite3", file)
if err != nil {
	log.Println(err)
	return nil, err
}
db.SetLogger(DBLogger{})
db.LogMode(true)

if err := db.AutoMigrate(&Bookmark{}).Error; err != nil {
	log.Println(err)
	return nil, err
}
```

ファイルを作成できないときは`:memory:`で、インメモリにしています。メモリ上に一時的にDBを作成して使います。
正直インメモリのメリットは皆無なので、ここは改善しようかなと思っています。

ちなみに、`sqlite3`は面白いことに、空のファイルをDBとして使うことができます。
モックアプリを作る時にとても便利だなと思いました。

DBの操作は[GORM](https://github.com/jinzhu/gorm)というORMを使っています。標準のパッケージでも使おうかなと思いましたが、慣れているライブラリに頼りました。ちゃんと`database/sql`を勉強したい…

## プレビュー
`ff`は引数`-preview`もしくは以下の設定を`config.yaml`に追加することでプレビュー機能を有効にすることができます。

```yaml
preview:
  enable: false
  # preview colorscheme. you can use colorscheme following
  # https://xyproto.github.io/splash/docs/all.html
  colorscheme: monokai
```

プレビューではファイル、ディレクトリの中身をプレビューできます。ファイルはシンタックスハイライトされます。
ただ、大きすぎるファイルを開くと固まってしまうのでプレビューしないようしています。

シンタックスハイライトは[alecthomas/chroma](github.com/alecthomas/chroma)というライブラリを使っています。
ライブラリの使い方についてはREADMEを参照してください。
GoでソースコードをANSIエスケープシーケンスでハイライトしたいときに便利です。

具体的な実装は次のようになります。`chroma`はANSIの文字列を返しますが、そのまま`tview`に出力しても色がつかず、ANSIもそのまま出力されます。`tview`でも色が表示できるように`tview.TranslateANSI()`を使う必要があります。

```go
func (p *Preview) Highlight(entry *Entry) string {
	// Determine lexer.
	b, err := ioutil.ReadFile(entry.PathName)
	if err != nil {
		log.Println(err)
		return err.Error()
	}

	ext := filepath.Ext(entry.Name)
	l := lexers.Get(ext)
	if l == nil {
		l = lexers.Analyse(string(b))
	}
	if l == nil {
		l = lexers.Fallback
	}
	l = chroma.Coalesce(l)

	// Determine formatter.
	// TODO check terminal
	f := formatters.Get("terminal256")
	if f == nil {
		f = formatters.Fallback
	}

	// Determine style.
	s := styles.Get(p.colorscheme)
	if s == nil {
		s = styles.Fallback
	}

	it, err := l.Tokenise(nil, string(b))
	if err != nil {
		log.Println(err)
		return err.Error()
	}

	var buf bytes.Buffer

	if err := f.Format(&buf, s, it); err != nil {
		log.Println(err)
		return err.Error()
	}

	return tview.TranslateANSI(buf.String())
}
```



## ファイル編集
`e`で`$EDITOR`を使ってファイルを編集できますが、`Vim`の場合は少し面白い動きをします。
`Vim`にはターミナルの機能があり、Vim上でターミナルを開きコマンドを実行することができます。
通常のターミナルなのでその中で更にVimを立ち上げることもできます。つまり`Vim` in `Vim`になってしまうんです。
`ff`をVimのプラグインとして使う時、選択したファイルを起動中の`Vim`で開きたいと思う方が多いかと思います。

そこで、Vimの`terminal api`機能を使って、選択したファイルを実行しているVimで開くようにします。
詳細は省きますが、`Vim`のターミナル上で`sh -c 'echo "\x1b]51;[\"drop\",\"main.go\"]\x07"'`を実行すると`main.go`が開きます。便利ですね。
Goの場合は`exec`パッケージを使って実行します。

```go
// if `ff` running in vim terminal, use running vim
if os.Getenv("VIM_TERMINAL") != "" && editor == "vim" {
	cmd := exec.Command("sh", "-c", fmt.Sprintf(`echo '\x1b]51;["drop","%s"]\x07'`, file))
	cmd.Stdout = os.Stdout
	return cmd.Run()
}
```

何を言っているのかよくわからない方もいると思いますが、以下のgifを見てみてください。
それでもよくわからんって方はぜひ試してみてください。

![ff-in-vim-edit.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/66178/d3016c93-e003-7e0c-2b76-d0c428204ff5.gif)

## コマンド実行
`ff`は任意の外部コマンドを実行する事ができます。内部では単に`exec.Command`を使っているだけです。

```go
text := cmdline.GetText()
if text == "" {
	return
}

cmdText := strings.Split(text, " ")

// expand environments
for i, c := range cmdText[1:] {
	cmdText[i+1] = os.ExpandEnv(c)
}

cmd := exec.Command(cmdText[0], cmdText[1:]...)

buf := bytes.Buffer{}
cmd.Stderr = &buf
cmd.Stdout = &buf
if err := cmd.Run(); err == nil {
	cmdline.SetText("")
}

result := strings.TrimRight(buf.String(), "\n")
if result != "" {
	gui.Message(result, cmdline)
}
```

例えば`mkdir -p a/b/c`というようにディレクトリ階層を作りたい場合はコマンドのが早いです。そういった柔軟な使い方が出来るようにしたいためコマンド機能を実装しましたが、1点問題があります。
それは標準入力、出力を使用できないことです。
それにより`Vim`といった標準入力を使うようなコマンドを実行すると`ff`が固ります。
実装側で防ぐ方法があるなら良いのですが、今のところベストな対処法は思いつかないです。

一応READMEに記述していますが、ユーザに気をつけてねっていうような機能は果たしてどうなんだろう？というモヤモヤ感は自分の中にあるので将来的にはこの機能を廃止するかもしれないです。

# まとめ
ざっくりですが、実装について軽く解説しました。ファイラーを作ることで、ファイルシステムまわりについて知見を得られたので、作ってよかったと感じています。
ただ、ファイルシステムの深いところは全然わからないので（例えばなぜコピーのシステムコールがないのかなど）、そこは引き続き勉強していこうと思います。