🛠️

telescope.nvim 拡張機能のつくりかた

2023/11/11に公開

概要

telescope.nvim は fuzzy finder を提供する Neovim の人気プラグインです。拡張性にも優れており、入力ソースと適切な表示方法さえ定義してやれば比較的お手軽に拡張機能を作成することができます。
telescope.nvim というよく作りこまれたプラグインの拡張として機能を開発することで、telescope.nvim 本体に組み込まれている豊富な機能や UI が自動的についてくるので使いやすいものが簡単につくれます。

今回 Zenn の記事をタイトルおよび topic ベースで検索できるような telescope.nvim の拡張を書いたので、この実装を題材としてハンズオン形式で telescope.nvim の拡張機能作成からパッケージ方法までをまとめました。
Zenn の執筆環境を題材にしたものではありますが、拡張機能の作成方法としては入力ソース部分さえ取り替えれば一般に応用が効く内容になっていると思います。

この記事を読むことで以下のことが理解できることを目指します。

  • telescope.nvim とは何か
    • なんのためのプラグインか
    • 拡張機能をつくると何ができるようになるのか
  • telescope.nvim の拡張機能の具体的な作り方がわかる
    • 基本的な拡張機能の構成がわかる
    • 表示画面の見た目の調整がある程度できる

作成した拡張機能は以下に置いてあります。こちらも使ってみていただければ嬉しいです。

https://github.com/sankantsu/telescope-zenn.nvim

また、作成した上でのモチベーション等を書いた記事は以下にあります。

https://zenn.dev/sankantsu/articles/69ba90ff55a598

telescope.nvim とは

最初に、telescope.nvim プラグインについて簡単に紹介します。

https://github.com/nvim-telescope/telescope.nvim

telescope.nvim の中心となっている機能は、「様々な検索対象に対して対話的に fuzzy finder を動かし選択対象に対して何らかの操作を行う」ということです。
これだけだと抽象的に見えますが、豊富な検索対象 + それに対する操作が builtin ライブラリとして提供されており、たとえば

  • プロジェクト内のファイルを検索し、選択したファイルを開く。(find_files)
  • プロジェクト内のソースコード中の文字列を検索し、選択した行を開く。(live_grep)
  • Neovim のヘルプタグを検索し、選択したヘルプを開く。(help_tags)
  • Git のコミットやブランチを検索し、選択したコミットにチェックアウトする。(git_commits, git_branches)

などの機能が最初から利用できます。

Builtin で提供されている以外の検索対象およびそれに対する操作を自分で定義することで、telescope.nvim の機能を拡張することができます。拡張機能としてプラグインを作成することで、telescope.nvim の充実したフィルタ機能や UI をはじめから利用できるのは大きなメリットです。

作成したい機能

今回は Zenn の記事に特化した検索方法を telescope.nvim の拡張機能として実装していきます。

もう少し具体的には、ファイル名 (slug) がランダム生成等でわかりにくい名前になっているときに、記事本体のメタデータに記載されたタイトルなどを並べて表示、検索できるようにすることでファイルの検索性を良くしようというものです。

完成イメージは以下の画像のような感じです。

telescope.nvim 拡張のつくりかた

ドキュメントの紹介

telescope.nvim の拡張を作成するにあたっては、次のドキュメントが一番よくまとまっています。

細かい部分は実際の builtiin picker の実装を参考にすると良いです。
git_commits の picker などが比較的読みやすくおすすめです。

処理の流れ

ここからは、実際に拡張機能の実装をいくつかのステップに分けて見ていきます。

まず、コードを見る前におおまかな処理の流れについて確認しておきましょう。

  • Finder が外部コマンドなどを用いて検索したいデータを生の文字列として持ってくる。
  • EntryMaker が生の文字列データをパースして Lua のテーブルなどに置き換える。
  • Sorter が表示する entry をフィルタおよびソートする。
  • これらの機能を Picker がまとめあげ、UI 機能を提供する。

今回主に実装するのは Finder, EntryMaker の部分で、これらを Picker にラップしてインターフェースとして提供します。
この説明は telescope.nvim の内部的なアーキテクチャについて正確さを保証するものではないですが、今回題材にしている拡張機能をつくる上ではこのぐらいの理解で十分だと思います。

準備

この拡張機能は zenn-cli を用いた Zenn の執筆環境を対象にしたものなので、zenn-cli の環境が必要です。
もしこうした環境が現時点で手元にない場合には以下のコマンドでセットアップしてください。

mkdir zenn && cd zenn
npm init --yes
npm install zenn-cli
npx zenn init # Zenn 環境を初期化
npx zenn new:article # 適当な記事を作成

zenn-cli の詳しい使用方法は Zenn CLI の公式ガイドを参照してください。
あるいは、筆者の Zenn レポジトリ を clone して使ってもらっても構いません。

また、telescope.nvim のインストールが必要です。こちらは telescope.nvim の README を参照してください。

Minimal な picker の実装

はじめに最小限の構成で picker を作成してみます。
以下の内容でファイルを Zenn のレポジトリのルートに置いてみてください。
内容としては find コマンドで articles 以下のファイルを集めて検索するだけの簡単なものです。

local pickers = require "telescope.pickers" -- picker 作成用の API
local finders = require "telescope.finders" -- finder 作成用の API
local conf = require("telescope.config").values -- ユーザーの init.lua を反映した設定内容

local article_picker = function(opts)
  opts = opts or {}
  local cmd = { "find", "./articles", "-name", "*.md" } -- 検索対象としたい項目を集めてくるコマンド
  pickers.new(opts, {
    prompt_title = "Zenn article", -- 入力プロンプト上部に表示されるタイトル
    finder = finders.new_oneshot_job(cmd, opts), -- cmd をシェルで実行し結果を検索対象として返す。
    sorter = conf.generic_sorter(opts), -- ユーザー設定の sorter を使う。
  }):find()
end

article_picker()

この状態で :luafile % を実行すると作成した picker が呼び出され、記事の markdown ファイルの一覧が表示されるはずです。どれか適当に選択してみるとファイルを開くこともできます。
なお、find コマンドの検索が正しく行われるように neovim の動作するカレントディレクトリは zenn のレポジトリルートと一致させてください。

入力データの準備

記事の slug, タイトル等を入力データとして扱うためにはこれらのデータをなにがしかの方法でとってきてやる必要があります。そのためには articles/ 以下のディレクトリにある markdown ファイル全部を集めてきて、各ファイルに以下の形式で書かれているメタデータ (front matter というらしい) をパースするための機構が必要になります。

---
title: "telescope.nvim 拡張機能のつくりかた"
emoji: "🛠️"
type: "tech"
topics: [ "Neovim", "telescope", "Zenn", "Lua"]
published: true
---

<!-- ここから記事本体 -->

Front matter は基本的に --- の区切り行で挟まれた yaml データなので、この部分を切り出して yaml パーサにかければ良いです。
この部分は自分で実装しても良いのですが、zenn-cli に用意されている npx zenn list:articles --format json というコマンドを利用すると json 形式でメタデータの一覧を表示してくれます。今回はこれを利用することにしました。

EntryMaker の作成

次に、json 形式の入力データをデコードして適切な情報を表示してやるための EntryMaker を作成してみます。EntryMaker は、Finder がとってきた生の文字列を受け取って適切な形式の entry を返す関数です。

json のデコードについては vim.json.decode() が使えます。あとはデコード済みのテーブルから必要な情報を取り出して entry を作成してやれば良いです。

ここまで行った段階の article_picker の定義を示します。

local article_picker = function(opts)
  opts = opts or {}
  opts.entry_maker = function (entry) -- EntryMaker: 入力は finder の返す文字列
    local metadata = vim.json.decode(entry) -- json を Lua のテーブルに変換
    return {
      value = metadata, -- あとから displayer などで使うためフルの情報を渡しておく
      ordinal = metadata.slug .. " " .. metadata.title, -- 検索対象として使われる文字列
      display = metadata.slug .. " " .. metadata.title, -- 画面上に表示される文字列
      path = "articles/" .. metadata.slug .. ".md", -- 選択したときに開くファイルのパス
    }
  end
  local cmd = { "npx", "zenn", "list:articles", "--format", "json" }
  pickers.new(opts, {
    prompt_title = "Zenn article",
    finder = finders.new_oneshot_job(cmd, opts), -- opts 経由で EntryMaker が渡される。
    sorter = conf.generic_sorter(opts),
  }):find()
end

この状態で再び :luafile % を実行すると、記事の slug とタイトルが合わせて表示されるのがわかると思います。
この時点でだいぶ実用的になってきていますね!

見た目の調整

出力画面をもう少し華やかにしてみましょう。機能的にはこれが最後です。
まず、先程は entry の display を単なる文字列として表示しましたが、特定部分をハイライトしたり表示幅の揃った表形式で表示できたりしたら見やすいです。
このような表示は displayer というものを使って実現できます。
displayerentry_display.create() という API に表の各フィールド幅や区切り文字を指定してやることで作成できます。

ここでは、slug, emoji, title を左から順に並べた3カラムの表形式の表示を実装してみます。

local entry_display = require "telescope.pickers.entry_display"

local displayer = entry_display.create {
  separator = " ", -- フィールド間の区切り文字
  items = {
    { width = 10 }, -- slug フィールドの表示幅
    { width = 2 },  -- emoji フィールドの表示幅
    { remaining = true }, -- title を残りのスペース一杯に割り当て
  },
}

さらに、実際の entry の情報を渡して displayer で整形するような関数を書けば entry の表示を行うための関数が完成です。

-- entry の表示を作成する関数
-- EntryMaker の返り値の display 以外のフィールドが埋まったものが渡される。
local make_display = function (entry)
  local metadata = entry.value
  return displayer {
    -- 1つ目のフィールドに slug を表示
    -- TelescopeResultsIdentifier は highlight group
    { metadata.slug, "TelescopeResultsIdentifier" },
    metadata.emoji, -- 2つ目のフィールドに emoji を表示
    metadata.title, -- 3つ目のフィールドに title を表示
  }
end

local article_picker = function(opts)
  opts = opts or {}
  opts.entry_maker = function (entry)
    local metadata = vim.json.decode(entry)
    return {
      value = metadata,
      display = make_display, -- display に表示用の関数を指定
      -- (省略) --
    }
  end
  -- (省略) --
end

また、ファイルの中身を表示する preview もあると嬉しいです。これは、pickers.new の第2引数のテーブルに previewer のフィールドを追加してやれば良いです。
ここまでの実装全体を以下の折りたたみの中に掲載しておきます。

ここまでの実装まとめ
local pickers = require "telescope.pickers"
local finders = require "telescope.finders"
local conf = require("telescope.config").values
local entry_display = require "telescope.pickers.entry_display"

local displayer = entry_display.create {
  separator = " ",
  items = {
    { width = 10 },
    { width = 2 },
    { remaining = true },
  },
}

local make_display = function (entry)
  local metadata = entry.value
  return displayer {
    { metadata.slug, "TelescopeResultsIdentifier" },
    metadata.emoji,
    metadata.title,
  }
end

local article_picker = function(opts)
  opts = opts or {}
  opts.entry_maker = function (entry)
    local metadata = vim.json.decode(entry)
    return {
      value = metadata,
      ordinal = metadata.slug .. " " .. metadata.title,
      display = make_display,
      path = "articles/" .. metadata.slug .. ".md",
    }
  end
  local cmd = { "npx", "zenn", "list:articles", "--format", "json" }
  pickers.new(opts, {
    prompt_title = "Zenn article",
    finder = finders.new_oneshot_job(cmd, opts),
    sorter = conf.generic_sorter(opts),
    previewer = conf.file_previewer(opts),
  }):find()
end

article_picker()

拡張機能としてパッケージ化する

最後に、配布可能なパッケージの形式にまとめれば完成です。
ここでは、以下のようなパッケージ構成を採用してみます。

.
└── lua
    ├── telescope_zenn          # Your actual plugin code
    │   └── init.lua
    └── telescope
        └── _extensions         # The underscore is significant
            └─ zenn.lua         # Init and register your extension

先程までに実装した拡張機能本体のコードは lua/telescope_zenn/init.lua に置きます。
さらに、telescope.nvim 本体から拡張機能として認識してもらうためには、lua/telescope/_extensions 内に拡張として登録したい名前のファイルを置いて register_extension() という API により拡張機能を登録する必要があります。最小限の設定は以下で行うことができます。

-- lua/telescope/_extensions/zenn.lua
-- ファイル名の "zenn" が拡張の名前として登録される
return require("telescope").register_extension {
  -- setup = function(ext_config, config)
    -- ユーザー設定を受け取って setup を行う (後述)
  -- end,
  exports = {
    -- article_picker という名前で picker を登録
    -- `:Telescope zenn article_picker` でアクセスできるようになる。
    article_picker = require("telescope_zenn").article_picker,
  }
}

拡張機能固有のユーザー設定を受け取る

拡張機能固有の設定項目をユーザーに提供したい場合もあるでしょう。この場合、ユーザーは以下のような形式で (.config/nvim/init.lua などに) 設定を記述します。

require("telescope").setup({
    extensions = {
        zenn = { -- 拡張機能の名前
            -- <option-name> = <value>
        },
    },
})

ここで記述された設定は先程触れなかった register_extension() の引数の setup フィールドに登録した関数に渡されます。
ここから必要な値を取り出して picker を呼び出すときのオプションなどに自動的に放り込まれるようにすれば良いのですが、このあたりは説明するより実装を見ていただいたほうが早いかと思います。

https://github.com/sankantsu/telescope-zenn.nvim/blob/main/lua/telescope/_extensions/zenn.lua

まとめ

この記事では、Zenn の記事検索を快適にする執筆支援機能を題材として telescope.nvim 拡張機能の実装方法について説明しました。
telescope.nvim の拡張機能は比較的少ない実装量で書けるわりにリッチな UI がついてきて使いやすいのでぜひ検索機能を実装したいときの選択肢として考えてみてください!

以上

GitHubで編集を提案

Discussion