🐼
TOMLはスニペット書くのに良い感じ
結論
- いわゆる「スニペット」を保存しておくファイルには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