Skip to content

Commit 6c88bfd

Browse files
committed
feat: cycle scopes with Grapple.cycle_scopes
1 parent 386b583 commit 6c88bfd

File tree

7 files changed

+177
-68
lines changed

7 files changed

+177
-68
lines changed

README.md

+35-2
Original file line numberDiff line numberDiff line change
@@ -438,15 +438,48 @@ Where:
438438
<summary><b>Examples</b></summary>
439439

440440
```lua
441-
-- Cycle to the previous tagged file
441+
-- Cycle to the next tagged file
442442
require("grapple").cycle_tags("next")
443443

444-
-- Cycle to the next tagged file
444+
-- Cycle to the previous tagged file
445445
require("grapple").cycle_tags("prev")
446446
```
447447

448448
</details>
449449

450+
#### `Grapple.cycle_scopes`
451+
452+
Cycle through and use the next or previous available scope. By default, will only cycle through non-`hidden` scopes. Use `{ all = true }` to cycle through _all_ defined scopes.
453+
454+
**API**: `require("grapple").cycle_scopes(direction, opts)`
455+
456+
Where:
457+
458+
- **`direction`**: `"next"` | `"prev"`
459+
- **`opts?`**: `table`
460+
- **`scope?`**: `string` scope name (default: `settings.scope`)
461+
- **`all?`**: `boolean` (default: `false`)
462+
463+
<details>
464+
<summary><b>Examples</b></summary>
465+
466+
```lua
467+
-- Cycle to the next scope
468+
require("grapple").cycle_scopes("next")
469+
470+
-- Cycle to the previous scope
471+
require("grapple").cycle_scopes("prev")
472+
473+
-- Hide a scope during Grapple setup
474+
require("grapple").setup({
475+
default_scopes = {
476+
cwd = { hidden = true }
477+
}
478+
})
479+
```
480+
481+
</details>
482+
450483
#### `Grapple.unload`
451484

452485
Unload tags for a give (scope) name or loaded scope (id).

lua/grapple.lua

+23-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ local Grapple = {}
33
---@param err? string
44
---@return string? err
55
local function notify_err(err)
6-
if err and not vim.env.CI then
6+
if err then
77
vim.notify(err, vim.log.levels.ERROR)
88
end
99
return err
@@ -57,7 +57,13 @@ end
5757
---@param opts? grapple.options
5858
---@return string? error
5959
function Grapple.cycle_tags(direction, opts)
60-
return notify_err(Grapple.app():cycle_tags(direction, opts))
60+
local next_index, err = Grapple.app():cycle_tags(direction, opts)
61+
if err then
62+
vim.notify(err, vim.log.levels.ERROR)
63+
return err
64+
elseif next_index then
65+
Grapple.select({ index = next_index })
66+
end
6167
end
6268

6369
-- Cycle through and select the next or previous available tag for a given scope.
@@ -88,6 +94,21 @@ function Grapple.cycle_backward(opts)
8894
return Grapple.cycle_tags("prev", opts)
8995
end
9096

97+
---Cycle through and use the next or previous available scope.
98+
---By default, will only cycle through non-`hidden` scopes.
99+
---@param direction "next" | "prev"
100+
---@param opts? { scope?: string, all?: boolean }
101+
---@return string? error
102+
function Grapple.cycle_scopes(direction, opts)
103+
local next_scope, err = Grapple.app():cycle_scopes(direction, opts)
104+
if err then
105+
vim.notify(err --[[ @as string ]], vim.log.levels.ERROR)
106+
return err
107+
elseif next_scope then
108+
Grapple.use_scope(next_scope)
109+
end
110+
end
111+
91112
---@param opts? grapple.options
92113
---@return string? error
93114
function Grapple.touch(opts)

lua/grapple/app.lua

+55-31
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ local Settings = require("grapple.settings")
77
local State = require("grapple.state")
88
local TagContent = require("grapple.tag_content")
99
local TagManager = require("grapple.tag_manager")
10+
local Util = require("grapple.util")
1011
local Window = require("grapple.window")
1112

1213
---@class grapple.app
@@ -196,7 +197,7 @@ function App:select(opts)
196197
local path, _ = self:extract_path(opts.path, opts.buffer)
197198
opts.path = path
198199

199-
return self:enter_with_event(function(container)
200+
return self:enter(function(container)
200201
local index, err = container:find(opts)
201202
if err then
202203
return err
@@ -205,38 +206,37 @@ function App:select(opts)
205206
local tag = assert(container:get({ index = index }))
206207

207208
tag:select(opts.command)
208-
end, { scope = opts.scope, scope_id = opts.scope_id })
209+
end, { scope = opts.scope, scope_id = opts.scope_id, event = true })
209210
end
210211

211-
---@param current_index? integer
212+
---@param current_idx? integer
212213
---@param direction "next" | "prev"
213214
---@param length integer
214215
---@return integer
215-
local function next_index(current_index, direction, length)
216+
local function next_index(current_idx, direction, length)
216217
-- Fancy maths to get the next index for a given direction
217218
-- 1. Change to 0-based indexing
218219
-- 2. Perform index % container length, being careful of negative values
219220
-- 3. Change back to 1-based indexing
220221
-- stylua: ignore
221-
current_index = (
222-
current_index
222+
current_idx = (
223+
current_idx
223224
or direction == "next" and length
224225
or direction == "prev" and 1
225226
) - 1
226227

227228
local next_inc = direction == "next" and 1 or -1
228-
local next_idx = math.fmod(current_index + next_inc + length, length) + 1
229+
local next_idx = math.fmod(current_idx + next_inc + length, length) + 1
229230

230231
return next_idx
231232
end
232233

233-
-- Cycle through and select the next or previous available tag for a given scope.
234+
-- Cycle through and find the next or previous available tag for a given scope.
234235
---By default, uses the current scope
235236
---@param direction "next" | "prev" | "previous" | "forward" | "backward"
236237
---@param opts? grapple.options
237-
---@return string? error
238+
---@return integer | nil next_index, string? error
238239
function App:cycle_tags(direction, opts)
239-
240240
-- stylua: ignore
241241
direction = direction == "forward" and "next"
242242
or direction == "backward" and "prev"
@@ -246,32 +246,63 @@ function App:cycle_tags(direction, opts)
246246
---@cast direction "next" | "prev"
247247

248248
if not vim.tbl_contains({ "next", "prev" }, direction) then
249-
return string.format("invalid direction: %s", direction)
249+
return nil, string.format("invalid direction: %s", direction)
250250
end
251251

252252
opts = opts or {}
253253

254254
local path, _ = self:extract_path(opts.path, opts.buffer or 0)
255255
opts.path = path
256256

257-
return self:enter_with_event(function(container)
257+
return self:enter_with_result(function(container)
258258
if container:is_empty() then
259-
return
259+
return nil, nil
260260
end
261261

262-
local index = next_index(container:find(opts), direction, container:len())
263-
264-
local tag, err = container:get({ index = index })
265-
if err or not tag then
266-
return err
262+
local current_idx, _ = container:find(opts)
263+
local next_idx = next_index(container:find(opts), direction, container:len())
264+
if next_idx == current_idx then
265+
return nil, nil
267266
end
268267

269-
---@diagnostic disable-next-line: redefined-local
270-
local err = tag:select()
271-
if err then
272-
return err
273-
end
274-
end, { scope = opts.scope, scope_id = opts.scope_id })
268+
return next_idx, nil
269+
end, { scope = opts.scope, scope_id = opts.scope_id, event = true })
270+
end
271+
272+
-- Cycle through and find the next or previous available scope.
273+
---By default, will only cycle through non-`hidden` scopes.
274+
---@param direction "next" | "prev"
275+
---@param opts? { scope?: string, all?: boolean }
276+
---@return string | nil next_scope, string? error
277+
function App:cycle_scopes(direction, opts)
278+
if not vim.tbl_contains({ "next", "prev" }, direction) then
279+
return nil, string.format("invalid direction: %s", direction)
280+
end
281+
282+
opts = opts or {}
283+
284+
local current_scope, err = self.scope_manager:get(opts.scope or self.settings.scope)
285+
if err or not current_scope then
286+
return nil, err
287+
end
288+
289+
local scopes = self:list_scopes()
290+
if not opts.all then
291+
scopes = vim.tbl_filter(function(scope)
292+
return not scope.hidden
293+
end, scopes)
294+
end
295+
296+
local current_idx = Util.index_of(scopes, function(s)
297+
return s.name == current_scope.name
298+
end)
299+
300+
local next_idx = next_index(current_idx, direction, #scopes)
301+
if next_idx == current_idx then
302+
return nil, nil
303+
end
304+
305+
return scopes[next_idx].name, nil
275306
end
276307

277308
---Update a tag in a given scope
@@ -590,11 +621,4 @@ function App:enter_with_save(callback, opts)
590621
return self:enter(callback, vim.tbl_deep_extend("force", opts or {}, { sync = true, event = true }))
591622
end
592623

593-
---@param callback fun(container: grapple.tag_container): string? error
594-
---@param opts? grapple.app.enter_options
595-
---@return string? error
596-
function App:enter_with_event(callback, opts)
597-
return self:enter(callback, vim.tbl_deep_extend("force", opts or {}, { sync = false, event = true }))
598-
end
599-
600624
return App

lua/grapple/util.lua

+12
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,18 @@ function Util.reduce(list, fn, init)
3535
return acc
3636
end
3737

38+
---@generic T
39+
---@param list table
40+
---@param fn fun(value: T): boolean
41+
---@return integer | nil
42+
function Util.index_of(list, fn)
43+
for i, v in ipairs(list) do
44+
if fn(v) then
45+
return i
46+
end
47+
end
48+
end
49+
3850
---@generic T
3951
---@param tbl_a T[]
4052
---@param tbl_b T[]

plugin/grapple.lua

+34-33
Original file line numberDiff line numberDiff line change
@@ -81,27 +81,28 @@ vim.api.nvim_create_user_command(
8181
local scope_kwargs = { "scope", "id" }
8282
local window_kwargs = { "style", unpack(scope_kwargs) }
8383

84-
-- stylua: ignore
85-
-- Lookup table of API functions and their available arguments
86-
local subcommand_lookup = {
87-
clear_cache = { args = { "scope" }, kwargs = {} },
88-
cycle_tags = { args = { "direction" }, kwargs = use_kwargs },
89-
open_loaded = { args = {}, kwargs = { "all" } },
90-
open_scopes = { args = {}, kwargs = {} },
91-
open_tags = { args = {}, kwargs = window_kwargs },
92-
prune = { args = {}, kwargs = { "limit" } },
93-
quickfix = { args = {}, kwargs = scope_kwargs },
94-
reset = { args = {}, kwargs = scope_kwargs },
95-
select = { args = {}, kwargs = use_kwargs },
96-
tag = { args = {}, kwargs = new_kwargs },
97-
toggle = { args = {}, kwargs = tag_kwargs },
98-
toggle_loaded = { args = {}, kwargs = { "all" } },
99-
toggle_scopes = { args = {}, kwargs = { "all" } },
100-
toggle_tags = { args = {}, kwargs = window_kwargs },
101-
unload = { args = {}, kwargs = scope_kwargs },
102-
untag = { args = {}, kwargs = use_kwargs },
103-
use_scope = { args = { "scope" }, kwargs = {} },
104-
}
84+
-- stylua: ignore
85+
-- Lookup table of API functions and their available arguments
86+
local subcommand_lookup = {
87+
clear_cache = { args = { "scope" }, kwargs = {} },
88+
cycle_tags = { args = { "direction" }, kwargs = use_kwargs },
89+
cycle_scopes = { args = { "direction" }, kwargs = { "scope", "all" } },
90+
open_loaded = { args = {}, kwargs = { "all" } },
91+
open_scopes = { args = {}, kwargs = {} },
92+
open_tags = { args = {}, kwargs = window_kwargs },
93+
prune = { args = {}, kwargs = { "limit" } },
94+
quickfix = { args = {}, kwargs = scope_kwargs },
95+
reset = { args = {}, kwargs = scope_kwargs },
96+
select = { args = {}, kwargs = use_kwargs },
97+
tag = { args = {}, kwargs = new_kwargs },
98+
toggle = { args = {}, kwargs = tag_kwargs },
99+
toggle_loaded = { args = {}, kwargs = { "all" } },
100+
toggle_scopes = { args = {}, kwargs = { "all" } },
101+
toggle_tags = { args = {}, kwargs = window_kwargs },
102+
unload = { args = {}, kwargs = scope_kwargs },
103+
untag = { args = {}, kwargs = use_kwargs },
104+
use_scope = { args = { "scope" }, kwargs = {} },
105+
}
105106

106107
-- Lookup table of arguments and their known values
107108
local argument_lookup = {
@@ -154,10 +155,10 @@ vim.api.nvim_create_user_command(
154155
-- "Grapple sub|"
155156

156157
if #input == 2 then
157-
-- stylua: ignore
158-
return current == ""
159-
and subcmds
160-
or vim.tbl_filter(Util.startswith(current), subcmds)
158+
-- stylua: ignore
159+
return current == ""
160+
and subcmds
161+
or vim.tbl_filter(Util.startswith(current), subcmds)
161162
end
162163

163164
local completion = subcommand_lookup[input_subcmd]
@@ -175,10 +176,10 @@ vim.api.nvim_create_user_command(
175176
local arg_name = completion.args[#input_args]
176177
local arg_values = argument_lookup[arg_name] or {}
177178

178-
-- stylua: ignore
179-
return current == ""
180-
and arg_values
181-
or vim.tbl_filter(Util.startswith(current), arg_values)
179+
-- stylua: ignore
180+
return current == ""
181+
and arg_values
182+
or vim.tbl_filter(Util.startswith(current), arg_values)
182183
end
183184

184185
-- "Grapple subcmd arg |"
@@ -189,10 +190,10 @@ vim.api.nvim_create_user_command(
189190
local input_keys = vim.tbl_map(Util.match_key, input_kwargs)
190191
local kwarg_keys = Util.subtract(completion.kwargs, input_keys)
191192

192-
-- stylua: ignore
193-
local filtered = current == ""
194-
and kwarg_keys
195-
or vim.tbl_filter(Util.startswith(current), completion.kwargs)
193+
-- stylua: ignore
194+
local filtered = current == ""
195+
and kwarg_keys
196+
or vim.tbl_filter(Util.startswith(current), completion.kwargs)
196197

197198
return vim.tbl_map(Util.with_suffix("="), filtered)
198199
end

tests/grapple_spec.lua

+16
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,22 @@ describe("Grapple", function()
103103
end)
104104
end)
105105

106+
describe("Grapple.cycle_scopes", function()
107+
describe("next", function()
108+
it("works", function()
109+
assert.is_nil(Grapple.cycle_scopes("next", { scope = "git" }))
110+
assert.is_same("git_branch", Grapple.app().settings.scope)
111+
end)
112+
end)
113+
114+
describe("prev", function()
115+
it("works", function()
116+
assert.is_nil(Grapple.cycle_scopes("prev", { scope = "git_branch" }))
117+
assert.is_same("git", Grapple.app().settings.scope)
118+
end)
119+
end)
120+
end)
121+
106122
describe("Grapple.touch", function()
107123
it("works", function()
108124
vim.api.nvim_win_set_buf(0, vim.fn.bufnr("/test1", false))

tests/minimal_init.lua

+2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ vim.fs.joinpath = vim.fs.joinpath
66
return path
77
end
88

9+
vim.notify = function() end
10+
911
local root_path = vim.fn.fnamemodify(".", ":p")
1012
local temp_path = vim.fs.joinpath(root_path, ".tests")
1113

0 commit comments

Comments
 (0)