🐼

[Neovim] 設定にキャッシュは便利かもしれない

2024/12/14に公開

まとめ

ふと思いつきで生み出したLua製のキャッシュがやたら便利だったので紹介します。

背景

Neovimの設定で、コストの高い処理が必要になることがあります。

  • 特定のExecutableの存在に依存するが、executable()には時間が掛かるので極力減らしたい
  • 複数のプラグインで同じ外部APIへ繋ぐためにシークレットトークンが必要だが、ログイン処理は面倒くさいので極力減らしたい

これらを毎回実行すると起動時間が延びたり、いちいち手作業が挟まったりと面倒です。
そこで、一度求めた結果をキャッシュし、以降は即座に取得することで、より軽快な操作感を得られるようにしてみました。

どちらの悩みも、次のようにすれば解決できそうです。

  • 処理Aをした結果Xをどこかに保存しておく
  • 各種の設定やプラグインは結果Xを保存先から参照する。まだ値が格納されていない場合は保存されるのを待つ

作ったもの

作ったのは次のような小さいライブラリです。

Cacheクラス

nvim/lua/kyoh86/lib/cache.lua
local Cache = {}
Cache.__index = Cache

--- Create a new Cache instance backed by a specified file.
--- This will load any previously stored data from the file.
--- @param file string The filepath where the cache will be persisted.
function Cache.new(file)
  local self = setmetatable({
    store = {},
    waiters = {},
    filepath = file,
  }, Cache)
  self:load() -- Load existing data from the file during initialization.
  return self
end

function Cache:set(key, value)
  self.store[key] = value
  if self.waiters[key] then
    for _, waiter in ipairs(self.waiters[key]) do
      waiter(value)
    end
    self.waiters[key] = nil
  end
  self:serialize() -- Save the data to the file when the value set.
end

--- Retrieve a cached value. If the value is not yet available, the callback
--- will be queued and invoked once the value is set. If the returned value is invalid,
--- the callback can invoke `fail()` to remove the entry from the cache.
---
--- @param key string The key associated with the cached value.
--- @param callback fun(value: string, fail: fun()) Called when the value is available. `value` is the cached data, and `fail()` removes the key from the cache if invalid.
function Cache:get(key, callback)
  if self.store[key] then
    callback(self.store[key], function()
      self:del(key)
    end)
    return
  end

  if not self.waiters[key] then
    self.waiters[key] = {}
  end
  table.insert(self.waiters[key], callback)
end

--- Check whether a value is stored under the given key.
--- @param key string The key to check.
--- @return boolean True if a value is stored, false otherwise.
function Cache:has(key)
  return self.store[key] ~= nil
end

--- Remove the value associated with the specified key.
--- After removal, the updated store is persisted to the file.
function Cache:del(key)
  self.store[key] = nil
  self.waiters[key] = nil
  self:serialize() -- Persist the updated data to the file after deletion.
end

--- Clear all stored values, making the cache empty.
--- The change is then persisted to the file.
function Cache:clear()
  self.store = {}
  self.waiters = {}
  self:serialize() -- Persist the now-empty store to the file.
end

--- Serialize the current store and write it to the file in JSON format.
--- If the file does not exist, it will be created.
function Cache:serialize()
  local data = vim.json.encode(self.store)
  local FILE_MODE = 438 -- equivalent to octal 0666 permissions
  local file = vim.uv.fs_open(self.filepath, "w", FILE_MODE)
  if file then
    vim.uv.fs_write(file, data, -1)
    vim.uv.fs_close(file)
  else
    error("Failed to open cached file: " .. self.filepath)
  end
end

--- Load previously stored values from the file into the cache.
--- If the file is missing, empty, or invalid, the store defaults to an empty table.
function Cache:load()
  local FILE_MODE = 438 -- equivalent to octal 0666 permissions
  local file = vim.uv.fs_open(self.filepath, "r", FILE_MODE)
  if file then
    local stat = vim.uv.fs_fstat(file)
    if stat then
      local content = vim.uv.fs_read(file, stat.size, 0)
      if content then
        self.store = vim.json.decode(content) or {}
      else
        self.store = {}
      end
    else
      self.store = {}
    end
    vim.uv.fs_close(file)
  else
    self.store = {}
  end
end

return Cache

Cacheクラスの主なAPI:

  • Cache.new(file): 指定ファイルに紐づくキャッシュインスタンスを生成
  • :set(key, value): 値をキャッシュに保存(即座にファイルへ反映)
  • :get(key, callback): 値の取得。なければ値が設定されるまで待つコールバック登録が可能
  • :has(key): 値が存在するか確認
  • :del(key): 値を削除
  • :clear(): 全削除

glazeモジュール

glaze.luaはCacheを元に、「ある設定値を初回だけ計算し、以降はキャッシュから取得する」パターンを簡易的に実現するための補助モジュールです。
glaze.glaze()で値の取得処理を定義しておけば、次回以降は単純なglaze.get()呼び出しで値がすぐに取り出せます。

nvim/lua/kyoh86/lib/glaze.lua
local Cache = require("kyoh86.lib.cache")

-- Use Neovim's cache directory for persistent storage of settings.
local file = vim.fs.joinpath(vim.fn.stdpath("cache") --[[@as string]], "kyoh86-glaze.json")
local cache = Cache.new(file)

--- Check if a specific setting has been "baked" into the cache.
--- If a setting is "baked", it means its value has been computed and stored for subsequent accesses.
--- @param name string The name of the setting.
--- @return boolean True if the setting exists in the cache, false otherwise.
local function has(name)
  return cache:has(name)
end

--- Store a given setting value directly in the cache, overwriting any existing value.
--- After calling this, the value will be persisted and immediately available for future retrievals.
--- @param name string The name of the setting.
--- @param value any The value to store.
local function set(name, value)
  cache:set(name, value)
end

--- Retrieve a cached setting value. If the value is not yet available, the callback will be invoked once it becomes available.
--- If the retrieved value is invalid for any reason, the callback can call `fail()` to remove it from the cache.
---
--- @param name string The name of the setting.
--- @param callback fun(value: string, fail: fun()) A callback function that receives the value once available.
---        `fail()` can be called within the callback to remove the cached entry if it is deemed invalid.
local function get(name, callback)
  cache:get(name, callback)
end

--- "Bakes" a setting value into the cache. If the setting is already cached, this does nothing.
--- Otherwise, it computes the value (which may be expensive) and stores it for future use.
--- Use this to speed up initialization by avoiding repeated expensive computations.
---
--- @param name string The name of the setting.
--- @param get_variant fun():any A function that computes the setting value when it isn't already cached.
local function glaze(name, get_variant)
  if cache:has(name) then
    return
  end
  set(name, get_variant())
end

--- Reset all cached settings, clearing all previously stored values.
--- After calling this, you will need to "bake" or set values again.
local function reset()
  cache:clear()
end

return {
  glaze = glaze,
  has = has,
  set = set,
  get = get,
  reset = reset,
}

使い方

重い処理を要する部分はすべてglazeモジュールを介してやりとりします。
たとえば、私は外部ブラウザを開く設定を次のように書いています。

nvim/lua/kyoh86/conf/open.lua
--- 外部ファイルを開く方法を設定する
local glaze = require("kyoh86.lib.glaze")
glaze.glaze("opener", function()
  if vim.fn.executable("wslview") ~= 0 then
    return "wslview"
  elseif vim.fn.executable("xdg-open") ~= 0 then
    return "xdg-open"
  end
  return ""
end)
nvim/lua/kyoh86/conf/envar.lua
-- browser 環境変数に設定する
local glaze = require("kyoh86.lib.glaze")
glaze.get("opener", function(opener)
  vim.env.BROWSER = opener
end)

nvim/lua/kyoh86/plug/markdown.lua
local glaze = require("kyoh86.lib.glaze")
glaze.get("opener", function(opener)
vim.g.previm_open_cmd = opener
end)

こうすることで、

  • "wslview" か "xdg-open" どちらを使うかの判定を一か所で書ける
  • 一度判定してしまえばキャッシュされるので再び判定する必要がない
  • :terminalのための環境変数の設定と、previmプラグインのための設定を相互に依存なく書ける

ようになっています。

他にも、

  • 環境変数の初期化
  • 外部APIへのアクセスキー取得(初回のみログインしてトークンを保存し、その後はトークンをキャッシュから取得)
    • 起動時にのみ必要な言語サーバーの有無確認

など、多くのケースでこの仕組みが使えそうです。

参考

ここで引用した私の各種設定は次のリンクからご参照ください。

Discussion