🐡

tataku.vim というプラグインを作っている話

2022/12/11に公開

ここ数か月、tataku.vimというプラグインを作っています。

なぜつくったか

筆者はこれまで、dps-translate-vimdps-paiza-io-vimなどのdeno(denops.vim)を使ってweb apiを実行するプラグインを作ってきました。

その中で、以下の点が"面倒だな"と感じました。

  • プラグインごとにいちいちバッファなどから文字列を取得しなければいけない
  • プラグインごとにいちいちバッファに書き出す処理を書かなければいけない

要するにweb apiを叩く部分がメインなのに、それ以外の部分に気を使わないといけないことが気になっていました。

そこで、入力部分と処理部分、出力部分を分けて実装できるような仕組みを作ることにしました。

構成

tataku.vimでは(今のところ)入力部分をcollector、処理部分をprocessor、出力部分をemitterと呼んでいます。

shellのようにcollector | processor[0] | processor[1] ... | emitterのような流れで文字列を処理していくイメージで作っています。

流れていくデータの型はVimから受け取る文字列を想定しているため、typescriptでいうところのstring[]になっています。

実際に使うには

tataku.vimはデフォルトでcollectorprocessoremitterを含んでいないため、必要なものは別途インストールする必要があります。
(この辺りはddc.vimなどのdark-poweredなプラグインの影響を強く受けています。)

今のところ、以下のものが自分では必要だったので作っています。

tataku.vimではcollector | processor[0] | processor[1] ... | emitterのような一つの流れをRecipeと呼んでいます。

例えば、現在行のテキストをgoogle翻訳を使って英語から日本語に翻訳し、echoエリアに出力するというRecipeは以下のような設定を書くことで実現できます。
(vim-plug形式で書いています。ほかのプラグインマネージャの場合は適宜読み替えてください。)

Plug 'vim-denops/denops.vim'
Plug 'Omochice/tataku.vim'
Plug 'Omochice/tataku-collector-current_line'
Plug 'Omochice/tataku-processor-google_translate'
Plug 'Omochice/tataku-emitter-echo'

let g:tataku_recipes = #{
\  translate: #{
\    collector: #{ name: 'current_line', }, 
\    processor: [#{ name: 'google_translate', options: #{ source: 'en', target: 'ja' }}], 
\    collector: #{ name: 'echo' }, 
\  },
\}

これでtranslateという名前のRecipeが定義されました。

定義したRecipeを呼び出すためには関数tataku#call_recipe()を使います。

この関数は引数でRecipeの名前を取ります。

今回の場合、translateという名前でRecipeを定義しているのでtataku#call_recipe('translate')を実行することで、日本語に変換された現在行がエコーエリアに出力されます。

operatorを使ったレシピの呼び出し

少し脱線しますが、私はVimと他のエディタを比べた時、Vimの強みになるのはモードという概念とそれを基礎とするoperatortextobjectの機能だと考えています。

そこで、tataku.vimをoperatorとして動作させる方法も(暫定的に、ですが)提供しています。

変数g:tataku_enable_operatorv:true1に設定すると、定義したRecipeをoperatorとして使うことができるようになります。

例えば、先述したtranslateのRecipeであれば<Plug>(operator-tataku-translate)でoperatorとして呼び出すことができます。

このとき、Recipe内で定義したcollectorの値は無視され、iwなどのtextobjectを対象としてRecipeを実行します。

processorを自分で定義する方法

collectorprocessoremitterを定義するには、tataku.vimが定義しているCollectorProcessorEmitterのインターフェースを満たすクラスを実装する必要があります。

例えば、['aaa', 'bbb', 'ccc']を受け取った時にそれを['aaaaaa', 'bbbbbb', 'cccccc']にするようなprocessor: doubleは以下のように書けます。

import { Processor } from "https://raw.githubusercontent.com/Omochice/tataku.vim/master/denops/tataku/interface.ts";
import { Denops } from "https://raw.githubusercontent.com/Omochice/tataku.vim/master/denops/tataku/deps.ts";
import {
  isObject,
  isString,
} from "https://deno.land/x/unknownutil@v2.0.0/mod.ts";

export default class implements Processor {
  constructor(private readonly option: Record<string, unknown>){
    if isOption(x) {
      throw new Error("option must has 'times' filed")
    }
  }

  async run(_: Denops, source: string[]) {
    return source.map((e: string) => e.repeat(this.option.times ?? 2))
  }
}

type Option = {
  times?: number
}

function isOption(x): x is Option {
  return isObject(x) && (isNumber(x.times) || isUndefined(x.times))
}

定義したクラスをVimのruntimepathの中の@tataku/processor/double.tsdefault exportすることでdoubleという名前でtataku.vimから使えるようになります。

このクラスはrunという名前の非同期な関数を持つ必要があります。

また、クラスのコンストラクタにはRecipeで設定したoptionsの値が渡されます。

constructor()run()の中でthrowした場合、tataku.vimはそれをcatchしてRecipeの実行を止め、ユーザにエラー内容(今回は"option must has 'times' filed")を伝えます。

最後に

このプラグインはまだ開発途中なので、もしかするとRecipeの定義方法やoperatorの登録方法等が変わる可能性があります。

(operatorとしての登録が all or nothingになってしまう点やprocessorなどの定義でdefault exportを使わないといけないところなど、個人的にもにょっているところが多々あるので、気が向いたらリファクタする、ぐらいの意味です。)

Discussion