Files
diple/suggestions.go
2026-08-03 11:46:52 +02:00

323 lines
8.2 KiB
Go

package main
import (
"strings"
"sync"
"github.com/charmbracelet/x/ansi"
)
const suggestionRenderCacheLimit = 256
var renderedSuggestions = newSuggestionRenderCache(suggestionRenderCacheLimit)
type parsedCommentBody struct {
Prose string
Suggestions []string
}
type codeRange struct {
Start int
End int
}
type suggestionRenderCacheKey struct {
path string
removed string
removedLines int
replacement string
width int
}
type suggestionRenderCacheEntry struct {
removed []detailLine
added []detailLine
}
type suggestionRenderCache struct {
mu sync.Mutex
limit int
entries map[suggestionRenderCacheKey]suggestionRenderCacheEntry
order []suggestionRenderCacheKey
}
func newSuggestionRenderCache(limit int) *suggestionRenderCache {
return &suggestionRenderCache{
limit: limit, entries: make(map[suggestionRenderCacheKey]suggestionRenderCacheEntry),
}
}
func (c *suggestionRenderCache) get(
key suggestionRenderCacheKey,
) (suggestionRenderCacheEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry, ok := c.entries[key]
return cloneSuggestionRenderEntry(entry), ok
}
func (c *suggestionRenderCache) put(
key suggestionRenderCacheKey, entry suggestionRenderCacheEntry,
) suggestionRenderCacheEntry {
c.mu.Lock()
defer c.mu.Unlock()
if cached, ok := c.entries[key]; ok {
return cloneSuggestionRenderEntry(cached)
}
if len(c.entries) >= c.limit {
delete(c.entries, c.order[0])
c.order = c.order[1:]
}
c.entries[key] = cloneSuggestionRenderEntry(entry)
c.order = append(c.order, key)
return cloneSuggestionRenderEntry(entry)
}
func (c *suggestionRenderCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.entries = make(map[suggestionRenderCacheKey]suggestionRenderCacheEntry)
c.order = nil
}
func cloneSuggestionRenderEntry(entry suggestionRenderCacheEntry) suggestionRenderCacheEntry {
return suggestionRenderCacheEntry{
removed: append([]detailLine(nil), entry.removed...),
added: append([]detailLine(nil), entry.added...),
}
}
func renderSuggestion(
path string, reviewed []string, replacement string, width int,
) suggestionRenderCacheEntry {
key := suggestionRenderCacheKey{
path: path, removed: strings.Join(reviewed, "\n"), removedLines: len(reviewed),
replacement: replacement, width: width,
}
if cached, ok := renderedSuggestions.get(key); ok {
return cached
}
removed, added := normalizeSuggestion(reviewed, replacement)
removedRanges, addedRanges := suggestionChangedRanges(removed, added)
entry := suggestionRenderCacheEntry{}
for index, source := range removed {
entry.removed = append(entry.removed, wrapSuggestionLine(
path, source, '-', removedRanges[index], width,
)...)
}
for index, source := range added {
entry.added = append(entry.added, wrapSuggestionLine(
path, source, '+', addedRanges[index], width,
)...)
}
return renderedSuggestions.put(key, entry)
}
func parseCommentBody(body string) parsedCommentBody {
var (
result parsedCommentBody
prose []string
suggestion []string
openingLine string
inSuggestion bool
)
for _, line := range strings.Split(body, "\n") {
trimmed := strings.TrimSpace(line)
if !inSuggestion && isSuggestionFence(trimmed) {
inSuggestion = true
openingLine = line
suggestion = nil
continue
}
if inSuggestion && trimmed == "```" {
result.Suggestions = append(result.Suggestions, strings.Join(suggestion, "\n"))
inSuggestion = false
openingLine = ""
continue
}
if inSuggestion {
suggestion = append(suggestion, line)
} else {
prose = append(prose, line)
}
}
if inSuggestion {
prose = append(prose, openingLine)
prose = append(prose, suggestion...)
}
result.Prose = strings.TrimSpace(strings.Join(prose, "\n"))
return result
}
func isSuggestionFence(line string) bool {
if !strings.HasPrefix(line, "```suggestion") {
return false
}
suffix := strings.TrimPrefix(line, "```suggestion")
return suffix == "" || strings.HasPrefix(suffix, ":")
}
func reviewedSourceLines(thread ReviewThread, comment ReviewComment) []string {
start, end := commentReviewAnchor(thread, comment)
parsed := parseDiff(comment.DiffHunk, start, end, thread.DiffSide)
lines := make([]string, 0, end-start+1)
for _, line := range parsed {
if !line.selected {
continue
}
lines = append(lines, sourceFromDiffLine(line.raw))
}
return lines
}
func sourceFromDiffLine(raw string) string {
if strings.HasPrefix(raw, "+") || strings.HasPrefix(raw, "-") || strings.HasPrefix(raw, " ") {
raw = raw[1:]
}
return strings.ReplaceAll(raw, "\t", " ")
}
func normalizeSuggestion(removed []string, replacement string) ([]string, []string) {
added := []string(nil)
if replacement != "" {
added = strings.Split(replacement, "\n")
for i := range added {
added[i] = strings.ReplaceAll(added[i], "\t", " ")
}
}
padding := commonSourceIndent(removed, added)
return trimSourceIndent(removed, padding), trimSourceIndent(added, padding)
}
func commonSourceIndent(groups ...[]string) int {
padding := -1
for _, lines := range groups {
for _, line := range lines {
if strings.TrimSpace(line) == "" {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " "))
if padding == -1 || indent < padding {
padding = indent
}
}
}
return max(0, padding)
}
func trimSourceIndent(lines []string, padding int) []string {
result := make([]string, len(lines))
for i, line := range lines {
result[i] = trimIndent(line, padding)
}
return result
}
func suggestionChangedRanges(removed, added []string) ([]codeRange, []codeRange) {
removedRanges := make([]codeRange, len(removed))
addedRanges := make([]codeRange, len(added))
matches := matchingLinePairs(removed, added)
matches = append(matches, [2]int{len(removed), len(added)})
oldStart, newStart := 0, 0
for _, match := range matches {
oldEnd, newEnd := match[0], match[1]
blockSize := max(oldEnd-oldStart, newEnd-newStart)
for offset := 0; offset < blockSize; offset++ {
oldIndex, newIndex := oldStart+offset, newStart+offset
switch {
case oldIndex < oldEnd && newIndex < newEnd:
removedRanges[oldIndex], addedRanges[newIndex] = changedRange(
removed[oldIndex], added[newIndex],
)
case oldIndex < oldEnd:
removedRanges[oldIndex] = fullCodeRange(removed[oldIndex])
case newIndex < newEnd:
addedRanges[newIndex] = fullCodeRange(added[newIndex])
}
}
if oldEnd < len(removed) && newEnd < len(added) {
oldStart, newStart = oldEnd+1, newEnd+1
} else {
oldStart, newStart = oldEnd, newEnd
}
}
return removedRanges, addedRanges
}
func matchingLinePairs(removed, added []string) [][2]int {
dp := make([][]int, len(removed)+1)
for i := range dp {
dp[i] = make([]int, len(added)+1)
}
for i := len(removed) - 1; i >= 0; i-- {
for j := len(added) - 1; j >= 0; j-- {
if removed[i] == added[j] {
dp[i][j] = dp[i+1][j+1] + 1
} else {
dp[i][j] = max(dp[i+1][j], dp[i][j+1])
}
}
}
var matches [][2]int
for i, j := 0, 0; i < len(removed) && j < len(added); {
switch {
case removed[i] == added[j]:
matches = append(matches, [2]int{i, j})
i++
j++
case dp[i+1][j] >= dp[i][j+1]:
i++
default:
j++
}
}
return matches
}
func changedRange(oldLine, newLine string) (codeRange, codeRange) {
oldRunes, newRunes := []rune(oldLine), []rune(newLine)
prefix := 0
for prefix < len(oldRunes) && prefix < len(newRunes) && oldRunes[prefix] == newRunes[prefix] {
prefix++
}
oldSuffix, newSuffix := len(oldRunes), len(newRunes)
for oldSuffix > prefix && newSuffix > prefix &&
oldRunes[oldSuffix-1] == newRunes[newSuffix-1] {
oldSuffix--
newSuffix--
}
return codeRange{
Start: ansi.StringWidth(string(oldRunes[:prefix])),
End: ansi.StringWidth(string(oldRunes[:oldSuffix])),
}, codeRange{
Start: ansi.StringWidth(string(newRunes[:prefix])),
End: ansi.StringWidth(string(newRunes[:newSuffix])),
}
}
func fullCodeRange(line string) codeRange {
return codeRange{End: ansi.StringWidth(line)}
}
func commentReviewAnchor(thread ReviewThread, comment ReviewComment) (int, int) {
end := comment.OriginalLine
start := comment.OriginalStartLine
if end == 0 {
end = comment.Line
start = comment.StartLine
}
if end > 0 {
if start == 0 {
start = end
}
return start, end
}
start = thread.StartLine
if start == 0 {
start = thread.Line
}
return start, thread.Line
}