-- 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.5 and LuaJIT. Scintillua 6.7 loads all of its lexers on
-- LuaJIT, so the two do not have to be matched up. A lexer that will not load
-- is skipped and that file falls back to plain escaped text, so a mismatched
-- pair degrades rather than breaking the page.
--
-- 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. Around 160
-- languages as plain .lua files, nothing to compile. Download a release and
-- unpack it anywhere. Only the lexers directory is needed.
--
-- https://orbitalquark.github.io/scintillua/
--
-- The lexers are found by probing, in order
--
-- $CGIT_SCINTILLUA_PATH (used alone when set, no fallback)
--
/scintillua/lexers
-- the scintillua_dirs list among the configuration values 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 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.
-- 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 = { ["&"] = "&", ["<"] = "<", [">"] = ">" }
-- 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 = "" .. part .. ""
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