🧩

denippet.vimの紹介 - TypeScriptで動くVim/Neovim両対応Snippetプラグイン

2023/12/08に公開

はじめに

どうもVimのオタクです。
改め、DeNA 24新卒のuga-rosaです。

今回は最近作ったSnippetプラグインを紹介します。

https://github.com/uga-rosa/denippet.vim

前置き

皆さんはSnippetを利用したことがあるでしょうか。
ifclassのような構文をサッと展開してくれる便利なやつです。
VSCodeやLSPを使ったことのある人ならば馴染み深いでしょう。

こういうの

この記事ではプラグインの機能紹介とチュートリアルを行います。

主な特徴は以下になります。

  • Vim/Neovim両対応
  • TypeScript/json/toml/yamlの好きな形式で定義を記述できる
  • VSCode形式の定義を拡張した、馴染みやすい文法

ぜひ使ってみてください!issueも歓迎しております。

開発経緯

VimのSnippetプラグインは既に幾つもあります。

私はこれまでvim-vsnipを愛用していたのですが、1つ明確な弱点として

  • Vim scriptで実装されているので、JSの正規表現を扱えない

点があります。

ですが、今はdenops.vimという画期的なエコシステムがあります。
これを使ってTypeScriptでプラグインを実装すれば、この点を簡単に解消できるのではないかと思い付きました。

また、denops.vimはDenoというランタイムを利用しているのですが、Denoの標準ライブラリにはtomlやyamlのパーサが含まれています。
jsonは人間が直接書くものではないと思っているので、これらの形式を簡単にサポートできるのも魅力に感じました。

そして、せっかくTypeScriptで実装するのならプログラマブルな定義にも対応しようと思い、今の形に行き着きました。

(あとよく使うものなので一度自作してみたかった)
(ぶっちゃけモチベの8割はこれです)

チュートリアル

依存先である denops.vim のセットアップは済ませておいてください。

このプラグインを利用する流れは以下のようになります。

  1. 定義ファイルを作成する
  2. denippet#load()で読み込む
  3. triggerを入力して<Plug>(denippet-expand)で展開する

順に説明していきましょう。

定義ファイルの作成

TypeScript/json/toml/yamlから好きな形式を選んでください。
作者はtoml推しなので、今回はtomlを使ってみましょう。

  • ~/.config/nvim/snippets/typescript.toml(パスは任意)
[if]
prefix = 'if'
body = """
if ($1) {
\t$0
}\
"""

これは以下のjsonによる定義と等価です。

{
  "if": {
    "prefix": "if",
    "body": [
      "if ($1) {",
      "\t$0",
      "}"
    ]
  }
}

オブジェクトのキーは単なる識別子なので、prefixを書く場合はなんでもいいです。
もしprefixが省略された場合は、この値が代わりに使われます。

prefixはSnippetを展開するためのトリガーです。
文字列の配列を使って、複数のトリガーを設定することもできます。

bodyは実際に展開される文字列です。
配列の場合は、それぞれが1行として認識されます。
jsonでも\nを使って1行で書くことはできますが、読み書きしづらいのでオススメはしません。
tomlでは"""で囲むことで複数行の文字列が利用できます(最後の\は末尾の改行を消すためのものです。最初の改行は自動で消えます)。

行頭の\tはタブ文字ですが、denippetはそのファイルタイプで使われている設定を参照して適切なインデントに変換します。

$1や$0はTabstopと呼ばれるもので、Tabstop間はジャンプで1発で移動できます。
$0は最後のジャンプ先を意味します。
この場合は$2と書いても同義です。

denippet#load()で読み込む

denippet#load()について、helpから引用します。

denippet#load({filepath} [, {filetype}...])		*denippet#load()*
	Load the snippet definition file.

	Multiple filetypes can be specified.
	If {filetype} is omitted, it is the {filename} removed the extension.

	Using * for {filetype}, a global snippet can be defined. This is a
	snippet available for all 'filetype'.

denippetはSnippetをファイルタイプに紐付けて管理します。
{filetype}を省略した場合は、拡張子を除いたファイル名が使われるようになっています。

作者は、特定のディレクトリにまとめて配置することで管理しています。
非同期なのでまとめて呼んでも本体はブロックされません。

let dir = stdpath('config') . '/snippet/'
for file in glob(dir . '*.*', 1, 1)
  call denippet#load(file)
endfor

triggerを入力して<Plug>(denippet-expand)で展開する

マッピングを用意しましょう。
今回は<C-l>をexpandに、<Tab>/<S-Tab>をjumpにします。

inoremap <C-l> <Plug>(denippet-expand)
inoremap <expr> <Tab> denippet#jumpable() ? '<Plug>(denippet-jump-next)' : '<Tab>'
snoremap <expr> <Tab> denippet#jumpable() ? '<Plug>(denippet-jump-next)' : '<Tab>'
inoremap <expr> <S-Tab> denippet#jumpable(-1) ? '<Plug>(denippet-jump-prev)' : '<S-Tab>'
snoremap <expr> <S-Tab> denippet#jumpable(-1) ? '<Plug>(denippet-jump-prev)' : '<S-Tab>'

これで'filetype'typescriptのファイルを開き、ifと入力してから<C-l>を押せばSnippetを展開できます。
展開後は<Tab>/<S-Tab>でジャンプできます。

うまくいかないときは'filetype'typescriptreactなどになっていないか確認してみてください。

機能の紹介

Tabstopのコピー

同じ番号のTabstopが複数あるときは、同期されコピーになります。

for (let ${1:i} = ${2:0}; $1 < ${3:10}; ${4:$1++}) {
	$0
}\

Placeholderのネスト

Tabstopにはデフォルト値を入れることができ、このようなTabstopのことをPlaceholderといいます。
この中で、他のTabstopのコピーを使うことができます。

console.log($1${2:, $1})

Snippetのネスト

Snippetの展開中に別のSnippetを展開することができます。
内部のSnippetの最後のTabstopからジャンプすると、外側のSnippetのTabstopに移動できます。

if ($1) {
  $0
}

Choice

Tabstopでは自由に入力するだけでなく、候補の中から選ぶこともできます。

マッピングを用意しておきましょう。

inoremap <expr> <C-j> denippet#choosable() ? '<Plug>(denippet-choice-next)' : '<C-j>'
inoremap <expr> <C-k> denippet#choosable(-1) ? '<Plug>(denippet-choice-prev)' : '<C-k>'

文法は以下のようになります。

${1|foo,bar,baz|}

カンマで区切られたそれぞれの文字列が候補です。

本当は候補の一覧を表示させたいのですが、自動補完プラグインとの干渉やVim/Neovimの差分(popup/floating window)を考えると本体でサポートするのは困難です。
必要なAPIは用意したので、欲しい人は各自の環境に合わせて設定を書いてもらえればと思います。

こちらを参考にしてください。

https://github.com/uga-rosa/denippet.vim/wiki/choice‐popup

TypeScriptを使ったSnippetの定義

関数として定義することで動的にbodyを決めることができます。
snippetsをexportすることを忘れないでください。

以下の例はVim scriptのautoload functionです。
ディレクトリ構成からprefixを自動的に計算します。

import { TSSnippet } from "https://deno.land/x/denippet_vim@v0.4.1/loader.ts";
import { Denops, fn } from "https://deno.land/x/denippet_vim@v0.4.1/deps/denops.ts";

export const snippets: Record<string, TSSnippet> = {
  "autoload function": {
    prefix: "fn",
    body: async (denops: Denops) => {
      const path = await fn.expand(denops, "%:p") as string;
      const match = path.match(/autoload\/(.+)\.vim$/);
      const prefix = match ? match[1].replaceAll("/", "#") + "#" : "";
      return [
        `function ${prefix}$1($2) abort`,
        "\t$0",
        "endfunction",
      ];
    },
  },
};

正規表現を用いたコピーの加工

複数の同一Tabstopがあるとき、コピーとして扱われることは上述の通りです。
そのとき単純にコピーするだけではなく、正規表現を使って加工することができます。

const [${1:state}, set${1/(.*)/${1:/capitalize}/}] = useState($2)

この加工は、そのTabstopの入力中は反映されません。
これは不完全な入力に対する正規表現のエラーを防ぐためです。
別のTabstopにジャンプしたり、Normal modeに戻るときに反映されます。

構文について簡単に説明しておきます。

${n/regex/format/flags}

nはTabstopのID、regexはJSの正規表現、optionは正規表現のflagsです。

formatが少々特殊で、ここでは通常の文字列の他にサブマッチへのアクセスが可能です。
上記のuseStateの例で、${1:/capitalize}という表現があります。
これは「サブマッチの1つ目をcapitalize(先頭を大文字にする)したもの」を意味します。
/capitalizeの他に/upcase/downcase/camelcase/pascalcaseが利用可能です。

勿論そのまま使うことも可能です。

$1 ${1/(.)(.)(.)/$3$2$1/}

例えばこのスニペットを展開し$1にabcと入力すれば、コピー先にはcbaが入力されます。

変数

これらの変数を全てサポートしています。ただし一部の対応する概念がないものは、常に空文字列を返します。

https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables

またオリジナルの変数としてVIMLUAJS があります。
文字列をVim script、Lua、JavaScriptとして評価できます。

${VIM:['foo', 'bar'][0]}
-> foo
${LUA:math.floor(1.23)}
-> 1
${JS:(() => 1 + 1)()}
-> 2

Snippetの条件指定

ifを使ってSnippetの条件を指定することができます。

[print]
prefix = ["p", "print"]
body = "console.log($0)"
if = "start"

これは補完にも影響します。必要なときだけ補完に出てくるようにできるわけです。

以下のものが利用できます。

  • base
    • バッファが空(prefix除く)のときだけ
  • start
    • 行頭(prefix除く)のときだけ
  • vimscript
  • lua
    • evalを評価してtrueを返したときだけ

vimscriptluaのときはevalフィールドの文字列を評価します。

例えば、denoのshebangは1行目以外で使うことはないでしょう。

[shebang]
prefix = "#"
body = "#!/usr/bin/env -S deno run -A"
if = "vimscript"
eval = "line('.') == 1"

便利なテクニック

補完プラグインとの連携

全てのprefixを覚えておくのは難しく、それを解消してくれるのが補完プラグインとの連携です。

私はddcユーザーなので、ddc-sourceだけは同梱しています。

nvim-cmp用のsourceも一応作っておきました。
継続的なメンテは厳しいと思うので、メンテしてくれる方募集中です。

https://github.com/uga-rosa/cmp-denippet

既存のSnippetコレクションを読み込む

friendly-snippetsのようなSnippetのコレクションを読み込むこともできます。

https://github.com/rafamadriz/friendly-snippets

プラグインマネージャーを使ってダウンロードすると管理が楽です。
lazy.nvimを使っている筆者の場合、以下のような設定で読み込めます。

local options = require("lazy.core.config").options
root = vim.fs.joinpath(options.root, "friendly-snippets", "snippets")
for name, type in vim.fs.dir(root) do
  if type == "file" then
    vim.fn["denippet#load"](vim.fs.joinpath(root, name))
  elseif type == "directory" then
    local dirname = name
    for name2, type2 in vim.fs.dir(vim.fs.joinpath(root, dirname)) do
      if type2 == "file" then
        vim.fn["denippet#load"](vim.fs.joinpath(root, dirname, name2))
      end
    end
  end
end

VSCodeとの互換性のため、code-snippets拡張子にも対応しています。

細かな挙動の設定

  • g:denippet_sync_delay

コピーの挙動を制御します。

デフォルトは0で、これは遅延なしで常に同期します。

-1にすると入力中の同期をやめます。
別のTabstopにジャンプしたりNormal modeに戻るときにだけ更新されます。

1以上の値にするとdebounceするようになります(単位はミリ秒)。
その時間入力が止まると同期処理が走ります。

  • g:denippet_drop_on_zero

$0にジャンプしたときにdropするかどうかを設定できます。
デフォルトはv:falseです。

dropは現在展開しているSnippetを破棄することで、通常はNormal modeに戻るときに発火します。
dropするとジャンプで戻れなくなりますし、コピー処理も止まります。

これをv:trueに設定することで、$0にジャンプしたときに自動的にdropするようになります。
ただしSnippetがネストしている場合は、一番外側の$0に達するまでdropされません。

dropさせないと入力を監視する処理が走り続けます。
あまりInsert modeから抜けずに、そのまま入力を続けるような使い方をする場合は設定した方が良いかもしれません。

終わりに

全ての機能を解説した訳ではありませんが、主要な機能はお伝えできたかと思います。

作るのはとても楽しかったですが、複雑な状態を扱うものなので難しい部分も多かったです。
ですがその甲斐あって、なかなかの完成度になったと思っています。
今後も開発を継続していくつもりなので、バグ報告や機能要望があれば是非送ってください!

GitHubで編集を提案

Discussion