Skip to content

Commit 6b55029

Browse files
committed
feat(move,swap): wrapping around sibling list
this adds support for wrapping around on parameter lists by querying the surrounding scope
1 parent 851e865 commit 6b55029

4 files changed

Lines changed: 135 additions & 13 deletions

File tree

‎lua/nvim-treesitter-textobjects/move.lua‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,19 @@ local function move(opts, query_strings, query_group)
114114
end
115115
end
116116

117+
-- precompute enclosing scope once so we can discard out-of-scope results
118+
-- during wrap-around without re-walking the tree each iteration.
119+
local scope ---@type Range6?
120+
if opts.wrap then
121+
for _, query_string in ipairs(query_strings) do
122+
local current = shared.textobject_at_point(query_string, query_group, bufnr)
123+
if current then
124+
scope = shared.get_scope_range(bufnr, current)
125+
if scope then break end
126+
end
127+
end
128+
end
129+
117130
for _ = 1, vim.v.count1 do
118131
local best_range ---@type Range6?
119132
local best_score ---@type integer
@@ -132,6 +145,12 @@ local function move(opts, query_strings, query_group)
132145
end
133146
)
134147

148+
if current_range then
149+
if scope and not ts_range.contains(scope, current_range) then
150+
current_range = nil
151+
end
152+
end
153+
135154
if current_range then
136155
local score = scoring_function(start_, current_range)
137156
if not best_range then
@@ -147,6 +166,19 @@ local function move(opts, query_strings, query_group)
147166
end
148167
end
149168
end
169+
170+
-- no direct successor/precursor was found => pick first/last of siblings
171+
if not best_range and scope then
172+
for _, query_string in ipairs(query_strings) do
173+
local siblings = shared.ranges_in_scope(bufnr, query_string, query_group, scope)
174+
if #siblings > 0 then
175+
best_range = forward and siblings[1] or siblings[#siblings]
176+
best_start = starts[1]
177+
break
178+
end
179+
end
180+
end
181+
150182
goto_node(best_range and best_range, not best_start, not config.set_jumps)
151183
end
152184
end
@@ -156,51 +188,61 @@ local move_repeatable = repeatable_move.make_repeatable_move(move)
156188

157189
---@param query_strings string|string[]
158190
---@param query_group? string
159-
M.goto_next_start = function(query_strings, query_group)
191+
---@param opts? {wrap?: boolean}
192+
M.goto_next_start = function(query_strings, query_group, opts)
160193
move_repeatable({
161194
forward = true,
162195
start = true,
196+
wrap = opts and opts.wrap,
163197
}, query_strings, query_group)
164198
end
165199
---@param query_strings string|string[]
166200
---@param query_group? string
167-
M.goto_next_end = function(query_strings, query_group)
201+
---@param opts? {wrap?: boolean}
202+
M.goto_next_end = function(query_strings, query_group, opts)
168203
move_repeatable({
169204
forward = true,
170205
start = false,
206+
wrap = opts and opts.wrap,
171207
}, query_strings, query_group)
172208
end
173209
---@param query_strings string|string[]
174210
---@param query_group? string
175-
M.goto_previous_start = function(query_strings, query_group)
211+
---@param opts? {wrap?: boolean}
212+
M.goto_previous_start = function(query_strings, query_group, opts)
176213
move_repeatable({
177214
forward = false,
178215
start = true,
216+
wrap = opts and opts.wrap,
179217
}, query_strings, query_group)
180218
end
181219
---@param query_strings string|string[]
182220
---@param query_group? string
183-
M.goto_previous_end = function(query_strings, query_group)
221+
---@param opts? {wrap?: boolean}
222+
M.goto_previous_end = function(query_strings, query_group, opts)
184223
move_repeatable({
185224
forward = false,
186225
start = false,
226+
wrap = opts and opts.wrap,
187227
}, query_strings, query_group)
188228
end
189229

190230
---@param query_strings string|string[]
191231
---@param query_group? string
192-
M.goto_next = function(query_strings, query_group)
232+
---@param opts? {wrap?: boolean}
233+
M.goto_next = function(query_strings, query_group, opts)
193234
move_repeatable({
194235
forward = true,
236+
wrap = opts and opts.wrap,
195237
}, query_strings, query_group)
196238
end
197239
---@param query_strings string|string[]
198240
---@param query_group? string
199-
M.goto_previous = function(query_strings, query_group)
241+
---@param opts? {wrap?: boolean}
242+
M.goto_previous = function(query_strings, query_group, opts)
200243
move_repeatable({
201244
forward = false,
202-
query_strings = query_strings,
203-
query_group = query_group,
245+
wrap = opts and opts.wrap,
204246
}, query_strings, query_group)
205247
end
206248

‎lua/nvim-treesitter-textobjects/repeatable_move.lua‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ local M = {}
66
---@class TSTextObjects.MoveOpts
77
---@field forward boolean If true, move forward, and false is for backward.
88
---@field start? boolean If true, choose the start of the node, and false is for the end.
9+
---@field wrap? boolean If true, wrap around within the enclosing scope (e.g. argument list) when reaching the first or last sibling.
910

1011
---@alias TSTextObjects.MoveFunction fun(opts: TSTextObjects.MoveOpts, ...: any)
1112

‎lua/nvim-treesitter-textobjects/shared.lua‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,50 @@ function M.find_best_range(bufnr, capture_string, query_group, filter_predicate,
212212
return best
213213
end
214214

215+
---Get the Range6 of the treesitter parent node that encloses the given range.
216+
---Used to derive the "scope" (e.g. argument list) for a textobject capture.
217+
---@param bufnr integer
218+
---@param range Range6
219+
---@return Range6?
220+
function M.get_scope_range(bufnr, range)
221+
local srow, scol, erow, ecol = range[1], range[2], range[4], range[5]
222+
local node = ts.get_node { bufnr = bufnr, pos = { srow, scol } }
223+
if not node then return nil end
224+
225+
-- walk up until we find a node that fully contains the textobject range,
226+
-- then return its parent's range as the scope
227+
while node do
228+
local nsr, nsc, ner, nec = node:range()
229+
if ts_range.cmp_pos.le(nsr, nsc, srow, scol) and ts_range.cmp_pos.ge(ner, nec, erow, ecol) then
230+
local parent = node:parent()
231+
if not parent then return nil end
232+
local psr, psc, per, pec = parent:range()
233+
return ts_range.add_bytes(bufnr, { psr, psc, per, pec })
234+
end
235+
node = node:parent()
236+
end
237+
return nil
238+
end
239+
240+
---Get all capture ranges for `capture_string` that fall within `scope_range`,
241+
---sorted by start byte. Used to enumerate siblings for wrap-around navigation.
242+
---@param bufnr integer
243+
---@param capture_string string
244+
---@param query_group string
245+
---@param scope_range Range6
246+
---@return Range6[]
247+
function M.ranges_in_scope(bufnr, capture_string, query_group, scope_range)
248+
local ranges = get_capture_ranges_recursively(bufnr, capture_string, query_group)
249+
local result = {} ---@type Range6[]
250+
for _, r in ipairs(ranges) do
251+
if ts_range.contains(scope_range, r) then
252+
result[#result + 1] = r
253+
end
254+
end
255+
table.sort(result, function(a, b) return a[3] < b[3] end)
256+
return result
257+
end
258+
215259
-- TODO: replace with `vim.Range:has(vim.Pos)` when we drop support for nvim 0.11
216260
---@param range Range
217261
---@param line integer

‎lua/nvim-treesitter-textobjects/swap.lua‎

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,8 @@ local M = {}
167167
---@param query_strings string|string[]
168168
---@param query_group? string
169169
---@param direction integer
170-
local function swap_textobject(query_strings, query_group, direction)
170+
---@param opts? {wrap?: boolean}
171+
local function swap_textobject(query_strings, query_group, direction, opts)
171172
if type(query_strings) == 'string' then
172173
query_strings = { query_strings }
173174
end
@@ -186,11 +187,43 @@ local function swap_textobject(query_strings, query_group, direction)
186187
return
187188
end
188189

190+
local scope = opts and opts.wrap and shared.get_scope_range(bufnr, textobject_range) or nil
191+
189192
local step = direction > 0 and 1 or -1
190193
for _ = 1, math.abs(direction), step do
191194
local adjacent = direction > 0
192195
and next_textobject(textobject_range, query_string, query_group, bufnr)
193196
or previous_textobject(textobject_range, query_string, query_group, bufnr)
197+
198+
-- next/previous_textobject searched the whole file: if the found node is
199+
-- outside our scope it is not a sibling, so discard it.
200+
if adjacent and scope and not ts_range.contains(scope, adjacent) then
201+
adjacent = nil
202+
end
203+
204+
-- if no adjacent sibling found and wrap is requested, find the first/last
205+
-- sibling within the enclosing scope and swap with that instead.
206+
if not adjacent and scope then
207+
local siblings = shared.ranges_in_scope(bufnr, query_string, query_group, scope)
208+
if #siblings >= 2 then
209+
if direction > 0 then
210+
for _, sibling in ipairs(siblings) do
211+
if not range_eq(sibling, textobject_range) then
212+
adjacent = sibling
213+
break
214+
end
215+
end
216+
else
217+
for i = #siblings, 1, -1 do
218+
if not range_eq(siblings[i], textobject_range) then
219+
adjacent = siblings[i]
220+
break
221+
end
222+
end
223+
end
224+
end
225+
end
226+
194227
if adjacent then
195228
swap_nodes(textobject_range, adjacent, bufnr, 'yes, set cursor!')
196229
end
@@ -206,17 +239,19 @@ end
206239

207240
---@param query_strings string lua pattern describing the query string
208241
---@param query_group? string
209-
function M.swap_next(query_strings, query_group)
242+
---@param opts? {wrap?: boolean}
243+
function M.swap_next(query_strings, query_group, opts)
210244
return make_dot_repeatable(function()
211-
swap_textobject(query_strings, query_group, 1)
245+
swap_textobject(query_strings, query_group, 1, opts)
212246
end)
213247
end
214248

215249
---@param query_strings string lua pattern describing the query string
216250
---@param query_group? string
217-
function M.swap_previous(query_strings, query_group)
251+
---@param opts? {wrap?: boolean}
252+
function M.swap_previous(query_strings, query_group, opts)
218253
return make_dot_repeatable(function()
219-
swap_textobject(query_strings, query_group, -1)
254+
swap_textobject(query_strings, query_group, -1, opts)
220255
end)
221256
end
222257

0 commit comments

Comments
 (0)