Help us understand the problem. What is going on with this article?

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

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

以前書いたこちらの記事でも軽く紹介しています。

機能

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

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

詳しくはREADMEを参照してください。

実装について

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

ファイル一覧

image.png

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

// 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といライブラリを使って読みやすい形式に変換しています。
    こちらのライブラリはサイズ以外にもいろんな情報を人間が読みやすい形に変換したりパースできます。詳細はREADMEを参照してください。

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

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

// 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ならわかるシステムプログラミングを読んでみてください。

ファイル、ディレクトリの操作

ファイル、ディレクトリのリネーム、新規作成などは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というライブラリをを使いました。

ブックマーク

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

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

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

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というORMを使っています。標準のパッケージでも使おうかなと思いましたが、慣れているライブラリに頼りました。ちゃんとdatabase/sqlを勉強したい…

プレビュー

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

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

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

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

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

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パッケージを使って実行します。

// 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

コマンド実行

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

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

まとめ

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

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした