-- 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)
--
/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 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, "&", "&")
s = string.gsub(s, "<", "<")
s = string.gsub(s, ">", ">")
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 = "" .. 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
html(escape(text))
return 0
end