diff --git a/README.md b/README.md index 4a8fdf74b..2f5f5031f 100644 --- a/README.md +++ b/README.md @@ -292,6 +292,7 @@ vim.keymap.set('n', 'ff', function() require('fff').find_files() end, { desc = ' require('fff').find_files() -- find files in current repo require('fff').live_grep() -- live content grep require('fff').live_grep_under_cursor() -- grep in normal, selection in visual +require('fff').go_to_definition() -- jump to one declaration, or open the picker require('fff').scan_files() -- force rescan require('fff').refresh_git_status() -- refresh git status require('fff').find_files_in_dir(path) -- find in a specific dir @@ -302,6 +303,21 @@ require('fff').file_search(query, opts) -- fuzzy search files / dirs require('fff').content_search(query, opts) -- programmatic grep ``` +#### `go_to_definition(opts)` + +Searches saved files in the active FFF index for declarations that match the word under the cursor. Pass `cwd` to search another directory. If the search completes with exactly one match that declares the query as a whole name, it jumps there. Otherwise, it opens the definition-only picker with the query filled in. An incomplete index or search opens the picker too. + +Uses FFF's declaration matcher without an LSP server. Picker matches can include part of a name, so `user_file` can find `fn test_user_file`; a direct jump never targets a longer name such as `fn length()` for `len`. Returns `true` for a direct jump and `false` otherwise. + +```lua +vim.keymap.set('n', '', function() require('fff').go_to_definition() end, { desc = 'FFF go to definition' }) + +-- Optional query, directory, and picker overrides: +require('fff').go_to_definition({ query = 'UserFile', cwd = '/path/to/project' }) +``` + +Also accepts `title`, `layout`, and `grep` options. This API always searches definitions, even when `grep.classify_definitions` is disabled. + #### `file_search(query, opts)` Returns a structured result `{ items, scores, total_matched, total_files?, total_dirs?, location? }`. Each item has a `type` field (`"file"` or `"directory"`) and `name` / `relative_path`. File items also expose `size`, `modified`, `git_status`, `is_binary`, and frecency scores. diff --git a/crates/fff-nvim/src/lib.rs b/crates/fff-nvim/src/lib.rs index 8853c9735..7754e9c5b 100644 --- a/crates/fff-nvim/src/lib.rs +++ b/crates/fff-nvim/src/lib.rs @@ -1185,9 +1185,16 @@ fn live_grep_with_options( return Err(error::to_lua_error(Error::FilePickerMissing)); }; let parsed_query = FFFQuery::parse(query, NvimGrepConfig); + let is_index_ready = picker.has_content_indexing() + && picker.bigram_index().is_some() + && !picker.is_scan_active(); let mut result = picker.grep(&parsed_query, options); if options.classify_definitions == fff::DefinitionMode::Classify { result.matches.sort_by_key(|m| !m.is_definition); } - lua_types::GrepResultLua::new(result, picker).into_lua(lua) + let result = lua_types::GrepResultLua::new(result, picker).into_lua(lua)?; + if let LuaValue::Table(table) = &result { + table.set("is_index_ready", is_index_ready)?; + } + Ok(result) } diff --git a/crates/fff-nvim/src/lua_types.rs b/crates/fff-nvim/src/lua_types.rs index 01d81e079..53c4f0677 100644 --- a/crates/fff-nvim/src/lua_types.rs +++ b/crates/fff-nvim/src/lua_types.rs @@ -236,6 +236,10 @@ impl IntoLua for MixedSearchResultLua<'_> { impl IntoLua for GrepResultLua<'_> { fn into_lua(self, lua: &Lua) -> LuaResult { let table = lua.create_table()?; + table.set( + "base_path", + self.picker.base_path().to_string_lossy().as_ref(), + )?; // Convert grep match items — each includes file metadata + match metadata let items_table = lua.create_table()?; diff --git a/doc/fff.nvim.txt b/doc/fff.nvim.txt index 4dfbe51aa..4c7d0047f 100644 --- a/doc/fff.nvim.txt +++ b/doc/fff.nvim.txt @@ -88,6 +88,7 @@ PUBLIC API ~ require('fff').find_files() -- find files in current repo require('fff').live_grep() -- live content grep require('fff').live_grep_under_cursor() -- grep in normal, selection in visual + require('fff').go_to_definition() -- jump to one declaration, or open the picker require('fff').scan_files() -- force rescan require('fff').refresh_git_status() -- refresh git status require('fff').find_files_in_dir(path) -- find in a specific dir @@ -99,6 +100,31 @@ PUBLIC API ~ < +GO_TO_DEFINITION(OPTS) *fff.go_to_definition()* + +Searches saved files in the active FFF index for declarations that match the +word under the cursor. Pass `cwd` to search another directory. +If the search completes with exactly one match that declares the query as a +whole name, it jumps there. Otherwise, it opens the definition-only picker +with the query filled in. An incomplete index or search opens the picker too. + +Uses FFF's declaration matcher without an LSP server. Picker matches can +include part of a name, so `user_file` can find `fn test_user_file`; a direct +jump never targets a longer name such as `fn length()` for `len`. Returns +`true` for a direct jump and `false` otherwise. + +>lua + vim.keymap.set('n', '', function() + require('fff').go_to_definition() + end, { desc = 'FFF go to definition' }) + + require('fff').go_to_definition({ query = 'UserFile', cwd = '/path/to/project' }) +< + +Also accepts `title`, `layout`, and `grep` options. This API always searches +definitions, even when `grep.classify_definitions` is disabled. + + FILE_SEARCH(QUERY, OPTS) Returns a structured result `{ items, scores, total_matched, total_files?, diff --git a/lua/fff/main.lua b/lua/fff/main.lua index 5ad41e30d..f6b1ebc06 100644 --- a/lua/fff/main.lua +++ b/lua/fff/main.lua @@ -1,4 +1,5 @@ local M = {} +local declares_exact_name M.state = { initialized = false } @@ -672,4 +673,88 @@ function M.open_file_under_cursor(open_cb) end) end +--- Jump to a unique declaration; otherwise open the definition picker. +--- @param opts? {query?: string, cwd?: string, title?: string, layout?: table, grep?: table} +--- @return boolean jumped Whether a declaration was opened without the picker. +function M.go_to_definition(opts) + vim.validate({ opts = { opts, 'table', true } }) + opts = vim.deepcopy(opts or {}) + vim.validate({ + query = { opts.query, 'string', true }, + cwd = { opts.cwd, 'string', true }, + title = { opts.title, 'string', true }, + layout = { opts.layout, 'table', true }, + grep = { opts.grep, 'table', true }, + }) + + opts.query = opts.query or vim.fn.expand('') + opts.title = opts.title or 'Definitions' + opts.grep = opts.grep or {} + opts.grep.modes = { 'definition_only' } + opts.grep.classify_definitions = 'definition_only' + local config = require('fff.conf').get() + local root, same_root + if opts.cwd and opts.cwd ~= '' then + root = vim.uv.fs_realpath(vim.fn.expand(opts.cwd)) + if not root or vim.fn.isdirectory(root) ~= 1 then + vim.notify('FFF go_to_definition: cwd does not exist', vim.log.levels.ERROR) + return false + end + same_root = vim.uv.fs_realpath(config.base_path) == root + end + + local fuzzy = require('fff.core').ensure_initialized() + local ready, progress = pcall(fuzzy.get_scan_progress) + if opts.query ~= '' and ready and progress.is_index_ready and same_root ~= false then + local grep_config = vim.tbl_deep_extend('force', config.grep, opts.grep, { + max_matches_per_file = 2, + trim_whitespace = false, + time_budget_ms = 150, + enforce_time_budget = true, + }) + local ok, result = + pcall(require('fff.picker_ui.grep_renderer').search, opts.query, 0, 2, grep_config, 'definition_only') + -- A partial page or index cannot prove that a declaration is unique. + if + ok + and result.is_index_ready + and #result.items == 1 + and #(result.items[1].match_ranges or {}) == 1 + and result.next_file_offset == 0 + and (not root or vim.uv.fs_realpath(result.base_path) == root) + and declares_exact_name(result.items[1], opts.query) + then + local item = result.items[1] + local path = vim.fs.joinpath(result.base_path, item.relative_path) + vim.cmd("normal! m'") + if vim.fn.bufadd(path) ~= vim.api.nvim_get_current_buf() then open_resolved_file(path, item.relative_path) end + require('fff.location_utils').jump_to_location({ line = item.line_number, col = item.col + 1 }) + vim.cmd('normal! zv') + return true + end + end + + M.live_grep({ + query = opts.query, + cwd = root or config.base_path, + title = opts.title, + layout = opts.layout, + grep = opts.grep, + }) + return false +end + +local function is_identifier_byte(byte) return byte ~= nil and (byte >= 128 or string.char(byte):match('[%w_]') ~= nil) end + +-- The matcher is a substring search, so `len` also finds `fn length()`. +-- A direct jump requires the declared identifier to equal the query. +function declares_exact_name(item, query) + local line, range = item.line_content, item.match_ranges[1] + if type(line) ~= 'string' or type(range) ~= 'table' then return false end + local start, stop = range[1], range[2] + if type(start) ~= 'number' or type(stop) ~= 'number' then return false end + if line:sub(start + 1, stop) ~= query then return false end + return not is_identifier_byte(line:byte(start)) and not is_identifier_byte(line:byte(stop + 1)) +end + return M diff --git a/lua/fff/picker_ui/grep_renderer.lua b/lua/fff/picker_ui/grep_renderer.lua index b7b219d78..f1111fa88 100644 --- a/lua/fff/picker_ui/grep_renderer.lua +++ b/lua/fff/picker_ui/grep_renderer.lua @@ -13,6 +13,8 @@ local tresitter_highlight = require('fff.treesitter_hl') ---@field is_definition boolean ---@class fff.grep.SearchResult +---@field base_path string Root of the index that produced these matches +---@field is_index_ready boolean Whether this index has completed its initial scan ---@field items fff.grep.Match[] Array of grep match items ---@field total_matched number Total matches found in this call ---@field total_files_searched number Files actually searched in this call diff --git a/tests/go_to_definition_spec.lua b/tests/go_to_definition_spec.lua new file mode 100644 index 000000000..82f5e4fef --- /dev/null +++ b/tests/go_to_definition_spec.lua @@ -0,0 +1,216 @@ +---@diagnostic disable: undefined-field +local fff = require('fff') +local rust = require('fff.rust') +local fuzzy = require('fff.fuzzy') +local core = require('fff.core') +local grep + +describe('go_to_definition', function() + local root, original_cwd, fallback + local live_grep, search, get_scan_progress, health_check, realpath + + before_each(function() + original_cwd = vim.fn.getcwd() + root = vim.fn.tempname() + vim.fn.mkdir(root, 'p') + vim.fn.writefile({ + 'mod nested {', + ' fn UniqueTarget() {}', + '}', + 'fn RepeatedTarget() {}', + 'fn RepeatedTargetExtra() {}', + 'fn test_partial_name() {}', + 'fn TwinOne() {} fn TwinTwo() {}', + 'fn length() {}', + }, root .. '/definitions.rs') + vim.fn.writefile({ 'UniqueTarget();', 'RepeatedTarget();', 'NoDefinition();' }, root .. '/usage.rs') + vim.cmd('noautocmd cd ' .. vim.fn.fnameescape(root)) + local config = require('fff.conf').get() + config.base_path = root + config.frecency.enabled = false + config.history.enabled = false + config.frecency.db_path = root .. '/frecency' + config.history.db_path = root .. '/history' + config.logging.enabled = false + config.grep.classify_definitions = false + config.grep.trim_whitespace = true + config.grep.max_matches_per_file = 1 + core.mark_file_picker_uninitialized() + core.ensure_initialized() + assert.is_true(vim.wait(30000, function() return fuzzy.get_scan_progress().is_index_ready end)) + grep = require('fff.picker_ui.grep_renderer') + vim.cmd('edit usage.rs') + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + fallback = nil + live_grep, search, get_scan_progress = fff.live_grep, grep.search, fuzzy.get_scan_progress + health_check, realpath = fuzzy.health_check, vim.uv.fs_realpath + fff.live_grep = function(opts) fallback = opts end + end) + + after_each(function() + fff.live_grep, grep.search, fuzzy.get_scan_progress = live_grep, search, get_scan_progress + fuzzy.health_check, vim.uv.fs_realpath = health_check, realpath + vim.wo.winfixbuf = false + vim.cmd('only!') + vim.cmd('noautocmd %bwipeout!') + rust.stop_background_monitor() + rust.cleanup_file_picker() + rust.destroy_frecency_db() + rust.destroy_query_db() + core.mark_file_picker_uninitialized() + vim.cmd('noautocmd cd ' .. vim.fn.fnameescape(original_cwd)) + vim.fn.delete(root, 'rf') + end) + + it('jumps from a use to the only declaration with an untrimmed byte column', function() + assert.is_true(fff.go_to_definition()) + assert.is_nil(fallback) + assert.are.equal('definitions.rs', vim.fn.expand('%:t')) + assert.are.same({ 2, 7 }, vim.api.nvim_win_get_cursor(0)) + vim.cmd.normal({ vim.api.nvim_replace_termcodes('', true, false, true), bang = true }) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + assert.are.same({ 1, 0 }, vim.api.nvim_win_get_cursor(0)) + end) + + it('keeps unsaved changes when the definition is in the current buffer', function() + vim.cmd('edit definitions.rs') + vim.api.nvim_buf_set_lines(0, -1, -1, false, { '// unsaved' }) + assert.is_true(fff.go_to_definition({ query = 'UniqueTarget' })) + assert.is_true(vim.bo.modified) + assert.are.equal('// unsaved', vim.api.nvim_buf_get_lines(0, -2, -1, false)[1]) + assert.are.same({ 2, 7 }, vim.api.nvim_win_get_cursor(0)) + end) + + it('reuses the active index without resolving roots or running health checks', function() + local other = root .. '/other' + local expected = realpath(root) .. '/definitions.rs' + vim.fn.mkdir(other, 'p') + vim.cmd('noautocmd lcd ' .. vim.fn.fnameescape(other)) + fuzzy.health_check = function() error('navigation must not run health checks') end + vim.uv.fs_realpath = function(_path, _callback) error('default navigation must not resolve roots') end + assert.is_true(fff.go_to_definition()) + assert.is_true(fff.go_to_definition({ query = 'UniqueTarget' })) + assert.are.equal(expected, vim.api.nvim_buf_get_name(0)) + assert.are.same({ 2, 7 }, vim.api.nvim_win_get_cursor(0)) + end) + + it('opens another window when the source window has winfixbuf', function() + local source = vim.api.nvim_get_current_win() + vim.wo.winfixbuf = true + assert.is_true(fff.go_to_definition()) + assert.are_not.equal(source, vim.api.nvim_get_current_win()) + assert.are.equal('usage.rs', vim.fn.fnamemodify(vim.api.nvim_buf_get_name(vim.api.nvim_win_get_buf(source)), ':t')) + assert.are.equal('definitions.rs', vim.fn.expand('%:t')) + end) + + it('detects two declarations in one file despite a configured limit of one', function() + local opts = { query = 'RepeatedTarget', grep = { modes = { 'fuzzy' } } } + local saved = vim.deepcopy(opts) + assert.is_false(fff.go_to_definition(opts)) + assert.are.equal('RepeatedTarget', fallback.query) + assert.are.same({ 'definition_only' }, fallback.grep.modes) + assert.are.equal('definition_only', fallback.grep.classify_definitions) + assert.are.same(saved, opts) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('opens the picker when two matching declarations share a line', function() + assert.is_false(fff.go_to_definition({ query = 'Twin' })) + assert.are.equal('Twin', fallback.query) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('opens the definition picker when only usages match', function() + assert.is_false(fff.go_to_definition({ query = 'NoDefinition' })) + assert.are.equal('NoDefinition', fallback.query) + assert.are.same({ 'definition_only' }, fallback.grep.modes) + end) + + it('opens the picker when the only match is part of a longer declared name', function() + assert.is_false(fff.go_to_definition({ query = 'len' })) + assert.are.equal('len', fallback.query) + assert.are.same({ 'definition_only' }, fallback.grep.modes) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('opens the picker when the query is a suffix of the declared name', function() + assert.is_false(fff.go_to_definition({ query = 'partial_name' })) + assert.are.equal('partial_name', fallback.query) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('jumps when the single match is the whole declared name', function() + assert.is_true(fff.go_to_definition({ query = 'length' })) + assert.is_nil(fallback) + assert.are.same({ 8, 3 }, vim.api.nvim_win_get_cursor(0)) + end) + + it('does not treat one result on an incomplete page as unique', function() + grep.search = function(...) + local result = search(...) + assert.are.equal(1, #result.items) + result.next_file_offset = 1 + return result + end + assert.is_false(fff.go_to_definition()) + assert.are.equal('UniqueTarget', fallback.query) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('opens the picker without probing a partial index', function() + fuzzy.get_scan_progress = function() return { is_index_ready = false } end + grep.search = function() error('partial index must not be searched') end + assert.is_false(fff.go_to_definition()) + assert.are.equal('UniqueTarget', fallback.query) + end) + + it('does not jump if the searched index was incomplete despite the earlier readiness check', function() + grep.search = function(...) + local result = search(...) + result.is_index_ready = false + return result + end + assert.is_false(fff.go_to_definition()) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('uses the active index when cwd is empty', function() + assert.is_true(fff.go_to_definition({ cwd = '' })) + assert.are.equal('definitions.rs', vim.fn.expand('%:t')) + end) + + it('jumps for an explicit cwd that matches the active index', function() + assert.is_true(fff.go_to_definition({ cwd = root })) + assert.are.equal('definitions.rs', vim.fn.expand('%:t')) + end) + + it('opens the picker for a different root without searching the old index', function() + local other = root .. '/other' + vim.fn.mkdir(other, 'p') + grep.search = function() error('the old root must not be searched') end + assert.is_false(fff.go_to_definition({ cwd = other })) + assert.are.equal(vim.uv.fs_realpath(other), fallback.cwd) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('opens an empty definition picker when there is no word under the cursor', function() + assert.is_false(fff.go_to_definition({ query = '' })) + assert.are.equal('', fallback.query) + end) + + it('does not jump to the old index while a cwd change is pending', function() + local other = root .. '/other' + vim.fn.mkdir(other, 'p') + require('fff.conf').get().base_path = other + assert.is_false(fff.go_to_definition({ cwd = other })) + assert.are.equal(vim.uv.fs_realpath(other), fallback.cwd) + assert.are.equal('usage.rs', vim.fn.expand('%:t')) + end) + + it('validates the public arguments', function() + assert.has_error(function() fff.go_to_definition('UniqueTarget') end) + assert.has_error(function() fff.go_to_definition({ query = 1 }) end) + assert.has_error(function() fff.go_to_definition({ cwd = false }) end) + assert.has_error(function() fff.go_to_definition({ grep = 'plain' }) end) + end) +end) diff --git a/tests/grep_classification_spec.lua b/tests/grep_classification_spec.lua index f9c1eaf13..90a15a11b 100644 --- a/tests/grep_classification_spec.lua +++ b/tests/grep_classification_spec.lua @@ -64,7 +64,8 @@ describe('native grep page classification', function() require('fff.file_picker').setup() rust.cleanup_file_picker() rust.init_file_picker(root) - rust.wait_for_initial_scan(30000) + -- Both pages must observe the same content-index state when we compare them. + assert.is_true(vim.wait(30000, function() return rust.get_scan_progress().is_index_ready end)) end) after_each(function()