blob: 8dc0bc32974302f172a0c9ef5c278f0672e32409 (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
-- cgit commit-filter that turns git object names and configurable text
-- references in a commit message into links, named by the commit-filter or
-- repo.commit-filter setting in cgitrc. cgit hands over the message already
-- HTML-escaped, so all this does is wrap matches in anchors. Every match is
-- resolved in one left-to-right pass, so nothing is ever linked twice. The
-- two tables below are the whole configuration, and the filter runs on Lua
-- 5.1 through 5.4 and LuaJIT with nothing outside the standard library.
--
--     commit-filter=lua:/path/to/link-commits.lua


-- Object names are handled apart from the rules below because the length
-- bound on them cannot be written as a plain Lua pattern. A commit-filter
-- cannot ask the repository whether a hash is real, so matching is by shape
-- and a long hex number may link to an object that does not exist, which
-- cgit renders as a harmless "Bad object id" page.
local objects = {
	-- Set false to stop linking bare hashes.
	enabled = true,
	-- Git abbreviations run about 7 to 12 characters, and a full name is 40
	-- characters for sha1 or 64 for sha256.
	min_length = 7,
	max_length = 64,
	-- Link target, where %s is replaced with the matched hash. The relative
	-- form is resolved against the current page and works for the common
	-- virtual-root layout.
	url = "./?id=%s",
}

-- Text-reference rules, each one a Lua pattern with a single capture and a
-- URL where %s is replaced by that capture, percent-encoded. The whole match
-- is what gets shown and the capture is only what goes into the URL. Rules are
-- tried in order and the leftmost match wins, so put the more specific
-- patterns first, and an empty list leaves only object names linked.
--
-- Lua patterns are not regular expressions. The reference is
-- https://www.lua.org/manual/5.1/manual.html#5.4.1
--
-- Patterns run against the escaped message, so match the entity spellings
-- '&', '<' and '>' rather than the bare characters, and keep a
-- pattern from ending part way through one.
local rules = {
	{ pattern = "#(%d+)", url = "https://bugs.example.com/?bug=%s" },
	-- { pattern = "CVE%-(%d%d%d%d%-%d+)",
	--   url = "https://www.cve.org/CVERecord?id=CVE-%s" },
	-- { pattern = "!(%d+)",
	--   url = "https://gitlab.example.com/my/repo/-/merge_requests/%s" },
	-- { pattern = "RFC%s?(%d+)",
	--   url = "https://www.rfc-editor.org/rfc/rfc%s" },
}


local chunks = {}

-- Percent-encode everything but the URL unreserved characters, so a captured
-- value cannot break out of the href attribute or out of the URL itself.
local function url_encode(s)
	return (string.gsub(s, "[^%w._~-]", function(c)
		return string.format("%%%02X", string.byte(c))
	end))
end

-- Build one anchor from a URL template holding %s and the text to show. The
-- replacement is a function so that a '%' in the encoded value is not taken
-- for a gsub reference.
local function make_link(url_template, capture, display)
	local encoded = url_encode(capture)
	local href = string.gsub(url_template, "%%s", function()
		return encoded
	end)
	return "<a href='" .. href .. "'>" .. display .. "</a>"
end

-- Collect every candidate match in the message. Priority records which rule
-- found it, and the lower priority wins a tie on the same start position.
local function collect(text)
	local candidates = {}
	for priority, rule in ipairs(rules) do
		-- A malformed pattern is an operator error, so skip that
		-- rule rather than fail the whole page.
		pcall(function()
			local init = 1
			while init <= #text do
				local start, stop, capture = string.find(text, rule.pattern, init)
				if not start then break end
				if capture == nil then
					capture = string.sub(text, start, stop)
				end
				candidates[#candidates + 1] = {
					start = start,
					stop = stop,
					priority = priority,
					link = make_link(rule.url, capture, string.sub(text, start, stop)),
				}
				-- An empty match still has to advance the
				-- scan, or it never reaches the end.
				init = (stop >= start) and stop + 1 or start + 1
			end
		end)
	end
	if objects.enabled then
		local priority = #rules + 1
		local init = 1
		while init <= #text do
			local start, stop, run = string.find(text, "%f[%w](%x+)%f[%W]", init)
			if not start then break end
			if #run >= objects.min_length and #run <= objects.max_length then
				candidates[#candidates + 1] = {
					start = start,
					stop = stop,
					priority = priority,
					link = make_link(objects.url, run, run),
				}
			end
			init = stop + 1
		end
	end
	return candidates
end

function filter_open(...)
	chunks = {}
end

function filter_write(str)
	chunks[#chunks + 1] = str
end

function filter_close()
	local text = table.concat(chunks)
	local candidates = collect(text)
	table.sort(candidates, function(a, b)
		if a.start ~= b.start then
			return a.start < b.start
		end
		return a.priority < b.priority
	end)
	local out = {}
	local pos = 1
	for _, candidate in ipairs(candidates) do
		-- A candidate reaching back into one already emitted is
		-- dropped, so no run of text is ever wrapped twice.
		if candidate.start >= pos then
			out[#out + 1] = string.sub(text, pos, candidate.start - 1)
			out[#out + 1] = candidate.link
			pos = candidate.stop + 1
		end
	end
	out[#out + 1] = string.sub(text, pos)
	html(table.concat(out))
	return 0
end