動機
私の現在働いている会社では、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
ゴール
最終的なゴールは以下の機能の実装です。
- コマンドを実行するとVimが起動する
- 起動したVimには、どのように記載すれば目的どおりの編集が可能であるかのガイドラインをコメントとして表示する
- Vimで編集/保存した内容をGoで読み取りし目的となる結果を得る
まずは結論から
うだうだ言ってないでコード出せ。という方は以下のリポジトリをご参照ください。
実装内容
幾つかのステップに分けてご紹介していこうと思います。
ステップ1: とりあえずGoからVimを開く
とりあえずVimを開かなければお話になりません。
何も考えずにos/exec
パッケージでVimを実行してみましょう。
packagemainimport("fmt""os""os/exec")funcmain(){exitStatus:=launchVim()os.Exit(exitStatus)}funclaunchVim()int{// Open text editorerr:=openEditor("vim")iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed open text editor. %s\n",err.Error()))return1}return0}funcopenEditor(programstring)error{c:=exec.Command(program)c.Stdin=os.Stdinc.Stdout=os.Stdoutc.Stderr=os.Stderrreturnc.Run()}
実行してみると...やったーVimが開いたよー。
ステップ2: tmpファイルを用意してVimで編集する
ステップ1のコードを見て気がついた人もいらっしゃるでしょうが
なんと。ステップ1のままではVimが起動するだけで編集した内容を受け取ることができません。
これではまるで意味がありません。
なので次は以下の機能を実装します。
- プログラム起動時にVimで開くためのtmpファイルを作成する(今回は~/tmpにご用意しました。)
- Vimで該当のファイルを開く
- 編集後のファイルを読み取りして、内容を取得する
packagemainimport("fmt""io/ioutil""os""os/exec""path/filepath""runtime")funcmain(){exitStatus:=launchVim()os.Exit(exitStatus)}funclaunchVim()int{// Make temp editing filefPath:=getFilePath("ISSUE")err:=makeFile(fPath)iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed make edit file. %s\n",err.Error()))return1}deferdeleteFile(fPath)// Open text editorerr=openEditor("vim",fPath)iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed open text editor. %s\n",err.Error()))return1}// Read edit filecontent,err:=ioutil.ReadFile(fPath)iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed read content. %s\n",err.Error()))return1}fmt.Fprint(os.Stdout,string(content))return0}funcgetFilePath(aboutstring)string{home:=os.Getenv("HOME")ifhome==""&&runtime.GOOS=="windows"{home=os.Getenv("APPDATA")}fname:=filepath.Join(home,"tmp",fmt.Sprintf("%s_EDITMSG",about))returnfname}funcmakeFile(fPathstring)(errerror){if!isFileExist(fPath){err=ioutil.WriteFile(fPath,[]byte(""),0644)iferr!=nil{return}}return}funcisFileExist(fPathstring)bool{_,err:=os.Stat(fPath)returnerr==nil||!os.IsNotExist(err)}funcdeleteFile(fPathstring)error{returnos.Remove(fPath)}funcopenEditor(programstring,args...string)error{c:=exec.Command(program,args...)c.Stdin=os.Stdinc.Stdout=os.Stdoutc.Stderr=os.Stderrreturnc.Run()}
実行してみると...こいつ。読めるぞっ
というわけでなんとなくそれっぽい感じになってきました。
ステップ3: コメントとかでガイドしたとおりVimで編集されたファイルをパースする
ステップ2ではプログラムにおけるI/Oが出来上がりました。
あとプログラムに必要な要素は何でしょうか?
そう。インプットされた内容を加工し、適切に整形した内容をアウトプットすること。
要するにデータのフィルターや加工です。
これができればプログラムはもはや完成したといっても過言ではないでしょう。
今回はGitLabのIssueやMRが書きたいので、その目的に則した内容にします。
なので次は以下の機能を実装します。
- ファイル作成時にガイドラインとして活用するコメント行をファイルに書き込む
- Vimをファイルタイプ
gitcommit
で起動し、コメント行を解釈できるようにする - 編集後のファイルからコメント行を除外し、タイトルと内容を個別に取得する
packagemainimport("bufio""bytes""fmt""io""io/ioutil""os""os/exec""path/filepath""regexp""runtime""strings")funcmain(){exitStatus:=launchVim()os.Exit(exitStatus)}funclaunchVim()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 filefPath:=getFilePath("ISSUE")err:=makeFile(fPath,message)iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed make edit file. %s\n",err.Error()))return1}deferdeleteFile(fPath)// Open text editorerr=openEditor("vim","--cmd","set ft=gitcommit tw=0 wrap lbr",fPath)iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed open text editor. %s\n",err.Error()))return1}// Read edit filecontent,err:=ioutil.ReadFile(fPath)iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed read content. %s\n",err.Error()))return1}// Parce read contentreader:=bytes.NewReader(content)title,body,err:=perseTitleAndBody(reader,"#")iferr!=nil{fmt.Fprint(os.Stdout,fmt.Sprintf("failed parce content. %s\n",err.Error()))return1}fmt.Fprint(os.Stdout,fmt.Sprintf("title=%s, body=%s\n",title,body))return0}funcgetFilePath(aboutstring)string{home:=os.Getenv("HOME")ifhome==""&&runtime.GOOS=="windows"{home=os.Getenv("APPDATA")}fname:=filepath.Join(home,"tmp",fmt.Sprintf("%s_EDITMSG",about))returnfname}funcmakeFile(fPath,messagestring)(errerror){// only write message if file doesn't existif!isFileExist(fPath)&&message!=""{err=ioutil.WriteFile(fPath,[]byte(message),0644)iferr!=nil{return}}return}funcisFileExist(fPathstring)bool{_,err:=os.Stat(fPath)returnerr==nil||!os.IsNotExist(err)}funcdeleteFile(fPathstring)error{returnos.Remove(fPath)}funcopenEditor(programstring,args...string)error{c:=exec.Command(program,args...)c.Stdin=os.Stdinc.Stdout=os.Stdoutc.Stderr=os.Stderrreturnc.Run()}funcperseTitleAndBody(readerio.Reader,csstring)(title,bodystring,errerror){vartitleParts,bodyParts[]stringr:=regexp.MustCompile("\\S")scanner:=bufio.NewScanner(reader)forscanner.Scan(){line:=scanner.Text()ifstrings.HasPrefix(line,cs){continue}iflen(bodyParts)==0&&r.MatchString(line){titleParts=append(titleParts,line)}else{bodyParts=append(bodyParts,line)}}iferr=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アニメを簡単につくれるツールを誰かおしえて。