internal/suggest/suggest.go

176 lines · 5078 bytes

  1// Package suggest reads the replacement lines a review comment proposes
  2// in a fenced suggestion block, and applies them to a file's anchored
  3// line range. The server's apply and the CLI's local apply share it, so
  4// both produce the same bytes.
  5package suggest
  6
  7import (
  8	"bytes"
  9	"errors"
 10	"fmt"
 11	"strings"
 12)
 13
 14// Parse returns the lines of the one ```suggestion block in body. found
 15// is false when there is none. An empty block proposes deleting the
 16// range; a block holding one empty line proposes a blank line.
 17func Parse(body string) (lines []string, found bool, err error) {
 18	start, end, err := locate(body)
 19	if err != nil || start < 0 {
 20		return nil, false, err
 21	}
 22	all := strings.Split(normalize(body), "\n")
 23	return append([]string{}, all[start+1:end]...), true, nil
 24}
 25
 26// Strip returns body without its suggestion block, for rendering the
 27// prose around a suggestion that is shown as a diff instead.
 28func Strip(body string) string {
 29	start, end, err := locate(body)
 30	if err != nil || start < 0 {
 31		return body
 32	}
 33	all := strings.Split(normalize(body), "\n")
 34	return strings.TrimSpace(strings.Join(append(all[:start:start], all[end+1:]...), "\n"))
 35}
 36
 37func normalize(body string) string { return strings.ReplaceAll(body, "\r\n", "\n") }
 38
 39// locate finds the suggestion block's opening and closing fence lines.
 40// start is -1 when there is no block.
 41func locate(body string) (start, end int, err error) {
 42	all := strings.Split(normalize(body), "\n")
 43	start = -1
 44	for i := 0; i < len(all); i++ {
 45		fence, ok := opening(all[i])
 46		if !ok {
 47			continue
 48		}
 49		if start >= 0 {
 50			return -1, -1, errors.New("a comment carries one suggestion block")
 51		}
 52		j := i + 1
 53		for ; j < len(all); j++ {
 54			if closing(all[j], fence) {
 55				break
 56			}
 57		}
 58		if j == len(all) {
 59			return -1, -1, errors.New("the suggestion block is not closed")
 60		}
 61		start, end = i, j
 62		i = j
 63	}
 64	return start, end, nil
 65}
 66
 67// opening reports whether line opens a suggestion block, and the length
 68// of its backtick fence.
 69func opening(line string) (int, bool) {
 70	t := strings.TrimLeft(line, " ")
 71	if len(line)-len(t) > 3 {
 72		return 0, false
 73	}
 74	n := len(t) - len(strings.TrimLeft(t, "`"))
 75	if n < 3 {
 76		return 0, false
 77	}
 78	return n, strings.TrimSpace(t[n:]) == "suggestion"
 79}
 80
 81func closing(line string, fence int) bool {
 82	t := strings.TrimSpace(line)
 83	return len(t) >= fence && strings.Trim(t, "`") == ""
 84}
 85
 86// Text is the replacement as one string, every line ending in a newline:
 87// "" deletes the range and "\n" is one blank line.
 88func Text(lines []string) string {
 89	var b strings.Builder
 90	for _, l := range lines {
 91		b.WriteString(l)
 92		b.WriteByte('\n')
 93	}
 94	return b.String()
 95}
 96
 97// FromText undoes Text.
 98func FromText(s string) []string {
 99	if s == "" {
100		return nil
101	}
102	return strings.Split(strings.TrimSuffix(s, "\n"), "\n")
103}
104
105// split cuts content into lines, each keeping its terminator. The last
106// line has none when the file does not end in a newline.
107func split(content []byte) [][]byte {
108	var out [][]byte
109	for len(content) > 0 {
110		i := bytes.IndexByte(content, '\n')
111		if i < 0 {
112			out = append(out, content)
113			break
114		}
115		out = append(out, content[:i+1])
116		content = content[i+1:]
117	}
118	return out
119}
120
121// Range returns lines start through end (1-based, inclusive) of content
122// with their terminators, and false when the file is shorter than that.
123func Range(content []byte, start, end int) ([]byte, bool) {
124	lines := split(content)
125	if start < 1 || end < start || end > len(lines) {
126		return nil, false
127	}
128	return bytes.Join(lines[start-1:end], nil), true
129}
130
131// Apply replaces lines start through end of content with repl. The
132// replacement takes the line ending the file uses there, CRLF or LF, and
133// the last replacement line keeps whatever ended the range, so a range
134// at the end of a file with no final newline still has none.
135func Apply(content []byte, start, end int, repl []string) ([]byte, error) {
136	lines := split(content)
137	if start < 1 || end < start {
138		return nil, fmt.Errorf("bad line range %d-%d", start, end)
139	}
140	if end > len(lines) {
141		return nil, fmt.Errorf("the file has %d lines; the suggestion ends at line %d", len(lines), end)
142	}
143	last := lines[end-1]
144	eol, lastEOL := []byte("\n"), []byte{}
145	switch {
146	case bytes.HasSuffix(last, []byte("\r\n")):
147		eol, lastEOL = []byte("\r\n"), []byte("\r\n")
148	case bytes.HasSuffix(last, []byte("\n")):
149		lastEOL = []byte("\n")
150	case end > 1 && bytes.HasSuffix(lines[end-2], []byte("\r\n")):
151		eol = []byte("\r\n")
152	}
153	var b bytes.Buffer
154	for _, l := range lines[:start-1] {
155		b.Write(l)
156	}
157	for i, r := range repl {
158		b.WriteString(r)
159		if i == len(repl)-1 {
160			b.Write(lastEOL)
161		} else {
162			b.Write(eol)
163		}
164	}
165	for _, l := range lines[end:] {
166		b.Write(l)
167	}
168	return b.Bytes(), nil
169}
170
171// Message is the commit message of an applied suggestion, naming the
172// merge request and the thread. The server and the CLI's local apply
173// both write it.
174func Message(repoPath string, mr, thread int64, author string) string {
175	return fmt.Sprintf("Apply suggestion from %s\n\nThread %d on %s!%d.\n", author, thread, repoPath, mr)
176}