Grapple.nvim
Theme: kanagawa
Introduction
Grapple is a plugin that aims to provide immediate navigation to important files (and their last known cursor location) by means of persistent file tags within a project scope. Tagged files can be bound to a keymap or selected from within an editable popup menu.
See the quickstart section to get started.
Features
- Project scoped file tagging for immediate navigation
- Persistent cursor tracking for tagged files
- Popup menu to manage tags and scopes as regular text
- Integration with portal.nvim for additional jump options
Requirements
- Neovim >= 0.8
- Neovim >= 0.9 - optional, for floating window title
- plenary.nvim
Quickstart
- Install Grapple.nvim using your preferred package manager
- Add a keybind to
tag
,untag
, ortoggle
a tag. For example,
vim.keymap.set("n", "<leader>m", require("grapple").toggle)
Next steps
- Check out the default settings in the settings section
- View your tags in a popup using
:GrapplePopup tags
- Know when a file is tagged by adding statusline component
- Choose a builtin scope or try your hand at creating a custom scope to store your tags
- Explore the Grapple and Scope APIs
Installation
lazy.nvim
{
"cbochs/grapple.nvim",
dependencies = { "nvim-lua/plenary.nvim" },
}
packer
use {
"cbochs/grapple.nvim",
requires = { "nvim-lua/plenary.nvim" },
}
vim-plug
Plug "nvim-lua/plenary.nvim"
Plug "cbochs/grapple.nvim"
Settings
The following are the default settings for Grapple. Setup is not required, but settings may be overridden by passing them as table arguments to the grapple#setup
function.
Default Settings
require("grapple").setup({
---@type "debug" | "info" | "warn" | "error"
log_level = "warn",
---Can be either the name of a builtin scope resolver,
---or a custom scope resolver
---@type string | Grapple.ScopeResolver
scope = "git",
---The save location for tags
---@type string
save_path = tostring(Path:new(vim.fn.stdpath("data")) / "grapple"),
---Window options used for the popup menu
popup_options = {
relative = "editor",
width = 60,
height = 12,
style = "minimal",
focusable = false,
border = "single",
},
integrations = {
---Support for saving tag state using resession.nvim
resession = false,
},
})
Usage
Grapple API
Grapple API and Examples
grapple#tag
Create a scoped tag on a file or buffer with an (optional) tag key.
Command: :GrappleTag [key={index} or key={name}] [buffer={buffer}] [file_path={file_path}] [scope={scope}]
API: require("grapple").tag(opts)
opts?
: Grapple.Options
buffer?
:integer
(default:0
)file_path?
:string
(overridesbuffer
)key?
:Grapple.TagKey
scope?
:Grapple.ScopeResolverLike
(default:settings.scope
)
Note: only one tag can be created per scope per file. If a tag already exists for the given file or buffer, it will be overridden with the new tag.
Examples
-- Tag the current buffer
require("grapple").tag()
-- Tag a file using its file path
require("grapple").tag({ file_path = "{file_path}" })
-- Tag the current buffer using a specified key
require("grapple").tag({ key = 1 })
require("grapple").tag({ key = "{name}" })
-- Tag the current buffer in a specified scope
require("grapple").tag({ scope = "global" })
grapple#untag
Remove a scoped tag on a file or buffer.
Command: :GrappleUntag [key={name} or key={index}] [buffer={buffer}] [file_path={file_path}] [scope={scope}]
API: require("grapple").untag(opts)
opts
: Grapple.Options
(one of)
buffer?
:integer
(default:0
)file_path?
:string
(overridesbuffer
)key?
:Grapple.TagKey
(overridesbuffer
andfile_path
)scope?
:Grapple.ScopeResolverLike
(default:settings.scope
)
Examples
-- Untag the current buffer
require("grapple").untag()
-- Untag a file using its file path
require("grapple").untag({ file_path = "{file_path}" })
-- Untag a file using its tag key
require("grapple").untag({ key = 1 })
require("grapple").untag({ key = "{name}" })
-- Untag a the current buffer from a scope
require("grapple").untag({ scope = "global" })
grapple#toggle
Toggle a tag or untag on a file or buffer.
Command: :GrappleToggle [key={index} or key={name}] [buffer={buffer}] [file_path={file_path}] [scope={scope}]
API: require("grapple").toggle(opts)
opts
: Grapple.Options
buffer?
:integer
(default:0
)file_path?
:string
(overridesbuffer
)key?
:Grapple.TagKey
(behaviour inherited from grapple#tag and grapple#untag)scope?
:Grapple.ScopeResolverLike
Examples
-- Toggle a tag on the current buffer
require("grapple").toggle()
grapple#select
Select and open a tagged file or buffer in the current window.
Command: :GrappleSelect [key={index} or key={name}]
API: require("grapple").select(opts)
opts
: Grapple.Options
(one of)
buffer?
:integer
file_path?
:string
key?
:Grapple.TagKey
(preferred)
Examples
-- Select an anonymous (numbered) tag
require("grapple").select({ key = 1 })
-- Select a named tag
require("grapple").select({ key = "{name}" })
grapple#find
Attempt to find a scoped tag.
API: require("grapple").find(opts)
returns
: Grapple.Tag
| nil
opts?
: Grapple.Options
(one of)
buffer?
:integer
(default:0
)file_path?
:string
(overridesbuffer
)key?
:Grapple.TagKey
(overridesbuffer
andfile_path
)
Examples
-- Find the tag associated with the current buffer
require("grapple").find()
grapple#key
Attempt to find the key associated with a file tag.
API: require("grapple").key(opts)
returns
: Grapple.TagKey
| nil
opts?
: Grapple.Options
(one of)
buffer?
:integer
(default:0
)file_path?
:string
(overridesbuffer
)key?
:Grapple.TagKey
(overridesbuffer
andfile_path
)
Examples
-- Find the tag key associated with the current buffer
require("grapple").key()
grapple#exists
API: require("grapple").exists(opts)
returns
: boolean
opts?
: Grapple.Options
(one of)
buffer?
:integer
(default:0
)file_path?
:string
(overridesbuffer
)key?
:Grapple.TagKey
(overridesbuffer
andfile_path
)scope?
:Grapple.ScopeResolverLike
(default:settings.scope
)
Examples
-- Check whether the current buffer is tagged or not
require("grapple").exists()
-- Check for a tag in a different scope
require("grapple").exists({ scope = "global" })
grapple#cycle
Cycle through and select from the available tagged files in a scoped tag list.
Command: :GrappleCycle {direction}
API:
require("grapple").cycle(direction)
require("grapple").cycle_backward()
require("grapple").cycle_forward()
direction
: "backward"
| "forward"
Note: only anonymous tags are cycled through, not named tags.
Examples
-- Cycle to the previous tagged file
require("grapple").cycle_backward()
-- Cycle to the next tagged file
require("grapple").cycle_forward()
grapple#tags
Return all tags for a given project scope.
Command: :Grapple tags {scope}
API: require("grapple").tags(scope)
scope?
: Grapple.ScopeResolverLike
(default: settings.scope
)
Examples
-- Get all tags for the current scope
require("grapple").tags()
grapple#reset
Clear all tags for a given project scope.
Command: :GrappleReset [scope]
API: require("grapple").reset(scope)
scope?
: Grapple.ScopeResolverLike
(default: settings.scope
)
Examples
-- Reset tags for the current scope
require("grapple").reset()
-- Reset tags for a specified scope
require("grapple").reset("global")
grapple#quickfix
Open the quickfix menu and populate the quickfix list with a project scope's tags.
API: require("grapple").quickfix(scope)
scope?
: Grapple.ScopeResolverLike
(default: settings.scope
)
Examples
-- Open the quickfix menu for the current scope
require("grapple").quickfix()
-- Open the quickfix menu for a specified scope
require("grapple").quickfix("global")
Scope API
Scope API and Examples
grapple.scope#resolver
Create a scope resolver that generates a project scope.
API: require("grapple.scope").resolver(scope_callback, opts)
returns
: Grapple.ScopeResolver
scope_callback
: Grapple.ScopeFunction
| Grapple.ScopeJob
opts?
: Grapple.ScopeOptions
cache?
:boolean
|string
|string[]
|integer
(default:true
)persist?
:boolean
(default:true
)
Examples
-- Create a scope resolver that updates when the current working
-- directory changes
require("grapple.scope").resolver(function()
return vim.fn.getcwd()
end, { cache = "DirChanged" })
-- Create an scope resolver that asynchronously runs the "echo"
-- shell command and uses its output as the resolved scope
require("grapple.scope").resolver({
command = "echo",
args = [ "hello_world" ],
cwd = vim.fn.getcwd(),
on_exit = function(job, return_value)
return job:result()[1]
end
})
grapple.scope#root
Create a scope resolver that generates a project scope by looking upwards for directories containing a specific file or directory.
API: require("grapple.scope").root(root_names, opts)
returns
: Grapple.ScopeResolver
root_names
: string
| string[]
opts?
: Grapple.ScopeOptions
cache?
:boolean
|string
|string[]
|integer
(default:"DirChanged"
)persist?
:boolean
(default:true
)
Note: it is recommended to use this with a fallback scope resolver to guarantee that a scope is found.
Examples
-- Create a root scope resolver that looks for a directory containing
-- a "Cargo.toml" file
require("grapple.scope").root("Cargo.toml")
-- Create a root scope resolver that falls back to using the initial working
-- directory for your neovim session
require("grapple.scope").fallback({
require("grapple.scope").root("Cargo.toml"),
require("grapple").resolvers.static,
})
grapple.scope#root_from_buffer
Create a scope resolver that generates a project scope by looking upwards for directories containing a specific file or directory from the current buffer.
API: require("grapple.scope").root(root_names, opts)
returns
: Grapple.ScopeResolver
root_names
: string
| string[]
opts?
: Grapple.ScopeOptions
cache?
:boolean
|string
|string[]
|integer
(default:"BufEnter"
)persist?
:boolean
(default:true
)
Note: it is recommended to use this with a fallback scope resolver to guarantee that a scope is found.
Examples
-- Create a buffer-based root scope resolver that looks for a directory
-- containing a "Cargo.toml" file
require("grapple.scope").root_from_buffer("Cargo.toml")
-- Create a buffer-based root scope resolver that falls back to using
-- the initial working directory for your neovim session
require("grapple.scope").fallback({
require("grapple.scope").root_from_buffer("Cargo.toml"),
require("grapple").resolvers.static,
})
grapple.scope#fallback
Create a scope resolver that generates a project scope by attempting to get the project scope of other scope resolvers, in order.
API: require("grapple.scope").fallback(scope_resolvers, opts)
returns
: Grapple.ScopeResolver
scope_resolvers
: Grapple.ScopeResolver[]
opts?
: Grapple.ScopeOptions
cache?
:boolean
|string
|string[]
|integer
(default:false
)persist?
:boolean
(default:true
)
Examples
-- Create a fallback scope resolver that first tries to use the LSP for a scope
-- path, then looks for a ".git" repository, and finally falls back on using
-- the initial working directory that neovim was started in
require("grapple.scope").fallback({
require("grapple").resolvers.lsp_fallback,
require("grapple").resolvers.git_fallback,
require("grapple").resolvers.static
})
grapple.scope#suffix
Create a scope resolver that takes in two scope resolvers: a path resolver and a suffix resolver. If the scope determined from the path resolver is not nil, then the scope from the suffix resolver may be appended to it. Useful in situations where you may want to append additional project information (i.e. the current git branch).
API: require("grapple.scope").suffix(path_resolver, suffix_resolver, opts)
returns
: Grapple.ScopeResolver
path_resolver
: Grapple.ScopeResolver
suffix_resolver
: Grapple.ScopeResolver
opts?
: Grapple.ScopeOptions
cache?
:boolean
|string
|string[]
|integer
(default:false
)persist?
:boolean
(default:true
)
Examples
-- Create a suffix scope resolver that duplicates a static resolver
-- and appends it to itself (e.g. "asdf#asdf")
require("grapple.scope").suffix(
require("grapple.scope").static("asdf"),
require("grapple.scope").static("asdf"),
)
grapple.scope#static
Create a scope resolver that simply returns a static string. Useful when creating sub-groups with grapple.scope#suffix
.
API: require("grapple.scope").static(plain_string, opts)
returns
: Grapple.ScopeResolver
plain_string
: string
opts?
: Grapple.ScopeOptions
cache?
:boolean
|string
|string[]
|integer
(default:false
)persist?
:boolean
(default:true
)
Examples
-- Create a static scope resolver that simply returns "I'm a teapot"
require("grapple.scope").static("I'm a teapot")
-- Create a suffix scope resolver that appends the suffix "commands"
-- to the end of the git scope resolver
require("grapple.scope").suffix(
require("grapple").resolvers.git,
require("grapple.scope").static("commands")
)
grapple.scope#invalidate
Clear the cached project scope, forcing the next call to get the project scope to re-resolve and re-instantiate the cache.
API: require("grapple.scope").invalidate(scope_resolver)
scope_resolver
: Grapple.ScopeResolverLike
Examples
local my_resolver = require("grapple.scope").resolver(function()
return vim.fn.getcwd()
end)
-- Invalidate a cached scope associated with a scope resolver
require("grapple.scope").invalidate(my_resolver)
grapple.scope#update
Update the cached project scope. Unlike grapple.scope#invalidate
which lazily updates the project scope, this immediately updates the cached project scope.
API: require("grapple.scope").update(scope_resolver)
scope_resolver
: Grapple.ScopeResolverLike
Examples
local my_resolver = require("grapple.scope").resolver(function()
return vim.fn.getcwd()
end)
-- Update a cached scope associated with a scope resolver
require("grapple.scope").update(my_resolver)
File Tags
A tag is a persistent tag on a file or buffer. It is a means of indicating a file you want to return to. When a file is tagged, Grapple will save your cursor location so that when you jump back, your cursor is placed right where you left off. In a sense, tags are like file-level marks (:h mark
).
There are a couple types of tag types available, each with a different use-case in mind. The options available are anonymous and named tags. In addition, tags are scoped to prevent tags in one project polluting the namespace of another. For command and API information, please see the Grapple API. For additional examples, see the Wiki.
Anonymous Tags
This is the default tag type. Anonymous tags are added to a list, where they may be selected by index, cycled through, or jumped to using the tag popup menu or plugins such as portal.nvim.
Anonymous tags are similar to those found in plugins like harpoon.
Named Tags
Tags that are given a name are considered to be named tags. These tags will not be cycled through with cycle_{backward, forward}
, but instead must be explicitly selected.
Named tags are useful if you want one or two keymaps to be used for tagging and selecting. For example, the pairs <leader>j/J
and <leader>k/K
to select/toggle
a file tag (see: suggested keymaps).
Project Scopes
A scope is a means of namespacing tags to a specific project. During runtime, scopes are typically resolved into an absolute directory path (i.e. current working directory), which - in turn - is used as the "root" location for a set of tags.
Project scopes are cached by default, and will only update when the cache is explicitly invalidated, an associated (:h autocmd
) is triggered, or at a specified interval. For example, the static
scope never updates once cached; the directory
scope only updates on DirChanged
; and the lsp
scope updates on either LspAttach
or LspDetach
.
A project scope is determined by means of a scope resolver. The builtin options are as follows:
none
: tags are ephemeral and deleted on exitglobal
: tags are scoped to a global namespacestatic
: tags are scoped to neovim's initial working directorydirectory
: tags are scoped to the current working directorylsp
: tags are scoped using theroot_dir
of the current buffer's attached LSP server, fallback:static
git
: tags are scoped to the current git repository, fallback:static
git_branch
: tags are scoped to the current git repository and branch (async), fallback:static
There are three additional scope resolvers which should be preferred when creating a fallback scope resolver or suffix scope resolver. These resolvers act identically to their similarly named counterparts, but do not have default fallbacks.
lsp_fallback
: the same aslsp
, but without a fallbackgit_fallback
: the same asgit
, but without a fallbackgit_branch_suffix
: resolves suffix (branch) forgit_branch
(async)
It is also possible to create your own custom scope resolver. For the available scope resolver types, please see the Scope API. For additional examples, see the Wiki.
Examples
-- Setup using a builtin scope resolver
require("grapple").setup({
scope = require("grapple").resolvers.git
})
-- Setup using a custom scope resolver
require("grapple").setup({
scope = require("grapple.scope").resolver(function()
return vim.fn.getcwd()
end, { cache = "DirChanged" })
})
Popup Menu
A popup menu is available to enable easy management of tags and scopes. The opened buffer (filetype: grapple
) can be modified like a regular buffer; meaning items can be selected, modified, reordered, or deleted with well-known vim motions. Currently, there are two available popup menus: one for tags and another for scopes.
Tag Popup Menu
The tags popup menu opens a floating window containing all the tags within a specified scope. The floating window can be exited with either q
, <esc>
, or any keybinding that is bound to <esc>
. Several actions are available within the tags popup menu:
- Selection: a tag can be selected by moving to its corresponding line and pressing enter (
<cr>
) - Deletion: a tag (or tags) can be removed by deleting them from the popup menu (i.e. NORMAL
dd
and VISUALd
) - Reordering: an anonymous tag (or tags) can be reordered by moving them up or down within the popup menu. Ordering is determined by the tags position within the popup menu: top (first index) to bottom (last index)
- Renaming: a named tag can be renamed by editing its key value between the
[
square brackets]
- Quickfix (
<c-q>
): all tags will be sent to the quickfix list, the popup menu closed, and the quickfix menu opened - Split (
<c-v>
): similar to tag selection, but the tagged file opened in a vertical split
Command: :GrapplePopup tags
API: require("grapple").popup_tags(scope)
scope?
: Grapple.ScopeResolverLike
(default: settings.scope
)
Examples
-- Open the tags popup menu in the current scope
require("grapple").popup_tags()
-- Open the tags popup menu in a different scope
require("grapple").popup_tags("global")
Scope Popup Menu
The scopes popup menu opens a floating window containing all the loaded project scopes. A scope (or scopes) can be deleted with typical vim edits (i.e. NORMAL dd
and VISUAL d
). The floating window can be exited with either q
or any keybinding that is bound to <esc>
. The total number of tags within a scope will be displayed to the left of the project scope.
Command: :GrapplePopup scopes
API: require("grapple.popup_scopes()
Examples
-- Open the scopes popup menu
require("grapple").popup_scopes()
Persistent State
Grapple saves all project scopes to a common directory. This directory is aptly named grapple
and lives in Neovim's "data"
directory (see: :h standard-path
). Each non-empty scope (scope contains at least one item) will be saved as an individiual scope file; serialized as a JSON blob, and named using the resolved scope's path.
Each tag in a scope will contain two pieces of information: the absolute file path
of the tagged file and its last known cursor
location.
When a user starts Neovim, no scopes are initially loaded. Instead, Grapple will wait until the user requests a project scope (e.g. tagging a file or opening the tags popup menu). At that point, one of three things can occur:
- the scope is already loaded, nothing is needed to be done
- the scope has not been loaded, attempt to load scope state from its associated scope file
- the scope file was not found, initialize the scope state as an empty table
Suggested Keymaps
Anonymous tag keymaps
vim.keymap.set("n", "<leader>m", require("grapple").toggle)
Named tag keymaps
vim.keymap.set("n", "<leader>j", function()
require("grapple").select({ key = "{name}" })
end)
vim.keymap.set("n", "<leader>J", function()
require("grapple").toggle({ key = "{name}" })
end)
Integrations
Statusline
A statusline component can be easily added to show whether a buffer is tagged or not by using either (or both) grapple#key
and grapple#find
.
Simple lualine.nvim statusline
require("lualine").setup({
sections = {
lualine_b = {
{
require("grapple").key,
cond = require("grapple").exists
}
}
}
})
Slightly nicer lualine.nvim statusline
require("lualine").setup({
sections = {
lualine_b = {
{
function()
local key = require("grapple").key()
return " [" .. key .. "]"
end,
cond = require("grapple").exists,
}
}
}
})
Grapple Types
Type Definitions
Grapple.Options
Options available for most top-level tagging actions (e.g. tag, untag, select, toggle, etc).
Type: table
buffer
:integer
file_path
:string
key
:Grapple.TagKey
scope
:Grapple.ScopeResolverLike
Grapple.Tag
A tag contains two pieces of information: the absolute file_path
of the tagged file, and the last known cursor
location. A tag is stored in a tag table keyed with a Grapple.TagKey
, but can only be deterministically identified by its file_path
.
Type: table
file_path
:string
cursor
:integer[2]
(row, column)
Grapple.TagKey
A tag may be referenced as an anonymous tag by its index (integer
) or a named tag by its key (string
).
Type: integer
| string
Grapple.ScopeOptions
Options available when creating custom scope resolvers. Builtin resolvers
Giving a scope resolver a key
will allow it to be identified within the require("grapple").resolvers
table. For a scope to persisted, the persist
options must be set to true
; otherwise, any scope that is resolved by the scope resolver will be deleted when Neovim exits.
In addition to scope persistence, a scope may also be cached for faster access during a Neovim session. The cache
option may be one of the following:
cache = true
: project scope is resolved once and cached until explicitly invalidatedcache = false
project scope is never cached and must always be resolvedcache = string | string[]
project scope is cached and invalidated when a given autocommand event is triggered (see::h autocmd
)cache = integer
project scope is cached and updated on a given interval (in milliseconds)
Type: table
cache
:boolean
|string
|string[]
|integer
persist
:boolean
Grapple.ScopeFunction
A synchronous scope resolving callback function. Used when creating a scope resolver.
Type: fun(): Grapple.Scope | nil
Grapple.ScopeJob
An asynchronous scope resolving callback command. Used when creating a scope resolver. The command
and args
should specify a complete shell command to execute. The on_exit
callback should understand how to parse the output of the command into a project scope (Grapple.Scope
), or return nil
on execution failure. The cwd
must be specified as the directory which the command should be executed in.
Type: table
command
:string
args
:string[]
cwd
:string
on_exit
:fun(job, return_value): Grapple.Scope | nil
Grapple.ScopeResolver
Resolves into a Grapple.Scope
. Should be created using the Scope API (e.g. grapple.scope#resolver
). For more information, see project scopes.
Type: table
key
:integer
callback
:Grapple.ScopeFunction
|Grapple.ScopeJob
cache
:boolean
|string
|string[]
|integer
autocmd
:number
|nil
Grapple.ScopeResolverLike
Either the name of a builtin scope resolver, or a scope resolver.
Type: string
| Grapple.ScopeResolver
Grapple.Scope
The name of a project scope that has been resolved from a Grapple.ScopeResolver
.
Type: string
Inspiration and Thanks
- tjdevries vlog.nvim
- ThePrimeagen's harpoon
- kwarlwang's bufjump.nvim