telescope.nvim で最後に開いた picker の次/前の候補を開く

:::note info

この記事は [Vim 駅伝](https://vim-jp.org/ekiden/)の 2025/1/6 の記事です。前回は yuys13 さんによる「[NeovimのDiagnosticのfloating windowの中に入る](https://zenn.dev/vim_jp/articles/entering-neovim-diagnosic-floating-window)」でした。

:::

## telescope.nvim に足りないもの

「候補を列挙して選択して何らかの操作をするもの」、つまり、ファジファインダーというものを知ったのは [Shougo/unite.vim](https://github.com/Shougo/unite.vim) が初めてでした。その後開発された [Shougo/denite.nvim](https://github.com/Shougo/denite.nvim) を利用中に Neovim の存在を知り、今は [nvim-telescope/telescope.nvim](https://github.com/nvim-telescope/telescope.nvim) のユーザーです。telescope.nvim、大体気にいっているのですが、Unite、Denite、そして更に後継の [Shougo/ddu.vim](https://github.com/Shougo/ddu.vim)[^1] が一貫して実装している機能の中で、telescope.nvim には存在しないものがあります。それが、

* 最後に開いた候補一覧の、次の、あるいは前の候補を開く

というものです。うーん、分かる？分かんないかな、これじゃ。

[^1]: ddu.vim は UI に関わる部分を別のプラグインに切り出しており、今回の記事で紹介する機能は [Shougo/ddu-ui-ff](https://github.com/Shougo/ddu-ui-ff) が担っています。ですが主題では無いので詳しいことは省いています。

## どんな場合に使うの？

例えば、以下のようなファイルが並ぶディレクトリで、`:Telescope file_browser` したとします。

```
access_log.2024010622
access_log.2024010623
access_log.2024010700
access_log.2024010701
```

`<CR>` を押して `access_log.2024010622` を開いた後、次の `access_log.2024010623` を開く際、みなさんはどうするでしょうか？

### telescope.nvim だけでこれを実現するなら

telescope.nvim だけで実現するなら以下のような操作になるでしょう。

1. `:Telescope file_browser` で `access_log.2024010622` を開く。
0. `:Telescope resume` で picker を復元する。
2. `<C-n>`（`move_selection_next`）で次の候補を選択する。
3. `<CR>`（`select_default`）で開く。

最低で 3 つもキーを叩く必要がありますね。これをなんとかしたいです。

### Quickfix とかでいいのでは？

あるいは [Quickfix](https://neovim.io/doc/user/quickfix.html) を使う手もありますね。

1. `:Telescope file_browser` で表示した後、`<C-q>`（`send_to_qflist` + `open_qflist`）で候補を Quickfix に流し込んで Quickfix Window を表示する。
2. 後は、[`:cnext`](https://neovim.io/doc/user/quickfix.html#%3Acnext) や [`:cprev`](https://neovim.io/doc/user/quickfix.html#%3Acprev) を使ってファイル間を移動する。

一度 Quickfix に入れてしまえば、あとは Neovim 本体のコマンドや各種プラグインによって素早く移動できます。しかし、それが使えるのも対象がファイル（あるいはそれに類するもの）だったからです。

候補がファイルではなかったら？例えば `:Telescope man_pages` の結果ならどうでしょう。この picker は `<CR>` を押した時に指定されたファイルを開くのでは無く、`man` コマンドの実行結果をバッファーに表示します。そのため、Quickfix に入れてしまうとそのあと望む操作、つまり、選択して man ページを開くことができないのです[^2]。

[^2]: 本筋とはズレるのですが、少し前の Vim 駅伝の記事「[vim-incopenでファイル名順で前後のファイルを開く](https://zenn.dev/nil2/articles/301113c980a6e7)」が似たような話題を扱っています。ただここで紹介されるプラグインも、ファイルを前提とした移動のためのものなので今回の要件には適しません。

### Unite / Denite / ddu なら簡単！

まさにこの用途のための機能が Shougoware には実装されています。

* Unite
    * `:UniteNext`, `:UnitePrevious` コマンド
* Denite
    * [doc へのリンク](https://github.com/Shougo/denite.nvim/blob/055dd686c274a293dc4b7365be07c159148c31dd/doc/denite.txt#L2059)
* ddu
    * [doc へのリンク](https://github.com/Shougo/ddu-ui-ff/blob/db8d93672ccab2554ae4ce9977d0376a1aa5edcc/doc/ddu-ui-ff.txt#L1044-L1045)[^3]

[^3]: 上述の通り、ddu.vim は UI 自体を別のプラグインに切り出していますので ddu-ui-ff へのリンクになっています。

いずれも、適切に設定すればキーを1回叩くだけで候補間を移動できます。これを telescope.nvim で実現したいんですよね〜。

## telescope.nvim で頑張る

という訳で実装してみました。こんな感じで動作します。

https://x.com/delphinus35/status/1876088733692531162

`:Telescope man_pages` を表示し `File::` という文字列で絞り込んだ後、最初の一つを表示します。その後、キーマッピング（今回は `<Leader>fj` にしました）を叩くたびに、次の Man ページが表示されています。

```lua
local function get_picker(prompt_bufnr)
  local action_state = require "telescope.actions.state"
  local actions = require "telescope.actions"

  local picker = action_state.get_current_picker(prompt_bufnr)
  if not picker then
    vim.notify("found no picker", vim.log.levels.WARN)
    return
  elseif picker.manager:num_results() <= 1 then
    vim.notify("picker has no entry to open", vim.log.levels.WARN)
    actions.close(prompt_bufnr)
    if picker.initial_mode == "insert" then
      vim.api.nvim_feedkeys([[<C-\><C-n>]], "i", true)
    end
    return
  end
  return picker
end

local function resume_and_select(change)
  return function()
    local actions = require "telescope.actions"
    local builtin = require "telescope.builtin"

    vim.api.nvim_create_autocmd("User", {
      group = vim.api.nvim_create_augroup("resume_and_select", {}),
      pattern = "TelescopeResumePost",
      once = true,
      callback = function(args)
        local picker = get_picker(args.buf)
        if picker then
          picker:move_selection(change)
          vim.schedule_wrap(actions.select_default)(args.buf)
        end
      end,
    })

    builtin.resume {}
  end
end
```

結構複雑なことをやってるので解説が必要だと思います。このコードでは以下の手順で今回の要望を実現しています。

1. 自動コマンドを設定した上で `resume` picker を開く
2. 開いた picker への参照を得る
3. 一つ上/下の候補を選択して開く

### 自動コマンドを設定した上で `resume` picker を開く

`resume` picker は、直前に閉じた picker を候補や入力文字列を保ったまま、再度開くものです。通常の利用でも大変便利なものですので、`:Telescope resume` はどこかのキーにマッピングしておきましょう。

今回は picker を開く前に自動コマンドを設定しています。

```lua
-- 一部略しています
vim.api.nvim_create_autocmd("User", {
  pattern = "TelescopeResumePost",
  once = true, -- 一度実行したら自動コマンド自体を削除します
  callback = function(args)
    -- args.buf でプロンプトとなるバッファーが得られます
    local picker = get_picker(args.buf)
    -- …… 省略
  end,
})
```

通常の picker と違い、`resume` picker が表示された際は特殊なイベントが発行されます。

https://github.com/nvim-telescope/telescope.nvim/blob/2eca9ba22002184ac05eddbe47a7fe2d5a384dfc/lua/telescope/pickers.lua#L1705-L1711

```lua
local on_resume_complete = function()
  if vim.api.nvim_buf_is_valid(self.prompt_bufnr) then
    -- prompt_bufnr はプロンプトとなるバッファーを表します。
    vim.api.nvim_buf_call(self.prompt_bufnr, function()
      vim.cmd "do User TelescopeResumePost"
    end)
  end
end
```

この、`TelescopeResumePost` というイベントにコールバックを設定した上で `require("telescope.builtin").resume {}` で `resume` picker を開いています。上記のリンク先のコードを見ると分かる通り、コールバックはプロンプトとなるバッファーで実行されます。コールバック自体の内容は後ほど説明します。

### 開いた picker への参照を得る

```lua
local picker = action_state.get_current_picker(prompt_bufnr)
```

この時点でプロンプトとなるバッファーは分かっていますから `telescope.actions.state` モジュールの `get_current_picker` 関数で picker への参照が得られます。

```lua
elseif picker.manager:num_results() <= 1 then
```

picker は前回（`resume` で開く前）の時点で列挙した候補を [`telescope.entry_manager` クラス](https://github.com/nvim-telescope/telescope.nvim/blob/2eca9ba22002184ac05eddbe47a7fe2d5a384dfc/lua/telescope/entry_manager.lua)内の[連結リスト](https://ja.wikipedia.org/wiki/連結リスト)で保持しています。`num_results` はその候補の数を返す関数で、今回の場合それが `1` 以下だと選択する候補が無いとして終了しています。

```lua
-- picker を閉じる
actions.close(prompt_bufnr)
-- これが無いと挿入モードで終了してしまいます
if picker.initial_mode == "insert" then
  vim.api.nvim_feedkeys([[<C-\><C-n>]], "i", true)
end
```

ちょっと厄介なのはその終了のさせ方です。picker を開いた際、デフォルトではすぐに文字を入力できるよう、挿入モードになるよう設定されています（`:h telescope.defaults.initial_mode`。コードだと[この部分](https://github.com/nvim-telescope/telescope.nvim/blob/2eca9ba22002184ac05eddbe47a7fe2d5a384dfc/lua/telescope/pickers.lua#L595-L604))。そのため [`nvim_feedkeys`](https://neovim.io/doc/user/api.html#nvim_feedkeys()) を使って [`<C-\><C-n>`](https://neovim.io/doc/user/intro.html#CTRL-%5C_CTRL-N) というマッピングを叩いています。これはあらゆるモードで使える、ノーマルモードに戻るためのキーです。


### 一つ上/下の候補を選択して開く

上で省いたコールバックの解説です。

```lua
-- 1. picker への参照を得る
local picker = get_picker(args.buf)
if picker then
  -- 2. change で指定された分だけカーソルを動かして選択する。
  picker:move_selection(change)
  -- 3. default に設定されている action を実行する。
  vim.schedule_wrap(actions.select_default)(args.buf)
end
```

_2._ はデフォルトのマッピングだと `<C-n>` / `<C-p>` を叩いた時の動作を表しています。予めこの関数の引数（`change`）として `1` / `-1` を与えておき、上下の候補を選択できるようにしている訳です。

_3._ は同様に、デフォルトでは `<CR>` を叩いた時の動作ですが、[`vim.schedule_wrap()`](https://neovim.io/doc/user/lua.html#vim.schedule_wrap()) で囲んで次のループで実行するようにしています。これが無いと、ファイルタイプが設定されず正しくファイルが開かれません[^4]。

[^4]: この理由は良く分かっていません。

---

最後に、この関数を適当なキーにマッピングします。以下の例では `<Leader>fj` / `<Leader>fk` というキーに割り当てています。

```lua
vim.keymap.set(
  "n",
  "<Leader>fj",
  resume_and_select(1),
  { desc = "Resume Telescope picker and open the next candidate" }
)
vim.keymap.set(
  "n",
  "<Leader>fk",
  resume_and_select(-1),
  { desc = "Resume Telescope picker and open the previous candidate" }
)
```

## 今後の課題

以上の件については本家の issue にもまとめていまして、また、PR も作っています。

* [Resume picker and select next/prev entry and open it automatically · Issue #3391 · nvim-telescope/telescope.nvim](https://github.com/nvim-telescope/telescope.nvim/issues/3391)
* [feat(pickers): add an option to resume and select the entry by delphinus · Pull Request #3394 · nvim-telescope/telescope.nvim](https://github.com/nvim-telescope/telescope.nvim/pull/3394)

しかし、解決できていない問題もあります。今回のコードでは `resume` picker を開くため UI が表示されてしまいます。Unite / Denite / ddu の同種の機能は UI の表示をスキップするので高速ですが、今回のコードではほんの一瞬待たされる感じがします。実際に候補を画面に描画せずに、カーソルだけ移動できる機能があればいいのですが、まだ実現できる方法が分かっていません。

もう一つ、resume するべき picker が無い場合、つまり、一度も picker を開いていない状態で上記の関数を実行すると、何も動作しないのに自動コマンド（`TelescopeResumePost` イベントで発火するもの）だけが設定されたまま終了しています。これをきちんとお掃除する方法も見付かりませんでした[^5]。

[^5]: ただ、後者の問題については telescope.nvim の内部に手を入れることにより PR では解決しています。

## まとめ

この記事では「最後に開いた候補一覧の、次の、あるいは前の候補を開く」という機能を題材にして、telescope.nvim の内部構造を知り機能を実装する方法を説明しました。

telescope.nvim は Neovim のプラグインの中でも、トップクラスに実装が複雑です。今回説明したのは picker 周りの極々一部ですが、この記事が皆さんの telescope.nvim ハッキングに役立つようなことがあれば嬉しいです。