diff options
| author | Bryce Kwon <bryce@brycekwon.com> | |
|---|---|---|
| committer | Bryce Kwon <bryce@brycekwon.com> | |
| commit | ||
| parent | ||
| tree | ||
| download | ||
Move syntax highlighting to a Lua source filter
Diffstat (limited to 'extensions')
| -rw-r--r-- | extensions/syntax-highlight.lua | 202 | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
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, "&", "&") + 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 = "<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 |
