diff --git a/prosediff/token.go b/prosediff/token.go index e65c69cf647b8f058be9b80e813ad96ac08befb7..dc2f4eb85cfbe8781e14b1d7d2c55efcb64dda09 100644 --- a/prosediff/token.go +++ b/prosediff/token.go @@ -199,7 +199,15 @@ // spans walks an edit script and materializes it into renderable runs. func spans(script []edit, a, b []Token) []Span { var out []Span - emit := func(op Op, toks []Token) { + // space is passed in rather than read off toks[0] because an equal run + // exists on both sides at once and the two sides can disagree about the + // separator in front of it. A block that gains words at its head has + // Space=false on the old side's first token — nothing precedes it there — + // and Space=true on the new side's, where the inserted words do. Reading + // only the old side emitted "{+two words+}the rest" with the words run + // together; a renderer has no way to recover the separator, because the new + // side's flag never reached it. + emit := func(op Op, toks []Token, space bool) { if len(toks) == 0 { return } @@ -210,7 +218,7 @@ sb.WriteByte(' ') } sb.WriteString(t.Text) } - out = append(out, Span{Op: op, Text: sb.String(), Space: toks[0].Space}) + out = append(out, Span{Op: op, Text: sb.String(), Space: space}) } i, j := 0, 0 @@ -220,23 +228,23 @@ // An insertion directly replacing a deletion must not re-announce // the whitespace the deletion already carried, or every one-word // substitution renders as "[-old-] {+new+}". if e.op == OpInsert && k > 0 && script[k-1].op == OpDelete { - emit(OpInsert, b[j:j+e.n]) - if len(out) > 0 { - out[len(out)-1].Space = false - } + emit(OpInsert, b[j:j+e.n], false) j += e.n continue } switch e.op { case OpEqual: - emit(OpEqual, a[i:i+e.n]) + // Either side's separator is reason enough to emit one: the run is + // rendered once, between whatever precedes it on the old side and + // whatever precedes it on the new. + emit(OpEqual, a[i:i+e.n], a[i].Space || b[j].Space) i += e.n j += e.n case OpDelete: - emit(OpDelete, a[i:i+e.n]) + emit(OpDelete, a[i:i+e.n], a[i].Space) i += e.n case OpInsert: - emit(OpInsert, b[j:j+e.n]) + emit(OpInsert, b[j:j+e.n], b[j].Space) j += e.n } } diff --git a/prosediff/token_test.go b/prosediff/token_test.go index 439e388553b8db519ff8b21f610620d80a5d727f..375cd636b7d971e518ef698c8d764a19d2582dc2 100644 --- a/prosediff/token_test.go +++ b/prosediff/token_test.go @@ -128,6 +128,38 @@ assert.Equal(t, OpInsert, spans[1].Op) assert.Equal(t, "agents emit, operators approve", spans[1].Text) } +// TestEqualRunAfterAHeadInsertionKeepsItsSeparator checks the one place the two +// sides of a diff disagree about whitespace. An equal run exists in both +// revisions at once; when words are inserted before it, the old side's first +// token has nothing in front of it and the new side's has the insertion. Reading +// only the old side dropped the separator, and a renderer joining the spans +// wrote "{+two words+}the rest" with no space — a defect the reader would read +// as the author's, since nothing in the output says a space went missing. +func TestEqualRunAfterAHeadInsertionKeepsItsSeparator(t *testing.T) { + spans := DiffWords( + "the storage layer keeps every revision", + "in practice the storage layer keeps every revision", + ) + require.Len(t, spans, 2) + assert.Equal(t, OpInsert, spans[0].Op) + assert.Equal(t, "in practice", spans[0].Text) + assert.False(t, spans[0].Space, "nothing precedes the first span") + assert.Equal(t, OpEqual, spans[1].Op) + assert.True(t, spans[1].Space, "the inserted words are separated from the rest") +} + +// TestSubstitutionStillCarriesNoDoubleSeparator guards the rule the fix above +// must not undo: an insertion directly replacing a deletion inherits the +// deletion's separator rather than announcing its own. +func TestSubstitutionStillCarriesNoDoubleSeparator(t *testing.T) { + spans := DiffWords("the quick brown fox", "the quick red fox") + require.Len(t, spans, 4) + assert.Equal(t, OpDelete, spans[1].Op) + assert.True(t, spans[1].Space) + assert.Equal(t, OpInsert, spans[2].Op) + assert.False(t, spans[2].Space) +} + func TestDiffLinesIsWhitespaceSensitive(t *testing.T) { old := []string{"func main() {", "\tfmt.Println(1)", "}"} nw := []string{"func main() {", " fmt.Println(1)", "}"}