diff --git a/lua/jumpy/context-tools.lua b/lua/jumpy/context-tools.lua index f79b39d..da3515f 100644 --- a/lua/jumpy/context-tools.lua +++ b/lua/jumpy/context-tools.lua @@ -1,5 +1,7 @@ local M = {} +local path = require("jumpy.path") + -- TODO: -- Revisit this... -- I don't really like it, but it works for now. @@ -44,23 +46,20 @@ function M.get_workspace_symbols(bufnr, callback) return end - local cwd = (vim.uv and vim.uv.cwd and vim.uv.cwd()) or vim.fn.getcwd() - if cwd:sub(-1) ~= "/" then - cwd = cwd .. "/" - end + local root = path.project_root() local out = {} local kinds = vim.lsp.protocol.SymbolKind local kept = 0 for _, s in ipairs(result or {}) do - local path = vim.uri_to_fname(s.location.uri) - if STRUCTURAL_KINDS[s.kind] and path:sub(1, #cwd) == cwd then + local abs_path = vim.uri_to_fname(s.location.uri) + local rel = path.rel_path(abs_path, root) + if STRUCTURAL_KINDS[s.kind] and rel ~= abs_path then kept = kept + 1 if kept > MAX_SYMBOLS then break end - local rel = path:sub(#cwd + 1) out[#out + 1] = string.format("- %s [%s] > %s (%s)", s.name, kinds[s.kind] or "?", s.containerName or "global", rel) end diff --git a/lua/jumpy/init.lua b/lua/jumpy/init.lua index bb8513d..1632673 100644 --- a/lua/jumpy/init.lua +++ b/lua/jumpy/init.lua @@ -110,33 +110,24 @@ 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.quickfix, function() - require("jumpy.navigate").add_hunks_to_quickfix() - end, opts) + local keymaps = { + { c.prompt, "jumpy.prompt", "open" }, + { c.next_hunk, "jumpy.navigate", "next_hunk" }, + { c.prev_hunk, "jumpy.navigate", "prev_hunk" }, + { c.accept, "jumpy.navigate", "accept" }, + { c.reject, "jumpy.navigate", "reject" }, + { c.accept_all, "jumpy.navigate", "accept_all" }, + { c.reject_all, "jumpy.navigate", "reject_all" }, + { c.reprompt, "jumpy.prompt", "reprompt" }, + { c.quickfix, "jumpy.navigate", "add_hunks_to_quickfix" }, + } + + for _, km in ipairs(keymaps) do + local key, mod, fn = km[1], km[2], km[3] + map("n", key, function() + require(mod)[fn]() + end, opts) + end end return M diff --git a/lua/jumpy/llm.lua b/lua/jumpy/llm.lua index 2e50e5a..5b1f8cc 100644 --- a/lua/jumpy/llm.lua +++ b/lua/jumpy/llm.lua @@ -72,34 +72,29 @@ local function is_anthropic() return config.provider == "anthropic" end +local function build_curl_cmd(body_json, config, extra_headers) + local cmd = { "curl", "-s", "-H", "Content-Type: application/json" } + for _, h in ipairs(extra_headers or {}) do + table.insert(cmd, "-H") + table.insert(cmd, h) + end + table.insert(cmd, "-d") + table.insert(cmd, body_json) + table.insert(cmd, config.endpoint) + return cmd +end + local function build_curl_cmd_openai(body_json, config) - return { - "curl", - "-s", - "-H", - "Content-Type: application/json", - "-H", + return build_curl_cmd(body_json, config, { 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", + return build_curl_cmd(body_json, config, { 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) diff --git a/lua/jumpy/navigate.lua b/lua/jumpy/navigate.lua index c424b58..fceba53 100644 --- a/lua/jumpy/navigate.lua +++ b/lua/jumpy/navigate.lua @@ -2,6 +2,9 @@ local M = {} local render = require("jumpy.render") +local MSG_NO_HUNKS = "jumpy: no hunks" +local MSG_NO_HUNK_UNDER_CURSOR = "jumpy: no hunk under cursor" + local function get_active_hunks(bufnr) local state = render.get_state(bufnr) if not state then @@ -46,7 +49,7 @@ function M.next_hunk() local active = get_active_hunks(bufnr) if #active == 0 then - vim.notify("jumpy: no hunks", vim.log.levels.INFO) + vim.notify(MSG_NO_HUNKS, vim.log.levels.INFO) return end @@ -66,7 +69,7 @@ function M.prev_hunk() local active = get_active_hunks(bufnr) if #active == 0 then - vim.notify("jumpy: no hunks", vim.log.levels.INFO) + vim.notify(MSG_NO_HUNKS, vim.log.levels.INFO) return end @@ -85,7 +88,7 @@ function M.accept() local hunk_idx = M.hunk_at_cursor() if not hunk_idx then - vim.notify("jumpy: no hunk under cursor", vim.log.levels.WARN) + vim.notify(MSG_NO_HUNK_UNDER_CURSOR, vim.log.levels.WARN) return end @@ -112,7 +115,7 @@ function M.reject() local hunk_idx = M.hunk_at_cursor() if not hunk_idx then - vim.notify("jumpy: no hunk under cursor", vim.log.levels.WARN) + vim.notify(MSG_NO_HUNK_UNDER_CURSOR, vim.log.levels.WARN) return end @@ -126,7 +129,7 @@ function M.accept_all() local active = get_active_hunks(bufnr) if #active == 0 then - vim.notify("jumpy: no hunks", vim.log.levels.INFO) + vim.notify(MSG_NO_HUNKS, vim.log.levels.INFO) return end @@ -164,7 +167,7 @@ function M.add_hunks_to_quickfix() local items = M._transform_hunks_to_quickfix(states) if #items == 0 then - vim.notify("jumpy: no hunks", vim.log.levels.INFO) + vim.notify(MSG_NO_HUNKS, vim.log.levels.INFO) return end diff --git a/lua/jumpy/patch.lua b/lua/jumpy/patch.lua index 6390796..ba2ad05 100644 --- a/lua/jumpy/patch.lua +++ b/lua/jumpy/patch.lua @@ -1,6 +1,9 @@ local M = {} local function split_lines(text) + if vim and vim.split then + return vim.split(text, "\n", { plain = true }) + end local lines = {} local start = 1 while true do @@ -16,6 +19,17 @@ local function split_lines(text) return lines end +local function shallow_copy(t) + if vim and vim.deepcopy then + return vim.deepcopy(t) + end + local copy = {} + for _, v in ipairs(t) do + table.insert(copy, v) + end + return copy +end + local function find_lines(haystack, needle) if #needle == 0 then return nil @@ -84,10 +98,7 @@ function M.parse(text) end local function apply_blocks(original_lines, blocks) - local lines = {} - for _, l in ipairs(original_lines) do - table.insert(lines, l) - end + local lines = shallow_copy(original_lines) local unmatched = 0 diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index 77c0e42..38357cc 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -2,6 +2,7 @@ local M = {} local context_tools = require("jumpy.context-tools") local path = require("jumpy.path") +local tags = require("jumpy.tags") local utils = require("jumpy.utils") local state = { @@ -22,7 +23,7 @@ local function index_tagged_files(tagged_files) return by_path end -local function buffer_for_tagged_file(tags, file) +local function buffer_for_tagged_file(file) if file.bufnr and vim.api.nvim_buf_is_valid(file.bufnr) then return file.bufnr end @@ -70,7 +71,7 @@ local function highlight_mentions(buf) if prev_ch:match("[%w@]") then search_from = at_pos + 1 else - local _, _, token = line:find("^([%w/_.%-]+)", at_pos + 1) + local _, _, token = line:find("^(" .. tags.MENTION_CHARS .. "+)", at_pos + 1) if token and path_set[token] then vim.api.nvim_buf_set_extmark(buf, mention_ns, lnum - 1, at_pos - 1, { end_col = at_pos + #token, @@ -195,7 +196,7 @@ function M._setup_completions(buf) local col = vim.fn.col(".") local before = line:sub(1, col - 1) - local query = before:match("@([%w/_.%-]*)$") + local query = before:match("@(" .. tags.MENTION_CHARS .. "*)$") if not query then return end @@ -264,13 +265,12 @@ function M._submit() end local source_buf = state.source_buf - local tags = require("jumpy.tags") local source_lines = state.visual_selection and vim.split(state.visual_selection.text, "\n", { plain = true }) or vim.api.nvim_buf_get_lines(source_buf, 0, -1, false) local source_name = vim.api.nvim_buf_get_name(source_buf) - local source_rel = source_name ~= "" and tags.rel_path(source_name, path.project_root()) or "current" + local source_rel = source_name ~= "" and path.rel_path(source_name, path.project_root()) or "current" local parsed = tags.parse(prompt_text, { source = { @@ -366,7 +366,7 @@ function M._submit() for file_path, result in pairs(results) do local file = tagged_by_path[file_path] if file then - local bufnr = buffer_for_tagged_file(tags, file) + local bufnr = buffer_for_tagged_file(file) if bufnr then local hunks = diff.compute(file.lines, result.lines) if #hunks > 0 then diff --git a/lua/jumpy/render.lua b/lua/jumpy/render.lua index 88e7c24..9690f3b 100644 --- a/lua/jumpy/render.lua +++ b/lua/jumpy/render.lua @@ -2,6 +2,14 @@ local M = {} local ns = vim.api.nvim_create_namespace("jumpy") +local function build_virt_lines(added_lines) + local virt_lines = {} + for _, line in ipairs(added_lines) do + table.insert(virt_lines, { { line, "JumpyAdded" } }) + end + return virt_lines +end + local buf_states = {} function M.get_state(bufnr) @@ -60,10 +68,7 @@ function M.show(bufnr, hunks, original_lines, proposed_lines) end if #hunk.added_lines > 0 then - local virt_lines = {} - for _, added_line in ipairs(hunk.added_lines) do - table.insert(virt_lines, { { added_line, "JumpyAdded" } }) - end + local virt_lines = build_virt_lines(hunk.added_lines) 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) @@ -82,10 +87,7 @@ function M.show(bufnr, hunks, original_lines, proposed_lines) end display_hunk.extmarks = {} - local virt_lines = {} - for _, added_line in ipairs(hunk.added_lines) do - table.insert(virt_lines, { { added_line, "JumpyAdded" } }) - end + local virt_lines = build_virt_lines(hunk.added_lines) local anchor_line = math.max(0, hunk.old_start - 1) anchor_line = math.min(anchor_line, vim.api.nvim_buf_line_count(bufnr) - 1) @@ -174,10 +176,7 @@ function M.update_hunk_lines(bufnr, hunk_idx, new_added_lines) end if #new_added_lines > 0 then - local virt_lines = {} - for _, added_line in ipairs(new_added_lines) do - table.insert(virt_lines, { { added_line, "JumpyAdded" } }) - end + local virt_lines = build_virt_lines(new_added_lines) local anchor_line if hunk.old_count > 0 then diff --git a/lua/jumpy/tags.lua b/lua/jumpy/tags.lua index 7c809e7..2f2bc6a 100644 --- a/lua/jumpy/tags.lua +++ b/lua/jumpy/tags.lua @@ -2,6 +2,13 @@ local path = require("jumpy.path") local M = {} +local function trim(text) + if vim and vim.trim then + return vim.trim(text) + end + return (text:gsub("^%s+", ""):gsub("%s+$", "")) +end + M.MAX_BYTES = 256 * 1024 M.MAX_LINES = 2000 @@ -9,6 +16,8 @@ local RESERVED = { lsp = true, } +M.MENTION_CHARS = "[%.%w%-_/]" + local function word_boundary_before(text, pos) if pos <= 1 then return true @@ -37,11 +46,11 @@ local function mention_remove_len(raw) return #raw end -function M.find_mentions(text) - local mentions = {} - local seen = {} +-- Walks `text` and calls `on_match(at, raw, norm_path)` for every valid +-- file mention (non-reserved, correct word boundaries). Returns the +-- (possibly mutated) text after all callbacks have run. +local function scan_mentions(text, on_match) local search_from = 1 - while search_from <= #text do local at = text:find("@", search_from, true) if not at then @@ -49,15 +58,12 @@ function M.find_mentions(text) end if word_boundary_before(text, at) then - local rest = text:sub(at + 1) - local raw = rest:match("^([%.%w%-_/]+)") + local raw = text:sub(at + 1):match("^(" .. M.MENTION_CHARS .. "+)") local norm_path = raw and normalize_mention_path(raw) or nil if norm_path and norm_path ~= "" and not RESERVED[norm_path] and word_boundary_after(text, at + #raw) then - if not seen[norm_path] then - seen[norm_path] = true - table.insert(mentions, norm_path) - end - search_from = at + #raw + 1 + local next_pos + text, next_pos = on_match(text, at, raw, norm_path) + search_from = next_pos else search_from = at + 1 end @@ -65,41 +71,29 @@ function M.find_mentions(text) search_from = at + 1 end end - - return mentions + return text end -local function trim(text) - return (text:gsub("^%s+", ""):gsub("%s+$", "")) +function M.find_mentions(text) + local mentions = {} + local seen = {} + scan_mentions(text, function(t, at, raw, norm_path) + if not seen[norm_path] then + seen[norm_path] = true + table.insert(mentions, norm_path) + end + return t, at + #raw + 1 + end) + return mentions end function M.strip_mentions(text) - local stripped = text - local search_from = 1 - - while search_from <= #stripped do - local at = stripped:find("@", search_from, true) - if not at then - break - end - - if word_boundary_before(stripped, at) then - local rest = stripped:sub(at + 1) - local raw = rest:match("^([%.%w%-_/]+)") - local norm_path = raw and normalize_mention_path(raw) or nil - if norm_path and norm_path ~= "" and not RESERVED[norm_path] and word_boundary_after(stripped, at + #raw) then - local remove_len = mention_remove_len(raw) - stripped = stripped:sub(1, at - 1) .. stripped:sub(at + remove_len + 1) - search_from = at - else - search_from = at + 1 - end - else - search_from = at + 1 - end - end - - return trim((stripped:gsub("%s+", " "))) + local stripped = scan_mentions(text, function(t, at, raw, norm_path) + local remove_len = mention_remove_len(raw) + local next_t = t:sub(1, at - 1) .. t:sub(at + remove_len + 1) + return next_t, at + end) + return trim(stripped:gsub("%s+", " ")) end local function slice_lines(lines, count)