mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
199fd208a8
* fix(items): undo of an emptied line, and a patch that changes nothing is refused (PLAN-3578)
Retro CONVE-735 findings on #2058 and #2055.
Undo: an edit that deleted a line's only content recorded empty,
line-clipped context on both sides, so its inverse had no anchor and the
undo was refused as an empty anchor. ApplyWithContext now reaches across
the line breaks when the clipped context around an edit that wrote nothing
is empty. A patch that empties the WHOLE body still leaves nothing to
anchor on; its undo now says so and points at History instead of "old is
empty".
No-op: an edit whose old equals its new, or edits that cancel out, used to
answer success and advance seq with no version behind it, against "every
patch is a version". Both are refused 400, writing nothing: per edit up
front in the handler, and the combined case through bodypatch.ErrNoChange
on the no-room path.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* fix(items): a no-op patch through an open editor is refused too (PLAN-3578)
Codex round 1 on d6c3cc381 (P2): edits that cancel out were refused on
the no-room path but still written on the open-editor path, where the ack
branch ignored ErrNoChange and wrote a version and a seq for a no-op. When
the tab's before and after are equal, the patch is now refused with the
same no-change 400, writing nothing.
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
* docs(api): the patch endpoint's no-op refusal and the emptied-body undo (PLAN-3578)
Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
226 lines
7.1 KiB
Go
226 lines
7.1 KiB
Go
// Package bodypatch applies content-anchored edits to an item body
|
|
// (PLAN-3578): each edit names text that must occur EXACTLY ONCE in the body
|
|
// at the moment it applies, and replaces it. Anchors are text, never line
|
|
// numbers: the body is markdown serialized from a collaborative document, so
|
|
// line N at read time need not be line N at write time. A line hint may only
|
|
// choose among several exact matches; it never stands in for one.
|
|
package bodypatch
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// Edit is one replacement: Old must occur exactly once (or exactly once on
|
|
// LineHint's line when it occurs more than once), and becomes New.
|
|
type Edit struct {
|
|
Old string `json:"old"`
|
|
New string `json:"new"`
|
|
// LineHint, when > 0, is the 1-based line on which an ambiguous Old
|
|
// starts. Ignored when Old occurs once.
|
|
LineHint int `json:"line_hint,omitempty"`
|
|
}
|
|
|
|
// MaxEdits bounds one patch.
|
|
const MaxEdits = 100
|
|
|
|
// EmptyAnchorError refuses an edit whose Old is empty: an empty anchor
|
|
// matches everywhere, so it says nothing about where the edit goes.
|
|
type EmptyAnchorError struct{ Index int }
|
|
|
|
func (e *EmptyAnchorError) Error() string {
|
|
return fmt.Sprintf("edit %d: old is empty; an anchor must name text in the body", e.Index)
|
|
}
|
|
|
|
// AnchorMissingError: Old does not occur in the body as it stands when the
|
|
// edit applies (after the edits before it).
|
|
type AnchorMissingError struct{ Index int }
|
|
|
|
func (e *AnchorMissingError) Error() string {
|
|
return fmt.Sprintf("edit %d: old text not found in the current body", e.Index)
|
|
}
|
|
|
|
// AnchorAmbiguousError: Old occurs more than once and no line hint picks
|
|
// exactly one. Lines are the 1-based start lines of every match.
|
|
type AnchorAmbiguousError struct {
|
|
Index int
|
|
Lines []int
|
|
}
|
|
|
|
func (e *AnchorAmbiguousError) Error() string {
|
|
return fmt.Sprintf("edit %d: old text occurs %d times (lines %v); quote more context, or pass line_hint", e.Index, len(e.Lines), e.Lines)
|
|
}
|
|
|
|
// Apply runs edits in order against body, each against the result of the
|
|
// ones before it, and returns the new body. All or nothing: the first edit
|
|
// that cannot apply returns its error and no body.
|
|
func Apply(body string, edits []Edit) (string, error) {
|
|
original := body
|
|
if len(edits) == 0 {
|
|
return "", fmt.Errorf("no edits")
|
|
}
|
|
if len(edits) > MaxEdits {
|
|
return "", fmt.Errorf("%d edits; at most %d per patch", len(edits), MaxEdits)
|
|
}
|
|
for i, e := range edits {
|
|
if e.Old == "" {
|
|
return "", &EmptyAnchorError{Index: i}
|
|
}
|
|
}
|
|
for i, e := range edits {
|
|
at := matches(body, e.Old)
|
|
switch {
|
|
case len(at) == 0:
|
|
return "", &AnchorMissingError{Index: i}
|
|
case len(at) > 1:
|
|
pick := -1
|
|
if e.LineHint > 0 {
|
|
for _, p := range at {
|
|
if lineOf(body, p) == e.LineHint {
|
|
if pick >= 0 {
|
|
pick = -2 // two matches start on the hinted line
|
|
break
|
|
}
|
|
pick = p
|
|
}
|
|
}
|
|
}
|
|
if pick < 0 {
|
|
lines := make([]int, len(at))
|
|
for k, p := range at {
|
|
lines[k] = lineOf(body, p)
|
|
}
|
|
return "", &AnchorAmbiguousError{Index: i, Lines: lines}
|
|
}
|
|
at = []int{pick}
|
|
}
|
|
body = body[:at[0]] + e.New + body[at[0]+len(e.Old):]
|
|
}
|
|
if body == original {
|
|
return "", ErrNoChange
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// ErrNoChange: the edits, applied in order, leave the body exactly as it was
|
|
// (retro CONVE-735 finding on #2055). A patch is a version; one that changes
|
|
// nothing is refused rather than advancing seq with no version behind it.
|
|
var ErrNoChange = errors.New("the edits change nothing")
|
|
|
|
// matches returns every start offset of sub in s, overlapping ones included:
|
|
// "aa" occurs twice in "aaa", and a uniqueness check that counted it once
|
|
// would let an edit land on whichever one a scan happened to find first.
|
|
func matches(s, sub string) []int {
|
|
var out []int
|
|
for from := 0; from <= len(s)-len(sub); {
|
|
k := strings.Index(s[from:], sub)
|
|
if k < 0 {
|
|
break
|
|
}
|
|
out = append(out, from+k)
|
|
from += k + 1
|
|
}
|
|
return out
|
|
}
|
|
|
|
// lineOf is the 1-based line on which byte offset p lies.
|
|
func lineOf(s string, p int) int {
|
|
return strings.Count(s[:p], "\n") + 1
|
|
}
|
|
|
|
// ContextChars bounds the body context kept on each side of an applied edit
|
|
// (PLAN-3578 step 3, lead ruling: about 40, clipped at line ends).
|
|
const ContextChars = 40
|
|
|
|
// AppliedEdit is an edit as it landed: what was sent, plus the body text on
|
|
// either side of the replacement right after it applied, so an undo can
|
|
// anchor on text even when the edit deleted its Old.
|
|
type AppliedEdit struct {
|
|
Old string `json:"old"`
|
|
New string `json:"new"`
|
|
CtxBefore string `json:"ctx_before"`
|
|
CtxAfter string `json:"ctx_after"`
|
|
}
|
|
|
|
// ApplyWithContext is Apply, also returning each edit with its context. The
|
|
// context of edit i is read from the body as it stood right after edit i,
|
|
// which is exactly the body an undo of edit i meets when the edits after it
|
|
// have been undone first, so Invert's reverse order lines up.
|
|
func ApplyWithContext(body string, edits []Edit) (string, []AppliedEdit, error) {
|
|
if _, err := Apply(body, edits); err != nil {
|
|
return "", nil, err
|
|
}
|
|
out := make([]AppliedEdit, 0, len(edits))
|
|
for _, e := range edits {
|
|
at := matches(body, e.Old)
|
|
if len(at) > 1 {
|
|
for _, p := range at {
|
|
if lineOf(body, p) == e.LineHint {
|
|
at = []int{p}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
p := at[0]
|
|
body = body[:p] + e.New + body[p+len(e.Old):]
|
|
end := p + len(e.New)
|
|
cb, ca := ctxBefore(body, p, true), ctxAfter(body, end, true)
|
|
if cb+e.New+ca == "" {
|
|
// The edit emptied its line (retro CONVE-735 finding on #2058):
|
|
// line-clipped context is empty on both sides, which would leave
|
|
// the undo no anchor at all. Reach across the line breaks instead.
|
|
cb, ca = ctxBefore(body, p, false), ctxAfter(body, end, false)
|
|
}
|
|
out = append(out, AppliedEdit{Old: e.Old, New: e.New, CtxBefore: cb, CtxAfter: ca})
|
|
}
|
|
return body, out, nil
|
|
}
|
|
|
|
// ctxBefore is up to ContextChars bytes before p, not crossing a newline when
|
|
// clip is set, and never splitting a UTF-8 sequence.
|
|
func ctxBefore(s string, p int, clip bool) string {
|
|
from := p - ContextChars
|
|
if from < 0 {
|
|
from = 0
|
|
}
|
|
if nl := strings.LastIndexByte(s[from:p], '\n'); clip && nl >= 0 {
|
|
from += nl + 1
|
|
}
|
|
for from < p && !utf8.RuneStart(s[from]) {
|
|
from++
|
|
}
|
|
return s[from:p]
|
|
}
|
|
|
|
// ctxAfter is up to ContextChars bytes from p, stopping before a newline when
|
|
// clip is set.
|
|
func ctxAfter(s string, p int, clip bool) string {
|
|
to := p + ContextChars
|
|
if to > len(s) {
|
|
to = len(s)
|
|
}
|
|
if nl := strings.IndexByte(s[p:to], '\n'); clip && nl >= 0 {
|
|
to = p + nl
|
|
}
|
|
for to > p && to < len(s) && !utf8.RuneStart(s[to]) {
|
|
to--
|
|
}
|
|
return s[p:to]
|
|
}
|
|
|
|
// Invert turns applied edits into the patch that undoes them: each anchored
|
|
// on its context around what it wrote, replaced by its context around what it
|
|
// removed, in reverse order. The ordinary rules then apply: an anchor that no
|
|
// longer occurs exactly once (the text was edited since) refuses the undo and
|
|
// writes nothing, so an undo never reverts someone else's later change.
|
|
func Invert(applied []AppliedEdit) []Edit {
|
|
out := make([]Edit, 0, len(applied))
|
|
for i := len(applied) - 1; i >= 0; i-- {
|
|
a := applied[i]
|
|
out = append(out, Edit{Old: a.CtxBefore + a.New + a.CtxAfter, New: a.CtxBefore + a.Old + a.CtxAfter})
|
|
}
|
|
return out
|
|
}
|