From 6774501d8200e616c8c2393bbf47d090172e3dde Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sun, 17 May 2026 22:05:20 -0400 Subject: [PATCH 1/3] test: add busted tests for diff module --- tests/diff_spec.lua | 165 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 tests/diff_spec.lua diff --git a/tests/diff_spec.lua b/tests/diff_spec.lua new file mode 100644 index 0000000..0c8913a --- /dev/null +++ b/tests/diff_spec.lua @@ -0,0 +1,165 @@ +-- Add lua/ to package.path so require works outside Neovim +package.path = package.path .. ";lua/?.lua;lua/?/init.lua" + +local diff = require("jumpy.diff") + +describe("diff.compute", function() + it("returns empty for identical inputs", function() + local old = { "a", "b", "c" } + local new = { "a", "b", "c" } + local hunks = diff.compute(old, new) + assert.are.equal(0, #hunks) + end) + + it("returns empty for two empty inputs", function() + local hunks = diff.compute({}, {}) + assert.are.equal(0, #hunks) + end) + + it("detects single line deletion", function() + local old = { "a", "b", "c" } + local new = { "a", "c" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].old_count) + assert.are.equal(0, hunks[1].new_count) + assert.are.same({ "b" }, hunks[1].removed_lines) + assert.are.same({}, hunks[1].added_lines) + end) + + it("detects single line insertion", function() + local old = { "a", "c" } + local new = { "a", "b", "c" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.equal(0, hunks[1].old_count) + assert.are.equal(1, hunks[1].new_count) + assert.are.same({}, hunks[1].removed_lines) + assert.are.same({ "b" }, hunks[1].added_lines) + end) + + it("detects single line replacement", function() + local old = { "a", "b", "c" } + local new = { "a", "X", "c" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].old_count) + assert.are.equal(1, hunks[1].new_count) + assert.are.same({ "b" }, hunks[1].removed_lines) + assert.are.same({ "X" }, hunks[1].added_lines) + end) + + it("detects multiple separate hunks", function() + local old = { "a", "b", "c", "d", "e" } + local new = { "a", "X", "c", "Y", "e" } + local hunks = diff.compute(old, new) + + assert.are.equal(2, #hunks) + assert.are.same({ "b" }, hunks[1].removed_lines) + assert.are.same({ "X" }, hunks[1].added_lines) + assert.are.same({ "d" }, hunks[2].removed_lines) + assert.are.same({ "Y" }, hunks[2].added_lines) + end) + + it("handles deletion at start", function() + local old = { "a", "b", "c" } + local new = { "b", "c" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.equal(1, hunks[1].old_start) + assert.are.same({ "a" }, hunks[1].removed_lines) + end) + + it("handles deletion at end", function() + local old = { "a", "b", "c" } + local new = { "a", "b" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.same({ "c" }, hunks[1].removed_lines) + end) + + it("handles insertion at start", function() + local old = { "b", "c" } + local new = { "a", "b", "c" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.same({ "a" }, hunks[1].added_lines) + end) + + it("handles insertion at end", function() + local old = { "a", "b" } + local new = { "a", "b", "c" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.same({ "c" }, hunks[1].added_lines) + end) + + it("handles complete replacement", function() + local old = { "a", "b" } + local new = { "x", "y", "z" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.equal(2, hunks[1].old_count) + assert.are.equal(3, hunks[1].new_count) + assert.are.same({ "a", "b" }, hunks[1].removed_lines) + assert.are.same({ "x", "y", "z" }, hunks[1].added_lines) + end) + + it("handles old empty, new has lines", function() + local old = {} + local new = { "a", "b" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.equal(0, hunks[1].old_count) + assert.are.equal(2, hunks[1].new_count) + assert.are.same({ "a", "b" }, hunks[1].added_lines) + end) + + it("handles new empty, old has lines", function() + local old = { "a", "b" } + local new = {} + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.equal(2, hunks[1].old_count) + assert.are.equal(0, hunks[1].new_count) + assert.are.same({ "a", "b" }, hunks[1].removed_lines) + end) + + it("handles multi-line contiguous change", function() + local old = { "a", "b", "c", "d" } + local new = { "a", "X", "Y", "d" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.same({ "b", "c" }, hunks[1].removed_lines) + assert.are.same({ "X", "Y" }, hunks[1].added_lines) + end) + + it("preserves line content exactly", function() + local old = { " indented", "trailing ", "" } + local new = { " indented", "changed", "" } + local hunks = diff.compute(old, new) + + assert.are.equal(1, #hunks) + assert.are.same({ "trailing " }, hunks[1].removed_lines) + assert.are.same({ "changed" }, hunks[1].added_lines) + end) + + it("old_start is 1-indexed", function() + local old = { "a", "b", "c" } + local new = { "a", "X", "c" } + local hunks = diff.compute(old, new) + + assert.are.equal(2, hunks[1].old_start) + end) +end) From 31d765ac3aaa9558d4d828ad7cbca1384e4ed50b Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sun, 17 May 2026 22:05:39 -0400 Subject: [PATCH 2/3] chore: add Makefile with lint and format targets --- .luacheckrc | 7 +++++++ Makefile | 12 ++++++++++++ stylua.toml | 6 ++++++ 3 files changed, 25 insertions(+) create mode 100644 .luacheckrc create mode 100644 Makefile create mode 100644 stylua.toml diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..525d69f --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,7 @@ +std = "luajit" +globals = { "vim" } +max_line_length = 120 + +ignore = { + "212", -- unused argument +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..09af185 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +.PHONY: test lint format check + +test: + busted tests/ + +lint: + luacheck lua/ tests/ + +format: + stylua lua/ tests/ + +check: lint test diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..6090f42 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,6 @@ +column_width = 120 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" From 601aff3b44011699c435501d2b1e3eb8e295a14e Mon Sep 17 00:00:00 2001 From: akrm al-hakimi Date: Sun, 17 May 2026 22:05:51 -0400 Subject: [PATCH 3/3] ci: add GitHub Actions workflow for lint, format, and test --- .github/workflows/ci.yml | 58 ++++++++++++++++++++++++++++++++++++++++ lua/jumpy/diff.lua | 10 ++++--- lua/jumpy/init.lua | 35 +++++++++++++++++------- lua/jumpy/llm.lua | 38 ++++++++++++++++---------- lua/jumpy/loading.lua | 32 +++++++++++++--------- lua/jumpy/navigate.lua | 8 ++++-- lua/jumpy/render.lua | 26 +++++++++--------- 7 files changed, 155 insertions(+), 52 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f5807b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install luacheck + run: sudo apt-get update && sudo apt-get install -y lua-check + + - name: Run luacheck + run: luacheck lua/ tests/ + + format: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Check formatting with stylua + uses: JohnnyMorganz/stylua-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + version: latest + args: --check lua/ tests/ + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Lua + uses: leafo/gh-actions-lua@v10 + with: + luaVersion: "5.1" + + - name: Setup LuaRocks + uses: leafo/gh-actions-luarocks@v4 + + - name: Cache LuaRocks packages + uses: actions/cache@v4 + with: + path: ~/.luarocks + key: ${{ runner.os }}-luarocks-${{ hashFiles('Makefile') }} + restore-keys: | + ${{ runner.os }}-luarocks- + + - name: Install busted + run: luarocks install busted + + - name: Run tests + run: busted tests/ diff --git a/lua/jumpy/diff.lua b/lua/jumpy/diff.lua index 0b06b3c..a1a7f08 100644 --- a/lua/jumpy/diff.lua +++ b/lua/jumpy/diff.lua @@ -15,8 +15,13 @@ local function myers_diff(old_lines, new_lines) local v = {} v[1] = 0 local trace = {} + local done = false for d = 0, max do + if done then + break + end + local snapshot = {} for k, val in pairs(v) do snapshot[k] = val @@ -40,13 +45,12 @@ local function myers_diff(old_lines, new_lines) v[k] = x if x >= n and y >= m then - goto backtrack + done = true + break end end end - ::backtrack:: - local edits = {} local x, y = n, m diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua index b49d889..2cdf7f0 100644 --- a/lua/jumpy/init.lua +++ b/lua/jumpy/init.lua @@ -5,7 +5,8 @@ M.config = { endpoint = nil, model = nil, api_key = nil, - system_prompt = [[You are a code editor. The user will give you a file and an instruction. Return ONLY the complete modified file contents. Do not wrap in markdown code fences. Do not explain.]], + system_prompt = [[You are a code editor. The user will give you a file and an instruction. ]] + .. [[Return ONLY the complete modified file contents. Do not wrap in markdown code fences. Do not explain.]], keymaps = { prompt = "j", next_hunk = "]h", @@ -80,14 +81,30 @@ function M._setup_keymaps() local opts = { silent = true } local c = M.config.keymaps - map("n", c.prompt, function() require("jumpy.prompt").open() end, opts) - map("n", c.next_hunk, function() require("jumpy.navigate").next_hunk() end, opts) - map("n", c.prev_hunk, function() require("jumpy.navigate").prev_hunk() end, opts) - map("n", c.accept, function() require("jumpy.navigate").accept() end, opts) - map("n", c.reject, function() require("jumpy.navigate").reject() end, opts) - map("n", c.accept_all, function() require("jumpy.navigate").accept_all() end, opts) - map("n", c.reject_all, function() require("jumpy.navigate").reject_all() end, opts) - map("n", c.reprompt, function() require("jumpy.prompt").reprompt() end, opts) + map("n", c.prompt, function() + require("jumpy.prompt").open() + end, opts) + map("n", c.next_hunk, function() + require("jumpy.navigate").next_hunk() + end, opts) + map("n", c.prev_hunk, function() + require("jumpy.navigate").prev_hunk() + end, opts) + map("n", c.accept, function() + require("jumpy.navigate").accept() + end, opts) + map("n", c.reject, function() + require("jumpy.navigate").reject() + end, opts) + map("n", c.accept_all, function() + require("jumpy.navigate").accept_all() + end, opts) + map("n", c.reject_all, function() + require("jumpy.navigate").reject_all() + end, opts) + map("n", c.reprompt, function() + require("jumpy.prompt").reprompt() + end, opts) end return M diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index afee775..cee6328 100644 --- a/lua/jumpy/llm.lua +++ b/lua/jumpy/llm.lua @@ -21,8 +21,12 @@ end local function build_reprompt_messages(context) local config = get_config() + local template = "File type: %s\n\n" + .. "--- ORIGINAL LINES ---\n%s\n--- END ORIGINAL ---\n\n" + .. "--- PREVIOUSLY PROPOSED (rejected) ---\n%s\n--- END PROPOSED ---\n\n" + .. "New instruction: %s\n\nReturn ONLY the replacement lines. No explanation, no fences." local user_content = string.format( - "File type: %s\n\n--- ORIGINAL LINES ---\n%s\n--- END ORIGINAL ---\n\n--- PREVIOUSLY PROPOSED (rejected) ---\n%s\n--- END PROPOSED ---\n\nNew instruction: %s\n\nReturn ONLY the replacement lines. No explanation, no fences.", + template, context.filetype or "text", table.concat(context.original_lines, "\n"), table.concat(context.proposed_lines, "\n"), @@ -42,30 +46,36 @@ end local function build_curl_cmd_openai(body_json, config) return { - "curl", "-s", - "-H", "Content-Type: application/json", - "-H", string.format("Authorization: Bearer %s", config.api_key), - "-d", body_json, + "curl", + "-s", + "-H", + "Content-Type: application/json", + "-H", + string.format("Authorization: Bearer %s", config.api_key), + "-d", + body_json, config.endpoint, } end local function build_curl_cmd_anthropic(body_json, config) return { - "curl", "-s", - "-H", "Content-Type: application/json", - "-H", string.format("x-api-key: %s", config.api_key), - "-H", "anthropic-version: 2023-06-01", - "-d", body_json, + "curl", + "-s", + "-H", + "Content-Type: application/json", + "-H", + string.format("x-api-key: %s", config.api_key), + "-H", + "anthropic-version: 2023-06-01", + "-d", + body_json, config.endpoint, } end local function extract_content_openai(parsed) - return parsed.choices - and parsed.choices[1] - and parsed.choices[1].message - and parsed.choices[1].message.content + return parsed.choices and parsed.choices[1] and parsed.choices[1].message and parsed.choices[1].message.content end local function extract_content_anthropic(parsed) diff --git a/lua/jumpy/loading.lua b/lua/jumpy/loading.lua index 496f3f3..7f3f9f7 100644 --- a/lua/jumpy/loading.lua +++ b/lua/jumpy/loading.lua @@ -86,14 +86,18 @@ function M.start() open_float(text) timer = vim.loop.new_timer() - timer:start(80, 80, vim.schedule_wrap(function() - if not active then - return - end - frame_idx = frame_idx % #FRAMES + 1 - local line = string.format(" %s waiting for model…%s ", FRAMES[frame_idx], format_elapsed()) - update_text(line) - end)) + timer:start( + 80, + 80, + vim.schedule_wrap(function() + if not active then + return + end + frame_idx = frame_idx % #FRAMES + 1 + local line = string.format(" %s waiting for model…%s ", FRAMES[frame_idx], format_elapsed()) + update_text(line) + end) + ) end function M.stop() @@ -130,10 +134,14 @@ function M.error(msg) open_float(text) dismiss_timer = vim.loop.new_timer() - dismiss_timer:start(4000, 0, vim.schedule_wrap(function() - cancel_dismiss() - close_ui() - end)) + dismiss_timer:start( + 4000, + 0, + vim.schedule_wrap(function() + cancel_dismiss() + close_ui() + end) + ) end return M diff --git a/lua/jumpy/navigate.lua b/lua/jumpy/navigate.lua index 3633e79..3099214 100644 --- a/lua/jumpy/navigate.lua +++ b/lua/jumpy/navigate.lua @@ -4,7 +4,9 @@ local render = require("jumpy.render") local function get_active_hunks(bufnr) local state = render.get_state(bufnr) - if not state then return {} end + if not state then + return {} + end local active = {} for idx, hunk in pairs(state.hunks) do @@ -167,7 +169,9 @@ function M._apply_offset(bufnr, accepted_idx, delta) end local state = render.get_state(bufnr) - if not state then return end + if not state then + return + end for idx, hunk in pairs(state.hunks) do if hunk and idx > accepted_idx then diff --git a/lua/jumpy/render.lua b/lua/jumpy/render.lua index 1e59b10..40ec1e6 100644 --- a/lua/jumpy/render.lua +++ b/lua/jumpy/render.lua @@ -51,10 +51,7 @@ function M.show(bufnr, hunks, original_lines, proposed_lines) table.insert(virt_lines, { { added_line, "JumpyAdded" } }) end - local anchor_line = math.min( - hunk.old_start - 1 + hunk.old_count - 1, - vim.api.nvim_buf_line_count(bufnr) - 1 - ) + local anchor_line = math.min(hunk.old_start - 1 + hunk.old_count - 1, vim.api.nvim_buf_line_count(bufnr) - 1) anchor_line = math.max(0, anchor_line) local id = vim.api.nvim_buf_set_extmark(bufnr, ns, anchor_line, 0, { @@ -103,10 +100,14 @@ end function M.clear_hunk(bufnr, hunk_idx) local state = buf_states[bufnr] - if not state then return end + if not state then + return + end local hunk = state.hunks[hunk_idx] - if not hunk then return end + if not hunk then + return + end for _, eid in ipairs(hunk.extmarks) do vim.api.nvim_buf_del_extmark(bufnr, ns, eid) @@ -130,10 +131,14 @@ end function M.update_hunk_lines(bufnr, hunk_idx, new_added_lines) local state = buf_states[bufnr] - if not state then return end + if not state then + return + end local hunk = state.hunks[hunk_idx] - if not hunk then return end + if not hunk then + return + end for _, eid in ipairs(hunk.extmarks) do vim.api.nvim_buf_del_extmark(bufnr, ns, eid) @@ -162,10 +167,7 @@ function M.update_hunk_lines(bufnr, hunk_idx, new_added_lines) local anchor_line if hunk.old_count > 0 then - anchor_line = math.min( - hunk.old_start - 1 + hunk.old_count - 1, - vim.api.nvim_buf_line_count(bufnr) - 1 - ) + anchor_line = math.min(hunk.old_start - 1 + hunk.old_count - 1, vim.api.nvim_buf_line_count(bufnr) - 1) anchor_line = math.max(0, anchor_line) else anchor_line = math.max(0, hunk.old_start - 1)