blob: c88be874ffb30d8abef48066c9dd9df2774d0f6a (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
-- 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
-- this filter's place.
--
-- 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. Works with Lua 5.1 onward,
-- including LuaJIT.
--
-- 1. lpeg, the parsing module for the Lua that 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
--        macOS               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
--     /usr/local/share/scintillua/lexers
--     /usr/share/scintillua/lexers
--     /opt/homebrew/share/scintillua/lexers
--
-- so either set the variable in the web server environment, or place
-- (or symlink) the scintillua directory next to your cgitrc. When
-- Scintillua or lpeg is missing the filter passes text through escaped
-- and unhighlighted, so it is safe to enable before the dependencies
-- are installed.
--
-- OUTPUT
--
-- Tokens are wrapped in <span> elements carrying the hl- classes that
-- assets/cgit.css styles. Every input byte is preserved, so the line
-- number gutter stays aligned.

-- Files larger than this are passed through without lexing.
local max_bytes = 512 * 1024

local function scintillua_path()
	local env = os.getenv("CGIT_SCINTILLUA_PATH")
	if env then
		return env
	end
	local candidates = {
		"/usr/local/share/scintillua/lexers",
		"/usr/share/scintillua/lexers",
		"/opt/homebrew/share/scintillua/lexers",
	}
	local config = os.getenv("CGIT_CONFIG")
	if config then
		local dir = string.match(config, "^(.*)/[^/]+$")
		if dir then
			table.insert(candidates, 1, dir .. "/scintillua/lexers")
		end
	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

-- Scintillua tag name (first dotted component) to cgit css class.
local css = {
	comment      = "hl-comment",
	string       = "hl-string",
	regex        = "hl-string",
	number       = "hl-number",
	keyword      = "hl-keyword",
	preprocessor = "hl-keyword",
	type         = "hl-type",
	class        = "hl-type",
	constant     = "hl-number",
	["function"] = "hl-func",
}

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

local function escape(s)
	s = string.gsub(s, "&", "&amp;")
	s = string.gsub(s, "<", "&lt;")
	s = string.gsub(s, ">", "&gt;")
	return s
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

-- Resolve a lexer for the file, preferring Scintillua's own filename
-- detection when this version provides it.
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 okl, lex = pcall(lexer_mod.load, lang)
			if okl then
				return lex
			end
		end
		return nil
	end
	local ext = string.match(name, "%.([^.]+)$")
	if not ext then
		return nil
	end
	local ok, lex = pcall(lexer_mod.load, string.lower(ext))
	if ok then
		return lex
	end
	return nil
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
	html(escape(text))
	return 0
end