Skip to content
quarry Artifact [1d9bcf68a5]
Login

Artifact [1d9bcf68a5]

Artifact 1d9bcf68a5e2d767c8e8647a821ddc63a7fa03ebda27ba19cf34fee999f483c5:


package web

import (
	"fmt"
	"hash/fnv"
	"strconv"
	"strings"

	"craftdesign.group/quarry/internal/diff"
	"craftdesign.group/quarry/internal/index"
)

// Line notes on a landing: a remark pinned to one line of the diff,
// stored as an ordinary reply in the record's thread (index.LineNote).
// The page shows each under its line and in the discussion, and a
// reviewer who may speak opens one by pressing a line number.

// lineCompose is the line the composer is aimed at (?line=).
type lineCompose struct {
	File, Side string
	// Line is the first line; To the last of a range, 0 for one line.
	Line, To int
	At       string
}

// End is the composer's last line: To for a range, else Line.
func (c *lineCompose) End() int {
	if c.To > 0 {
		return c.To
	}
	return c.Line
}

// lineHref is a line number's ?line= value. With the composer open on
// the same file and side, pressing another number extends the comment
// to it: the range runs from the composer's first line to that one,
// either way round. Otherwise it starts a comment on that one line.
func lineHref(c *lineCompose, file, side string, n int) string {
	if c == nil || c.File != file || c.Side != side || n == c.Line {
		return lineHere(file, side, n)
	}
	a, b := c.Line, n
	if b < a {
		a, b = b, a
	}
	if b-a >= 500 {
		return lineHere(file, side, n)
	}
	return lineHere(file, side, a) + "-" + strconv.Itoa(b)
}

// inRange reports whether a line falls inside the composer's range, for
// the highlight; a single line counts as a range of one.
func inRange(c *lineCompose, file, side string, n int) bool {
	return c != nil && c.File == file && c.Side == side && n >= c.Line && n <= c.End()
}

func rowInRange(c *lineCompose, file string, l diff.Line) bool {
	side, n := sideOf(l)
	return inRange(c, file, side, n)
}

func splitInRange(c *lineCompose, file string, r splitRow) bool {
	return (r.Right != nil && inRange(c, file, "new", r.Right.NewNum)) ||
		(r.Left != nil && r.Left.Kind == "deletion" && inRange(c, file, "old", r.Left.OldNum))
}

func lineKey(file, side string, n int) string {
	return file + "\x00" + side + "\x00" + strconv.Itoa(n)
}

// lineID is a row's anchor: a short hash of the path, so any path makes
// a valid id, and the side and number a reader can see.
func lineID(file, side string, n int) string {
	h := fnv.New32a()
	h.Write([]byte(file))
	return fmt.Sprintf("L%08x-%c%d", h.Sum32(), side[0], n)
}

// lineHere is a line's ?line= value: file, side and number.
func lineHere(file, side string, n int) string {
	return file + ":" + side + ":" + strconv.Itoa(n)
}

// sideOf says which numbering names a diff line: a removed line exists
// only in the target, so its old number; every other line by the
// branch's number.
func sideOf(l diff.Line) (string, int) {
	if l.Kind == "deletion" {
		return "old", l.OldNum
	}
	return "new", l.NewNum
}

func lineNotesAt(m map[string][]forumPostRow, file string, l diff.Line) []forumPostRow {
	if m == nil {
		return nil
	}
	side, n := sideOf(l)
	return m[lineKey(file, side, n)]
}

func splitNotesAt(m map[string][]forumPostRow, file string, r splitRow) []forumPostRow {
	if m == nil {
		return nil
	}
	var out []forumPostRow
	if r.Left != nil && r.Left.Kind == "deletion" {
		out = append(out, m[lineKey(file, "old", r.Left.OldNum)]...)
	}
	if r.Right != nil {
		out = append(out, m[lineKey(file, "new", r.Right.NewNum)]...)
	}
	return out
}

// lineNotesFor marks each line note stale when the branch has moved on
// since it was written (its line number may no longer name the same
// line, so it stays in the discussion but leaves the diff), maps the
// rest to their lines, and aims the composer at ?line= when the reader
// may comment and the line is one this diff shows.
func lineNotesFor(data *pageData, det *landingDetail, tip, q string) {
	for i := range det.Comments {
		c := &det.Comments[i]
		if c.Line == nil || c.Hidden {
			continue
		}
		if c.Line.At != "" && tip != "" && !strings.HasPrefix(tip, c.Line.At) {
			c.LineStale = true
			continue
		}
		if data.LineNotes == nil {
			data.LineNotes = map[string][]forumPostRow{}
		}
		// Under its last line: a range is read before its remark.
		k := lineKey(c.Line.File, c.Line.Side, c.Line.End())
		data.LineNotes[k] = append(data.LineNotes[k], *c)
	}
	data.LineLinks = det.CanSpeak && det.State == "open" && det.Diff != nil
	if q == "" || !data.LineLinks {
		return
	}
	rest, num, ok := cutLastColon(q)
	if !ok {
		return
	}
	file, side, ok := cutLastColon(rest)
	if !ok {
		return
	}
	from, to, _ := strings.Cut(num, "-")
	n, err := strconv.Atoi(from)
	if err != nil {
		return
	}
	end := 0
	if to != "" {
		if end, err = strconv.Atoi(to); err != nil {
			return
		}
	}
	note := index.LineNote{File: file, Side: side, Line: n, To: end, At: tip}
	if note.Valid() != nil || !diffHasLine(det.Diff, note) {
		return
	}
	if end > 0 {
		if last := note; true {
			last.Line, last.To = end, 0
			if !diffHasLine(det.Diff, last) {
				return
			}
		}
	}
	data.LineCompose = &lineCompose{File: file, Side: side, Line: n, To: end, At: tip}
}

func cutLastColon(s string) (before, after string, ok bool) {
	i := strings.LastIndexByte(s, ':')
	if i < 0 {
		return "", "", false
	}
	return s[:i], s[i+1:], true
}

// diffHasLine reports whether the diff on the page shows that line, so
// a hand-typed ?line= cannot aim a note at a line nobody was shown.
func diffHasLine(d *diffDetail, n index.LineNote) bool {
	for _, f := range d.Structured {
		if f.Path != n.File {
			continue
		}
		for _, h := range f.Hunks {
			for _, l := range h.Lines {
				if side, num := sideOf(l); side == n.Side && num == n.Line {
					return true
				}
			}
		}
	}
	return false
}

// rowID is a unified row's anchor, by the numbering that names it.
func rowID(file string, l diff.Line) string {
	side, n := sideOf(l)
	return lineID(file, side, n)
}

// splitID anchors a side-by-side row by its new line, or its old one
// when the branch removed it.
func splitID(file string, r splitRow) string {
	if r.Right != nil {
		return lineID(file, "new", r.Right.NewNum)
	}
	if r.Left != nil {
		return lineID(file, "old", r.Left.OldNum)
	}
	return ""
}

// lineIs reports whether the composer is aimed at this row.
func lineIs(c *lineCompose, file string, l diff.Line) bool {
	if c == nil || c.File != file {
		return false
	}
	side, n := sideOf(l)
	return side == c.Side && n == c.End()
}

func splitLineIs(c *lineCompose, file string, r splitRow) bool {
	if c == nil || c.File != file {
		return false
	}
	return (r.Right != nil && c.Side == "new" && r.Right.NewNum == c.End()) ||
		(r.Left != nil && r.Left.Kind == "deletion" && c.Side == "old" && r.Left.OldNum == c.End())
}