0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

大規模ソフトウェアを手探ろう

Last updated at Posted at 2025-11-20

Copilot.luaとtelescope.luaに立ち向かう!

この記事はとある大学の報告レポートの子記事です。親記事はこちら
手探り方については、この記事前準備を参考にしているので、読んでみてください。

02 coplilot.lua

文字通りcopilotの導入をしてくれるプラグイン。APIやらなんやらの実装を行ってくれ、基本的なコマンド(例::Copilot)も導入してくれている。

問題点

現代には欠かせないコード補完を、NeoVimで実装してくれるcopilot.luaだが他のプラグインと衝突してバグる(例:Tabキーを押しても補完が有効にならない)ことがある。観察してきた中で、パターンは2つくらい。

  1. キーの割り当て競合
  2. 仮想テキストの干渉

解決策

本当は、どのプラグインと競合を引き起こしてるのかを分かりやすく表示してユーザー側が優先するプラグインを選択できるようにしたかったが、他プラグインとの連携は技術的にも、プラグインの数的にも大変そうということで断念。そこで、デバッグをしやすいようにcopilotの状態をもう少し詳しく表示し、履歴として残せるようにする方針に舵を切った。

実装

方針は定めたので、いざ実装。ということで、どのファイルを弄れば良いか物色しているとドキュメントに「弄るな」的文言を見つけてしまった。(よって本体ソースコードを弄ってもプルリクは叶わなそう🥺)しかし、機能の実装はしたいので自分用の設定としてファイルを作成することに決定。つまり、.config/lua/pluginsのところにcopilot.luaを実装。

状態保持のためのemit_copilot_status関数

local function emit_copilot_status(kind, payload)
      local data = { kind = kind, payload = payload or {} }

      pcall(function()
        local bufnr = (vim.api.nvim_get_current_buf and vim.api.nvim_get_current_buf()) or 0
        pcall(vim.api.nvim_buf_set_var, bufnr, "copilot_status", data)
        pcall(vim.api.nvim_set_var, "copilot_status", data)
      end)

      vim.schedule(function()
        if vim.api.nvim_exec_autocmds then
          pcall(vim.api.nvim_exec_autocmds, "User", { pattern = "CopilotStatusChanged", data = data })
        else
          pcall(vim.api.nvim_command, "doautocmd User CopilotStatusChanged")
        end
      end)
    end

馴染みがないと難しそうに見えるが、内容はシンプル。Copilot の状態(提案が出た/消えた等)を Neovim 全体に通知するためのイベント送信関数。

  • Copilot 状態を変数として保存
    現在バッファの変数 b:copilot_status とグローバル変数 g:copilot_status に{ kind = ..., payload = ... } を pcall で安全に書き込む。
  • User CopilotStatusChanged autocmd を発火
    vim.schedule でメインスレッド上で非同期に実行。
    Neovim 0.8 以降にある nvim_exec_autocmds があればそれを使用し、ない場合は doautocmd を使って発火。これにより、他のプラグインや設定が Copilot の状態変化をフックできる。

表示する記述の詳細化safe_wrap関数

local function safe_wrap(module, name, wrapper_fn)
      if not module or type(module) ~= "table" then return end
      local key = tostring(module) .. ":" .. tostring(name)
      if wrapped[key] then return end
      local orig = module[name]
      if type(orig) ~= "function" then return end
      module[name] = wrapper_fn(orig)
      wrapped[key] = true
    end

    local ok_sugg, suggestion_mod = pcall(require, "copilot.suggestion")
    if ok_sugg and type(suggestion_mod) == "table" then
      safe_wrap(suggestion_mod, "show", function(orig)
        return function(...)
          pcall(emit_copilot_status, "suggestion_available", { msg = "suggestion shown" })
          return orig(...)
        end
      end)

      if type(suggestion_mod.clear) == "function" then
        safe_wrap(suggestion_mod, "clear", function(orig)
          return function(...)
            pcall(emit_copilot_status, "suggestion_cleared", { msg = "suggestion cleared" })
            return orig(...)
          end
        end)
      end
      if type(suggestion_mod.hide) == "function" then
        safe_wrap(suggestion_mod, "hide", function(orig)
          return function(...)
            pcall(emit_copilot_status, "suggestion_cleared", { msg = "suggestion hidden" })
            return orig(...)
          end
        end)
      end
      if type(suggestion_mod.dismiss) == "function" then
        safe_wrap(suggestion_mod, "dismiss", function(orig)
          return function(...)
            pcall(emit_copilot_status, "suggestion_cleared", { msg = "suggestion dismissed" })
            return orig(...)
          end
        end)
      end
    end

これもif文が多いだけで見掛け倒し。copilot.suggestion モジュールの関数(show / clear / hide / dismiss)を安全にラップしてイベントを流すユーティリティ。要点は以下。

  • safe_wrap(module, name, wrapper_fn)
    • module がテーブルであること、module[name] が関数であることを確認。
    • tostring(module) .. ":" .. tostring(name) をキーにして既にラップ済みかを wrapped キャッシュで判定し、二重ラップを防止。
      オリジナル関数を wrapper_fn(orig) で置き換える(ラッパーは元の戻り値をそのまま返す)。
  • pcall(require, "copilot.suggestion") でモジュールを安全に読み込み、存在すれば各関数を safe_wrap でラップ
    • show を呼ぶときは "suggestion_available" を送る。
    • clear / hide / dismiss を呼ぶときは "suggestion_cleared" を送る。
    • イベント送信(emit_copilot_status)は pcall でラップしていて、失敗しても元の処理は続く(エラー耐性)。
  • 設計上の特徴:安全性(型チェック・pcall)・二重ラップ防止・元関数の透過的保持。

名前空間取得のfind_copilot_namespace関数および仮想テキスト検出のcheck_suggestion_presence関数

local function find_copilot_namespace()
      local ns = nil
      local names = vim.api.nvim_get_namespaces()
      for name, id in pairs(names) do
        if type(name) == "string" and name:lower():find("copilot") then
          ns = id
          break
        end
      end
      return ns
    end

local copilot_ns = find_copilot_namespace()

    local function check_suggestion_presence()
      local ok, bufnr = pcall(vim.api.nvim_get_current_buf)
      if not ok or not bufnr then return false end
      if not copilot_ns then
        copilot_ns = find_copilot_namespace()
        if not copilot_ns then return false end
      end
      local ok2, marks = pcall(vim.api.nvim_buf_get_extmarks, bufnr, copilot_ns, 0, -1, {})
      if not ok2 or not marks then return false end
      return #marks > 0
    end

仮想テキストの干渉を感知するために、copilotが仮想テキストを使い始めるのを検出する。

  • find_copilot_namespace()

    • vim.api.nvim_get_namespaces() で登録済みの namespace を走査し、名前に "copilot" を含むものを小文字比較で探してその ID を返す。見つからなければ nil を返す。
  • copilot_ns

    • 起動時に find_copilot_namespace() を呼んで見つかった namespace ID を保持する(キャッシュ)。
  • check_suggestion_presence()

    • 現在のバッファ番号を安全に取得(pcall(vim.api.nvim_get_current_buf))。取得できなければ false。
    • copilot_ns が未設定なら再度 find_copilot_namespace() を呼んで設定を試み、見つからなければ false。
    • vim.api.nvim_buf_get_extmarks(bufnr, copilot_ns, 0, -1, {}) を安全に呼んで(pcall)当該 namespace の extmark一覧を取得。失敗または空なら false、1つ以上あれば true を返す。
  • 共通特徴:Neovim API 呼び出しを pcall でラップしてエラー耐性を持たせ、extmark の有無で Copilot の提案存在を判定する設計。

copilotの仮想テキストを定期的に観察するための発火と停止関数

local function start_watcher()
      if timer then return end
      timer = uv.new_timer()
      if not timer then return end
      timer:start(100, 250, vim.schedule_wrap(function()
        local mode = vim.fn.mode()
        if mode ~= "i" and mode ~= "ic" then
          return
        end

        local ok, present = pcall(check_suggestion_presence)
        if not ok then return end

        if present and not last_has_suggestion then
          last_has_suggestion = true
          pcall(emit_copilot_status, "suggestion_available", { msg = "detected via extmark" })
        elseif not present and last_has_suggestion then
          last_has_suggestion = false
          pcall(emit_copilot_status, "suggestion_cleared", { msg = "cleared via extmark" })
        end
      end))
    end

    local function stop_watcher()
      if timer then
        pcall(function() timer:stop() end)
        pcall(function() timer:close() end)
        timer = nil
      end
      last_has_suggestion = false
    end
  • start_watcher()
    • 既に timer があれば何もしない。なければ uv.new_timer() を作り timer:start(100, 250, ...) で定期実行を登録。
    • コールバックは vim.schedule_wrap でラップしてメインの Neovim スケジュール上で安全に実行。
    • 現在モードを vim.fn.mode() で確認し、挿入モード以外なら何もしない。
    • pcall(check_suggestion_presence) で提案の有無を安全に取得し、前回状態(last_has_suggestion)と比較して状態変化があれば emit_copilot_status で "suggestion_available" / "suggestion_cleared" を送る(エラーは pcall で無視)。
  • stop_watcher()
    • timer があれば安全に停止・クローズ(pcall)し timer = nil、last_has_suggestion = false にリセット。
  • 設計上の特徴:定期ポーリング、挿入モード限定の動作、エラー耐性(pcall)、Neovim のメインループでの安全実行(vim.schedule_wrap)。

以上を実装すると次のようになる!

Screenshot 2025-11-20 at 12.58.58.png

補完候補が仮想テキストに出てきたよーという報告

Screenshot 2025-11-20 at 12.59.39.png

補完を行うと出てくる表示。仮想テキストでclearされましたよーという報告

03 telescope.lua

機能

ファイルや他の項目を高速で検索・フィルタリングするためのツール。このプラグインは、NeoVim内でインクリメンタルサーチやgrepなどを実行する際に、リストから目的の項目を素早く見つけられるようにしてくれる。 またキーボード操作でリアルタイムに候補が絞り込まれるため、ツリー表示のファイルマネージャーを使わなくてもファイルを探せるようになる。

問題点

telescopeのファイル検索アルゴリズムは、大雑把にいうと検索文字列が含まれるかどうかがメインのソートアルゴリズム。そのため、トップディレクトリでnvimを起動し、ファイル検索をすると候補表示されるのは、あまり見慣れないファイルになってくる。

大雑把な構成

デフォルトの検索アルゴリズムとは別に、検索しない時には、普段よく使うファイルが候補に現れていて欲しい。つまり

  1. 検索文字列に入力をいれる->デフォルト
  2. 検索文字列に入力をいれない->優先アルゴリズム

でファイルの候補表示をするように修正する。

自作優先アルゴリズムの方針

意図する動作としては、最近開いたファイルが優先的に候補表示されるようにしたいので、ファイルを開いた時に絶対パス回数を更新し、保存するデータファイルを作成する。計算のときには、このデータからスコアを計算する。あとは、基本的なソートを降順にする。

実装①

修正を加える箇所は.lua/telescope/builtin/__files.lua。このファイルのfind関数において次のようにする(場所はcommandの条件分岐の後ろが良い)。ソートした後に、finderやpickerに渡すところも少し変更している。

  opts.entry_maker = opts.entry_maker or make_entry.gen_from_file(opts)

  local Job = require("plenary.job")
  local results = {}
  
  do
    local j = Job:new({
      command = find_command[1],
      args = vim.list_slice(find_command, 2, #find_command),
      cwd = opts.cwd or vim.loop.cwd(),
    })
    j:sync()
    local out = j:result() or {}

    for i, p in ipairs(out) do
      local abs = p
      if not Path:new(p):is_absolute() then
        abs = Path:new(opts.cwd or vim.loop.cwd()):joinpath(p):absolute()
      end
      table.insert(results, abs)
    end
  end

  local scored = {}
  for _, p in ipairs(results) do
    local s = 0
    local ok, _ = pcall(function() s = frecency._score and frecency._score(p) or (frecency.score and frecency.score(p)) end)
    table.insert(scored, { path = p, score = s or 0 })
  end
  table.sort(scored, function(a, b)
    if a.score == b.score then return a.path < b.path end
    return a.score > b.score
  end)

  local sorted_results = {}
  for _, r in ipairs(scored) do table.insert(sorted_results, r.path) end

  local finder = finders.new_table {
    results = sorted_results,
    entry_maker = opts.entry_maker or make_entry.gen_from_file(opts),
  }

  pickers
    .new(opts, {
      prompt_title = "Find Files",
      __locations_input = true,
      finder = finder,
      previewer = conf.grep_previewer(opts),
      sorter = conf.file_sorter(opts),

      attach_mappings = function(prompt_bufnr, map)
        action_set.select:enhance {
          post = function()
            local selection = action_state.get_selected_entry()
            if not selection then
              return
            end

            local path = selection.path or selection.filename or selection.value
            if not path or path == "" then return end
            local ok, norm = pcall(vim.fn.fnamemodify, path, ":p")
            if ok and norm and norm ~= "" then
              pcall(function() frecency.inc(norm) end)
            else 
              pcall(function() frecency.inc(path) end)
            end
          end,
        }

        return true
      end,
    })
    :find()
  1. 検索結果となるファイル一覧を取得
    plenary.job を使って find_command(例:fd, rg, find など)を実行。その出力を取得し、相対パスを絶対パスに変換して results に格納。
  2. ファイルごとに「frecency スコア」を計算
    frecency._score または frecency.score を使って、最近どれくらい使ったか+頻度 に基づくスコアを付与。{ path = ..., score = ... } のテーブルにまとめる。
  3. スコア順にソート
    高スコア(よく使うファイル)を上位に表示。同スコアの場合はファイルパスの辞書順で並べる。
  4. Telescope Finder を作り、一覧を表示
    finders.new_table にソート済みリストを渡す。Telescope の Picker として表示(prompt_title = "Find Files" )。
  5. ファイルを選択した後、そのファイルの「frecency」を更新
    action_set.select に post-hook を追加し、選択されたファイルを取得。正規化したパスを使って frecency.inc(path) を呼び出し、そのファイルの使用スコアを更新する。

実装②

データの保存云々に関する修正は、上のファイルとは違うところにする。今回は./lua/telescope/internal/にファイルを作る。内容自体は簡潔で、データの保存先・読み取り先のファイル名の設定、開いたファイルに関するデータ(最後いつ開いたか、何回開いたか)の更新。スコアリングもここでしていて、次のような式で定義している。

$\text{score} = \log(1 + \text{count}) + \displaystyle{\frac{1}{1+ (\text{now} - \text{last})}}$

開いた回数はある程度増えたら、そこまで重要度は変化してほしくないので、ゆるやかに増加するlog関数を利用。また、luaで得られる時間は実行時の時間なので、開いたとき(now)と前回(last)の差の逆数を取るようにしている。

local Path = require("plenary.path")
local M = {}

local db_file = vim.fn.stdpath('data') .. '/telescope_frecency.json'
local db = nil

local function load_db()
  if db then return db end

  local p = Path:new(db_file)
  if not p:exists() then
    db = {}
    return db
  end
  local ok, content = pcall(function() return p:read() end)
  if not ok or content == nil or content == "" then
    db = {}
    return db
  end

  local decode_ok, decoded = pcall(vim.fn.json_decode, content)
  if decode_ok and type(decoded) == "table" then
    db = decoded
  else
    vim.notify("failed to decode DB", vim.log.levels.WARN)
    db = {}
  end

  return db
end

local function save_db()
  if not db then return end
  local p = Path:new(db_file)
  p:parent():mkdir({ parents = true })

  local ok, err = pcall(function()
    p:write(vim.fn.json_encode(db))
  end)
  if not ok then
    local data = vim.fn.json_encode(db)
    local lines = { data }
    local wrote, write_err = pcall(function() vim.fn.writefile(lines, db_file, "b") end)
    if not wrote then
      vim.notify("failed to save DB" .. tostring(write_err), vim.log.levels.WARN)
    end
  end
end

function M.inc(path)
  load_db()
  db[path] = db[path] or { count = 0, last = 0 }
  db[path].count = (db[path].count or 0) + 1
  db[path].last = os.time()
  save_db()
end

function M.score(path)
  load_db()
  local meta = db[path]
  if not meta then return 0 end
  local cnt = meta.count or 0
  local days = (os.time() - (meta.last or 0)) / 86400
  if days < 0 then days = 0 end
  local rec = 1 / (1 + days)
  return math.log(1+cnt) + rec
end

function M.load() return load_db() end

return M

以上のファイルを作成したら、実装①で修正を行なったファイルの先頭に次のような宣言を加えておこう(←忘れずに!)

local frecency = require('telescope._internal.frecency') 

これで、スコアの関数を利用することができるようになる(frecencyの部分はファイル名なので自分のお好みで大丈夫)。

まとめ

  • copilot.lua
    本体コードを弄っているわけではないが、プログラム間の関係を保つためには、本体コードも理解しないといけないのがとても大変でした(関数や変数の命名規則 .etc)。また、luaでプログラムを書いている中で、最高と感じたのはpcall。新しく追加しても必ずしも一発で動作するわけじゃないときにpcallを挟むことで、全体の動作は保ちながら、デバッグができました。

  • telescope.lua
    Copilot.luaの改造のときとは違い、本体コードに直接変更を加えた上、ディレクトリ数、ファイル数ともに多く、それぞれがどのような機能を実装しているのかを把握するのがとても大変でした。一方で、前期の授業「アルゴリズム」で習ったソートを使って機能を実装することができて楽しかった作業でもありました。

どちらの場合も、これまでの授業などでは到底達し得ないコードの行数であったり、ファイルが複数存在して、どこに手を付ければよいのかと最初は苦労しましたが、ドキュメントやpcallなどのデバッグ作業で次第にコードの全容が理解できるようになりました。また、学習したアルゴリズムも使い、実装して、意図通りに動作させられたことは大きな達成感とともに、改めてアルゴリズムの重要性も感じることとなりました。今後、オープンソースを手探る際には今回の経験を糧にしていこうと思います。

また、今回はプラグインをいじる形で実験を終えたが、一からプラグインないしは大規模ソフトウェアを構築するような経験もできたらと思います。ここまで読んで頂き、恐悦至極です。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?