You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
terraform/lang/langserver/text_edit.go

207 lines
5.9 KiB

package langserver
import (
"bytes"
"github.com/agext/levenshtein"
"github.com/hashicorp/terraform/internal/lsp"
)
// makeTextEdits constructs a slice of LSP text edits representing the
// edits required to change old into new, trying to minimize the number of
// changes.
//
// It primarily deals with whole-line insertions and deletes but will use
// a similarity heuristic to replace the content of some lines in-place where
// sensible, which helps text editor clients to keep other annotations (like
// diagnostics) attached to where they ought to be.
func makeTextEdits(old, new sourceLines, simThreshold float64) []lsp.TextEdit {
var ret []lsp.TextEdit
lcs := longestCommonSubsequence(old, new)
var oldI, newI, lcsI int
for oldI < len(old) || newI < len(new) || lcsI < len(lcs) {
for oldI < len(old) && (lcsI >= len(lcs) || !bytes.Equal(old[oldI].content, lcs[lcsI].content)) {
if newI < len(new) {
// See if the next "new" is similar enough to our "old" that
// we'll treat this as an Update rather than a Delete/Create.
if lineContentsSimilar(old[oldI], new[newI], simThreshold) {
ret = append(ret, makeTextEditUpdate(old[oldI], new[newI]))
oldI++
newI++ // we also consume the next "new" in this case
continue
}
}
ret = append(ret, makeTextEditDelete(old[oldI]))
oldI++
}
for newI < len(new) && (lcsI >= len(lcs) || !bytes.Equal(new[newI].content, lcs[lcsI].content)) {
ret = append(ret, makeTextEditInsert(new[newI]))
newI++
}
if lcsI < len(lcs) {
// All of our indexes advance together now, since the line
// is common to all three sequences.
lcsI++
oldI++
newI++
}
}
// We've built each of our changes as unaware of those before it, so we'll
// apply them in reverse order so they don't trample.
reverseTextEditSlice(ret)
return ret
}
func makeTextEditUpdate(old, new sourceLine) lsp.TextEdit {
// For our purposes here, an "update" is still a whole-line replacement
// but (unlike for a delete and an insert) we do it only for the content
// of the line and don't touch any line-ending characters, thus allowing
// the editor to understand that this is still the same line we had before
// in case it wants to preserve any line-oriented state, such as whether
// the line begins a folded region.
// To do this, though, we need to know how long the line is in UTF-16 code
// units, because that's how LSP counts columns.
l := old.lspLen()
return lsp.TextEdit{
Range: lsp.Range{
Start: lsp.Position{Line: float64(new.rng.Start.Line - 1), Character: 0},
End: lsp.Position{Line: float64(new.rng.Start.Line - 1), Character: float64(l)},
},
NewText: string(new.content),
}
}
func makeTextEditInsert(new sourceLine) lsp.TextEdit {
pos := lsp.Position{Line: float64(new.rng.Start.Line - 1), Character: 0}
return lsp.TextEdit{
Range: lsp.Range{
Start: pos,
End: pos,
},
NewText: string(new.content) + "\n",
}
}
func makeTextEditDelete(old sourceLine) lsp.TextEdit {
// Delete from the start of this line to the start of the next one, which'll
// also (helpfully) remove any newline characters we can't otherwise see from here.
return lsp.TextEdit{
Range: lsp.Range{
Start: lsp.Position{Line: float64(old.rng.Start.Line - 1), Character: 0},
End: lsp.Position{Line: float64(old.rng.Start.Line), Character: 0},
},
NewText: "",
}
}
// longestCommonSequence is the first step of producing an edit diff: we
// find the longest sequence of consecutive lines that have common content
// in both xs and ys.
func longestCommonSubsequence(xs, ys sourceLines) []sourceLine {
if len(xs) == 0 || len(ys) == 0 {
return nil
}
c := make([]int, len(xs)*len(ys))
eqs := make([]bool, len(xs)*len(ys))
w := len(xs)
for y := 0; y < len(ys); y++ {
for x := 0; x < len(xs); x++ {
if bytes.Equal(xs[x].content, ys[y].content) {
eqs[(w*y)+x] = true // equality tests can be expensive, so cache it
// Sequence gets one longer than for the cell at top left,
// since we'd append a new item to the sequence here.
if x == 0 || y == 0 {
c[(w*y)+x] = 1
} else {
c[(w*y)+x] = c[(w*(y-1))+(x-1)] + 1
}
} else {
// We follow the longest of the sequence above and the sequence
// to the left of us in the matrix.
l := 0
u := 0
if x > 0 {
l = c[(w*y)+(x-1)]
}
if y > 0 {
u = c[(w*(y-1))+x]
}
if l > u {
c[(w*y)+x] = l
} else {
c[(w*y)+x] = u
}
}
}
}
// The bottom right cell tells us how long our longest sequence will be
// The result is generic []sourceLine rather than our "sourceLines" named
// type because part of the contract for a "sourceLines" is that the
// source line numbers correlate with the line numbers in our ranges.
seq := make([]sourceLine, c[len(c)-1])
// Now we will walk back from the bottom right cell, finding again all
// of the equal pairs to construct our sequence.
x := len(xs) - 1
y := len(ys) - 1
i := len(seq) - 1
for x > -1 && y > -1 {
if eqs[(w*y)+x] {
// Add the value to our result list and then walk diagonally
// up and to the left.
seq[i] = xs[x]
x--
y--
i--
} else {
// Take the path with the greatest sequence length in the matrix.
l := 0
u := 0
if x > 0 {
l = c[(w*y)+(x-1)]
}
if y > 0 {
u = c[(w*(y-1))+x]
}
if l > u {
x--
} else {
y--
}
}
}
if i > -1 {
// should never happen if the matrix was constructed properly
panic("not enough elements in sequence")
}
return seq
}
func lineContentsSimilar(a, b sourceLine, threshold float64) bool {
sim := levenshtein.Similarity(
string(a.content), string(b.content),
levenshtein.NewParams().MinScore(threshold),
)
return sim >= threshold
}
// reverseTextEditSlice reverses a slice of text edits in-place
func reverseTextEditSlice(edits []lsp.TextEdit) {
max := len(edits) / 2
for i := 0; i < max; i++ {
j := len(edits) - 1 - i
edits[i], edits[j] = edits[j], edits[i]
}
}