🐼

Quickfixの内容をTableで書き出してみると意外と便利

2023/09/25に公開

背景

VimやNeovimにはQuickfixという機能が備わっています。
grepなどの検索結果や、コンパイラーやリンターのエラーメッセージなどから、

  • ファイル名(ファイルパス)
  • 行位置

などをリスト化して、選択したアイテムの位置にジャンプできる機能です。

今回は、ここにリスト化された各アイテムをMarkdownの表として出力したいシーンがあったので実現してみました。

私のユースケース

モノリポで大きいプロジェクトをいじってると、リファクタリングの作業対象が一人では片付けられない、なんてことも。
気軽に「リファクタリングするべき場所のリストを共有して分担する」ムーブを決めたい時なんかは、便利です。

お断り

Vim/Neovimでは~と語りだしておきながら申し訳ないのですが、今回は急いで記事を起こしたため、私のDotfilesからそのまま切り出しています。
そのため、NeovimのためのLuaで書かれた設定になっています。
ただ、内容はVimとNeovimで大差ありません。Vim scriptでも同等のものは容易に実現できると思います。

どんな感じ?

現時点ではQuickfix(スクショ内の下半分)から、Markdownのテーブル(上半分)を自動で起こす、という形になっています。

どうやったの

Neovimの設定(init.luaなど)の中で、次のようにコマンドを定義しています。

---Quickfixの中身を表として出力する
--- Synopsis
---    :QfTable
---    :QfTable -format=markdown -column=bufname,lnum,text -output=reg:*
--- Arguments
---    -format 出力フォーマットを指定する。markdownのみ対応。 default: markdown
---    -column 各列に出力する要素をカンマ(,)つなぎで指定する。 default: bufname,lnum,text
---              指定出来る要素: name, bufname, module, lnum, end_lnum, col, end_col, vcol, nr, pattern, text, type, valid
---    -output 出力先を指定する。 default: hnew
---              出力先には以下の4種類を指定出来る。
---              - cur          カーソル行の下に追記する
---              - hnew[:name]  横分割で新しいバッファ・ウインドウを開いて記入する。 :name のようにコロンに続けてバッファ名を指定出来る。
---              - vnew[:name]  縦分割で新しいバッファ・ウインドウを開いて記入する。 :name のようにコロンに続けてバッファ名を指定出来る。
---              - reg[:name]   レジスタ |registers| に書き込む。`:a`や`:A`のようにコロンに続けてレジスタ名を指定できる。省略した場合はunnamed registerに書き込む。

-- Columnの表現

---@param n number|nil
local function column_itoa(n)
  if n == nil then
    return ""
  end
  return string.format("%d", n)
end

---@param b boolean|nil
local function column_btoa(b)
  if b == nil then
    return ""
  end
  if b then
    return "yes"
  end
  return ""
end

---@param s string|nil
local function column_raw(s)
  if s == nil then
    return ""
  end
  return s
end

---@class QuickfixColumn
---@field source string  Column source
---@field postprocess fun(n: any): string
---@field title string  Title
---@field length? number  A length to display

---@type table<string, QuickfixColumn>
local VALID_COLUMNS = {
  bufnr = { --number of buffer
    source = "bufnr",
    postprocess = column_itoa,
    title = "Buffer",
  },
  bufname = { -- name of the buffer (file name)
    source = "bufnr",
    postprocess = function(nr)
      return vim.fn.bufname(nr)
    end,
    title = "Name",
  },
  module = { --module name
    source = "module",
    postprocess = column_raw,
    title = "Module",
  },
  lnum = { --line number in the buffer (first line is 1)
    source = "lnum",
    postprocess = column_itoa,
    title = "Line",
  },
  end_lnum = { --end of line number if the item is multiline
    source = "end_lnum",
    postprocess = column_itoa,
    title = "End Line",
  },
  col = { --column number (first column is 1)
    source = "col",
    postprocess = column_itoa,
    title = "Column",
  },
  end_col = { --end of column number if the item has range
    source = "end_col",
    postprocess = column_itoa,
    title = "End Column",
  },
  vcol = { --|TRUE|: "col" is visual column:|FALSE|: "col" is byte index
    source = "vcol",
    postprocess = column_btoa,
    title = "Visual Column",
  },
  nr = { --error number
    source = "nr",
    postprocess = column_itoa,
    title = "Error Number",
  },
  pattern = { --search pattern used to locate the error
    source = "pattern",
    postprocess = column_raw,
    title = "Match Pattern",
  },
  text = { --description of the error
    source = "text",
    postprocess = column_raw,
    title = "Text",
  },
  type = { --type of the error, 'E', '1', etc.
    source = "type",
    postprocess = column_raw,
    title = "Error Type",
  },
  valid = { --|TRUE|: recognized error message
    source = "valid",
    postprocess = column_btoa,
    title = "Valid Error",
  },
}

---@type string[]
local DEFAULT_COLUMN_NAMES = { "bufname", "lnum", "text" }

-- フォーマット

--- Markdown形式でフォーマットする
---@param printer Printer
---@param columns QuickfixColumn[]
---@param rows string[][])>
local function format_markdown(printer, columns, rows)
  printer:put("| " .. table.concat(
    vim.tbl_map(function(col)
      return col.title .. string.rep(" ", col.length - #col.title)
    end, columns),
    " | "
  ) .. " |")
  printer:put("| " .. table.concat(
    vim.tbl_map(function(col)
      return "-" .. string.rep(" ", col.length - 1)
    end, columns),
    " | "
  ) .. " |")
  for _, row in ipairs(rows) do
    local cells = {}
    for c, cell in ipairs(row) do
      local col = columns[c]
      table.insert(cells, cell .. string.rep(" ", col.length - #cell))
    end
    printer:put("| " .. table.concat(cells, " | ") .. " |")
  end
end

---@type table<string, fun(printer: Printer, columns: QuickfixColumn[], rows: string[][])>
local VALID_FORMATS = {
  markdown = format_markdown,
}

local DEFAULT_FORMAT = "markdown"

--- 出力先

---@class Printer
local Printer = {}

---@param target string  Output target
function Printer:open(target)
  error("not implemented: " .. target)
end

---@param text string  Output value
function Printer:put(text)
  error("not implemented: " .. text)
end

---@class CurPrinter : Printer
local CurPrinter = {}

---@return CurPrinter
function CurPrinter.new()
  return setmetatable({ line = vim.fn.line(".") }, { __index = CurPrinter })
end

function CurPrinter:open(_) end

---@param text string  Output value
function CurPrinter:put(text)
  vim.fn.append(self.line, text)
  self.line = self.line + 1
end

---@class BufPrinter : Printer
---@field cmd fun(target?: string)  A command to open new buffer window
local BufPrinter = {}
---@return BufPrinter
function BufPrinter.hnew()
  return setmetatable({ line = 1, cmd = vim.cmd.new }, { __index = BufPrinter })
end
---@return BufPrinter
function BufPrinter.vnew()
  return setmetatable({ line = 1, cmd = vim.cmd.vnew }, { __index = BufPrinter })
end

---@param target string  Output target
function BufPrinter:open(target)
  if target == "" then
    self.cmd()
  else
    self.cmd(target)
  end
end

---@param text string  Output value
function BufPrinter:put(text)
  vim.fn.setline(self.line, text)
  self.line = self.line + 1
end

---@class RegPrinter : Printer
local RegPrinter = {}
---@return RegPrinter
function RegPrinter.new()
  return setmetatable({ line = 1, option = "l" }, { __index = RegPrinter })
end

---@type object
local valid_reg = vim.regex([[^[a-zA-Z0-9\*+]$]])

---@param target string  Output target
function RegPrinter:open(target)
  if target ~= "" and not valid_reg:match_str(target) then
    error(string.format("invalid argument: %q is not valid register name", target))
  end
  self.regname = target
end

---@param text string  Output value
function RegPrinter:put(text)
  vim.fn.setreg(self.regname, text, self.option)
  self.option = self.option .. "a"
end

---@type table<string, fun(): Printer>
local VALID_OUTPUTS = {
  cur = CurPrinter.new, -- vim.fn.append()
  hnew = BufPrinter.hnew, -- vim.cmd.new, vim.fn.setline()
  vnew = BufPrinter.vnew, -- vim.cmd.vnew, vim.fn.setline()
  reg = RegPrinter.new, -- vim.fn.setreg("?", line, "la"),
}

local DEFAULT_OUTPUT = BufPrinter.hnew

-- 処理本体
local function quickfix_to_table(event)
  local format = DEFAULT_FORMAT
  local columnNames = DEFAULT_COLUMN_NAMES
  local printerFactory = DEFAULT_OUTPUT
  local name = ""
  for _, arg in pairs(event.fargs) do
    if vim.startswith(arg, "-format=") then
      format = string.sub(arg, 9)
    elseif vim.startswith(arg, "-column=") then
      columnNames = vim.split(string.sub(arg, 9), ",", { trimempty = true, plain = true })
    elseif vim.startswith(arg, "-output=") then
      local terms = vim.split(string.sub(arg, 9), ":", { trimempty = true, plain = true })
      printerFactory = VALID_OUTPUTS[terms[1]]
      if not printerFactory then
        error(string.format("invalid argument: %q is not valid printer name"))
      end
      if #terms >= 2 then
        name = terms[2]
      end
    end
  end
  ---@type QuickfixColumn[]
  local columns = vim.tbl_map(function(c)
    local column = VALID_COLUMNS[c]
    if not column then
      error(string.format("invalid argument: %q is not valid column name", c))
    end
    return vim.tbl_deep_extend("force", { length = #column.title }, column)
  end, columnNames)
  local formatter = VALID_FORMATS[format]
  if not formatter then
    error(string.format("invalid argument: %q is no valid format", format))
  end
  ---@type string[][]
  local rows = {}
  for _, item in ipairs(vim.fn.getqflist()) do
    ---@type string[]
    local cells = {}
    for c, column in ipairs(columns) do
      local v = column.postprocess(vim.tbl_get(item, column.source))
      columns[c].length = math.max(columns[c].length, #v)
      table.insert(cells, v)
    end
    table.insert(rows, cells)
  end
  local printer = printerFactory()
  printer:open(name)
  formatter(printer, columns, rows)
end

-- コマンド/keymap設定
vim.api.nvim_create_user_command("QfTable", quickfix_to_table, { force = true, range = true, nargs = "*" })
vim.cmd([[ cabbrev <expr> Qftable (getcmdtype() ==# ":" && getcmdline() ==# "Qftable") ? "QfTable" : "Qftable" ]])
vim.keymap.set({ "n", "v" }, "<leader>qt", "<cmd>QfTable<cr>", { remap = false, desc = "Put quickfix table with Markdown format" })

ワンポイント

I/F上では一応フォーマットを切り替えられるようになっています。
Markdownの表以外の形でフォーマットする関数を書けば、差し替えることは容易です。

早すぎる最適化…?聞こえんなァ。

おまけ

Quickfix自体がただのファイル名と行番号(など)のリストなので、一時記憶領域として使えます。
「現在のカーソル位置をQuickfixに追加する」コマンドなどを作っておくと妙なシナジーが発生します。

vim.api.nvim_create_user_command("QfAdd", function(opts)
  local filename = vim.fn.expand("%:p")
  local list = {}
  for lnum = opts["line1"], opts["line2"] do
    local text = vim.fn.getline(lnum)
    if vim.fn.trim(text) ~= "" then
      table.insert(list, { filename = filename, lnum = lnum, text = text })
    end
  end
  local action = "a"
  local hidden = false
  for _, arg in pairs(opts.fargs) do
    if arg == "-reset" or arg == "-r" then
      if action ~= " " then
        action = "r"
      end
    elseif arg == "-hidden" or arg == "-h" then
      hidden = true
    elseif arg == "-new" or arg == "-n" then
      action = " "
    end
  end
  vim.fn.setqflist(list, action)
  if not hidden then
    vim.cmd.copen()
  end
end, { force = true, range = true, nargs = "*" })
vim.cmd([[ cabbrev <expr> Qfadd (getcmdtype() ==# ":" && getcmdline() ==# "Qfadd") ? "QfAdd" : "Qfadd" ]])
vim.keymap.set({ "n", "v" }, "<leader>qa", "<cmd>QfAdd<cr>", { remap = false, desc = "Add the line under the cursor to quickfix" })
  1. 色々ファイルを開いて、:QfAddでリストにどんどん追加していく
  2. :QfTable でリストの中身を表にして吐き出す

のように合わせ技で使うと地味に便利です。

他にもqfeditcfilterなどQuickfixリストをいじくるプラグインも便利なので合わせて使ってみてください。

Discussion