Neovim のフロートウィンドウ機能を使って git-messenger.vim をつくりなおした
git-messenger.vim は,カーソル下の行のコミット情報を表示する Vim プラグインです.
他所のプロジェクトのコードや OSS のコードを読んでいると,なぜこうなってるんだろう?と思うことがよくあります.コミットメッセージをまともに書いているプロジェクトでは,その部分の変更を加えたコミットメッセージに答えがあることがあるのですが,毎回 git blame
で対象のコミットを引っ張ってきて git show
で読むのは結構大変です.
git-messenger.vim では,その負担を軽減するために,(1) カーソルがいる行にコミット情報を git
から引っ張ってきて (2) 良い感じに表示する という機能を提供します.
もう6年も前につくったプラグインなのですが,Vim の制約上いくつかの問題があり,syohex さんの Emacs port に比べて良いものになりませんでした.Vim のバルーン(マウスを一定時間バッファ上に置いておくと出せるツールチップ)が GUI にしか対応していないのが主な理由でした.
先日,Neovim にフロートウィンドウ機能が入り,CUI でもツールチップのような UI が簡単に使えるようになったので,git-messenger.vim をフルスクラッチでつくりなおしました.Neovim のフロートウィンドウについては後の章でまとめました.
git-messenger.vim
インストール
すべて Vim script で実装されているので,他のプラグインと同様に rhysd/git-messenger.vim
リポジトリをインストールするだけで OK です.あともちろん git
コマンドも要ります.
コマンドの実行は以前は system()
を使っていたのですが,大きいリポジトリでもユーザの入力をブロックしないように job を使って書いています.なので,Neovim または Vim (8 以降)が必要です.
また,フロートウィンドウは最近 Neovim の master に入ったところなのでまだ安定版としてはリリースされていません.開発版の 0.4.0-dev を使う必要があります.例えば macOS であれば brew install neovim --HEAD
で入ります.
使い方
:GitMessenger
もしくはデフォルトでマップされている <Leader>gm
を入力すると,カーソルしたのコミット情報がポップアップウィンドウで表示されます.
- Commit hash
- Author, Committer
- Summary (コミットメッセージの1行目)
- Body (コミットメッセージの2行目以降)
ポップアップウィンドウは Neovim 0.4.0 以降では前述のフロートウィンドウで,それ以外ではプレビューウィンドウで実装されています.その後カーソルを動かすとポップアップは自動で閉じます.
基本的にはこれだけですが,ポップアップを開いたあとにカーソルを動かさずそのままもう一度 :GitMessenger
コマンドか <Leader>gm
を入力するとポップアップウィンドウの中にカーソルを移動できます.コミットメッセージが長すぎて全部表示できなかった時のスクロールやメッセージのクリップボードへのコピーなどができます.
ウィンドウ内ではいくつかローカルなマッピングが定義されており,o
でより古いコミットを手繰ることができます.直近のコミットにほしい情報が書いてあるとは限らない(例えばフォーマッタでの整形が挟まったり,別のリファクタリングが挟まったりなど)ので,そういうときはより古いコミットのメッセージにほしい情報があることがあります.さらに O
で新しいコミットに戻ったり,q
でウィンドウを閉じたりできます(?
でヘルプ)
カスタマイズ
- デフォルトのマッピング
<Leader>gm
が気に入らない場合はg:git_messenger_no_default_mappings
にv:true
をセットして<Plug>(git-messenger)
をマップすれば OK です - 他にもいくつか
<Plug>
マップが定義されています - いくつかの挙動はグローバル変数で制御できるようになっています
- Neovim のみポップアップウィンドウ内のハイライトをカスタマイズできます.デフォルトの色合いがお使いのカラースキームに合わないときは自分で色をカスタマイズできます.Neovim のみなのはウィンドウローカルにハイライト色を変更できる
winhighlight
というオプションを Neovim だけが持っているためです
詳しくはリポジトリの README.md か :help git-messenger
で確認できます.
Neovim のフロートウィンドウについて
Vim のウィンドウは分割することでタイル型に配置されるレイアウトしか対応しておらず,ウィンドウが重なるようなレイアウトは(無理矢理バッファを書き換えてそれっぽく見せるようなことをしない限り)実現できませんでした.
そこで Neovim では CSS の position: relative
や position: absolute
のように位置基準でウィンドウの重なりを許すウィンドウレイアウトを実装しました.
何が良いのか
この機能の設計で特に優れていると感じる点は,レイアウト以外はほぼ完全に普通の Vim のウィンドウと同じところです.
- CUI で使える
- プラグイン開発者はウィンドウの開き方だけ分かれば,あとは普通の Vim のウィンドウと同様に扱える
- ウィンドウ内のコンテンツは普通の Vim のバッファなので,
setline()
などで自由に追加・変更・削除できる - filetype をセットしたり,自由にハイライトできる
- ウィンドウを開く以外はバルーンのような専用の API が要らない
この機能により,プラグイン開発者はウィンドウの上にオーバーレイするような UI を変なハックをしたり妥協することなく実装することができます. ぱっと思いつくのは
などに使えそうです.
使い方
フロートウィンドウを開くのには nvim_open_win()
,サイズや配置を変えるのには nvim_win_set_config()
を使います.
" 開くウィンドウの幅 let width = 40 " 開くウィンドウの高さ let height = 10 " ウィンドウを開いたあと,カーソルをそのウィンドウ内に移動するか let enter = v:true " カーソルを :wincmd やマウスでウィンドウ内に移動できるか let focusable = v:true " 何に対して相対的にウィンドウの配置位置を決めるか " - "editor": エディタのスクリーンに対して.スクリーン上の絶対座標で指定 " - "win": 現在のウィンドウ位置に対して.これを使う場合はオプションに 'win' というキーで別途対象ウィンドウのウィンドウ ID を指定する " - "cursor": カーソル位置に対して let relative = 'cursor' " 基準となるウィンドウの角を四隅のどこにするかを指定します(デフォルト "NW") " - "NW": 左上 " - "NE": 右上 " - "SE": 右下 " - "SW": 左下 let anchor = 'NW' " 'relative' で指定した位置に対する相対的なオフセット let row = 1 let col = 0 " Neovim 内ではなく,GUI フロントエンド側にウィンドウを出すよう依頼するか let external = v:false " 現在のバッファをフロートウィンドウで開く " カーソルのすぐ下に 40x10 のウィンドウが開かれる let win_id = nvim_open_win(bufnr('%'), enter, { \ 'width': width, \ 'height': height, \ 'relative': relative, \ 'anchor': anchor, \ 'row': row, \ 'col': col, \ 'external': external, \}) " 新しいバッファを開いたり enew " 普通に setline() を使ってコンテンツをセットしたり call setline('.', ['hello', 'world!']) " filetype を指定してハイライトを定義したり set filetype=ruby bufhidden=wipe nomodified buftype=nofile " フロートウィンドウを別のハイライトで描画したり set winhighlight=Normal:MyNormal,NormalNC:MyNormalNC " nvim_win_set_config() でウィンドウのレイアウトをやり直せる call nvim_win_set_config(win_id, { \ 'width': 60, \ 'height': 30, \ 'relative': 'editor', \ 'row': 10, \ 'col': 10, \})
フロートウィンドウにはボーダーが無いので,デフォルトのままではフロートウィンドウと下のウィンドウの境界がわかりません.なので特に最後の既存のウィンドウと別のハイライトで背景色を描画するのは重要です.また,フロートウィンドウ内で新しいバッファを開いた際はウィンドウを閉じると中身も自動で開放されるよう bufhidden=wipe
を指定しておきます.
nvim_win_set_config()
でウィンドウのレイアウトをやりなおすことができるので,これを使ってウィンドウのサイズ変更や位置変更をします.
ちなみに row
,col
,width
,height
がエディタのスクリーンからはみ出してしまってもエラーになりません.Neovim はなるべくウィンドウをスクリーン内に収めるように描画します.git-messenger.vim では現在のカーソル位置からポップアップを出すのに十分な幅・高さがあるかを確認し,カーソルの上下左右にフロートウィンドウを出し分けます.
Rust 公式 linter の clippy に新しいルールを実装した
Rust 公式の linter,clippy に新しいルールを足すプルリクを出してマージされた時のメモです.
dbg!
マクロ
Rust 1.32 で dbg!
というマクロが追加されました.
これは値を1つ引数にとってその値を返すマクロで,受け取った値とソースコード上での位置を print します.
fn factorial(n: u32) -> u32 { if dbg!(n <= 1) { dbg!(1) } else { dbg!(n * factorial(n - 1)) } } dbg!(factorial(4));
名前の通り,いわゆる print debug 用途のマクロなので,デバッグが終わってリポジトリに commit する前にはコードから dbg!
マクロを削除しておくのがベストプラクティスです.
公式ドキュメントにも
Note that the macro is intended as a debugging tool and therefore you should avoid having uses of it in version control for longer periods.
と書いてあります.
ちょっとしたデバッグに非常に便利なのですが,値を move で受け取ってその値を返すだけなのでうっかり消し忘れるとテストを壊すこともなく気付けないケースがあることに気付きました.
dbg_macro
ルール
そこで,コード内の dbg!
マクロを検出できる dbg_macro
ルールをつくりました.
#![deny(clippy::dbg_macro)] fn main() { dbg!(42); }
のようなコードに対して,
cargo clippy -- -W clippy::dbg_macro
のように実行すると
error: `dbg!` macro is intended as a debugging tool --> foo.rs:4:5 | 4 | dbg!(42); | ^^^^^^^^ | help: ensure to avoid having uses of it in version control | 4 | 42; | ^^
のように警告を出します.
デフォルトで有効になっているとデバッグ中にエディタが警告しまくってうるさいので,restriction
カテゴリでデフォルトは無効になっています.
restriction
カテゴリは README には載っていませんが,「unimplemented!
を使わない」や今回のルールのような特定の場面(production 前のチェックなど)で有効なルールや,「mem::forget
を Drop
を実装した型で使わない」といった万人向けではないキツめのルールが登録されています.
clippy のリポジトリを restriction で検索するとざっと見渡すことができます.
新しいルールの提案と追加
まずは issue で「こういうルールあると良いと思う」と提案し,特に反対もなくメイン開発者の upvote も付きました.'good first issue'(初めて contribute する人が取り組むと良い易しい issue)のラベルがついたので,自分で実装してみて,プルリクを出しました.あとは一般的なプルリクと同じで何度かレビューしてもらって OK が出てマージという流れでした.
clippy の実装
基本的にはまず CONTRIBUTING.md を読めば大体分かるようになっており,必読です.
ディレクトリ構成
src/*
: clippy のコマンドライン部分とドライバ(実行の前段部分)の実装のみclippy_lints/
: linter の各ルールの実装clippy_lints/lib.rs
: ルールすべてを import して登録しているところclippy_lints/*.rs
: ルールの実装(lib.rs
以外)clippy_lints/utils
: ルールの実装に使うあれこれ
clippy_dev/
: 開発時に使うツール群(ルールリストの更新など)clippy_dummy/
: テスト向けtests/
: pass を走らせる部分のテストや各ルールを適用して正しく警告が出るかどうかのテスト
新しいルールを足すには基本的に clippy_lints
crate および tests/
に追加・修正を加えることになります.
Early Pass と Later Pass
clippy のルールは構文木(syntax::ast
)もしくは HIR(rustc::hir
)に対する pass として実装します.HIR はパースした構文木にコンパイラ向けの情報を足したもので,Rust RFC 1191に詳しく書いてあります.
自前の pass を実装して登録しておくと,clippy が構文木または HIR をトラバースしたときに各ノードにその pass を適用して,pass に実装したコールバックメソッドがマッチすると適宜呼び出されます.
構文木にマッチさせる pass を early pass,HIR にマッチさせる pass を later pass といい,下記のような順序で適用されます.
early pass は構文木のみ,later pass は HIR,型情報,コンパイラコンフィグ(cfg!
)などにアクセスできます.
構文木をパースしたり pass を適用するなどの処理は rustc コンパイラ本体に linter 向けの汎用的な API があり(rust/src/librustc/lint
),rustc コンパイラ自体の unused 警告などもこれを利用しています(rust/src/librustc_lint
).rust-clippy リポジトリではそれを利用してルール集とコマンドライン部分のみを実装しています.なので,clippy の実装時には rustc
,rustc_*
や syntax
といった rustc コンパイラ API を知る必要があります.
具体的に early pass と later pass でどのパスを通しているかは rust/src/librustc/lint/mod.rs
に実装があります.
https://github.com/rust-lang/rust/blob/master/src/librustc/lint/mod.rs
新しい Early Pass を追加する
まず clippy_lints/src
内にルール向けのソースファイルを作成します.ここでは clippy_lints/src/my_rule.rs
としたとします.
declare_clippy_lint! { pub MY_RULE, style, "my rule for clippy" }
第1引数が linter インスタンス,2引数目が perf, correctness, complexity, style などのカテゴリです.
declare_clippy_lint
は rustc::lint
が提供する declare_tool_lint!
マクロの薄い wrapper になっていて,カテゴリを見てデフォルトの警告レベルをセットするなどの設定を行っています.
次に pass オブジェクトを定義します.
#[derive(Copy, Clone, Debug)] pub struct MyRule; impl LintPass for MyRule { fn get_lints(&self) -> LintArray { lint_array!(MY_RULE) } fn name(&self) -> &'static str { "MyRule" } } impl EarlyLintPass for MyRule { fn check_expr(&mut self, cx: &EarlyContext<'_>, e: &syntax::Expr) { // ここにチェック処理を実装 } }
rustc::lint::LintPass
の実装ではその pass の共通情報を記述します.1つの pass で複数のルールをチェックすることができ,rustc::lint::lint_array!
マクロで指定します.
early pass の実装本体は rustc::lint::EarlyLintPass
を実装することで実装します.もともと EarlyLintPass
にある check_*
メソッドをオーバーライドすると,そのメソッドが対応する構文木ノードを visit したときに適用されます.rustc::lint::EarlyContext
を通じて lint のコンテキスト情報を受け取ることができます.
上記では式に対する処理 check_expr
をオーバーライドしていますが,一覧はrustc のソース内で確認できます.
構文木のパターンがチェックしたいパターンにマッチしているかをチェックし必要な情報を抜き出すには match
や if let
などのパターンマッチをネストさせまくることになるので,if_chain::if_chain!
マクロが便利です.
例えば let x = EXPR; x
を取り出す処理はこんな感じに書けます.
if_chain! { if let Some(retexpr) = it.next_back(); if let ast::StmtKind::Expr(ref retexpr) = retexpr.node; if let Some(stmt) = it.next_back(); if let ast::StmtKind::Local(ref local) = stmt.node; if let Some(ref initexpr) = local.init; if let ast::PatKind::Ident(_, ident, _) = local.pat.node; if let ast::ExprKind::Path(_, ref path) = retexpr.node; if !in_external_macro(cx.sess(), initexpr.span); then { // ここでマッチしたときの処理 } }
警告すべきコードをパターンマッチで見つけたら警告を出す処理を書きます.警告を出す方法は clippy_lints/src/utils
に便利関数群があり,
- 警告メッセージのみ:
span_lint()
,span_lint_node()
- 警告メッセージとヘルプ:
span_help_and_lint()
- 警告メッセージとノート:
span_note_and_lint()
- 警告メッセージとヘルプと修正提案:
span_lint_and_sugg()
などが使えます.span とはソースコード片のことで,開始位置・終了位置・コンテキスト情報がエンコードされた u32
の値で,ソースコードのうち警告を出す部分を指定するのに使えます.各構文木ノードは syntax::Spanned
で wrap されて check_*
メソッドに渡されるので,ノードに対応する span は簡単に取得できます.
span_lint_and_sugg()
ではソースコードの修正提案を String
で渡せます.rustc_errors::Applicability::MachineApplicable
を指定することで,自動修正機能(おそらく rustfix?)で自動修正できます.渡した文字列で span で指定した範囲を置き換えます.span は utils::snippet()
を使ってコード片(スニペット)として文字列化することができ,修正提案のための文字列が楽につくれるケースがあります.
最後に作成したルールを clippy に登録します.clippy_lints/src/lib.rs
内で下記のように対応するカテゴリに linter 情報を追加します.
// ... pub mod my_rule; // ... reg.register_lint_group("clippy::style", Some("clippy_style"), vec![ // ... my_rule::MY_RULE // ... ]); // ...
最後につくった pass を登録するのですが、early pass の場合はここで注意が必要です。
// マクロ展開後で OK な pass は普通に early pass として登録 pub fn register_plugins(reg: &mut rustc_plugin::Registry<'_>, conf: &Conf) { // ... reg.register_early_lint_pass(box reference::Pass); // ... } // マクロに対する lint など,マクロの展開前でないと動かない pass はこっちに登録 pub fn register_pre_expansion_lints(...) { // ... store.register_pre_expansion_pass(Some(session), true, false, box dbg_macro::Pass); }
マクロの構文木ノードにマッチさせる check_mac()
などを使う場合は後者の register_pre_expansion_pass
側に pass を登録する必要があります.
early pass の適用順序は
pre_expansion_pass
- マクロの展開処理
early_lint_pass
となっています。
新しい Later Pass を追加する
early pass と基本的には同じで,rustc::lint::EarlyLintPass<'a>
の代わりに rustc::lint::LateLintPass<'a, 'tcx>
を実装します.tcx
は型情報のコンテキストの寿命を表しているようです.
impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule { fn check_expr(&mut self, cx: &LateContext<'a, 'tcx>, expr: &'tcx rustc::hir::Expr) { // ルールの実装 } }
オーバーライドできるメソッドはrustc のソース内で確認できます.
early pass と違い,check_expr
などのメソッドに rustc::hir::*
が渡され,型情報のコンテキストの寿命 tcx
が制約として付きます.
型情報には LateContext
の tcx
フィールドからアクセスできます.(e.g. cx.tcx.fn_sig
)コンパイラコンフィグ(cfg
)は cx.tcx.sess.parse_sess.config
にある rustc::session::config::Config
な値にアクセスすることでチェックできるようです.構造体のサイズなどターゲット依存で変わるものをチェックする際に使われます.
また,later pass では call flow graph も rustc::cfg::CFG
を使って取得できます.
impl<'a, 'tcx> LateLintPass<'a, 'tcx> for MyRule { fn check_fn( &mut self, cx: &LateContext<'a, 'tcx>, kind: intravisit::FnKind<'tcx>, decl: &'tcx FnDecl, body: &'tcx Body, span: Span, node_id: NodeId, ) { // call flow graph を生成 let cfg = CFG::new(cx.tcx, body); // ... } }
例えば cyclomatic complexity を計算する際に使われているようです.
pass の登録について,later pass の場合はマクロに対するテストかどうかで pass の登録先を分ける必要はありません.ある HIR ノードがマクロ展開結果生成されたものかは,そのノードの span を使って clippy_lints/src/utils
の utils::is_expn_of
で分かります.
テストの実装
テストは UI テスト(問題があるコードに実装したルールを適用して,結果として期待する警告の出力がコマンドライン出力として得られるかどうか)のみで行います.
CONTRIBUTING.md によると,実装前にまずは警告を出してほしいコードを tests/ui/my_rule.rs
に書き,TESTNAME=ui/my_rule cargo test --test compile-test
と実行すると,そのコードの構文木にマッチする linter 実装コードの雛形をつくってくれるらしいのですが,僕の場合は dbg!
が展開された後の構文木にマッチするようなコードが吐かれてしまったため使えませんでした.
linter の実装が終わったら CLIPPY_TESTS=true cargo run --bin clippy-driver -- -L ./target/debug tests/ui/my_rule.rs
で警告が意図通り出力されていることを確認し,その結果を多少整形して tests/ui/my_rule.stderr
として保存します.最後に TESTNAME=ui/my_rule cargo test --test compile-test
で my_rule
向けのテストを実行して結果を確認できます.
dbg_macro
ルールの実装
今回実装したルールは dbg!(expr)
マクロの使用箇所を検知して警告として表示し,dbg!(expr)
の代わりに expr
を修正提案として表示する小さなものです.
rustc::lint::EarlyLintPass
として実装し,pre-expansion pass として登録します.マクロ呼出しにマッチする EarlyLintPass::check_mac
メソッドをオーバーライドして実装し,渡された ast::Mac
のパスが "dbg"
かどうかチェックするだけです.
impl EarlyLintPass for Pass { fn check_mac(&mut self, cx: &EarlyContext<'_>, mac: &ast::Mac) { if mac.node.path == "dbg" { // 警告を表示 } } }
ast::Mac
は syntax::Spaned
で wrap された型なので mac.span
でマクロ呼出しのソースコード上での範囲(span)は簡単に取得できます.
あとは修正提案用の文字列を生成できれば終わりです.
マクロは引数にトークン列を取るので,展開前は引数のトークン列のみが構文木ノードに格納されています(mac.node.tts
).
dbg!(expr)
マクロの中身 expr
の span が取得できれば,その範囲を utils::snippet
でコード片化できます.
引数の span を取る方法は
- トークン列の最初のトークンの span の始まり位置とトークン列の最後のトークンの span の終わり位置からトークン列全体の範囲を表す span を生成する
dbg!()
は仕様として1つの値を取るので,トークン列をsyntax::ast::Expr
にパースした結果のノードから span を取り出す- early pass をやめ,later pass にして
dbg!()
の引数部分から展開された HIR ノードを特定して span を取り出す
のざっくり3通りが考えられます.3. は大変そうだし 2. は式としてパースできなかった場合のエラーハンドリングやパースのコストがあるので,1. が一番良さそうです.
Rust のトークン列は syntax::tokenstream::TokenStream
という型で表され,syntax::tokenstream::TokenTree
の列で表現されています.どうやらこれらは proc macro の実装などで使う proc_macro::TokenStream
や proc_macro::TokenTree
とは別物のようです.
TokenStream
は .trees()
で(clone した)TokenTree
のイテレータを返せるので,そこから最初のトークンツリーと最後のトークンツリーを取得し,Span::to
を使って最初のトークンツリーの span から最後のトークンツリーの span までの範囲を表す span を新たに生成します.
let mut cursor = mac.node.tts.trees(); let first = cursor.next().unwrap(); let last = cursor.last().unwrap_or(first); let entire_span = first.span().to(last.span());
最後にスニペット文字列を span から生成すれば修正提案に使う文字列が生成できます.
let snip = utils::snippet_opt(cx, entire_span).unwrap().to_string();
最後に clippy_dev
と cargo fmt
を実行しておきます.clippy_dev
は clippy_lints/src/lib.rs
で登録されている pass をチェックしたり,CHANGELOG.md と README.md を更新したりしてくれます.
cd clippy_dev/ cargo run -- update_lints cargo +nightly fmt --all
まとめ
dbg_macro
ルールを Rust 公式 linter の clippy に追加しました.次の clippy のリリース時に入ると思うので,良ければ CI や Git の pre-commit もしくは pre-push フックなどで
cargo clippy -- -W clippy::dbg_macro
と実行して使ってみてください.
また clippy に新しいルールを追加する方法についても簡単に紹介しました.rustc
,rustc_*
,syntax
あたりの nightly でしか使えない rustc コンパイラ API を使ったり読んだりする必要はなかなか無いので,実際使ったのはごく一部ですが良い機会だったと思います.ちなみにこれらの crate は rustc コンパイラの内部実装で頻繁に変更されるので,上記の紹介した内容もいずれ正しくなくなる可能性が高いです.
Vim の構文ハイライトでクリスマスツリー🎄を飾ってメリクリする
Vim Advent Calendar 2018 の24日目の記事です.昨日は Kaoriya さんのVim に VOICEROID で喋らせたでした.
もうすぐクリスマスなので,クリスマスツリーを飾りたいと思います.ただ飾るだけだと Vim のネタにならないので,Crystal や Wast,vim-gfm-sytnax, vim-github-actions といったファイルタイププラグインをつくった経験を活かし,構文ハイライトを使って Vim の中でクリスマスツリーを飾っていきたいと思います.
Vim の構文ハイライトについての情報は下記の help ドキュメントを読んでいただければ,ある程度網羅的な情報が手に入るので,実際に何かのファイルタイプを追加するプラグイン(ファイルタイププラグイン)を実装する時は,まずざっと一通りドキュメントを眺めることをおすすめします.
この記事はまだファイルタイプを書いたことが無い人を対象とします.網羅的な情報ではなく,ハイライトをつけていく過程を実例とともにコードで説明して,ファイルタイププラグインを実装する大まかな手順を把握していただくのを目的として進めていきます.今回はインデントについては時間の都合(や題材の都合)で省略します.
今回使ったコードはすべてこのリポジトリにあります.
ファイルタイププラグインとは
本題に入る前にファイルタイププラグインを知らない方向けにざっくりとした説明をしておきます.
特定の拡張子のファイルを開いたときなど,set filetype=...
のようにファイルタイプをセットしたときにそのファイルタイプの構文ハイライトやインデント設定などを行ってくれるプラグインをファイルタイププラグインと呼びます(:help filetype-plugin
).
Vim は c
や vim
をはじめ,デフォルトで多くのファイルタイプをサポートしていますが,自分で新しいファイルタイプを足すこともできます.有名どころだと typescript-vim や vim-toml など無数にあります.
また,vim-css3-syntax や vim-jsx-pretty, vim-gfm-syntax のように,自前でファイルタイプを定義するのではなく,既存のハイライトに独自のハイライトを足すようなプラグインもあります.
下記のような場合にファイルタイププラグインを自作する必要があります
- Vim が公式で対応していない言語のコードをハイライトしたい(e.g. 自作言語,自作設定ファイル)
- 既存のファイルタイププラグインではうまく動かない(or メンテされていない)などの事情で,自分でつくりたい
- アプリのログを読むときに重要な箇所をハイライトしたい
0. まずは対象の仕様を把握する
ファイルタイププラグインを書き始める前に,まずは対象を把握します.特定の言語のファイルタイププラグインをつくる場合は,まずはその言語の構文の仕様を眺めたりします.例えば vim-wasm をつくる際には WebAssembly の Text Format の仕様を読んだりしました.
今回はクリスマスなので,下記のクリスマスツリーをハイライト(飾り付け)していきたいと思います.
asciiart.eu の Laura T さんの作品をベースにしてつくってみました.
* ☆ * * .o * * * .o.'. .'.'o'. * * o'.*.'.o. * .'.o.'.'.*. * * * .*.'.o.'.o.'. * * o'.'.'.'*'.'.'. * * .'.'*'.'o'.'.'*'o * * [_____] * * \___/ * * Merry Christmas!
ツリーのてっぺんには☆が飾られており,o
や *
で装飾された木だと思ってください.周りの *
はクリスマスなので雪が降っています.このままでも綺麗ですが(?),色を付けて飾りつけていきましょう.
新しいファイルタイプとして christmastree
を定義することにします.このファイルタイプが定義されている時は Vim はバッファにクリスマスツリーが描かれているとしてハイライトするようにこれから実装していきます.
1. リポジトリとファイルタイプの認識
まずは Vim プラグインのリポジトリを用意します.ディレクトリを runtimepath
に直接追加する(.vimrc
内で set rtp+=/path/to/vim-syntax-christmas-tree
)か,お好みのプラグインマネージャでロードするプラグインのリストに追加してください.
mkdir vim-syntax-christmas-tree && cd vim-syntax-christmas-tree git init .
ディレクトリ構成はこのようになっています. christmas-tree.txt
は上記のアスキーアートが描かれたテキストファイルです.
vim-syntax-christmas-tree/ ├── christmas-tree.txt ├── ftdetect/ │ └── christmastree.vim ├── syntax/
まずはファイルタイプを認識させます.Vim は ftdetect/*.vim
の中身を起動時にロードしてくれるので,ftdetect/christmastree.vim
をつくり,この中にファイルタイプを認識するための :autocmd
を書きます.
autocmd BufNewFile,BufReadPost christmas-tree.txt setlocal filetype=christmastree
今回は christmas-tree.txt
という固定のファイル名のファイルを読んだときのみ,ファイルタイプ christmastree
をセットするようにしています.実際は TypeScript なら *.ts
のように拡張子でパターンを指定することが多いです.
ここで一旦 Vim を新たに開き,christmas-tree.txt
を開いて :set ft
で今のファイルタイプが christmastree
になっているのを確認します.
2. 構文ハイライトファイルをつくる
Vim は構文ハイライトファイルとして syntax/{filetype}.vim
か syntax/{filetype}/*.vim
を探すようになっているので,syntax/christmastree.vim
をつくります.
まだ何もハイライトしない空の状態です.
if exists("b:current_syntax") finish endif " ここにハイライト処理の実装を書いていく let b:current_syntax = "christmastree"
変数 b:current_syntax
はそのバッファをハイライトしている構文の名前です.すでにハイライト済みかどうかはその変数があるかどうかでチェックし,まだハイライトされていなければハイライト処理を行うという構造になっています.ハイライトを行った後は,すでにハイライトしたということが分かるように,b:current_syntax
を定義しておきます.
3. はじめてのハイライト: :syn match
まずはてっぺんの☆をハイライトしてみます.
これは単に ☆
という文字列を色をつけてハイライトすれば良さそうです.
syn match christmastreeStar "☆" hi def link christmastreeStar Identifier
:syn match {ハイライトグループ} {パターン}
を使っています.これにより,指定したパターン(正規表現)にマッチする文字列を指定したハイライトグループでハイライトできます.
ここでは christmastreeStar
というハイライトグループをつくることにし,"☆"
というパターン(正規表現)にマッチする箇所とそのハイライトグループを紐づけます.この時点ではハイライトグループを紐づけただけなのでまだ色はつきません.ハイライトグループ名は小文字始まりのファイルタイプ名をプレフィックスとしてつけます.これはハイライトグループ名が一意でないといけない(他とかぶると上書きされてしまう)ためです.
次の行の hi def link {ハイライトグループ} {別のハイライトグループ}
という行で,christmastreeStar
を Identifier
というハイライトグループに紐づけています.
Vim はデフォルトでいくつかのハイライトグループを定義しています.例えば (Normal
: 通常のテキスト,Visual
: ビジュアルモード選択 など).また,習慣として,同じ構文を同じグループにまとめたものが推奨されるハイライトグループ(Identifier
: 変数名,Statement
: 文 など)として明記されています.いずれも :help :hi
で一覧を確認できるので,ざっと見ておいたほうが良いです.カラースキームプラグインはこれらの推奨ハイライトグループやデフォルトのハイライトグループに対して色を指定することで好きな色合いを実現しています.(カラースキームについては,もしよければフルスクラッチからさいきょうの Vim カラースキームをつくろう!を参照してみてください)
Vim で :hi
と打つと今のハイライトグループとそれに指定されている色がリストで表示されるので,そこで実際の色を確認できます.
:hi def link
はデフォルトでハイライトグループを別のハイライトグループにリンクします.なので,ここでは christmastreeStar
というハイライトグループにマッチするテキストは Identifier
と同じ色でハイライトされます.
実際にプログラミング言語のハイライトを書く場合は,対象の構文が何にあたるかでリンクするハイライトグループを決めます.例えば if
文なら Statement
,関数定義なら Function
など.今回は残念ながら(?)クリスマスツリーに適した推奨ハイライトグループが無いため,適当に手元のカラースキームで合いそうな色を選択しました.
ちなみに
syn match Identifier "☆"
のように新しいハイライトグループを定義せず直接共通のハイライトグループを指定することもできますが,カラースキームがハイライト色を上書きできなくなるためやめておいたほうが良いです.:hi def
はすでにハイライトがリンクされているときに何もしないので,カラースキーム側で先に :hi link
してやることで,ハイライト色を上書きできるようになります.
実際に Vim で開いて確認してみます.:term ++close vim christmas-tree.txt
とすることで,Vim の中で直接 Vim を開いて最新のハイライトを確認できます.
やりました! ☆
がハイライトされていることが分かります.
ちなみに本来は短い特定の文字列は :syn keyword
でキーワードとしてハイライトするのが一般的ですが,:syn keyword
は 'iskeyword'
に指定されている文字でないとハイライトしてくれないので,☆
には使えず :syn match
を使いました.
4. 葉をハイライトする: :syn region
次にツリーの葉を緑でハイライトしてみます.先程の :syn match
を使っても良いですが,今回は :syn region
を使ってハイライトします.周りには雪が舞っていてそれらは葉に含めたくないので,行ごとにハイライトするのが良さそうです.
syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ oneline hi def link christmastreeLeaves String
新たなハイライトグループ christmastreeLeaves
を定義しています. syn region
はハイライトするテキストの始め(start=
)と終わり(end=
)によってハイライトする範囲を指定できます.始まりと終わりがダブルクォートな文字列リテラル("..."
)や,{...}
によるブロック構文などは :syn region
を使うことが多いです.
ここでは,木は .
か o
で始まり,.
か o
で終わっていることに目をつけて,空白の後に .
か o
が続く場合を始めとし,空白または改行に続く .
か o
を終端とします.
最後に,葉は行ごとにハイライトすると決めたので,oneline
オプションを指定しておきます.これによって,ハイライトが次の行に継続してハイライトされることがなくなります.
実際に Vim で開いて確認してみます.
無事,意図通り緑色(私のローカルのカラースキームでは文字列が緑なので String
を指定している)でハイライトされています.少し木っぽくなってきた気がします.
5. ハイライトをネストさせる: contained
, containedin=
, contains=
飾り *
をハイライトしてみます.☆
のとき同様に *
にマッチするハイライトを新しいハイライトグループ christmastreeGlitter
で指定し,
syn match christmastreeGlitter /\*/ hi def link christmastreeGlitter Constant
実際に Vim で開いて確認してみます.
雪のほうがハイライトされてしまいました.一方でハイライトしたほうの木の *
のほうはハイライトされていません.これは,葉のハイライトのほうが優先されてしまっているためです.
ここでやりたいことは,葉の中の *
だけをハイライトするということです.
これはハイライトを「ネストさせる」ことで実現できます.Vim のハイライトは正規表現でマッチする領域を重ねた層のような構造になっており,contains=
, containedin=
, contained
などの :syn
のオプションでそれらを制御します.
今回の例では
syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=christmastreeGlitter oneline keepend syn match christmastreeGlitter /\*/ contained containedin=christmastreeLeaves hi def link christmastreeLeaves String hi def link christmastreeGlitter Constant
のようにすることで実現できます.
contained
をつけたハイライトはトップレベルではハイライトされなくなり,後述の contains=
でネストしてマッチした場合のみハイライトされるようになります.これによって,外の雪は白いままになります.
ハイライトをネストさせるには,別のハイライトを含む側に contains=
で含むハイライトのグループ名をコンマ区切りで書き,含まれる側のハイライトに含むハイライトグループを containedin=
で指定します. containedin=
は無くても良いですが,あったほうが意図しないハイライトを防げて良いと思います.
実際に Vim で開いて確認してみます.
無事,木の飾りの *
だけに色をつけることができました.
実際のファイルタイププラグインでは,このようにハイライトをネストさせることで特定の構文のみに適用されるハイライトを定義します.例えば if
文が関数ブロックの中でしか書けないような言語であれば,関数ブロック全体にマッチするハイライトを定義し,その中にネストして if
文のハイライトをマッチさせるような実装が考えられます.
ちなみに,しれっと keepend
というオプションを足していることに気付いたでしょうか?:syn region
は同じ構文でネストしたハイライトをうまく扱う(例えば { { ... } }
のようにネストしていても内側と外側にうまくハイライトマッチできる)ような挙動になっているのですが,これだと外側(christmastreeLeaves
)のマッチするテキストの末尾で内側(christmastreeGlitter
)が同時にマッチするとき,内側が優先されて外側のマッチが終了しなくなります.この挙動を変えるのが keepend
で,内側のハイライトと外側のハイライトが同時に終了するようになります.
6. 複数のハイライトをまとめる
木の飾りは *
と o
の2種類あります.それぞれに違う色を割り当てられたほうが華やかになるので,o
は *
とは別のハイライトを割り当てます.
ここで先程のセクションと同様にして o
もハイライトしても良いですが,せっかくなので1つの「クラスタ」にまとめることにします.
複数のハイライトグループをひとまとめにする :syn cluster
を使います.
syn cluster christmastreeGlitters contains=christmastreeGlitterSmall,christmastreeGlitterLarge syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=@christmastreeGlitters oneline keepend syn match christmastreeGlitterLarge /o/ contained containedin=christmastreeLeaves syn match christmastreeGlitterSmall /\*/ contained containedin=christmastreeLeaves hi def link christmastreeLeaves String hi def link christmastreeGlitterSmall Constant hi def link christmastreeGlitterLarge Special
まずは *
を christmastreeGlitterSmall
,o
を christmastreeGlitterLarge
として別のハイライトグループにし,それぞれに別のハイライト色を指定できるようにします.
次に :syn cluster {クラスタ名} contains={コンマ区切りのハイライトグループ}
を使って @christmastreeGlitters
をひとまとめにします.
クラスタは :syn region
の contains=
のように,コンマ区切りで複数のハイライトグループを指定する箇所で使えます.
実際に Vim で開いて確認してみます.
今回の例では,まとめずに *
と o
で別個にハイライトを指定するのに比べてあまり嬉しさが分かりませんが,数が増えたり,階層構造が深くなったりしたときに管理がかなり楽になります.
実際のファイルタイププラグインでは,例えば関数ブロックの中に含められるハイライトグループをひとまとめにして1つのクラスタにしたりといった管理をしているのを見ます.vim-wasmでは,WebAssembly のテキストフォーマットはS式で記述するので,(...)
で囲まれた中のみでハイライトが効く(逆に括弧の外であるトップレベルではコメント以外ハイライトされない)ように,括弧の中だけでハイライトすべきハイライトグループを1つのクラスタにしています.
7. 完成
最後に忘れていた植木鉢を :syn match
でハイライトさせて完成です.[___]
と \___/
に分けてマッチさせていますが,それぞれ同じ構文の一部として同じハイライトグループ名 christmastreeFlowerpot
を指定しています.
このように,1つのハイライトグループを複数の :syn
で構成することもできます.
これでようやく一通り飾り終えることができました. syntax/christmastree.vim
の全体像は下記の通りです.
if exists("b:current_syntax") finish endif syn cluster christmastreeGlitters contains=christmastreeGlitterSmall,christmastreeGlitterLarge syn match christmastreeStar /☆/ syn region christmastreeLeaves start=/\s\@<=[o.]/ end=/[o.]\%(\s\|\_$\)\@=/ contains=@christmastreeGlitters oneline keepend syn match christmastreeGlitterLarge /o/ contained containedin=christmastreeLeaves syn match christmastreeGlitterSmall /\*/ contained containedin=christmastreeLeaves syn match christmastreeFlowerpot /\[_\+\]/ syn match christmastreeFlowerpot /\\_\+\// hi def link christmastreeStar Identifier hi def link christmastreeLeaves String hi def link christmastreeGlitterSmall Constant hi def link christmastreeGlitterLarge Special hi def link christmastreeFlowerpot PreProc let b:current_syntax = "christmastree"
実際に Vim で開いて確認してみます.
無事綺麗に飾りつけられました.お疲れ様でした.
8. さらにピカピカさせる
構文ハイライトだけでも綺麗ですが,ftplugin
を使ってさらに華やかにしてみます.
Vim はファイルタイプをセットしたときに ftplugin/{filetype}.vim
または ftplugin/{filetype}/*.vim
を読みに行くため,そのファイルの中にコードを書いておけば,特定のファイルタイプをセットしたときに処理を行うことができます.
今回は ftplugin/christmastree.vim
を作成し,構文ハイライトの b:current_syntax
のように,もう ftplugin
を読み終えたことを示す b:did_ftplugin
をチェックする処理を最初に書きます.これにより,意図せず複数の ftplugin
スクリプトが読まれてしまうことを防ぎます.
こうすることで,例えば別の ftplugin
を作成して既存の ftplugin
を置き換えると言ったことができるようになります.
if exists("b:did_ftplugin") finish endif let b:did_ftplugin = 1
今回はタイマーを使って,一定時間ごとに飾りの色が変わるようにしてみました.
function! s:christmas_glitter_tick(timer) abort let small = get(b:, 'christmastree_glitter_small_colors', ['Constant', 'Statement', 'Special', 'Ignore']) let large = get(b:, 'christmastree_glitter_large_colors', ['Special', 'Keyword', 'Identifier', 'Normal', 'Statement', 'Constant', 'Ignore']) let idx = s:tick % len(small) execute 'hi link christmastreeGlitterSmall' small[idx] let idx = s:tick % len(large) execute 'hi link christmastreeGlitterLarge' large[idx] let s:tick += 1 endfunction function! s:christmas_glitter_start() abort if exists('s:timer_id') return endif let s:tick = 1 let s:timer_id = timer_start(1000, function('s:christmas_glitter_tick'), {'repeat': -1}) endfunction function! s:christmas_glitter_stop() abort if !exists('s:timer_id') return endif call timer_stop(s:timer_id) unlet! s:timer_id unlet! s:tick endfunction if get(g:, 'christmastree_glitter_update', 1) call s:christmas_glitter_start() autocmd BufWipeout <buffer> call <SID>christmas_glitter_stop() endif command! -nargs=0 -bar -buffer ChristmasTreeTurnOn call <SID>christmas_glitter_start() command! -nargs=0 -bar -buffer ChristmasTreeTurnOff call <SID>christmas_glitter_stop()
詳しくは説明しませんが,timer_start()
を使って1秒毎に christmastreeGlitterSmall
と christmastreeGlitterLarge
のリンクするハイライトグループを書き換えることでハイライト色を変更しています.
JavaScript の setInterval
のようにタイマーは非同期に呼ばれるので,s:christmas_glitter_tick()
が実行されるわずかな時間しかユーザの操作をブロックしません.
なので,Vim の中にクリスマスツリーをピカピカ光らせながら普段のコーディングができます.
ftplugin/christmastree.vim
はファイルタイプ christmastree
がセットされるたびに毎回読まれるので,その中に直に処理を書けば良く, g:christmastree_glitter_update
が 0
にセットされていないときのみタイマーを開始するようにしています.
また,手動でタイマーを止めたり開始したりできるように,:ChristmasTreeTurnOn
と :ChristmasTreeTurnOff
も定義しています.-buffer
付きで定義しているので,そのバッファ内のみで使えるコマンドとして定義されます.
これでクリスマスの準備もバッチリですね!(?)