Vim上でリアルタイムにMarkdownをプレビューするプラグインを作った

4 min読了の目安(約4300字TECH技術記事

VimでMarkdownを書くために様々なプラグインが存在しています。

その中でも私は、preview-markdown.vimを使用していました。

このプラグインは、Vim上でMarkdownを表示することができる、とても便利なプラグインです。

ただ、リアルタイムプレビューやカーソル追従の機能が欲しくなってきました。

いい機会なので、Vim script入門も兼ねて自作Vim Pluginに挑戦しました。

本記事では、初めてのVim scriptで作成した自作Plugin及び使用したVim Scriptの機能について紹介したいと思います。

作ったやつ

screenshot

https://github.com/tmrekk121/prev-md.vim

仕組み

Markdownの表示には、glowというterminalベースのMarkdown readerを使用しています。

https://github.com/charmbracelet/glow

glowについて

glowでは、-pオプションを使用してpagerを指定して表示することができます。
今回は、pagerにlessコマンドの-Rオプションを指定してMarkdownの表示を行なっています。

export PAGER='less -R'

result

Vim上でMarkdownを表示する

Vim上でglowを動作させるために、今回はterm_start()を使用しました。

https://vim-jp.org/vimdoc-ja/terminal.html#term_start()

term_start()では、端末ウィンドウを開き、引数に指定したコマンドを実行させることができます。

また、term_start()で起動した端末ウィンドウに追加で命令を送ることもできます。

これには、term_sendkeys()が使えます。term_sendkeys()にterm_start()で起動した端末ウィンドウのバッファの番号を指定することで命令を送ることができます。

今回は、環境変数にpagerを指定する必要があったのでterm_start()から/bin/shを起動し、term_sendkeys()を使って、環境変数の設定を行いました。

  " write tmp file
  let s:tmp = tempname() . '.md'
  call writefile(getline(1, "$"), s:tmp)

  let s:option = {
    \ 'vertical': 1,
    \ 'exit_cb': function('s:remove_tmp', [s:tmp]),
    \ 'term_finish': 'open',
    \ 'term_opencmd': 'vnew|b %d',
    \ 'term_kill': 'kill',
    \ }
  " term_start()は端末ウィンドウのバッファ番号を返す
  let s:prev_buf_nr = term_start('/bin/sh', s:option)
  call term_sendkeys(s:prev_buf_nr, "export PAGER=\"less -R\" \<CR>")
  let glow_cmd = printf("glow %s -p \<CR>", s:tmp)
  call term_sendkeys(s:prev_buf_nr, glow_cmd)

上記を関数化して実行すると、以下のようになります。
result

リアルタイムプレビューに対応する

今回作成するプラグインでは、このglowコマンドをmdファイルの編集中に実行することで、リアルタイムのようなプレビューを実現しました。

Markdownファイルの編集中に、glowコマンドを実行するためにtimer_start()を使用しました。

https://vim-jp.org/vimdoc-ja/eval.html#timer_start()

timer_start()では、指定した時間後に指定したコマンドを実行することができます。

また引数に回数を指定することもでき、今回は-1を指定し無限ループにしています。

この機能を使うことで指定した時間の間隔でglowコマンドを実行し、リアルタイムプレビューを実現しています。

let g:auto_prev_time = 5000
let timer = timer_start(g:auto_prev_time, 'GlowExec', {'repeat': -1})

function! GlowExec(timer) abort
  let glow_cmd = printf("glow %s -p \<CR>", s:tmp)

  " exit glow window
  call term_sendkeys(s:prev_buf_nr, 'q')
  call term_sendkeys(s:prev_buf_nr, "\<c-l>")
  call term_wait(s:prev_buf_nr)

  call term_sendkeys(s:prev_buf_nr, glow_cmd)
  call term_wait(s:prev_buf_nr)

endfunction

上記を実行すると、以下のようになります。

screenshot

カーソル追従に対応する(未実装)

編集中のMarkdownのカーソル位置に合わせて端末ウィンドウの表示も同期させたいと思い、いくつかの方法で実装してみました。

ですが、結論から言うとまだ実用的な実装はできていません...

今回、試した方法は、

  • glowコマンド実行後にMarkdownウィンドウのカーソル列の文字を検索する
  • glowコマンド実行時にpagerオプションで表示位置をカーソル位置に合わせる

最初の案では、glowコマンドの-pオプションでの起動時にlessコマンドと同等の検索が行うことができる機能を利用しました。

しかし、term_sendkeys()を使ってカーソル列の文字の送信を行いましたが、glowコマンド実行中に上手く命令を送ることができませんでした。

  " don't work
  call term_sendkeys(s:prev_buf_nr, "/<カーソル行の文字> \<CR>")

したがって、一旦保留としました。

次の案では、lessコマンドの-pオプション及び、+オプションを使用して起動時の表示位置の指定することでカーソル追従を実現しようとしました。

しかし、lessコマンドの-pオプション及び、+オプションでは空白を含む文字列の検索が行えず、実用的に使えるレベルにはなりませんでした。(空白を含む文字列の指定方法があれば教えていただきたい...)

export PAGER="less -R+/<カーソル行の文字>"

苦戦した点

  • bufferとwindow

timer_start()で実行した関数では、編集中のMarkdownファイルに変更がない場合には、いちいちglowコマンドを実行したくありません。

そこで、bufferの変更を検知するために'modified'フラグを監視してbufferに変更があった場合のみglowコマンドを実行するようにしました。

しかし、bufferとwindowの違いすらわからない状態での開発だったので色々と試行錯誤しました。

Vimのbufferとwindwoは1:1の関係になく、

  • bufferはvimがファイルを読み込んだ後にファイル内容をコピーするメモリ領域
  • windowは、bufferの内容を表示するための領域

です。

この関係がわかっていないために、上手くbufferやwindowを扱うことができませんでした。

最後に

今回、初めてのVim Script・Vim Pluginでしたが、とても面白かったです。

カーソル追従の実現はまだできていないものの、自分にとってとても実用的なプラグインを作成することができました。

また、普段使っているVim Pluginの実装を見たりすることが今まで全くなかったので実際に読んでみてとても勉強になりました。

最後に、興味がある方は、ぜひ試してもらえると嬉しいです。

screenshot

https://github.com/tmrekk121/prev-md.vim

また、アイデアやバグがありましたプルリクお待ちしています。