smoka7/hop.nvim

Feature Request: hint_nodes, but for the whole buffer? (or at least what's visible)

Opened this issue · 1 comments

hint_nodes looks legit, but I find I want to go beyond "current surrounding"

I'm currently using hint_words, which feels sliiiiightly too verbose?

I have an implementation of something very close to this. My main motivation was jumping between links in markdown — an obvious usecase for nvim-treesitter-textobjects but I hit a roadblock with nvim-treesitter/nvim-treesitter-textobjects#479. Fixing that issue seemed beyond my abilities, as nvim-treesitter(-textobjects) seems to use a lot of its own abstractions for treesitter that aren't necessary in current neovim, and also aren't documented well. Implementing this using hop.nvim was much easier 🙂

So now I have a mapping for <Cmd>:HopTextObjects markup.link.label<CR> that does what I want, but if you skip the markup.link.label it just hops between all text objects. It does piggyback on textobjects queries from nvim-treesitter-textobjects, so that you get even less noise than hopping between all nodes (or all words, as you say).

For the fun of it, I extended my implementation with a :HopNodesNamed that skips the query bit and just works with all named treesitter nodes. Now :HopNodesNamed link_text lets me hop between links in markdown, and :HopNodesNamed atx_heading between headings.

Implemented as a hop.nvim extension (needs to be mentioned in opts.extensions):

local M = {}
M.opts = {}

local function parse()
	local ok, parser = pcall(vim.treesitter.get_parser)
	if ok then
		parser:parse(true)
		return parser
	end
end

local function for_each_named_child_node(node, fn)
	fn(node)
	for i = 0, node:named_child_count() - 1, 1 do
		for_each_named_child_node(node:named_child(i), fn)
	end
end

local function named_nodes()
	local root_parser = parse()
	if root_parser then
		local nodes = {}
		root_parser:for_each_tree(function(tree, _parser)
			for_each_named_child_node(tree:root(), function(node)
				table.insert(nodes, node)
			end)
		end)
		return nodes
	end
end

local function text_objects()
	local root_parser = parse()
	if root_parser then
		local objects = {}
		root_parser:for_each_tree(function(tree, parser)
			local query = vim.treesitter.query.get(parser:lang(), 'textobjects')
			if query then
				for _, match, _metadata in query:iter_matches(tree:root(), 0, nil, nil, { all = true }) do
					for id, nodes in pairs(match) do
						table.insert(objects, {
							capture = query.captures[id],
							nodes = nodes,
						})
					end
				end
			end
		end)
		return objects
	end
end

local function jump_target_from_node(node, seen)
	local row, col = node:range()
	row = row + 1

	local seen_key = row .. ':' .. col
	if not seen[seen_key] then
		seen[seen_key] = true

		return {
			window = 0,
			buffer = 0,
			cursor = {
				row = row,
				col = col,
			},
			length = 0,
		}
	end
end

local function named_nodes_locations(types)
	local jump_targets = {}
	local seen = {}

	for _, node in ipairs(named_nodes() or {}) do
		if not types or vim.tbl_contains(types, node:type()) then
			local jump_target = jump_target_from_node(node, seen)
			if jump_target then
				table.insert(jump_targets, jump_target)
			end
		end
	end

	return { jump_targets = jump_targets }
end

local function text_objects_locations(captures)
	local jump_targets = {}
	local seen = {}

	for _, object in ipairs(text_objects() or {}) do
		if not captures or vim.tbl_contains(captures, object.capture) then
			for _, node in ipairs(object.nodes) do
				local jump_target = jump_target_from_node(node, seen)
				if jump_target then
					table.insert(jump_targets, jump_target)
				end
			end
		end
	end

	return { jump_targets = jump_targets }
end

local function sort_indirect_jump_targets(locations, opts)
	local indirect_jump_targets = {}
	local c_row, c_col = unpack(vim.api.nvim_win_get_cursor(0))
	local cursor = { row = c_row, col = c_col }
	for i, jump_target in ipairs(locations.jump_targets) do
		table.insert(indirect_jump_targets, {
			index = i,
			score = opts.distance_method(cursor, jump_target.cursor, opts.x_bias),
		})
	end
	require'hop.jump_target'.sort_indirect_jump_targets(indirect_jump_targets, opts)

	locations.indirect_jump_targets = indirect_jump_targets
end

function M.named_nodes(types, opts)
	opts = setmetatable(opts or {}, { __index = M.opts })
	require'hop'.hint_with(function()
		local locations = named_nodes_locations(types)
		sort_indirect_jump_targets(locations, opts)
		return locations
	end, opts)
end

function M.text_objects(captures, opts)
	opts = setmetatable(opts or {}, { __index = M.opts })
	require'hop'.hint_with(function()
		local locations = text_objects_locations(captures)
		sort_indirect_jump_targets(locations, opts)
		return locations
	end, opts)
end

function M.register(opts)
	M.opts = opts

	vim.api.nvim_create_user_command('HopNodesNamed', function(info)
		M.named_nodes(#info.fargs > 0 and info.fargs)
	end, {
		nargs = '*',
	})

	vim.api.nvim_create_user_command('HopTextObjects', function(info)
		M.text_objects(#info.fargs > 0 and info.fargs)
	end, {
		nargs = '*',
	})
end

return M

I guess it needs a little bit of polish (and some documentation, and maybe support for MultiWindow) to be included in hop.nvim.
I can't commit to getting it over the finish line myself, though, this was already too much of a side quest. :-(