できること

Neovimで、MarkdownのTODOをキー操作で切り替えています。外部プラグインは不要です。

TODOの行にカーソルを置き、Space → x と押します。もう一度押すと、元に戻ります。

- [] 記事を書く  ↔  - [x] 記事を書く
  • 同じ行にいれば、カーソルの位置はどこでもよい
  • :Todo コマンドでも切り替えられる
  • -、+、* の箇条書きと、番号付きリストに対応
  • 本文のない - [] や - [x] も切り替えられる

自分のLeaderキーはSpaceです。下の例では、その設定からNeovimのAPIへの接続まで載せています。

未完了の [] は手元の略記です。GitHubでも使う場合の注意点は、末尾にまとめます。

ソースコード:自分のNeovim設定への組み込み例

使うのは、Lua標準関数とNeovimのAPIだけです。Tree-sitterや描画プラグインも必要ありません。

共通設定でLeaderキーをSpaceにする

自分は ~/.config/nvim/lua/core/options.lua で指定しています。

vim.g.mapleader = " "

このファイルは init.lua の require("core.options") で読み込みます。Leaderキーの指定は、キーバインドの登録より先に済ませます。

Markdown用のファイルに、実装と登録処理を置く

次が ~/.config/nvim/ftplugin/markdown.lua の全体です。Markdown用に読み込まれる設定ファイルです。

先頭は折り返しの設定です。TODOの実装とNeovimへの接続は、toggle_todo から下にあります。

-- Markdown は文章として読みやすい表示を優先する。
vim.opt_local.wrap = true
vim.opt_local.linebreak = true
vim.opt_local.breakindent = true
vim.opt_local.breakindentopt = { "shift:2", "min:20" }
vim.opt_local.showbreak = ""

local function toggle_todo()
 local line = vim.api.nvim_get_current_line()
 local prefix, state, suffix = line:match("^(%s*[-+*]%s+)%[([ xX]?)%](.*)$")
 if not prefix then
  prefix, state, suffix = line:match("^(%s*%d+[.)]%s+)%[([ xX]?)%](.*)$")
 end
 if not prefix then
  return
 end

 local checked = state == "x" or state == "X"
 vim.api.nvim_set_current_line(prefix .. (checked and "[]" or "[x]") .. suffix)
end

vim.api.nvim_buf_create_user_command(0, "Todo", toggle_todo, { desc = "Toggle Markdown TODO checkbox" })
vim.keymap.set("n", "<leader>x", toggle_todo, { buffer = true, desc = "Toggle Markdown TODO checkbox" })

実装の要点

切り替えは、現在行を書き換えるだけ

Luaの string.match で、現在行を三つに分けます。

変数- [ ] 記事を書く の場合
prefix"- "
state" "
suffix" 記事を書く"

x と X は完了、それ以外は未完了として扱います。前後の文字列を残し、角括弧の中だけを切り替えます。

関数をキーとコマンドに登録する

自分は ftplugin/markdown.lua の末尾で、同じ関数を登録しています。

vim.keymap.set("n", "<leader>x", toggle_todo, { buffer = true })
vim.api.nvim_buf_create_user_command(0, "Todo", toggle_todo, {})

これは説明用に desc を省いた例です。Space → x と :Todo のどちらでも、toggle_todo が呼ばれます。

登録先はMarkdownのバッファだけです。呼ばれた関数が、現在行を読み書きします。

編集だけを担当し、描画には関わらない

書き換えるのは、バッファ内の [] と [x] です。アイコンの表示や、プラグインの状態は参照しません。

render-markdown.nvimなどによる装飾は任意です。導入しなくても、このキーバインドは動きます。

Enterで次のリスト項目を作る処理も別機能です。この切り替え処理の依存先には含まれません。

使うときの注意点

[] は、GitHub Flavored Markdownの書式 ではありません。GitHubでも使うなら、未完了を [ ] に戻す形に変えます。

vim.api.nvim_set_current_line(prefix .. (checked and "[ ]" or "[x]") .. suffix)

この実装には、次の制約もあります。

  • 切り替えは文字列だけを判定するため、コードブロック内も対象になる
  • 引用の > - [] には対応しない
  • [ ] から完了にして戻すと、元の [ ] ではなく [] になる
  • 普通の箇条書きに、チェックボックスを新規追加する機能はない

動作確認

切り替え処理は、プラグインなしで検証できます。以下のテストは、Neovim v0.12.0-nightly+fc7e5cf で9件すべて通っています。

切り替えテストと実行方法

ftplugin/markdown.lua を配置し、次を check-todo.lua として保存します。

dofile(vim.fn.stdpath("config") .. "/ftplugin/markdown.lua")

local cases = {
 { "- [] 記事を書く", "- [x] 記事を書く" },
 { "- [ ] 記事を書く", "- [x] 記事を書く" },
 { "- [x] 記事を書く", "- [] 記事を書く" },
 { "- [X] 記事を書く", "- [] 記事を書く" },
 { "  + [] 確認", "  + [x] 確認" },
 { "1) [x] 確認", "1) [] 確認" },
 { "- []", "- [x]" },
 { "- 普通の箇条書き", "- 普通の箇条書き" },
 { "> - [] 引用", "> - [] 引用" },
}

for _, case in ipairs(cases) do
 vim.api.nvim_set_current_line(case[1])
 vim.cmd("Todo")
 assert(vim.api.nvim_get_current_line() == case[2], case[1])
end

print("TODO toggle: " .. #cases .. " cases passed")

保存先のディレクトリで実行します。-l に対応したNeovimが必要です。

nvim --headless -u NONE -l check-todo.lua

-u NONE で通常の設定を読み込まずに起動します。dofile で対象ファイルだけを読み込みます。

成功すると TODO toggle: 9 cases passed と出力されます。

このテストは、切り替え処理だけを確認します。描画やEnterのリスト継続は対象外です。