-
Notifications
You must be signed in to change notification settings - Fork 3
Advanced Recipes
These recipes assume you have read the Recipes guide and understand how motions are assembled from pipeline parts. If terms like "collector", "extractor", or "filter" are unfamiliar, start there first.
This guide covers treesitter-powered motions, custom text objects, composable operators, and complex action composition.
The treesitter collector walks the syntax tree and yields nodes matching ts_node_types. Combined with pass_through extractor (since treesitter already produces complete targets) and filter_visible, you can jump to any language construct.
require("smart-motion").register_motion("<leader>i", {
collector = "treesitter",
extractor = "pass_through",
filter = "filter_visible",
visualizer = "hint_start",
action = "jump_centered",
map = true,
modes = { "n" },
metadata = {
motion_state = {
ts_node_types = {
"if_statement",
"for_statement",
"while_statement",
"for_in_statement",
"repeat_statement",
"switch_statement",
"match_expression",
},
multi_window = true,
},
},
})How it works: The treesitter collector finds every node whose type matches the list. Labels appear on all visible control flow statements across every open window. Press a label to jump.
Tip: Run
:InspectTreein any buffer to see the exact node types your language's treesitter parser uses. Node names vary between languages.
require("smart-motion").register_motion("<leader>n", {
collector = "treesitter",
extractor = "pass_through",
filter = "filter_visible",
visualizer = "hint_start",
action = "jump_centered",
map = true,
modes = { "n" },
metadata = {
motion_state = {
ts_node_types = {
"function_declaration",
"function_definition",
"arrow_function",
"method_definition",
"function_item",
"method_declaration",
"method",
},
ts_child_field = "name",
multi_window = true,
},
},
})What's different: The ts_child_field = "name" option tells the collector to yield only the name field of each matching node, not the entire function. So instead of labeling the full function declaration, you get a label on just the function's identifier. This is useful when you want to jump directly to function names for renaming or reference checking.
require("smart-motion").register_motion("<leader>a", {
collector = "treesitter",
extractor = "pass_through",
filter = "filter_visible",
visualizer = "hint_start",
action = "jump_centered",
map = true,
modes = { "n" },
metadata = {
motion_state = {
ts_node_types = {
"arguments",
"argument_list",
"parameters",
"parameter_list",
"formal_parameters",
},
ts_yield_children = true,
multi_window = true,
},
},
})What's different: The ts_yield_children = true option changes the collector's behavior. Instead of yielding the container node (the entire argument list), it yields each named child individually. So foo(a, b, c) produces three separate targets: one on a, one on b, and one on c.
If you want each parameter target to include its surrounding comma (useful for deletion or selection), add ts_around_separator:
metadata = {
motion_state = {
ts_node_types = { "arguments", "parameters", "formal_parameters" },
ts_yield_children = true,
ts_around_separator = true,
},
},What's different: With ts_around_separator = true, the range of each child target is expanded to include the adjacent comma and whitespace. Selecting or deleting a parameter will cleanly remove the separator too, preventing leftover commas.
require("smart-motion").register_motion("<leader>t", {
collector = "lines",
extractor = "words",
filter = "filter_visible",
visualizer = "hint_start",
action = "jump_centered",
map = true,
modes = { "n" },
metadata = {
word_pattern = [[\v(TODO|FIXME|HACK|NOTE|XXX|WARN)]],
motion_state = {
multi_window = true,
},
},
})How it works: The words extractor uses the custom word_pattern regex instead of the default (\k\+\|\%(\k\@!\S\)\+ — keyword sequences or punctuation sequences). It matches only TODO-style comment tags. The lines collector feeds all visible buffer lines, and multi_window = true searches across every open split.
require("smart-motion").register_motion("<leader>e", {
collector = "diagnostics",
extractor = "pass_through",
filter = "filter_visible",
visualizer = "hint_start",
action = "jump_centered",
map = true,
modes = { "n" },
metadata = {
motion_state = {
diagnostic_severity = vim.diagnostic.severity.ERROR,
},
},
})How it works: The diagnostics collector gathers LSP diagnostics from the buffer. Setting diagnostic_severity to ERROR filters out warnings, hints, and info. Only error diagnostics get labels.
require("smart-motion").register_motion("<leader>g", {
collector = "git_hunks",
extractor = "pass_through",
filter = "filter_visible",
visualizer = "hint_start",
action = "jump_centered",
map = true,
modes = { "n" },
metadata = {
motion_state = {
multi_window = true,
},
},
})How it works: The git_hunks collector finds changed regions using gitsigns.nvim (or falls back to git diff). With multi_window = true, you can jump to any modified region in any visible split.
SmartMotion's pipeline can create treesitter-powered text objects that work with every vim operator.
require("smart-motion").register_motion("al", {
collector = "treesitter",
extractor = "pass_through",
modifier = "weight_distance",
filter = "filter_visible",
visualizer = "hint_start",
action = "textobject_select",
map = true,
modes = { "x", "o" },
metadata = {
motion_state = {
ts_node_types = {
"for_statement",
"while_statement",
"for_in_statement",
"repeat_statement",
},
is_textobject = true,
},
},
})Key parts:
-
action = "textobject_select"-- This action sets a charwise visual selection spanning the entire target range, which the pending operator then applies to. -
modes = { "x", "o" }-- Text objects must work in visual (x) and operator-pending (o) modes. They are not registered in normal mode. -
is_textobject = true-- This flag tells the exit system to let the operator-pending selection complete naturally instead of overriding it with a jump.
Once registered, al works with every operator: dal (delete around loop), yal (yank around loop), gqal (format around loop), >al (indent around loop), and any other operator you can think of.
require("smart-motion").register_motion("il", {
collector = "treesitter",
extractor = "pass_through",
modifier = "weight_distance",
filter = "filter_visible",
visualizer = "hint_start",
action = "textobject_select",
map = true,
modes = { "x", "o" },
metadata = {
motion_state = {
ts_node_types = {
"for_statement",
"while_statement",
"for_in_statement",
"repeat_statement",
},
ts_inner_body = true,
is_textobject = true,
},
},
})What's different: Adding ts_inner_body = true makes the collector yield only the body content of the node. For brace-delimited languages, it trims the { and } delimiters, giving you just the inner content. dil deletes the loop body without touching the loop statement itself.
The built-in fn text object demonstrates how to make a text object work with the multi-char inference system:
require("smart-motion").register_motion("fn", {
composable = true,
collector = "treesitter",
extractor = "pass_through",
modifier = "weight_distance",
filter = "filter_visible",
visualizer = "hint_start",
action = "textobject_select",
map = true,
modes = { "o" },
metadata = {
motion_state = {
ts_node_types = {
"function_declaration",
"function_definition",
"arrow_function",
"method_definition",
"function_item",
},
ts_child_field = "name",
is_textobject = true,
},
},
})How it works: The composable = true flag registers this text object with the multi-char inference system. When you type dfn quickly, the infer system reads f, sees that fn is a longer composable match, waits for n, and resolves to the function name text object. If you type df and pause, it falls through to the normal find-char motion. This multi-char resolution happens automatically for any composable motion with a multi-character key.
A composable operator is a motion with infer = true. It reads a second key, looks up a composable motion, inherits that motion's pipeline, and runs with its own action.
This creates an operator gh that composes with every motion to jump and highlight the target:
-- Step 1: Register the operator motion
require("smart-motion").register_motion("gh", {
infer = true,
collector = "lines",
filter = "filter_visible",
visualizer = "hint_start",
map = true,
modes = { "n" },
})
-- Step 2: Register the action with keys = {"h"} so infer can find it
local registries = require("smart-motion.core.registries"):get()
registries.actions.register("highlight_jump", {
keys = { "h" },
run = function(ctx, cfg, motion_state)
local target = motion_state.selected_jump_target
if not target then return end
-- Jump to the target first
require("smart-motion.actions.jump").run(ctx, cfg, motion_state)
-- Highlight the target range
local ns = vim.api.nvim_create_namespace("custom_highlight")
local bufnr = target.metadata.bufnr
vim.api.nvim_buf_add_highlight(
bufnr, ns, "Search",
target.start_pos.row,
target.start_pos.col,
target.end_pos.col
)
-- Clear after 2 seconds
vim.defer_fn(function()
if vim.api.nvim_buf_is_valid(bufnr) then
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
end
end, 2000)
end,
})How it works:
- You press
gh-- the operator is triggered,infer = truecauses it to wait for another key. - You press
w-- the infer system reads the key and looks up the composablewmotion. - The
wmotion's pipeline (words extractor, after-cursor filter) overrides the operator's defaults. - The pipeline runs: labels appear on words, you press a label to select a target.
- The infer system finds
highlight_jumpviakeys = {"h"}(matching the operator's action key) and executes it.
Now ghw, ghj, ghs, ghf, and every other composable motion automatically works as a highlight operation.
If you want a custom trigger key that doesn't match the action key, use trigger_key and let the registration name serve as the action key:
require("smart-motion").register_motion("d", {
infer = true,
trigger_key = "<leader>m",
-- action_key defaults to "d" (the first argument / registration name)
collector = "lines",
filter = "filter_visible",
visualizer = "hint_start",
map = true,
modes = { "n" },
})How it works: The trigger_key is the actual keymap that gets created (<leader>m). The action_key (which defaults to the registration name, "d") is what the infer system uses to look up the action (delete_jump has keys = {"d"}). This lets you bind <leader>m + w to delete-word, <leader>m + j to delete-line, and so on, while reusing the existing delete_jump action. Double-tap detection also uses action_key: pressing <leader>m then d triggers delete-line.
The merge utility combines multiple actions into a single sequential action. Import it from the core utils module.
local merge = require("smart-motion.core.utils").action_utils.merge
require("smart-motion").register_motion("gy", {
collector = "lines",
extractor = "words",
filter = "filter_words_after_cursor",
visualizer = "hint_start",
action = merge({ "jump", "yank" }),
map = true,
modes = { "n" },
metadata = {
label = "Jump and Yank Word",
},
})How it works: When you select a target, merge runs each action in order: first jump moves the cursor to the target, then yank yanks the word at the new position. Actions share the same motion_state, so the selected target is available to every action in the chain.
action = merge({ "yank", "restore" })How it works: The yank action yanks text at the target, then restore returns the cursor to its original position. The result is a yank that does not move the cursor.
action = merge({ "jump", "yank", "center" })How it works: Three actions in sequence: jump to the target, yank the word, then center the screen on the new cursor position. You can chain as many actions as you need.
-
Use
:InspectTreeto find node types. Open a buffer in the language you are targeting and run:InspectTree. This shows the full treesitter AST with every node type. Copy the exact type names into yourts_node_typeslist. -
Node types are cross-language. Most recipes list node types from multiple languages (Lua, Python, JavaScript, Rust, Go, etc.). Types that do not exist in a particular language are safely ignored, so you can keep broad lists without errors.
-
Test with a new key first. When building a custom motion, bind it to an unused key like
<leader>xso it does not interfere with existing motions. Move it to your preferred key once it works. -
Debug with logging. If a motion is not producing the targets you expect, enable debug logging:
vim.g.smart_motion_log_level = "debug". This prints each pipeline stage's input and output so you can see where targets are being lost. -
Chain recipes from both guides. The Recipes guide covers filter swaps, extractor changes, and preset overrides. Everything there composes with the techniques in this guide. For example, you can take a treesitter motion from this page and make it bidirectional by applying a filter swap from the Recipes page.
-> Recipes: Pipeline basics, filter swaps, and preset overrides
-> Building Custom Motions: Register your own pipeline stages from scratch
-> Pipeline Architecture: Deep dive into how each pipeline stage works internally
-> API Reference: Complete reference for all registries, modules, and motion_state fields
Getting Started
Using SmartMotion
Customizing
Building Your Own
Reference