Neovim で Treesitter を使って Markdown コードブロックを自動実行する方法
概要
はじめに
先日、私が開発している Neovim のプラグインの ft-mapper.nvim を V2 にバージョンアップしましたが、このバージョンアップは、当初は Lua を Vimscript に置き換えようとしていました。理由は、マルチバイト文字の取扱いは Vimscript の方が向いているだろうと思ったためです。
とはいえ、いきなり本番用のコードを置き換えるのは無謀なので「小さなコードを書く→コードを実行する→コードを修正する」のサイクルを素早く回してコードを置き換えようとしました。そのためには、コードを書いたら即実行できる環境が必要ですが、Vimscript には Codepen.io のようなオンラインで即座にコードを試せる環境がありません。そこで、色々考えた結果、Markdown のコードフェンスにコードを書いてその場で実行できれば、サイクルを素早く回せる上、コードとメモを一元的に管理できると気付きました。
そこで、Slack の Vim-jp の チャットルーム で相談したところ、コードを選択して :'<,'>source
コマンドを実行すればその場でコードを実行できると教えてもらいました。
これで「即座にコードを試す」ことはできるようになりましたが、これができると、次は「コードフェンスにカーソルがあるときに任意のキーをタイプしたらコードを実行してくれる」機能が欲しくなりました。
この機能は ChatGPT にコードを書いてもらって実現できましたが、「AI に書いてもらいました」で済ませると自分のレベルアップは実現できませんので、AI に書いたコードを頑張って読み解いてみました。そこで、その解読結果を備忘録としてまとめます。
なお、最初にこの機能の処理の流れをまとめると、次のとおりとなります。
- Treesitter を使ってカーソルがあるコードフェンスの開始行と終了行を取得する
- コードフェンスの中にあるコードを取得してテンポラリファイルに書き込む
vim.cmd.source()
orvim.cmd.luafile()
の引数にテンポラリファイルを指定して実行する
実際のコード
実際のコードは以下のとおりです。
1-------------------------------------
2-- Markdownパーサを強制使用して取得
3-------------------------------------
4local function get_md_node_at_cursor(bufnr)
5 bufnr = bufnr or vim.api.nvim_get_current_buf()
6 local ok, parser = pcall(vim.treesitter.get_parser, bufnr, "markdown", {})
7 if not ok or not parser then return nil end
8 local pos = vim.api.nvim_win_get_cursor(0)
9 local row, col = pos[1] - 1, pos[2]
10 local tree = parser:parse()[1]
11 if not tree then return nil end
12 local root = tree:root()
13 return root and root:named_descendant_for_range(row, col, row, col) or nil
14end
15
16
17local function find_child(node, wanted_types)
18 if not node then return nil end
19 for child in node:iter_children() do
20 if wanted_types[child:type()] then
21 return child
22 end
23 end
24 return nil
25end
26
27local function find_fence_content_via_markdown(bufnr)
28 local n = get_md_node_at_cursor(bufnr)
29 while n do
30 if n:type() == "fenced_code_block" then
31 local content = find_child(n, { code_fence_content = true })
32 -- info_string も返す
33 local info_node = find_child(n, { info_string = true })
34 local info = nil
35 if info_node then
36 info = vim.treesitter.get_node_text(info_node, bufnr)
37 end
38 return content or n, info
39 end
40 n = n:parent()
41 end
42 return nil, nil
43end
44
45-------------------------------------
46-- 言語名の抽出と判定
47-------------------------------------
48local function parse_lang_from_info(info)
49 if not info or info == "" then return nil end
50 local lang = info:match("^%s*([%w_+%.%-]+)")
51 if not lang or lang == "" then return nil end
52 -- よくある表記ゆれを吸収
53 local map = {
54 viml = "vim",
55 vimscript = "vim",
56 vim = "vim",
57 lua = "lua",
58 luau = "lua",
59 }
60 return (map[lang] or lang):lower()
61end
62
63-------------------------------------
64-- フェンス中身を文字列として取得(```除外)
65-------------------------------------
66local function get_fence_text(bufnr, node)
67 local sr, sc, er, ec = node:range() -- endはexclusive
68
69 -- exclusive→inclusive調整
70 if ec == 0 then
71 er = er - 1
72 if er < sr then return {} end
73 local last = vim.api.nvim_buf_get_lines(bufnr, er, er + 1, true)[1] or ""
74 ec = #last
75 else
76 ec = ec - 1
77 end
78 -- 「終端行が閉じフェンス」なら1行手前に寄せる
79 local last_line = vim.api.nvim_buf_get_lines(bufnr, er, er + 1, true)[1] or ""
80 if last_line:match("^%s*`%s*`%s*`") then
81 er = er - 1
82 if er < sr then return {} end
83 local prev = vim.api.nvim_buf_get_lines(bufnr, er, er + 1, true)[1] or ""
84 ec = #prev
85 end
86 return vim.api.nvim_buf_get_text(bufnr, sr, sc, er, ec, {})
87end
88
89-------------------------------------
90-- 実行器: Vim / Lua / Sh
91-------------------------------------
92local function exec_vim(lines)
93 local tmp = vim.fn.tempname() .. ".vim"
94 vim.fn.writefile(lines, tmp)
95 vim.cmd.source(tmp)
96 vim.notify("Sourced (Vim): " .. tmp, vim.log.levels.INFO)
97end
98
99
100local function exec_lua(lines)
101 local tmp = vim.fn.tempname() .. ".lua"
102 vim.fn.writefile(lines, tmp)
103 vim.cmd.luafile(tmp)
104 vim.notify("Sourced (Lua): " .. tmp, vim.log.levels.INFO)
105end
106
107-------------------------------------
108-- メイン: 言語自動判定で実行
109-------------------------------------
110local function exec_fence_auto()
111 local bufnr = vim.api.nvim_get_current_buf()
112
113 local content_node, info = find_fence_content_via_markdown(bufnr)
114 if not content_node the
115 vim.notify("Not inside a fenced code block", vim.log.levels.WARN)
116 return
117 end
118 local lines = get_fence_text(bufnr, content_node)
119 if #lines == 0 then
120 vim.notify("Empty fence content", vim.log.levels.WARN)
121 return
122 end
123
124 local lang = parse_lang_from_info(info)
125 -- 既定: info_string が無い/不明 → Vim とみなす(必要なら "markdown" にも対応可)
126 if not lang then lang = "vim" end
127 if lang == "vim" then
128 exec_vim(lines)
129 elseif lang == "lua" then
130 exec_lua(lines)
131 else
132 -- 未対応言語: とりあえず Vim として実行するか、エラーにする
133 vim.notify(("Unsupported fence language: %s"):format(lang), vim.log.levels.ERROR)
134 end
135end
136
137vim.api.nvim_create_autocmd("FileType", {
138 pattern = "markdown",
139 callback = function(args)
140 vim.api.nvim_buf_create_user_command(args.buf, "FenceExec", exec_fence_auto, {})
141 vim.keymap.set("n", "<leader>qr", exec_fence_auto,
142 { buffer = args.buf, desc = "Run fenced code content (force markdown parser)" })
143 end,
144})
コードの解説
ここからコードを再掲しながら内容を解説していきます。なお、エラー処理のコードは再掲・解説ともに省略します。
1. この機能のエントリーポイント
この機能のエントリーポイントは、vim.api.nvim_create_autocmd()
のコールバック関数で呼び出している vim.keymap.set()
の rhs
に指定している exec_fence_auto
関数です。
exec_fence_auto
関数は、最初に現在のバッファ番号を取得し、そのバッファ番号を find_fence_content_via_markdown()
に渡します。
1vim.api.nvim_create_autocmd("FileType", {
2 pattern = "markdown",
3 callback = function(args)
4 vim.api.nvim_buf_create_user_command(args.buf, "FenceExec", exec_fence_auto, {})
5 vim.keymap.set("n", "<leader>qr", exec_fence_auto,
6 { buffer = args.buf, desc = "Run fenced code content (force markdown parser)" })
7 end,
8})
9
10local function exec_fence_auto()
11 local bufnr = vim.api.nvim_get_current_buf()
12 local content_node, info = find_fence_content_via_markdown(bufnr)
13 ...
14end
2. カーソル位置のノード取得
find_fence_content_via_markdown
関数は、まず get_md_node_at_cursor()
にバッファ番号を渡して返り値を n
変数に格納します。
get_md_node_at_cursor
関数は vim.treesitter.get_parser()
にバッファ番号とパーサーの種類を示す文字列("markdown"
)を渡して、parser
変数に構文解析器である LanguageTree
型のオブジェクトを格納します。
1local function find_fence_content_via_markdown(bufnr)
2 local n = get_md_node_at_cursor(bufnr)
3 ...
4end
5
6local function get_md_node_at_cursor(bufnr)
7 bufnr = bufnr or vim.api.nvim_get_current_buf()
8 local ok, parser = pcall(vim.treesitter.get_parser, bufnr, "markdown", {})
9 ...
10end
pcall
を使っているのは、パーサーの取得失敗に備えているためです。
3. Treesitterによる構文解析
次に vim.api.nvim_win_get_cursor(0)
関数でカーソル位置を取得し、行番号と列番号を row
変数と col
変数に格納します。なお、Neovimの行番号は1ベースですが、Treesitterは0ベースなので -1
して調整しています。
1local function get_md_node_at_cursor(bufnr)
2 local pos = vim.api.nvim_win_get_cursor(0)
3 local row, col = pos[1] - 1, pos[2]
4end
それから、parser:parse()[1]
を実行してカレントバッファのテキストの構文木オブジェクトの配列(TSTree[]
)を取得すると同時に、末尾に [1]
を付けることで構文木の最初の要素を取得して tree
変数(TSTree
)に格納しています。
1local function get_md_node_at_cursor(bufnr)
2 local tree = parser:parse()[1]
3end
そして、tree:root()
を実行して構文木オブジェクトから最上位のノード(TSNode
)を取得して root
変数に格納しています。
1local function get_md_node_at_cursor(bufnr)
2 local root = tree:root()
3end
Treesitter の型階層は以下のようになっています:
1LanguageTree (パーサーオブジェクト)
2 ↓ :parse() メソッド
3TSTree[] (構文木オブジェクトの配列)
4 ↓ [1] で最初の要素取得
5TSTree (単一の構文木オブジェクト)
6 ↓ :root() メソッド
7TSNode (ルートノード)
4. カーソル位置の正確な特定
root:named_descendant_for_range()
の引数にカーソル位置を渡して、カーソル位置の名前付きノードを取得して get_md_node_at_cursor()
関数の返り値にします。
1return root and root:named_descendant_for_range(row, col, row, col) or nil
5. フェンスドコードブロックの探索
find_fence_content_via_markdown()
関数は、取得したノードから親ノードを辿り、fenced_code_block
タイプのノードを探します。
1local function find_fence_content_via_markdown(bufnr)
2 while n do
3 if n:type() == "fenced_code_block" then
4 end
5 n = n:parent()
6 end
7 return nil, nil
8end
6. コンテンツと言語情報の取得
fenced_code_block
ノードが見つかったら、そのノードの子ノードの中から特定のノード(code_fence_content
& info_string
)を探すため、find_child()
関数にノードと探索すべきノードのタイプを渡します。
find_child()
関数は、node:iter_children()
関数を使って渡されたノードの子ノードを全て取得し、それを for
文で順番に処理し、指定されたノードが見つかれば返り値とします。
1local function find_child(node, wanted_types)
2 for child in node:iter_children() do
3 if wanted_types[child:type()] then
4 return child
5 end
6 end
7 return nil
8end
find_child()
関数から指定したノードが返されたら、それらを content
変数と info_node
変数に格納します。そして、vim.treesitter.get_node_text()
関数に info_node
変数とバッファ番号を渡して言語名(`
の後ろの文字列)を取得して info
変数に格納しています。
1local function find_fence_content_via_markdown(bufnr)
2 while n do
3 if n:type() == "fenced_code_block" then
4 local content = find_child(n, { code_fence_content = true })
5 local info_node = find_child(n, { info_string = true })
6 local info = nil
7 if info_node then
8 info = vim.treesitter.get_node_text(info_node, bufnr)
9 end
10 return content or n, info
11 end
12 n = n:parent()
13 end
14 return nil, nil
15end
7. コード範囲の正確な取得
これで exec_fence_auto()
に処理が戻り、コードフェンス内のコードを含むノードが content_node
変数に格納され、言語名が info
変数に格納されます。
次に、get_fence_text()
関数にバッファ番号と content_node
変数を渡します。get_fence_text()
関数は、まず node:range()
関数を実行してコードフェンスの範囲を取得して変数に格納します。
1local function exec_fence_auto()
2 local content_node, info = find_fence_content_via_markdown(bufnr)
3 local lines = get_fence_text(bufnr, content_node)
4 if #lines == 0 then
5 vim.notify("Empty fence content", vim.log.levels.WARN)
6 return
7 end
8end
9
10local function get_fence_text(bufnr, node)
11 -- sr -> 開始行の位置
12 -- sc -> 開始行の列の位置
13 -- er -> 終了行の位置
14 -- ec -> 終了行の列の位置
15 local sr, sc, er, ec = node:range() -- endはexclusive
それから、Treesitterの 範囲指定が exclusive であることを考慮した調整(er = er - 1
)や終端行が閉じフェンス(`
)の場合に除外する処理も行ってから、return vim.api.nvim_buf_get_text(bufnr, sr, sc, er, ec, {})
でコードフェンスのコードを取得して返り値にします。
1local function get_fence_text(bufnr, node)
2 -- exclusive→inclusive調整
3 if ec == 0 then
4 er = er - 1
5 if er < sr then return {} end
6 local last = vim.api.nvim_buf_get_lines(bufnr, er, er + 1, true)[1] or ""
7 ec = #last
8 else
9 ec = ec - 1
10 end
11 -- 「終端行が閉じフェンス」なら1行手前に寄せる
12 local last_line = vim.api.nvim_buf_get_lines(bufnr, er, er + 1, true)[1] or ""
13 if last_line:match("^%s*`%s*`%s*`") then
14 er = er - 1
15 if er < sr then return {} end
16 local prev = vim.api.nvim_buf_get_lines(bufnr, er, er + 1, true)[1] or ""
17 ec = #prev
18 end
19 return vim.api.nvim_buf_get_text(bufnr, sr, sc, er, ec, {})
20end
8. 言語の自動判定と正規化
再び exec_fence_auto()
に処理が戻り、コードフェンス内のコードが lines
変数に格納されます。
それから、コードフェンスの言語を判定するため、parse_lang_from_info()
関数に info
変数を渡します。
parse_lang_from_info()
関数は info:match("^%s*([%w_+%.%-]+)")
で言語名を表わすテキストを抽出して lang
変数に格納し、それを map
テーブルで定義している表記ゆれのリストを使って正しい表記に修正し、それを返り値にします。
1local function exec_fence_auto()
2 local lines = get_fence_text(bufnr, content_node)
3 local lang = parse_lang_from_info(info)
4end
5
6local function parse_lang_from_info(info)
7 local lang = info:match("^%s*([%w_+%.%-]+)")
8 local map = {
9 viml = "vim",
10 vimscript = "vim",
11 vim = "vim",
12 lua = "lua",
13 luau = "lua",
14 }
15 return (map[lang] or lang):lower()
16end
9. コードの実行
これで exec_fence_auto()
関数の lang
変数に言語の種類が格納されましたので、言語に応じて適切な実行関数を呼び出します。
1local function exec_fence_auto()
2 local lang = parse_lang_from_info(info)
3 if lang == "vim" then
4 exec_vim(lines)
5 elseif lang == "lua" then
6 exec_lua(lines)
7 end
8end
10. 実行処理の詳細
実際の実行は、テンポラリファイルを経由して行われます。vim.fn.tempname() .. ".vim"
または vim.fn.tempname() .. ".lua"
でテンポラリファイルのファイル名を設定し、そのテンポラリファイルに vim.fn.writefile(lines, tmp)
でコードを書き込み、vim.fn.source()
または vim.fn.luafile()
の引数に渡してコードを実行します。
1local function exec_vim(lines)
2 local tmp = vim.fn.tempname() .. ".vim"
3 vim.fn.writefile(lines, tmp)
4 vim.cmd.source(tmp)
5 vim.notify("Sourced (Vim): " .. tmp, vim.log.levels.INFO)
6end
7
8local function exec_lua(lines)
9 local tmp = vim.fn.tempname() .. ".lua"
10 vim.fn.writefile(lines, tmp)
11 vim.cmd.luafile(tmp)
12 vim.notify("Sourced (Lua): " .. tmp, vim.log.levels.INFO)
13end
11. Markdownファイル専用の設定
最後に、この機能をMarkdownファイルでのみ有効化します。
1vim.api.nvim_create_autocmd("FileType", {
2 pattern = "markdown",
3 callback = function(args)
4 vim.api.nvim_buf_create_user_command(args.buf, "FenceExec", exec_fence_auto, {})
5 vim.keymap.set("n", "<leader>qr", exec_fence_auto,
6 { buffer = args.buf, desc = "Run fenced code content (force markdown parser)" })
7 end,
8})
まとめ
Neovim の Treesitter API を活用することで Markdown のコードフェンスのコードをその場で実行できるようになりました。この記事が何かの参考になれば幸いです。