mirror of
https://github.com/folke/snacks.nvim
synced 2025-07-07 21:25:11 +00:00
791 lines
23 KiB
Lua
791 lines
23 KiB
Lua
---@class snacks.scope
|
|
local M = {}
|
|
|
|
M.meta = {
|
|
desc = "Scope detection, text objects and jumping based on treesitter or indent",
|
|
needs_setup = true,
|
|
}
|
|
|
|
---@class snacks.scope.Opts: snacks.scope.Config,{}
|
|
---@field buf? number
|
|
---@field pos? {[1]:number, [2]:number} -- (1,0) indexed
|
|
---@field end_pos? {[1]:number, [2]:number} -- (1,0) indexed
|
|
|
|
---@class snacks.scope.TextObject: snacks.scope.Opts
|
|
---@field linewise? boolean if nil, use visual mode. Defaults to `false` when not in visual mode
|
|
---@field notify? boolean show a notification when no scope is found (defaults to true)
|
|
|
|
---@class snacks.scope.Jump: snacks.scope.Opts
|
|
---@field bottom? boolean if true, jump to the bottom of the scope, otherwise to the top
|
|
---@field notify? boolean show a notification when no scope is found (defaults to true)
|
|
|
|
---@alias snacks.scope.Attach.cb fun(win: number, buf: number, scope:snacks.scope.Scope?, prev:snacks.scope.Scope?)
|
|
|
|
---@class snacks.scope.Config
|
|
---@field max_size? number
|
|
---@field enabled? boolean
|
|
local defaults = {
|
|
-- absolute minimum size of the scope.
|
|
-- can be less if the scope is a top-level single line scope
|
|
min_size = 2,
|
|
-- try to expand the scope to this size
|
|
max_size = nil,
|
|
cursor = true, -- when true, the column of the cursor is used to determine the scope
|
|
edge = true, -- include the edge of the scope (typically the line above and below with smaller indent)
|
|
siblings = false, -- expand single line scopes with single line siblings
|
|
-- what buffers to attach to
|
|
filter = function(buf)
|
|
return vim.bo[buf].buftype == "" and vim.b[buf].snacks_scope ~= false and vim.g.snacks_scope ~= false
|
|
end,
|
|
-- debounce scope detection in ms
|
|
debounce = 30,
|
|
treesitter = {
|
|
-- detect scope based on treesitter.
|
|
-- falls back to indent based detection if not available
|
|
enabled = true,
|
|
injections = true, -- include language injections when detecting scope (useful for languages like `vue`)
|
|
---@type string[]|{enabled?:boolean}
|
|
blocks = {
|
|
enabled = false, -- enable to use the following blocks
|
|
"function_declaration",
|
|
"function_definition",
|
|
"method_declaration",
|
|
"method_definition",
|
|
"class_declaration",
|
|
"class_definition",
|
|
"do_statement",
|
|
"while_statement",
|
|
"repeat_statement",
|
|
"if_statement",
|
|
"for_statement",
|
|
},
|
|
-- these treesitter fields will be considered as blocks
|
|
field_blocks = {
|
|
"local_declaration",
|
|
},
|
|
},
|
|
-- These keymaps will only be set if the `scope` plugin is enabled.
|
|
-- Alternatively, you can set them manually in your config,
|
|
-- using the `Snacks.scope.textobject` and `Snacks.scope.jump` functions.
|
|
keys = {
|
|
---@type table<string, snacks.scope.TextObject|{desc?:string}>
|
|
textobject = {
|
|
ii = {
|
|
min_size = 2, -- minimum size of the scope
|
|
edge = false, -- inner scope
|
|
cursor = false,
|
|
treesitter = { blocks = { enabled = false } },
|
|
desc = "inner scope",
|
|
},
|
|
ai = {
|
|
cursor = false,
|
|
min_size = 2, -- minimum size of the scope
|
|
treesitter = { blocks = { enabled = false } },
|
|
desc = "full scope",
|
|
},
|
|
},
|
|
---@type table<string, snacks.scope.Jump|{desc?:string}>
|
|
jump = {
|
|
["[i"] = {
|
|
min_size = 1, -- allow single line scopes
|
|
bottom = false,
|
|
cursor = false,
|
|
edge = true,
|
|
treesitter = { blocks = { enabled = false } },
|
|
desc = "jump to top edge of scope",
|
|
},
|
|
["]i"] = {
|
|
min_size = 1, -- allow single line scopes
|
|
bottom = true,
|
|
cursor = false,
|
|
edge = true,
|
|
treesitter = { blocks = { enabled = false } },
|
|
desc = "jump to bottom edge of scope",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
local id = 0
|
|
|
|
---@alias snacks.scope.scope {buf: number, from: number, to: number, indent?: number}
|
|
|
|
---@class snacks.scope.Scope
|
|
---@field buf number
|
|
---@field from number
|
|
---@field to number
|
|
---@field indent? number
|
|
---@field opts snacks.scope.Opts
|
|
local Scope = {}
|
|
Scope.__index = Scope
|
|
|
|
---@generic T: snacks.scope.Scope
|
|
---@param self T
|
|
---@param scope snacks.scope.scope
|
|
---@param opts snacks.scope.Opts
|
|
---@return T
|
|
function Scope:new(scope, opts)
|
|
local ret = setmetatable(scope, { __index = self, __eq = self.__eq, __tostring = self.__tostring })
|
|
ret.opts = opts
|
|
return ret
|
|
end
|
|
|
|
function Scope:__eq(other)
|
|
return other
|
|
and self.buf == other.buf
|
|
and self.from == other.from
|
|
and self.to == other.to
|
|
and self.indent == other.indent
|
|
end
|
|
|
|
---@generic T: snacks.scope.Scope
|
|
---@param self T
|
|
---@param opts snacks.scope.Opts
|
|
---@return T?
|
|
function Scope:find(opts)
|
|
error("not implemented")
|
|
end
|
|
|
|
---@generic T: snacks.scope.Scope
|
|
---@param self T
|
|
---@return T?
|
|
function Scope:parent()
|
|
error("not implemented")
|
|
end
|
|
|
|
---@generic T: snacks.scope.Scope
|
|
---@param self T
|
|
---@return T
|
|
function Scope:with_edge()
|
|
error("not implemented")
|
|
end
|
|
|
|
---@generic T: snacks.scope.scope
|
|
---@param self T
|
|
---@return T
|
|
function Scope:inner()
|
|
error("not implemented")
|
|
end
|
|
|
|
---@param line number
|
|
function Scope.get_indent(line)
|
|
local ret = vim.fn.indent(line)
|
|
return ret == -1 and nil or ret, line
|
|
end
|
|
|
|
---@generic T: snacks.scope.Scope
|
|
---@param self T
|
|
---@param opts {buf?: number, from?: number, to?: number, indent?: number}}
|
|
---@return T?
|
|
function Scope:with(opts)
|
|
opts = vim.tbl_extend("keep", opts, self)
|
|
return setmetatable(opts, getmetatable(self)) --[[ @as snacks.scope.Scope ]]
|
|
end
|
|
|
|
function Scope:size()
|
|
return self.to - self.from + 1
|
|
end
|
|
|
|
function Scope:size_with_edge()
|
|
return self:with_edge():size()
|
|
end
|
|
|
|
---@generic T: snacks.scope.Scope
|
|
---@param self T
|
|
---@return T?
|
|
function Scope:expand(line)
|
|
local ret = self ---@type snacks.scope.Scope?
|
|
while ret do
|
|
if line >= ret.from and line <= ret.to then
|
|
return ret
|
|
end
|
|
ret = ret:parent()
|
|
end
|
|
end
|
|
|
|
---@class snacks.scope.IndentScope: snacks.scope.Scope
|
|
local IndentScope = setmetatable({}, Scope)
|
|
IndentScope.__index = IndentScope
|
|
|
|
---@param line number 1-indexed
|
|
---@param indent number
|
|
---@param up? boolean
|
|
function IndentScope._expand(line, indent, up)
|
|
local next = up and vim.fn.prevnonblank or vim.fn.nextnonblank
|
|
while line do
|
|
local i, l = IndentScope.get_indent(next(line + (up and -1 or 1)))
|
|
if (i or 0) == 0 or i < indent or l == 0 then
|
|
return line
|
|
end
|
|
line = l
|
|
end
|
|
return line
|
|
end
|
|
|
|
-- Inner indent scope is all lines with higher indent than the current scope
|
|
function IndentScope:inner()
|
|
local from, to, indent = nil, nil, math.huge
|
|
for l = self.from, self.to do
|
|
local i, il = IndentScope.get_indent(vim.fn.nextnonblank(l))
|
|
if il == l then
|
|
if i > self.indent then
|
|
from = from or l
|
|
to = l
|
|
indent = math.min(indent, i)
|
|
end
|
|
end
|
|
end
|
|
return from and to and self:with({ from = from, to = to, indent = indent }) or self
|
|
end
|
|
|
|
function IndentScope:with_edge()
|
|
if self.indent == 0 then
|
|
return self
|
|
end
|
|
local before_i, before_l = Scope.get_indent(vim.fn.prevnonblank(self.from - 1))
|
|
local after_i, after_l = Scope.get_indent(vim.fn.nextnonblank(self.to + 1))
|
|
local indent = math.min(math.max(before_i or self.indent, after_i or self.indent), self.indent)
|
|
local from = before_i and before_i == indent and before_l or self.from
|
|
local to = after_i and after_i == indent and after_l or self.to
|
|
if from == 0 or to == 0 or indent < 0 then
|
|
return self
|
|
end
|
|
return self:with({ from = from, to = to, indent = indent })
|
|
end
|
|
|
|
---@param opts snacks.scope.Opts
|
|
function IndentScope:find(opts)
|
|
local indent, line = Scope.get_indent(opts.pos[1])
|
|
local prev_i, prev_l = Scope.get_indent(vim.fn.prevnonblank(line - 1))
|
|
local next_i, next_l = Scope.get_indent(vim.fn.nextnonblank(line + 1))
|
|
|
|
-- fix indent when line is empty
|
|
if vim.fn.prevnonblank(line) ~= line then
|
|
indent, line = Scope.get_indent(prev_i > next_i and prev_l or next_l)
|
|
prev_i, prev_l = Scope.get_indent(vim.fn.prevnonblank(line - 1))
|
|
next_i, next_l = Scope.get_indent(vim.fn.nextnonblank(line + 1))
|
|
end
|
|
|
|
if line == 0 then
|
|
return
|
|
end
|
|
|
|
-- adjust line to the nearest indent block
|
|
if prev_i <= indent and next_i > indent then
|
|
-- at top edge
|
|
line = next_l
|
|
indent = next_i
|
|
elseif next_i <= indent and prev_i > indent then
|
|
-- at bottom edge
|
|
line = prev_l
|
|
indent = prev_i
|
|
elseif next_i > indent and prev_i > indent then
|
|
-- at edge of two blocks. Prefer the one below.
|
|
line = next_l
|
|
indent = next_i
|
|
end
|
|
|
|
if opts.cursor then
|
|
indent = math.min(indent, vim.fn.virtcol(opts.pos) + 1)
|
|
end
|
|
|
|
-- expand to include bigger indents
|
|
return IndentScope:new({
|
|
buf = opts.buf,
|
|
from = IndentScope._expand(line, indent, true),
|
|
to = IndentScope._expand(line, indent, false),
|
|
indent = indent,
|
|
}, opts)
|
|
end
|
|
|
|
function IndentScope:parent()
|
|
for i = self.indent - 1, 1, -1 do
|
|
local u, d = IndentScope._expand(self.from, i, true), IndentScope._expand(self.to, i, false)
|
|
if u ~= self.from or d ~= self.to then -- update only when expanded
|
|
return self:with({ from = u, to = d, indent = i })
|
|
end
|
|
end
|
|
end
|
|
|
|
---@class snacks.scope.TSScope: snacks.scope.Scope
|
|
---@field node TSNode
|
|
local TSScope = setmetatable({}, Scope)
|
|
TSScope.__index = TSScope
|
|
|
|
-- Expand the scope to fill the range of the node
|
|
function TSScope:fill()
|
|
local n = self.node
|
|
local u, _, d = n:range()
|
|
while n do
|
|
local uu, _, dd = n:range()
|
|
if uu == u and dd == d and not self:is_field(n) then
|
|
self.node = n
|
|
else
|
|
break
|
|
end
|
|
n = n:parent()
|
|
end
|
|
end
|
|
|
|
function TSScope:fix()
|
|
self:fill()
|
|
self.from, _, self.to = self.node:range()
|
|
self.from, self.to = self.from + 1, self.to + 1
|
|
self.indent = math.min(vim.fn.indent(self.from), vim.fn.indent(self.to))
|
|
return self
|
|
end
|
|
|
|
---@param node? TSNode
|
|
function TSScope:is_field(node)
|
|
node = node or self.node
|
|
local parent = node:parent()
|
|
parent = parent ~= node:tree():root() and parent or nil
|
|
if not parent then
|
|
return false
|
|
end
|
|
for child, field in parent:iter_children() do
|
|
if child == node then
|
|
return not (field == nil or vim.tbl_contains(self.opts.treesitter.field_blocks, field))
|
|
end
|
|
end
|
|
error("node not found in parent")
|
|
end
|
|
|
|
function TSScope:with_edge()
|
|
local ret = self ---@type snacks.scope.TSScope?
|
|
while ret do
|
|
if ret:size() >= 1 and not ret:is_field() then
|
|
return ret
|
|
end
|
|
ret = ret:parent()
|
|
end
|
|
return self
|
|
end
|
|
|
|
function TSScope:root()
|
|
if type(self.opts.treesitter.blocks) ~= "table" or not self.opts.treesitter.blocks.enabled then
|
|
return self:fix()
|
|
end
|
|
local root = self.node --[[@as TSNode?]]
|
|
while root do
|
|
if vim.tbl_contains(self.opts.treesitter.blocks, root:type()) then
|
|
return self:with({ node = root })
|
|
end
|
|
root = root:parent()
|
|
end
|
|
return self:fix()
|
|
end
|
|
|
|
---@param opts {buf?: number, from?: number, to?: number, indent?: number, node?: TSNode}}
|
|
function TSScope:with(opts)
|
|
local ret = Scope.with(self, opts) --[[ @as snacks.scope.TSScope ]]
|
|
return ret:fix()
|
|
end
|
|
|
|
---@param opts snacks.scope.Opts
|
|
function TSScope:parser(opts)
|
|
local lang = vim.bo[opts.buf].filetype
|
|
local has_parser, parser = pcall(vim.treesitter.get_parser, opts.buf, lang, { error = false })
|
|
return has_parser and parser or nil
|
|
end
|
|
|
|
---@param cb fun()
|
|
---@param opts snacks.scope.Opts
|
|
function TSScope:init(cb, opts)
|
|
local parser = self:parser(opts)
|
|
if not parser then
|
|
return cb()
|
|
end
|
|
Snacks.util.parse(parser, opts.treesitter.injections, cb)
|
|
end
|
|
|
|
---@param opts snacks.scope.Opts
|
|
function TSScope:find(opts)
|
|
local lang = vim.treesitter.language.get_lang(vim.bo[opts.buf].filetype)
|
|
local line = vim.fn.nextnonblank(opts.pos[1])
|
|
line = line == 0 and vim.fn.prevnonblank(opts.pos[1]) or line
|
|
-- FIXME:
|
|
local pos = {
|
|
math.max(line - 1, 0),
|
|
(vim.fn.getline(line):find("%S") or 1) - 1, -- find first non-space character
|
|
}
|
|
|
|
local node = vim.treesitter.get_node({
|
|
pos = pos,
|
|
bufnr = opts.buf,
|
|
lang = lang,
|
|
ignore_injections = not opts.treesitter.injections,
|
|
})
|
|
if not node then
|
|
return
|
|
end
|
|
|
|
if opts.cursor then
|
|
-- expand to biggest ancestor with a lower start position
|
|
local n = node ---@type TSNode?
|
|
local virtcol = vim.fn.virtcol(opts.pos)
|
|
while n and n ~= n:tree():root() do
|
|
local r, c = n:range()
|
|
local virtcol_n = vim.fn.virtcol({ r + 1, c })
|
|
if virtcol_n > virtcol then
|
|
node, n = n, n:parent()
|
|
else
|
|
break
|
|
end
|
|
end
|
|
end
|
|
|
|
local ret = TSScope:new({ buf = opts.buf, node = node }, opts):root()
|
|
return ret
|
|
end
|
|
|
|
function TSScope:parent()
|
|
local parent = self.node:parent()
|
|
return parent and parent ~= self.node:tree():root() and self:with({ node = parent }):root() or nil
|
|
end
|
|
|
|
-- Inner treesitter scope includes all lines for which the node
|
|
-- has a start position lower than the start of the scope.
|
|
function TSScope:inner()
|
|
local from, to, indent = nil, nil, math.huge
|
|
for l = self.from + 1, self.to do
|
|
if l == vim.fn.nextnonblank(l) then
|
|
local col = (vim.fn.getline(l):find("%S") or 1) - 1
|
|
local node = vim.treesitter.get_node({ pos = { l - 1, col }, bufnr = self.buf })
|
|
local s = TSScope:new({ buf = self.buf, node = node }, self.opts):fix()
|
|
if s and s.from > self.from and s.to <= self.to then
|
|
from = from or l
|
|
to = l
|
|
indent = math.min(indent, vim.fn.indent(l))
|
|
end
|
|
end
|
|
end
|
|
return from and to and IndentScope:new({ from = from, to = to, indent = indent }, self.opts) or self
|
|
end
|
|
|
|
function Scope:__tostring()
|
|
local meta = getmetatable(self)
|
|
return ("%s(buf=%d, from=%d, to=%d, indent=%d)"):format(
|
|
rawequal(meta, TSScope) and "TSScope" or rawequal(meta, IndentScope) and "IndentSCope" or "Scope",
|
|
self.buf or -1,
|
|
self.from or -1,
|
|
self.to or -1,
|
|
self.indent or 0
|
|
)
|
|
end
|
|
|
|
---@param cb fun(scope?: snacks.scope.Scope)
|
|
---@param opts? snacks.scope.Opts|{parse?:boolean}
|
|
function M.get(cb, opts)
|
|
opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Opts ]]
|
|
opts.buf = (opts.buf == nil or opts.buf == 0) and vim.api.nvim_get_current_buf() or opts.buf
|
|
if not opts.pos then
|
|
assert(opts.buf == vim.api.nvim_win_get_buf(0), "missing pos")
|
|
opts.pos = vim.api.nvim_win_get_cursor(0)
|
|
end
|
|
|
|
-- run in the context of the buffer if not current
|
|
if vim.api.nvim_get_current_buf() ~= opts.buf then
|
|
vim.api.nvim_buf_call(opts.buf, function()
|
|
M.get(cb, opts)
|
|
end)
|
|
return
|
|
end
|
|
|
|
---@type snacks.scope.Scope
|
|
local Class = (opts.treesitter.enabled and Snacks.util.get_lang(opts.buf)) and TSScope or IndentScope
|
|
if rawequal(Class, TSScope) and opts.parse ~= false then
|
|
TSScope:init(function()
|
|
opts.parse = false
|
|
M.get(cb, opts)
|
|
end, opts)
|
|
return
|
|
end
|
|
local scope = Class:find(opts) --[[ @as snacks.scope.Scope? ]]
|
|
|
|
-- fallback to indent based detection
|
|
if not scope and rawequal(Class, TSScope) then
|
|
Class = IndentScope
|
|
scope = Class:find(opts)
|
|
end
|
|
|
|
-- when end_pos is provided, get its scope and expand the current scope
|
|
-- to include it.
|
|
if scope and opts.end_pos and not vim.deep_equal(opts.pos, opts.end_pos) then
|
|
local end_scope = Class:find(vim.tbl_extend("keep", { pos = opts.end_pos }, opts)) --[[ @as snacks.scope.Scope? ]]
|
|
if end_scope and end_scope.from < scope.from then
|
|
scope = scope:expand(end_scope.from) or scope
|
|
end
|
|
if end_scope and end_scope.to > scope.to then
|
|
scope = scope:expand(end_scope.to) or scope
|
|
end
|
|
end
|
|
|
|
local min_size = opts.min_size or 2
|
|
local max_size = opts.max_size or min_size
|
|
|
|
-- expand block with ancestors until min_size is reached
|
|
-- or max_size is reached
|
|
if scope then
|
|
local s = scope --- @type snacks.scope.Scope?
|
|
while s do
|
|
if opts.edge and scope:size_with_edge() >= min_size and s:size_with_edge() > max_size then
|
|
break
|
|
elseif not opts.edge and scope:size() >= min_size and s:size() > max_size then
|
|
break
|
|
end
|
|
scope, s = s, s:parent()
|
|
end
|
|
-- expand with edge
|
|
if opts.edge then
|
|
scope = scope:with_edge() --[[@as snacks.scope.Scope]]
|
|
end
|
|
end
|
|
|
|
-- expand single line blocks with single line siblings
|
|
if opts.siblings and scope and scope:size() == 1 then
|
|
while scope and scope:size() < min_size do
|
|
local prev, next = vim.fn.prevnonblank(scope.from - 1), vim.fn.nextnonblank(scope.to + 1) ---@type number, number
|
|
local prev_dist, next_dist = math.abs(opts.pos[1] - prev), math.abs(opts.pos[1] - next)
|
|
local prev_s = prev > 0 and Class:find(vim.tbl_extend("keep", { pos = { prev, 0 } }, opts))
|
|
local next_s = next > 0 and Class:find(vim.tbl_extend("keep", { pos = { next, 0 } }, opts))
|
|
prev_s = prev_s and prev_s:size() == 1 and prev_s
|
|
next_s = next_s and next_s:size() == 1 and next_s
|
|
local s = prev_dist < next_dist and prev_s or next_s or prev_s
|
|
if s and (s.from < scope.from or s.to > scope.to) then
|
|
scope = Scope.with(scope, { from = math.min(scope.from, s.from), to = math.max(scope.to, s.to) })
|
|
else
|
|
break
|
|
end
|
|
end
|
|
end
|
|
cb(scope)
|
|
end
|
|
|
|
---@class snacks.scope.Listener
|
|
---@field id integer
|
|
---@field cb snacks.scope.Attach.cb
|
|
---@field opts snacks.scope.Config
|
|
---@field dirty table<number, boolean>
|
|
---@field timer uv.uv_timer_t
|
|
---@field augroup integer
|
|
---@field enabled boolean
|
|
---@field active table<number, snacks.scope.Scope>
|
|
local Listener = {}
|
|
|
|
---@param cb snacks.scope.Attach.cb
|
|
---@param opts? snacks.scope.Config
|
|
function Listener.new(cb, opts)
|
|
local self = setmetatable({}, { __index = Listener })
|
|
self.cb = cb
|
|
self.dirty = {}
|
|
self.timer = assert((vim.uv or vim.loop).new_timer())
|
|
self.enabled = false
|
|
self.opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Opts ]]
|
|
id = id + 1
|
|
self.id = id
|
|
self.active = {}
|
|
return self
|
|
end
|
|
|
|
--- Check if the scope has changed in the window / buffer
|
|
function Listener:check(win)
|
|
local buf = vim.api.nvim_win_get_buf(win)
|
|
if not self.opts.filter(buf) then
|
|
if self.active[win] then
|
|
local prev = self.active[win]
|
|
self.active[win] = nil
|
|
self.cb(win, buf, nil, prev)
|
|
end
|
|
return
|
|
end
|
|
|
|
M.get(
|
|
function(scope)
|
|
local prev = self.active[win]
|
|
if prev == scope then
|
|
return -- no change
|
|
end
|
|
self.active[win] = scope
|
|
self.cb(win, buf, scope, prev)
|
|
end,
|
|
vim.tbl_extend("keep", {
|
|
buf = buf,
|
|
pos = vim.api.nvim_win_get_cursor(win),
|
|
}, self.opts)
|
|
)
|
|
end
|
|
|
|
--- Get the active scope for a window
|
|
function Listener:get(win)
|
|
local scope = self.active[win]
|
|
return scope and vim.api.nvim_win_is_valid(win) and vim.api.nvim_win_get_buf(win) == scope.buf and scope or nil
|
|
end
|
|
|
|
--- Cleanup invalid scopes
|
|
function Listener:clean()
|
|
for win in pairs(self.active) do
|
|
self.active[win] = self:get(win)
|
|
end
|
|
end
|
|
|
|
--- Iterate over active scopes
|
|
function Listener:iter()
|
|
self:clean()
|
|
return pairs(self.active)
|
|
end
|
|
|
|
--- Schedule a scope update
|
|
---@param wins? number|number[]
|
|
---@param opts? {now?: boolean}
|
|
function Listener:update(wins, opts)
|
|
wins = type(wins) == "number" and { wins } or wins or vim.api.nvim_list_wins() --[[ @as number[] ]]
|
|
for _, b in ipairs(wins) do
|
|
self.dirty[b] = true
|
|
end
|
|
local function update()
|
|
self:_update()
|
|
end
|
|
if opts and opts.now then
|
|
update()
|
|
end
|
|
self.timer:start(self.opts.debounce, 0, vim.schedule_wrap(update))
|
|
end
|
|
|
|
--- Process all pending updates
|
|
function Listener:_update()
|
|
for win in pairs(self.dirty) do
|
|
if vim.api.nvim_win_is_valid(win) then
|
|
self:check(win)
|
|
end
|
|
end
|
|
self.dirty = {}
|
|
end
|
|
|
|
--- Start listening for scope changes
|
|
function Listener:enable()
|
|
assert(not self.enabled, "already enabled")
|
|
self.enabled = true
|
|
self.augroup = vim.api.nvim_create_augroup("snacks_scope_" .. self.id, { clear = true })
|
|
vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, {
|
|
group = self.augroup,
|
|
callback = function(ev)
|
|
for _, win in ipairs(vim.fn.win_findbuf(ev.buf)) do
|
|
self:update(win)
|
|
end
|
|
end,
|
|
})
|
|
vim.api.nvim_create_autocmd({ "WinClosed", "BufDelete", "BufWipeout" }, {
|
|
group = self.augroup,
|
|
callback = function()
|
|
self:clean()
|
|
end,
|
|
})
|
|
self:update(nil, { now = true })
|
|
end
|
|
|
|
--- Stop listening for scope changes
|
|
function Listener:disable()
|
|
assert(self.enabled, "already disabled")
|
|
self.enabled = false
|
|
vim.api.nvim_del_augroup_by_id(self.augroup)
|
|
self.timer:stop()
|
|
self.active = {}
|
|
self.dirty = {}
|
|
end
|
|
|
|
--- Attach a scope listener
|
|
---@param cb snacks.scope.Attach.cb
|
|
---@param opts? snacks.scope.Config
|
|
---@return snacks.scope.Listener
|
|
function M.attach(cb, opts)
|
|
local ret = Listener.new(cb, opts)
|
|
ret:enable()
|
|
return ret
|
|
end
|
|
|
|
-- Text objects for indent scopes.
|
|
-- Best to use with Treesitter disabled.
|
|
-- When in visual mode, it will select the scope containing the visual selection.
|
|
-- When the scope is the same as the visual selection, it will select the parent scope instead.
|
|
---@param opts? snacks.scope.TextObject
|
|
function M.textobject(opts)
|
|
opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.TextObject ]]
|
|
|
|
local mode = vim.fn.mode()
|
|
local selection = mode:find("[vV]") ~= nil
|
|
|
|
-- prepare for visual mode and determine linewise
|
|
if mode == "v" then
|
|
vim.cmd("normal! v")
|
|
elseif mode == "V" then
|
|
vim.cmd("normal! V")
|
|
opts.linewise = opts.linewise == nil and true or opts.linewise
|
|
end
|
|
|
|
-- use the actual range instead of the cursor position
|
|
-- in case of visual mode
|
|
if selection then
|
|
opts.pos = vim.api.nvim_buf_get_mark(0, "<")
|
|
opts.end_pos = vim.api.nvim_buf_get_mark(0, ">")
|
|
end
|
|
local inner = not opts.edge
|
|
opts.edge = true -- always include the edge of the scope to make inner work
|
|
|
|
M.get(function(scope)
|
|
if not scope then
|
|
return opts.notify ~= false and Snacks.notify.warn("No scope in range")
|
|
end
|
|
|
|
scope = inner and scope:inner() or scope
|
|
-- determine scope range
|
|
local from, to =
|
|
{ scope.from, opts.linewise and 0 or vim.fn.indent(scope.from) },
|
|
{ scope.to, opts.linewise and 0 or vim.fn.col({ scope.to, "$" }) - 2 }
|
|
|
|
-- select the range
|
|
vim.api.nvim_win_set_cursor(0, from)
|
|
vim.cmd("normal! " .. (opts.linewise and "V" or "v"))
|
|
vim.api.nvim_win_set_cursor(0, to)
|
|
end, opts)
|
|
end
|
|
|
|
--- Jump to the top or bottom of the scope
|
|
--- If the scope is the same as the current scope, it will jump to the parent scope instead.
|
|
---@param opts? snacks.scope.Jump
|
|
function M.jump(opts)
|
|
opts = Snacks.config.get("scope", defaults, opts or {}) --[[ @as snacks.scope.Jump ]]
|
|
M.get(function(scope)
|
|
if not scope then
|
|
return opts.notify ~= false and Snacks.notify.warn("No scope in range")
|
|
end
|
|
while scope do
|
|
local line = opts.bottom and scope.to or scope.from
|
|
local pos = { line, vim.fn.indent(line) }
|
|
if not vim.deep_equal(vim.api.nvim_win_get_cursor(0), pos) then
|
|
return vim.api.nvim_win_set_cursor(0, { line, vim.fn.indent(line) })
|
|
end
|
|
scope = scope:parent()
|
|
end
|
|
end, opts)
|
|
end
|
|
|
|
---@private
|
|
function M.setup()
|
|
local keys = Snacks.config.get("scope", defaults).keys
|
|
for key, opts in pairs(keys.textobject) do
|
|
vim.keymap.set({ "x", "o" }, key, function()
|
|
M.textobject(opts)
|
|
end, { silent = true, desc = opts.desc })
|
|
end
|
|
for key, opts in pairs(keys.jump) do
|
|
vim.keymap.set({ "n", "x", "o" }, key, function()
|
|
M.jump(opts)
|
|
end, { silent = true, desc = opts.desc })
|
|
end
|
|
end
|
|
|
|
M.TSScope = TSScope
|
|
M.IdentScope = IndentScope
|
|
|
|
return M
|