package core
import "fmt"
// CommentSide is which revision of a changed block a comment hangs off.
//
// A modified block exists twice — once as it was approved, once as proposed —
// and the two have different content, so "this sentence is wrong" has to say
// which sentence. Comments go on the new side, because that is the text under
// review; the old side exists for a block the proposal deletes outright, where
// there is no new text to point at.
type CommentSide string
const (
// SideNew anchors to the proposed revision of a block.
SideNew CommentSide = "new"
// SideOld anchors to the approved revision, for a deleted block.
SideOld CommentSide = "old"
)
// ParseCommentSide validates a side read back from Postgres or an API request.
func ParseCommentSide(s string) (CommentSide, error) {
switch CommentSide(s) {
case SideNew, SideOld:
return CommentSide(s), nil
}
return "", fmt.Errorf("%w: %q is not one of new|old", ErrInvalidCommentSide, s)
}
// CommentAnchor is where a comment is attached: one block of one document,
// named by what it says rather than by where it sits.
//
// Line numbers are useless here. Markdown reflows, so a one-word edit rewraps a
// paragraph and every line number below it moves; that is the same property
// that made a line-oriented differ unusable for prose and it makes a
// line-oriented comment anchor unusable for the same reason.
//
// The tuple is the one prosediff already emits for every block, which is why
// the anchoring model could be validated before it was committed to a schema.
type CommentAnchor struct {
// DocID is the archive's addressing key for the document — its frontmatter
// id when it has a unique well-formed one, otherwise its path. Anchoring on
// the id rather than the path is what makes a comment survive a rename.
DocID string
// HeadingPath is the enclosing headings, outermost first.
//
// It is deliberately not part of BlockHash: renaming a section would
// otherwise change the hash of every block beneath it and orphan every
// comment in that section at once, which is the failure mode most likely to
// happen on a real editing pass.
HeadingPath []string
// Index is the block's position among the blocks sharing its HeadingPath,
// 0-based — not its position in the document.
//
// prosediff numbers blocks document-globally, and that number is what a
// caller has in hand; it is converted here because a document-global index
// is destroyed by any insertion above it. Since the fallback exists
// precisely for the case where the block's content changed, an index that
// every unrelated edit invalidates would leave the fallback unable to fire
// exactly when it is needed.
Index int
// BlockHash is prosediff's structure-plus-normalized-content hash of the
// block as it read when the comment was written.
BlockHash string
// Side is which revision of the block was commented on.
Side CommentSide
}
// AnchorBlock is the part of a segmented block that anchoring reads.
//
// It exists so that core does not import prosediff. Dependency direction is
// strictly downward and the comment anchoring rules are domain logic, not diff
// logic — a caller passes prosediff's blocks through this shape.
type AnchorBlock struct {
HeadingPath []string
// Index is the block's position among blocks sharing HeadingPath, 0-based.
// Build a slice of these with [AnchorBlocks] rather than filling it by hand.
Index int
Hash string
}
// AnchorState is how well a comment's anchor still describes the revision being
// looked at. It is derived, never stored: a comment is not outdated in general,
// it is outdated *at a revision*, and a proposal branch moves under it as the
// agent revises.
type AnchorState string
const (
// AnchorExact means the commented block is still present verbatim.
AnchorExact AnchorState = "anchored"
// AnchorEdited means the block at the anchor's position is still there but
// its text has changed since the comment was written. The comment is shown
// against it, marked, so the reader can see the critique may no longer fit.
AnchorEdited AnchorState = "edited"
// AnchorOutdated means neither the content nor the position matched.
//
// The comment is kept and reported as outdated rather than relocated to a
// best guess. A comment moved to the wrong paragraph is worse than one
// admitting it lost its place: the reader cannot tell it is wrong.
AnchorOutdated AnchorState = "outdated"
)
// AnchorBlocks converts a revision's blocks, in document order, into the shape
// [ResolveAnchor] reads — numbering each block within its own heading path.
//
// hashes and paths are parallel slices in document order, which is what a
// caller holds after segmenting: pass block.Hash and block.HeadingPath.
func AnchorBlocks(hashes []string, paths [][]string) []AnchorBlock {
out := make([]AnchorBlock, len(hashes))
seen := make(map[string]int, len(hashes))
for i, h := range hashes {
var p []string
if i < len(paths) {
p = paths[i]
}
key := headingKey(p)
out[i] = AnchorBlock{HeadingPath: p, Index: seen[key], Hash: h}
seen[key]++
}
return out
}
// ResolveAnchor locates a comment's block in a revision, returning the index
// into blocks and how confident that answer is. The index is -1 when the anchor
// did not resolve.
//
// The order is content first, position second, give up third:
//
// 1. an identical block hash means the commented text is still there, wherever
// it now sits — content is the strongest evidence and survives reflow,
// renumbering and section renames;
// 2. failing that, the block at the same position under the same headings is
// taken to be the same block, edited;
// 3. failing both, the anchor is outdated.
//
// Step 1 can match more than once — a document may repeat a paragraph, and
// "TBD" appears verbatim in a dozen places — so the heading path breaks the tie
// and the nearest index breaks what the heading path does not. Without that,
// which duplicate a comment landed on would depend on document order.
func ResolveAnchor(a CommentAnchor, blocks []AnchorBlock) (int, AnchorState) {
if best := bestHashMatch(a, blocks); best >= 0 {
return best, AnchorExact
}
for i, b := range blocks {
if b.Index == a.Index && headingKey(b.HeadingPath) == headingKey(a.HeadingPath) {
return i, AnchorEdited
}
}
return -1, AnchorOutdated
}
// bestHashMatch returns the index of the block whose hash equals the anchor's,
// preferring one under the same headings and then the closest index. It returns
// -1 when no block has that hash.
//
// sameSectionBonus dominates any positional term, so a duplicate under the
// comment's own headings always beats a nearer one somewhere else: a comment on
// "TBD" under "Storage" belongs to Storage's TBD even when another section's
// sits at a closer index.
func bestHashMatch(a CommentAnchor, blocks []AnchorBlock) int {
const sameSectionBonus = 1 << 20
if a.BlockHash == "" {
return -1
}
want := headingKey(a.HeadingPath)
best, bestScore := -1, -1
for i, b := range blocks {
if b.Hash != a.BlockHash {
continue
}
score := -abs(b.Index - a.Index) // nearer is better
if headingKey(b.HeadingPath) == want {
score += sameSectionBonus
}
if score > bestScore {
best, bestScore = i, score
}
}
return best
}
// headingKey flattens a heading path to a comparable string. "\x00" is the
// separator because it cannot occur in a heading, so no two distinct paths
// collide the way " › " would for a heading containing that sequence.
func headingKey(path []string) string {
key := ""
for _, h := range path {
key += h + "\x00"
}
return key
}
func abs(n int) int {
if n < 0 {
return -n
}
return n
}