🐼

TOMLはスニペット書くのに良い感じ

2023/11/06に公開

結論

  • いわゆる「スニペット」を保存しておくファイルにはTOML形式が良い
  • 簡単なコンバーター作った(for Neovim)

背景

Vim/Neovim用のスニペットプラグインとして、vsnipは便利です。
利用人口の多いVisual Studio Code(VSCode)の豊富なスニペットがそのまま使えるという利点が素晴らしいですね。

ですが、VSCodeのスニペットはJSON形式で、自分で書こうと思うとエスケープまみれで軽く地獄です。

"async-readable-stream": {
    "prefix": "asyncreadablestream",
    "body": "new ReadableStream({\n  async start(controller) {\n    try {\n      $1\n    } finally {\n      controller.close()\n    }\n  },\n});"
},

text blockを書けた方が良いですね。

TOMLが良い感じ

text blockを書ける軽量データシリアライズ形式といえば、YAMLが真っ先に浮かびますが、
データ構造がインデントに影響するので、入れ子のあるコードを置いた途端に少々キモい事が起きます。

async-readable-stream:
    prefix: "asyncreadablestream"
    body: |
        new ReadableStream({
          async start(controller) {
            try {
              $1
            } finally {
              controller.close()
            }
          },
        });

YAMLも他の言語も全部インデントは同じです!という人や、YAMLに慣れた人なら耐えられるかもしれないですね。
個人的にはGo(タブインデント)をここに書き出すととてもキモいです。

そこで、同様にtext blockを持つフォーマットとしてTOMLを採用してみましたが、これがちょうど良い具合です。

[async-readable-stream]
prefix = "asyncreadablestream"
body = """\
new ReadableStream({
  async start(controller) {
    try {
      $1
    } finally {
      controller.close()
    }
  },
});"""

インデントで表現する必要が無いので、スニペットの構造自体はインデントを持たずに書くことができ、スニペット内部のインデントだけに寄せることができます。

Neovim用に変換する処理を書いた

TOML to JSONで変換する処理を作っておけば、TOMLで書いて自動でJSON形式に変換できて便利であろうと考えて、簡単な処理を起こしました。
そしてせっかくそんな変換処理をするなら、ついでにいくつか仕様を盛り込んでみました。

  • 特定のディレクトリ(g:vsnip_snippet_dirsまたはg:vsnip_snippet_dir)に.tomlなファイルを保存したら自動で変換する
  • xxx.AAA.toml, yyy.AAA.tomlをAAA.jsonに変換+マージする
    • 1種類のfileformatに大量のスニペットがあると、スニペットが管理しにくかった
    • deno.typescript.toml, ddu-source.typescript.tomlなどのように、目的別でファイルを分けられる

TOMLとJSONの変換は、LuaやVim scriptだと大変しんどいので、Denoで書いています。
Denops便利。

tomlvsnip.lua:

local group = vim.api.nvim_create_augroup("kyoh86-conf-tomlvsinp", { clear = true })

local function snippet_dirs()
  local candidates = vim.g.vsnip_snippet_dirs or {}
  table.insert(candidates, 1, vim.g.vsnip_snippet_dir)
  return candidates
end
---@param dir string link-followd full path
---@return boolean true if the dir is a snippets directory
local function match_paths(dir, candidates)
  for _, c in pairs(candidates) do
    if vim.fn.resolve(vim.fn.expand(c)) == vim.fn.resolve(vim.fn.expand(dir)) then
      return true
    end
  end
  return false
end

local ext = "toml"
local function open_new_snippet(mods, cmd, dir, filetype)
  vim.ui.input({ prompt = "Prefix (if you don't need, empty): " }, function(input)
    if input == nil then
      return
    end
    local filename = (input == "" and string.format("%s/%s.%s", dir, filetype, ext) or string.format("%s/%s.%s.%s", dir, input, filetype, ext))
    vim.api.nvim_cmd({ cmd = cmd, mods = mods, args = { filename } }, {})
  end)
end

local function open_snippet(filetype, mods)
  local cmd = "edit"
  if mods.split ~= "" or mods.horizontal or mods.vertical then
    cmd = "new"
  end

  local expanded_dir = vim.fn.resolve(vim.fn.expand(vim.g.vsnip_snippet_dir))
  local files = vim.list_extend(vim.fn.glob(string.format("%s/*.%s.%s", expanded_dir, filetype, ext), true, true), vim.fn.glob(string.format("%s/%s.%s", expanded_dir, filetype, ext), true, true))
  if #files == 0 then
    return open_new_snippet(mods, cmd, expanded_dir, filetype)
  end
  table.insert(files, 1, "New one")
  vim.ui.select(files, {
    prompt = "Select file to edit: ",
    format_item = function(file)
      return vim.fs.basename(file)
    end,
  }, function(item, idx)
    vim.cmd.redraw()
    if item == nil then
      return
    end
    if idx == 1 then
      -- new one
      return open_new_snippet(mods, cmd, expanded_dir, filetype)
    end
    vim.api.nvim_cmd({ cmd = cmd, mods = mods, args = { item } }, {})
  end)
end

vim.api.nvim_create_user_command("Snip", function(args)
  local candidates = vim.fn["vsnip#source#filetypes"](vim.fn.bufnr("%"))
  if args.bang then
    open_snippet(candidates[1], args.smods)
    return
  end
  vim.ui.select(candidates, { prompt = "Select type" }, function(item, index)
    vim.cmd.redraw()
    if index == nil then
      return
    end
    open_snippet(item, args.smods)
  end)
end, { desc = "Edit snippet for the current file-type", force = true, bang = true })

vim.api.nvim_create_autocmd("BufWritePost", {
  group = group,
  pattern = "*.toml",
  callback = function(ev)
    local path = vim.fn.resolve(vim.fn.fnamemodify(ev.file, ":p"))
    local candidates = snippet_dirs()
    if not match_paths(vim.fn.fnamemodify(path, ":h"), candidates) then
      return
    end
    vim.fn["denops#request"]("tomlvsnip", "process", {
      path,
      vim.fn.fnamemodify(ev.file, ":t"),
      candidates,
      table.concat(vim.api.nvim_buf_get_lines(ev.buf, 0, -1, false), "\n"),
      4,
    })
  end,
})

vim.api.nvim_create_autocmd({ "BufNewFile", "BufRead" }, {
  group = group,
  pattern = "*.json",
  callback = function(ev)
    local path = vim.fn.resolve(vim.fn.fnamemodify(ev.file, ":p"))
    local candidates = snippet_dirs()
    if not match_paths(vim.fn.fnamemodify(path, ":h"), candidates) then
      return
    end
    vim.api.nvim_buf_create_user_command(ev.buf, "DeconvertToTOML", function()
      vim.fn["denops#request"]("tomlvsnip", "deconvert", {
        path,
        table.concat(vim.api.nvim_buf_get_lines(ev.buf, 0, -1, false), "\n"),
        4,
      })
    end, {})
  end,
})

nvim/denops/tomlvsinp/main.ts:

import { Denops } from "https://deno.land/x/denops_std@v5.0.1/mod.ts";
import { execute } from "https://deno.land/x/denops_std@v5.0.1/helper/mod.ts";
import {
  ensure,
  isArrayOf,
  isNumber,
  isString,
} from "https://deno.land/x/unknownutil@v3.2.0/mod.ts";
import { expandGlob } from "https://deno.land/std@0.193.0/fs/mod.ts";
import { parse } from "https://deno.land/std@0.193.0/toml/parse.ts";
import { stringify } from "https://deno.land/std@0.193.0/toml/stringify.ts";
import { extname, join } from "https://deno.land/std@0.193.0/path/mod.ts";

function deconvertText(text: string) {
  const obj = JSON.parse(text);
  return stringify(obj);
}

export function main(denops: Denops): void {
  denops.dispatcher = {
    process: async (
      unknownPath: unknown,
      unknownName: unknown,
      unknownDirs: unknown,
      unknownText: unknown,
      unknownIndent: unknown,
    ) => {
      const path = ensure(unknownPath, isString);
      const name = ensure(unknownName, isString);
      const dirs = ensure(unknownDirs, isArrayOf(isString));
      const text = ensure(unknownText, isString);
      const indent = ensure(unknownIndent, isNumber);

      const ext = extname(path);
      const extTrim = name.substring(0, name.length - ext.length);
      const ft = (/\..+\./.test(name)) ? extTrim.replace(/^.+\./, "") : extTrim;
      const newPath = join(dirs[0], ft + ".json");
      const obj = parse(text);
      for (const dir of dirs) {
        const glob = `${dir}/*.${ft}.toml`;
        for await (const file of expandGlob(glob, { followSymlinks: true })) {
          if (!file.isFile) {
            continue;
          }
          if (file.path == path) {
            continue;
          }
          Object.assign(obj, parse(await Deno.readTextFile(file.path)));
        }
      }
      await Deno.writeTextFile(newPath, JSON.stringify(obj, null, indent));
      await execute(
        denops,
        `echomsg "Converted to ${newPath}"`,
      );
    },

    deconvert: async (unknownPath: unknown, unknownText: unknown) => {
      const name = ensure(unknownPath, isString);
      const text = ensure(unknownText, isString);

      const ext = extname(name);
      const newPath = name.substring(0, name.length - ext.length) + ".toml";
      await Deno.writeTextFile(newPath, deconvertText(text));
      await execute(
        denops,
        `echomsg "Deconverted to ${newPath}"`,
      );
    },
  };
}

Discussion