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() or vim.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 のコードフェンスのコードをその場で実行できるようになりました。この記事が何かの参考になれば幸いです。