From cbcf1476fe0844712412e7fa72a04a7c5e03b64c Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Sat, 16 May 2026 16:25:18 +0300 Subject: [PATCH] highlight: add stateless single-line syntax highlighter for diffs highlight_text is line-stateful (multi-line strings, block comments) and not appropriate for diff rendering where each line stands alone. Add highlight_line that handles one line at a time and a render_diff_line template helper, then wire it into the PR file diff view. Style the resulting b/u/i tokens through .pr-diff__content. --- diff.v | 9 ++++ highlight/highlight.v | 100 ++++++++++++++++++++++++++++++++++++++ static/css/gitly.scss | 24 ++++++++- templates/pull_files.html | 2 +- 4 files changed, 132 insertions(+), 3 deletions(-) diff --git a/diff.v b/diff.v index 10a2668..e33f16b 100644 --- a/diff.v +++ b/diff.v @@ -2,6 +2,15 @@ // Use of this source code is governed by a GPL license that can be found in the LICENSE file. module main +import veb +import highlight + +// render_diff_line is a template helper that returns the diff line's +// content with single-line syntax highlighting applied. +fn render_diff_line(content string, file_path string) veb.RawHtml { + return veb.RawHtml(highlight.highlight_line(content, file_path)) +} + struct FileDiff { mut: path string diff --git a/highlight/highlight.v b/highlight/highlight.v index 0503711..cd4e7de 100644 --- a/highlight/highlight.v +++ b/highlight/highlight.v @@ -133,6 +133,106 @@ pub fn highlight_text(st string, file_path string, commit bool) (string, int, in return res.bytestr(), lines, sloc } +// highlight_line returns HTML-escaped, syntax-highlighted markup for a +// single line of source code. It is stateless across calls (does not +// track multi-line strings or block comments), so it suits diff rendering +// where each line is colored independently. +pub fn highlight_line(content string, file_path string) string { + if content.len == 0 { + return '' + } + lang := extension_to_lang(file_path) or { return escape_html(content) } + lc := lang.line_comments + mut mlc := '' + if lang.mline_comments.len >= 2 { + mlc = lang.mline_comments[0] + } + runes := content.bytes() + mut res := []u8{cap: runes.len + 16} + mut in_string := false + mut ss := u8(` `) + mut in_line_comment := false + for pos := 0; pos < runes.len; pos++ { + mut c := runes[pos] + if in_line_comment { + res << write(c) + continue + } + if in_string { + res << write(c) + if pos > 0 && runes[pos - 1] == `\\` && ss == `"` { + continue + } + if c == ss { + in_string = false + res << ''.bytes() + } + continue + } + if is_letter(c, lang) { + word_start := pos + for pos < runes.len && is_letter(runes[pos], lang) { + pos++ + } + w := runes[word_start..pos].bytestr() + pos-- + if w in lang.keywords { + res << ''.bytes() + res << w.bytes() + res << ''.bytes() + } else { + res << w.bytes() + } + continue + } + if is_string_token(c, lang) { + in_string = true + ss = c + res << ''.bytes() + res << write(c) + continue + } + if mlc != '' && c == mlc[0] && pos + mlc.len <= runes.len + && is_line_comment(runes, pos, mlc) { + in_line_comment = true + res << ''.bytes() + res << write(c) + continue + } + if lc != '' && c == lc[0] && pos + lc.len <= runes.len && is_line_comment(runes, pos, lc) { + in_line_comment = true + res << ''.bytes() + res << write(c) + continue + } + res << write(c) + } + if in_line_comment { + res << ''.bytes() + } + if in_string { + res << ''.bytes() + } + return res.bytestr() +} + +fn escape_html(s string) string { + mut res := []u8{cap: s.len} + for i in 0 .. s.len { + c := s[i] + if c == `<` { + res << '<'.bytes() + } else if c == `>` { + res << '>'.bytes() + } else if c == `&` { + res << '&'.bytes() + } else { + res << c + } + } + return res.bytestr() +} + fn write(c u8) []u8 { mut tmp := []u8{} if c == `<` { diff --git a/static/css/gitly.scss b/static/css/gitly.scss index b11ed51..1aedd46 100644 --- a/static/css/gitly.scss +++ b/static/css/gitly.scss @@ -1796,11 +1796,13 @@ form { background-color: $white; } -.pr-diff__hunk-header td { +.pr-diff__hunk-header td, +.pr-diff__hunk-header td code { background-color: #ddf4ff; color: #57606a; padding: 4px 12px; font-size: 12px; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; } .pr-diff__row td { @@ -1808,6 +1810,8 @@ form { white-space: pre; vertical-align: top; line-height: 1.5; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; } .pr-diff__row--context td { background-color: $white; } @@ -1833,9 +1837,25 @@ form { pre { margin: 0; - font-family: inherit; + font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; white-space: pre; } + + b { + font-weight: 600; + color: #cf222e; + } + + u { + text-decoration: none; + color: #0a3069; + } + + i { + font-style: normal; + color: #6e7781; + } } .pr-diff__commentrow td { diff --git a/templates/pull_files.html b/templates/pull_files.html index cc9d6fa..62a9af5 100644 --- a/templates/pull_files.html +++ b/templates/pull_files.html @@ -59,7 +59,7 @@ @{dline.old_line_str()} @{dline.new_line_str()} @{dline.sign()} -
@dline.content
+
@{render_diff_line(dline.content, fd.path)}
@{render_inline_comments(fd.path, dline, comments_by_key)} @if ctx.logged_in && pr.is_open() && dline.kind != 'context' -- 2.39.5