blob: 83105049ac188d54ba14c13fe56bc371386434fd (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
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
-- Server-side syntax highlighting for the tree and blob views, used with the
-- source-filter setting in cgitrc and the lua: prefix so it runs in cgit's
-- embedded interpreter with no per-request process.
--
--     source-filter=lua:/usr/lib/cgit/extensions/syntax-highlight.lua
--
-- Highlighting is deliberately not built into cgit itself. Without this filter
-- cgit serves plain escaped text, and any other program can take its place.
--
-- SUPPORTED LUA
--
-- Lua 5.1 through 5.4 and LuaJIT. The Scintillua version matters too. Recent
-- Scintillua (6.x) needs Lua 5.3 or newer to load its lexers, older Scintillua
-- releases still load under 5.1 and 5.2. Pick a Scintillua release that matches
-- the Lua cgit is built against.
--
-- REQUIREMENTS
--
-- Two pieces, and BOTH must be installed. When either is missing the filter
-- serves plain escaped text by design, so uncolored code means a missing
-- dependency, not an error.
--
-- 1. lpeg, the parsing module, for the Lua cgit is linked against. Scintillua
--    does NOT bundle it, it must come from the system, and forgetting it is the
--    usual reason nothing happens.
--
--        # 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
--
-- 2. Scintillua, the lexer collection from the Textadept editor. Roughly 120
--    languages as plain .lua files, nothing to compile. Download a release and
--    unpack it anywhere.
--
--        https://orbitalquark.github.io/scintillua/
--
-- The lexers are found by probing, in order
--
--     $CGIT_SCINTILLUA_PATH        (used alone when set, no fallback)
--     <dir of $CGIT_CONFIG>/scintillua/lexers
--     the scintillua_dirs list in the CONFIGURATION block below
--
-- so either set the variable in the web server environment, or place (or
-- symlink) the scintillua directory next to your cgitrc.
--
-- SECURITY
--
-- Every probed directory is placed on package.path and its Lua is executed in
-- cgit's process. Make sure none of them is writable by other users, or someone
-- who can write there gains code execution as the web server. On macOS in
-- particular, /opt/homebrew/share is group-writable by default.
--
-- LIMITATIONS
--
-- cgit sends the filter output through a C string sink that stops at the first
-- NUL byte, so a blob containing a NUL is truncated there. This affects binary
-- files that slip past cgit's text detection, not ordinary source.
--
-- OUTPUT
--
-- Tokens are wrapped in <span> elements carrying the hl- classes that
-- assets/cgit.css styles. Every input byte up to the first NUL is preserved, so
-- the line number gutter stays aligned.

--
-- ===== CONFIGURATION =====
--

-- 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

-- Environment variable that, when set, points straight at the Scintillua
-- lexers directory and is used alone.
local scintillua_env = "CGIT_SCINTILLUA_PATH"

-- Directories probed for the lexers when that variable is not set. The
-- directory of $CGIT_CONFIG, when set, is tried ahead of these. Keep every one
-- of these unwritable by others, see the SECURITY note above.
local scintillua_dirs = {
	"/usr/local/share/scintillua/lexers",
	"/usr/share/scintillua/lexers",
	"/opt/homebrew/share/scintillua/lexers",
}

-- Scintillua tag name (its first dotted component) to a cgit css class. Only
-- the six classes below exist in assets/cgit.css. Add a class there and a row
-- here to style more token kinds. Tokens with no row render 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, used only when this
-- Scintillua has no detect(). Most extensions already equal their lexer name,
-- these are the frequent exceptions. A wrong guess just falls back to plain
-- text, so there is no harm in listing best-effort entries.
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 lexer_mod = nil
local filename = ""
local chunks = {}

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

-- Escape the three HTML metacharacters in a single pass.
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 _, d in ipairs(scintillua_dirs) do
		candidates[#candidates + 1] = d
	end
	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
	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(). Anything else on the path that happens
	-- to be called lexer is not usable.
	if ok and type(mod) == "table" and type(mod.load) == "function" then
		return mod
	end
	return nil
end

local function load_lexer_name(name)
	if name == nil then
		return nil
	end
	local ok, lex = pcall(lexer_mod.load, name)
	if ok and lex then
		return lex
	end
	return nil
end

-- Resolve a lexer for the file, preferring Scintillua's own filename detection
-- when this version provides it, then an extension map, then the raw extension.
local function lexer_for(name)
	if type(lexer_mod.detect) == "function" then
		local ok, lang = pcall(lexer_mod.detect, name)
		if ok and lang then
			local lex = load_lexer_name(lang)
			if lex then
				return lex
			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 lex = lexer_for(filename)
	if not lex then
		return nil
	end
	local ok, tokens = pcall(lex.lex, lex, text)
	if not ok or type(tokens) ~= "table" then
		return nil
	end
	local out = {}
	local pos = 1
	for i = 1, #tokens, 2 do
		local tag = tokens[i]
		local fin = tokens[i + 1]
		local part = escape(string.sub(text, pos, fin - 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 = fin
	end
	-- Anything the lexer left unconsumed is kept, escaped.
	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

function filter_close()
	local text = table.concat(chunks)
	chunks = {}
	if #text <= max_bytes then
		if lexer_mod == nil then
			lexer_mod = load_scintillua() or false
		end
		if lexer_mod then
			local ok, marked = pcall(highlight, text)
			if ok and marked then
				html(marked)
				return 0
			end
		end
	end
	-- Fallback, escaped plain text emitted in slices so a large blob does not
	-- cost a full-size second copy all at once.
	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 + 65535)))
		pos = pos + 65536
	end
	return 0
end