package site
import (
"context"
"fmt"
"html/template"
"path"
"regexp"
"strconv"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/lexers"
)
const (
maxCommitPatchBytes = 8 * 1024 * 1024
maxCommitDiffRows = 4000
maxFileDiffRows = 2000
)
var hunkHeaderPattern = regexp.MustCompile(`^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@`)
type CommitParent struct {
OID string
ShortOID string
URL string
}
type CommitView struct {
Commit Commit
Parents []CommitParent
Files []DiffFile
FileCount int
Binaries int
Additions int
Deletions int
Truncated bool
}
type DiffFile struct {
Anchor string
OldPath string
NewPath string
DisplayPath string
TreePath string
Status string
Rows []DiffRow
Additions int
Deletions int
Binary bool
Truncated bool
}
type DiffRow struct {
Hunk bool
Header string
OldNumber int
NewNumber int
OldText string
NewText string
OldCode template.HTML
NewCode template.HTML
OldClass string
NewClass string
OldPresent bool
NewPresent bool
}
type numberedLine struct {
number int
text string
}
func (g *Generator) commitPages(ctx context.Context, branch, oldOID, newOID string, summary branchSummary) ([]Page, error) {
wanted := make(map[string]struct{})
// A site-version change is also our migration signal: the first push after
// installing commit views backfills historical pages once. Ordinary pushes
// with current assets remain proportional to newly reachable commits.
if oldOID == "" || !g.AssetsCurrent {
for _, group := range summary.Groups {
for _, commit := range group.Commits {
wanted[commit.OID] = struct{}{}
}
}
} else {
out, err := g.Git.Run(ctx, "rev-list", newOID, "^"+oldOID)
if err != nil {
g.progress("could not enumerate new commits from %s; generating reachable commit pages", short(oldOID))
for _, group := range summary.Groups {
for _, commit := range group.Commits {
wanted[commit.OID] = struct{}{}
}
}
} else {
for _, oid := range strings.Fields(string(out)) {
wanted[oid] = struct{}{}
}
}
}
var pages []Page
for _, group := range summary.Groups {
for _, commit := range group.Commits {
if _, ok := wanted[commit.OID]; !ok || g.commitGenerated(commit.OID) {
continue
}
page, err := g.commitPage(ctx, branch, commit, summary)
if err != nil {
return nil, err
}
pages = append(pages, page)
}
}
if len(pages) > 0 {
g.progress("generated %d commit diff page(s)", len(pages))
}
return pages, nil
}
func (g *Generator) commitGenerated(oid string) bool {
g.commitMu.Lock()
defer g.commitMu.Unlock()
_, ok := g.generated[oid]
return ok
}
func (g *Generator) markCommitGenerated(oid string) {
g.commitMu.Lock()
g.generated[oid] = struct{}{}
g.commitMu.Unlock()
}
func (g *Generator) commitPage(ctx context.Context, branch string, commit Commit, summary branchSummary) (Page, error) {
view := CommitView{Commit: commit}
for _, oid := range commit.Parents {
view.Parents = append(view.Parents, CommitParent{
OID: oid,
ShortOID: short(oid),
URL: routeURL(g.BaseURL, path.Join("commit", oid)),
})
}
args := []string{"diff", "--no-ext-diff", "--no-color", "--find-renames", "--unified=3"}
if len(commit.Parents) > 0 {
args = append(args, commit.Parents[0], commit.OID, "--")
} else {
args = []string{"diff-tree", "--root", "--no-commit-id", "-r", "-p", "--no-ext-diff", "--no-color", "--find-renames", "--unified=3", commit.OID, "--"}
}
patch, truncated, err := g.Git.RunLimited(ctx, maxCommitPatchBytes, args...)
if err != nil {
return Page{}, err
}
view.Truncated = truncated
if !truncated {
parsed := parsePatch(patch)
view.FileCount = len(parsed)
for _, file := range parsed {
if file.Binary {
view.Binaries++
continue
}
highlightDiffFile(&file)
view.Files = append(view.Files, file)
view.Additions += file.Additions
view.Deletions += file.Deletions
}
}
data := g.pageData("commit", commit.Message+" · "+g.RepoName, branch, summary)
data.ViewedCommit = view
body, err := g.render(data)
if err != nil {
return Page{}, err
}
return Page{
Route: path.Join("commit", commit.OID),
Body: body,
ContentType: "text/html; charset=utf-8",
Cache: siteCache,
}, nil
}
func highlightDiffFile(file *DiffFile) {
filename := file.NewPath
if filename == "" {
filename = file.OldPath
}
lexer := lexers.Match(filename)
if lexer == nil {
setPlainDiffCode(file)
return
}
lexer = chroma.Coalesce(lexer)
for start := 0; start < len(file.Rows); {
if file.Rows[start].Hunk {
start++
continue
}
end := start
for end < len(file.Rows) && !file.Rows[end].Hunk {
end++
}
highlightDiffSide(file.Rows[start:end], lexer, true)
highlightDiffSide(file.Rows[start:end], lexer, false)
start = end
}
}
func setPlainDiffCode(file *DiffFile) {
for index := range file.Rows {
if file.Rows[index].OldPresent {
file.Rows[index].OldCode = template.HTML(template.HTMLEscapeString(file.Rows[index].OldText))
}
if file.Rows[index].NewPresent {
file.Rows[index].NewCode = template.HTML(template.HTMLEscapeString(file.Rows[index].NewText))
}
}
}
func highlightDiffSide(rows []DiffRow, lexer chroma.Lexer, old bool) {
var indexes []int
var source strings.Builder
for index, row := range rows {
present, value := row.NewPresent, row.NewText
if old {
present, value = row.OldPresent, row.OldText
}
if !present {
continue
}
indexes = append(indexes, index)
source.WriteString(value)
source.WriteByte('\n')
}
if len(indexes) == 0 {
return
}
iterator, err := lexer.Tokenise(nil, source.String())
if err != nil {
setPlainDiffSide(rows, indexes, old)
return
}
lines := chroma.SplitTokensIntoLines(iterator.Tokens())
if len(lines) != len(indexes) {
setPlainDiffSide(rows, indexes, old)
return
}
for offset, tokens := range lines {
formatted := formatDiffTokens(tokens)
if old {
rows[indexes[offset]].OldCode = formatted
} else {
rows[indexes[offset]].NewCode = formatted
}
}
}
func setPlainDiffSide(rows []DiffRow, indexes []int, old bool) {
for _, index := range indexes {
value := rows[index].NewText
if old {
value = rows[index].OldText
}
formatted := template.HTML(template.HTMLEscapeString(value))
if old {
rows[index].OldCode = formatted
} else {
rows[index].NewCode = formatted
}
}
}
func formatDiffTokens(tokens []chroma.Token) template.HTML {
var result strings.Builder
for _, token := range tokens {
value := strings.TrimSuffix(token.Value, "\n")
if value == "" {
continue
}
escaped := template.HTMLEscapeString(value)
class := diffTokenClass(token.Type)
if class == "" {
result.WriteString(escaped)
continue
}
result.WriteString(``)
result.WriteString(escaped)
result.WriteString(``)
}
return template.HTML(result.String())
}
func diffTokenClass(tokenType chroma.TokenType) string {
for tokenType != 0 {
if class, ok := chroma.StandardTypes[tokenType]; ok {
return class
}
tokenType = tokenType.Parent()
}
return chroma.StandardTypes[tokenType]
}
func parsePatch(patch []byte) []DiffFile {
var files []DiffFile
var current *DiffFile
var oldLine, newLine int
var oldBlock, newBlock []numberedLine
var blockTruncated bool
renderedRows := 0
fileRows := 0
inHunk := false
rowAllowance := func() int {
return min(maxCommitDiffRows-renderedRows, maxFileDiffRows-fileRows)
}
appendRow := func(row DiffRow) {
if current == nil {
return
}
if rowAllowance() <= 0 {
current.Truncated = true
return
}
current.Rows = append(current.Rows, row)
renderedRows++
fileRows++
}
flushBlock := func() {
if current == nil || len(oldBlock)+len(newBlock) == 0 {
if current != nil {
current.Truncated = current.Truncated || blockTruncated
}
blockTruncated = false
return
}
count := max(len(oldBlock), len(newBlock))
for index := range count {
row := DiffRow{}
if index < len(oldBlock) {
row.OldNumber = oldBlock[index].number
row.OldText = oldBlock[index].text
row.OldClass = "deletion"
row.OldPresent = true
}
if index < len(newBlock) {
row.NewNumber = newBlock[index].number
row.NewText = newBlock[index].text
row.NewClass = "addition"
row.NewPresent = true
}
appendRow(row)
}
current.Truncated = current.Truncated || blockTruncated
oldBlock = oldBlock[:0]
newBlock = newBlock[:0]
blockTruncated = false
}
finishFile := func() {
flushBlock()
if current == nil {
return
}
if current.NewPath == "" {
current.NewPath = current.OldPath
}
if current.OldPath == "" {
current.OldPath = current.NewPath
}
switch {
case current.Status == "Renamed":
current.DisplayPath = current.OldPath + " → " + current.NewPath
current.TreePath = current.NewPath
case current.Status == "Deleted":
current.DisplayPath = current.OldPath
current.TreePath = current.OldPath
default:
current.DisplayPath = current.NewPath
current.TreePath = current.NewPath
}
if current.Status == "" {
current.Status = "Modified"
}
current.Anchor = fmt.Sprintf("diff-%d", len(files)+1)
files = append(files, *current)
current = nil
inHunk = false
fileRows = 0
}
for _, line := range strings.Split(string(patch), "\n") {
if strings.HasPrefix(line, "diff --git ") {
finishFile()
oldPath, newPath := diffHeaderPaths(strings.TrimPrefix(line, "diff --git "))
current = &DiffFile{OldPath: oldPath, NewPath: newPath}
continue
}
if current == nil {
continue
}
if matches := hunkHeaderPattern.FindStringSubmatch(line); matches != nil {
flushBlock()
oldLine, _ = strconv.Atoi(matches[1])
newLine, _ = strconv.Atoi(matches[2])
appendRow(DiffRow{Hunk: true, Header: line})
inHunk = true
continue
}
if inHunk {
if line == `\ No newline at end of file` {
continue
}
if line == "" {
continue
}
switch line[0] {
case ' ':
flushBlock()
appendRow(DiffRow{
OldNumber: oldLine, NewNumber: newLine,
OldText: line[1:], NewText: line[1:],
OldPresent: true, NewPresent: true,
})
oldLine++
newLine++
case '-':
if len(oldBlock) < rowAllowance() {
oldBlock = append(oldBlock, numberedLine{number: oldLine, text: line[1:]})
} else {
blockTruncated = true
}
current.Deletions++
oldLine++
case '+':
if len(newBlock) < rowAllowance() {
newBlock = append(newBlock, numberedLine{number: newLine, text: line[1:]})
} else {
blockTruncated = true
}
current.Additions++
newLine++
}
continue
}
switch {
case strings.HasPrefix(line, "new file mode "):
current.Status = "Added"
case strings.HasPrefix(line, "deleted file mode "):
current.Status = "Deleted"
case strings.HasPrefix(line, "rename from "):
current.Status = "Renamed"
current.OldPath = decodeGitPath(strings.TrimPrefix(line, "rename from "))
case strings.HasPrefix(line, "rename to "):
current.Status = "Renamed"
current.NewPath = decodeGitPath(strings.TrimPrefix(line, "rename to "))
case strings.HasPrefix(line, "Binary files ") || line == "GIT binary patch":
current.Binary = true
case strings.HasPrefix(line, "--- "):
value := patchPath(strings.TrimPrefix(line, "--- "))
if value != "" {
current.OldPath = value
}
case strings.HasPrefix(line, "+++ "):
value := patchPath(strings.TrimPrefix(line, "+++ "))
if value != "" {
current.NewPath = value
}
}
}
finishFile()
return files
}
func diffHeaderPaths(value string) (string, string) {
if strings.HasPrefix(value, `"`) {
oldPath, rest := quotedGitField(value)
newPath, _ := quotedGitField(strings.TrimSpace(rest))
return stripDiffPrefix(oldPath), stripDiffPrefix(newPath)
}
marker := strings.Index(value, " b/")
if marker < 0 {
return stripDiffPrefix(value), stripDiffPrefix(value)
}
return stripDiffPrefix(value[:marker]), stripDiffPrefix(value[marker+1:])
}
func quotedGitField(value string) (string, string) {
if !strings.HasPrefix(value, `"`) {
field, rest, _ := strings.Cut(value, " ")
return decodeGitPath(field), rest
}
escaped := false
for index := 1; index < len(value); index++ {
switch {
case escaped:
escaped = false
case value[index] == '\\':
escaped = true
case value[index] == '"':
return decodeGitPath(value[:index+1]), value[index+1:]
}
}
return decodeGitPath(value), ""
}
func patchPath(value string) string {
value = strings.TrimSuffix(value, "\t")
value = decodeGitPath(value)
if value == "/dev/null" {
return ""
}
return stripDiffPrefix(value)
}
func stripDiffPrefix(value string) string {
value = decodeGitPath(value)
value = strings.TrimPrefix(value, "a/")
value = strings.TrimPrefix(value, "b/")
return value
}
func decodeGitPath(value string) string {
if strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`) {
if decoded, err := strconv.Unquote(value); err == nil {
return decoded
}
}
return value
}