From 62a059c822fa3ddf9a5ae771fe1496bdf82f32c5 Mon Sep 17 00:00:00 2001 From: Bryce Kwon Date: Sun, 19 Jul 2026 16:40:16 -1000 Subject: Move syntax highlighting to a Lua source filter --- assets/cgit.css | 4 +- assets/cgit.js | 230 +--------------------------------------- cgitrc.5.txt | 12 ++- examples/cgitrc | 19 ++-- extensions/syntax-highlight.lua | 202 +++++++++++++++++++++++++++++++++++ source/ui-tree.c | 12 +-- tests/t0201-limits.sh | 18 ++++ 7 files changed, 247 insertions(+), 250 deletions(-) create mode 100644 extensions/syntax-highlight.lua diff --git a/assets/cgit.css b/assets/cgit.css index 7f7535d..46dfbba 100644 --- a/assets/cgit.css +++ b/assets/cgit.css @@ -736,11 +736,13 @@ div#cgit table.blob td.linenumbers a:target:before { content: "\2BA9"; } -/* Tokens produced by the client-side highlighter in cgit.js. */ +/* Tokens produced by the syntax-highlight.lua source filter. */ div#cgit .hl-comment { color: light-dark(#6a737d, #8b949e); font-style: italic; } div#cgit .hl-keyword { color: light-dark(#cf222e, #ff7b72); } div#cgit .hl-string { color: light-dark(#0a7d24, #7ee787); } div#cgit .hl-number { color: light-dark(#0550ae, #79c0ff); } +div#cgit .hl-type { color: light-dark(#953800, #ffa657); } +div#cgit .hl-func { color: light-dark(#6639ba, #d2a8ff); } /* ---- Blame ------------------------------------------------------------ */ diff --git a/assets/cgit.js b/assets/cgit.js index 347e977..de71a2d 100644 --- a/assets/cgit.js +++ b/assets/cgit.js @@ -154,8 +154,8 @@ document.addEventListener("DOMContentLoaded", function () { * code, pipe tables, links, images and emphasis. Every run of text is escaped * before any markup is added, and link and image URLs are restricted to http, * https, mailto and relative targets, so a hostile readme cannot inject markup - * or scripts. Fenced code keeps its data-lang so the highlighter below styles - * it. Without JavaScript the escaped source stays readable as plain text. + * or scripts. Fenced code carries its language in data-lang as a styling + * hook. Without JavaScript the escaped source stays readable as plain text. * * This is intentionally a subset, not CommonMark: no reference links, raw HTML * passthrough, nested lists or setext headings. Configure an about-filter to @@ -310,229 +310,3 @@ document.addEventListener("DOMContentLoaded", function () { }, false); })(); - -/* Built-in syntax highlighting for the blob view. When no server-side - * source filter is configured, cgit tags the element with - * data-lang set to the file's extension (or bare name, so Makefile and - * Dockerfile work too). We tokenise the plain text into comments, - * strings, numbers and keywords. Every character is preserved so the - * line-number gutter stays aligned, and any failure leaves the original - * text untouched, so a blob is always readable with or without JS. - * - * To support another language: reuse or add a lexical profile in P, add - * one LANGS entry with its keyword list, then map its extensions in EXT. */ - -(function () { - -var MAX_BYTES = 400000; - -/* Lexical profiles shared across language families. Fields: - * line - line-comment prefix, or an array of prefixes - * block - [open, close] block-comment delimiters - * str - characters that open a single-line quoted string - * tick - subset of str whose strings may also span newlines - * tstr - triple-quoted string delimiters (Python-style) - * ci - keywords match case-insensitively (SQL) */ -var P = { - c: { line: "//", block: [ "/*", "*/" ], str: "\"'" }, - tick: { line: "//", block: [ "/*", "*/" ], str: "\"'`", tick: "`" }, - hash: { line: "#", str: "\"'" }, - sql: { line: "--", block: [ "/*", "*/" ], str: "\"'", ci: true }, - lua: { line: "--", block: [ "--[[", "]]" ], str: "\"'" }, - haskell: { line: "--", block: [ "{-", "-}" ], str: "\"" }, - lisp: { line: ";", str: "\"" }, - ini: { line: [ ";", "#" ], str: "\"'" }, - css: { block: [ "/*", "*/" ], str: "\"'" }, - scss: { line: "//", block: [ "/*", "*/" ], str: "\"'" }, - json: { str: "\"" }, - ps: { line: "#", block: [ "<#", "#>" ], str: "\"'" }, - py: { line: "#", str: "\"'", tstr: [ "\"\"\"", "'''" ] } -}; - -var KW = { - c: "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while _Bool bool complex size_t ssize_t ptrdiff_t intptr_t uintptr_t int8_t int16_t int32_t int64_t uint8_t uint16_t uint32_t uint64_t true false NULL", - cpp: "auto break case catch char class const consteval constexpr constinit continue co_await co_return co_yield decltype default delete do double dynamic_cast else enum explicit export extern false final float for friend goto if inline int long mutable namespace new noexcept nullptr operator override private protected public register reinterpret_cast return short signed sizeof static static_assert static_cast struct switch template this thread_local throw true try typedef typeid typename union unsigned using virtual void volatile wchar_t while and or not xor concept requires bool size_t std string vector map set", - cs: "abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using virtual void volatile while var async await dynamic yield get set value record nameof when where", - java: "abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new package private protected public return short static strictfp super switch synchronized this throw throws transient try void volatile while true false null var record sealed permits yield String Integer Boolean Object List Map", - js: "async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return static super switch this throw try typeof var void while with yield null true false undefined of get set from as NaN Infinity console", - ts: "async await break case catch class const continue debugger default delete do else export extends finally for function if import in instanceof let new return static super switch this throw try typeof var void while with yield null true false undefined of get set from as interface type enum namespace declare abstract implements private public protected readonly any unknown never string number boolean object symbol bigint keyof infer satisfies is", - go: "break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false iota append cap close complex copy delete imag len make new panic print println real recover string bool byte rune error int int8 int16 int32 int64 uint uint8 uint16 uint32 uint64 uintptr float32 float64 complex64 complex128 any", - rust: "as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while union String str Vec Option Result Some None Ok Err Box Rc Arc bool char i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64", - swift: "associatedtype actor async await class deinit enum extension fileprivate func import init inout internal let open operator private protocol public rethrows static struct subscript typealias var break case continue default defer do else fallthrough for guard if in repeat return switch where while as catch is super self Self throw throws try false true nil some Any Int Double String Bool Array Dictionary Optional", - kotlin: "abstract actual annotation as break by catch class companion const constructor continue crossinline data delegate do dynamic else enum external false final finally for fun get if import in infix init inline inner interface internal is lateinit noinline null object open operator out override package private protected public reified return sealed set super suspend tailrec this throw true try typealias typeof val var vararg when where while Int Long Double Float String Boolean List Map", - scala: "abstract case catch class def do else extends false final finally for forSome given if implicit import lazy match new null object override package private protected return sealed super this throw trait try true type using val var while with yield enum export then Int Long Double String Boolean List Map Option Some None", - php: "abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum extends final finally fn for foreach function global goto if implements include include_once instanceof insteadof interface isset list match namespace new or print private protected public readonly require require_once return static switch throw trait try unset use var while xor yield true false null self parent int float string bool void mixed object", - dart: "abstract as assert async await break case catch class const continue covariant default deferred do dynamic else enum export extends extension external factory false final finally for get hide if implements import in interface is late library mixin new null on operator part required rethrow return set show static super switch sync this throw true try typedef var void while with yield int double String bool List Map Set Future Stream", - objc: "auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while bool true false NULL id Class SEL IMP BOOL YES NO nil Nil self super instancetype nonatomic atomic strong weak copy assign retain readonly readwrite nonnull nullable NSString NSObject NSArray NSInteger NSUInteger", - d: "abstract alias align asm assert auto body bool break byte case cast catch cdouble cent cfloat char class const continue creal dchar debug default delegate delete deprecated do double else enum export extern false final finally float for foreach function goto idouble if ifloat immutable import in inout int interface invariant ireal is lazy long macro mixin module new nothrow null out override package pragma private protected public pure real ref return scope shared short static struct super switch synchronized template this throw true try typeid typeof ubyte uint ulong union unittest ushort version void wchar while with string", - zig: "addrspace align allowzero and anyframe anytype asm async await break callconv catch comptime const continue defer else enum errdefer error export extern fn for if inline noalias noinline nosuspend opaque or orelse packed pub resume return struct suspend switch test threadlocal try union unreachable usingnamespace var volatile while bool void type u8 u16 u32 u64 usize i8 i16 i32 i64 isize f16 f32 f64 f128 comptime_int comptime_float true false null undefined", - py: "False None True and as assert async await break class continue def del elif else except finally for from global if import in is lambda match nonlocal not or pass raise return try while with yield self cls print range len int str float bool list dict set tuple bytes object Exception", - sh: "if then elif else fi for while until do done case esac in function select return local export readonly declare typeset unset shift eval exec source break continue trap set getopts read echo printf test cd pushd popd", - ruby: "alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true undef unless until when while yield require require_relative attr_accessor attr_reader attr_writer include extend private public protected new puts print lambda proc", - perl: "if elsif else unless while until for foreach do sub return last next redo goto my our local use require package no and or not eq ne lt gt le ge cmp print printf say chomp chop split join map grep keys values each shift unshift push pop defined undef ref bless wantarray qw", - r: "if else repeat while function for in next break TRUE FALSE NULL Inf NaN NA return library require", - yaml: "true false null yes no on off True False Null Yes No On Off", - toml: "true false", - ini: "true false yes no on off", - make: "ifeq ifneq ifdef ifndef else endif define endef include export unexport override vpath and or foreach filter wildcard patsubst subst shell call eval error warning info notdir dir basename suffix addprefix addsuffix", - docker: "FROM RUN CMD LABEL MAINTAINER EXPOSE ENV ADD COPY ENTRYPOINT VOLUME USER WORKDIR ARG ONBUILD STOPSIGNAL HEALTHCHECK SHELL AS", - lua: "and break do else elseif end false for function goto if in local nil not or repeat return then true until while self require print pairs ipairs tostring tonumber type setmetatable getmetatable pcall error assert table string math io os", - sql: "select from where insert into values update set delete create table drop alter add column index view join inner outer left right full on as distinct group by order having limit offset union all and or not null is in like between exists case when then else end asc desc primary key foreign references default unique check constraint int integer varchar char text date datetime timestamp boolean float double decimal begin commit rollback transaction grant revoke with returning true false", - haskell: "case class data default deriving do else foreign if import in infix infixl infixr instance let module newtype of then type where as qualified hiding forall family Int Integer Float Double Char String Bool Maybe Just Nothing Either Left Right IO map filter foldr foldl", - lisp: "defun defvar defparameter defmacro defconstant let let* lambda if cond case when unless progn setq setf quote function list cons car cdr append mapcar reduce and or not nil t define set begin delay force define-syntax syntax-rules require provide ns defn def fn loop recur do var true false", - scss: "if else for each while function return extend use import forward media content mixin include null true false and or not in from through to default important", - json: "true false null", - ps: "if else elseif switch foreach for while do until break continue function return param begin process end try catch finally throw class enum filter workflow in trap exit true false null" -}; - -var LANGS = { - c: def(KW.c, P.c), cpp: def(KW.cpp, P.c), cs: def(KW.cs, P.c), - java: def(KW.java, P.c), js: def(KW.js, P.tick), ts: def(KW.ts, P.tick), - go: def(KW.go, P.c), rust: def(KW.rust, P.c), swift: def(KW.swift, P.c), - kotlin: def(KW.kotlin, P.c), scala: def(KW.scala, P.c), - php: def(KW.php, { line: [ "//", "#" ], block: [ "/*", "*/" ], str: "\"'" }), - dart: def(KW.dart, P.c), objc: def(KW.objc, P.c), d: def(KW.d, P.c), - zig: def(KW.zig, { line: "//", str: "\"" }), - py: def(KW.py, P.py), sh: def(KW.sh, P.hash), ruby: def(KW.ruby, P.hash), - perl: def(KW.perl, P.hash), r: def(KW.r, P.hash), yaml: def(KW.yaml, P.hash), - toml: def(KW.toml, P.hash), ini: def(KW.ini, P.ini), - make: def(KW.make, P.hash), docker: def(KW.docker, P.hash), - lua: def(KW.lua, P.lua), sql: def(KW.sql, P.sql), - haskell: def(KW.haskell, P.haskell), lisp: def(KW.lisp, P.lisp), - css: def("", P.css), scss: def(KW.scss, P.scss), json: def(KW.json, P.json), - ps: def(KW.ps, P.ps) -}; - -/* file extension (or bare filename), lowercased, -> language id */ -var EXT = { - c: "c", h: "c", cc: "cpp", cpp: "cpp", cxx: "cpp", hpp: "cpp", hh: "cpp", - hxx: "cpp", ipp: "cpp", cs: "cs", java: "java", - js: "js", mjs: "js", cjs: "js", jsx: "js", - ts: "ts", tsx: "ts", mts: "ts", cts: "ts", - go: "go", rs: "rust", swift: "swift", kt: "kotlin", kts: "kotlin", - scala: "scala", sc: "scala", php: "php", phtml: "php", - dart: "dart", m: "objc", mm: "objc", d: "d", di: "d", zig: "zig", - py: "py", pyw: "py", pyi: "py", - sh: "sh", bash: "sh", zsh: "sh", ksh: "sh", bashrc: "sh", zshrc: "sh", - rb: "ruby", gemspec: "ruby", rake: "ruby", - pl: "perl", pm: "perl", r: "r", - yaml: "yaml", yml: "yaml", toml: "toml", - ini: "ini", cfg: "ini", conf: "ini", - makefile: "make", mk: "make", mak: "make", dockerfile: "docker", - lua: "lua", sql: "sql", hs: "haskell", - lisp: "lisp", cl: "lisp", el: "lisp", clj: "lisp", cljs: "lisp", - cljc: "lisp", scm: "lisp", rkt: "lisp", - css: "css", scss: "scss", sass: "scss", less: "scss", - json: "json", jsonc: "json", - ps1: "ps", psm1: "ps", psd1: "ps", - /* language names as they appear in markdown code fences */ - python: "py", ruby: "ruby", rust: "rust", golang: "go", cpp: "cpp", - "c++": "cpp", csharp: "cs", "c#": "cs", javascript: "js", - typescript: "ts", kotlin: "kotlin", haskell: "haskell", shell: "sh" -}; - -function words(s, ci) { - var d = Object.create(null), a = s.split(/\s+/), i, w; - for (i = 0; i < a.length; i++) { - w = a[i]; - if (w) - d[ci ? w.toLowerCase() : w] = true; - } - return d; -} - -function norm(v) { - return v == null ? [] : (typeof v === "string" ? [ v ] : v); -} - -function def(kw, p) { - return { - kw: words(kw || "", p.ci), ci: !!p.ci, - line: norm(p.line), bs: p.block ? p.block[0] : null, - be: p.block ? p.block[1] : null, str: p.str || "", - tick: p.tick || "", tstr: norm(p.tstr) - }; -} - -var reWord = /[A-Za-z_$][\w$]*/y; -var reNum = /(?:0[xX][0-9a-fA-F]+|0[bB][01]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)[fFlLuU]*/y; - -function esc(s) { - return s.replace(/&/g, "&").replace(//g, ">"); -} - -function span(cls, s) { - return "" + esc(s) + ""; -} - -function tokenize(text, cfg) { - var out = "", i = 0, n = text.length, c, j, k, m, t, multi; - while (i < n) { - c = text.charAt(i); - if (cfg.bs && text.startsWith(cfg.bs, i)) { - j = text.indexOf(cfg.be, i + cfg.bs.length); - j = j < 0 ? n : j + cfg.be.length; - out += span("comment", text.slice(i, j)); i = j; continue; - } - for (k = 0; k < cfg.line.length; k++) - if (text.startsWith(cfg.line[k], i)) { - j = text.indexOf("\n", i); if (j < 0) j = n; - out += span("comment", text.slice(i, j)); i = j; break; - } - if (k < cfg.line.length) continue; - for (k = 0; k < cfg.tstr.length; k++) { - t = cfg.tstr[k]; - if (text.startsWith(t, i)) { - j = text.indexOf(t, i + t.length); - j = j < 0 ? n : j + t.length; - out += span("string", text.slice(i, j)); i = j; break; - } - } - if (k < cfg.tstr.length) continue; - if (cfg.str.indexOf(c) >= 0) { - multi = cfg.tick.indexOf(c) >= 0; - j = i + 1; - while (j < n) { - if (text.charAt(j) === "\\") { j += 2; continue; } - if (text.charAt(j) === c) { j++; break; } - if (text.charAt(j) === "\n" && !multi) break; - j++; - } - out += span("string", text.slice(i, j)); i = j; continue; - } - if (c >= "0" && c <= "9") { - reNum.lastIndex = i; - m = reNum.exec(text); - if (m) { out += span("number", m[0]); i += m[0].length; continue; } - } - reWord.lastIndex = i; - m = reWord.exec(text); - if (m) { - t = cfg.ci ? m[0].toLowerCase() : m[0]; - out += cfg.kw[t] ? span("keyword", m[0]) : esc(m[0]); - i += m[0].length; continue; - } - out += esc(c); i++; - } - return out; -} - -document.addEventListener("DOMContentLoaded", function () { - var nodes = document.querySelectorAll("div#cgit code[data-lang]"), i, el, cfg, text; - for (i = 0; i < nodes.length; i++) { - el = nodes[i]; - cfg = LANGS[EXT[(el.getAttribute("data-lang") || "").toLowerCase()]]; - if (!cfg) - continue; - text = el.textContent; - if (!text || text.length > MAX_BYTES) - continue; - try { - el.innerHTML = tokenize(text, cfg); - } catch (e) { - /* leave the plain text untouched on any failure */ - } - } -}, false); - -})(); diff --git a/cgitrc.5.txt b/cgitrc.5.txt index e56cbae..a456ddf 100644 --- a/cgitrc.5.txt +++ b/cgitrc.5.txt @@ -474,9 +474,10 @@ source-filter:: and the name of the blob as its only command line argument. The STDOUT from the command will be included verbatim as the blob contents, i.e. this can be used to implement e.g. syntax highlighting. When no - source-filter is configured, cgit highlights common languages in the - browser with the bundled cgit.js, so a filter is only needed to - override that. Default value: none. See also: "FILTER API". + source-filter is configured, cgit serves the text plain. A ready + syntax highlighter using the Scintillua lexer collection ships as + extensions/syntax-highlight.lua, see the comments in that file for + its dependencies. Default value: none. See also: "FILTER API". summary-branches:: Specifies the number of branches to display in the repository "summary" @@ -951,8 +952,9 @@ mimetype.png=image/png mimetype.svg=image/svg+xml -# Source code is highlighted in the browser by the bundled cgit.js, so no -# source-filter is needed here. Set one only to override that. +# Source code is served plain unless a source-filter is set. The shipped +# extensions/syntax-highlight.lua highlights through the Scintillua lexers. +# source-filter=lua:/usr/share/cgit/extensions/syntax-highlight.lua # Markdown about pages render client-side by default (see enable-markdown). # For other formats such as manpages, point about-filter at your own script. diff --git a/examples/cgitrc b/examples/cgitrc index 62b10b5..e049d6c 100644 --- a/examples/cgitrc +++ b/examples/cgitrc @@ -11,9 +11,9 @@ # directory, or list repositories by hand in the per-repository section at # the end of this file. # -# In this fork, markdown readmes and source syntax highlighting are built in -# and rendered in the browser, so you need no about-filter or source-filter -# for them. +# In this fork, markdown readmes are rendered in the browser, so no +# about-filter is needed for them. Source syntax highlighting is optional +# and ships as a source-filter, see the filter section below. # # One key=value pair per line. Lines starting with # are comments. @@ -303,10 +303,13 @@ enable-filter-overrides=0 # with exec or lua. Default is none. #owner-filter=exec:/path/to/your-command -# Filter command used to format plaintext blobs in the tree view. Syntax -# highlighting is already built in, so set this only to replace it. Value is -# a command optionally prefixed with exec or lua. Default is none. -#source-filter=exec:/path/to/your-command +# Filter command used to format plaintext blobs in the tree view. Without it +# cgit serves the text plain. For syntax highlighting, the shipped filter +# below uses the Scintillua lexers and needs the lpeg module installed too, +# on Debian that is "apt install lua-lpeg". Missing either dependency means +# plain uncolored text, not an error. See the comments in that file. Value +# is a command optionally prefixed with exec or lua. Default is none. +#source-filter=lua:/usr/share/cgit/extensions/syntax-highlight.lua # Filter command invoked to authenticate access, gating repositories behind a # login. See extensions/auth-inline.lua and extensions/auth-file.lua. Value @@ -564,4 +567,4 @@ noplainemail=0 # Per-repo override of the owner-filter. Allowed in a repo cgitrc only when # enable-filter-overrides is 1. Value is a command. Default is the global # owner-filter value. -#repo.owner-filter=exec:/path/to/your-command \ No newline at end of file +#repo.owner-filter=exec:/path/to/your-command 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) +-- /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 diff --git a/source/ui-tree.c b/source/ui-tree.c index d3b3a30..df7e9e5 100644 --- a/source/ui-tree.c +++ b/source/ui-tree.c @@ -30,7 +30,6 @@ struct walk_tree_context { static void print_text_buffer(const char *name, char *buf, unsigned long size) { unsigned long lineno, idx; - const char *ext; const char *numberfmt = "%1$d\n"; html("\n"); @@ -68,13 +67,10 @@ static void print_text_buffer(const char *name, char *buf, unsigned long size) return; } - /* No source filter is configured, so tag the code with its file - * extension (or bare name, for Makefile and the like) and let - * cgit.js highlight it client-side. */ - ext = strrchr(name, '.'); - html("
");
+	/* No source filter is configured, so serve the text plain. Syntax
+	 * highlighting ships as an optional source filter in extensions/,
+	 * keeping language knowledge out of the core. */
+	html("
");
 	html_txt(buf);
 	html("
\n"); } diff --git a/tests/t0201-limits.sh b/tests/t0201-limits.sh index b7d82de..1ab3ce6 100755 --- a/tests/t0201-limits.sh +++ b/tests/t0201-limits.sh @@ -3,6 +3,10 @@ test_description='Check the ref listing and diff size limits' . ./setup.sh +if [ $CGIT_HAS_LUA -eq 1 ]; then + test_set_prereq LUA +fi + # A repo with several branches and tags, one commit with an oversized file # diff beside a small one, and configs that pin tiny limits. test_expect_success 'set up limit fixtures' ' @@ -96,4 +100,18 @@ test_expect_success 'the single-file page is not limited by max-diff-files' ' grep "class=.hunk." tmp ' +# --- The shipped highlight filter degrades to escaped passthrough ----------- +test_expect_success LUA 'highlight filter passes text through without scintillua' ' + { + echo "virtual-root=/" && + echo "cache-size=0" && + echo "source-filter=lua:$(cd ../../extensions && pwd)/syntax-highlight.lua" && + echo "repo.url=limits" && + echo "repo.path=$PWD/repos/limits/.git" + } >hlrc && + CGIT_SCINTILLUA_PATH=/nonexistent CGIT_CONFIG="$PWD/hlrc" \ + QUERY_STRING="url=limits/tree/small.c" cgit >tmp && + grep "int y;" tmp +' + test_done -- cgit v2.8.0