diff --git a/README.md b/README.md index 62ad794..c2ae6b3 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The first Neovim integration for [CodeRabbit](https://coderabbit.link/sam-natale - **Review viewer** — read the full review in a floating window or buffer, with findings grouped by file, severity levels, and syntax-highlighted code suggestions - **Review types** — review all changes, only committed changes, or only uncommitted changes, with optional base branch/commit comparison - **Review history** — browse and revisit past reviews, persisted to disk across sessions +- **Quickfix integration** — send findings to the quickfix list for fast `:cnext`/`:cprev` navigation - **Statusline integration** — drop `require("coderabbit").status()` into your statusline for a live spinner while reviews run ## Getting Started @@ -49,6 +50,7 @@ Run `:checkhealth coderabbit` to verify everything is wired up. | `:CodeRabbitClear` | Clear diagnostics | | `:CodeRabbitShow [id]` | View results (float or buffer). Defaults to the latest review | | `:CodeRabbitRestore [id]` | Reapply diagnostics from a saved review. Defaults to the most recent | +| `:CodeRabbitQuickfix [id]` | Populate quickfix list with findings | | `:CodeRabbitHistory` | Browse past reviews | For your statusline: @@ -92,6 +94,9 @@ require("coderabbit").setup({ border = "rounded", }, }, + quickfix = { + auto = false, -- populate on review complete + }, on_review_complete = nil, }) ``` diff --git a/doc/coderabbit.txt b/doc/coderabbit.txt index 016ba07..38543ce 100644 --- a/doc/coderabbit.txt +++ b/doc/coderabbit.txt @@ -62,6 +62,9 @@ All options are optional. Defaults: >lua border = "rounded", }, }, + quickfix = { + auto = false, + }, on_review_complete = nil, }) < @@ -87,6 +90,9 @@ show.float.width Fraction of editor width (0-1). Default: `0.6`. show.float.height Fraction of editor height (0-1). Default: `0.7`. show.float.border Border style for the floating window. Default: `"rounded"`. +quickfix.auto Populate the quickfix list automatically when a review + completes. Default: `false`. + on_review_complete Callback receiving the findings table when a review finishes. @@ -115,6 +121,11 @@ COMMANDS *coderabbit-commands* :CodeRabbitHistory *:CodeRabbitHistory* Browse saved reviews via |vim.ui.select|. +:CodeRabbitQuickfix [id] *:CodeRabbitQuickfix* + Populate the quickfix list with findings. Pass an `id` from + `:CodeRabbitHistory` to load a saved review. Without an `id`, + uses the current review findings. Navigate with |:cnext| and |:cprev|. + ============================================================================== LUA API *coderabbit-api* @@ -139,6 +150,9 @@ require("coderabbit").restore({id}) *coderabbit.restore()* require("coderabbit").history() *coderabbit.history()* Open the review history picker. +require("coderabbit").quickfix({id}) *coderabbit.quickfix()* + Populate the quickfix list with findings. `nil` = current, number = saved. + require("coderabbit").status() *coderabbit.status()* Returns `"⠋ CodeRabbit (12s)"` while reviewing, `nil` when idle. Designed for statusline use. diff --git a/lua/coderabbit/config.lua b/lua/coderabbit/config.lua index 00d17f3..d96b53f 100644 --- a/lua/coderabbit/config.lua +++ b/lua/coderabbit/config.lua @@ -30,6 +30,9 @@ M.defaults = { border = "rounded", }, }, + quickfix = { + auto = false, + }, on_review_complete = nil, } diff --git a/lua/coderabbit/init.lua b/lua/coderabbit/init.lua index ebe768d..450965b 100644 --- a/lua/coderabbit/init.lua +++ b/lua/coderabbit/init.lua @@ -32,6 +32,10 @@ function M.history() require("coderabbit.history").open() end +function M.quickfix(id) + require("coderabbit.quickfix").populate(id) +end + function M.status() return require("coderabbit.review").status() end diff --git a/lua/coderabbit/quickfix.lua b/lua/coderabbit/quickfix.lua new file mode 100644 index 0000000..3c02da8 --- /dev/null +++ b/lua/coderabbit/quickfix.lua @@ -0,0 +1,76 @@ +local M = {} + +local severity_types = { + [vim.diagnostic.severity.ERROR] = "E", + [vim.diagnostic.severity.WARN] = "W", +} + +--- Map vim.diagnostic.severity to quickfix type character. +--- @param severity number +--- @return string "E", "W", or "I" +function M.severity_to_type(severity) + return severity_types[severity] or "I" +end + +--- Convert findings to quickfix items (pure function, no side effects). +--- @param findings table[] Array of { diagnostic, filepath } +--- @return table[] Array of { filename, lnum, col, text, type } for setqflist() +function M.findings_to_qf_items(findings) + local items = {} + for _, f in ipairs(findings) do + local d = f.diagnostic + local raw = d.user_data and d.user_data.severity_raw + local prefix = raw and ("[" .. raw .. "] ") or "" + local first_line = d.message:match("^([^\n]*)") or d.message + table.insert(items, { + filename = f.filepath, + lnum = d.lnum + 1, + col = d.col + 1, + text = prefix .. first_line, + type = M.severity_to_type(d.severity), + }) + end + return items +end + +--- Populate the quickfix list from findings and open the window. +--- @param findings table[] Array of { diagnostic, filepath } +--- @param opts table|nil { title = string } +function M.set(findings, opts) + opts = opts or {} + local items = M.findings_to_qf_items(findings) + vim.fn.setqflist({}, "r", { + title = opts.title or "CodeRabbit Review", + items = items, + }) + vim.cmd("copen") +end + +--- Populate quickfix from current review or a saved review by ID. +--- @param id number|nil Review ID (nil = current in-memory findings) +function M.populate(id) + local findings, title + + local review = require("coderabbit.review") + + if id then + local entry = review.get_review(id) + if not entry then + vim.notify("CodeRabbit: Review #" .. id .. " not found", vim.log.levels.WARN) + return + end + findings = type(entry.findings) == "table" and entry.findings or {} + title = "CodeRabbit Review #" .. id + else + findings = review.get_results() + if #findings == 0 and not review.get_context() then + vim.notify("CodeRabbit: No review results. Run :CodeRabbitReview first", vim.log.levels.WARN) + return + end + title = "CodeRabbit Review" + end + + M.set(findings, { title = title }) +end + +return M diff --git a/lua/coderabbit/review.lua b/lua/coderabbit/review.lua index 073d6bf..a9494d5 100644 --- a/lua/coderabbit/review.lua +++ b/lua/coderabbit/review.lua @@ -211,6 +211,12 @@ function M.run(opts) storage.save(state.findings, M.get_context()) + if type(cfg.quickfix) == "table" and cfg.quickfix.auto and #state.findings > 0 then + require("coderabbit.quickfix").set(state.findings, { + title = "CodeRabbit Review", + }) + end + if cfg.on_review_complete then cfg.on_review_complete(state.findings) end diff --git a/plugin/coderabbit.lua b/plugin/coderabbit.lua index 6541779..3b76eaa 100644 --- a/plugin/coderabbit.lua +++ b/plugin/coderabbit.lua @@ -73,3 +73,22 @@ vim.api.nvim_create_user_command("CodeRabbitHistory", function() end, { desc = "Browse CodeRabbit review history", }) + +vim.api.nvim_create_user_command("CodeRabbitQuickfix", function(args) + ensure_setup() + local id = nil + if args.fargs[1] then + id = tonumber(args.fargs[1]) + if not id then + vim.notify("CodeRabbitQuickfix: invalid review ID: " .. args.fargs[1], vim.log.levels.ERROR) + return + end + end + require("coderabbit").quickfix(id) +end, { + nargs = "?", + complete = function() + return require("coderabbit.storage").ids() + end, + desc = "Populate quickfix list with CodeRabbit findings (optional: review ID)", +}) diff --git a/tests/coderabbit/quickfix_spec.lua b/tests/coderabbit/quickfix_spec.lua new file mode 100644 index 0000000..7f6ac35 --- /dev/null +++ b/tests/coderabbit/quickfix_spec.lua @@ -0,0 +1,186 @@ +local quickfix = require("coderabbit.quickfix") +local h = require("tests.helpers") +local test, eq = h.test, h.eq +local E, W, I = h.E, h.W, h.I + +-- ────────────────────────────────────────────────────────── +-- Tests: severity_to_type +-- ────────────────────────────────────────────────────────── + +test("severity_to_type: ERROR -> E", function() + eq(quickfix.severity_to_type(E), "E") +end) + +test("severity_to_type: WARN -> W", function() + eq(quickfix.severity_to_type(W), "W") +end) + +test("severity_to_type: INFO -> I", function() + eq(quickfix.severity_to_type(I), "I") +end) + +test("severity_to_type: HINT -> I (fallback)", function() + eq(quickfix.severity_to_type(vim.diagnostic.severity.HINT), "I") +end) + +-- ────────────────────────────────────────────────────────── +-- Tests: findings_to_qf_items +-- ────────────────────────────────────────────────────────── + +test("findings_to_qf_items: empty findings returns empty", function() + local items = quickfix.findings_to_qf_items({}) + eq(#items, 0) +end) + +test("findings_to_qf_items: single finding produces correct entry", function() + local findings = { h.finding("/tmp/repo/foo.lua", 41, E, "null check", {}) } + local items = quickfix.findings_to_qf_items(findings) + eq(#items, 1) + eq(items[1].filename, "/tmp/repo/foo.lua") + eq(items[1].lnum, 42) -- 0-indexed -> 1-indexed + eq(items[1].col, 1) -- col 0 -> 1 + eq(items[1].type, "E") +end) + +test("findings_to_qf_items: lnum 0 becomes 1", function() + local findings = { h.finding("/tmp/repo/a.lua", 0, I, "file-level issue") } + local items = quickfix.findings_to_qf_items(findings) + eq(items[1].lnum, 1) +end) + +test("findings_to_qf_items: severity maps correctly", function() + local findings = { + h.finding("/tmp/repo/a.lua", 0, E, "error"), + h.finding("/tmp/repo/b.lua", 0, W, "warn"), + h.finding("/tmp/repo/c.lua", 0, I, "info"), + } + local items = quickfix.findings_to_qf_items(findings) + eq(items[1].type, "E") + eq(items[2].type, "W") + eq(items[3].type, "I") +end) + +test("findings_to_qf_items: text includes severity_raw prefix", function() + local findings = { h.finding("/tmp/repo/a.lua", 10, W, "missing import") } + local items = quickfix.findings_to_qf_items(findings) + -- helpers.finding sets severity_raw = "minor" by default + eq(items[1].text, "[minor] missing import") +end) + +test("findings_to_qf_items: multi-line message uses first line only", function() + local findings = { h.finding("/tmp/repo/a.lua", 5, E, "first line\nsecond line\nthird") } + local items = quickfix.findings_to_qf_items(findings) + eq(items[1].text, "[minor] first line") +end) + +test("findings_to_qf_items: missing severity_raw omits prefix", function() + local findings = { + { + filepath = "/tmp/repo/a.lua", + diagnostic = { + lnum = 0, + col = 0, + severity = E, + message = "bare finding", + source = "coderabbit", + }, + }, + } + local items = quickfix.findings_to_qf_items(findings) + eq(items[1].text, "bare finding") +end) + +test("findings_to_qf_items: multiple findings produce correct count", function() + local findings = { + h.finding("/tmp/repo/a.lua", 1, E, "one"), + h.finding("/tmp/repo/b.lua", 2, W, "two"), + h.finding("/tmp/repo/c.lua", 3, I, "three"), + } + local items = quickfix.findings_to_qf_items(findings) + eq(#items, 3) +end) + +-- ────────────────────────────────────────────────────────── +-- Tests: populate +-- ────────────────────────────────────────────────────────── + +local storage = require("coderabbit.storage") +local populate_test_dir = vim.fn.tempname() .. "/coderabbit_populate_test" +storage._set_base_dir(populate_test_dir) + +-- Save a review so storage.load(1) returns it. +local saved_findings = { + h.finding("/tmp/repo/a.lua", 10, E, "error here"), + h.finding("/tmp/repo/b.lua", 20, W, "warning here"), +} +storage.save(saved_findings, h.context()) + +test("populate: valid id populates quickfix from stored review", function() + quickfix.populate(1) + vim.cmd("cclose") + local qf = vim.fn.getqflist({ title = 1, items = 1 }) + eq(qf.title, "CodeRabbit Review #1") + eq(#qf.items, 2) +end) + +test("populate: invalid id does not error and leaves quickfix unchanged", function() + -- Set a known state first + quickfix.set({ h.finding("/tmp/repo/x.lua", 0, I, "baseline") }, { title = "Baseline" }) + vim.cmd("cclose") + -- Call with non-existent id + quickfix.populate(999) + local qf = vim.fn.getqflist({ title = 1, items = 1 }) + -- Should remain unchanged (populate returns early with a warning) + eq(qf.title, "Baseline") + eq(#qf.items, 1) +end) + +test("populate: nil id with no review context warns and leaves quickfix unchanged", function() + -- Set a known state first + quickfix.set({ h.finding("/tmp/repo/x.lua", 0, I, "baseline") }, { title = "Baseline" }) + vim.cmd("cclose") + -- Clear review state so get_results() returns {} and get_context() returns nil + require("coderabbit.review").clear() + quickfix.populate(nil) + local qf = vim.fn.getqflist({ title = 1, items = 1 }) + -- Should remain unchanged (populate returns early with a warning) + eq(qf.title, "Baseline") + eq(#qf.items, 1) +end) + +-- ────────────────────────────────────────────────────────── +-- Tests: set +-- ────────────────────────────────────────────────────────── + +test("set: populates quickfix list with items", function() + local findings = { + h.finding("/tmp/repo/a.lua", 10, E, "error here"), + h.finding("/tmp/repo/b.lua", 20, W, "warning here"), + } + quickfix.set(findings, { title = "Test Review" }) + vim.cmd("cclose") + local qf = vim.fn.getqflist({ title = 1, items = 1 }) + eq(qf.title, "Test Review") + eq(#qf.items, 2) +end) + +test("set: empty findings clears quickfix list", function() + quickfix.set({ h.finding("/tmp/repo/a.lua", 0, E, "x") }) + quickfix.set({}) + vim.cmd("cclose") + local qf = vim.fn.getqflist({ items = 1 }) + eq(#qf.items, 0) +end) + +test("set: replaces existing quickfix content", function() + quickfix.set({ h.finding("/tmp/repo/a.lua", 0, E, "first") }) + quickfix.set({ h.finding("/tmp/repo/b.lua", 1, W, "second") }) + vim.cmd("cclose") + local qf = vim.fn.getqflist({ items = 1 }) + eq(#qf.items, 1) +end) + +h.summary() + +-- Clean up temp dir after all tests complete +vim.fn.delete(populate_test_dir, "rf")