blob: 862965a6b26bb3b3a9f4d77f93e0d56cc8f5e44b (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
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
-- Server-side syntax highlighting for cgit's tree and blob views, run inside
-- cgit's embedded Lua interpreter so a coloured blob costs no extra process
-- per request. Colouring is deliberately left out of cgit itself, which
-- serves plain escaped text on its own, so this filter is named by the
-- source-filter setting and any other program could take its place. Tokens
-- come from the Scintillua lexers and reach the page wrapped in span elements
-- carrying the hl- classes that assets/cgit.css styles. Whenever a piece is
-- missing or will not load, from lpeg down to a single lexer, the file falls
-- back to plain escaped text instead of failing, so uncoloured code means a
-- missing dependency rather than an error. It runs on Lua 5.1 through 5.5 and
-- LuaJIT.
--
--     source-filter=lua:/usr/lib/cgit/extensions/syntax-highlight.lua


-- Files larger than this many bytes are served escaped but unhighlighted, so
-- a huge blob does not cost a lexing pass. Kept well below cgit's
-- max-blob-size.
local max_bytes = 512 * 1024

-- Size of the pieces the unhighlighted fallback is written in, so a large
-- blob does not cost a full-size second copy all at once.
local slice_bytes = 64 * 1024

-- Set this in the web server environment to point straight at the Scintillua
-- lexers directory, in which case nothing else is probed.
local scintillua_env = "CGIT_SCINTILLUA_PATH"

-- Directories probed for the lexers when that variable is not set, tried
-- after the directory of $CGIT_CONFIG, so placing or symlinking a scintillua
-- directory next to cgitrc is enough to be found. Scintillua is the lexer
-- collection from the Textadept editor, around 160 languages as plain .lua
-- files with nothing to compile, from
-- https://orbitalquark.github.io/scintillua/. It does not bundle lpeg, which
-- it needs and which has to be built for the Lua cgit is linked against, and
-- forgetting that is the usual reason nothing is coloured.
--
--     # Debian and Ubuntu
--     sudo apt install lua-lpeg
--     # Fedora
--     sudo dnf install lua-lpeg
--     # Alpine
--     sudo apk add lua5.1-lpeg
--     # or with LuaRocks, matched to your Lua version
--     sudo luarocks --lua-version 5.1 install lpeg
--
-- Every directory probed goes on package.path and its Lua is executed in
-- cgit's process, so one that other users can write to hands them code
-- execution as the web server. On macOS /opt/homebrew/share is group-writable
-- by default, so check it before leaving it in this list.
local scintillua_dirs = {
	"/usr/local/share/scintillua/lexers",
	"/usr/share/scintillua/lexers",
	"/opt/homebrew/share/scintillua/lexers",
}

-- A Scintillua tag name, meaning its first dotted component, mapped to a cgit
-- css class. Only the six classes named here exist in assets/cgit.css, so
-- styling another kind of token means adding a class there and a row here. A
-- tag with no row renders as plain text, which is what most themes want for
-- operators and identifiers.
local css = {
	comment      = "hl-comment",
	string       = "hl-string",
	regex        = "hl-string",
	number       = "hl-number",
	constant     = "hl-number",
	keyword      = "hl-keyword",
	preprocessor = "hl-keyword",
	tag          = "hl-keyword",
	label        = "hl-keyword",
	annotation   = "hl-keyword",
	type         = "hl-type",
	class        = "hl-type",
	attribute    = "hl-type",
	["function"] = "hl-func",
}

-- Extension to lexer name fixes for the fallback path, reached only when this
-- Scintillua has no detect(). Most extensions already equal their lexer name
-- and these are the frequent exceptions. A wrong guess only falls back to
-- plain text, so a best-effort entry costs nothing.
local ext_lexer = {
	py = "python", js = "javascript", ts = "typescript",
	rb = "ruby", pl = "perl", pm = "perl", sh = "bash",
	md = "markdown", htm = "html", yml = "yaml",
	rs = "rust", c = "ansi_c", h = "ansi_c",
}


local scintillua = nil
local filename = ""
local chunks = {}

local escape_map = { ["&"] = "&amp;", ["<"] = "&lt;", [">"] = "&gt;" }

local function escape(s)
	return (string.gsub(s, "[&<>]", escape_map))
end

local function scintillua_path()
	local env = os.getenv(scintillua_env)
	if env then
		return env
	end
	local candidates = {}
	local config = os.getenv("CGIT_CONFIG")
	if config then
		local dir = string.match(config, "^(.*)/[^/]+$")
		if dir then
			candidates[#candidates + 1] = dir .. "/scintillua/lexers"
		end
	end
	for _, dir in ipairs(scintillua_dirs) do
		candidates[#candidates + 1] = dir
	end
	-- A candidate counts only when lexer.lua is actually in it, so a
	-- directory that exists but holds no lexers does not shadow a
	-- later one.
	for _, dir in ipairs(candidates) do
		local f = io.open(dir .. "/lexer.lua", "r")
		if f then
			f:close()
			return dir
		end
	end
	return nil
end

local function load_scintillua()
	local dir = scintillua_path()
	if not dir then
		return nil
	end
	-- cgit keeps this interpreter alive across requests, so package.path is
	-- only extended when the directory is not already on it.
	if not string.find(package.path, dir, 1, true) then
		package.path = dir .. "/?.lua;" .. package.path
	end
	local ok, mod = pcall(require, "lexer")
	-- A real Scintillua exposes load(), so anything else on the path that
	-- happens to be called lexer is rejected rather than used.
	if ok and type(mod) == "table" and type(mod.load) == "function" then
		return mod
	end
	return nil
end

-- Loading a lexer runs its Lua, and one written for another Scintillua can
-- raise, so a failure here just leaves this file uncoloured.
local function load_lexer_name(name)
	if name == nil then
		return nil
	end
	local ok, lexer = pcall(scintillua.load, name)
	if ok and lexer then
		return lexer
	end
	return nil
end

-- Resolve a lexer for the file, preferring Scintillua's own filename
-- detection where this version provides it, then the extension map above,
-- then the raw extension.
local function lexer_for(name)
	if type(scintillua.detect) == "function" then
		local ok, lang = pcall(scintillua.detect, name)
		if ok and lang then
			local lexer = load_lexer_name(lang)
			if lexer then
				return lexer
			end
		end
	end
	local ext = string.match(name, "%.([^.]+)$")
	if not ext then
		return nil
	end
	ext = string.lower(ext)
	return load_lexer_name(ext_lexer[ext]) or load_lexer_name(ext)
end

local function highlight(text)
	local lexer = lexer_for(filename)
	if not lexer then
		return nil
	end
	local ok, tokens = pcall(lexer.lex, lexer, text)
	if not ok or type(tokens) ~= "table" then
		return nil
	end
	local out = {}
	local pos = 1
	-- Scintillua returns one flat list of a tag name and the position
	-- just past the token it names, so a token is the text from where
	-- the one before it ended.
	for i = 1, #tokens, 2 do
		local tag = tokens[i]
		local stop = tokens[i + 1]
		local part = escape(string.sub(text, pos, stop - 1))
		local class = css[string.match(tag, "^[%w_]+")]
		if class and part ~= "" then
			part = "<span class='" .. class .. "'>" .. part .. "</span>"
		end
		out[#out + 1] = part
		pos = stop
	end
	-- A lexer can stop short of the end, and every byte still has to reach
	-- the page or the line number gutter beside it drifts out of step.
	if pos <= #text then
		out[#out + 1] = escape(string.sub(text, pos))
	end
	return table.concat(out)
end

function filter_open(name)
	filename = name or ""
	chunks = {}
end

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

-- cgit takes filter output through a C string sink that stops at the first
-- NUL byte, so a blob holding one is truncated there. That reaches binary
-- files which slip past cgit's text detection, not ordinary source.
function filter_close()
	local text = table.concat(chunks)
	chunks = {}
	if #text <= max_bytes then
		if scintillua == nil then
			scintillua = load_scintillua() or false
		end
		if scintillua then
			local ok, marked = pcall(highlight, text)
			if ok and marked then
				html(marked)
				return 0
			end
		end
	end
	local n = #text
	if n == 0 then
		html("")
		return 0
	end
	local pos = 1
	while pos <= n do
		html(escape(string.sub(text, pos, pos + slice_bytes - 1)))
		pos = pos + slice_bytes
	end
	return 0
end