diff --git a/lua/jumpy/path.lua b/lua/jumpy/path.lua new file mode 100644 index 0000000..1a448f9 --- /dev/null +++ b/lua/jumpy/path.lua @@ -0,0 +1,78 @@ +local M = {} + +function M.normalize_abs(raw_path) + if vim and vim.fn and vim.fn.fnamemodify then + raw_path = vim.fn.fnamemodify(raw_path, ":p") + end + if raw_path:sub(-1) == "/" then + raw_path = raw_path:sub(1, -2) + end + return raw_path +end + +function M.project_root() + local cwd = vim.fn.getcwd() + if vim.system then + local result = vim.system({ "git", "rev-parse", "--show-toplevel" }, { cwd = cwd }):wait() + if result.code == 0 then + local root = vim.trim(result.stdout or "") + if root ~= "" then + return M.normalize_abs(root) + end + end + end + return M.normalize_abs(cwd) +end + +function M.resolve_path(raw_path, root) + root = M.normalize_abs(root or (vim and vim.fn and vim.fn.getcwd() or ".")) + if raw_path:sub(1, 1) == "/" then + return M.normalize_abs(raw_path) + end + return M.normalize_abs(root .. "/" .. raw_path) +end + +function M.rel_path(abs_path, root) + abs_path = M.normalize_abs(abs_path) + root = M.normalize_abs(root) + local prefix = root .. "/" + if abs_path:sub(1, #prefix) == prefix then + return abs_path:sub(#prefix + 1) + end + return abs_path +end + +function M.list_files() + local paths = {} + local root = M.project_root() + + local git_files = vim.fn.systemlist({ + "git", + "-C", + root, + "ls-files", + "--cached", + "--others", + "--exclude-standard", + }) + + -- git command runs successfully + if vim.v.shell_error == 0 then + for _, path in ipairs(git_files) do + if path ~= "" then + table.insert(paths, path) + end + end + return paths + end + + -- fallback to all files + for path, ftype in vim.fs.dir(root, { depth = math.huge }) do + if ftype == "file" and path ~= "" then + table.insert(paths, path) + end + end + return paths +end + +return M diff --git a/lua/jumpy/prompt.lua b/lua/jumpy/prompt.lua index f181bc1..77c0e42 100644 --- a/lua/jumpy/prompt.lua +++ b/lua/jumpy/prompt.lua @@ -1,6 +1,7 @@ local M = {} local context_tools = require("jumpy.context-tools") +local path = require("jumpy.path") local utils = require("jumpy.utils") local state = { @@ -8,6 +9,7 @@ local state = { buf = nil, source_buf = nil, reprompt_hunk_idx = nil, + paths = nil, } local mention_ns = vim.api.nvim_create_namespace("jumpy_mentions") @@ -33,22 +35,53 @@ local function highlight_mentions(buf) end vim.api.nvim_buf_clear_namespace(buf, mention_ns, 0, -1) local lines = vim.api.nvim_buf_get_lines(buf, 0, -1, false) + + local path_set = {} + for _, p in ipairs(state.paths or {}) do + path_set[p] = true + end + for lnum, line in ipairs(lines) do local search_from = 1 while true do - local s, e = line:find("@lsp", search_from, true) - if not s then + local lsp_start, lsp_end = line:find("@lsp", search_from, true) + if not lsp_start then break end - local prev_ch = s > 1 and line:sub(s - 1, s - 1) or "" - local next_ch = line:sub(e + 1, e + 1) + local prev_ch = lsp_start > 1 and line:sub(lsp_start - 1, lsp_start - 1) or "" + local next_ch = line:sub(lsp_end + 1, lsp_end + 1) if not prev_ch:match("[%w@]") and not next_ch:match("[%w]") then - vim.api.nvim_buf_set_extmark(buf, mention_ns, lnum - 1, s - 1, { - end_col = e, + vim.api.nvim_buf_set_extmark(buf, mention_ns, lnum - 1, lsp_start - 1, { + end_col = lsp_end, hl_group = "JumpyMention", }) end - search_from = e + 1 + search_from = lsp_end + 1 + end + + if next(path_set) ~= nil then + search_from = 1 + while true do + local at_pos = line:find("@", search_from, true) + if not at_pos then + break + end + local prev_ch = at_pos > 1 and line:sub(at_pos - 1, at_pos - 1) or "" + if prev_ch:match("[%w@]") then + search_from = at_pos + 1 + else + local _, _, token = line:find("^([%w/_.%-]+)", 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, + hl_group = "JumpyMention", + }) + search_from = at_pos + 1 + #token + else + search_from = at_pos + 1 + end + end + end end end end @@ -96,6 +129,7 @@ function M.open() state.source_buf = vim.api.nvim_get_current_buf() state.reprompt_hunk_idx = nil + state.paths = path.list_files() state.visual_selection = is_scoped and utils.get_visual_selection(state.source_buf) or nil local title = is_scoped and " jumpy (scoped) " or " jumpy " @@ -106,6 +140,35 @@ function M.open() M._setup_completions(state.buf) end +local function build_completion_items(query) + local items = {} + local PATH_COMPLETION_LIMIT = 50 + + if ("lsp"):sub(1, #query) == query then + items[#items + 1] = { word = "@lsp", menu = "[jumpy]", equal = 1 } + end + + local paths = state.paths or {} + local matches + if query == "" then + matches = paths + else + local ok, res = pcall(vim.fn.matchfuzzy, paths, query) + matches = ok and res or {} + end + + local limit = math.min(PATH_COMPLETION_LIMIT, #matches) + for i = 1, limit do + items[#items + 1] = { + word = "@" .. matches[i], + abbr = matches[i], + menu = "[file]", + equal = 1, + } + end + return items +end + function M._setup_completions(buf) vim.bo[buf].completeopt = "menu,menuone,noselect" @@ -119,11 +182,7 @@ function M._setup_completions(buf) -- valid word boundary — i.e. exactly what _submit will detect). vim.api.nvim_set_hl(0, "JumpyMention", { link = "Special", default = true }) - local completionItems = { - { word = "@lsp", menu = "[jumpy]" }, - } - - vim.api.nvim_create_autocmd({ "TextChangedI", "TextChanged" }, { + vim.api.nvim_create_autocmd({ "TextChangedI", "TextChangedP", "TextChanged" }, { buffer = buf, callback = function() highlight_mentions(buf) @@ -135,13 +194,21 @@ function M._setup_completions(buf) local line = vim.api.nvim_get_current_line() local col = vim.fn.col(".") local before = line:sub(1, col - 1) - local match = before:match("@%w*$") - if match then - vim.schedule(function() - vim.fn.complete(col - #match, completionItems) - end) + local query = before:match("@([%w/_.%-]*)$") + if not query then + return + end + + local items = build_completion_items(query) + if #items == 0 then + return end + + local start_col = col - #query - 1 + vim.schedule(function() + vim.fn.complete(start_col, items) + end) end, }) @@ -158,6 +225,7 @@ function M.reprompt() state.source_buf = vim.api.nvim_get_current_buf() state.reprompt_hunk_idx = hunk_idx + state.paths = path.list_files() state.buf, state.win = create_float(" jumpy: reprompt this hunk ") M._set_submit_keymap() @@ -202,12 +270,12 @@ function M._submit() 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, tags.project_root()) or "current" + local source_rel = source_name ~= "" and tags.rel_path(source_name, path.project_root()) or "current" local parsed = tags.parse(prompt_text, { source = { path = source_rel, - abs_path = source_name ~= "" and tags.normalize_abs(source_name) or nil, + abs_path = source_name ~= "" and path.normalize_abs(source_name) or nil, lines = source_lines, bufnr = source_buf, }, @@ -295,8 +363,8 @@ function M._submit() local tagged_by_path = index_tagged_files(tagged_files) local total_hunks = 0 - for path, result in pairs(results) do - local file = tagged_by_path[path] + 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) if bufnr then @@ -306,7 +374,7 @@ function M._submit() total_hunks = total_hunks + #hunks end else - vim.notify("jumpy: could not open " .. path .. ", skipping", vim.log.levels.WARN) + vim.notify("jumpy: could not open " .. file_path .. ", skipping", vim.log.levels.WARN) end end end @@ -393,6 +461,7 @@ function M._close() end state.win = nil state.buf = nil + state.paths = nil vim.cmd("stopinsert") end diff --git a/lua/jumpy/tags.lua b/lua/jumpy/tags.lua index 7c666de..7c809e7 100644 --- a/lua/jumpy/tags.lua +++ b/lua/jumpy/tags.lua @@ -1,3 +1,5 @@ +local path = require("jumpy.path") + local M = {} M.MAX_BYTES = 256 * 1024 @@ -21,16 +23,16 @@ local function word_boundary_after(text, pos) return not text:sub(pos + 1, pos + 1):match("[%w]") end -local function normalize_mention_path(path) - path = path:gsub("/+$", "") - path = path:gsub("%.$", "") - return path +local function normalize_mention_path(mention_path) + mention_path = mention_path:gsub("/+$", "") + mention_path = mention_path:gsub("%.$", "") + return mention_path end local function mention_remove_len(raw) - local path = normalize_mention_path(raw) - if raw:sub(-1) == "." and #raw == #path + 1 then - return #path + local norm_path = normalize_mention_path(raw) + if raw:sub(-1) == "." and #raw == #norm_path + 1 then + return #norm_path end return #raw end @@ -49,11 +51,11 @@ function M.find_mentions(text) if word_boundary_before(text, at) then local rest = text:sub(at + 1) local raw = rest:match("^([%.%w%-_/]+)") - local path = raw and normalize_mention_path(raw) or nil - if path and path ~= "" and not RESERVED[path] and word_boundary_after(text, at + #raw) then - if not seen[path] then - seen[path] = true - table.insert(mentions, path) + 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 else @@ -84,8 +86,8 @@ function M.strip_mentions(text) if word_boundary_before(stripped, at) then local rest = stripped:sub(at + 1) local raw = rest:match("^([%.%w%-_/]+)") - local path = raw and normalize_mention_path(raw) or nil - if path and path ~= "" and not RESERVED[path] and word_boundary_after(stripped, at + #raw) then + 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 @@ -100,34 +102,6 @@ function M.strip_mentions(text) return trim((stripped:gsub("%s+", " "))) end -function M.normalize_abs(path) - if vim and vim.fn and vim.fn.fnamemodify then - path = vim.fn.fnamemodify(path, ":p") - end - if path:sub(-1) == "/" then - path = path:sub(1, -2) - end - return path -end - -function M.resolve_path(raw_path, root) - root = M.normalize_abs(root or (vim and vim.fn and vim.fn.getcwd() or ".")) - if raw_path:sub(1, 1) == "/" then - return M.normalize_abs(raw_path) - end - return M.normalize_abs(root .. "/" .. raw_path) -end - -function M.rel_path(abs_path, root) - abs_path = M.normalize_abs(abs_path) - root = M.normalize_abs(root) - local prefix = root .. "/" - if abs_path:sub(1, #prefix) == prefix then - return abs_path:sub(#prefix + 1) - end - return abs_path -end - local function slice_lines(lines, count) local out = {} for i = 1, math.min(count, #lines) do @@ -145,26 +119,12 @@ function M.truncate_lines(lines) return lines, truncated end -function M.project_root() - local cwd = vim.fn.getcwd() - if vim.system then - local result = vim.system({ "git", "rev-parse", "--show-toplevel" }, { cwd = cwd }):wait() - if result.code == 0 then - local root = vim.trim(result.stdout or "") - if root ~= "" then - return M.normalize_abs(root) - end - end - end - return M.normalize_abs(cwd) -end - function M.find_bufnr(abs_path) - abs_path = M.normalize_abs(abs_path) + abs_path = path.normalize_abs(abs_path) for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(bufnr) then local name = vim.api.nvim_buf_get_name(bufnr) - if name ~= "" and M.normalize_abs(name) == abs_path then + if name ~= "" and path.normalize_abs(name) == abs_path then return bufnr end end @@ -173,7 +133,7 @@ function M.find_bufnr(abs_path) end function M.open_buffer(abs_path) - abs_path = M.normalize_abs(abs_path) + abs_path = path.normalize_abs(abs_path) local bufnr = M.find_bufnr(abs_path) if bufnr then @@ -237,7 +197,7 @@ end function M.parse(prompt_text, opts) opts = opts or {} - local root = opts.root or M.project_root() + local root = opts.root or path.project_root() local mentions = M.find_mentions(prompt_text) local tagged = {} local errors = {} @@ -245,10 +205,10 @@ function M.parse(prompt_text, opts) if opts.source then local src = opts.source - local abs_path = M.normalize_abs(src.abs_path or M.resolve_path(src.path, root)) + local abs_path = path.normalize_abs(src.abs_path or path.resolve_path(src.path, root)) seen_abs[abs_path] = true table.insert(tagged, { - path = src.path or M.rel_path(abs_path, root), + path = src.path or path.rel_path(abs_path, root), abs_path = abs_path, lines = src.lines, bufnr = src.bufnr, @@ -256,7 +216,7 @@ function M.parse(prompt_text, opts) end for _, raw_path in ipairs(mentions) do - local abs_path = M.resolve_path(raw_path, root) + local abs_path = path.resolve_path(raw_path, root) if not seen_abs[abs_path] then local lines, err, bufnr = M.read_lines(abs_path, opts) @@ -264,7 +224,7 @@ function M.parse(prompt_text, opts) table.insert(errors, err or ("could not read: " .. raw_path)) else table.insert(tagged, { - path = M.rel_path(abs_path, root), + path = path.rel_path(abs_path, root), abs_path = abs_path, lines = lines, bufnr = bufnr, diff --git a/tests/path_spec.lua b/tests/path_spec.lua new file mode 100644 index 0000000..55ec709 --- /dev/null +++ b/tests/path_spec.lua @@ -0,0 +1,43 @@ +package.path = package.path .. ";lua/?.lua;lua/?/init.lua" + +_G.vim = _G.vim or {} +_G.vim.fn = _G.vim.fn or {} +_G.vim.fn.getcwd = _G.vim.fn.getcwd or function() + return "/project" +end +_G.vim.fn.fnamemodify = _G.vim.fn.fnamemodify + or function(path, mod) + if mod == ":p" then + if path:sub(1, 1) == "/" then + return path:sub(-1) == "/" and path:sub(1, -2) or path + end + local joined = "/project/" .. path + while joined:find("/%./") do + joined = joined:gsub("/%./", "/") + end + return joined:sub(-1) == "/" and joined:sub(1, -2) or joined + end + return path + end + +local path = require("jumpy.path") + +describe("path.resolve_path", function() + it("resolves relative paths against root", function() + assert.are.equal("/project/lua/foo.lua", path.resolve_path("lua/foo.lua", "/project")) + end) + + it("keeps absolute paths unchanged", function() + assert.are.equal("/tmp/foo.lua", path.resolve_path("/tmp/foo.lua", "/project")) + end) +end) + +describe("path.rel_path", function() + it("returns a path relative to root", function() + assert.are.equal("lua/foo.lua", path.rel_path("/project/lua/foo.lua", "/project")) + end) + + it("returns absolute path when outside root", function() + assert.are.equal("/tmp/foo.lua", path.rel_path("/tmp/foo.lua", "/project")) + end) +end) diff --git a/tests/tags_spec.lua b/tests/tags_spec.lua index d9de760..69a543e 100644 --- a/tests/tags_spec.lua +++ b/tests/tags_spec.lua @@ -70,26 +70,6 @@ describe("tags.strip_mentions", function() end) end) -describe("tags.resolve_path", function() - it("resolves relative paths against root", function() - assert.are.equal("/project/lua/foo.lua", tags.resolve_path("lua/foo.lua", "/project")) - end) - - it("keeps absolute paths unchanged", function() - assert.are.equal("/tmp/foo.lua", tags.resolve_path("/tmp/foo.lua", "/project")) - end) -end) - -describe("tags.rel_path", function() - it("returns a path relative to root", function() - assert.are.equal("lua/foo.lua", tags.rel_path("/project/lua/foo.lua", "/project")) - end) - - it("returns absolute path when outside root", function() - assert.are.equal("/tmp/foo.lua", tags.rel_path("/tmp/foo.lua", "/project")) - end) -end) - describe("tags.truncate_lines", function() it("passes through small files", function() local lines = { "a", "b" }