summaryrefslogtreecommitdiffstats
path: root/.config/nvim/plugin/50-extmarks.lua
blob: 50ba81d46ac6312b84b5d9c8ab13dedfee4649da (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
--
-- 50-extmarks.lua
--
-- * Creates a global highlight namespace for extended marks.
-- * Keeps track of the current global highlight namespace in `vim.g.hl_ns`.
-- * Creates an autocommand that initializes and updates extended marks.
--   For now, only "TODO" strings are marked but the system can be easily extended by adding items
--   in `marks`.
--   Doesn't support multi-line patterns.
--
-- Global variables:
--   `vim.g.hl_ns`: global highlight namespace
--
-- User commands:
--   `ExtmarkToggle`: toggle extended marks highlight namespace
--

vim.g.hl_ns = 0

local extmarks_hl_ns = vim.api.nvim_create_namespace("dotfiles_extmarks")

local hl_group_todo = "dotfiles.Todo"
vim.api.nvim_set_hl(extmarks_hl_ns, hl_group_todo, { link = "Todo" })

-- Wrapper around `nvim_set_hl_ns()`; sets the global highlight namespace to `hl_ns` and save it to
-- `vim.g.hl_ns`. Useful because the global highlight namespace is not saved anywhere by default.
local function set_global_hl_ns(hl_ns)
	vim.g.hl_ns = hl_ns
	vim.api.nvim_set_hl_ns(hl_ns)
end

-- Toggles extended marks highlight namespace
local function toggle_marks()
	local new_hl_ns = vim.g.hl_ns == 0 and extmarks_hl_ns or 0
	set_global_hl_ns(new_hl_ns)
end

-- Parses the treesitter tree synchronously, including injections, in the 0-based, end-exclusive
-- line range `first`:`last` of buffer `buf`.
--- @return boolean parser_exists
local function parse_tree(buf, first, last)
	local parser = vim.treesitter.get_parser(buf)
	if parser then
		parser:parse({ first, last })
	end
	return parser ~= nil
end

-- Returns a predicate telling whether a "TODO" string should be marked at a given position.
-- In markdown buffers, positions inside code are invalid, otherwise positions outside comments are
-- invalid. All positions are valid if there is no parser for the buffer filetype.
-- Only the line range `first`:`last` is parsed; the predicate must not be called outside it.
--- @param buf number
--- @return fun(row: number, col: number): boolean predicate taking a 0-based position
local function todo_filter(buf, first, last)
	local has_parser = parse_tree(buf, first, last)
	return function(row, col)
		assert(first <= row and row < last, "Position outside the parsed line range")
		if not has_parser then
			return true
		end
		local block = vim.treesitter.get_node({
			bufnr = buf,
			pos = { row, col },
		})
		assert(block, "Expected node to be truthy, is the tree parsed?")
		if vim.bo[buf].filetype == "markdown" then
			if string.match(block:type(), "code") then
				return false
			end
			local inline = vim.treesitter.get_node({
				bufnr = buf,
				ignore_injections = false,
				pos = { row, col },
			})
			assert(inline, "Expected node to be truthy, is the tree parsed?")
			return inline:type() ~= "code_span"
		else
			return string.match(block:type(), "comment") ~= nil
		end
	end
end

-- Returns an iterator over the positions of valid "TODO" strings in buffer `buf` in the line range
-- `first`:`last`. Each item is a table with 0-based fields: row `y`, start column `x1` (inclusive)
-- and end column `x2` (exclusive). See `todo_filter()` for what makes a position valid.
local function find_todo(buf, first, last)
	local lines = vim.api.nvim_buf_get_lines(buf, first, last, false)
	local should_mark = todo_filter(buf, first, first + #lines)
	local x1, x2, y = nil, nil, 1
	return function()
		while y <= #lines do
			x1, x2 = string.find(lines[y], "TODO", (x2 or 0) + 1)
			if x1 and x2 then
				local row = y + first - 1
				if should_mark(row, x1 - 1) then
					return { y = row, x1 = x1 - 1, x2 = x2 }
				end
			else
				x1, x2, y = 0, 0, y + 1
			end
		end
		return nil
	end
end

-- List of extended marks to apply with `mark_all()`
--
-- Each item is a table with two fields: `hl_group`, the name of the highlight group to use
-- for the marks, and `find`, which must return an iterator over the positions to mark (see
-- `find_todo()` for the format).
local marks = {
	{ hl_group = hl_group_todo, find = find_todo },
}

-- Updates extended marks in buffer `buf` in range `first`:`last`.
-- For each item in `marks`, sets extended marks on positions returned by `item.find` to the
-- highlight group `item.hl_group`.
local function mark_all(buf, first, last)
	vim.api.nvim_buf_clear_namespace(buf, extmarks_hl_ns, first, last)
	for _, marks_opts in ipairs(marks) do
		for p in marks_opts.find(buf, first, last) do
			vim.api.nvim_buf_set_extmark(
				buf,
				extmarks_hl_ns,
				p.y,
				p.x1,
				{ end_col = p.x2, hl_group = marks_opts.hl_group }
			)
		end
	end
end

-- Create extended marks in buffer `ev.buf` and updates them when the buffer changes.
local function watch_extmarks(ev)
	mark_all(ev.buf, 0, -1)
	if vim.b[ev.buf].dotfiles_todo_attached then
		return
	end
	vim.b[ev.buf].dotfiles_todo_attached = true
	vim.api.nvim_buf_attach(ev.buf, false, {
		on_lines = function(_, b, _, first, _, last_new)
			vim.schedule(function()
				if vim.api.nvim_buf_is_valid(b) then
					mark_all(b, first, last_new)
				end
			end)
		end,
	})
end

----------------------------------------------------------------------------------------------------

vim.api.nvim_create_autocmd("FileType", {
	desc = "Set extended marks watcher",
	group = vim.g.dotfiles.augroup,
	callback = watch_extmarks,
})

vim.api.nvim_create_user_command("ExtmarkToggle", toggle_marks, { desc = "Toggle extended marks" })

set_global_hl_ns(extmarks_hl_ns)