Theme: kanagawa
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.
- 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
- Neovim >= 0.8
- Neovim >= 0.9 - optional, for floating window title
- plenary.nvim
- 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
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"
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"),
--- A callback function that returns the popup tags window title
---@type nil | fun(): string | nil
popup_tags_title = nil,
---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,
},
})
Grapple API and Examples
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" })
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" })
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()
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}" })
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()
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()
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" })
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()
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()
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")
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 and Examples
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" })
-- You can even filter for patterns!
require("grapple.scope").resolver(function()
return vim.fn.getcwd()
end, { cache = "User PluginEvent"})
-- 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
})
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,
})
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,
})
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
})
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"),
)
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")
)
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)
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)
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.
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.
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).
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" })
})
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.
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
By default, the popup menu title is set to match the corresponding scope. However, you also have the flexibility to customize it using a callback function that receives the resolved form of the default scope as an optional argument. This custom title can be tailored to suit your specific preferences and doesn't need to correlate directly with the scope. Below, are some examples illustrating the possibilities:
-- Set the title to "Grapple"
require("grapple").setup({
popup_tags_title = function() return "Grapple" end
})
-- Set the title to the git root directory, with "~" substituted for $HOME
require("grapple").setup({
popup_tags_title = function()
local resolved_path = require("grapple.state").ensure_loaded(require("grapple.scope_resolvers").git)
return resolved_path:gsub(vim.env.HOME, "~")
end,
})
-- Alternatively, you can modify the resolved default scope
require("grapple").setup({
popup_tags_title = function(scope)
return string.format(" %s ", scope:gsub(vim.env.HOME, "~"))
end,
})
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")
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()
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
vim.keymap.set("n", "<leader>m", require("grapple").toggle)
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)
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,
}
}
}
})
Statuslines that update on autocommand events can hook into the GrappleStateUpdate
User autocommand that Grapple emits whenever the state is updated.
An example heirline.nvim statusline
local GrappleComponent = {
provider = function()
local key = require("grapple").key()
return " [" .. key .. "]"
end,
condition = require("grapple").exists,
update = { "User", pattern = "GrappleStateUpdate", callback = vim.schedule_wrap(function() vim.cmd.redrawstatus() end) },
}
Type Definitions
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
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)
A tag may be referenced as an anonymous tag by its index (integer
) or a named tag by its key (string
).
Type: integer
| string
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
A synchronous scope resolving callback function. Used when creating a scope resolver.
Type: fun(): Grapple.Scope | nil
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
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
Either the name of a builtin scope resolver, or a scope resolver.
Type: string
| Grapple.ScopeResolver
The name of a project scope that has been resolved from a Grapple.ScopeResolver
.
Type: string
- tjdevries vlog.nvim
- ThePrimeagen's harpoon
- kwarlwang's bufjump.nvim