package main
import (
"bytes"
"context"
"crypto/sha256"
"errors"
"fmt"
"html/template"
"net/http"
"net/url"
"slices"
"strconv"
"strings"
"sync"
"unicode/utf8"
"github.com/alecthomas/chroma/v2"
)
// A commit page shows its plain diff at once and its semantic diff after:
// the declarations the commit added, removed, changed, renamed, moved or
// only reformatted, from the syntax helper, and within each changed run of
// lines the tokens that changed, from the lexers. A commit never changes,
// so the semantic diff is kept under the commit's full hash, and a later
// view renders it inline.
// decl is one declaration in one version of a file.
type decl struct {
name string // qualified, eg tree.serve or Parser.parse
bare string // its last part, eg serve
sr, er int // rows from 0, leading comments included
raw [32]byte
sum [32]byte // the tokens, whitespace dropped
shape [32]byte // the tokens with the bare name blanked, to find renames
}
// declChange is what a commit did to one declaration.
type declChange struct {
what []string // eg added, or moved and changed
name string
was string // the old name of a rename, or ""
came string // the file a move between files came from, or ""
file string // the file it lies in, after the commit or, if removed, before
sr int // rows from 0 in that version, for the link
er int
gone bool // removed: the rows are in the parent
}
// semantic is a commit's semantic diff, as the cache keeps it.
type semantic struct {
changes []declChange
marks map[string]map[int][][2]int // by file, by diff line: changed byte ranges
note string // why declarations are missing, or ""
}
// maxDecls bounds the declarations compared by order in one file.
const maxDecls = 2000
// maxTokenGrid bounds the cells of one token comparison, so a large run
// of changed lines gets no marks rather than a slow page.
const maxTokenGrid = 1 << 20
// declKind reports whether a node of this kind declares something, if it
// also has a name.
func declKind(kind string) bool {
for _, s := range []string{"_declaration", "_definition", "_item", "_spec", "_signature", "_declarator"} {
if strings.HasSuffix(kind, s) {
return true
}
}
switch kind {
case "method", "singleton_method", "class", "module":
return true
}
return false
}
// containerKind reports whether declarations inside a node of this kind
// are members, eg a class's methods, rather than locals.
func containerKind(kind string) bool {
for _, s := range []string{"class", "struct", "interface", "trait", "module", "namespace", "impl", "object", "enum"} {
if strings.Contains(kind, s) {
return true
}
}
return false
}
// scopeKind reports whether a node of this kind is a function without a
// name, whose declarations are locals, eg a callback's variables.
func scopeKind(kind string) bool {
return strings.Contains(kind, "function") || strings.Contains(kind, "lambda") || strings.Contains(kind, "closure") || kind == "func_literal"
}
// declarations finds the declarations of src, named in name, with the
// helper's syntax tree. Locals, such as a function's variables, are left
// out; a member is qualified by its container, eg Parser.parse.
func declarations(ctx context.Context, name string, src []byte) ([]decl, error) {
nodes, err := syntaxNodes(ctx, name, src)
if err != nil {
return nil, err
}
lines := strings.Split(string(src), "\n")
text := func(n synNode) string {
if n.sr != n.er || n.sr >= len(lines) || n.ec > len(lines[n.sr]) || n.sc > n.ec {
return ""
}
return strings.TrimSpace(lines[n.sr][n.sc:n.ec])
}
parent := make([]int, len(nodes))
children := make([][]int, len(nodes))
var stack []int
for i, n := range nodes {
stack = stack[:min(n.depth, len(stack))]
parent[i] = -1
if len(stack) > 0 {
parent[i] = stack[len(stack)-1]
children[parent[i]] = append(children[parent[i]], i)
}
stack = append(stack, i)
}
child := func(i int, field string) int {
for _, c := range children[i] {
if nodes[c].field == field {
return c
}
}
return -1
}
first := func(i int, kind string) int {
for j := i + 1; j < len(nodes) && nodes[j].depth > nodes[i].depth; j++ {
if nodes[j].kind == kind {
return j
}
}
return -1
}
// nameOf is a declaration's own name, or "" when the node names
// nothing. C's functions name themselves through declarators.
nameOf := func(i int) string {
if !declKind(nodes[i].kind) {
return ""
}
if c := child(i, "name"); c >= 0 {
return text(nodes[c])
}
if nodes[i].kind != "function_definition" {
return ""
}
for c := child(i, "declarator"); c >= 0; c = child(c, "declarator") {
switch nodes[c].kind {
case "identifier", "field_identifier", "qualified_identifier", "destructor_name", "operator_name":
return text(nodes[c])
}
}
return ""
}
var out []decl
seen := map[string]int{}
for i, n := range nodes {
bare := nameOf(i)
if bare == "" {
continue
}
// A member takes its containers' names; a declaration inside
// anything else that declares is a local.
var quals []string
local := false
for p := parent[i]; p >= 0 && !local; p = parent[p] {
switch {
case nameOf(p) == "" && scopeKind(nodes[p].kind):
// A function called where it is written, eg the
// (() => { ... })() around a whole script, scopes
// nothing a reader would call local.
q := parent[p]
iife := q >= 0 && nodes[q].kind == "parenthesized_expression" && parent[q] >= 0 && nodes[parent[q]].kind == "call_expression"
local = !iife
case nodes[p].kind == "impl_item":
if c := child(p, "type"); c >= 0 {
quals = append(quals, text(nodes[c]))
}
case nameOf(p) != "":
if containerKind(nodes[p].kind) {
quals = append(quals, nameOf(p))
} else {
local = true
}
}
}
if local {
continue
}
if n.kind == "method_declaration" {
if c := child(i, "receiver"); c >= 0 {
if t := first(c, "type_identifier"); t >= 0 {
quals = append(quals, text(nodes[t]))
}
}
}
slices.Reverse(quals)
qual := strings.Join(append(quals, bare), ".")
if seen[qual]++; seen[qual] > 1 {
qual += "#" + strconv.Itoa(seen[qual])
}
// A decorated definition starts at its decorators, and comments
// right above a declaration belong to it.
top := i
if p := parent[i]; p >= 0 && nodes[p].kind == "decorated_definition" {
top = p
}
sr, er := nodes[top].sr, n.er
if n.ec == 0 && er > n.sr {
er--
}
if p := parent[top]; p >= 0 {
sib := children[p]
for k := slices.Index(sib, top) - 1; k >= 0; k-- {
c := nodes[sib[k]]
if !strings.Contains(c.kind, "comment") || c.er != sr-1 && c.er != sr {
break
}
sr = c.sr
}
}
er = min(er, len(lines)-1)
body := strings.Join(lines[sr:er+1], "\n")
toks := codeTokens(name, body)
d := decl{name: qual, bare: bare, sr: sr, er: er, raw: sha256.Sum256([]byte(body))}
d.sum = tokenSum(toks, "")
d.shape = tokenSum(toks, bare)
out = append(out, d)
}
return out, nil
}
// codeToken is one token of code that is not whitespace, and where it
// lies in the text lexed.
type codeToken struct {
text string
start, end int
}
// codeTokens lexes text as the file name says and drops whitespace, so
// layout alone changes nothing.
func codeTokens(name, text string) []codeToken {
var out []codeToken
at := 0
for _, t := range tokenise(codeLexer(name), text) {
v := t.Value
if strings.TrimSpace(v) != "" && t.Type != chroma.TextWhitespace {
out = append(out, codeToken{v, at, at + len(v)})
}
at += len(v)
}
return out
}
// tokenSum hashes tokens, with blank, when given, in place of every
// token equal to it.
func tokenSum(toks []codeToken, blank string) [32]byte {
h := sha256.New()
for _, t := range toks {
if blank != "" && t.text == blank {
h.Write([]byte{1})
} else {
h.Write([]byte(t.text))
}
h.Write([]byte{0})
}
var s [32]byte
h.Sum(s[:0])
return s
}
// compareDecls says what changed between the declarations of one file
// before and after a commit. It returns what was added and removed apart,
// so moves between files can be matched once every file is read.
func compareDecls(file string, old, cur []decl) (changes []declChange, added, removed []decl) {
oldBy := map[string]decl{}
for _, d := range old {
oldBy[d.name] = d
}
curBy := map[string]bool{}
var common []string
for _, d := range cur {
curBy[d.name] = true
o, ok := oldBy[d.name]
switch {
case !ok:
added = append(added, d)
continue
case o.raw == d.raw:
case o.sum == d.sum:
changes = append(changes, declChange{what: []string{"reformatted"}, name: d.name, file: file, sr: d.sr, er: d.er})
default:
changes = append(changes, declChange{what: []string{"changed"}, name: d.name, file: file, sr: d.sr, er: d.er})
}
common = append(common, d.name)
}
for _, d := range old {
if !curBy[d.name] {
removed = append(removed, d)
}
}
// A declaration whose place among the others changed moved, eg a
// function cut and pasted lower down.
if len(common) <= maxDecls {
var before []string
for _, d := range old {
if curBy[d.name] {
before = append(before, d.name)
}
}
stay := map[string]bool{}
for _, p := range lcs(before, common) {
stay[before[p[0]]] = true
}
for _, n := range common {
if stay[n] {
continue
}
i := slices.IndexFunc(changes, func(c declChange) bool { return c.name == n })
if i >= 0 {
changes[i].what = append([]string{"moved"}, changes[i].what...)
continue
}
d := cur[slices.IndexFunc(cur, func(d decl) bool { return d.name == n })]
changes = append(changes, declChange{what: []string{"moved"}, name: n, file: file, sr: d.sr, er: d.er})
}
}
// A declaration gone under one name and new under another with the
// same tokens was renamed.
for i := 0; i < len(added); i++ {
a := added[i]
j := slices.IndexFunc(removed, func(r decl) bool { return r.shape == a.shape })
if j < 0 {
continue
}
changes = append(changes, declChange{what: []string{"renamed"}, name: a.name, was: removed[j].name, file: file, sr: a.sr, er: a.er})
removed = slices.Delete(removed, j, j+1)
added = slices.Delete(added, i, i+1)
i--
}
return changes, added, removed
}
// lcs returns the index pairs of a longest common subsequence of a and
// b, each an index into a and one into b.
func lcs(a, b []string) [][2]int {
n, m := len(a), len(b)
t := make([][]int32, n+1)
for i := range t {
t[i] = make([]int32, m+1)
}
for i := n - 1; i >= 0; i-- {
for j := m - 1; j >= 0; j-- {
if a[i] == b[j] {
t[i][j] = t[i+1][j+1] + 1
} else {
t[i][j] = max(t[i+1][j], t[i][j+1])
}
}
}
var out [][2]int
for i, j := 0, 0; i < n && j < m; {
switch {
case a[i] == b[j]:
out = append(out, [2]int{i, j})
i++
j++
case t[i+1][j] >= t[i][j+1]:
i++
default:
j++
}
}
return out
}
// tokenMarks finds, in a run of removed lines followed by a run of added
// ones, the tokens that changed, as byte ranges per line without the
// diff's leading sign. Runs that share under half their tokens get no
// marks, since marking nearly everything says nothing.
func tokenMarks(name string, del, add []string) (dm, am [][][2]int) {
strip := func(ls []string) string {
var b strings.Builder
for _, l := range ls {
b.WriteString(l[1:] + "\n")
}
return b.String()
}
dt, at := strip(del), strip(add)
dk, ak := codeTokens(name, dt), codeTokens(name, at)
if len(dk) == 0 || len(ak) == 0 || len(dk)*len(ak) > maxTokenGrid {
return nil, nil
}
dv, av := make([]string, len(dk)), make([]string, len(ak))
for i, t := range dk {
dv[i] = t.text
}
for i, t := range ak {
av[i] = t.text
}
common := lcs(dv, av)
if 2*len(common) < max(len(dk), len(ak)) {
return nil, nil
}
keptD, keptA := map[int]bool{}, map[int]bool{}
for _, p := range common {
keptD[p[0]], keptA[p[1]] = true, true
}
return lineRanges(dt, dk, keptD, len(del)), lineRanges(at, ak, keptA, len(add))
}
// lineRanges turns the tokens not kept into byte ranges per line of text,
// joining neighbours on one line that only spaces part.
func lineRanges(text string, toks []codeToken, kept map[int]bool, n int) [][][2]int {
out := make([][][2]int, n)
starts := []int{0}
for i := 0; i < len(text); i++ {
if text[i] == '\n' {
starts = append(starts, i+1)
}
}
for i, t := range toks {
if kept[i] {
continue
}
// A token may span lines, eg a long string; each line gets its
// part.
for s := t.start; s < t.end; {
line := lineOf(starts, s)
if line >= n {
break
}
lineEnd := starts[line+1] - 1
e := min(t.end, lineEnd)
if e > s {
from, to := s-starts[line], e-starts[line]
rs := out[line]
if k := len(rs); k > 0 && strings.TrimSpace(text[starts[line]+rs[k-1][1]:s]) == "" {
rs[k-1][1] = to
} else {
rs = append(rs, [2]int{from, to})
}
out[line] = rs
}
s = lineEnd + 1
}
}
return out
}
// lineOf returns the line whose start is the last at or before offset.
func lineOf(starts []int, offset int) int {
i, _ := slices.BinarySearch(starts, offset+1)
return i - 1
}
// diffMarks finds the changed tokens of one file's diff: in every hunk,
// each run of removed lines and the run of added lines after it. The
// result maps a line's index in lines to its ranges.
func diffMarks(name string, lines []string) map[int][][2]int {
marks := map[int][][2]int{}
for i := 0; i < len(lines); {
if !strings.HasPrefix(lines[i], "-") {
i++
continue
}
d := i
for i < len(lines) && strings.HasPrefix(lines[i], "-") {
i++
}
a := i
for i < len(lines) && strings.HasPrefix(lines[i], "+") {
i++
}
if a == i {
continue
}
dm, am := tokenMarks(name, lines[d:a], lines[a:i])
for k, r := range dm {
if len(r) > 0 {
marks[d+k] = r
}
}
for k, r := range am {
if len(r) > 0 {
marks[a+k] = r
}
}
}
return marks
}
// semanticCache keeps semantic diffs by commit, as gitCache keeps the
// plain ones; a parse costs a process start per file and version.
var semanticCache = newCache(8 << 20)
// semanticKey is where the semantic diff of the commit hash, limited to
// name, is kept, or "" when hash is not a full one.
func (t *tree) semanticKey(name, hash string) string {
if !fullHash(hash) {
return ""
}
return t.dir + "\x00" + t.prefix + "\x00" + name + "\x00" + hash + fmt.Sprintf("\x00%t", t.shared)
}
// semanticDiff reads each changed file before and after the commit and
// compares them. Files are read and parsed four at a time.
func (t *tree) semanticDiff(ctx context.Context, name, hash string, diffs []fileDiff) *semantic {
key := t.semanticKey(name, hash)
if key != "" {
if v, ok := semanticCache.get(key); ok {
return v.(*semantic)
}
}
sem := &semantic{marks: map[string]map[int][][2]int{}}
dir, _, err := t.gitPlace(name)
if err != nil {
return sem
}
type side struct {
decls []decl
err error
}
type result struct{ old, cur side }
results := make([]result, len(diffs))
read := func(rev, rel, file string) side {
if rel == "" {
return side{}
}
b, err := git(ctx, dir, "cat-file", "blob", rev+":./"+rel)
if err != nil || len(b) > maxCode || !isText(b) {
return side{}
}
ds, err := declarations(ctx, file, b)
return side{ds, err}
}
var wg sync.WaitGroup
slots := make(chan struct{}, 4)
for i, d := range diffs {
sem.marks[d.name] = diffMarks(d.name, d.lines)
wg.Add(1)
go func() {
defer wg.Done()
slots <- struct{}{}
defer func() { <-slots }()
results[i] = result{read(hash+"^", d.rel, d.name), read(hash, d.rel, d.name)}
}()
}
wg.Wait()
var added, removed []declChange
var addedDecls, removedDecls []decl
for i, d := range diffs {
r := results[i]
for _, e := range []error{r.old.err, r.cur.err} {
switch {
case e == nil, errors.Is(e, errNoGrammar):
case errors.Is(e, errNoHelper):
sem.note = "Declarations need " + progName + "-syntax, which is not installed; `make syntax` builds it."
default:
logErr("no declarations for %s at %s: %v", d.name, hash, e)
}
}
ch, add, rem := compareDecls(d.name, r.old.decls, r.cur.decls)
sem.changes = append(sem.changes, ch...)
for _, a := range add {
added = append(added, declChange{what: []string{"added"}, name: a.name, file: d.name, sr: a.sr, er: a.er})
addedDecls = append(addedDecls, a)
}
for _, x := range rem {
removed = append(removed, declChange{what: []string{"removed"}, name: x.name, file: d.name, sr: x.sr, er: x.er, gone: true})
removedDecls = append(removedDecls, x)
}
}
// A declaration removed from one file and added to another under the
// same name, or with the same tokens, moved there.
for i := 0; i < len(added); i++ {
a := addedDecls[i]
j := -1
for k, x := range removedDecls {
if removed[k].file != added[i].file && (x.name == a.name || x.shape == a.shape) {
j = k
break
}
}
if j < 0 {
continue
}
x := removedDecls[j]
c := added[i]
c.what, c.came = []string{"moved"}, removed[j].file
switch {
case x.name != a.name:
c.what = append(c.what, "renamed")
c.was = x.name
case x.raw == a.raw:
case x.sum == a.sum:
c.what = append(c.what, "reformatted")
default:
c.what = append(c.what, "changed")
}
sem.changes = append(sem.changes, c)
added = slices.Delete(added, i, i+1)
addedDecls = slices.Delete(addedDecls, i, i+1)
removed = slices.Delete(removed, j, j+1)
removedDecls = slices.Delete(removedDecls, j, j+1)
i--
}
sem.changes = append(append(sem.changes, added...), removed...)
// Each file's changes keep the order of the diff, and within a file,
// the order of the rows.
order := map[string]int{}
for i, d := range diffs {
order[d.name] = i
}
slices.SortStableFunc(sem.changes, func(a, b declChange) int {
if a.file != b.file {
return order[a.file] - order[b.file]
}
return a.sr - b.sr
})
if key != "" && ctx.Err() == nil {
size := int64(256)
for _, c := range sem.changes {
size += int64(len(c.name)+len(c.was)+len(c.came)+len(c.file)) + 96
}
for _, m := range sem.marks {
size += int64(len(m)) * 48
}
semanticCache.put(key, sem, size)
}
return sem
}
// semanticHTML renders the declarations a commit changed, one row each
// under its file's name. A row links the declaration's own page, below
// the commit page at page.
func (t *tree) semanticHTML(page string, c *commit, sem *semantic) string {
esc := template.HTMLEscapeString
var b strings.Builder
b.WriteString("<div class=\"semantic\">\n")
if sem.note != "" {
b.WriteString("<p class=\"own\">" + esc(sem.note) + "</p>\n")
}
file := ""
for _, ch := range sem.changes {
if ch.file != file {
if file != "" {
b.WriteString("</ul>\n")
}
file = ch.file
b.WriteString("<p class=\"own file\">" + esc(file) + "</p>\n<ul class=\"decls\">\n")
}
fmt.Fprintf(&b, "<li><span class=\"what\">%s</span> <a href=\"%s\">%s</a>%s</li>\n",
esc(strings.Join(ch.what, ", ")), esc(declURL(page, c.hash, ch.file, ch.name)), esc(ch.name), esc(ch.whence()))
}
if file != "" {
b.WriteString("</ul>\n")
}
b.WriteString("</div>\n")
return b.String()
}
// whence says where a moved or renamed declaration came from, eg
// " from x.go, was Old", or "".
func (ch declChange) whence() string {
var s string
if ch.came != "" {
s += " from " + ch.came
}
if ch.was != "" {
s += ", was " + ch.was
}
if ch.came == "" {
s = strings.TrimPrefix(s, ",")
}
return s
}
// declURL is the page of the declaration name in file, as the commit
// hash changed it, below the commit page at page.
func declURL(page, hash, file, name string) string {
q := url.Values{"commit": {hash}, "file": {file}, "decl": {name}}
return (&url.URL{Path: page, RawQuery: q.Encode()}).String()
}
// declText reads the declaration name in file, at git path rel, as of
// rev: its lines and its rows from 0.
func declText(ctx context.Context, dir, rev, rel, file, name string) (lines []string, sr, er int, err error) {
b, err := git(ctx, dir, "cat-file", "blob", rev+":./"+rel)
if err != nil {
return nil, 0, 0, err
}
ds, err := declarations(ctx, file, b)
if err != nil {
return nil, 0, 0, err
}
i := slices.IndexFunc(ds, func(d decl) bool { return d.name == name })
if i < 0 {
return nil, 0, 0, errors.New("no " + name + " in " + file)
}
all := strings.Split(string(b), "\n")
return all[ds[i].sr : ds[i].er+1], ds[i].sr, ds[i].er, nil
}
// lineDiff is the diff of two texts as lines with git's leading signs:
// a space for a kept line, - for a removed one and + for an added one.
func lineDiff(old, cur []string) []string {
var out []string
i, j := 0, 0
for _, p := range lcs(old, cur) {
for ; i < p[0]; i++ {
out = append(out, "-"+old[i])
}
for ; j < p[1]; j++ {
out = append(out, "+"+cur[j])
}
out = append(out, " "+old[i])
i, j = i+1, j+1
}
for ; i < len(old); i++ {
out = append(out, "-"+old[i])
}
for ; j < len(cur); j++ {
out = append(out, "+"+cur[j])
}
return out
}
// serveDecl shows one declaration as the commit hash changed it: what
// happened to it, links to its lines before and after where the tree
// has versions, and a diff of the declaration alone.
func (t *tree) serveDecl(w http.ResponseWriter, r *http.Request, name string, dir bool, hash, file, declName string) {
ctx := r.Context()
crumbs := t.pageCrumbs(name, dir)
crumbs = append(crumbs, crumb{Label: logLabel, Href: crumbs[len(crumbs)-1].Href + "?log"})
title := declName + " in git show " + hash
fail := func(status int, err error) {
t.render(w, r, status, page{Title: title, Crumbs: append(crumbs, crumb{Label: declName}), Kind: "error",
Body: template.HTML("<pre class=\"error\">" + template.HTMLEscapeString(err.Error()) + "</pre>")})
}
if !hexHash.MatchString(hash) {
fail(http.StatusNotFound, errors.New("Not a commit hash: "+hash))
return
}
c, diffs, _, err := t.gitShow(ctx, name, hash)
if err != nil {
fail(http.StatusNotFound, err)
return
}
commitPage := (&url.URL{Path: r.URL.Path, RawQuery: "commit=" + c.hash}).String()
crumbs = append(crumbs, crumb{Label: c.short, Href: commitPage}, crumb{Label: declName})
sem := t.semanticDiff(ctx, name, c.hash, diffs)
i := slices.IndexFunc(sem.changes, func(ch declChange) bool { return ch.file == file && ch.name == declName })
if i < 0 {
fail(http.StatusNotFound, fmt.Errorf("%s does not change %s in %s", c.short, declName, file))
return
}
ch := sem.changes[i]
rels := map[string]string{}
for _, d := range diffs {
rels[d.name] = d.rel
}
gdir, _, err := t.gitPlace(name)
if err != nil {
fail(http.StatusNotFound, t.gitErr(err))
return
}
oldFile, oldName := file, declName
if ch.came != "" {
oldFile = ch.came
}
if ch.was != "" {
oldName = ch.was
}
var old, cur []string
var osr, oer, nsr, ner int
if ch.what[0] != "added" {
if old, osr, oer, err = declText(ctx, gdir, c.hash+"^", rels[oldFile], oldFile, oldName); err != nil {
logErr("%s: %v", t.dir, err)
}
}
if !ch.gone {
if cur, nsr, ner, err = declText(ctx, gdir, c.hash, rels[file], file, declName); err != nil {
logErr("%s: %v", t.dir, err)
}
}
esc := template.HTMLEscapeString
rows := func(sr, er int) string {
s := "L" + strconv.Itoa(sr+1)
if er > sr {
s += "-L" + strconv.Itoa(er+1)
}
return s
}
var links []string
if t.historyInVersions(ctx, name) {
if old != nil {
if parent, err := git(ctx, gdir, "rev-parse", "--short=12", c.hash+"^"); err == nil {
u := (&url.URL{Path: versionPrefix(t.prefix, "@"+strings.TrimSpace(string(parent))) + oldFile, Fragment: rows(osr, oer)}).String()
links = append(links, "<a href=\""+esc(u)+"\">before</a>")
}
}
if cur != nil {
links = append(links, "<a href=\""+esc(t.versionURL(t.starsFor(ctx, name), c, file)+"#"+rows(nsr, ner))+"\">after</a>")
}
}
var body strings.Builder
fmt.Fprintf(&body, "<div class=\"commit\">\n<p class=\"own\">%s · <span class=\"hash\">%s</span></p>\n<p class=\"subject\">%s</p>\n</div>\n", commitLine(t.starsFor(ctx, name).label(c), c), esc(c.hash), esc(c.subject))
fmt.Fprintf(&body, "<div class=\"diffs\">\n<p class=\"own file\">%s %s in %s%s", esc(strings.Join(ch.what, ", ")), esc(declName), esc(file), esc(ch.whence()))
if len(links) > 0 {
body.WriteString(" · " + strings.Join(links, " · "))
}
body.WriteString("</p>\n")
lines := lineDiff(old, cur)
body.WriteString(diffPre(lines, diffMarks(file, lines)))
body.WriteString("</div>\n")
t.render(w, r, http.StatusOK, page{Title: title, Crumbs: crumbs, Kind: "commit", Body: template.HTML(body.String())})
}
// markLine renders one line of a diff with its changed ranges marked.
func markLine(line string, ranges [][2]int) string {
esc := template.HTMLEscapeString
if len(ranges) == 0 || line == "" {
return esc(line)
}
var b bytes.Buffer
b.WriteString(esc(line[:1]))
body := line[1:]
at := 0
for _, r := range ranges {
if r[0] < at || r[1] > len(body) || !utf8.ValidString(body[r[0]:r[1]]) {
continue
}
b.WriteString(esc(body[at:r[0]]) + "<mark>" + esc(body[r[0]:r[1]]) + "</mark>")
at = r[1]
}
b.WriteString(esc(body[at:]))
return b.String()
}