diff options
context:
space:
mode:
authorBryce Kwon <bryce@brycekwon.com>
committerBryce Kwon <bryce@brycekwon.com>
commit
parent
tree
download
Move syntax highlighting to a Lua source filter
Diffstat (limited to 'extensions/syntax-highlight.lua')
-rw-r--r--extensions/syntax-highlight.lua202
1 file changed, 202 insertions, 0 deletions
diff --git a/extensions/syntax-highlight.lua b/extensions/syntax-highlight.lua
new file mode 100644
index 0000000..c88be87
--- /dev/null
+++ b/extensions/syntax-highlight.lua
@@ -0,0 +1,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