internal/suggest/suggest.go
176 lines · 5078 bytes
12 symbols in this file
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}