Vim
Go
Go3Day 9

GoでVimを開いて編集内容をパースする方法

動機

私の現在働いている会社では、GitLabを使用して開発を行っています。
その中でIssueやMRを作成するたびにWeb画面がもっさり表示され、本来するべきである
書く。という作業にたどり着くまでに非常に多くのステップや時間が費やされることに不満を感じていました。

私は現在labコマンドというGitLabのCLIクライアントを作成しており、
上記不満を解消するため新規投稿コマンドを実行するとテキストエディタを開いてIssueやMR内容を記載し、
ファイルを保存することで、編集内容がそのままリクエストされるという機能を思いつきました。

要するにgit commitした瞬間に発動するアレです。

アレな動作の動画

本日はGoでアレな機能を作る過程でできたマイクロコードを用いて、アレの実現方法をご紹介致します。

なおCLIで細々オプションを指定して書いていっても良いのですが、
改行が含まれたりする長文を考えながら記載する場合、
ワンラインで編集するのはなかなかにストレスが貯まる作業かと思います。
今回の目的以外にも、幅広い用途で使えるのではと思います。CLIの梯子の力...ヤバイ。

なお今回使用するコードはhubコマンドからちょろっと拝借したコードを分析するため、
バリバリ構造化された内容を解きほぐして1ファイルに収めたものとなっております。
なので、こんな泥臭いコードでなくてもっと構造化されたものを見たいという方は、
hubコマンドの以下ファイルを眺めてみると良いかと思います。

https://github.com/github/hub/blob/master/github/editor.go

ゴール

最終的なゴールは以下の機能の実装です。

  1. コマンドを実行するとVimが起動する
  2. 起動したVimには、どのように記載すれば目的どおりの編集が可能であるかのガイドラインをコメントとして表示する
  3. Vimで編集/保存した内容をGoで読み取りし目的となる結果を得る

まずは結論から

うだうだ言ってないでコード出せ。という方は以下のリポジトリをご参照ください。

lighttiger2505/launch-vim

実装内容

幾つかのステップに分けてご紹介していこうと思います。

ステップ1: とりあえずGoからVimを開く

とりあえずVimを開かなければお話になりません。
何も考えずにos/execパッケージでVimを実行してみましょう。

package main

import (
    "fmt"
    "os"
    "os/exec"
)

func main() {
    exitStatus := launchVim()
    os.Exit(exitStatus)
}

func launchVim() int {
    // Open text editor
    err := openEditor("vim")
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed open text editor. %s\n", err.Error()))
        return 1
    }
    return 0
}

func openEditor(program string) error {
    c := exec.Command(program)
    c.Stdin = os.Stdin
    c.Stdout = os.Stdout
    c.Stderr = os.Stderr
    return c.Run()
}

実行してみると...やったーVimが開いたよー。

とりあえずGoからVimを開く動画

ステップ2: tmpファイルを用意してVimで編集する

ステップ1のコードを見て気がついた人もいらっしゃるでしょうが
なんと。ステップ1のままではVimが起動するだけで編集した内容を受け取ることができません。
これではまるで意味がありません。

なので次は以下の機能を実装します。

  • プログラム起動時にVimで開くためのtmpファイルを作成する(今回は~/tmpにご用意しました。)
  • Vimで該当のファイルを開く
  • 編集後のファイルを読み取りして、内容を取得する
package main

import (
    "fmt"
    "io/ioutil"
    "os"
    "os/exec"
    "path/filepath"
    "runtime"
)

func main() {
    exitStatus := launchVim()
    os.Exit(exitStatus)
}

func launchVim() int {
    // Make temp editing file
    fPath := getFilePath("ISSUE")
    err := makeFile(fPath)
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed make edit file. %s\n", err.Error()))
        return 1
    }
    defer deleteFile(fPath)

    // Open text editor
    err = openEditor("vim", fPath)
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed open text editor. %s\n", err.Error()))
        return 1
    }

    // Read edit file
    content, err := ioutil.ReadFile(fPath)
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed read content. %s\n", err.Error()))
        return 1
    }

    fmt.Fprint(os.Stdout, string(content))

    return 0
}

func getFilePath(about string) string {
    home := os.Getenv("HOME")
    if home == "" && runtime.GOOS == "windows" {
        home = os.Getenv("APPDATA")
    }
    fname := filepath.Join(home, "tmp", fmt.Sprintf("%s_EDITMSG", about))
    return fname
}

func makeFile(fPath string) (err error) {
    if !isFileExist(fPath) {
        err = ioutil.WriteFile(fPath, []byte(""), 0644)
        if err != nil {
            return
        }
    }
    return
}

func isFileExist(fPath string) bool {
    _, err := os.Stat(fPath)
    return err == nil || !os.IsNotExist(err)
}

func deleteFile(fPath string) error {
    return os.Remove(fPath)
}

func openEditor(program string, args ...string) error {
    c := exec.Command(program, args...)
    c.Stdin = os.Stdin
    c.Stdout = os.Stdout
    c.Stderr = os.Stderr
    return c.Run()
}

実行してみると...こいつ。読めるぞっ

tmpファイルを用意してVimで編集する動画

というわけでなんとなくそれっぽい感じになってきました。

ステップ3: コメントとかでガイドしたとおりVimで編集されたファイルをパースする

ステップ2ではプログラムにおけるI/Oが出来上がりました。
あとプログラムに必要な要素は何でしょうか?

そう。インプットされた内容を加工し、適切に整形した内容をアウトプットすること。
要するにデータのフィルターや加工です。
これができればプログラムはもはや完成したといっても過言ではないでしょう。

今回はGitLabのIssueやMRが書きたいので、その目的に則した内容にします。
なので次は以下の機能を実装します。

  • ファイル作成時にガイドラインとして活用するコメント行をファイルに書き込む
  • Vimをファイルタイプgitcommitで起動し、コメント行を解釈できるようにする
  • 編集後のファイルからコメント行を除外し、タイトルと内容を個別に取得する
package main

import (
    "bufio"
    "bytes"
    "fmt"
    "io"
    "io/ioutil"
    "os"
    "os/exec"
    "path/filepath"
    "regexp"
    "runtime"
    "strings"
)

func main() {
    exitStatus := launchVim()
    os.Exit(exitStatus)
}

func launchVim() int {
    message := `# |<----  Opened the file with your favorite editor. The first block of text is the title.  ---->|


# |<----  The following blocks are explanations  ---->|

`

    // Make temp editing file
    fPath := getFilePath("ISSUE")
    err := makeFile(fPath, message)
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed make edit file. %s\n", err.Error()))
        return 1
    }
    defer deleteFile(fPath)

    // Open text editor
    err = openEditor("vim", "--cmd", "set ft=gitcommit tw=0 wrap lbr", fPath)
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed open text editor. %s\n", err.Error()))
        return 1
    }

    // Read edit file
    content, err := ioutil.ReadFile(fPath)
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed read content. %s\n", err.Error()))
        return 1
    }

    // Parce read content
    reader := bytes.NewReader(content)
    title, body, err := perseTitleAndBody(reader, "#")
    if err != nil {
        fmt.Fprint(os.Stdout, fmt.Sprintf("failed parce content. %s\n", err.Error()))
        return 1
    }

    fmt.Fprint(os.Stdout, fmt.Sprintf("title=%s, body=%s\n", title, body))

    return 0
}

func getFilePath(about string) string {
    home := os.Getenv("HOME")
    if home == "" && runtime.GOOS == "windows" {
        home = os.Getenv("APPDATA")
    }
    fname := filepath.Join(home, "tmp", fmt.Sprintf("%s_EDITMSG", about))
    return fname
}

func makeFile(fPath, message string) (err error) {
    // only write message if file doesn't exist
    if !isFileExist(fPath) && message != "" {
        err = ioutil.WriteFile(fPath, []byte(message), 0644)
        if err != nil {
            return
        }
    }
    return
}

func isFileExist(fPath string) bool {
    _, err := os.Stat(fPath)
    return err == nil || !os.IsNotExist(err)
}

func deleteFile(fPath string) error {
    return os.Remove(fPath)
}

func openEditor(program string, args ...string) error {
    c := exec.Command(program, args...)
    c.Stdin = os.Stdin
    c.Stdout = os.Stdout
    c.Stderr = os.Stderr
    return c.Run()
}

func perseTitleAndBody(reader io.Reader, cs string) (title, body string, err error) {
    var titleParts, bodyParts []string

    r := regexp.MustCompile("\\S")
    scanner := bufio.NewScanner(reader)
    for scanner.Scan() {
        line := scanner.Text()
        if strings.HasPrefix(line, cs) {
            continue
        }

        if len(bodyParts) == 0 && r.MatchString(line) {
            titleParts = append(titleParts, line)
        } else {
            bodyParts = append(bodyParts, line)
        }
    }

    if err = scanner.Err(); err != nil {
        return
    }

    title = strings.Join(titleParts, " ")
    title = strings.TrimSpace(title)

    body = strings.Join(bodyParts, "\n")
    body = strings.TrimSpace(body)

    return
}

実行してみると...これや。これが欲しかったんや!

コメントとかでガイドしたとおりVimで編集されたファイルをパースする動画

最後に

もちろん起動方法やパース方法を工夫することでVim以外のテキストエディタにも適用可能です。
Emacsな貴方もnanoな貴方も、そしてviなあなたにも。

しかし、コードが無駄に長くなってしまった。精進せねば。

あと、gifアニメを簡単につくれるツールを誰かおしえて。