Deno を使って Vim/Neovim のプラグインを書く by denops.vim

10 min read読了の目安(約9800字

どうも、最近 Rust と Deno にハマってるありすえです。

今日は vim-jp で開発を開始した denops.vim の紹介と denops.vim を利用したプラグイン作成のチュートリアルを書きたいと思います。

denops.vim とは

denops.vim は JavaScript/TypeScript のランタイムである Deno を利用して Vim/Neovim 双方で動作するプラグインを作るためのエコシステムです。以下のような特徴があります。

  • Vim / Neovim で同一コードを利用可能
  • Vim プラグインとしてインストールが可能
  • Vim script と比較してエンジンが爆速なのでゴリ押しが可能
  • ユーザーによるライブラリの依存管理が不要
  • プラグインが別プロセスとして動作するため Vim が固まりにくい
  • プラグイン毎にスレッドが分かれているため相互干渉が起こりにくい
  • プラグイン毎にパーミッションを設定可能なため安全性が高い(未実装)

用語集

用語 意味
Denops Deno をランタイムとして利用した Vim/Neovim のプラグインエコシステムです
Denops プラグイン Denops を用いて書かれた Vim/Neovim 双方で動作するプラグインを表します

Denops のインストール

Denops プラグインを利用するためには Denops 自体をインストールする必要があります。
これは Denops プラグインを利用するだけのユーザーも行う必要があります。

0. Deno のインストール

https://deno.land/#installation を参考に Deno をインストールしてください。
Getting Started の以下のコマンドを実行して結果が帰ってくれば成功です。

deno run https://deno.land/std/examples/welcome.ts

なお、既にインストール済みであれば、以下のコマンドで最新版にアップデートしてください。

deno upgrade

1. Denops のインストール

通常の Vim プラグインとして denops.vim をインストールしてください。
例えば vim-plug を利用している場合には .vimrc に以下のように記載してから :PlugInstall を実行します。

Plug 'vim-denops/denops.vim'

Denops は VinEnter で処理を実行するため dein.vim 等の遅滞ロード機能は非推奨です。

開発チュートリアル

ここから小さな Denops プラグインを実際に作ってみます。プラグイン名は helloworld でプラグインディレクトリはホーム直下の dps-helloworld と仮定します。

0. プラグイン開発前準備

Vim プラグインは Vim の runtimepath に存在している必要があります。Denops プラグインも Vim プラグインであるため、同様に runtimepath に存在する必要があります。そのため、以下を .vimrc に追記してください。

set runtimepath^=~/dps-helloworld

次に Deno の起動時型チェックを有効にするため、コマンド引数を変更します。
以下を .vimrc に追記してください。

let g:denops#server#service#deno_args = [
     \ '-q',
     \ '--unstable',
     \ '-A',
     \]

型チェックが有効な状態では、Deno の起動に時間がかかります。開発が落ち着いたらコマンド引数の明示指定を消してください。

1. プラグインディレクトリ構造の作成

まず以下のコマンドで ~/dps-helloworld を作成し、作業ディレクトリを変更します。
Windows を利用している方は、適時コマンドを読み替えてください。

mkdir ~/dps-helloworld
cd ~/dps-helloworld

次に以下のコマンドで必要最低限のディレクトリ構造を作成します。

mkdir -p denops/helloworld
touch denops/helloworld/app.ts

最終的に以下のようなディレクトリ構造になっていれば OK です。

dps-helloworld
└── denops
    └── helloworld
        └── app.ts

Denops は自動的に runtimepath 内の denops/*/app.ts を読み込みます。
そのため上記のような構造が Denops プラグインの基本型となります。

denops.vim v0.11.0 以前は app.ts の代わりに mod.ts ファイルを読み込んでいました

2. 骨組みの追加

Denops プラグインは denops-std という Deno モジュールの main() 関数で初期化を行います。そのため app.ts の内容を以下のように書き換えてください。

app.ts
import { main } from "https://deno.land/x/denops_std@v0.8/mod.ts";

main(async ({ vim }) => {
  // ここにプラグインの初期化処理を記載する
  console.log("Hello Denops!");
});

denops-std v0.7 以前は start() 関数にて以下のように初期化していました。

app.ts
import { start } from "https://deno.land/x/denops_std@v0.7/mod.ts";

start(async (vim) => {
  // ここにプラグインの初期化処理を記載する
  console.log("Hello Denops!");
});

この状態で一度 Vim を再起動すると起動時に [denops] Hello Denops! が表示されます。

3. API の追加

Denops では各プラグインが API を関数として登録します。まず、与えられた文字列を返却する echo() 関数を API として登録してみましょう。app.ts を以下のように書き直してください。

app.ts
import { main } from "https://deno.land/x/denops_std@v0.8/mod.ts";

main(async ({ vim }) => {
  vim.register({
    async echo(text: unknown): Promise<unknown> {
      if (typeof text !== "string") {
        throw new Error(
          `'text' attribute of 'echo' in ${vim.name} must be string`,
        );
      }
      return await Promise.resolve(text);
    },
  });
});

引数が全て unknown 型で戻り値が Promise<unknown> もしくは Promise<void> な関数のみ API として登録可能です。

これで echo という API が helloworld というプラグインに登録されます。この API を呼び出すには denops#request({plugin}, {func}, {args}) を利用します。Vim を再起動後以下のコマンドを実行してみてください。

:echo denops#request('helloworld', 'echo', ["Hello Denops!"])

これにより Hello Denops! が表示されれば成功です。

なお denops#request('helloworld', 'echo', [123]) のように、文字列以外を与えると以下のようにエラーを吐きます。

4. Vim 機能の呼び出し

Denops プラグインから Vim の機能を呼び出すにはコールバックに渡される vim インスタンスを利用します。
先ほどの echo API を Vim のコマンドとして登録してみるので、以下のように app.ts を変更してください。

app.ts
import { main } from "https://deno.land/x/denops_std@v0.8/mod.ts";

main(async ({ vim }) => {
  vim.register({
    async echo(text: unknown): Promise<unknown> {
      if (typeof text !== "string") {
        throw new Error(
          `'text' attribute of 'echo' in ${vim.name} must be string`,
        );
      }
      return await Promise.resolve(text);
    },
  });

  await vim.execute(`
    command! -nargs=1 HelloWorldEcho echomsg denops#request('${vim.name}', 'echo', [<q-args>])
  `)
});

vim.execute() は渡された複数行文字列を Vim script として実行します。また vim.name は実行中のプラグイン名を表します。これにより HelloWorldEcho コマンドが登録されるので Vim を再起動後以下のコマンドを実行してください。

:HelloWorldEcho Hello Vim!

これにより以下のように Hello Vim! が表示されれば成功です。

なお vim の詳細 API は https://doc.deno.land/https/deno.land/x/denops_std/mod.ts#Vim を参照してください。

5. 実用的なプラグインの開発

ここまでで、基本的なプラグインの作り方は説明したので、次は実用的なプラグインを作ってみます。

突然ですが、皆様はプログラミングしているときに突然迷路を解きたくなったことはありませんか?
僕はありません。
ただ、世の中には迷路が好きで好きでたまらない人もいるはずなので Vim からいつでも迷路を生成して表示できるプラグインを作ってみます。

迷路生成アルゴリズムから自作しても良いのですが、せっかく Deno を利用しているのでサードパーティの迷路生成ライブラリである maze_generator を使います。
まず HelloWorldEcho コマンドと同様にして Maze コマンドを定義し、内部では迷路を生成して console.log() で出力します。

app.ts
import { main } from "https://deno.land/x/denops_std@v0.8/mod.ts";
import { Maze } from "https://deno.land/x/maze_generator@v0.4.0/mod.js";

main(async ({ vim }) => {
  vim.register({
    async maze(): Promise<void> {
      const maze = new Maze({}).generate();
      const content = maze.getString();
      console.log(content);
    },
  });

  vim.execute(`
    command! Maze call denops#request('${vim.name}', 'maze', [])
  `);
});

Vim を再起動し以下のコマンドで出力を確認すると迷路が生成できているのがわかります。

:Maze
:mes

これで完成でもいいのですが、少し味気がないのでバッファに出力してみましょう。以下のようにプログラムを書き換えてください。

app.ts
import { main } from "https://deno.land/x/denops_std@v0.8/mod.ts";
import { Maze } from "https://deno.land/x/maze_generator@v0.4.0/mod.js";

main(async ({ vim }) => {
  vim.register({
    async maze(): Promise<void> {
      const maze = new Maze({}).generate();
      const content = maze.getString();
      await vim.cmd("enew");
      await vim.call("setline", 1, content.split(/\n/));
    },
  });

  vim.execute(`
    command! Maze call denops#request('${vim.name}', 'maze', [])
  `);
});

上記では vim.cmd() で Vim の enew コマンドを呼び出し新規バッファを現在の Window で開いた後 vim.call()setline() 関数を呼ぶことでバッファに迷路を書き込んでいます。
これを実行すると以下のようになります。

良い感じですね。
これで終わりでもいいですが、せっかくなので enew 以外のコマンドを外部から与えられるようにしたり、現在の表示領域から迷路を生成したりなどいろいろ改良を加えて以下のようにしてみました。

app.ts
import { main } from "https://deno.land/x/denops_std@v0.8/mod.ts";
import { Maze } from "https://deno.land/x/maze_generator@v0.4.0/mod.js";

main(async ({ vim }) => {
  vim.register({
    async maze(opener: unknown): Promise<void> {
      if (typeof opener !== "string") {
        throw new Error(`'opener' must be a string`);
      }
      const [xSize, ySize] = (await vim.eval("[&columns, &lines]")) as [
        number,
        number
      ];
      const maze = new Maze({
        xSize: xSize / 3,
        ySize: ySize / 3,
      }).generate();
      const content = maze.getString();
      await vim.cmd(opener || "new");
      await vim.call("setline", 1, content.split(/\r?\n/g));
      await vim.execute(`
        setlocal bufhidden=wipe buftype=nofile
        setlocal nobackup noswapfile
        setlocal nomodified nomodifiable
      `);
    },
  });

  vim.execute(`
    command! -nargs=? -bar Maze call denops#request('${vim.name}', 'maze', [<q-args>])
  `);
});

ちゃんと小さな迷路ができてますね。

おわりに

どうでしょう?Denops を利用すると、かなり簡単に Vim/Neovim で動くプラグインが作れると思いませんか?
まだまだ開発中ですが Vim/Neovim 双方で効率的に動くポータビリティが高いエコシステムになっていると思います。
よければ、このチュートリアルと以下のドキュメントを参考に Denops プラグインを作ってみてください。

皆様のフィードバックをお待ちしております 🙇

ポエム:開発動機

今 Vim/Neovim の関係は大きな変貌期にいます。

Vim 側は Vim script の欠点を補った新しい言語である Vim 9 script の開発を進めており Neovim 側は Vim script を完全に捨てて Lua に移行しようとしています。

このように Vim/Neovim の乖離が大きく広がっており、双方で動作するプラグインを書くのが非常に難しくなってきている状態です。

そんな中 coc.nvim はランタイムに Node.js を採用し Vim/Neovim のプラグイン機構の で独自のエコシステムを展開することで Vim/Neovim 双方をサポートすることを可能にしています。

しかし coc.nvim が採用している Node.js は依存管理が複雑なため、プラグインとして利用するにはビルドが必要だったりと、エコシステムとして使い勝手が良いものではありません。

そのため依存管理を内包し、バイナリ一つがあれば動作する Deno をベースにすれば Vim/Neovim 双方で動作し、開発も簡単なエコシステムができるのではないか?と思い開発に踏み切りました。

この記事に贈られたバッジ