Vimのfoldexprで新たに折り畳みを定義する方法とその活用法

Table of Contents

はじめに

Vimで十分に活用されていない組み込み機能の一つは折り畳み(Folding)だと思います。私は以前から折り畳みを様々な手段で活用しており、特にお気に入りの機能の一つになっています。この記事では、私がどのように折り畳みを設定しているのか、さらには折り畳みを活用するためのプラグインについてみなさんに共有します。

折り畳みを定義する方法

折り畳みは'foldmethod'に設定する値に応じて、次の方法で定義することができます。

  • manual: 手動で折り畳みを定義する
  • indent: インデントの数を折り畳みのレベル(深さ)とする
  • expr: 折り畳みを定義する式を指定する
  • syntax: 構文強調により折り畳みを定義する
  • diff: 変更されていないテキストを折り畳み対象とする
  • marker: テキスト中の印で折り畳みを定義する
https://vim-jp.org/vimdoc-ja/fold.html#fold-methods

この中で特に良く使われるのはexprmarkerでしょう。markerはソースに{{{のようなマーカーを埋め込むことで、好きな場所に折り畳みを定義することができます。しかし、Vim以外のエディターを使うユーザーにとっては、マーカーは意味のない邪魔な文字列でしかなく、共同編集するソースで使うことはためらわれます。私は、行数の多い個人的なアプリケーションの設定ファイル(vimrcなど)でのみ、markerを使用するのがいいと思います。

exprはどうでしょう?exprでは'foldexpr'に設定された任意の式を評価し、得られた折り畳みレベルを表す特別な値から折り畳みが定義されます。'foldexpr'はファイルタイプに応じて適切なもの設定する必要がありますが、残念ながら組み込みの定義は何もありません。つまり'foldexpr'の設定は自分で定義するか、それを行う何らかのプラグインを導入する必要があります。

私は'foldexpr'を設定する独自のFiletype Pluginをいくつかのファイルタイプについて作成しました。以降では私がどのように'foldexpr'を設定したのか、そして'foldexpr'による折り畳みの限界について説明します。

foldexprの基本的な定義

'foldexpr'は評価されると、次のような折り畳みレベルを表す特別な値を得る式です。式の中では現在の計算対象の行を表すv:lnumが利用できます。

意味
0 対象行は折り畳みに含まれない
1, 2, .. 対象行はこのレベルの折り畳みに含まれる
-1 折り畳みレベルを未定義とし、対象行の直前か直後の行のうち低い方を、対象行のレベルとする。
"=" 直前の行と同じレベルを使用する
"a1", "a2", .. 直前の行のレベルを+1, +2して、結果を現在の行に適用する。
"s1", "s2", .. 直前の行のレベルを-1, -2して、結果を現在の行に適用する。
"<1", "<2", .. 指定したレベルの折り畳みを対象行で終了する
">1", ">2", .. 指定したレベルの折り畳みを対象行から開始する
https://vim-jp.org/vimdoc-ja/fold.html#fold-expr

この時、注意すべきなのは、この式はバッファの編集中に何度も評価されるので、パフォーマンスに可能な限り配慮するべきということです。

NOTE: 各行について式評価が実行されるので、この折り畳み方式は非常に動作が遅くなる可能性がある!

https://vim-jp.org/vimdoc-ja/fold.html#fold-expr

そのため、'foldexpr'の式では対象の言語の文法を厳密に解釈しようとするのではなく、対象の行かその前後に、特定のトークンがあるかどうかをパターンで照合するのが基本となるでしょう。例えば、VimScriptでの折り畳みは次のように定義できます。

setlocal foldexpr=VimFold(v:lnum)
setlocal foldmethod=expr

function! VimFold(lnum) abort
  let current = getline(a:lnum)
  if current =~# '^\s*fu\%[nction]!\?\>'
    return 'a1'
  elseif current =~# '^\s*endf\%[unction]\s*$'
    return 's1'
  endif
  return '='
endfunction
VimScriptの折り畳み設定

この関数は計算対象の行がfunctionendfunctionであるかに応じて、折り畳みレベルを増減させています。この時、functionは入れ子にすることができることから、折り畳みレベルをa1s1で指定することで相対的に増減させています。インデントから絶対的なレベルを計算することができると思うかもしれませんが、次のようなifブロック中で定義された関数で折り畳みレベルが狂うので、相対的な指定の方がより良いです。

if has('nvim')
  function! Greet() abort
    echo 'Hello NeoVim!'
  endfunction
endif
ifブロック中にある関数定義

さらに、VimScriptではLuaの埋め込み等のヒアドキュメントについても考慮する必要があります。例えば次のようにLuaスクリプトが埋め込まれたソースでは、現在行を確認するだけでは不十分です。この場合function greet()の行がfunctionと一致するので、誤って折り畳みの開始がされますが、endは折り畳みの終了を表すendfunctionとは一致しないので、最後まで折り畳みは終了しません。

lua << EOF
function greet()
  print('Hello Lua!')
end
greet()
EOF
VimScriptに埋め込まれたLua

このような問題を回避するためには、functionendfunctionが確かにVimScriptにおける関数の開始と終了を表すトークンかどうかを確認する必要があります。幸いなことに私達にはその手段があります。それは構文ハイライト(Syntax Highlighting)です。構文ハイライトを使うことで先の関数は次のように改良することができます。

Update(2024-05-22): 関数定義のハイライトのチェックを修正しました。

function! VimFold(lnum) abort
  let current = getline(a:lnum)

  if current =~# '^\s*fu\%[nction]!\?\>'
    if s:check_syntax(a:lnum, match(current, '\S') + 1, ['vimFunction', 'vimCommand'])
      return 'a1'
    endif
  elseif current =~# '^\s*endf\%[unction]\s*$'
    if s:check_syntax(a:lnum, match(current, '\S') + 1, ['vimEndfunction', 'vimCommand'])
      return 's1'
    endif
  endif

  return '='
endfunction

function! s:check_syntax(lnum, col, expected_syntax_names) abort
  for syntax in synstack(a:lnum, a:col)
    let name = synIDattr(syntax, 'name')
    if index(a:expected_syntax_names, name) >= 0
      return 1
    endif
  endfor
  return 0
endfunction
構文ハイライトを使った改良版のVimScriptの折り畳み設定

ただし、foldexprでの構文ハイライトの確認は、パフォーマンスの悪化を避けるために、最小限に抑えるべきです。今回の関数では、行に関数の開始と終了を表すトークンが含まれることを確認した上で構文を確認することで、不要な確認を避けています。

foldexprを定義するのが難しい言語

VimScriptでの折り畳みは、関数の開始と終了はfunctionendfunctionと専用のトークンが割り当てられているので、その判定は簡単でした。一方で、例えばC言語では関数の宣言に開始と終了を表す専用のトークンがないので、少なくともその折り畳みは(厳密に構文解析しない限り)困難です。

void greet() {
  printf("Hello, World!");
}
C言語には関数の開始と終了を表すトークンはない

これがRustの場合なら折り畳み対象のブロックにはその開始を示すトークンが必ずあるため(関数ならfn、構造体ならstructなど)、少なくとも折り畳みの開始は正しく定義できます。しかし、一方でその終了はすべて}で表されるので、折り畳みの終了を適切に判定することは困難です。これは、実際には折り畳み対象のブロックが開始していないにも関わらず、折り畳みが終了される可能性を意味します。詳しくは後で説明しますが、誤って折り畳みが終了すると、次行から意図しない折り畳みが開始される場合があります。

fn greet() {
  println!("Hello world!");
}
Rustでは関数の開始はfnで表される

この問題は折り畳みを入れ子にできる数('foldnestmax')を制限することで、不完全ながらもある程度抑止することができます。実際に、次のRust用の'foldexpr'の設定では、インデントから折り畳みレベルを計算するとともに、そのレベルが3以下である場合にのみ、折り畳みが定義されるよう制限しています。

setlocal foldmethod=expr
setlocal foldexpr=RustFold(v:lnum)
setlocal foldnestmax=3

function! RustFold(lnum)
  let current = getline(a:lnum)

  if current =~# '^\s*\%('
  \            . 'macro_rules!'
  \            . '\|\%(unsafe\s\+\)\?impl\>'
  \            . '\|\%(pub\%(\s*(\s*\%(crate\|super\)\s*)\)\?\s\+\)\?\%(const\s\+\)\?\%(unsafe\s\+\)\?\%(async\s\+\)\?\%(extern\s\+"[^"]\+"\s\+\)\?\%(enum\|fn\|mod\|struct\|trait\|union\)\>'
  \            . '\)'
  \  && current !~# '[;}]\s*$'
    let level = indent(a:lnum) / shiftwidth() + 1
    if level <= &l:foldnestmax
      return '>' . level
    endif
  elseif current =~# '^\s*}\s*$'
    let level = indent(a:lnum) / shiftwidth() + 1
    if level <= &l:foldnestmax
      return '<' . level
    endif
  endif

  return '='
endfunction
Rust用のfoldexpr定義

この設定は多くの場合適切に機能しますが、次のソースでは誤った折り畳みが作成されます。

lnum   level   foldexpr   line
------------------------------------------------------
1      1       >1         pub fn main() {
2      1       =              loop {
3      1       =                  if true {
4      1       =                      println!("loop")
5      1       <3                 }
6      2       =                  break;
7      2       <2             }
8      1       <1         }
誤った折り畳みが作成されるRustソース

誤った折り畳みが作成される原因となるのは5行目の}です。この行では実際にはレベル3の折り畳みが開始していないにも関わらず、レベル3の折り畳みの終了が定義されています。その結果、次行から終了した折り畳みの一つ下のレベル2の折り畳みが開始されてしまいます。

これはVim本体のバグである可能があります。少なくともドキュメント化されていない動作です。しかし、これはVimの初期のバージョンからのもので、修正することで既存のfoldexprの設定を壊す可能性があります(恐らく実用上の問題は起きない)。そうだとしても、この動作は予期できない望ましくない動作であるので、バグとして報告しました。

Update(2024-01-18): この問題はVim 9.1.0002で修正されました。

この問題が本体で修正されるまでは、Vim(NeoVim)に次のパッチを当ててビルドすることで、この問題を回避することができます。

--- a/src/fold.c
+++ b/src/fold.c
@@ -3362,7 +3362,7 @@ foldlevelExpr(fline_T *flp)
          break;

    // "<1", "<2", .. : end a fold with a certain level
-   case '<': flp->lvl_next = n - 1;
+   case '<': flp->lvl_next = MIN(flp->lvl, n - 1);
          flp->end = n;
          break;
折り畳みの終了時に決して新しい折り畳みを作成しないようにする(Vim用)
--- a/src/nvim/fold.c
+++ b/src/nvim/fold.c
@@ -2960,7 +2960,7 @@ static void foldlevelExpr(fline_T *flp)

   // "<1", "<2", .. : end a fold with a certain level
   case '<':
-    flp->lvl_next = n - 1;
+    flp->lvl_next = MIN(flp->lvl, n - 1);
     flp->end = n;
     break;
折り畳みの終了時に決して新しい折り畳みを作成しないようにする(NeoVim用)

この修正によって、少なくとも先のRustの折り畳みは、ほとんど完璧に機能するようになります。一方で、C言語の関数のような開始を表すトークンがない場合の折り畳みは、依然として困難です。それを解決する良い方法はないでしょうか?それが以降で紹介するLSP(Language Server Protocol)とTreesitterによる折り畳みです。

LSPによる折り畳み

LSPには変数、クラス、インターフェスなどのプログラム構造を表すDocumentSymbolというデータ構造があります。今回はこのデータ構造を折り畳みを定義するために使います。

DocumentSymbolを取得するためにはLanguage Serverにリクエストを送ってデータを取得する必要があります。 しかし、リクエストは非同期に行われ、その応答と折り畳みの更新のタイミングは一致しないので、実装には工夫が必要でした。

本項では、LSPによる折り畳みをどのように実装したのか説明します。LSPクライアントにはNeoVim組み込みのものを使用したので、動作対象はNeoVimに限られることに注意して下さい。

同期版のLSPによる折り畳み実装

LSPによる折り畳みは'foldexpr'に設定する関数として実装します。まずは折り畳みのベースとなるDocumentSymbolを取得する必要があるでしょう。最初の実装ではシンプルに'foldexpr'から同期版のリクエスト関数(buf_request_sync)を呼び出して、Language Serverにリクエストを送信することにします。

local function calculate_fold_levels(symbols, folds, level)
  if level > vim.wo.foldnestmax then
    return
  end
  for _, symbol in ipairs(symbols) do
    if symbol.range['end'].line - symbol.range.start.line > vim.wo.foldminlines then
      folds[symbol.range.start.line + 1] = '>' .. level
      folds[symbol.range['end'].line + 1] = '<' .. level
      if symbol.children then
        calculate_fold_levels(symbol.children, folds, level + 1)
      end
    end
  end
end

local M = {}

function M.setup(bufnr)
  vim.api.nvim_set_option_value('foldmethod', 'expr', { buf = bufnr })
  vim.api.nvim_set_option_value(
    'foldexpr',
    'v:lua.require("lsp_fold").foldexpr(v:lnum)',
    { buf = bufnr }
  )
end

function M.foldexpr(lnum)
  local bufnr = vim.api.nvim_get_current_buf()
  local changedtick = vim.api.nvim_buf_get_changedtick(bufnr)
  local cache = vim.b.lsp_fold_cache or { folds = {} }

  if cache.changedtick ~= changedtick then
    local line_count = vim.api.nvim_buf_line_count(bufnr)
    for i = 1, #cache.folds do
      cache.folds[i] = '='
    end
    for _ = 1, line_count - #cache.folds do
      table.insert(cache.folds, '=')
    end
    local params = {
      textDocument = vim.lsp.util.make_text_document_params(bufnr),
    }
    local responses = vim.lsp.buf_request_sync(
      bufnr,
      'textDocument/documentSymbol',
      params
    )
    if responses then
      for _, response in ipairs(responses) do
        if response.result then
          calculate_fold_levels(response.result, cache.folds, 1)
        end
      end
    end
    cache.changedtick = changedtick
    vim.b.lsp_fold_cache = cache
  end

  return cache.folds[lnum] or -1
end

return M
同期版のLSPによる折り畳み実装

この実装では、現在のバッファの変更回数を表すchangetickの値を追跡して、バッファが変更された時にLanguage Serverにリクエストを送って折り畳みのキャッシュを更新します。以降、changetickが変更されるまでは、キャッシュされた折り畳みレベルを参照してそれを返します。

この実装は、最初にバッファが読み込まれた時点ではうまく機能しました。しかし、バッファに何らかの変更が発生すると、折り畳みは壊れてしまいました。この時のLanguage Serverの応答を調べると、変更前の一つ古いバッファの状態を元にDocumentSymbolを返しているようです。それは何故でしょうか?

原因を調べるためにNeoVimのLSPクライアントのソースを参照すると、LSPクライアントはnvim_buf_attach()on_linesコールバックでバッファの変更内容を送信していることがわかりました1。恐らく、'foldexpr'の評価はこのコールバックが実行される前にされています。だとすると、DocumentSymbolのリクエストを、LSPクライアントが変更内容を送信した後に実行することで問題は解決するでしょう。

非同期版のLSPによる折り畳み実装

先の同期版の実装ではDocumentSymbolのリクエストを'foldexpr'で行なっていましたが、今回の実装ではその処理はnvim_buf_attach()で登録したコールバックか、最初に初期化関数が呼ばれた時に、非同期で実行されます。その後、DocumentSymbolの応答を受け取ると、そこから折り畳みレベルを計算して、結果をモジュール内の変数に保存します。それらを行うのが次の関数です。

function M.attach(bufnr)
  local state = fold_states[bufnr]

  if state then
    -- This buffer has already been setup. So reuse the state that already
    -- exists and abort detaching.
    state.detached = false
    return
  end

  vim.api.nvim_buf_attach(bufnr, false, {
    on_lines = function(event, bufnr, changedtick)
      local state = fold_states[bufnr]
      if state then
        get_document_symboles(bufnr, state, changedtick)
        return state.detached
      end
    end,
    on_reload = function(event, bufnr)
      local state = fold_states[bufnr]
      if state then
        local changedtick = vim.api.nvim_buf_get_changedtick(bufnr)
        get_document_symboles(bufnr, state, changedtick)
        return state.detached
      end
    end,
    on_detach = function(event, bufnr)
      local state = fold_states[bufnr]
      if state then
        if state.request then
          state.request()
          state.request = nil
        end
        if vim.api.nvim_buf_is_loaded(bufnr) then
          restore_fold_options(bufnr, state)
        end
        fold_states[bufnr] = nil
      end
    end,
  })

  state = new_state(bufnr)
  fold_states[bufnr] = state

  local changedtick = vim.api.nvim_buf_get_changedtick(bufnr)
  configure_fold_options(bufnr)
  get_document_symboles(bufnr, state, changedtick)
end
非同期版のLSPによる折り畳みの初期化関数

折り畳みレベルが更新されたなら、'foldexpr'の再評価を要求する必要があります。それを行うのが次の関数です。ここでは折り畳みが更新された時のちらつき防止するために'lazyredraw'を有効にしています。この対策は残念ながら完璧ではありません。完全にちらつきを防止するにはVim本体の何らかのサポートが必要だと思います。

Update(2024-06-04): winsaveview()winrestview()でウィンドウの状態を復元するように修正しました。

local function update_folds(bufnr)
  local original_lazyredraw = vim.go.lazyredraw
  local view = vim.fn.winsaveview()

  vim.go.lazyredraw = true

  -- Reconfigure 'foldmethod', which forces a re-evaluation of 'foldexpr'.
  vim.api.nvim_set_option_value('foldmethod', 'expr', { scope = 'local' })

  -- The fold under the cursor may be closed, so reopen it.
  if vim.fn.foldclosed(view.lnum) >= 0 then
    vim.cmd.foldopen({ bang = true, range = { view.lnum, view.lnum } })
  end

  vim.fn.winrestview(view)
  vim.go.lazyredraw = original_lazyredraw
end
折り畳みの再評価関数

Update(2024-06-14): 組み込みのtreesitterの折り畳み関数vim.treesitter.foldexpr()ではネイティブ実装された非公開API vim._foldupdate()を呼び出して折り畳みを更新しています。この実装を参考して先程の関数を書き直すことで、折り畳み更新時のちらつきは完全に解消されました!

local function update_fold(bufnr, top, bottom)
  for _, win in ipairs(vim.fn.win_findbuf(bufnr)) do
    if vim.wo[win].foldmethod == 'expr' then
      vim._foldupdate(win, top, bottom)
    end
  end
end

local function request_update_fold(bufnr)
  if vim.api.nvim_get_mode().mode:match('^i') then
    if #(vim.api.nvim_get_autocmds({ group = group, buffer = bufnr })) > 0 then
      return
    end
    vim.api.nvim_create_autocmd('InsertLeave', {
      group = augroup,
      buffer = bufnr,
      once = true,
      callback = function()
        update_fold(bufnr, 0, vim.api.nvim_buf_line_count(bufnr))
      end,
    })
  else
    vim.schedule(function()
      if vim.api.nvim_buf_is_loaded(bufnr) then
        update_fold(bufnr, 0, vim.api.nvim_buf_line_count(bufnr))
      end
    end)
  end
end
vim._foldupdate()を使った改良版の折り畳みの再評価関数

最後に、'foldexpr'で呼び出されるのが次の関数です。折り畳みレベルは事前に計算されているので、この関数ではそれらを参照して行番号に対応するレベルを返すだけです。

function M.foldexpr(lnum)
  local bufnr = vim.api.nvim_get_current_buf()
  local state = fold_states[bufnr]
  if state == nil then
    return -1
  end
  local fold = state.folds[lnum]
  if fold == nil then
    return state.levels[lnum] or '='
  elseif fold.type == TYPE_START then
    return '>' .. fold.level
  else
    return '<' .. fold.level
  end
end
非同期版のLSPによる折り畳み実装のfoldexpr関数

実装のすべては次のリンクから参照することができます。

この実装を使用するには、lsp_fold.lua'runtimepath'luaディレクトリに配置して、LspAttachのイベントコールバックから次のように呼び出します。

vim.api.nvim_create_autocmd('LspAttach', {
  callback = function(args)
    local client = vim.lsp.get_client_by_id(args.data.client_id)
    if client.server_capabilities.documentSymbolProvider then
      require('lsp_fold').setup(args.buf)
    end
  end
})
lsp_fold.luaの設定

そうすると、LSPが有効なバッファでは以下のような折り畳みが定義されます。これで自分の手で'foldexpr'を定義するのが難しい言語であっても、LSPの力を借りて折り畳みを定義できるようになりました!

LSPのDocumentSymbolを使った折り畳み
LSPのDocumentSymbolを使った折り畳み

Treesitterによる折り畳み

前項のLSPによる折り畳みの実装では、バッファーの内容が更新される度にDocumentSymbolの一覧をすべて取得し直して、折り畳みレベルを再計算していました。そのため、バッファーの内容が巨大だとパフォーマンスの問題が発生する可能性があります。もし、バッファーの変更された部分だけを更新することができればより効率的です。Treesitterによる折り畳みはこれを実現することができます。

Treesitterによる折り畳みを使用するには、組み込みのAPIのvim.treesitter.foldexpr()'foldexpr'として設定します。私はFileTypeイベントの発行時、対応するパーサーがインストールされていかどうかを確認して、これを自動的に設定するようにしています。

vim.api.nvim_create_autocmd('Filetype', {
  callback = function(args)
    if require('nvim-treesitter.parsers').has_parser(args.match) then
      vim.api.nvim_set_option_value('foldmethod', 'expr', { scope = 'local' })
      vim.api.nvim_set_option_value(
        'foldexpr',
        'v:lua.vim.treesitter.foldexpr()',
        { scope = 'local' }
      )
    end
  end,
})
Treesitterによる折り畳みの設定

この時、折り畳みの対象となるノードはランタイムディレクトリ内のqueries/{filetype}/folds.scmで定義されます。これらは組み込み、あるいはnvim-treesitterでいくつかのファイルタイプについての設定が提供されます。

しかし、いずれの設定も関数やクラスの定義だけではなく、ifブロック等も折り畳みの対象となるので、この記事が目標とするソースのアウトライン表示には都合が悪いです。Treesitterのクエリは先に読み込まれたものが優先されるので、ユーザー設定で置き換えと良いでしょう。

折り畳みの設定を置き換えるにはfolds.scm~/.config/nvim/queries/{filetype}/以下に作成します。例として、私はLuaの折り畳みを以下のように定義しています。

[
  (function_declaration)
  (function_definition)
] @fold
Luaの折り畳み定義。関数の宣言と定義のみを対象とする。

折り畳みに対する操作

これまで私が定義した折り畳みはソースのアウトラインとなる範囲を示すものでした。これらの範囲を使うことで、関数やクラスの定義に対する操作を提供できます。私はそのために、これから説明する2つのプラグインを作りました。

折り畳みのアウトライン表示

私はいわゆるFuzzy Finderプラグインとしてkuを長く使ってきました。しかし、残念ながらkuは2009年に開発が停止していて、最新のVim(NeoVim)の進化に追従できていません(今でも使うことはできますが)。そこで、私はkuをフォークしてLuis(Lookup UI for Sourcesの頭文字)という新しいプラグインを作りました。Luisでは候補を収集するための様々なソースが提供されます。本項では、その中からfoldソースの実装について説明します。Luis自体の説明はまた別の記事で詳しくしたいと思います。

Luisのfoldソースはバッファ中の折り畳みを候補として収集するもので、次の図のように動作します。

Luisのfoldソースの動作画面
Luisのfoldソースの動作画面

候補が確定されると、その候補が示す折り畳みが開かれて、カーソルはその先頭行に移動します。また、選択中の候補が示す折り畳みは、ポップアップメニュー下部のウィンドウにプレビューされます。

これはまさに、ソースコードのアウトライン表示として機能します!専用のプラグインなしに、組込みの機能である折り畳みとFuzzy Finderプラグインを組み合わせることでアウトライン表示を実現できました。もし、新しい'filetype'をアウトライン表示に対応させたければ、そのための'foldexpr'を設定すれば良いです(そのための方法はこれまで説明してきました)。私はシンプルで拡張性が高いこのやり方をとても気に入っています。

foldソースの折り畳みの収集部分は次のようなっています。この実装1つですべての'foldmethod'の設定に対応しています(exprに限定するならウィンドウの移動なしに実装することができますが汎用性が失なわれます)。実装は非常にシンプルなので、他のFuzzyFinderプラグインに移植するのも簡単です。

function! s:Source.on_source_enter(context) abort dict
  let original_window = win_getid()
  let original_lazyredraw = &lazyredraw

  " Suppress the redrawing during collecting folds.
  set lazyredraw
  " Go to the target window.
  noautocmd call win_gotoid(self.window)
  " Duplicate the target window.
  split

  let bufnr = winbufnr(self.window)
  let candidates = []

  try
    " Close all existing folds.
    normal! zM
    for lnum in range(1, line('$'))
      let foldstart = foldclosed(lnum)
      if foldstart > 0
        let foldtext = s:remove_foldtext_decorations(foldtextresult(lnum))
        let foldend = foldclosedend(lnum)
        let indent = repeat(' ', (foldlevel(lnum) - 1) * 2)
        call add(candidates, {
        \   'word': foldtext,
        \   'abbr': indent . foldtext,
        \   'menu': (foldend - foldstart + 1) . ' lines',
        \   'dup': 1,
        \   'user_data': {
        \     'buffer_nr': bufnr,
        \     'buffer_cursor': [lnum, 1],
        \     'preview_bufnr': bufnr,
        \     'preview_cursor': [lnum, 1],
        \   },
        \   'luis_sort_priority': -lnum,
        \ })
        execute lnum 'foldopen'
      endif
    endfor
  finally
    close
    noautocmd call win_gotoid(original_window)
    let &lazyredraw = original_lazyredraw
  endtry

  let self.cached_candidates = candidates
endfunction
foldソースの折り畳みの収集部分

折り畳みの並べ替え

みなさんは、関数やメソッドの定義をどのような順序で並べますか?私は、より粒度の大きな関数を上に、他の関数から呼ばれる粒度の小さな関数を下に並べることが多いです。とはいえ、その基準は曖昧なもので、同じような粒度の関数がいくつもあると、どのように並べるか迷うことがあります。そのような時には、関数の定義を辞書順に並べ変えることを検討します。

私はまさにそれを行うためのfoldsortというプラグインを作りました。このプラグインは選択された範囲内にある折り畳み同士を入れ替えて並べ変えるための:FoldSortコマンドを提供します。:FoldSortには引数としてパターンを指定することもできます。その場合は折り畳みが示す範囲の中からパターンに一致する文字列を抽出して、その文字列を基準に並べ変えます。パターンが指定されない場合は、折り畳みの先頭行の文字列が基準になります。

このプラグインと、これまで定義してきた折り畳みの設定を組合せることで、関数やメソッドの定義を辞書順に並び換えることができます。それを行なっているのが次の動画です。

foldsortの実行画面
foldsortの実行画面

おわりに

これまで述べてきたように、私は折り畳みを有用な機能だと信じていますが、過小評価されていると思います。

一方、この記事を書く過程でいくつかの問題も見付かりました。'foldexpr'にはバグのような動作があり(修正されました)、非同期に折り畳みを更新するとちらつきが発生します(非公開のvim._foldupdate()を使うと回避可能です)。NeoVimにおいてはLua用の折り畳みAPIはありません。そして、折り畳みの設定もいくらか面倒です(それを行うプラグインが必要かもしれません)。

それでも折り畳みが有用であることに変わりありません。みなさんもこの記事を参考に折り畳みを活用してみて下さい!

You may also like...

  • A keyboard-oriented system tray for X11 tiling window managers

    Typically, tiling window managers like XMonad do not have a system tray. If you want a system tray, you can use a standalone implementation like stalonetray or a status bar implementation with built-in system tray support, such as polybar.

  • Why you should use wcwidth() to calculate a character width

    Character width depends on the font, and Unicode Consortium does not provide explicit width definitions for all characters. There are characters that have ambiguous widths other than those defined as "Ambiguous (A)" in EastAsianWidth.txt. For example, "☀ (U+2600)" is defined as "Neutral (N)" in EastAsianWidth.txt, but its width may be full-width in a CJK font or an emoji font. This means that a non-East Asian character may be also the ambiguous-width character. Character width tables in default locales is problematic for both CJK and non-CJK users. You can create a custom locale to define a better character width table. wcwidth() respects defined by the locale. All TUI applications should consistently use wcwidth() to calculate the width of the character without an embedded character width table. If there is a mismatch in character width between applications, the screen will be broken.

  • XMonadでカスタマイズ可能なマルチカラムレイアウトを

    XMonadで3列以上のレイアウトを提供するモジュールとして、xmonad-contribのThreeColumnsとMultiColumnsが存在する。ThreeColumnsは3列固定のレイアウトで、MultiColumnsは列数が動的に増減するレイアウトだ。しかし、いずれのレイアウトも筆者の要求を満たすものではなく、新しいレイアウトを独自に作成するに至った。

  • 調和の取れたカラーパレット生成をデザインシステムに向けて

    近年、サービス全体の一貫性を確保するためにデザインシステムを構築する例が多い。この時、特に難しいのがカラーパレットの設計で、その理由は以下のような要件があるからだ。