// Copyright 2019 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // package gotextdiff supports a pluggable diff algorithm. package gotextdiff import ( "sort" "strings" "github.com/hexops/gotextdiff/span" ) // TextEdit represents a change to a section of a document. // The text within the specified span should be replaced by the supplied new text. type TextEdit struct { Span span.Span NewText string } // ComputeEdits is the type for a function that produces a set of edits that // convert from the before content to the after content. type ComputeEdits func(uri span.URI, before, after string) []TextEdit // SortTextEdits attempts to order all edits by their starting points. // The sort is stable so that edits with the same starting point will not // be reordered. func SortTextEdits(d []TextEdit) { // Use a stable sort to maintain the order of edits inserted at the same position. sort.SliceStable(d, func(i int, j int) bool { return span.Compare(d[i].Span, d[j].Span) < 0 }) } // ApplyEdits applies the set of edits to the before and returns the resulting // content. // It may panic or produce garbage if the edits are not valid for the provided // before content. func ApplyEdits(before string, edits []TextEdit) string { // Preconditions: // - all of the edits apply to before // - and all the spans for each TextEdit have the same URI if len(edits) == 0 { return before } _, edits, _ = prepareEdits(before, edits) after := strings.Builder{} last := 0 for _, edit := range edits { start := edit.Span.Start().Offset() if start > last { after.WriteString(before[last:start]) last = start } after.WriteString(edit.NewText) last = edit.Span.End().Offset() } if last < len(before) { after.WriteString(before[last:]) } return after.String() } // LineEdits takes a set of edits and expands and merges them as necessary // to ensure that there are only full line edits left when it is done. func LineEdits(before string, edits []TextEdit) []TextEdit { if len(edits) == 0 { return nil } c, edits, partial := prepareEdits(before, edits) if partial { edits = lineEdits(before, c, edits) } return edits } // prepareEdits returns a sorted copy of the edits func prepareEdits(before string, edits []TextEdit) (*span.TokenConverter, []TextEdit, bool) { partial := false c := span.NewContentConverter("", []byte(before)) copied := make([]TextEdit, len(edits)) for i, edit := range edits { edit.Span, _ = edit.Span.WithAll(c) copied[i] = edit partial = partial || edit.Span.Start().Offset() >= len(before) || edit.Span.Start().Column() > 1 || edit.Span.End().Column() > 1 } SortTextEdits(copied) return c, copied, partial } // lineEdits rewrites the edits to always be full line edits func lineEdits(before string, c *span.TokenConverter, edits []TextEdit) []TextEdit { adjusted := make([]TextEdit, 0, len(edits)) current := TextEdit{Span: span.Invalid} for _, edit := range edits { if current.Span.IsValid() && edit.Span.Start().Line() <= current.Span.End().Line() { // overlaps with the current edit, need to combine // first get the gap from the previous edit gap := before[current.Span.End().Offset():edit.Span.Start().Offset()] // now add the text of this edit current.NewText += gap + edit.NewText // and then adjust the end position current.Span = span.New(current.Span.URI(), current.Span.Start(), edit.Span.End()) } else { // does not overlap, add previous run (if there is one) adjusted = addEdit(before, adjusted, current) // and then remember this edit as the start of the next run current = edit } } // add the current pending run if there is one return addEdit(before, adjusted, current) } func addEdit(before string, edits []TextEdit, edit TextEdit) []TextEdit { if !edit.Span.IsValid() { return edits } // if edit is partial, expand it to full line now start := edit.Span.Start() end := edit.Span.End() if start.Column() > 1 { // prepend the text and adjust to start of line delta := start.Column() - 1 start = span.NewPoint(start.Line(), 1, start.Offset()-delta) edit.Span = span.New(edit.Span.URI(), start, end) edit.NewText = before[start.Offset():start.Offset()+delta] + edit.NewText } if start.Offset() >= len(before) && start.Line() > 1 && before[len(before)-1] != '\n' { // after end of file that does not end in eol, so join to last line of file // to do this we need to know where the start of the last line was eol := strings.LastIndex(before, "\n") if eol < 0 { // file is one non terminated line eol = 0 } delta := len(before) - eol start = span.NewPoint(start.Line()-1, 1, start.Offset()-delta) edit.Span = span.New(edit.Span.URI(), start, end) edit.NewText = before[start.Offset():start.Offset()+delta] + edit.NewText } if end.Column() > 1 { remains := before[end.Offset():] eol := strings.IndexRune(remains, '\n') if eol < 0 { eol = len(remains) } else { eol++ } end = span.NewPoint(end.Line()+1, 1, end.Offset()+eol) edit.Span = span.New(edit.Span.URI(), start, end) edit.NewText = edit.NewText + remains[:eol] } edits = append(edits, edit) return edits }